diff --git a/.craft.yml b/.craft.yml index 3b1b181bb..80d164cf6 100644 --- a/.craft.yml +++ b/.craft.yml @@ -11,6 +11,9 @@ targets: - name: npm id: "@sentry/junior-agent-browser" includeNames: /^sentry-junior-agent-browser-\d.*\.tgz$/ + - name: npm + id: "@sentry/junior-dashboard" + includeNames: /^sentry-junior-dashboard-\d.*\.tgz$/ - name: npm id: "@sentry/junior-datadog" includeNames: /^sentry-junior-datadog-\d.*\.tgz$/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index febc40832..9c4ae8b0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,7 @@ jobs: pnpm --filter @sentry/junior pack --pack-destination artifacts pnpm --filter @sentry/junior-plugin-api pack --pack-destination artifacts pnpm --filter @sentry/junior-agent-browser pack --pack-destination artifacts + pnpm --filter @sentry/junior-dashboard pack --pack-destination artifacts pnpm --filter @sentry/junior-datadog pack --pack-destination artifacts pnpm --filter @sentry/junior-github pack --pack-destination artifacts pnpm --filter @sentry/junior-hex pack --pack-destination artifacts diff --git a/.gitignore b/.gitignore index bee8809e2..f960a1122 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,9 @@ coverage dist/ /packages/docs/.astro packages/junior/dist +.codex/environments/ # Auto-generated by dotagents — do not commit these files. .agents/.gitignore # Generated by eval replay auto mode; existing tracked recordings stay tracked. packages/junior-evals/.vitest-evals/recordings/**/*.json +.env* diff --git a/AGENTS.md b/AGENTS.md index 1c20c434f..6404b2af7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,6 +62,7 @@ Co-Authored-By: (agent model name) - `policies/README.md` (when to add a policy doc and how policy docs should stay scoped) - `policies/code-comments.md` (repo default for code comments, docstrings, and exported-function JSDoc) - `policies/evals.md` (evals as behavior integration tests and rubric authoring boundaries) +- `policies/frontend-components.md` (Tailwind colocation and component-owned frontend styling) - `policies/interface-design.md` (naming, module paths, and minimal interface boundaries) - `policies/policy-template.md` (template for adding new policy docs) @@ -107,6 +108,7 @@ Co-Authored-By: (agent model name) - `TELEMETRY.spec.md` (format contract for repository-root telemetry maps) - `specs/index.md` (spec taxonomy, naming rules, and canonical vs archive guidance) - `specs/security-policy.md` (global runtime/container/token security policy) +- `specs/data-redaction-policy.md` (conversation privacy classification and raw payload redaction policy) - `specs/chat-architecture.md` (chat composition, service, and test-seam architecture contract) - `specs/agent-turn-handling.md` (agent user-message response policy: reply/silence, tool use, Slack side effects, resumed turns, and completion) - `specs/slack-agent-delivery.md` (Slack entry surfaces, reply delivery, continuation, files, images, and resume behavior contract) @@ -126,5 +128,6 @@ Co-Authored-By: (agent model name) - `specs/plugin.md` (plugin architecture for self-contained provider integrations) - `specs/plugin-manifest.md` (plugin manifest fields and validation contract) - `specs/plugin-runtime.md` (plugin discovery, loading, skills, and MCP runtime contract) +- `specs/dashboard.md` (authenticated dashboard, stateless Better Auth, and reporting boundary contract) - `specs/testing.md` (testing taxonomy and layer boundaries: unit/integration/eval) - Historical evaluations and superseded trackers live under `specs/archive/`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b7b40252e..12b052df7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,6 +62,7 @@ This repo uses Craft for manual lockstep npm releases of: - `@sentry/junior` - `@sentry/junior-plugin-api` - `@sentry/junior-agent-browser` +- `@sentry/junior-dashboard` - `@sentry/junior-datadog` - `@sentry/junior-github` - `@sentry/junior-hex` diff --git a/README.md b/README.md index d7b0bde86..bf65b42dc 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Start here: | `@sentry/junior` | Core Slack bot runtime | | `@sentry/junior-plugin-api` | Lightweight trusted plugin API types and helpers | | `@sentry/junior-agent-browser` | Agent Browser plugin package for browser automation | +| `@sentry/junior-dashboard` | Authenticated dashboard package for Junior runtime diagnostics | | `@sentry/junior-datadog` | Datadog plugin package for observability workflows through Datadog's Pup CLI | | `@sentry/junior-github` | GitHub plugin package for issue workflows | | `@sentry/junior-hex` | Hex plugin package for data warehouse query workflows | diff --git a/apps/example/README.md b/apps/example/README.md index 5fb834fdf..48c775a53 100644 --- a/apps/example/README.md +++ b/apps/example/README.md @@ -30,6 +30,7 @@ Copy `.env.example` and set: - `JUNIOR_SECRET` (required outside `pnpm dev`; the local wrapper supplies a dev-only secret when unset) - `JUNIOR_SCHEDULER_SECRET` or `CRON_SECRET` (optional for `pnpm dev`; the local wrapper supplies a dev-only heartbeat secret when both are unset) - `NOTION_TOKEN` (optional, enables the bundled Notion plugin) +- Dashboard auth is enabled by default. `pnpm dev` disables dashboard auth only for local non-Vercel development. ## Wiring diff --git a/apps/example/nitro.config.ts b/apps/example/nitro.config.ts index 4352440bb..b4ac05f3b 100644 --- a/apps/example/nitro.config.ts +++ b/apps/example/nitro.config.ts @@ -1,7 +1,22 @@ import { defineConfig } from "nitro"; +import { juniorDashboardNitro } from "@sentry/junior-dashboard/nitro"; import { juniorNitro } from "@sentry/junior/nitro"; import { examplePluginPackages } from "./plugin-packages"; +function isVercelEnvironment(): boolean { + return Boolean( + process.env.VERCEL?.trim() || + process.env.VERCEL_ENV?.trim() || + process.env.VERCEL_URL?.trim() || + process.env.VERCEL_PROJECT_PRODUCTION_URL?.trim(), + ); +} + +/** Return whether the example dashboard should require browser auth. */ +export function exampleDashboardAuthRequired(): boolean { + return process.env.NODE_ENV !== "development" || isVercelEnvironment(); +} + export default defineConfig({ preset: "vercel", modules: [ @@ -10,6 +25,10 @@ export default defineConfig({ packages: examplePluginPackages, }, }), + juniorDashboardNitro({ + authRequired: exampleDashboardAuthRequired(), + allowedGoogleDomains: ["sentry.io"], + }), ], routes: { "/**": { handler: "./server.ts" }, diff --git a/apps/example/package.json b/apps/example/package.json index de44cbc50..d610832c1 100644 --- a/apps/example/package.json +++ b/apps/example/package.json @@ -3,6 +3,7 @@ "private": true, "type": "module", "scripts": { + "predev": "pnpm --filter @sentry/junior build && pnpm --filter @sentry/junior-dashboard build", "dev": "nitro dev", "build": "junior snapshot create && nitro build", "preview": "nitro preview", @@ -11,13 +12,13 @@ "dependencies": { "@sentry/junior": "workspace:*", "@sentry/junior-agent-browser": "workspace:*", + "@sentry/junior-dashboard": "workspace:*", "@sentry/junior-datadog": "workspace:*", "@sentry/junior-github": "workspace:*", "@sentry/junior-hex": "workspace:*", "@sentry/junior-linear": "workspace:*", "@sentry/junior-notion": "workspace:*", "@sentry/junior-sentry": "workspace:*", - "@sentry/node": "10.53.1", "hono": "^4.12.22" }, "devDependencies": { diff --git a/package.json b/package.json index aea853c67..335d95537 100644 --- a/package.json +++ b/package.json @@ -11,18 +11,18 @@ "lint": "pnpm --filter @sentry/junior lint", "lint:fix": "pnpm --filter @sentry/junior lint:fix", "lint-staged": "lint-staged", - "build": "pnpm --filter @sentry/junior build", + "build": "pnpm --filter @sentry/junior build && pnpm --filter @sentry/junior-dashboard build", "build:example": "pnpm --filter @sentry/junior-example build", "docs:dev": "pnpm --filter @sentry/junior-docs dev", "docs:build": "pnpm --filter @sentry/junior-docs build", "docs:check": "pnpm --filter @sentry/junior-docs check", "release:check": "node scripts/check-release-config.mjs", "start": "pnpm --filter @sentry/junior-example dev", - "test": "pnpm --filter @sentry/junior build && pnpm --filter @sentry/junior test", + "test": "pnpm --filter @sentry/junior build && pnpm --filter @sentry/junior-dashboard build && pnpm --filter @sentry/junior test && pnpm --filter @sentry/junior-dashboard test", "test:watch": "pnpm --filter @sentry/junior test:watch", "evals": "pnpm --filter @sentry/junior-evals evals", "evals:record": "pnpm --filter @sentry/junior-evals evals:record", - "typecheck": "pnpm --filter @sentry/junior-plugin-api typecheck && pnpm --filter @sentry/junior-scheduler typecheck && pnpm --filter @sentry/junior typecheck && pnpm --filter @sentry/junior-testing typecheck && pnpm --filter @sentry/junior-example typecheck", + "typecheck": "pnpm --filter @sentry/junior-plugin-api typecheck && pnpm --filter @sentry/junior-scheduler typecheck && pnpm --filter @sentry/junior typecheck && pnpm --filter @sentry/junior-dashboard typecheck && pnpm --filter @sentry/junior-testing typecheck && pnpm --filter @sentry/junior-example typecheck", "skills:check": "pnpm --filter @sentry/junior skills:check" }, "simple-git-hooks": { diff --git a/packages/docs/astro.config.mjs b/packages/docs/astro.config.mjs index 93a46d0fa..ec0838ddc 100644 --- a/packages/docs/astro.config.mjs +++ b/packages/docs/astro.config.mjs @@ -119,6 +119,7 @@ export default defineConfig({ label: "Security Hardening", link: "/operate/security-hardening/", }, + { label: "Dashboard", link: "/operate/dashboard/" }, { label: "Sandbox Snapshots", link: "/operate/sandbox-snapshots/", diff --git a/packages/docs/package.json b/packages/docs/package.json index ef72be557..11ee5879b 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -12,7 +12,7 @@ "dependencies": { "@astrojs/check": "^0.9.9", "@astrojs/starlight": "^0.39.2", - "@sentry/starlight-theme": "^0.7.0", + "@sentry/starlight-theme": "catalog:", "astro": "^6.3.7", "starlight-typedoc": "^0.23.0", "typedoc": "^0.28.19", diff --git a/packages/docs/src/content/docs/contribute/development.md b/packages/docs/src/content/docs/contribute/development.md index 77109990e..3ad1aa4f9 100644 --- a/packages/docs/src/content/docs/contribute/development.md +++ b/packages/docs/src/content/docs/contribute/development.md @@ -38,7 +38,7 @@ If your team account requires an explicit Vercel scope, add `--scope pnpm dev ``` -This starts the example app on `http://localhost:3000` by default. +This starts the example app on `http://localhost:3000` by default. It also rebuilds and watches the workspace packages that the example app consumes, so dashboard and runtime package edits are reflected without manually rebuilding first. ## Common checks diff --git a/packages/docs/src/content/docs/contribute/releasing.md b/packages/docs/src/content/docs/contribute/releasing.md index ea90f36f6..b88bec055 100644 --- a/packages/docs/src/content/docs/contribute/releasing.md +++ b/packages/docs/src/content/docs/contribute/releasing.md @@ -14,6 +14,7 @@ Junior uses lockstep package releases for: - `@sentry/junior` - `@sentry/junior-plugin-api` - `@sentry/junior-agent-browser` +- `@sentry/junior-dashboard` - `@sentry/junior-datadog` - `@sentry/junior-github` - `@sentry/junior-hex` diff --git a/packages/docs/src/content/docs/operate/dashboard.md b/packages/docs/src/content/docs/operate/dashboard.md new file mode 100644 index 000000000..ea3b84989 --- /dev/null +++ b/packages/docs/src/content/docs/operate/dashboard.md @@ -0,0 +1,120 @@ +--- +title: Dashboard +description: Mount the authenticated Junior dashboard with Google domain auth. +type: tutorial +summary: Add the dashboard package to a Nitro deployment and protect diagnostics with Better Auth and Google domain authorization. +prerequisites: + - /start-here/existing-app/ + - /reference/config-and-env/ +related: + - /reference/handler-surface/ + - /operate/security-hardening/ + - /start-here/verify-and-troubleshoot/ +--- + +Use `@sentry/junior-dashboard` when you want browser access to Junior runtime diagnostics without exposing plugin, skill, or filesystem discovery publicly. The dashboard mounts into the same Nitro deployment as Junior, but its Better Auth session only protects dashboard routes. + +## Install + +Install the dashboard package next to `@sentry/junior`: + +```bash +pnpm add @sentry/junior-dashboard +``` + +## Mount the routes + +Add `juniorDashboardNitro()` before the catch-all Junior route. Configure the Google Workspace domain that should be allowed to view the dashboard: + +```ts title="nitro.config.ts" +import { defineConfig } from "nitro"; +import { juniorDashboardNitro } from "@sentry/junior-dashboard/nitro"; +import { juniorNitro } from "@sentry/junior/nitro"; + +export default defineConfig({ + preset: "vercel", + modules: [ + juniorNitro({ + plugins: { + packages: ["@sentry/junior-sentry"], + }, + }), + juniorDashboardNitro({ + allowedGoogleDomains: ["sentry.io"], + trustedOrigins: ["https://"], + }), + ], + routes: { + "/**": { handler: "./server.ts" }, + }, +}); +``` + +You can also provide the same authorization policy through deployment environment variables when the handler is loaded outside Nitro's virtual module path: + +| Variable | Purpose | +| ---------------------------------- | ------------------------------------------------------------- | +| `JUNIOR_DASHBOARD_GOOGLE_DOMAINS` | Comma-separated or JSON array of allowed Google domains. | +| `JUNIOR_DASHBOARD_ALLOWED_EMAILS` | Comma-separated or JSON array of explicit email allowlist. | +| `JUNIOR_DASHBOARD_TRUSTED_ORIGINS` | Comma-separated or JSON array of Better Auth trusted origins. | +| `JUNIOR_DASHBOARD_AUTH_REQUIRED` | Set to `false` only for explicit local dashboard auth bypass. | + +The dashboard package owns these routes: + +| Route | Purpose | +| ------------------ | --------------------------------------- | +| `/` | Authenticated command-center UI. | +| `/conversations` | Authenticated conversation-history UI. | +| `/api/dashboard/*` | Authenticated dashboard JSON APIs. | +| `/api/auth/*` | Better Auth Google login and callbacks. | + +`/health` remains the public minimal Junior runtime health response. + +The current dashboard API slices are: + +| Endpoint | Purpose | +| -------------------------------------------- | -------------------------------------------------------------------------------------- | +| `/api/dashboard/health` | Health status for the command center pulse. | +| `/api/dashboard/runtime` | Runtime paths, providers, skills, and packages. | +| `/api/dashboard/plugins` | Loaded plugin list. | +| `/api/dashboard/skills` | Discovered skill list. | +| `/api/dashboard/sessions` | Recent conversation feed from turn-session checkpoints. | +| `/api/dashboard/conversations/:conversation` | Expiring conversation transcript; private conversations return redacted metadata only. | +| `/api/dashboard/config` | Safe dashboard config signals and feature readiness. | +| `/api/dashboard/me` | Signed-in dashboard identity. | + +The dashboard UI is a React client using React Router for browser views and TanStack Query to poll dashboard APIs. `/` shows command-center health and recent turn durations; `/conversations` shows conversation history; `/conversations/:conversation` shows the transcript and turn/tool-call detail for one conversation. The dashboard does not wrap Slack webhooks, provider OAuth callbacks, sandbox egress, or `/api/internal/*`. +The conversation feed is a bounded metadata index with the same expiration policy as turn-session checkpoints. Conversation detail reads transcript data from the expiring checkpoint message store, so old transcripts disappear when checkpoint state expires. When `SENTRY_DSN` initializes the runtime and `SENTRY_ORG_SLUG` is set, conversation rows include a Sentry conversation link; when the runtime captures a trace ID, conversation detail shows it with the turn metadata. +Dashboard dates use `JUNIOR_TIMEZONE`, defaulting to `America/Los_Angeles`. + +## Configure Google auth + +Create a Google OAuth client for the deployment origin. Add this redirect URI: + +```text +https:///api/auth/callback/google +``` + +Set the required environment variables: + +| Variable | Purpose | +| ---------------------- | --------------------------- | +| `GOOGLE_CLIENT_ID` | Google OAuth client ID. | +| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret. | + +Dashboard cookies are signed with `JUNIOR_SECRET` by default. Set `BETTER_AUTH_SECRET` only when you need a separate rotation boundary for browser sessions. +Dashboard callbacks use `JUNIOR_BASE_URL`, Vercel URL envs, or local dev by default. Set `BETTER_AUTH_URL` only when dashboard auth needs a different public origin. + +## Verify + +After deployment: + +1. `GET https:///health` returns a minimal health JSON response. +2. `GET https:///api/info` returns `404`. +3. Opening `https:///` starts Google login. +4. A user from the configured Google Workspace domain reaches the dashboard. +5. A user outside the configured domain receives `403`. + +## Next step + +Use [Security Hardening](/operate/security-hardening/) to review production auth boundaries, then use [Verify & Troubleshoot](/start-here/verify-and-troubleshoot/) for deployment smoke checks. diff --git a/packages/docs/src/content/docs/reference/api/functions/createApp.md b/packages/docs/src/content/docs/reference/api/functions/createApp.md index 0e0f9f689..7c09dd910 100644 --- a/packages/docs/src/content/docs/reference/api/functions/createApp.md +++ b/packages/docs/src/content/docs/reference/api/functions/createApp.md @@ -7,7 +7,7 @@ title: "createApp" > **createApp**(`options?`): `Promise`\<`Hono`\<`BlankEnv`, `BlankSchema`, `"/"`\>\> -Defined in: [app.ts:179](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L179) +Defined in: [app.ts:177](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L177) Create a Hono app with all Junior routes. diff --git a/packages/docs/src/content/docs/reference/api/interfaces/JuniorAppOptions.md b/packages/docs/src/content/docs/reference/api/interfaces/JuniorAppOptions.md index 73f513fae..0520184ef 100644 --- a/packages/docs/src/content/docs/reference/api/interfaces/JuniorAppOptions.md +++ b/packages/docs/src/content/docs/reference/api/interfaces/JuniorAppOptions.md @@ -5,7 +5,7 @@ prev: false title: "JuniorAppOptions" --- -Defined in: [app.ts:32](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L32) +Defined in: [app.ts:30](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L30) ## Properties @@ -13,7 +13,7 @@ Defined in: [app.ts:32](https://github.com/getsentry/junior/blob/main/packages/j > `optional` **configDefaults?**: `Record`\<`string`, `unknown`\> -Defined in: [app.ts:34](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L34) +Defined in: [app.ts:32](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L32) Install-wide provider defaults (`provider.key` format). Channel overrides take precedence. @@ -23,7 +23,7 @@ Install-wide provider defaults (`provider.key` format). Channel overrides take p > `optional` **plugins?**: `PluginConfig` \| `JuniorPlugin`[] -Defined in: [app.ts:42](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L42) +Defined in: [app.ts:40](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L40) Plugin packages/overrides, or trusted plugin instances loaded by this app. @@ -37,4 +37,4 @@ their package config is merged with the catalog bundled by `juniorNitro()`. > `optional` **waitUntil?**: `WaitUntilFn` -Defined in: [app.ts:43](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L43) +Defined in: [app.ts:41](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L41) diff --git a/packages/docs/src/content/docs/reference/config-and-env.md b/packages/docs/src/content/docs/reference/config-and-env.md index ba4d5b33d..7bcc57ef5 100644 --- a/packages/docs/src/content/docs/reference/config-and-env.md +++ b/packages/docs/src/content/docs/reference/config-and-env.md @@ -37,6 +37,25 @@ node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url' Use one stable value per deployment. Rotating it invalidates pending internal resume callbacks and sandbox requester context signed with the previous value. +## Dashboard auth + +If you mount `@sentry/junior-dashboard`, set these browser-auth variables: + +| Variable | Required | Purpose | +| ---------------------- | -------- | ------------------------------------------------------------------------------------------------- | +| `GOOGLE_CLIENT_ID` | Yes | Google OAuth client ID. | +| `GOOGLE_CLIENT_SECRET` | Yes | Google OAuth client secret. | +| `BETTER_AUTH_URL` | No | Optional dashboard callback origin. Defaults to `JUNIOR_BASE_URL`, Vercel URL envs, or local dev. | +| `BETTER_AUTH_SECRET` | No | Optional override for dashboard cookies. Defaults to `JUNIOR_SECRET`. | + +Configure allowed Google Workspace domains in `juniorDashboardNitro()` for normal Nitro deployments. If your deployment imports the dashboard handler before Nitro virtual modules are available, set these optional policy variables instead: + +| Variable | Required | Purpose | +| ---------------------------------- | -------- | ------------------------------------------------------------- | +| `JUNIOR_DASHBOARD_GOOGLE_DOMAINS` | No | Comma-separated or JSON array of allowed Google domains. | +| `JUNIOR_DASHBOARD_ALLOWED_EMAILS` | No | Comma-separated or JSON array of explicit email allowlist. | +| `JUNIOR_DASHBOARD_TRUSTED_ORIGINS` | No | Comma-separated or JSON array of Better Auth trusted origins. | + ## Build-time snapshot warmup If your build command runs `junior snapshot create`: diff --git a/packages/docs/src/content/docs/reference/handler-surface.md b/packages/docs/src/content/docs/reference/handler-surface.md index 966f85ebd..48b7eda15 100644 --- a/packages/docs/src/content/docs/reference/handler-surface.md +++ b/packages/docs/src/content/docs/reference/handler-surface.md @@ -17,10 +17,11 @@ Handled `GET` routes: - `/` - `/health` -- `/api/info` - `/api/oauth/callback/:provider` - `/api/oauth/callback/mcp/:provider` +When `@sentry/junior-dashboard` is mounted, the dashboard package owns `/`, `/api/dashboard/*`, and `/api/auth/*`; use `/health` for unauthenticated health checks. + Handled `POST` routes: - `/api/internal/turn-resume` diff --git a/packages/docs/src/content/docs/start-here/existing-app.md b/packages/docs/src/content/docs/start-here/existing-app.md index d47a0b28a..af1e54529 100644 --- a/packages/docs/src/content/docs/start-here/existing-app.md +++ b/packages/docs/src/content/docs/start-here/existing-app.md @@ -54,7 +54,7 @@ export default defineConfig({ }); ``` -If your existing app already owns routes, make sure the Junior Hono app still receives the paths under `/api/webhooks`, `/api/oauth/callback`, `/api/internal/turn-resume`, `/api/info`, and `/health`. Do not split those routes across independent runtime instances. +If your existing app already owns routes, make sure the Junior Hono app still receives the paths under `/api/webhooks`, `/api/oauth/callback`, `/api/internal/turn-resume`, and `/health`. Do not split those routes across independent runtime instances. When mounted, `@sentry/junior-dashboard` owns `/`, `/api/dashboard/*`, and `/api/auth/*`. Some packages also export trusted runtime hooks. Register those in `createApp()`; do not rely on `juniorNitro()` alone. For example, see diff --git a/packages/junior-dashboard/package.json b/packages/junior-dashboard/package.json new file mode 100644 index 000000000..cb07e3a76 --- /dev/null +++ b/packages/junior-dashboard/package.json @@ -0,0 +1,62 @@ +{ + "name": "@sentry/junior-dashboard", + "version": "0.57.0", + "private": false, + "publishConfig": { + "access": "public" + }, + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/getsentry/junior.git", + "directory": "packages/junior-dashboard" + }, + "exports": { + ".": { + "types": "./dist/app.d.ts", + "default": "./dist/app.js" + }, + "./nitro": { + "types": "./dist/nitro.d.ts", + "default": "./dist/nitro.js" + }, + "./handler": { + "types": "./dist/handler.d.ts", + "default": "./dist/handler.js" + } + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "tsup && pnpm run build:css && tsc -p tsconfig.build.json --emitDeclarationOnly", + "build:css": "tailwindcss -i src/tailwind.css -o dist/tailwind.css --minify", + "prepare": "pnpm run build", + "prepack": "pnpm run build", + "test": "vitest run -c vitest.config.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@sentry/junior": "workspace:*", + "@tanstack/react-query": "^5.100.14", + "better-auth": "^1.3.36", + "hono": "^4.12.22", + "nitro": "3.0.260522-beta", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-router": "^7.16.0", + "recharts": "^3.8.1", + "shiki": "4.1.0" + }, + "devDependencies": { + "@tailwindcss/cli": "^4.3.0", + "@types/node": "^25.9.1", + "@types/react": "^19.2.15", + "@types/react-dom": "^19.2.3", + "tailwindcss": "^4.3.0", + "tsup": "^8.5.1", + "typescript": "^6.0.3", + "vitest": "^4.1.7" + } +} diff --git a/packages/junior-dashboard/src/app.ts b/packages/junior-dashboard/src/app.ts new file mode 100644 index 000000000..7fec9efb9 --- /dev/null +++ b/packages/junior-dashboard/src/app.ts @@ -0,0 +1,421 @@ +import { Hono, type Context, type Next } from "hono"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import type { JuniorReporting } from "@sentry/junior/reporting"; +import { createJuniorReporting } from "@sentry/junior/reporting"; +import { initSentry } from "@sentry/junior/instrumentation"; +import { + createDashboardAuth, + resolveGoogleHostedDomainHint, + sanitizeDashboardSession, + type DashboardAuth, + type DashboardSession, +} from "./auth"; + +const DEFAULT_BASE_PATH = "/"; +const DEFAULT_AUTH_PATH = "/api/auth"; +const DASHBOARD_CLIENT_VERSION = Date.now().toString(36); + +export interface JuniorDashboardOptions { + basePath?: string; + authPath?: string; + authRequired?: boolean; + allowedGoogleDomains?: string[]; + allowedEmails?: string[]; + sessionMaxAgeSeconds?: number; + trustedOrigins?: string[]; + auth?: DashboardAuth; + reporting?: JuniorReporting; +} + +type Variables = { + dashboardSession: DashboardSession; +}; + +function hasSentryConversationLinks(): boolean { + return Boolean( + process.env.SENTRY_DSN?.trim() && process.env.SENTRY_ORG_SLUG?.trim(), + ); +} + +function normalizePath(path: string, fallback: string): string { + const value = path.trim() || fallback; + const withSlash = value.startsWith("/") ? value : `/${value}`; + return stripTrailingSlashes(withSlash); +} + +function stripTrailingSlashes(value: string): string { + let end = value.length; + while (end > 1 && value.charCodeAt(end - 1) === 47) { + end -= 1; + } + return end === value.length ? value : value.slice(0, end); +} + +function normalizeValues(values: string[] | undefined): string[] { + return [ + ...new Set( + (values ?? []).map((value) => value.trim().toLowerCase()).filter(Boolean), + ), + ]; +} + +function isJsonRoute(pathname: string): boolean { + return pathname.startsWith("/api/"); +} + +function dashboardLoginUrl(request: Request): string { + const url = new URL(request.url); + url.pathname = "/api/dashboard/login"; + url.search = ""; + return url.toString(); +} + +function callbackUrl(request: Request, basePath: string): string { + const url = new URL(request.url); + url.pathname = basePath; + url.search = ""; + return url.toString(); +} + +function isAuthorized( + session: DashboardSession, + allowedDomains: string[], + allowedEmails: string[], +): boolean { + const email = session.user.email?.toLowerCase(); + const domain = session.user.hostedDomain?.toLowerCase(); + + if (session.user.emailVerified && email && allowedEmails.includes(email)) { + return true; + } + + return Boolean( + session.user.emailVerified && domain && allowedDomains.includes(domain), + ); +} + +function unauthorized(request: Request): Response { + if (isJsonRoute(new URL(request.url).pathname)) { + return Response.json({ error: "unauthenticated" }, { status: 401 }); + } + return Response.redirect(dashboardLoginUrl(request), 302); +} + +function forbidden(request: Request): Response { + if (!isJsonRoute(new URL(request.url).pathname)) { + return new Response( + ` + + + + + Junior access denied + + + +
+
+

Access denied

+

Your Google account is authenticated, but it is not allowed to use this Junior dashboard.

+
+
+ +`, + { + headers: { + "cache-control": "no-store", + "content-type": "text/html; charset=utf-8", + }, + status: 403, + }, + ); + } + return Response.json({ error: "forbidden" }, { status: 403 }); +} + +function dashboardSessionBypass(): DashboardSession { + return { + user: { + email: "local-dashboard@localhost", + emailVerified: true, + hostedDomain: "localhost", + }, + }; +} + +function readDashboardAsset(fileName: string): string { + const localDistUrl = new URL(`./${fileName}`, import.meta.url); + if (existsSync(localDistUrl)) { + return readFileSync(localDistUrl, "utf8"); + } + + const sourceDistUrl = new URL(`../dist/${fileName}`, import.meta.url); + if (existsSync(sourceDistUrl)) { + return readFileSync(sourceDistUrl, "utf8"); + } + + const workspacePackagePath = path.join( + process.cwd(), + "node_modules", + "@sentry", + "junior-dashboard", + "dist", + fileName, + ); + if (existsSync(workspacePackagePath)) { + return readFileSync(workspacePackagePath, "utf8"); + } + + return ""; +} + +function readDashboardClient(): string { + const client = readDashboardAsset("client.js"); + if (!client) { + throw new Error("Junior dashboard client bundle was not found"); + } + return client; +} + +function dashboardTimeZone(): string { + return process.env.JUNIOR_TIMEZONE || "America/Los_Angeles"; +} + +function readDashboardTailwind(): string { + return readDashboardAsset("tailwind.css"); +} + +function dashboardPagePaths(basePath: string): string[] { + return [ + basePath, + basePath === "/" ? "/conversations" : `${basePath}/conversations`, + basePath === "/" ? "/sessions" : `${basePath}/sessions`, + ]; +} + +function renderDashboard(basePath: string): Response { + return new Response( + ` + + + + + Junior + + + +
+ + + +`, + { + headers: { + "cache-control": "no-store", + "content-type": "text/html; charset=utf-8", + }, + }, + ); +} + +function renderFavicon(): Response { + return new Response( + `Jr`, + { headers: { "content-type": "image/svg+xml" } }, + ); +} + +/** Create the authenticated dashboard Hono app mounted by Nitro. */ +export function createDashboardApp( + options: JuniorDashboardOptions, +): Hono<{ Variables: Variables }> { + if (process.env.SENTRY_DSN?.trim()) { + initSentry(); + } + + const basePath = normalizePath( + options.basePath ?? DEFAULT_BASE_PATH, + DEFAULT_BASE_PATH, + ); + const authPath = normalizePath( + options.authPath ?? DEFAULT_AUTH_PATH, + DEFAULT_AUTH_PATH, + ); + const allowedDomains = normalizeValues(options.allowedGoogleDomains); + const allowedEmails = normalizeValues(options.allowedEmails); + + const authRequired = options.authRequired !== false; + + if ( + authRequired && + allowedDomains.length === 0 && + allowedEmails.length === 0 + ) { + throw new Error( + "Junior dashboard auth requires allowedGoogleDomains or allowedEmails", + ); + } + + const auth = authRequired + ? (options.auth ?? + createDashboardAuth({ + authPath, + trustedOrigins: options.trustedOrigins ?? [], + googleHostedDomain: resolveGoogleHostedDomainHint(allowedDomains), + sessionMaxAgeSeconds: options.sessionMaxAgeSeconds, + })) + : undefined; + const reporting = options.reporting ?? createJuniorReporting(); + const app = new Hono<{ Variables: Variables }>(); + + if (auth) { + app.on(["GET", "POST"], `${authPath}/*`, (c) => auth.handler(c.req.raw)); + } + + app.get("/favicon.ico", () => renderFavicon()); + + app.get("/api/dashboard/login", async (c) => { + if (!auth) { + return Response.redirect(callbackUrl(c.req.raw, basePath), 302); + } + return auth.signInWithGoogle(c.req.raw, callbackUrl(c.req.raw, basePath)); + }); + + const requireDashboardSession = async ( + c: Context<{ Variables: Variables }>, + next: Next, + ) => { + if (!authRequired) { + c.set("dashboardSession", dashboardSessionBypass()); + await next(); + return; + } + + if (!auth) { + return unauthorized(c.req.raw); + } + const session = await auth.getSession(c.req.raw); + if (!session) { + return unauthorized(c.req.raw); + } + if (!isAuthorized(session, allowedDomains, allowedEmails)) { + return forbidden(c.req.raw); + } + c.set("dashboardSession", sanitizeDashboardSession(session)); + await next(); + }; + + if (basePath === "/") { + // When mounted at root, a wildcard is required to cover all sub-routes + // (e.g. /conversations, /sessions). `app.use("/", ...)` only matches + // the exact root path in Hono and leaves those routes unprotected. + app.use("/*", requireDashboardSession); + } else { + app.use(basePath, requireDashboardSession); + app.use(`${basePath}/*`, requireDashboardSession); + } + app.use("/api/dashboard/*", requireDashboardSession); + + for (const path of dashboardPagePaths(basePath)) { + app.get(path, () => renderDashboard(basePath)); + if (path !== "/") { + app.get(`${path}/*`, () => renderDashboard(basePath)); + } + } + app.get("/api/dashboard/health", async () => { + return Response.json(await reporting.getHealth()); + }); + app.get("/api/dashboard/runtime", async () => { + return Response.json(await reporting.getRuntimeInfo()); + }); + app.get("/api/dashboard/plugins", async () => { + return Response.json(await reporting.getPlugins()); + }); + app.get("/api/dashboard/skills", async () => { + return Response.json(await reporting.getSkills()); + }); + app.get("/api/dashboard/sessions", async () => { + return Response.json(await reporting.getSessions()); + }); + app.get("/api/dashboard/conversations/:conversationId", async (c) => { + return Response.json( + await reporting.getConversation( + decodeURIComponent(c.req.param("conversationId")), + ), + ); + }); + app.get("/api/dashboard/config", () => { + return Response.json({ + allowedEmailCount: allowedEmails.length, + allowedGoogleDomainCount: allowedDomains.length, + authRequired, + authPath, + basePath, + sentryConversationLinks: hasSentryConversationLinks(), + timeZone: dashboardTimeZone(), + }); + }); + app.get("/api/dashboard/me", (c) => { + return Response.json(c.get("dashboardSession")); + }); + app.get("/api/dashboard/info", async () => { + return Response.json(await reporting.getRuntimeInfo()); + }); + app.get("/api/dashboard/client.js", () => { + return new Response(readDashboardClient(), { + headers: { + "cache-control": "no-store", + "content-type": "application/javascript; charset=utf-8", + }, + }); + }); + + return app; +} diff --git a/packages/junior-dashboard/src/auth.ts b/packages/junior-dashboard/src/auth.ts new file mode 100644 index 000000000..e8124d980 --- /dev/null +++ b/packages/junior-dashboard/src/auth.ts @@ -0,0 +1,202 @@ +import { betterAuth } from "better-auth"; + +const DEFAULT_SESSION_MAX_AGE_SECONDS = 60 * 60 * 8; + +export interface DashboardUser { + email?: string | null; + emailVerified?: boolean; + hostedDomain?: string | null; + name?: string | null; +} + +export interface DashboardSession { + user: DashboardUser; +} + +export interface DashboardAuthConfig { + baseURL?: string; + authPath: string; + trustedOrigins: string[]; + secret?: string; + googleClientId?: string; + googleClientSecret?: string; + googleHostedDomain?: string; + sessionMaxAgeSeconds?: number; +} + +export interface DashboardAuth { + handler(request: Request): Promise; + getSession(request: Request): Promise; + signInWithGoogle(request: Request, callbackURL: string): Promise; +} + +/** Keep dashboard identity responses limited to user display fields. */ +export function sanitizeDashboardSession( + session: DashboardSession, +): DashboardSession { + const { email, emailVerified, hostedDomain, name } = session.user; + return { + user: { + email, + emailVerified, + hostedDomain, + name, + }, + }; +} + +function required(value: string | undefined, name: string): string { + if (!value?.trim()) { + throw new Error(`${name} is required for Junior dashboard auth`); + } + return value.trim(); +} + +function firstHostedDomain(domains: string[]): string | undefined { + return domains.length === 1 ? domains[0] : undefined; +} + +function withHttps(host: string): string { + return /^https?:\/\//.test(host) ? host : `https://${host}`; +} + +function stripTrailingSlashes(value: string): string { + let end = value.length; + while (end > 1 && value.charCodeAt(end - 1) === 47) { + end -= 1; + } + return end === value.length ? value : value.slice(0, end); +} + +function resolveBaseURL(config: DashboardAuthConfig): string { + const explicit = + config.baseURL ?? + process.env.BETTER_AUTH_URL ?? + process.env.JUNIOR_BASE_URL; + if (explicit?.trim()) { + return stripTrailingSlashes(withHttps(explicit.trim())); + } + + const vercelProd = process.env.VERCEL_PROJECT_PRODUCTION_URL?.trim(); + if (vercelProd) { + return stripTrailingSlashes(withHttps(vercelProd)); + } + + const vercelUrl = process.env.VERCEL_URL?.trim(); + if (vercelUrl) { + return stripTrailingSlashes(withHttps(vercelUrl)); + } + + return "http://localhost:3000"; +} + +/** Create the Better Auth bridge used by dashboard browser routes. */ +export function createDashboardAuth( + config: DashboardAuthConfig, +): DashboardAuth { + const secret = required( + config.secret ?? + process.env.BETTER_AUTH_SECRET ?? + process.env.JUNIOR_SECRET, + "JUNIOR_SECRET or BETTER_AUTH_SECRET", + ); + const baseURL = resolveBaseURL(config); + const googleClientId = required( + config.googleClientId ?? process.env.GOOGLE_CLIENT_ID, + "GOOGLE_CLIENT_ID", + ); + const googleClientSecret = required( + config.googleClientSecret ?? process.env.GOOGLE_CLIENT_SECRET, + "GOOGLE_CLIENT_SECRET", + ); + + const auth = betterAuth({ + appName: "Junior Dashboard", + baseURL, + basePath: config.authPath, + secret, + trustedOrigins: config.trustedOrigins, + socialProviders: { + google: { + clientId: googleClientId, + clientSecret: googleClientSecret, + hd: config.googleHostedDomain, + prompt: "select_account", + mapProfileToUser(profile) { + return { + email: profile.email, + emailVerified: profile.email_verified, + hostedDomain: profile.hd, + image: profile.picture, + name: profile.name, + }; + }, + }, + }, + user: { + additionalFields: { + hostedDomain: { + type: "string", + required: false, + input: false, + returned: true, + }, + }, + }, + account: { + storeStateStrategy: "cookie", + storeAccountCookie: false, + updateAccountOnSignIn: false, + }, + session: { + expiresIn: config.sessionMaxAgeSeconds ?? DEFAULT_SESSION_MAX_AGE_SECONDS, + disableSessionRefresh: true, + cookieCache: { + enabled: true, + strategy: "jwe", + maxAge: config.sessionMaxAgeSeconds ?? DEFAULT_SESSION_MAX_AGE_SECONDS, + refreshCache: false, + }, + }, + }); + + return { + handler(request) { + return auth.handler(request); + }, + async getSession(request) { + const session = await auth.api.getSession({ headers: request.headers }); + if (!session) { + return null; + } + return sanitizeDashboardSession(session as DashboardSession); + }, + async signInWithGoogle(request, callbackURL) { + const result = await auth.api.signInSocial({ + body: { + provider: "google", + callbackURL, + }, + headers: request.headers, + returnHeaders: true, + }); + + if (!("url" in result.response) || !result.response.url) { + throw new Error("Google sign-in did not return a redirect URL"); + } + + result.headers.set("location", result.response.url); + return new Response(null, { + status: 302, + headers: result.headers, + }); + }, + }; +} + +/** Resolve a Google hosted-domain login hint when it is unambiguous. */ +export function resolveGoogleHostedDomainHint( + domains: string[], +): string | undefined { + return firstHostedDomain(domains.map((domain) => domain.toLowerCase())); +} diff --git a/packages/junior-dashboard/src/client.tsx b/packages/junior-dashboard/src/client.tsx new file mode 100644 index 000000000..5074a1a91 --- /dev/null +++ b/packages/junior-dashboard/src/client.tsx @@ -0,0 +1,79 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { Component, type ErrorInfo, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router"; + +import { DashboardShell } from "./client/App"; +import { client } from "./client/api"; + +declare global { + interface Window { + __JUNIOR_DASHBOARD_BASE_PATH__?: string; + __JUNIOR_DASHBOARD_SHOW_ERROR__?: (error: unknown) => void; + } +} + +type ErrorBoundaryState = { + error: Error | null; +}; + +class DashboardErrorBoundary extends Component< + { children: ReactNode }, + ErrorBoundaryState +> { + state: ErrorBoundaryState = { error: null }; + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + const stack = error.stack ?? errorInfo.componentStack; + window.__JUNIOR_DASHBOARD_SHOW_ERROR__?.(stack ? new Error(stack) : error); + } + + render() { + if (this.state.error) { + return ; + } + + return this.props.children; + } +} + +function DashboardErrorPanel(props: { error: Error }) { + return ( +
+
+
+ Dashboard Error +
+

+ Junior failed to render +

+

+ The dashboard hit a client-side exception. The stack trace is shown + here so the page does not fail blank. +

+
+          {props.error.stack ?? props.error.message}
+        
+
+
+ ); +} + +const root = document.getElementById("dashboard-root"); +if (!root) { + throw new Error("Junior dashboard root element was not found"); +} + +createRoot(root).render( + + + + + + + , +); diff --git a/packages/junior-dashboard/src/client/App.tsx b/packages/junior-dashboard/src/client/App.tsx new file mode 100644 index 000000000..cbf05eb19 --- /dev/null +++ b/packages/junior-dashboard/src/client/App.tsx @@ -0,0 +1,175 @@ +import { + Link, + Navigate, + NavLink, + Route, + Routes, + useParams, +} from "react-router"; + +import { useDashboardData } from "./api"; +import { LoadingView } from "./components/LoadingView"; +import { + conversationPath, + setDashboardTimeZone, + visualStatusForSession, +} from "./format"; +import { CommandCenter } from "./pages/CommandCenter"; +import { ConversationPage } from "./pages/ConversationPage"; +import { ConversationsPage } from "./pages/ConversationsPage"; +import { cn } from "./styles"; + +/** Render the dashboard SPA shell and route-level loading states. */ +export function DashboardShell() { + const query = useDashboardData(); + const data = query.data; + if (data) { + setDashboardTimeZone(data.config.timeZone); + } + const loading = !data && !query.error; + const loggedIn = Boolean(data?.config.authRequired && data.me.user.email); + const activeTurnCount = + data?.sessions.sessions.filter( + (session) => visualStatusForSession(session) === "active", + ).length ?? 0; + const headerSummary = query.error + ? query.error.message + : data + ? `${data.plugins.length} plugins / ${data.skills.length} skills / ${activeTurnCount} active` + : "loading command center"; + + async function signOut() { + await fetch(`${data?.config.authPath ?? "/api/auth"}/sign-out`, { + credentials: "same-origin", + method: "POST", + }); + window.location.assign(data?.config.basePath ?? "/"); + } + + const navLinkClass = ({ isActive }: { isActive: boolean }) => + cn( + "whitespace-nowrap border-b-4 px-0.5 pb-1.5 pt-2 text-[0.9rem] font-semibold leading-tight no-underline transition-colors", + isActive + ? "border-b-[#beaaff] text-white" + : "border-b-transparent text-[#b8b8b8] hover:border-b-white/45 hover:text-white", + ); + + return ( +
+
+ +
+

+ Junior +

+
+ {headerSummary} +
+
+ +
+ + {loggedIn ? ( + + ) : null} +
+
+ + + + ) : ( + + ) + } + path="/" + /> + + ) : ( + + ) + } + path="/conversations" + /> + + ) : ( + + ) + } + path="/conversations/:conversationId" + /> + } + path="/sessions" + /> + } + path="/sessions/:conversationId" + /> + } path="*" /> + +
+ ); +} + +function LegacyConversationRedirect() { + const routeParams = useParams(); + const conversationId = routeParams.conversationId + ? decodeURIComponent(routeParams.conversationId) + : ""; + return ; +} diff --git a/packages/junior-dashboard/src/client/api.ts b/packages/junior-dashboard/src/client/api.ts new file mode 100644 index 000000000..4d47b0148 --- /dev/null +++ b/packages/junior-dashboard/src/client/api.ts @@ -0,0 +1,99 @@ +import { QueryClient, useQuery } from "@tanstack/react-query"; + +import type { + ConversationDetailFeed, + DashboardConfig, + DashboardData, + Health, + Identity, + Plugin, + Runtime, + SessionFeed, + Skill, +} from "./types"; + +/** Share dashboard query cache between route data and tooltip detail lookups. */ +export const client = new QueryClient(); + +class DashboardApiError extends Error { + readonly status: number; + + constructor(path: string, status: number) { + super(`${path} returned ${status}`); + this.status = status; + } +} + +function restartDashboardSignIn(): void { + if (typeof window === "undefined") { + return; + } + + const loginPath = "/api/dashboard/login"; + if (window.location.pathname !== loginPath) { + window.location.assign(loginPath); + } +} + +async function read(path: string): Promise { + const response = await fetch(path, { credentials: "same-origin" }); + if (response.status === 401) { + restartDashboardSignIn(); + throw new DashboardApiError(path, response.status); + } + if (!response.ok) throw new DashboardApiError(path, response.status); + return (await response.json()) as T; +} + +/** Poll the dashboard summary feed used by command center and conversation lists. */ +export function useDashboardData() { + return useQuery({ + queryKey: ["dashboard"], + queryFn: async (): Promise => { + const [health, runtime, plugins, skills, sessions, me, config] = + await Promise.all([ + read("/api/dashboard/health"), + read("/api/dashboard/runtime"), + read("/api/dashboard/plugins"), + read("/api/dashboard/skills"), + read("/api/dashboard/sessions"), + read("/api/dashboard/me"), + read("/api/dashboard/config"), + ]); + return { + config, + health, + runtime, + plugins, + skills, + sessions, + me, + }; + }, + refetchInterval: 5_000, + refetchIntervalInBackground: false, + retry: false, + }); +} + +/** Poll one conversation transcript while preserving route-level disabled state. */ +export function useConversationData(conversationId: string | undefined) { + return useQuery({ + enabled: Boolean(conversationId), + queryKey: ["conversation", conversationId], + queryFn: async (): Promise => + readConversationData(conversationId!), + refetchInterval: 5_000, + refetchIntervalInBackground: false, + retry: false, + }); +} + +/** Read one conversation transcript payload for dashboard-local detail views. */ +export function readConversationData( + conversationId: string, +): Promise { + return read( + `/api/dashboard/conversations/${encodeURIComponent(conversationId)}`, + ); +} diff --git a/packages/junior-dashboard/src/client/code.tsx b/packages/junior-dashboard/src/client/code.tsx new file mode 100644 index 000000000..e72e0eddf --- /dev/null +++ b/packages/junior-dashboard/src/client/code.tsx @@ -0,0 +1,139 @@ +import { useQuery } from "@tanstack/react-query"; +import { codeToHtml, type BundledLanguage } from "shiki/bundle/web"; + +import { canRenderStructuredMarkup, parseMarkupNodes } from "./format"; +import type { CodeBlock, MarkupNode } from "./types"; + +/** Count rendered children so transcripts can decide which markup node expands. */ +export function countStructuredBlockChildren(block: CodeBlock): number { + if (!canRenderStructuredMarkup(block.language)) return 1; + const rootCount = parseMarkupNodes(block.code, block.language).length; + return rootCount > 0 ? rootCount : 1; +} + +/** Render structured markup blocks as collapsible nodes instead of flat code. */ +export function StructuredMarkup(props: { + block: CodeBlock; + firstChildIndex: number; + lastChildIndex: number; +}) { + const nodes = parseMarkupNodes(props.block.code, props.block.language); + if (nodes.length === 0) { + return ( + + ); + } + + return ( + <> + {nodes.map((node, index) => ( +
+ +
+ ))} + + ); +} + +function MarkupNodeView(props: { defaultOpen?: boolean; node: MarkupNode }) { + if (props.node.type === "text") { + return ( +
+ {props.node.text.trim()} +
+ ); + } + + const children = props.node.children; + const hasChildren = children.length > 0; + const attributes = props.node.attributes.map(([name, value]) => ( + + {name}="{value}" + + )); + + if (!hasChildren) { + return ( +
+ < + {props.node.tagName} + {attributes} + /> +
+ ); + } + + return ( +
+ + + + - + < + {props.node.tagName} + {attributes} + > + +
+ {children.map((child, index) => ( + + ))} +
+
+ </ + {props.node.tagName} + > +
+
+ ); +} + +/** Render highlighted code while keeping Shiki output responsive in transcripts. */ +export function HighlightedCode(props: { + code: string; + language: BundledLanguage; +}) { + const highlighted = useQuery({ + queryKey: ["highlight", props.language, props.code], + queryFn: async () => + codeToHtml(props.code, { + lang: props.language, + theme: "github-dark", + }), + staleTime: Infinity, + }); + + if (!highlighted.data) { + return ( +
+        {props.code}
+      
+ ); + } + + return ( +
+ ); +} diff --git a/packages/junior-dashboard/src/client/components/CommandRail.tsx b/packages/junior-dashboard/src/client/components/CommandRail.tsx new file mode 100644 index 000000000..a2e7352d4 --- /dev/null +++ b/packages/junior-dashboard/src/client/components/CommandRail.tsx @@ -0,0 +1,74 @@ +import { formatTime, isFailedSession, visualStatusForSession } from "../format"; +import type { DashboardData } from "../types"; +import { Section } from "./Section"; +import { SectionHeader } from "./SectionHeader"; +import { SectionTitle } from "./SectionTitle"; +import { StatusBadge } from "./StatusBadge"; + +/** Render the command-center summary rail from the dashboard health payload. */ +export function CommandRail(props: { + data?: DashboardData; + error: Error | null; +}) { + const sessions = props.data?.sessions.sessions ?? []; + const activeSessions = sessions.filter( + (session) => visualStatusForSession(session) === "active", + ); + const hungSessions = sessions.filter( + (session) => visualStatusForSession(session) === "hung", + ); + const failedSessions = sessions.filter(isFailedSession); + + return ( + + ); +} + +function Stat(props: { label: string; value: number }) { + return ( +
+
+ {props.value} +
+
+ {props.label} +
+
+ ); +} diff --git a/packages/junior-dashboard/src/client/components/ConversationList.tsx b/packages/junior-dashboard/src/client/components/ConversationList.tsx new file mode 100644 index 000000000..a333137bd --- /dev/null +++ b/packages/junior-dashboard/src/client/components/ConversationList.tsx @@ -0,0 +1,94 @@ +import { useNavigate } from "react-router"; + +import { + conversationPath, + formatTime, + visualStatusForConversation, +} from "../format"; +import { cn } from "../styles"; +import type { Conversation, VisualStatus } from "../types"; +import { ConversationRowStats } from "./ConversationRowStats"; +import { ConversationSummary } from "./ConversationSummary"; +import { EmptyTelemetry } from "./EmptyTelemetry"; +import { statusBorderClass } from "./statusStyles"; + +/** Render the full conversation table used by the conversations page. */ +export function ConversationList(props: { + conversations: Conversation[]; + selectedId?: string; + search?: string; +}) { + if (props.conversations.length === 0) { + return ( +
+ No matching conversation telemetry. +
+ ); + } + + return ( +
+
+
Conversation
+
Stats
+
+ {props.conversations.map((conversation) => ( + + ))} +
+ ); +} + +function ConversationTableRow(props: { + conversation: Conversation; + search?: string; + selected?: boolean; +}) { + const visualStatus = visualStatusForConversation(props.conversation); + const navigate = useNavigate(); + const href = { + pathname: conversationPath(props.conversation.id), + search: props.search ?? "", + }; + const openConversation = () => navigate(href); + return ( +
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + openConversation(); + } + }} + role="link" + tabIndex={0} + > + + +
+ ); +} + +function conversationRecordClass( + status: VisualStatus, + selected: boolean | undefined, +): string { + return cn( + "group grid min-w-0 cursor-pointer grid-cols-[minmax(13rem,1.7fr)_minmax(13rem,1fr)] items-center gap-3 overflow-hidden border-b border-l-4 border-b-white/10 bg-[#0b0b0b] px-3 py-3 text-left text-inherit no-underline transition-colors hover:bg-[#151515] max-md:grid-cols-1 max-md:px-4 max-md:py-4", + statusBorderClass(status), + status === "idle" && "saturate-50", + selected && "border-l-white bg-[#111]", + ); +} diff --git a/packages/junior-dashboard/src/client/components/ConversationRowStats.tsx b/packages/junior-dashboard/src/client/components/ConversationRowStats.tsx new file mode 100644 index 000000000..a7bdea1e7 --- /dev/null +++ b/packages/junior-dashboard/src/client/components/ConversationRowStats.tsx @@ -0,0 +1,21 @@ +import { slackLocationLabel } from "../format"; +import type { Conversation } from "../types"; + +/** Render compact conversation metadata aligned to row context. */ +export function ConversationRowStats(props: { + conversation: Conversation; + timeLabel: string; +}) { + return ( +
+
+ {props.conversation.turns.length} turns · {props.timeLabel} +
+ {props.conversation.channel ? ( +
+ {slackLocationLabel(props.conversation, { includeId: false })} +
+ ) : null} +
+ ); +} diff --git a/packages/junior-dashboard/src/client/components/ConversationStack.tsx b/packages/junior-dashboard/src/client/components/ConversationStack.tsx new file mode 100644 index 000000000..f18435281 --- /dev/null +++ b/packages/junior-dashboard/src/client/components/ConversationStack.tsx @@ -0,0 +1,67 @@ +import { useNavigate } from "react-router"; + +import { + conversationPath, + formatRelativeTime, + visualStatusForConversation, +} from "../format"; +import { cn } from "../styles"; +import type { Conversation, VisualStatus } from "../types"; +import { ConversationRowStats } from "./ConversationRowStats"; +import { ConversationSummary } from "./ConversationSummary"; +import { EmptyTelemetry } from "./EmptyTelemetry"; + +/** Render the compact latest-conversation stack on the command center. */ +export function ConversationStack(props: { conversations: Conversation[] }) { + if (props.conversations.length === 0) { + return No conversation telemetry yet.; + } + + return ( +
+ {props.conversations.map((conversation) => ( + + ))} +
+ ); +} + +function ConversationStackRow(props: { conversation: Conversation }) { + const visualStatus = visualStatusForConversation(props.conversation); + const navigate = useNavigate(); + const href = conversationPath(props.conversation.id); + return ( +
navigate(href)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + navigate(href); + } + }} + role="link" + tabIndex={0} + > + + +
+ ); +} + +function conversationStackRowClass(status: VisualStatus): string { + return cn( + "group relative grid min-h-16 cursor-pointer grid-cols-[minmax(0,1fr)_minmax(12rem,max-content)] items-center gap-3 overflow-hidden border-b border-l-4 border-b-white/10 bg-[#050505] px-4 py-3 text-inherit no-underline transition-colors last:border-b-0 hover:bg-[rgba(190,170,255,0.07)] max-md:grid-cols-1", + status === "active" && "border-l-emerald-400", + status === "hung" && "border-l-amber-400", + status === "failed" && "border-l-rose-400", + status === "idle" && "border-l-[#beaaff]/60", + status === "idle" && "saturate-50", + ); +} diff --git a/packages/junior-dashboard/src/client/components/ConversationSummary.tsx b/packages/junior-dashboard/src/client/components/ConversationSummary.tsx new file mode 100644 index 000000000..f497e5ca1 --- /dev/null +++ b/packages/junior-dashboard/src/client/components/ConversationSummary.tsx @@ -0,0 +1,40 @@ +import { + conversationDisplayTitle, + conversationIdentityMeta, + visualStatusForConversation, +} from "../format"; +import type { Conversation } from "../types"; +import { StatusBadge } from "./StatusBadge"; + +/** Render the shared conversation title, identity, status, and Sentry link. */ +export function ConversationSummary(props: { conversation: Conversation }) { + const visualStatus = visualStatusForConversation(props.conversation); + + return ( +
+
+
+ {conversationDisplayTitle(props.conversation)} +
+ +
+
+ {conversationIdentityMeta(props.conversation, props.conversation.id)} + {props.conversation.sentryConversationUrl ? ( + <> + {" · "} + event.stopPropagation()} + rel="noreferrer" + target="_blank" + > + View in Sentry + + + ) : null} +
+
+ ); +} diff --git a/packages/junior-dashboard/src/client/components/EmptyTelemetry.tsx b/packages/junior-dashboard/src/client/components/EmptyTelemetry.tsx new file mode 100644 index 000000000..664c8ee43 --- /dev/null +++ b/packages/junior-dashboard/src/client/components/EmptyTelemetry.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from "react"; + +/** Render the dashboard empty-state block with a warning accent. */ +export function EmptyTelemetry(props: { children: ReactNode }) { + return ( +
+ + {props.children} +
+ ); +} diff --git a/packages/junior-dashboard/src/client/components/FilterTabs.tsx b/packages/junior-dashboard/src/client/components/FilterTabs.tsx new file mode 100644 index 000000000..bb74639db --- /dev/null +++ b/packages/junior-dashboard/src/client/components/FilterTabs.tsx @@ -0,0 +1,35 @@ +import { cn } from "../styles"; +import type { SessionFilter } from "../types"; + +/** Render conversation filters while keeping URL state owned by the page. */ +export function FilterTabs(props: { + current: SessionFilter; + onChange(filter: SessionFilter): void; +}) { + const filters: SessionFilter[] = [ + "recent", + "active", + "hung", + "failed", + "all", + ]; + return ( +
+ {filters.map((filter) => ( + + ))} +
+ ); +} diff --git a/packages/junior-dashboard/src/client/components/JuniorLogo.tsx b/packages/junior-dashboard/src/client/components/JuniorLogo.tsx new file mode 100644 index 000000000..2ddafe73b --- /dev/null +++ b/packages/junior-dashboard/src/client/components/JuniorLogo.tsx @@ -0,0 +1,8 @@ +/** Render the compact Junior wordmark used by the dashboard shell. */ +export function JuniorLogo() { + return ( +
+ Jr +
+ ); +} diff --git a/packages/junior-dashboard/src/client/components/LoadingView.tsx b/packages/junior-dashboard/src/client/components/LoadingView.tsx new file mode 100644 index 000000000..2245bf6af --- /dev/null +++ b/packages/junior-dashboard/src/client/components/LoadingView.tsx @@ -0,0 +1,16 @@ +import { JuniorLogo } from "./JuniorLogo"; + +/** Render the full-page loading treatment before the first dashboard payload lands. */ +export function LoadingView(props: { label: string }) { + return ( +
+
+ +
+
{props.label}
+
+
+
+
+ ); +} diff --git a/packages/junior-dashboard/src/client/components/Section.tsx b/packages/junior-dashboard/src/client/components/Section.tsx new file mode 100644 index 000000000..634e8c4a2 --- /dev/null +++ b/packages/junior-dashboard/src/client/components/Section.tsx @@ -0,0 +1,17 @@ +import type { ReactNode } from "react"; + +import { cn } from "../styles"; + +/** Frame a dashboard content region without leaking CSS class contracts. */ +export function Section(props: { children: ReactNode; className?: string }) { + return ( +
+ {props.children} +
+ ); +} diff --git a/packages/junior-dashboard/src/client/components/SectionHeader.tsx b/packages/junior-dashboard/src/client/components/SectionHeader.tsx new file mode 100644 index 000000000..d818df929 --- /dev/null +++ b/packages/junior-dashboard/src/client/components/SectionHeader.tsx @@ -0,0 +1,16 @@ +import type { ReactNode } from "react"; + +/** Render a dashboard section heading row with optional controls. */ +export function SectionHeader(props: { + actions?: ReactNode; + children: ReactNode; +}) { + return ( +
+
{props.children}
+ {props.actions ? ( +
{props.actions}
+ ) : null} +
+ ); +} diff --git a/packages/junior-dashboard/src/client/components/SectionTitle.tsx b/packages/junior-dashboard/src/client/components/SectionTitle.tsx new file mode 100644 index 000000000..5f07a3303 --- /dev/null +++ b/packages/junior-dashboard/src/client/components/SectionTitle.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from "react"; + +/** Render compact section titles that fit inside operational panels. */ +export function SectionTitle(props: { children: ReactNode }) { + return ( +
+ {props.children} +
+ ); +} diff --git a/packages/junior-dashboard/src/client/components/StatusBadge.tsx b/packages/junior-dashboard/src/client/components/StatusBadge.tsx new file mode 100644 index 000000000..a12403cd2 --- /dev/null +++ b/packages/junior-dashboard/src/client/components/StatusBadge.tsx @@ -0,0 +1,40 @@ +import { cn } from "../styles"; +import type { VisualStatus } from "../types"; + +/** Render readable status text while keeping severity color restrained. */ +export function StatusBadge(props: { + label?: string; + status: VisualStatus | undefined; +}) { + const status = props.status ?? "idle"; + return ( + + + {props.label ?? status} + + ); +} + +function statusBadgeClass(status: VisualStatus): string { + return cn( + "border px-1.5 py-0.5 text-[0.68rem] font-bold uppercase leading-none", + status === "active" && + "border-emerald-400/25 bg-emerald-400/10 text-emerald-300", + status === "hung" && "border-amber-400/25 bg-amber-400/10 text-amber-300", + status === "failed" && "border-rose-400/25 bg-rose-400/10 text-rose-300", + status === "idle" && "border-white/10 bg-white/[0.03] text-[#888]", + ); +} diff --git a/packages/junior-dashboard/src/client/components/ToolFrame.tsx b/packages/junior-dashboard/src/client/components/ToolFrame.tsx new file mode 100644 index 000000000..df241315a --- /dev/null +++ b/packages/junior-dashboard/src/client/components/ToolFrame.tsx @@ -0,0 +1,52 @@ +import type { ReactNode } from "react"; + +import { cn } from "../styles"; + +/** Render the shared expandable/non-expandable frame for transcript tools. */ +export function ToolFrame(props: { + children?: ReactNode; + meta: string[]; + raw?: boolean; + signature: ReactNode; +}) { + const header = ( + <> + + {props.signature} + + + {props.meta.join(" · ")} + + + ); + + if (props.raw) { + return ( +
+
{header}
+ {props.children} +
+ ); + } + + return ( +
+ {header} + {props.children} +
+ ); +} + +/** Provide the shared transcript tool-frame shell for nonstandard part views. */ +export function toolFrameClass(): string { + return "border border-[#beaaff]/20 bg-[#111] transition-colors hover:border-[#beaaff]/45 hover:bg-[rgba(190,170,255,0.06)]"; +} + +function toolHeaderClass(interactive: boolean): string { + return cn( + "grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 px-3 py-2 font-mono text-[0.84rem] leading-tight text-[#b8b8b8] max-md:grid-cols-1 max-md:gap-1", + interactive + ? "cursor-pointer hover:bg-[rgba(190,170,255,0.07)]" + : "cursor-default", + ); +} diff --git a/packages/junior-dashboard/src/client/components/Transcript.tsx b/packages/junior-dashboard/src/client/components/Transcript.tsx new file mode 100644 index 000000000..47fb68558 --- /dev/null +++ b/packages/junior-dashboard/src/client/components/Transcript.tsx @@ -0,0 +1,39 @@ +import { useState } from "react"; + +import type { ConversationTurn } from "../types"; +import { TranscriptHeader } from "./TranscriptHeader"; +import { TurnTranscript } from "./TranscriptTurn"; +import type { TranscriptViewMode } from "./transcriptRenderModel"; +import { transcriptEmptyClass } from "./transcriptStyles"; + +/** Render ordered conversation turns as message, thinking, and tool-call events. */ +export function Transcript(props: { turns: ConversationTurn[] }) { + const [view, setView] = useState("rich"); + const hasRedactedTurns = props.turns.some((turn) => turn.transcriptRedacted); + + if (props.turns.length === 0) { + return ( +
+ No transcript is available for this conversation. +
+ ); + } + + return ( +
+ + {props.turns.map((turn, index) => ( + + ))} +
+ ); +} diff --git a/packages/junior-dashboard/src/client/components/TranscriptHeader.tsx b/packages/junior-dashboard/src/client/components/TranscriptHeader.tsx new file mode 100644 index 000000000..386ce75db --- /dev/null +++ b/packages/junior-dashboard/src/client/components/TranscriptHeader.tsx @@ -0,0 +1,52 @@ +import type { TranscriptViewMode } from "./transcriptRenderModel"; + +/** Render transcript controls without coupling them to turn rendering. */ +export function TranscriptHeader(props: { + onChange(value: TranscriptViewMode): void; + redacted: boolean; + value: TranscriptViewMode; +}) { + return ( +
+
+
+ Transcript +
+ {props.redacted ? ( +
+ Hidden because this conversation is not public. +
+ ) : null} +
+ +
+ ); +} + +function TranscriptViewToggle(props: { + onChange(value: TranscriptViewMode): void; + value: TranscriptViewMode; +}) { + const options: TranscriptViewMode[] = ["rich", "raw"]; + return ( +
+ {options.map((option) => ( + + ))} +
+ ); +} diff --git a/packages/junior-dashboard/src/client/components/TranscriptLoading.tsx b/packages/junior-dashboard/src/client/components/TranscriptLoading.tsx new file mode 100644 index 000000000..44d888b61 --- /dev/null +++ b/packages/junior-dashboard/src/client/components/TranscriptLoading.tsx @@ -0,0 +1,10 @@ +/** Render a transcript-shaped loading state for route transitions. */ +export function TranscriptLoading() { + return ( +
+
+
+
+
+ ); +} diff --git a/packages/junior-dashboard/src/client/components/TranscriptText.tsx b/packages/junior-dashboard/src/client/components/TranscriptText.tsx new file mode 100644 index 000000000..698ffe023 --- /dev/null +++ b/packages/junior-dashboard/src/client/components/TranscriptText.tsx @@ -0,0 +1,45 @@ +import { + countStructuredBlockChildren, + HighlightedCode, + StructuredMarkup, +} from "../code"; +import { canRenderStructuredMarkup, parseMarkdownBlocks } from "../format"; + +/** Render transcript markdown/code blocks with structured markup expansion. */ +export function TranscriptText(props: { + firstChildIndex: number; + lastChildIndex: number; + text: string; +}) { + const blocks = parseMarkdownBlocks(props.text); + let seenChildren = props.firstChildIndex; + + return ( +
+ {blocks.map((block, index) => { + const firstChildIndex = seenChildren; + const childCount = countStructuredBlockChildren(block); + seenChildren += childCount; + + if (!canRenderStructuredMarkup(block.language)) { + return ( + + ); + } + + return ( + + ); + })} +
+ ); +} diff --git a/packages/junior-dashboard/src/client/components/TranscriptToolView.tsx b/packages/junior-dashboard/src/client/components/TranscriptToolView.tsx new file mode 100644 index 000000000..cbf21cdd9 --- /dev/null +++ b/packages/junior-dashboard/src/client/components/TranscriptToolView.tsx @@ -0,0 +1,207 @@ +import type { ReactNode } from "react"; + +import { HighlightedCode } from "../code"; +import { + formatBytes, + formatMessageTimestamp, + formatMs, + stringifyPartValue, +} from "../format"; +import { cn } from "../styles"; +import type { TranscriptPart } from "../types"; +import { ToolFrame } from "./ToolFrame"; +import { isPreviewableValue } from "./transcriptPreview"; + +/** Render a tool call/result pair in rich or raw transcript mode. */ +export function TranscriptToolView(props: { + call?: TranscriptPart; + result?: TranscriptPart; + resultTimestamp?: number; + timestamp?: number; + view?: "raw" | "rich"; +}) { + const toolName = + props.call?.name ?? + props.result?.name ?? + props.call?.id ?? + props.result?.id ?? + "unknown"; + const input = props.call?.input; + const output = props.result?.output; + const outputBytes = props.result + ? new TextEncoder().encode(stringifyPartValue(output)).length + : undefined; + const duration = + typeof props.timestamp === "number" && + typeof props.resultTimestamp === "number" && + props.resultTimestamp >= props.timestamp + ? formatMs(props.resultTimestamp - props.timestamp) + : undefined; + const meta = [ + props.timestamp ? formatMessageTimestamp(props.timestamp) : undefined, + duration, + props.result ? formatBytes(outputBytes) : undefined, + props.result ? undefined : "missing result", + ].filter(isString); + const args = ; + + if (props.view === "raw") { + return ( + + {toolName} + + } + > + + + + + ); + } + + return ( + + + {toolName} + + {isPreviewableValue(input) ? ( + + ({args}) + + ) : null} + + } + > + {props.call ? ( + + + + ) : null} + {props.result ? ( + + + + ) : null} + + ); +} + +function ToolBodySection(props: { + children: ReactNode; + label?: string; + padded?: boolean; +}) { + return ( +
+ {props.label ? ( +
+ {props.label} +
+ ) : null} + {props.children} +
+ ); +} + +function ToolArgumentsPreview(props: { input: unknown }) { + const input = props.input; + if (input == null || input === "") return null; + + if (typeof input === "string") { + const formatted = stringifyPartValue(input).replace(/\s+/g, " ").trim(); + return ; + } + + if (Array.isArray(input)) { + return ( + + ); + } + + if (typeof input === "object") { + const entries = Object.entries(input as Record).slice( + 0, + 4, + ); + return ( + <> + {entries.map(([key, value], index) => ( + + ))} + + ); + } + + return ; +} + +function ToolArgEntry(props: { index: number; name: string; value: string }) { + return ( + + {props.index > 0 ? , : null} + {props.name} + : + + + ); +} + +function ToolArgValue(props: { value: string }) { + return {props.value}; +} + +function previewArgumentValue(value: unknown): string { + if (value == null) return "null"; + if (typeof value === "string") return JSON.stringify(truncateText(value, 48)); + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + return truncateText( + stringifyPartValue(value).replace(/\s+/g, " ").trim(), + 48, + ); +} + +function truncateText(value: string, maxLength: number): string { + return value.length > maxLength + ? `${value.slice(0, Math.max(0, maxLength - 3))}...` + : value; +} + +function isString(value: string | undefined): value is string { + return typeof value === "string" && value.length > 0; +} diff --git a/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx b/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx new file mode 100644 index 000000000..887bffd59 --- /dev/null +++ b/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx @@ -0,0 +1,487 @@ +import type { ClipboardEventHandler, ReactNode } from "react"; + +import { HighlightedCode } from "../code"; +import { + detectLanguage, + formatBytes, + formatMessageOffset, + formatMessageTimestamp, + formatMs, + formatUsage, + requesterLabel, + stringifyPartValue, + unavailableTranscriptLabel, + visualStatusForSession, +} from "../format"; +import { cn } from "../styles"; +import type { + ConversationTurn, + TranscriptMessage, + TranscriptPart, +} from "../types"; +import { StatusBadge } from "./StatusBadge"; +import { ToolFrame, toolFrameClass } from "./ToolFrame"; +import { TranscriptText } from "./TranscriptText"; +import { TranscriptToolView } from "./TranscriptToolView"; +import { + countRenderedTranscriptChildren, + groupTranscriptMessages, + groupTranscriptParts, + messageRawText, + type RenderedTranscriptPart, + type TranscriptViewMode, +} from "./transcriptRenderModel"; +import { + transcriptEmptyClass, + mutedTranscriptMetaClass, +} from "./transcriptStyles"; +import { previewToolValue } from "./transcriptPreview"; + +/** Render one conversation turn as actor messages and tool events. */ +export function TurnTranscript(props: { + number: number; + turn: ConversationTurn; + view: TranscriptViewMode; +}) { + const status = visualStatusForSession(props.turn); + + return ( +
+ +
+ + +
+
+ ); +} + +function turnMarkerClass( + status: ReturnType, +): string { + return cn( + "size-2.5 shrink-0 border", + status === "active" && "border-emerald-300 bg-emerald-300", + status === "hung" && "border-amber-300 bg-amber-300", + status === "failed" && "border-rose-300 bg-rose-300", + status === "idle" && "border-[#beaaff]/70 bg-[#beaaff]/50", + ); +} + +type TranscriptRoleKind = "assistant" | "other" | "system" | "tool" | "user"; + +function transcriptRoleKind(role: string): TranscriptRoleKind { + const normalized = role.toLowerCase(); + if (normalized === "assistant") return "assistant"; + if (normalized === "user") return "user"; + if (normalized === "system") return "system"; + if (normalized.includes("tool")) return "tool"; + return "other"; +} + +function transcriptRoleLabel(role: string, turn: ConversationTurn): string { + const kind = transcriptRoleKind(role); + if (kind === "assistant") return "Junior"; + if (kind === "user") return turnActorLabel(turn); + if (kind === "system") return "System"; + if (kind === "tool") return "Tool"; + return role; +} + +function transcriptMessageClass(role: string): string { + const kind = transcriptRoleKind(role); + + return cn( + "grid min-w-0 gap-2 border-l-4 py-2 pl-3", + kind === "assistant" && + "border-l-violet-300 bg-[rgba(190,170,255,0.08)] pr-3 text-white", + kind === "user" && "border-l-white/70 bg-white/[0.04] pr-3 text-[#f4f4f4]", + kind === "system" && + "border-l-amber-300 bg-amber-300/[0.06] pr-3 text-[#f4f4f4]", + kind === "tool" && "border-l-[#888] text-[#b8b8b8]", + kind === "other" && "border-l-white/35 text-[#f4f4f4]", + ); +} + +function transcriptRoleClass(role: string): string { + const kind = transcriptRoleKind(role); + + return cn( + "flex flex-wrap items-baseline gap-2 text-[0.88rem] leading-snug", + kind === "assistant" && "text-[#d8ccff]", + kind === "user" && "text-white", + kind === "system" && "text-amber-200", + kind === "tool" && "text-[#b8b8b8]", + kind === "other" && "text-[#f4f4f4]", + ); +} + +function transcriptRoleLabelClass(role: string): string { + const kind = transcriptRoleKind(role); + + return cn( + "inline-block max-w-full break-all text-[0.98rem] font-extrabold leading-tight", + kind === "assistant" && "text-violet-200", + kind === "user" && "text-white", + kind === "system" && "text-amber-200", + kind === "tool" && "text-[#b8b8b8]", + kind === "other" && "text-white", + ); +} + +function TranscriptMessageShell(props: { + children: ReactNode; + onCopy?: ClipboardEventHandler; + role: string; +}) { + return ( +
+ {props.children} +
+ ); +} + +function TurnHeader(props: { number: number; turn: ConversationTurn }) { + const status = visualStatusForSession(props.turn); + + return ( +
+
+
+ Turn {props.number} +
+
+ {turnMeta(props.turn).join(" · ")} + {props.turn.sentryTraceUrl ? ( + <> + {" · "} + + View in Sentry + + + ) : null} +
+
+ +
+ ); +} + +function TurnEvents(props: { + turn: ConversationTurn; + view: TranscriptViewMode; +}) { + return ( +
+ {props.turn.transcriptAvailable ? ( + groupTranscriptMessages(props.turn.transcript).map((entry, index) => + entry.kind === "tool" ? ( + + ) : ( + + ), + ) + ) : props.turn.transcriptRedacted && + props.turn.transcriptMetadata?.length ? ( + + ) : ( +
+ {unavailableTranscriptLabel(props.turn)} +
+ )} +
+ ); +} + +function RedactedTranscriptView(props: { turn: ConversationTurn }) { + return ( + <> + {groupTranscriptMessages(props.turn.transcriptMetadata ?? []).map( + (entry, index) => + entry.kind === "tool" ? ( + + ) : ( + + ), + )} + + ); +} + +function RedactedMessageView(props: { + message: TranscriptMessage; + turn: ConversationTurn; +}) { + const offset = formatMessageOffset(props.turn, props.message.timestamp); + const meta = [formatMessageTimestamp(props.message.timestamp), offset].filter( + isString, + ); + + return ( + +
+ + {transcriptRoleLabel(props.message.role, props.turn)} + + {meta.map((value, index) => ( + + {value} + + ))} +
+
+ {props.message.parts.map((part, index) => ( + + ))} +
+
+ ); +} + +function RedactedPartLine(props: { part: TranscriptPart }) { + if (props.part.type === "text") { + return ; + } + if (props.part.type === "thinking") { + return ; + } + return ; +} + +function RedactedMetadataRow(props: { meta?: string }) { + return ( +
+ + {props.meta ? ( + + {props.meta} + + ) : null} +
+ ); +} + +function RedactedMarker() { + return ( + + {""} + + ); +} + +function RedactedToolView(props: { + call?: TranscriptPart; + result?: TranscriptPart; + resultTimestamp?: number; + timestamp?: number; +}) { + const toolName = + props.call?.name ?? + props.result?.name ?? + props.call?.id ?? + props.result?.id ?? + "unknown"; + const duration = + typeof props.timestamp === "number" && + typeof props.resultTimestamp === "number" && + props.resultTimestamp >= props.timestamp + ? formatMs(props.resultTimestamp - props.timestamp) + : undefined; + const meta = [ + props.timestamp ? formatMessageTimestamp(props.timestamp) : undefined, + duration, + props.result ? undefined : "missing result", + ].filter(isString); + + return ( + + + {toolName} + + {props.call?.inputKeys?.length ? ( + + ({props.call.inputKeys.join(", ")}) + + ) : null} + + } + /> + ); +} + +function redactedMessageSize(part: TranscriptPart): string | undefined { + if (typeof part.bytes === "number") return formatBytes(part.bytes); + return typeof part.chars === "number" ? `${part.chars} chars` : undefined; +} + +function turnActorLabel(turn: ConversationTurn): string { + return ( + requesterLabel(turn.requesterIdentity, turn.requester) ?? "unknown actor" + ); +} + +function turnMeta(turn: ConversationTurn): string[] { + return [ + formatMs(turn.cumulativeDurationMs), + formatUsage(turn.cumulativeUsage), + ].filter((value) => value && value !== "none"); +} + +function TranscriptMessageView(props: { + message: TranscriptMessage; + turn: ConversationTurn; + view: TranscriptViewMode; +}) { + const offset = formatMessageOffset(props.turn, props.message.timestamp); + const renderedParts = groupTranscriptParts(props.message.parts); + const rawText = messageRawText(props.message); + const totalRenderedChildren = renderedParts.reduce( + (count, part) => count + countRenderedTranscriptChildren(part), + 0, + ); + let seenRenderedChildren = 0; + + return ( + { + if (props.view !== "rich" || !rawText) return; + event.clipboardData.setData("text/plain", rawText); + event.preventDefault(); + }} + > +
+ + {transcriptRoleLabel(props.message.role, props.turn)} + + + {formatMessageTimestamp(props.message.timestamp)} + + {offset ? ( + {offset} + ) : null} +
+ {props.view === "raw" ? ( + + ) : ( +
+ {renderedParts.map((part, index) => { + const firstChildIndex = seenRenderedChildren; + seenRenderedChildren += countRenderedTranscriptChildren(part); + return ( + + ); + })} +
+ )} +
+ ); +} + +function TranscriptPartView(props: { + firstChildIndex: number; + lastChildIndex: number; + part: RenderedTranscriptPart; +}) { + if (props.part.kind === "tool") { + return ( + + ); + } + + const part = props.part.part; + if (part.type === "text") { + return ( + + ); + } + + const value = part.output; + if (part.type === "thinking") { + const rendered = stringifyPartValue(value); + return ( +
+ + thinking + {previewToolValue(value)} + + +
+ ); + } + + const rendered = stringifyPartValue(value); + return ( +
+ + {part.type} + + {part.name ?? part.id ?? "unknown"} + + + {previewToolValue(value)} + + + +
+ ); +} + +function isString(value: string | undefined): value is string { + return typeof value === "string" && value.length > 0; +} diff --git a/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx b/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx new file mode 100644 index 000000000..7ce7393a7 --- /dev/null +++ b/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx @@ -0,0 +1,349 @@ +import { useQuery } from "@tanstack/react-query"; +import { useNavigate } from "react-router"; +import { + CartesianGrid, + ResponsiveContainer, + Scatter, + ScatterChart, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +import { readConversationData } from "../api"; +import { + conversationIdForSession, + conversationPath, + formatMs, + formatTokenTotal, + requesterLabel, + slackLocationLabel, + turnToolCallCount, + visualStatusForSession, +} from "../format"; +import { cn } from "../styles"; +import type { ConversationDetailFeed, Session, VisualStatus } from "../types"; +import { Section } from "./Section"; +import { SectionHeader } from "./SectionHeader"; +import { SectionTitle } from "./SectionTitle"; +import { statusBorderClass } from "./statusStyles"; + +/** Render recent turns by start time and duration. */ +export function TurnDurationChart(props: { + sessions: Session[]; + timeZone: string; +}) { + const navigate = useNavigate(); + const nowMs = Date.now(); + const rangeStartMs = nowMs - 7 * 24 * 60 * 60 * 1000; + const rangeEndMs = nowMs; + const chartEdgePaddingMs = 6 * 60 * 60 * 1000; + const chartRangeStartMs = rangeStartMs - chartEdgePaddingMs; + const chartRangeEndMs = rangeEndMs + chartEdgePaddingMs; + const points = props.sessions + .map((session) => turnPoint(session, props.timeZone)) + .filter((point): point is TurnDurationPoint => Boolean(point)) + .filter((point) => point.x >= rangeStartMs && point.x <= rangeEndMs) + .sort((left, right) => left.x - right.x); + const totals = points.reduce( + (sum, point) => ({ + failed: sum.failed + (point.status === "failed" ? 1 : 0), + hung: sum.hung + (point.status === "hung" ? 1 : 0), + total: sum.total + 1, + }), + { failed: 0, hung: 0, total: 0 }, + ); + const maxDurationMs = points.reduce( + (max, point) => Math.max(max, point.durationMs), + 0, + ); + const durationAxisMaxMs = Math.max(1000, Math.ceil(maxDurationMs * 1.12)); + const dayTicks = Array.from({ length: 7 }, (_, index) => { + return rangeStartMs + index * 24 * 60 * 60 * 1000; + }); + const openPoint = (point: TurnDurationPoint) => { + navigate(conversationPath(conversationIdForSession(point.session))); + }; + + return ( +
+ + + + +
+ } + > + Turns + +
+ + + + + bucketLabel(Number(value), props.timeZone) + } + tick={{ + fill: "#888", + fontFamily: + '-apple-system, "system-ui", "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif', + fontSize: 12, + }} + tickLine={false} + ticks={dayTicks} + type="number" + /> + formatMs(Number(value))} + tick={{ + fill: "#888", + fontFamily: + '-apple-system, "system-ui", "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif', + fontSize: 11, + }} + tickLine={false} + type="number" + /> + } + cursor={{ stroke: "rgba(255, 255, 255, 0.22)" }} + /> + + + +
+
+ {totals.total} turns / {totals.hung} hung / {totals.failed} errors +
+ + ); +} + +function ChartLegendItem(props: { className: string; label: string }) { + return ( + + + {props.label} + + ); +} + +type PlottedTurnStatus = Exclude; + +type TurnDurationPoint = { + durationMs: number; + tooltipLabel: string; + session: Session; + status: PlottedTurnStatus; + x: number; +}; + +function turnPoint( + session: Session, + timeZone: string, +): TurnDurationPoint | null { + const startedAtMs = Date.parse(session.startedAt ?? ""); + if (!Number.isFinite(startedAtMs)) { + return null; + } + const status = visualStatusForSession(session); + if (status === "active") { + return null; + } + + const lastSeenAtMs = Date.parse(session.lastSeenAt ?? ""); + const durationMs = + session.cumulativeDurationMs ?? + (Number.isFinite(lastSeenAtMs) + ? Math.max(0, lastSeenAtMs - startedAtMs) + : 0); + return { + durationMs, + session, + status, + tooltipLabel: new Date(startedAtMs).toLocaleString(undefined, { + timeZone, + }), + x: startedAtMs, + }; +} + +type DurationDotProps = { + cx?: number; + cy?: number; + payload?: TurnDurationPoint; +}; + +function durationDot( + onOpen: (point: TurnDurationPoint) => void, + active: boolean, +) { + return (props: DurationDotProps) => { + if (props.cx == null || props.cy == null || !props.payload) { + return ; + } + + const point = props.payload; + const fill = durationDotFill(point.status, active); + return ( + onOpen(point)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onOpen(point); + } + }} + r={active ? 5 : 4} + role="link" + stroke="rgba(0, 0, 0, 0.96)" + strokeWidth={1} + tabIndex={0} + /> + ); + }; +} + +function durationDotFill(status: PlottedTurnStatus, active: boolean): string { + if (status === "failed") { + return active ? "rgba(251, 113, 133, 1)" : "rgba(244, 63, 94, 0.95)"; + } + if (status === "hung") { + return active ? "rgba(251, 191, 36, 1)" : "rgba(245, 158, 11, 0.94)"; + } + return active ? "rgba(250, 250, 250, 0.96)" : "rgba(184, 184, 184, 0.82)"; +} + +function TurnDurationTooltip(props: { + active?: boolean; + payload?: Array<{ payload: TurnDurationPoint }>; +}) { + const point = props.payload?.[0]?.payload; + const conversationId = point + ? conversationIdForSession(point.session) + : undefined; + const detail = useQuery({ + enabled: Boolean(props.active && conversationId), + queryKey: ["conversation", conversationId], + queryFn: async () => readConversationData(conversationId!), + retry: false, + staleTime: 5_000, + }); + + if (!props.active || !point) { + return null; + } + const rows = turnTooltipRows(point, detail.data); + return ( +
+
+
+
+ {turnTooltipTitle(point.session)} +
+
+ {point.tooltipLabel} +
+
+ + {point.status} + +
+
+ {rows.map(([label, value]) => ( +
+ + {label} + + + {value} + +
+ ))} +
+
+ ); +} + +function turnTooltipRows( + point: TurnDurationPoint, + detail: ConversationDetailFeed | undefined, +): Array<[string, string]> { + const session = point.session; + const requester = requesterLabel( + session.requesterIdentity, + session.requester, + ); + const location = slackLocationLabel(session, { includeId: false }); + const tokens = formatTokenTotal(session.cumulativeUsage); + return [ + ["duration", formatMs(point.durationMs)], + tokens ? ["tokens", tokens] : null, + ["tool calls", turnTooltipToolCalls(point, detail)], + requester ? ["requester", requester] : null, + location ? ["surface", location] : null, + ].filter((row): row is [string, string] => Boolean(row)); +} + +function turnTooltipToolCalls( + point: TurnDurationPoint, + detail: ConversationDetailFeed | undefined, +): string { + if (!detail) { + return "loading"; + } + const turn = detail.turns.find((item) => item.id === point.session.id); + return String(turn ? turnToolCallCount(turn) : 0); +} + +function turnTooltipTitle(session: Session): string { + return ( + session.conversationTitle ?? + session.title ?? + conversationIdForSession(session) + ); +} + +function chartTooltipStatusClass(status: PlottedTurnStatus): string { + return cn( + "shrink-0 text-[0.68rem] font-bold uppercase leading-none", + status === "failed" && "text-rose-300", + status === "hung" && "text-amber-300", + status === "idle" && "text-[#b8b8b8]", + ); +} + +function bucketLabel(timestampMs: number, timeZone: string): string { + return new Date(timestampMs).toLocaleDateString(undefined, { + timeZone, + weekday: "short", + }); +} diff --git a/packages/junior-dashboard/src/client/components/statusStyles.ts b/packages/junior-dashboard/src/client/components/statusStyles.ts new file mode 100644 index 000000000..6ca9a0db5 --- /dev/null +++ b/packages/junior-dashboard/src/client/components/statusStyles.ts @@ -0,0 +1,9 @@ +import type { VisualStatus } from "../types"; + +/** Map turn health to the shared left-border severity accent. */ +export function statusBorderClass(status: VisualStatus): string { + if (status === "active") return "border-l-emerald-400"; + if (status === "hung") return "border-l-amber-400"; + if (status === "failed") return "border-l-rose-400"; + return "border-l-white/25"; +} diff --git a/packages/junior-dashboard/src/client/components/transcriptPreview.ts b/packages/junior-dashboard/src/client/components/transcriptPreview.ts new file mode 100644 index 000000000..6d10f046c --- /dev/null +++ b/packages/junior-dashboard/src/client/components/transcriptPreview.ts @@ -0,0 +1,20 @@ +/** Summarize a transcript payload value for closed tool/details headers. */ +export function previewToolValue(value: unknown): string { + if (!isPreviewableValue(value)) return "no arguments"; + const source = + typeof value === "string" + ? value + : JSON.stringify(value, (_key, nested) => + typeof nested === "string" && nested.length > 80 + ? `${nested.slice(0, 77)}...` + : nested, + ); + return source.length > 120 ? `${source.slice(0, 117)}...` : source; +} + +/** Decide whether a transcript payload has useful preview text. */ +export function isPreviewableValue(value: unknown): boolean { + if (value == null || value === "") return false; + if (typeof value === "string") return value.trim().length > 0; + return true; +} diff --git a/packages/junior-dashboard/src/client/components/transcriptRenderModel.ts b/packages/junior-dashboard/src/client/components/transcriptRenderModel.ts new file mode 100644 index 000000000..8f0a5b1dd --- /dev/null +++ b/packages/junior-dashboard/src/client/components/transcriptRenderModel.ts @@ -0,0 +1,193 @@ +import { countStructuredBlockChildren } from "../code"; +import { parseMarkdownBlocks, stringifyPartValue } from "../format"; +import type { TranscriptMessage, TranscriptPart } from "../types"; + +export type RenderedTranscriptPart = + | { kind: "part"; part: TranscriptPart } + | { kind: "tool"; call?: TranscriptPart; result?: TranscriptPart }; + +type RenderedTranscriptEntry = + | { kind: "message"; message: TranscriptMessage } + | RenderedToolEntry; + +type RenderedToolEntry = { + call?: TranscriptPart; + kind: "tool"; + result?: TranscriptPart; + resultTimestamp?: number; + timestamp?: number; +}; + +export type TranscriptViewMode = "raw" | "rich"; + +function isToolCall(part: TranscriptPart): boolean { + return part.type === "tool_call"; +} + +function isToolResult(part: TranscriptPart): boolean { + return part.type === "tool_result"; +} + +function isString(value: string | undefined): value is string { + return typeof value === "string" && value.length > 0; +} + +function sameToolInvocation( + call: TranscriptPart, + result: TranscriptPart, +): boolean { + if (call.id && result.id) return call.id === result.id; + if (call.name && result.name) return call.name === result.name; + return false; +} + +/** Group inline transcript parts so matching tool calls/results render together. */ +export function groupTranscriptParts( + parts: TranscriptPart[], +): RenderedTranscriptPart[] { + const grouped: RenderedTranscriptPart[] = []; + const consumed = new Set(); + + for (let index = 0; index < parts.length; index += 1) { + if (consumed.has(index)) continue; + + const part = parts[index]!; + if (isToolCall(part)) { + const resultIndex = parts.findIndex( + (candidate, candidateIndex) => + candidateIndex > index && + !consumed.has(candidateIndex) && + isToolResult(candidate) && + sameToolInvocation(part, candidate), + ); + if (resultIndex >= 0) { + consumed.add(resultIndex); + grouped.push({ kind: "tool", call: part, result: parts[resultIndex] }); + } else { + grouped.push({ kind: "tool", call: part }); + } + continue; + } + + if (isToolResult(part)) { + grouped.push({ kind: "tool", result: part }); + continue; + } + + grouped.push({ kind: "part", part }); + } + + return grouped; +} + +function findToolEntry( + entries: RenderedTranscriptEntry[], + result: TranscriptPart, +): RenderedToolEntry | undefined { + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]!; + if (entry.kind !== "tool" || entry.result) continue; + if (!entry.call || sameToolInvocation(entry.call, result)) { + return entry; + } + } + return undefined; +} + +/** Flatten message-local tool parts into turn-level events for scan-friendly rendering. */ +export function groupTranscriptMessages( + messages: TranscriptMessage[], +): RenderedTranscriptEntry[] { + const entries: RenderedTranscriptEntry[] = []; + + for (const message of messages) { + let messageParts: TranscriptPart[] = []; + const flushMessage = () => { + if (messageParts.length === 0) return; + entries.push({ + kind: "message", + message: { ...message, parts: messageParts }, + }); + messageParts = []; + }; + + for (const part of message.parts) { + if (isToolCall(part)) { + flushMessage(); + entries.push({ + call: part, + kind: "tool", + timestamp: message.timestamp, + }); + continue; + } + + if (isToolResult(part)) { + flushMessage(); + const entry = findToolEntry(entries, part); + if (entry) { + entry.result = part; + entry.resultTimestamp = message.timestamp; + } else { + entries.push({ + kind: "tool", + result: part, + resultTimestamp: message.timestamp, + }); + } + continue; + } + + messageParts.push(part); + } + + flushMessage(); + } + + return entries; +} + +/** Build the plain-text clipboard/raw view for one transcript message. */ +export function messageRawText(message: TranscriptMessage): string { + return message.parts + .map((part) => { + if (part.type === "text") return part.text ?? ""; + if (part.type === "thinking") return stringifyPartValue(part.output); + if (part.type === "tool_call") { + return [ + `tool_call ${part.name ?? part.id ?? "unknown"}`, + stringifyPartValue(part.input), + ] + .filter(isString) + .join("\n"); + } + if (part.type === "tool_result") { + return [ + `tool_result ${part.name ?? part.id ?? "unknown"}`, + stringifyPartValue(part.output), + ] + .filter(isString) + .join("\n"); + } + return stringifyPartValue(part.output ?? part.input ?? part.text ?? part); + }) + .filter((part) => part.trim().length > 0) + .join("\n\n"); +} + +function countTextRenderedChildren(text: string): number { + return parseMarkdownBlocks(text).reduce((count, block) => { + return count + countStructuredBlockChildren(block); + }, 0); +} + +/** Count rendered rows so structured transcript expansion opens the newest node. */ +export function countRenderedTranscriptChildren( + part: RenderedTranscriptPart, +): number { + if (part.kind === "tool") return 1; + if (part.part.type === "text") { + return countTextRenderedChildren(part.part.text ?? ""); + } + return 1; +} diff --git a/packages/junior-dashboard/src/client/components/transcriptStyles.ts b/packages/junior-dashboard/src/client/components/transcriptStyles.ts new file mode 100644 index 000000000..8acf9a1c3 --- /dev/null +++ b/packages/junior-dashboard/src/client/components/transcriptStyles.ts @@ -0,0 +1,11 @@ +import { cn } from "../styles"; + +/** Share muted transcript metadata styling between turn and message chrome. */ +export function mutedTranscriptMetaClass(size = "text-[0.82rem]"): string { + return cn("leading-relaxed text-[#b8b8b8]", size); +} + +/** Share the transcript empty/unavailable frame across top-level and turn views. */ +export function transcriptEmptyClass(): string { + return "border border-white/10 bg-[#050505] p-4 text-[0.9rem] leading-relaxed text-[#b8b8b8]"; +} diff --git a/packages/junior-dashboard/src/client/format.ts b/packages/junior-dashboard/src/client/format.ts new file mode 100644 index 000000000..e085906f2 --- /dev/null +++ b/packages/junior-dashboard/src/client/format.ts @@ -0,0 +1,628 @@ +import { bundledLanguages, type BundledLanguage } from "shiki/bundle/web"; + +import type { + CodeBlock, + Conversation, + ConversationTurn, + MarkupNode, + RequesterIdentity, + Session, + SessionFilter, + TurnUsage, + VisualStatus, +} from "./types"; + +let dashboardTimeZone = "America/Los_Angeles"; + +/** Set the dashboard display timezone returned by the authenticated config API. */ +export function setDashboardTimeZone(timeZone: string): void { + dashboardTimeZone = timeZone; +} + +function displayTimeZone(): string { + return dashboardTimeZone; +} + +function isActiveSession(session: Session): boolean { + return session.status === "active" || session.status === "running"; +} + +/** Identify turn summaries that should appear in failed conversation filters. */ +export function isFailedSession(session: Session): boolean { + return session.status === "failed"; +} + +function isHungSession(session: Session): boolean { + return session.status === "hung"; +} + +function isActiveConversation(conversation: Conversation): boolean { + return conversation.turns.some( + (turn) => visualStatusForSession(turn) === "active", + ); +} + +function isFailedConversation(conversation: Conversation): boolean { + return conversation.turns.some(isFailedSession); +} + +function isHungConversation(conversation: Conversation): boolean { + return conversation.turns.some(isHungSession); +} + +function parseTime(value: string | undefined): number | null { + if (!value) return null; + const time = Date.parse(value); + return Number.isFinite(time) ? time : null; +} + +/** Format absolute dashboard timestamps with a stable empty fallback. */ +export function formatTime(value: string | undefined): string { + const time = parseTime(value); + if (time == null) return "none"; + return new Date(time).toLocaleString(undefined, { + timeZone: displayTimeZone(), + }); +} + +/** Format conversation activity timestamps as human-relative recency labels. */ +export function formatRelativeTime(value: string | undefined): string { + const time = parseTime(value); + if (time == null) return "not updated yet"; + + const seconds = Math.round((time - Date.now()) / 1000); + const absoluteSeconds = Math.abs(seconds); + const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [ + ["year", 60 * 60 * 24 * 365], + ["month", 60 * 60 * 24 * 30], + ["week", 60 * 60 * 24 * 7], + ["day", 60 * 60 * 24], + ["hour", 60 * 60], + ["minute", 60], + ]; + + for (const [unit, unitSeconds] of units) { + if (absoluteSeconds >= unitSeconds) { + return new Intl.RelativeTimeFormat(undefined, { + numeric: "auto", + }).format(Math.round(seconds / unitSeconds), unit); + } + } + + return "just now"; +} + +/** Format millisecond durations for compact transcript metadata. */ +export function formatMs(value: number | undefined): string { + if (typeof value !== "number" || !Number.isFinite(value)) return "none"; + const ms = Math.max(0, Math.floor(value)); + if (ms < 1000) return `${ms}ms`; + const seconds = ms / 1000; + if (seconds < 60) return `${seconds.toFixed(seconds < 10 ? 1 : 0)}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = Math.round(seconds % 60); + return `${minutes}m ${remainingSeconds}s`; +} + +/** Format transcript event timestamps independently from turn start offsets. */ +export function formatMessageTimestamp(value: number | undefined): string { + if (typeof value !== "number" || !Number.isFinite(value)) + return "no timestamp"; + return new Date(value).toLocaleTimeString(undefined, { + timeZone: displayTimeZone(), + }); +} + +/** Format a transcript event as an offset from the current turn start. */ +export function formatMessageOffset( + turn: ConversationTurn, + value: number | undefined, +): string | undefined { + const start = parseTime(turn.startedAt); + if ( + start == null || + typeof value !== "number" || + !Number.isFinite(value) || + value < start + ) { + return undefined; + } + return `+${formatMs(value - start)}`; +} + +function formatNumber(value: number | undefined): string { + if (typeof value !== "number" || !Number.isFinite(value)) return "0"; + const number = Math.max(0, Math.floor(value)); + if (number < 1000) return String(number); + + const units: Array<[string, number]> = [ + ["m", 1_000_000], + ["k", 1_000], + ]; + const [suffix, divisor] = + units.find(([, threshold]) => number >= threshold) ?? units[1]!; + const scaled = number / divisor; + const formatted = + scaled >= 100 || Number.isInteger(scaled) + ? Math.round(scaled).toString() + : scaled >= 10 + ? Math.round(scaled).toString() + : (Math.floor(scaled * 10) / 10).toFixed(1).replace(/\.0$/, ""); + return `${formatted}${suffix}`; +} + +/** Format byte counts in lowercase compact units for transcript metadata. */ +export function formatBytes(value: number | undefined): string { + if (typeof value !== "number" || !Number.isFinite(value)) return "0b"; + const bytes = Math.max(0, Math.floor(value)); + if (bytes < 1024) return `${bytes}b`; + + const units: Array<[string, number]> = [ + ["mb", 1024 * 1024], + ["kb", 1024], + ]; + const [suffix, divisor] = + units.find(([, threshold]) => bytes >= threshold) ?? units[1]!; + const scaled = bytes / divisor; + const precision = scaled >= 10 || Number.isInteger(scaled) ? 0 : 1; + return `${scaled.toFixed(precision).replace(/\.0$/, "")}${suffix}`; +} + +function transcriptSource(turn: ConversationTurn) { + return turn.transcriptAvailable + ? turn.transcript + : (turn.transcriptMetadata ?? []); +} + +/** Count visible or redacted message records for a turn. */ +export function turnMessageCount(turn: ConversationTurn): number { + return turn.transcriptMessageCount ?? transcriptSource(turn).length; +} + +/** Count tool calls from visible transcripts or safe redacted metadata. */ +export function turnToolCallCount(turn: ConversationTurn): number { + return transcriptSource(turn).reduce((count, message) => { + return ( + count + message.parts.filter((part) => part.type === "tool_call").length + ); + }, 0); +} + +function totalUsageTokens(usage: TurnUsage | undefined): number | undefined { + if (!usage) return undefined; + if ( + typeof usage.totalTokens === "number" && + Number.isFinite(usage.totalTokens) + ) { + return usage.totalTokens; + } + return [ + usage.inputTokens, + usage.outputTokens, + usage.cachedInputTokens, + usage.cacheCreationTokens, + ].reduce((sum, value) => { + if (typeof value !== "number" || !Number.isFinite(value)) return sum; + return (sum ?? 0) + Math.max(0, Math.floor(value)); + }, undefined); +} + +/** Format known token counters without estimating per-message usage. */ +export function formatTokenTotal(usage: TurnUsage | undefined): string { + const total = totalUsageTokens(usage); + return total === undefined ? "" : `${formatNumber(total)} tokens`; +} + +/** Format known token counters with available input/output detail. */ +export function formatUsage(usage: TurnUsage | undefined): string { + const total = totalUsageTokens(usage); + if (total === undefined) return ""; + const pieces = [ + usage?.inputTokens !== undefined + ? `${formatNumber(usage.inputTokens)} in` + : undefined, + usage?.outputTokens !== undefined + ? `${formatNumber(usage.outputTokens)} out` + : undefined, + usage?.cachedInputTokens !== undefined + ? `${formatNumber(usage.cachedInputTokens)} cached` + : undefined, + usage?.cacheCreationTokens !== undefined + ? `${formatNumber(usage.cacheCreationTokens)} cache-write` + : undefined, + ].filter(Boolean); + return pieces.length > 0 + ? `${formatNumber(total)} tokens (${pieces.join(" / ")})` + : `${formatNumber(total)} tokens`; +} + +/** Format a conversation span from first turn start to latest activity. */ +export function formatConversationDuration(conversation: Conversation): string { + const start = parseTime(conversation.startedAt); + const end = parseTime(conversation.lastSeenAt) ?? Date.now(); + if (start == null || end < start) return "none"; + const seconds = Math.max(1, Math.round((end - start) / 1000)); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes}m`; + return `${Math.round(minutes / 60)}h`; +} + +/** Resolve the owning conversation id for a turn/session summary. */ +export function conversationIdForSession(session: Session): string { + return session.conversationId || session.id; +} + +function compareTimeDesc(a: string | undefined, b: string | undefined): number { + return (parseTime(b) ?? 0) - (parseTime(a) ?? 0); +} + +function compareTimeAsc(a: string | undefined, b: string | undefined): number { + return (parseTime(a) ?? 0) - (parseTime(b) ?? 0); +} + +function getConversationTitle(conversation: Conversation): string { + if (conversation.surface === "slack") { + return ( + slackLocationLabel(conversation, { includeId: false }) ?? + conversation.title + ); + } + return conversation.title; +} + +/** Choose the safe display title already prepared by the reporting API. */ +export function conversationDisplayTitle( + conversation: Conversation | undefined, +): string { + if (!conversation) return "Conversation"; + return conversation.conversationTitle ?? getConversationTitle(conversation); +} + +/** Prefer stable requester identifiers while keeping Slack ids as a last resort. */ +export function requesterLabel( + requester: RequesterIdentity | undefined, + fallback: string | undefined, +): string | undefined { + return ( + requester?.email ?? + requester?.slackUserName ?? + requester?.fullName ?? + fallback ?? + requester?.slackUserId + ); +} + +/** Format the owner and permalink id line shared by conversation rows and headers. */ +export function conversationIdentityMeta( + conversation: Conversation | undefined, + conversationId: string | undefined, +): string { + const id = conversationId ?? "missing conversation id"; + const owner = requesterLabel( + conversation?.requesterIdentity, + conversation?.requester, + ); + return owner ? `${owner} · ${id}` : id; +} + +/** Convert Slack channel ids and names into user-facing location labels. */ +export function slackLocationLabel( + input: Pick< + Session, + "channel" | "channelName" | "requester" | "requesterIdentity" + >, + options: { includeId?: boolean } = {}, +): string | undefined { + const channelId = input.channel; + if (!channelId) return undefined; + + const includeId = options.includeId ?? true; + const name = input.channelName?.replace(/^#/, ""); + const idSuffix = includeId ? ` (${channelId})` : ""; + if (channelId.startsWith("D")) { + return `Direct Message${idSuffix}`; + } + + if (channelId.startsWith("C")) { + return name ? `#${name}${idSuffix}` : `Public Channel${idSuffix}`; + } + + if (channelId.startsWith("G")) { + if (name?.startsWith("mpdm-")) return `Group DM${idSuffix}`; + return `Private Channel${idSuffix}`; + } + + return name ? `${name}${idSuffix}` : channelId; +} + +/** Collapse raw turn states into the dashboard's visual status language. */ +export function visualStatusForSession(session: Session): VisualStatus { + if (isHungSession(session)) return "hung"; + if (isFailedSession(session)) return "failed"; + if (isActiveSession(session)) return "active"; + return "idle"; +} + +/** Derive conversation status from its turn summaries. */ +export function visualStatusForConversation( + conversation: Conversation, +): VisualStatus { + if (isHungConversation(conversation)) return "hung"; + if (isActiveConversation(conversation)) return "active"; + if (isFailedConversation(conversation)) return "failed"; + return "idle"; +} + +/** Explain why a transcript body is absent without exposing private content. */ +export function unavailableTranscriptLabel(turn: ConversationTurn): string { + if (turn.transcriptRedacted) { + return "Transcript hidden because this conversation is not public."; + } + const status = visualStatusForSession(turn); + if (status === "active") { + return "Transcript pending for this active turn."; + } + if (status === "hung") { + return "Transcript pending for this hung turn."; + } + return "Transcript unavailable for this turn."; +} + +/** Build the canonical permalink route for a conversation id. */ +export function conversationPath(conversationId: string): string { + return `/conversations/${encodeURIComponent(conversationId)}`; +} + +function normalizeLanguage(language: string | undefined): BundledLanguage { + const normalized = language?.trim().toLowerCase(); + if (!normalized) return "markdown"; + const aliases: Record = { + console: "shellscript", + htm: "html", + js: "javascript", + jsonl: "json", + md: "markdown", + ndjson: "json", + sh: "shellscript", + text: "markdown", + txt: "markdown", + xml: "xml", + yml: "yaml", + }; + const candidate = aliases[normalized] ?? normalized; + return candidate in bundledLanguages + ? (candidate as BundledLanguage) + : "markdown"; +} + +/** Detect the syntax highlighter language for raw transcript blocks. */ +export function detectLanguage(text: string): BundledLanguage { + const trimmed = text.trim(); + if (!trimmed) return "markdown"; + try { + JSON.parse(trimmed); + return "json"; + } catch { + // continue with heuristics + } + if (prettyJsonl(trimmed)) return "json"; + if (/^<[\s\S]+>$/.test(trimmed) && /<\/?[a-zA-Z][^>]*>/.test(trimmed)) { + return "xml"; + } + if (/```|^#{1,6}\s|\n[-*]\s|\n\d+\.\s|\[[^\]]+\]\([^)]+\)/m.test(trimmed)) { + return "markdown"; + } + if (/\b(import|export|const|let|function|interface|type)\b/.test(trimmed)) { + return "typescript"; + } + if (/^\s*(\$|pnpm|npm|git|curl|cd|ls|node)\b/m.test(trimmed)) { + return "shellscript"; + } + return "markdown"; +} + +function prettyJson(text: string): string | undefined { + const trimmed = text.trim(); + if (!trimmed) return undefined; + try { + return JSON.stringify(JSON.parse(trimmed), null, 2); + } catch { + return undefined; + } +} + +function prettyJsonl(text: string): string | undefined { + const lines = text + .trim() + .split(/\r?\n/) + .filter((line) => line.trim().length > 0); + if (lines.length < 2) return undefined; + + const formatted: string[] = []; + for (const line of lines) { + const json = prettyJson(line); + if (!json) return undefined; + formatted.push(json); + } + return formatted.join("\n"); +} + +function prettyJsonData(text: string): string | undefined { + return prettyJson(text) ?? prettyJsonl(text); +} + +function formatCodeBlock(code: string, language: BundledLanguage): string { + return language === "json" ? (prettyJsonData(code) ?? code) : code; +} + +/** Decide whether a fenced block can use the interactive markup renderer. */ +export function canRenderStructuredMarkup(language: BundledLanguage): boolean { + return language === "xml" || language === "html"; +} + +/** Parse markdown into renderable code blocks while preserving plain text blocks. */ +export function parseMarkdownBlocks(text: string): CodeBlock[] { + const blocks: CodeBlock[] = []; + const fence = /```([A-Za-z0-9_-]+)?\n([\s\S]*?)```/g; + let cursor = 0; + let match: RegExpExecArray | null; + while ((match = fence.exec(text))) { + const prose = text.slice(cursor, match.index).trim(); + if (prose) { + const language = detectLanguage(prose); + blocks.push({ code: formatCodeBlock(prose, language), language }); + } + const language = normalizeLanguage(match[1]); + blocks.push({ + code: formatCodeBlock(match[2] ?? "", language), + language, + }); + cursor = match.index + match[0].length; + } + const rest = text.slice(cursor).trim(); + if (rest) { + const language = detectLanguage(rest); + blocks.push({ code: formatCodeBlock(rest, language), language }); + } + if (blocks.length > 0) return blocks; + const language = detectLanguage(text); + return [{ code: formatCodeBlock(text, language), language }]; +} + +/** Parse XML/HTML-ish fragments for the collapsible transcript renderer. */ +export function parseMarkupNodes( + code: string, + language: BundledLanguage, +): MarkupNode[] { + const parser = new DOMParser(); + if (language === "xml") { + const document = parser.parseFromString( + `${code}`, + "text/xml", + ); + if (!document.querySelector("parsererror")) { + return Array.from(document.documentElement.childNodes) + .map(markupNodeFromDom) + .filter( + (node) => node.type === "element" || node.text.trim().length > 0, + ); + } + } + + const document = parser.parseFromString(code, "text/html"); + return Array.from(document.body.childNodes) + .map(markupNodeFromDom) + .filter((node) => node.type === "element" || node.text.trim().length > 0); +} + +function markupNodeFromDom(node: ChildNode): MarkupNode { + if (node.nodeType === Node.ELEMENT_NODE) { + const element = node as Element; + return { + type: "element", + tagName: element.tagName.toLowerCase(), + attributes: Array.from(element.attributes).map((attribute) => [ + attribute.name, + attribute.value, + ]), + children: Array.from(element.childNodes) + .map(markupNodeFromDom) + .filter( + (child) => child.type === "element" || child.text.trim().length > 0, + ), + }; + } + + return { type: "text", text: node.textContent ?? "" }; +} + +/** Group recent turn summaries into conversation rows. */ +export function buildConversations(sessions: Session[]): Conversation[] { + const byId = new Map(); + for (const session of sessions) { + const id = conversationIdForSession(session); + byId.set(id, [...(byId.get(id) ?? []), session]); + } + + return [...byId.entries()] + .map(([id, turns]) => { + const sortedTurns = [...turns].sort((a, b) => + compareTimeAsc(a.startedAt, b.startedAt), + ); + const newest = [...turns].sort((a, b) => + compareTimeDesc( + a.lastSeenAt ?? a.startedAt, + b.lastSeenAt ?? b.startedAt, + ), + )[0]!; + const oldest = sortedTurns.reduce((current, next) => + (parseTime(next.startedAt) ?? Number.MAX_SAFE_INTEGER) < + (parseTime(current.startedAt) ?? Number.MAX_SAFE_INTEGER) + ? next + : current, + ); + const status = sortedTurns.some(isHungSession) + ? "hung" + : sortedTurns.some(isActiveSession) + ? "active" + : sortedTurns.some(isFailedSession) + ? "failed" + : newest.status; + const requesterTurn = + sortedTurns.find((turn) => turn.requesterIdentity) ?? + sortedTurns.find((turn) => turn.requester); + + return { + channel: newest.channel, + channelName: sortedTurns.find((turn) => turn.channelName)?.channelName, + conversationTitle: sortedTurns.find((turn) => turn.conversationTitle) + ?.conversationTitle, + id, + lastSeenAt: newest.lastSeenAt, + requester: requesterLabel( + requesterTurn?.requesterIdentity, + requesterTurn?.requester, + ), + requesterIdentity: requesterTurn?.requesterIdentity, + sentryConversationUrl: newest.sentryConversationUrl, + sentryTraceUrl: newest.sentryTraceUrl, + startedAt: oldest.startedAt, + status, + surface: newest.surface, + title: newest.title || id, + traceId: newest.traceId, + turns: sortedTurns, + }; + }) + .sort((a, b) => compareTimeDesc(a.lastSeenAt, b.lastSeenAt)); +} + +/** Apply the dashboard conversation filter to grouped conversation rows. */ +export function filterConversations( + conversations: Conversation[], + filter: SessionFilter, +): Conversation[] { + if (filter === "all") return conversations; + if (filter === "active") return conversations.filter(isActiveConversation); + if (filter === "hung") return conversations.filter(isHungConversation); + if (filter === "failed") return conversations.filter(isFailedConversation); + return conversations; +} + +/** Normalize URL filter params to the supported dashboard filter set. */ +export function getFilter(value: string | null): SessionFilter { + return value === "active" || + value === "hung" || + value === "failed" || + value === "all" + ? value + : "recent"; +} + +/** Serialize transcript part payloads for raw view and syntax highlighting. */ +export function stringifyPartValue(value: unknown): string { + if (value == null || value === "") return ""; + if (typeof value === "string") return prettyJsonData(value) ?? value; + return JSON.stringify(value, null, 2) ?? ""; +} diff --git a/packages/junior-dashboard/src/client/pages/CommandCenter.tsx b/packages/junior-dashboard/src/client/pages/CommandCenter.tsx new file mode 100644 index 000000000..5f4b9df6d --- /dev/null +++ b/packages/junior-dashboard/src/client/pages/CommandCenter.tsx @@ -0,0 +1,37 @@ +import { CommandRail } from "../components/CommandRail"; +import { ConversationStack } from "../components/ConversationStack"; +import { Section } from "../components/Section"; +import { SectionHeader } from "../components/SectionHeader"; +import { SectionTitle } from "../components/SectionTitle"; +import { TurnDurationChart } from "../components/TurnDurationChart"; +import { buildConversations } from "../format"; +import type { DashboardData } from "../types"; + +/** Render the dashboard home view with runtime pulse and recent conversations. */ +export function CommandCenter(props: { + data?: DashboardData; + queryError: Error | null; +}) { + const sessions = props.data?.sessions.sessions ?? []; + const conversations = buildConversations(sessions); + + return ( +
+ + +
+ + +
+ + Latest Conversations + + +
+
+
+ ); +} diff --git a/packages/junior-dashboard/src/client/pages/ConversationPage.tsx b/packages/junior-dashboard/src/client/pages/ConversationPage.tsx new file mode 100644 index 000000000..38855fa31 --- /dev/null +++ b/packages/junior-dashboard/src/client/pages/ConversationPage.tsx @@ -0,0 +1,145 @@ +import { useParams } from "react-router"; + +import { useConversationData } from "../api"; +import { StatusBadge } from "../components/StatusBadge"; +import { + buildConversations, + conversationDisplayTitle, + formatConversationDuration, + formatRelativeTime, + formatTime, + slackLocationLabel, + turnMessageCount, + turnToolCallCount, + visualStatusForConversation, +} from "../format"; +import { Transcript } from "../components/Transcript"; +import { TranscriptLoading } from "../components/TranscriptLoading"; +import type { + Conversation, + ConversationDetailFeed, + DashboardData, +} from "../types"; + +/** Render one permalinkable conversation transcript route. */ +export function ConversationPage(props: { data?: DashboardData }) { + const routeParams = useParams(); + const conversationId = routeParams.conversationId + ? decodeURIComponent(routeParams.conversationId) + : undefined; + const sessions = props.data?.sessions.sessions ?? []; + const conversations = buildConversations(sessions); + const conversation = conversations.find((item) => item.id === conversationId); + const detail = useConversationData(conversationId); + const visualStatus = conversation + ? visualStatusForConversation(conversation) + : undefined; + + return ( +
+
+
+
+
+

+ {conversationDisplayTitle(conversation)} +

+ +
+
+ +
+
+
+ updated{" "} + {formatRelativeTime( + conversation?.lastSeenAt ?? detail.data?.generatedAt, + )} +
+ +
+ + {detail.isPending ? ( + + ) : detail.error ? ( +
+ {detail.error.message} +
+ ) : ( + + )} +
+
+ ); +} + +function ConversationIdentity(props: { + conversation: Conversation | undefined; + conversationId: string | undefined; +}) { + const id = props.conversationId ?? "missing conversation id"; + const owner = + props.conversation?.requesterIdentity?.email ?? + props.conversation?.requester ?? + props.conversation?.requesterIdentity?.slackUserName; + return ( + <> + {owner ? `${owner} · ` : ""} + {id} + {props.conversation?.sentryConversationUrl ? ( + <> + {" · "} + + View in Sentry + + + ) : null} + + ); +} + +function ConversationStats(props: { + conversation: Conversation | undefined; + detail?: ConversationDetailFeed; +}) { + if (!props.conversation) return null; + const messages = props.detail + ? props.detail.turns.reduce( + (count, turn) => count + turnMessageCount(turn), + 0, + ) + : undefined; + const toolCalls = props.detail + ? props.detail.turns.reduce( + (count, turn) => count + turnToolCallCount(turn), + 0, + ) + : undefined; + const stats = [ + slackLocationLabel(props.conversation, { includeId: false }), + `${props.conversation.turns.length} turns`, + messages === undefined ? "messages loading" : `${messages} messages`, + toolCalls === undefined ? "tool calls loading" : `${toolCalls} tool calls`, + formatConversationDuration(props.conversation), + `started ${formatTime(props.conversation.startedAt)}`, + ].filter(Boolean); + + return ( +
+ {stats.map((value, index) => ( + + {index > 0 ? · : null} + {value} + + ))} +
+ ); +} diff --git a/packages/junior-dashboard/src/client/pages/ConversationsPage.tsx b/packages/junior-dashboard/src/client/pages/ConversationsPage.tsx new file mode 100644 index 000000000..253149142 --- /dev/null +++ b/packages/junior-dashboard/src/client/pages/ConversationsPage.tsx @@ -0,0 +1,59 @@ +import { useSearchParams } from "react-router"; + +import { ConversationList } from "../components/ConversationList"; +import { FilterTabs } from "../components/FilterTabs"; +import { Section } from "../components/Section"; +import { SectionHeader } from "../components/SectionHeader"; +import { SectionTitle } from "../components/SectionTitle"; +import { + buildConversations, + filterConversations, + formatTime, + getFilter, +} from "../format"; +import type { DashboardData, SessionFilter } from "../types"; + +/** Render the searchable conversation index from recent turn summaries. */ +export function ConversationsPage(props: { data?: DashboardData }) { + const [params, setParams] = useSearchParams(); + const filter = getFilter(params.get("filter")); + const sessions = props.data?.sessions.sessions ?? []; + const conversations = buildConversations(sessions); + const visibleConversations = filterConversations(conversations, filter); + const search = params.toString(); + const feedMeta = + props.data?.sessions.source === "turn_session_records" + ? `${conversations.length} conversations / ${sessions.length} turns / ${formatTime(props.data.sessions.generatedAt)}` + : "waiting for run history feed"; + + function updateFilter(nextFilter: SessionFilter) { + const next = new URLSearchParams(params); + next.set("filter", nextFilter); + setParams(next); + } + + return ( +
+
+
+ } + > +
+ Conversations +
+ {feedMeta} +
+
+
+
+ +
+
+
+
+ ); +} diff --git a/packages/junior-dashboard/src/client/styles.ts b/packages/junior-dashboard/src/client/styles.ts new file mode 100644 index 000000000..a82febd56 --- /dev/null +++ b/packages/junior-dashboard/src/client/styles.ts @@ -0,0 +1,6 @@ +/** Join component-owned Tailwind classes without pulling in a styling dependency. */ +export function cn( + ...classes: Array +): string { + return classes.filter(Boolean).join(" "); +} diff --git a/packages/junior-dashboard/src/client/types.ts b/packages/junior-dashboard/src/client/types.ts new file mode 100644 index 000000000..a15a3225d --- /dev/null +++ b/packages/junior-dashboard/src/client/types.ts @@ -0,0 +1,153 @@ +import type { BundledLanguage } from "shiki/bundle/web"; + +export type Health = { service: string; status: string; timestamp: string }; + +export type Runtime = { + cwd: string; + descriptionText?: string; + homeDir: string; + packagedContent: { packageNames: string[] }; +}; + +export type Plugin = { name: string }; + +export type Skill = { name: string; pluginProvider?: string }; + +export type RequesterIdentity = { + email?: string; + fullName?: string; + slackUserId?: string; + slackUserName?: string; +}; + +export type TurnUsage = { + cachedInputTokens?: number; + cacheCreationTokens?: number; + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; +}; + +export type Session = { + channel?: string; + channelName?: string; + conversationId?: string; + conversationTitle?: string; + cumulativeDurationMs?: number; + cumulativeUsage?: TurnUsage; + id: string; + lastProgressAt?: string; + lastSeenAt?: string; + requester?: string; + requesterIdentity?: RequesterIdentity; + sentryConversationUrl?: string; + sentryTraceUrl?: string; + startedAt?: string; + status: string; + surface?: string; + title?: string; + traceId?: string; +}; + +export type TranscriptPart = { + bytes?: number; + chars?: number; + id?: string; + input?: unknown; + inputKeys?: string[]; + inputSizeBytes?: number; + inputSizeChars?: number; + inputType?: string; + name?: string; + output?: unknown; + outputKeys?: string[]; + outputSizeBytes?: number; + outputSizeChars?: number; + outputType?: string; + redacted?: boolean; + text?: string; + type: string; +}; + +export type TranscriptMessage = { + parts: TranscriptPart[]; + role: string; + timestamp?: number; +}; + +export type ConversationTurn = Session & { + transcript: TranscriptMessage[]; + transcriptAvailable: boolean; + transcriptMetadata?: TranscriptMessage[]; + transcriptMessageCount?: number; + transcriptRedacted?: boolean; + transcriptRedactionReason?: "non_public_conversation"; +}; + +export type ConversationDetailFeed = { + conversationId: string; + generatedAt: string; + turns: ConversationTurn[]; +}; + +export type Conversation = { + channel?: string; + channelName?: string; + conversationTitle?: string; + id: string; + lastProgressAt?: string; + lastSeenAt?: string; + requester?: string; + requesterIdentity?: RequesterIdentity; + sentryConversationUrl?: string; + sentryTraceUrl?: string; + startedAt?: string; + status: Session["status"]; + surface?: string; + title: string; + traceId?: string; + turns: Session[]; +}; + +export type SessionFeed = { + generatedAt?: string; + sessions: Session[]; + source: string; +}; + +export type Identity = { user: { email?: string; hostedDomain?: string } }; + +export type DashboardConfig = { + allowedEmailCount: number; + allowedGoogleDomainCount: number; + authRequired: boolean; + authPath: string; + basePath: string; + sentryConversationLinks: boolean; + timeZone: string; +}; + +export type DashboardData = { + config: DashboardConfig; + health: Health; + me: Identity; + plugins: Plugin[]; + runtime: Runtime; + sessions: SessionFeed; + skills: Skill[]; +}; + +export type SessionFilter = "active" | "recent" | "hung" | "failed" | "all"; + +export type VisualStatus = "active" | "failed" | "hung" | "idle"; + +export type CodeBlock = { code: string; language: BundledLanguage }; + +export type MarkupNode = + | { + type: "element"; + attributes: Array<[string, string]>; + children: MarkupNode[]; + tagName: string; + } + | { type: "text"; text: string }; diff --git a/packages/junior-dashboard/src/config.ts b/packages/junior-dashboard/src/config.ts new file mode 100644 index 000000000..5c183c34f --- /dev/null +++ b/packages/junior-dashboard/src/config.ts @@ -0,0 +1,72 @@ +import type { JuniorDashboardOptions } from "./app"; + +export type JuniorDashboardRuntimeConfig = Omit< + JuniorDashboardOptions, + "auth" | "reporting" +>; + +/** Read dashboard runtime config injected by the Nitro module. */ +export async function resolveDashboardConfig(): Promise { + try { + const mod: { dashboard?: JuniorDashboardRuntimeConfig } = + await import("#junior-dashboard/config"); + return mod.dashboard ?? readEnvConfig(); + } catch (error) { + if (!isMissingVirtualConfig(error)) { + throw error; + } + return readEnvConfig(); + } +} + +function readEnvConfig(): JuniorDashboardRuntimeConfig { + return { + authRequired: process.env.JUNIOR_DASHBOARD_AUTH_REQUIRED !== "false", + allowedGoogleDomains: readListEnv("JUNIOR_DASHBOARD_GOOGLE_DOMAINS"), + allowedEmails: readListEnv("JUNIOR_DASHBOARD_ALLOWED_EMAILS"), + trustedOrigins: readListEnv("JUNIOR_DASHBOARD_TRUSTED_ORIGINS"), + }; +} + +function readListEnv(name: string): string[] { + const value = process.env[name]; + if (!value?.trim()) { + return []; + } + + if (value.trim().startsWith("[")) { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch (error) { + throw new Error(`${name} must be a JSON string array`, { + cause: error, + }); + } + if ( + !Array.isArray(parsed) || + parsed.some((item) => typeof item !== "string") + ) { + throw new Error(`${name} must be a JSON string array`); + } + return parsed; + } + + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); +} + +function isMissingVirtualConfig(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + const code = (error as { code?: string }).code; + return ( + (code === "ERR_PACKAGE_IMPORT_NOT_DEFINED" || + code === "ERR_MODULE_NOT_FOUND" || + code === "MODULE_NOT_FOUND") && + error.message.includes("#junior-dashboard/config") + ); +} diff --git a/packages/junior-dashboard/src/handler.ts b/packages/junior-dashboard/src/handler.ts new file mode 100644 index 000000000..7af4b38d1 --- /dev/null +++ b/packages/junior-dashboard/src/handler.ts @@ -0,0 +1,26 @@ +import { defineHandler } from "nitro"; +import { createDashboardApp } from "./app"; +import { resolveDashboardConfig } from "./config"; + +let app: ReturnType | undefined; +let appPromise: Promise> | undefined; + +async function resolveApp(): Promise> { + appPromise ??= resolveDashboardConfig() + .then((config) => { + app = createDashboardApp(config); + return app; + }) + .catch((error: unknown) => { + appPromise = undefined; + throw error; + }); + return app ?? appPromise; +} + +const handler: unknown = defineHandler(async (event) => { + const dashboardApp = await resolveApp(); + return dashboardApp.fetch(event.req); +}); + +export default handler; diff --git a/packages/junior-dashboard/src/nitro.ts b/packages/junior-dashboard/src/nitro.ts new file mode 100644 index 000000000..8540f12fd --- /dev/null +++ b/packages/junior-dashboard/src/nitro.ts @@ -0,0 +1,110 @@ +import type { Nitro } from "nitro/types"; + +export interface JuniorDashboardNitroOptions { + basePath?: string; + authPath?: string; + authRequired?: boolean; + allowedGoogleDomains?: string[]; + allowedEmails?: string[]; + trustedOrigins?: string[]; + sessionMaxAgeSeconds?: number; + disabled?: boolean; +} + +type NitroRouteConfig = NonNullable; + +function normalizePath(path: string | undefined, fallback: string): string { + const value = path?.trim() || fallback; + const withSlash = value.startsWith("/") ? value : `/${value}`; + return stripTrailingSlashes(withSlash); +} + +function stripTrailingSlashes(value: string): string { + let end = value.length; + while (end > 1 && value.charCodeAt(end - 1) === 47) { + end -= 1; + } + return end === value.length ? value : value.slice(0, end); +} + +function routeEntry(handler: string): { handler: string } { + return { handler }; +} + +function virtualHandler(config: Record): string { + return `import { defineHandler } from "nitro"; +import { createDashboardApp } from "@sentry/junior-dashboard"; + +let app; + +export default defineHandler(async (event) => { + app ??= createDashboardApp(${JSON.stringify(config)}); + return app.fetch(event.req); +}); +`; +} + +function dashboardPageRoutes( + basePath: string, + handler: string, +): NitroRouteConfig { + const sessionsPath = basePath === "/" ? "/sessions" : `${basePath}/sessions`; + const conversationsPath = + basePath === "/" ? "/conversations" : `${basePath}/conversations`; + + if (basePath === "/") { + return { + "/": routeEntry(handler), + [conversationsPath]: routeEntry(handler), + [`${conversationsPath}/**`]: routeEntry(handler), + [sessionsPath]: routeEntry(handler), + [`${sessionsPath}/**`]: routeEntry(handler), + }; + } + + return { + [basePath]: routeEntry(handler), + [`${basePath}/**`]: routeEntry(handler), + }; +} + +/** Mount the authenticated Junior dashboard into a Nitro deployment. */ +export function juniorDashboardNitro(options: JuniorDashboardNitroOptions): { + nitro: { setup(nitro: unknown): void }; +} { + return { + nitro: { + setup(nitro: Nitro) { + if (options.disabled) { + return; + } + + const basePath = normalizePath(options.basePath, "/"); + const authPath = normalizePath(options.authPath, "/api/auth"); + const handler = "#junior-dashboard/handler"; + const dashboardConfig = { + ...options, + basePath, + authPath, + disabled: undefined, + }; + + nitro.options.virtual[handler] = virtualHandler(dashboardConfig); + nitro.options.virtual["#junior-dashboard/config"] = + `export const dashboard = ${JSON.stringify(dashboardConfig)};`; + + const dashboardRoutes: NitroRouteConfig = { + ...dashboardPageRoutes(basePath, handler), + "/api/dashboard/**": routeEntry(handler), + [authPath]: routeEntry(handler), + [`${authPath}/**`]: routeEntry(handler), + }; + + nitro.options.routes = { + ...dashboardRoutes, + ...(nitro.options.routes ?? {}), + }; + }, + }, + }; +} diff --git a/packages/junior-dashboard/src/tailwind.css b/packages/junior-dashboard/src/tailwind.css new file mode 100644 index 000000000..e8874db4f --- /dev/null +++ b/packages/junior-dashboard/src/tailwind.css @@ -0,0 +1,13 @@ +@import "tailwindcss"; + +@theme { + --font-sans: + -apple-system, "system-ui", "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif; + --font-mono: + "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + "Liberation Mono", monospace; +} + +@source "./app.ts"; +@source "./client.tsx"; +@source "./client/**/*.{ts,tsx}"; diff --git a/packages/junior-dashboard/src/virtual-modules.d.ts b/packages/junior-dashboard/src/virtual-modules.d.ts new file mode 100644 index 000000000..30d5535ee --- /dev/null +++ b/packages/junior-dashboard/src/virtual-modules.d.ts @@ -0,0 +1,5 @@ +declare module "#junior-dashboard/config" { + import type { JuniorDashboardRuntimeConfig } from "./config"; + + export const dashboard: JuniorDashboardRuntimeConfig; +} diff --git a/packages/junior-dashboard/tests/auth-config.test.ts b/packages/junior-dashboard/tests/auth-config.test.ts new file mode 100644 index 000000000..446f0ae36 --- /dev/null +++ b/packages/junior-dashboard/tests/auth-config.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +describe("dashboard auth config", () => { + afterEach(() => { + vi.doUnmock("better-auth"); + vi.resetModules(); + }); + + it("keeps Google account tokens out of persistent dashboard cookies", async () => { + let capturedOptions: unknown; + + vi.doMock("better-auth", () => ({ + betterAuth(options: unknown) { + capturedOptions = options; + return { + handler: vi.fn(async () => new Response(null)), + api: { + getSession: vi.fn(async () => null), + signInSocial: vi.fn(async () => ({ + headers: new Headers(), + response: { url: "https://accounts.google.com/o/oauth2/v2/auth" }, + })), + }, + }; + }, + })); + + const { createDashboardAuth } = await import("../src/auth"); + + createDashboardAuth({ + authPath: "/api/auth", + googleClientId: "google-client-id", + googleClientSecret: "google-client-secret", + secret: "0123456789abcdef0123456789abcdef", + trustedOrigins: [], + }); + + expect(capturedOptions).toMatchObject({ + account: { + storeAccountCookie: false, + storeStateStrategy: "cookie", + updateAccountOnSignIn: false, + }, + session: { + cookieCache: { + strategy: "jwe", + }, + }, + }); + expect(capturedOptions).not.toHaveProperty("database"); + }); +}); diff --git a/packages/junior-dashboard/tests/client-api.test.ts b/packages/junior-dashboard/tests/client-api.test.ts new file mode 100644 index 000000000..9735f7c4b --- /dev/null +++ b/packages/junior-dashboard/tests/client-api.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { readConversationData } from "../src/client/api"; + +describe("dashboard client API", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("restarts Google sign-in when dashboard API auth expires", async () => { + const assign = vi.fn(); + const fetchMock = vi.fn(async () => + Response.json({ error: "unauthenticated" }, { status: 401 }), + ); + + vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal("window", { + location: { + assign, + pathname: "/conversations", + }, + }); + + await expect(readConversationData("slack:C1:123")).rejects.toThrow( + "/api/dashboard/conversations/slack%3AC1%3A123 returned 401", + ); + + expect(fetchMock).toHaveBeenCalledWith( + "/api/dashboard/conversations/slack%3AC1%3A123", + { credentials: "same-origin" }, + ); + expect(assign).toHaveBeenCalledWith("/api/dashboard/login"); + }); + + it("does not redirect for non-auth dashboard API failures", async () => { + const assign = vi.fn(); + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json({ error: "forbidden" }, { status: 403 })), + ); + vi.stubGlobal("window", { + location: { + assign, + pathname: "/conversations", + }, + }); + + await expect(readConversationData("slack:C1:123")).rejects.toThrow( + "/api/dashboard/conversations/slack%3AC1%3A123 returned 403", + ); + + expect(assign).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/junior-dashboard/tests/dashboard-routes.test.ts b/packages/junior-dashboard/tests/dashboard-routes.test.ts new file mode 100644 index 000000000..af51fe696 --- /dev/null +++ b/packages/junior-dashboard/tests/dashboard-routes.test.ts @@ -0,0 +1,745 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createApp } from "@sentry/junior"; +import type { JuniorReporting } from "@sentry/junior/reporting"; +import { createDashboardApp } from "../src/app"; +import { + createDashboardAuth, + type DashboardAuth, + type DashboardSession, +} from "../src/auth"; +import { filterConversations } from "../src/client/format"; +import type { Conversation } from "../src/client/types"; +import { resolveDashboardConfig } from "../src/config"; +import { juniorDashboardNitro } from "../src/nitro"; + +const dashboardEnvNames = [ + "BETTER_AUTH_SECRET", + "BETTER_AUTH_URL", + "GOOGLE_CLIENT_ID", + "GOOGLE_CLIENT_SECRET", + "JUNIOR_SECRET", + "JUNIOR_BASE_URL", + "VERCEL_PROJECT_PRODUCTION_URL", + "VERCEL_URL", + "JUNIOR_DASHBOARD_AUTH_REQUIRED", + "JUNIOR_DASHBOARD_GOOGLE_DOMAINS", + "JUNIOR_DASHBOARD_ALLOWED_EMAILS", + "JUNIOR_DASHBOARD_TRUSTED_ORIGINS", + "SENTRY_DSN", + "SENTRY_ORG_SLUG", +] as const; + +function reporting(): JuniorReporting { + return { + async getHealth() { + return { + status: "ok", + service: "junior", + timestamp: "2026-05-29T00:00:00.000Z", + }; + }, + async getRuntimeInfo() { + return { + cwd: "/workspace", + homeDir: "/workspace/app", + descriptionText: "Dashboard test", + providers: ["github"], + skills: [{ name: "triage", pluginProvider: "github" }], + packagedContent: { + packageNames: ["@sentry/junior-github"], + manifestRoots: [], + skillRoots: [], + tracingIncludes: [], + }, + }; + }, + async getPlugins() { + return [{ name: "github" }]; + }, + async getSkills() { + return [{ name: "triage", pluginProvider: "github" }]; + }, + async getSessions() { + return { + source: "turn_session_records", + generatedAt: "2026-05-29T00:00:00.000Z", + sessions: [ + { + conversationId: "slack:C1:123", + id: "turn-1", + status: "active", + startedAt: "2026-05-29T00:00:00.000Z", + lastSeenAt: "2026-05-29T00:00:01.000Z", + lastProgressAt: "2026-05-29T00:00:01.000Z", + surface: "slack", + title: "Turn turn-1", + channel: "C1", + sentryConversationUrl: + "https://sentry.sentry.io/explore/conversations/slack%3AC1%3A123/?project=1", + }, + ], + }; + }, + async getConversation(conversationId: string) { + return { + conversationId, + generatedAt: "2026-05-29T00:00:00.000Z", + turns: [ + { + conversationId, + id: "turn-1", + status: "active", + startedAt: "2026-05-29T00:00:00.000Z", + lastSeenAt: "2026-05-29T00:00:01.000Z", + lastProgressAt: "2026-05-29T00:00:01.000Z", + surface: "slack", + title: "Turn turn-1", + channel: "C1", + transcriptAvailable: true, + transcript: [ + { + role: "assistant", + parts: [ + { type: "text", text: "Checking." }, + { + type: "tool_call", + name: "search", + input: { query: "issue" }, + }, + ], + }, + ], + }, + ], + }; + }, + }; +} + +function auth(session: DashboardSession | null): DashboardAuth { + return { + async handler() { + return Response.json({ ok: true }); + }, + async getSession() { + return session; + }, + async signInWithGoogle() { + return Response.redirect( + "https://accounts.google.com/o/oauth2/v2/auth", + 302, + ); + }, + }; +} + +function dashboard( + session: DashboardSession | null, + customReporting: JuniorReporting = reporting(), +) { + return createDashboardApp({ + allowedGoogleDomains: ["sentry.io"], + allowedEmails: ["admin@example.com"], + auth: auth(session), + reporting: customReporting, + }); +} + +describe("dashboard routes", () => { + afterEach(() => { + for (const name of dashboardEnvNames) { + delete process.env[name]; + } + }); + + it("redirects unauthenticated dashboard page requests to login", async () => { + const app = dashboard(null); + + const response = await app.fetch(new Request("http://localhost/")); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe( + "http://localhost/api/dashboard/login", + ); + }); + + it("protects sub-routes at root basePath from unauthenticated access", async () => { + // app.use("/", ...) only matches the exact root in Hono; sub-routes like + // /conversations and /sessions must be covered by a wildcard middleware. + const app = dashboard(null); + + for (const path of [ + "/conversations", + "/conversations/slack%3AC1%3A123", + "/sessions", + "/sessions/some-session", + ]) { + const response = await app.fetch(new Request(`http://localhost${path}`)); + expect(response.status, path).toBe(302); + expect(response.headers.get("location"), path).toBe( + `http://localhost/api/dashboard/login`, + ); + } + }); + + it("can explicitly disable dashboard auth for local development", async () => { + const app = createDashboardApp({ + authRequired: false, + allowedGoogleDomains: [], + reporting: reporting(), + }); + + const page = await app.fetch(new Request("http://localhost/")); + expect(page.status).toBe(200); + + const me = await app.fetch( + new Request("http://localhost/api/dashboard/me"), + ); + expect(me.status).toBe(200); + expect(await me.json()).toEqual({ + user: { + email: "local-dashboard@localhost", + emailVerified: true, + hostedDomain: "localhost", + }, + }); + }); + + it("rejects unauthenticated dashboard API requests without diagnostics", async () => { + const app = dashboard(null); + + for (const path of [ + "/api/dashboard/health", + "/api/dashboard/runtime", + "/api/dashboard/plugins", + "/api/dashboard/skills", + "/api/dashboard/sessions", + "/api/dashboard/conversations/slack%3AC1%3A123", + "/api/dashboard/config", + "/api/dashboard/me", + "/api/dashboard/info", + "/api/dashboard/client.js", + ]) { + const response = await app.fetch(new Request(`http://localhost${path}`)); + expect(response.status, path).toBe(401); + expect(await response.json(), path).toEqual({ error: "unauthenticated" }); + } + }); + + it("allows verified users from an allowed Google hosted domain", async () => { + const app = dashboard({ + user: { + email: "person@sentry.io", + emailVerified: true, + hostedDomain: "sentry.io", + }, + }); + + const response = await app.fetch( + new Request("http://localhost/api/dashboard/info"), + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as { providers: string[] }; + expect(body.providers).toEqual(["github"]); + }); + + it("renders the authenticated ops deck shell", async () => { + const app = dashboard({ + user: { + email: "person@sentry.io", + emailVerified: true, + hostedDomain: "sentry.io", + }, + }); + + const response = await app.fetch(new Request("http://localhost/")); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.get("content-type")).toContain("text/html"); + const html = await response.text(); + expect(html).toContain("Junior"); + expect(html).toMatch(/\/api\/dashboard\/client\.js\?v=[a-z0-9]+/); + expect(html).toContain("__JUNIOR_DASHBOARD_BASE_PATH__"); + }); + + it("renders React Router dashboard page routes", async () => { + const app = dashboard({ + user: { + email: "person@sentry.io", + emailVerified: true, + hostedDomain: "sentry.io", + }, + }); + + const response = await app.fetch( + new Request("http://localhost/conversations"), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("text/html"); + const html = await response.text(); + expect(html).toContain("Junior"); + }); + + it("serves the dashboard client bundle without browser caching", async () => { + const app = dashboard({ + user: { + email: "person@sentry.io", + emailVerified: true, + hostedDomain: "sentry.io", + }, + }); + + const response = await app.fetch( + new Request("http://localhost/api/dashboard/client.js"), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.get("content-type")).toContain( + "application/javascript", + ); + }); + + it("serves the dashboard favicon without auth noise", async () => { + const app = dashboard(null); + + const response = await app.fetch( + new Request("http://localhost/favicon.ico"), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("image/svg+xml"); + }); + + it("returns command center API slices for authenticated users", async () => { + const app = dashboard({ + user: { + email: "person@sentry.io", + emailVerified: true, + hostedDomain: "sentry.io", + }, + }); + + const runtime = await app.fetch( + new Request("http://localhost/api/dashboard/runtime"), + ); + expect(runtime.status).toBe(200); + expect(await runtime.json()).toMatchObject({ + cwd: "/workspace", + providers: ["github"], + }); + + const plugins = await app.fetch( + new Request("http://localhost/api/dashboard/plugins"), + ); + expect(plugins.status).toBe(200); + expect(await plugins.json()).toEqual([{ name: "github" }]); + + const skills = await app.fetch( + new Request("http://localhost/api/dashboard/skills"), + ); + expect(skills.status).toBe(200); + expect(await skills.json()).toEqual([ + { name: "triage", pluginProvider: "github" }, + ]); + }); + + it("returns the signed-in identity and session feed", async () => { + const app = dashboard({ + session: { + token: "secret-session-token", + }, + user: { + email: "person@sentry.io", + emailVerified: true, + hostedDomain: "sentry.io", + name: "Dashboard User", + }, + } as DashboardSession); + + const me = await app.fetch( + new Request("http://localhost/api/dashboard/me"), + ); + expect(me.status).toBe(200); + expect(await me.json()).toEqual({ + user: { + email: "person@sentry.io", + emailVerified: true, + hostedDomain: "sentry.io", + name: "Dashboard User", + }, + }); + + const sessions = await app.fetch( + new Request("http://localhost/api/dashboard/sessions"), + ); + expect(sessions.status).toBe(200); + expect(await sessions.json()).toMatchObject({ + sessions: [ + { + conversationId: "slack:C1:123", + id: "turn-1", + sentryConversationUrl: + "https://sentry.sentry.io/explore/conversations/slack%3AC1%3A123/?project=1", + status: "active", + }, + ], + source: "turn_session_records", + }); + }); + + it("returns authenticated conversation transcript details", async () => { + const app = dashboard({ + user: { + email: "person@sentry.io", + emailVerified: true, + hostedDomain: "sentry.io", + }, + }); + + const response = await app.fetch( + new Request( + "http://localhost/api/dashboard/conversations/slack%3AC1%3A123", + ), + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + conversationId: "slack:C1:123", + turns: [ + { + id: "turn-1", + transcriptAvailable: true, + transcript: [ + { + role: "assistant", + parts: [ + { type: "text", text: "Checking." }, + { type: "tool_call", name: "search" }, + ], + }, + ], + }, + ], + }); + }); + + it("returns redacted private conversation details without transcript payloads", async () => { + const privateReporting = reporting(); + privateReporting.getConversation = async (conversationId: string) => ({ + conversationId, + generatedAt: "2026-05-29T00:00:00.000Z", + turns: [ + { + conversationId, + id: "turn-1", + status: "completed", + startedAt: "2026-05-29T00:00:00.000Z", + lastSeenAt: "2026-05-29T00:00:01.000Z", + lastProgressAt: "2026-05-29T00:00:01.000Z", + surface: "slack", + title: "Turn turn-1", + channel: "D1", + transcriptAvailable: false, + transcriptMessageCount: 2, + transcriptRedacted: true, + transcriptRedactionReason: "non_public_conversation", + transcript: [], + }, + ], + }); + const app = dashboard( + { + user: { + email: "person@sentry.io", + emailVerified: true, + hostedDomain: "sentry.io", + }, + }, + privateReporting, + ); + + const response = await app.fetch( + new Request( + "http://localhost/api/dashboard/conversations/slack%3AD1%3A123", + ), + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + conversationId: "slack:D1:123", + turns: [ + { + id: "turn-1", + transcriptAvailable: false, + transcriptMessageCount: 2, + transcriptRedacted: true, + transcriptRedactionReason: "non_public_conversation", + transcript: [], + }, + ], + }); + }); + + it("returns safe dashboard config signals", async () => { + process.env.SENTRY_DSN = "https://public@example.ingest.sentry.io/1"; + process.env.SENTRY_ORG_SLUG = "sentry"; + const app = dashboard({ + user: { + email: "person@sentry.io", + emailVerified: true, + hostedDomain: "sentry.io", + }, + }); + + const response = await app.fetch( + new Request("http://localhost/api/dashboard/config"), + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + allowedEmailCount: 1, + allowedGoogleDomainCount: 1, + authRequired: true, + authPath: "/api/auth", + basePath: "/", + sentryConversationLinks: true, + timeZone: "America/Los_Angeles", + }); + }); + + it("rejects verified users outside the allowed Google hosted domain", async () => { + const app = dashboard({ + user: { + email: "person@example.com", + emailVerified: true, + hostedDomain: "example.com", + }, + }); + + const response = await app.fetch( + new Request("http://localhost/api/dashboard/info"), + ); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ error: "forbidden" }); + }); + + it("renders a browser-readable forbidden page for denied dashboard routes", async () => { + const app = dashboard({ + user: { + email: "person@example.com", + emailVerified: true, + hostedDomain: "example.com", + }, + }); + + const response = await app.fetch(new Request("http://localhost/")); + + expect(response.status).toBe(403); + expect(response.headers.get("content-type")).toContain("text/html"); + const html = await response.text(); + expect(html).toContain(" - - -

> junior

`; - - if (d?.descriptionText) { - html += `\n
${esc(String(d.descriptionText))}
`; - } - - // Status section - html += `\n
-
Status
-
- - ${health.ok ? "Healthy" : "Unreachable"}`; - if (health.ok && health.data?.timestamp) { - html += `\n · ${esc(new Date(health.data.timestamp as string).toLocaleTimeString())}`; - } - html += `\n
`; - if (d) { - html += `\n
service${esc(String(health.data?.service ?? "junior"))}
`; - html += `\n
cwd${esc(String(d.cwd))}
`; - html += `\n
home${esc(String(d.homeDir))}
`; - } - html += `\n
`; - - // Endpoints section - const endpoints = [ - { method: "GET", path: "/health" }, - { method: "GET", path: "/api/info" }, - { method: "GET", path: "/api/oauth/callback/mcp/:provider" }, - { method: "GET", path: "/api/oauth/callback/:provider" }, - { method: "POST", path: "/api/internal/agent-dispatch" }, - { method: "GET", path: "/api/internal/heartbeat" }, - { method: "POST", path: "/api/webhooks/:platform" }, - ]; - html += `\n
-
Endpoints
-
    `; - for (const ep of endpoints) { - const cls = ep.method === "GET" ? "method-get" : "method-post"; - const link = ep.path.includes(":") - ? `${esc(ep.path)}` - : `${esc(ep.path)}`; - html += `\n
  • ${esc(ep.method)}${link}
  • `; - } - html += `\n
\n
`; - - if (d) { - const providers = d.providers as string[] | undefined; - const packagedContent = d.packagedContent as - | { packageNames?: string[] } - | undefined; - const skills = d.skills as - | Array<{ name: string; pluginProvider?: string }> - | undefined; - - if (providers?.length) { - html += `\n
-
Plugins (${providers.length})
-
`; - for (const p of providers) { - html += `\n ${esc(p)}`; - } - html += `\n
`; - if (packagedContent?.packageNames?.length) { - html += `\n
`; - for (const pkg of packagedContent.packageNames) { - html += `\n ${esc(pkg)}`; - } - html += `\n
`; - } - html += `\n
`; - } - - if (skills?.length) { - html += `\n
-
Skills (${skills.length})
-
`; - for (const s of skills) { - html += `\n ${esc(s.name)}`; - if (s.pluginProvider) { - html += ` ${esc(s.pluginProvider)}`; - } - html += ``; - } - html += `\n
\n
`; - } - } else if (!discovery.ok) { - html += `\n
-
Discovery
- unavailable · ${esc(discovery.error ?? "unknown")} -
`; - } - - html += `\n\n`; - - return new Response(html, { - headers: { "content-type": "text/html; charset=utf-8" }, - }); -} diff --git a/packages/junior/src/handlers/diagnostics.ts b/packages/junior/src/handlers/diagnostics.ts deleted file mode 100644 index e45946c66..000000000 --- a/packages/junior/src/handlers/diagnostics.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { readFileSync } from "node:fs"; -import path from "node:path"; -import { homeDir } from "@/chat/discovery"; -import { - getPluginPackageContent, - getPluginProviders, -} from "@/chat/plugins/registry"; -import { discoverSkills } from "@/chat/skills"; - -function readDescriptionText(): string | undefined { - try { - const raw = readFileSync( - path.join(homeDir(), "DESCRIPTION.md"), - "utf8", - ).trim(); - return raw || undefined; - } catch { - return undefined; - } -} - -/** Return a runtime discovery snapshot for built-app diagnostics. */ -export async function GET(): Promise { - const packagedContent = getPluginPackageContent(); - const skills = await discoverSkills(); - - return Response.json({ - cwd: process.cwd(), - homeDir: homeDir(), - descriptionText: readDescriptionText(), - providers: getPluginProviders().map((plugin) => plugin.manifest.name), - skills: skills.map((skill) => ({ - name: skill.name, - pluginProvider: skill.pluginProvider, - })), - packagedContent, - }); -} diff --git a/packages/junior/src/instrumentation.ts b/packages/junior/src/instrumentation.ts index b6eeb42d4..89d44f9b0 100644 --- a/packages/junior/src/instrumentation.ts +++ b/packages/junior/src/instrumentation.ts @@ -16,6 +16,10 @@ function getBoolean(value: string | undefined, fallback: boolean): boolean { /** Initialize Sentry for the Junior runtime. Call at the top of your entry point. */ export function initSentry(): void { + if (Sentry.getClient()) { + return; + } + const dsn = process.env.SENTRY_DSN; const enableLogs = getBoolean(process.env.SENTRY_ENABLE_LOGS, Boolean(dsn)); diff --git a/packages/junior/src/reporting.ts b/packages/junior/src/reporting.ts new file mode 100644 index 000000000..bec23c264 --- /dev/null +++ b/packages/junior/src/reporting.ts @@ -0,0 +1,615 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { isRecord } from "@/chat/coerce"; +import { homeDir } from "@/chat/discovery"; +import type { PiMessage } from "@/chat/pi/messages"; +import type { AgentTurnUsage } from "@/chat/usage"; +import { + getPluginPackageContent, + getPluginProviders, +} from "@/chat/plugins/registry"; +import { discoverSkills } from "@/chat/skills"; +import { parseSlackThreadId } from "@/chat/slack/context"; +import { + buildSentryConversationUrl, + buildSentryTraceUrl, +} from "@/chat/sentry-links"; +import { + canExposeConversationPayload, + resolveConversationPrivacy, +} from "@/chat/conversation-privacy"; +import { + getAgentTurnSessionRecord, + listAgentTurnSessionSummaries, + listAgentTurnSessionSummariesForConversation, + type AgentTurnRequester, + type AgentTurnSessionSummary, +} from "@/chat/state/turn-session"; +import { GET as healthGET } from "@/handlers/health"; + +const HUNG_TURN_PROGRESS_MS = 5 * 60 * 1000; +const SAFE_METADATA_KEY_LIMIT = 20; + +export interface HealthReport { + status: "ok"; + service: string; + timestamp: string; +} + +export interface PluginReport { + name: string; +} + +export interface SkillReport { + name: string; + pluginProvider?: string; +} + +export interface RuntimeInfoReport { + cwd: string; + homeDir: string; + descriptionText?: string; + providers: string[]; + skills: SkillReport[]; + packagedContent: ReturnType; +} + +export interface DashboardSessionReport { + conversationTitle?: string; + cumulativeDurationMs?: number; + cumulativeUsage?: AgentTurnUsage; + conversationId: string; + id: string; + status: "active" | "completed" | "failed" | "hung" | "superseded"; + startedAt: string; + lastSeenAt: string; + lastProgressAt: string; + completedAt?: string; + surface?: "slack" | "api" | "scheduler" | "internal"; + title?: string; + requester?: string; + requesterIdentity?: AgentTurnRequester; + channel?: string; + channelName?: string; + sentryConversationUrl?: string; + sentryTraceUrl?: string; + traceId?: string; +} + +export interface DashboardTranscriptPart { + bytes?: number; + chars?: number; + id?: string; + input?: unknown; + inputKeys?: string[]; + inputSizeBytes?: number; + inputSizeChars?: number; + inputType?: string; + name?: string; + output?: unknown; + outputKeys?: string[]; + outputSizeBytes?: number; + outputSizeChars?: number; + outputType?: string; + redacted?: boolean; + text?: string; + type: string; +} + +export interface DashboardTranscriptMessage { + parts: DashboardTranscriptPart[]; + role: string; + timestamp?: number; +} + +export interface DashboardTurnReport extends DashboardSessionReport { + transcriptAvailable: boolean; + transcriptMetadata?: DashboardTranscriptMessage[]; + transcriptMessageCount?: number; + transcriptRedacted?: boolean; + transcriptRedactionReason?: "non_public_conversation"; + transcript: DashboardTranscriptMessage[]; +} + +export interface DashboardConversationReport { + conversationId: string; + generatedAt: string; + turns: DashboardTurnReport[]; +} + +export interface DashboardSessionFeed { + sessions: DashboardSessionReport[]; + source: "turn_session_records"; + generatedAt: string; +} + +export interface JuniorReporting { + /** Read the public runtime health snapshot without exposing discovery data. */ + getHealth(): Promise; + /** Read authenticated dashboard runtime discovery data. */ + getRuntimeInfo(): Promise; + /** Read configured plugin names for authenticated dashboard views. */ + getPlugins(): Promise; + /** Read discovered skill names for authenticated dashboard views. */ + getSkills(): Promise; + /** + * Read recent turn metadata for authenticated dashboard views. + * + * Keep this API trace-shaped: callers should rely on timestamps, status, + * actor, route, usage, and links that can later be reconstructed from spans. + */ + getSessions(): Promise; + /** + * Read one conversation transcript for the dashboard. + * + * The current implementation joins turn-session records with expiring session + * logs, but the API should stay compatible with a future Sentry trace-history + * source. Avoid adding fields that require Redis-only transcript internals. + */ + getConversation(conversationId: string): Promise; +} + +function readDescriptionText(): string | undefined { + try { + const raw = readFileSync( + path.join(homeDir(), "DESCRIPTION.md"), + "utf8", + ).trim(); + return raw || undefined; + } catch { + return undefined; + } +} + +async function readHealth(): Promise { + const res = healthGET(); + return (await res.json()) as HealthReport; +} + +async function readSkills(): Promise { + const skills = await discoverSkills(); + return skills.map((skill) => ({ + name: skill.name, + pluginProvider: skill.pluginProvider, + })); +} + +async function readPlugins(): Promise { + return getPluginProviders().map((plugin) => ({ + name: plugin.manifest.name, + })); +} + +function statusFromCheckpoint( + summary: AgentTurnSessionSummary, +): DashboardSessionReport["status"] { + const state = summary.state; + if ( + state === "running" && + Date.now() - summary.lastProgressAtMs > HUNG_TURN_PROGRESS_MS + ) { + return "hung"; + } + if (state === "running" || state === "awaiting_resume") { + return "active"; + } + if (state === "abandoned") { + return "superseded"; + } + return state; +} + +function surfaceFromConversationId( + conversationId: string, +): DashboardSessionReport["surface"] { + return parseSlackThreadId(conversationId) ? "slack" : "internal"; +} + +function titleFromSummary(summary: AgentTurnSessionSummary): string { + if (summary.state === "awaiting_resume" && summary.resumeReason) { + return `Awaiting ${summary.resumeReason} resume`; + } + return `Turn ${summary.sessionId}`; +} + +function requesterLabel( + requester: AgentTurnRequester | undefined, +): string | undefined { + if (!requester) return undefined; + return ( + requester.email ?? + requester.slackUserName ?? + requester.fullName ?? + requester.slackUserId + ); +} + +function safePrivateLabel(summary: AgentTurnSessionSummary): string { + const slackThread = parseSlackThreadId(summary.conversationId); + if (slackThread?.channelId.startsWith("D")) { + return "Direct Message"; + } + if (slackThread?.channelId.startsWith("G")) { + return summary.channelName?.startsWith("mpdm-") + ? "Group DM" + : "Private Channel"; + } + return "Private Channel"; +} + +function sessionReportFromSummary( + summary: AgentTurnSessionSummary, +): DashboardSessionReport { + const slackThread = parseSlackThreadId(summary.conversationId); + const privacy = resolveConversationPrivacy({ + conversationId: summary.conversationId, + }); + const privateLabel = + privacy !== "public" ? safePrivateLabel(summary) : undefined; + const conversationTitle = privateLabel ?? summary.conversationTitle; + const channelName = privateLabel ?? summary.channelName; + const requester = requesterLabel(summary.requester); + const sentryConversationUrl = buildSentryConversationUrl( + summary.conversationId, + ); + const sentryTraceUrl = summary.traceId + ? buildSentryTraceUrl(summary.traceId) + : undefined; + return { + conversationId: summary.conversationId, + ...(conversationTitle ? { conversationTitle } : {}), + id: summary.sessionId, + status: statusFromCheckpoint(summary), + startedAt: new Date(summary.startedAtMs).toISOString(), + lastProgressAt: new Date(summary.lastProgressAtMs).toISOString(), + lastSeenAt: new Date(summary.updatedAtMs).toISOString(), + ...(summary.state === "completed" + ? { completedAt: new Date(summary.updatedAtMs).toISOString() } + : {}), + ...(summary.cumulativeDurationMs !== undefined + ? { cumulativeDurationMs: summary.cumulativeDurationMs } + : {}), + ...(summary.cumulativeUsage + ? { cumulativeUsage: summary.cumulativeUsage } + : {}), + surface: surfaceFromConversationId(summary.conversationId), + title: titleFromSummary(summary), + ...(requester ? { requester } : {}), + ...(summary.requester ? { requesterIdentity: summary.requester } : {}), + ...(slackThread ? { channel: slackThread.channelId } : {}), + ...(channelName ? { channelName } : {}), + ...(sentryConversationUrl ? { sentryConversationUrl } : {}), + ...(summary.traceId ? { traceId: summary.traceId } : {}), + ...(sentryTraceUrl ? { sentryTraceUrl } : {}), + }; +} + +function canExposeConversationTranscript( + summary: AgentTurnSessionSummary, +): boolean { + return canExposeConversationPayload({ + conversationId: summary.conversationId, + }); +} + +function textPart(text: string): DashboardTranscriptPart { + return { type: "text", text }; +} + +function recordField(value: Record, names: string[]): unknown { + for (const name of names) { + if (value[name] !== undefined) { + return value[name]; + } + } + return undefined; +} + +function normalizeTranscriptPart(part: unknown): DashboardTranscriptPart { + if (typeof part === "string") { + return textPart(part); + } + if (!isRecord(part)) { + return { type: "unknown", output: part }; + } + + const rawType = typeof part.type === "string" ? part.type : "unknown"; + if (rawType === "text") { + const text = recordField(part, ["text", "content"]); + return textPart( + typeof text === "string" ? text : (JSON.stringify(text) ?? ""), + ); + } + if (rawType === "toolCall") { + return { + type: "tool_call", + ...(typeof part.id === "string" ? { id: part.id } : {}), + ...(typeof part.name === "string" ? { name: part.name } : {}), + input: recordField(part, ["arguments", "input", "args"]), + }; + } + if (rawType === "toolResult") { + return { + type: "tool_result", + ...(typeof part.id === "string" ? { id: part.id } : {}), + ...(typeof part.name === "string" ? { name: part.name } : {}), + output: recordField(part, ["result", "output", "content"]), + }; + } + if (rawType === "thinking") { + return { + type: "thinking", + output: recordField(part, ["thinking", "text", "content", "output"]), + }; + } + + return { + type: rawType, + output: part, + }; +} + +function normalizeToolResultMessage( + record: Record, +): DashboardTranscriptPart { + const content = record.content; + let output = content; + if (Array.isArray(content) && content.length === 1 && isRecord(content[0])) { + const extracted = recordField(content[0], [ + "text", + "content", + "output", + "result", + ]); + output = extracted !== undefined ? extracted : content; + } + return { + type: "tool_result", + ...(typeof record.toolCallId === "string" ? { id: record.toolCallId } : {}), + ...(typeof record.name === "string" + ? { name: record.name } + : typeof record.toolName === "string" + ? { name: record.toolName } + : {}), + output, + }; +} + +function normalizeTranscriptMessage( + message: PiMessage, +): DashboardTranscriptMessage { + const record = message as unknown as Record; + const content = record.content; + const role = typeof record.role === "string" ? record.role : "unknown"; + return { + role, + ...(typeof record.timestamp === "number" + ? { timestamp: record.timestamp } + : {}), + parts: + role === "toolResult" + ? [normalizeToolResultMessage(record)] + : Array.isArray(content) + ? content.map(normalizeTranscriptPart) + : [normalizeTranscriptPart(content)], + }; +} + +function serializedChars(value: unknown): number { + if (typeof value === "string") return value.length; + return JSON.stringify(value)?.length ?? 0; +} + +function serializedBytes(value: unknown): number { + const serialized = typeof value === "string" ? value : JSON.stringify(value); + return new TextEncoder().encode(serialized ?? "").byteLength; +} + +function payloadType(value: unknown): string { + return Array.isArray(value) ? "array" : typeof value; +} + +function payloadKeys(value: unknown): string[] | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + const keys = Object.keys(value as Record).slice( + 0, + SAFE_METADATA_KEY_LIMIT, + ); + return keys.length > 0 ? keys : undefined; +} + +function redactedPayloadFields(prefix: "input" | "output", value: unknown) { + const keys = payloadKeys(value); + return { + [`${prefix}Type`]: payloadType(value), + [`${prefix}SizeBytes`]: serializedBytes(value), + [`${prefix}SizeChars`]: serializedChars(value), + ...(keys ? { [`${prefix}Keys`]: keys } : {}), + }; +} + +function redactTranscriptPart( + part: DashboardTranscriptPart, +): DashboardTranscriptPart { + if (part.type === "text") { + return { + type: "text", + redacted: true, + bytes: serializedBytes(part.text ?? ""), + chars: serializedChars(part.text ?? ""), + }; + } + if (part.type === "thinking") { + return { + type: "thinking", + redacted: true, + ...redactedPayloadFields("output", part.output), + }; + } + if (part.type === "tool_call") { + return { + type: "tool_call", + redacted: true, + ...(part.id ? { id: part.id } : {}), + ...(part.name ? { name: part.name } : {}), + ...redactedPayloadFields("input", part.input), + }; + } + if (part.type === "tool_result") { + return { + type: "tool_result", + redacted: true, + ...(part.id ? { id: part.id } : {}), + ...(part.name ? { name: part.name } : {}), + ...redactedPayloadFields("output", part.output), + }; + } + return { + type: part.type, + redacted: true, + ...redactedPayloadFields("output", part.output ?? part.input ?? part.text), + }; +} + +function redactTranscriptMessage( + message: DashboardTranscriptMessage, +): DashboardTranscriptMessage { + return { + role: message.role, + ...(typeof message.timestamp === "number" + ? { timestamp: message.timestamp } + : {}), + parts: message.parts.map(redactTranscriptPart), + }; +} + +function turnScopedMessages(messages: PiMessage[]): PiMessage[] { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const record = messages[index] as unknown as Record; + if (record.role === "user") { + return messages.slice(index); + } + } + return messages; +} + +function traceIdFromTranscript( + transcript: DashboardTranscriptMessage[], +): string | undefined { + for (const message of transcript) { + for (const part of message.parts) { + const text = + part.text ?? + (typeof part.output === "string" + ? part.output + : typeof part.input === "string" + ? part.input + : undefined); + const match = text?.match( + /\btrace[_-]?id["']?\s*[:=]\s*["']?([a-f0-9]{16,32})\b/i, + ); + if (match?.[1]) { + return match[1]; + } + } + } + return undefined; +} + +async function readSessions(): Promise { + const summaries = await listAgentTurnSessionSummaries(50); + return { + source: "turn_session_records", + generatedAt: new Date().toISOString(), + sessions: summaries.map(sessionReportFromSummary), + }; +} + +async function readConversation( + conversationId: string, +): Promise { + const summaries = ( + await listAgentTurnSessionSummariesForConversation(conversationId) + ).sort( + (left, right) => + left.startedAtMs - right.startedAtMs || + left.updatedAtMs - right.updatedAtMs || + left.sessionId.localeCompare(right.sessionId), + ); + + const turns = await Promise.all( + summaries.map(async (summary): Promise => { + const sessionRecord = await getAgentTurnSessionRecord( + summary.conversationId, + summary.sessionId, + ); + const scopedMessages = sessionRecord?.piMessages + ? turnScopedMessages(sessionRecord.piMessages) + : []; + const canExposeTranscript = canExposeConversationTranscript(summary); + const normalizedTranscript = scopedMessages.map( + normalizeTranscriptMessage, + ); + const transcript = canExposeTranscript ? normalizedTranscript : []; + const transcriptMetadata = canExposeTranscript + ? undefined + : normalizedTranscript.map(redactTranscriptMessage); + const traceId = + summary.traceId ?? + sessionRecord?.traceId ?? + (canExposeTranscript ? traceIdFromTranscript(transcript) : undefined); + const sentryTraceUrl = traceId ? buildSentryTraceUrl(traceId) : undefined; + return { + ...sessionReportFromSummary(summary), + ...(traceId ? { traceId } : {}), + ...(sentryTraceUrl ? { sentryTraceUrl } : {}), + transcriptAvailable: Boolean(sessionRecord) && canExposeTranscript, + ...(sessionRecord && scopedMessages.length > 0 + ? { transcriptMessageCount: scopedMessages.length } + : {}), + ...(!canExposeTranscript + ? { + transcriptMetadata, + transcriptRedacted: true, + transcriptRedactionReason: "non_public_conversation" as const, + } + : {}), + transcript, + }; + }), + ); + + return { + conversationId, + generatedAt: new Date().toISOString(), + turns, + }; +} + +/** Create the read-only reporting boundary used by authenticated dashboard routes. */ +export function createJuniorReporting(): JuniorReporting { + return { + getHealth: readHealth, + async getRuntimeInfo() { + const [plugins, skills] = await Promise.all([ + readPlugins(), + readSkills(), + ]); + + return { + cwd: process.cwd(), + homeDir: homeDir(), + descriptionText: readDescriptionText(), + providers: plugins.map((plugin) => plugin.name), + skills, + packagedContent: getPluginPackageContent(), + }; + }, + getPlugins: readPlugins, + getSkills: readSkills, + getSessions: readSessions, + getConversation: readConversation, + }; +} diff --git a/packages/junior/tests/integration/dashboard-reporting.test.ts b/packages/junior/tests/integration/dashboard-reporting.test.ts new file mode 100644 index 000000000..20acec754 --- /dev/null +++ b/packages/junior/tests/integration/dashboard-reporting.test.ts @@ -0,0 +1,359 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PiMessage } from "@/chat/pi/messages"; + +const ORIGINAL_ENV = { ...process.env }; + +describe("dashboard reporting", () => { + beforeEach(async () => { + process.env = { + ...ORIGINAL_ENV, + JUNIOR_STATE_ADAPTER: "memory", + }; + vi.resetModules(); + const { disconnectStateAdapter } = await import("@/chat/state/adapter"); + await disconnectStateAdapter(); + }); + + afterEach(async () => { + const { disconnectStateAdapter } = await import("@/chat/state/adapter"); + await disconnectStateAdapter(); + vi.resetModules(); + process.env = { ...ORIGINAL_ENV }; + }); + + it("indexes recent turn session summaries", async () => { + const { listAgentTurnSessionSummaries, upsertAgentTurnSessionRecord } = + await import("@/chat/state/turn-session"); + + await upsertAgentTurnSessionRecord({ + conversationId: "slack:C1:111", + sessionId: "turn-1", + sliceId: 1, + state: "running", + piMessages: [], + }); + await upsertAgentTurnSessionRecord({ + conversationId: "slack:C1:111", + sessionId: "turn-1", + sliceId: 2, + state: "completed", + piMessages: [], + cumulativeDurationMs: 1_200, + errorMessage: "provider failed with sensitive details", + loadedSkillNames: ["triage"], + }); + await upsertAgentTurnSessionRecord({ + conversationId: "slack:C2:222", + sessionId: "turn-2", + sliceId: 1, + state: "awaiting_resume", + piMessages: [], + resumeReason: "timeout", + }); + + const summaries = await listAgentTurnSessionSummaries(); + const turn1 = summaries.find((summary) => summary.sessionId === "turn-1"); + const turn2 = summaries.find((summary) => summary.sessionId === "turn-2"); + + expect( + summaries.filter((summary) => summary.sessionId === "turn-1"), + ).toHaveLength(1); + expect(turn1).toMatchObject({ + conversationId: "slack:C1:111", + sessionId: "turn-1", + sliceId: 2, + state: "completed", + cumulativeDurationMs: 1_200, + loadedSkillNames: ["triage"], + }); + expect(turn1?.startedAtMs).toBeLessThanOrEqual(turn1?.updatedAtMs ?? 0); + expect(turn1).not.toHaveProperty("errorMessage"); + expect(turn2).toMatchObject({ + conversationId: "slack:C2:222", + sessionId: "turn-2", + state: "awaiting_resume", + resumeReason: "timeout", + }); + }); + + it("reports only the current turn transcript from session history", async () => { + const { upsertAgentTurnSessionRecord } = + await import("@/chat/state/turn-session"); + const { createJuniorReporting } = await import("@/reporting"); + + await upsertAgentTurnSessionRecord({ + conversationId: "slack:C1:222", + sessionId: "turn-current", + sliceId: 1, + state: "completed", + piMessages: [ + { + role: "user", + content: [{ type: "text", text: "previous question" }], + timestamp: 1, + }, + { + role: "assistant", + content: [{ type: "text", text: "previous answer" }], + timestamp: 2, + }, + { + role: "user", + content: [{ type: "text", text: "current question" }], + timestamp: 3, + }, + { + role: "assistant", + content: [{ type: "text", text: "current answer" }], + timestamp: 4, + }, + ] as PiMessage[], + }); + + const report = + await createJuniorReporting().getConversation("slack:C1:222"); + + expect(report.turns).toHaveLength(1); + expect(report.turns[0]!.transcript).toEqual([ + { + role: "user", + timestamp: 3, + parts: [{ type: "text", text: "current question" }], + }, + { + role: "assistant", + timestamp: 4, + parts: [{ type: "text", text: "current answer" }], + }, + ]); + }); + + it("reports a conversation after newer turns evict it from the global index", async () => { + const { recordAgentTurnSessionSummary, upsertAgentTurnSessionRecord } = + await import("@/chat/state/turn-session"); + const { createJuniorReporting } = await import("@/reporting"); + + await upsertAgentTurnSessionRecord({ + conversationId: "slack:C1:999", + sessionId: "target-turn", + sliceId: 1, + state: "completed", + piMessages: [ + { + role: "user", + content: [{ type: "text", text: "target question" }], + timestamp: 1, + }, + ] as PiMessage[], + }); + + for (let index = 0; index < 5_005; index += 1) { + await recordAgentTurnSessionSummary({ + conversationId: `slack:C2:${index}`, + sessionId: `newer-turn-${index}`, + sliceId: 1, + state: "completed", + }); + } + + const report = + await createJuniorReporting().getConversation("slack:C1:999"); + + expect(report.turns).toHaveLength(1); + expect(report.turns[0]).toMatchObject({ + id: "target-turn", + transcriptAvailable: true, + }); + expect(report.turns[0]!.transcript).toEqual([ + { + role: "user", + timestamp: 1, + parts: [{ type: "text", text: "target question" }], + }, + ]); + }); + + it("keeps earlier turn transcripts pinned to their committed log prefix", async () => { + const { upsertAgentTurnSessionRecord } = + await import("@/chat/state/turn-session"); + const { createJuniorReporting } = await import("@/reporting"); + + await upsertAgentTurnSessionRecord({ + conversationId: "slack:C1:333", + sessionId: "turn-one", + sliceId: 1, + state: "completed", + piMessages: [ + { + role: "user", + content: [{ type: "text", text: "first question" }], + timestamp: 1, + }, + { + role: "assistant", + content: [{ type: "text", text: "first answer" }], + timestamp: 2, + }, + ] as PiMessage[], + }); + await upsertAgentTurnSessionRecord({ + conversationId: "slack:C1:333", + sessionId: "turn-two", + sliceId: 1, + state: "completed", + piMessages: [ + { + role: "user", + content: [{ type: "text", text: "first question" }], + timestamp: 1, + }, + { + role: "assistant", + content: [{ type: "text", text: "first answer" }], + timestamp: 2, + }, + { + role: "user", + content: [{ type: "text", text: "second question" }], + timestamp: 3, + }, + { + role: "assistant", + content: [{ type: "text", text: "second answer" }], + timestamp: 4, + }, + ] as PiMessage[], + }); + + const report = + await createJuniorReporting().getConversation("slack:C1:333"); + + expect(report.turns).toHaveLength(2); + expect(report.turns[0]).toMatchObject({ id: "turn-one" }); + expect(report.turns[0]!.transcript).toEqual([ + { + role: "user", + timestamp: 1, + parts: [{ type: "text", text: "first question" }], + }, + { + role: "assistant", + timestamp: 2, + parts: [{ type: "text", text: "first answer" }], + }, + ]); + expect(report.turns[1]).toMatchObject({ id: "turn-two" }); + expect(report.turns[1]!.transcript).toEqual([ + { + role: "user", + timestamp: 3, + parts: [{ type: "text", text: "second question" }], + }, + { + role: "assistant", + timestamp: 4, + parts: [{ type: "text", text: "second answer" }], + }, + ]); + }); + + it("redacts dashboard transcripts for non-public conversations", async () => { + const { upsertAgentTurnSessionRecord } = + await import("@/chat/state/turn-session"); + const { createJuniorReporting } = await import("@/reporting"); + const privateToolArgs = Object.fromEntries( + Array.from({ length: 25 }, (_, index) => [ + `privateKey${index}`, + `private value ${index}`, + ]), + ); + + await upsertAgentTurnSessionRecord({ + conversationId: "slack:D1:222", + sessionId: "turn-private", + sliceId: 1, + state: "completed", + channelName: "secret-dm-name", + conversationTitle: "sensitive generated thread title", + requester: { + email: "david@sentry.io", + slackUserId: "U1", + }, + piMessages: [ + { + role: "user", + content: [{ type: "text", text: "private question" }], + timestamp: 1, + }, + { + role: "assistant", + content: [ + { type: "text", text: "private answer" }, + { + type: "toolCall", + name: "search", + arguments: privateToolArgs, + }, + ], + timestamp: 2, + }, + ] as PiMessage[], + traceId: "0123456789abcdef0123456789abcdef", + }); + + const report = + await createJuniorReporting().getConversation("slack:D1:222"); + + expect(report.turns[0]).toMatchObject({ + conversationTitle: "Direct Message", + channelName: "Direct Message", + id: "turn-private", + traceId: "0123456789abcdef0123456789abcdef", + transcriptAvailable: false, + transcriptMessageCount: 2, + transcriptRedacted: true, + transcriptRedactionReason: "non_public_conversation", + transcript: [], + }); + expect(JSON.stringify(report)).not.toContain("private question"); + expect(JSON.stringify(report)).not.toContain("private answer"); + expect(JSON.stringify(report)).not.toContain("private value"); + expect(JSON.stringify(report)).not.toContain( + "sensitive generated thread title", + ); + expect(JSON.stringify(report)).not.toContain("secret-dm-name"); + const toolCall = report.turns[0]!.transcriptMetadata?.[1]?.parts.find( + (part) => part.type === "tool_call", + ); + expect(toolCall?.inputKeys).toHaveLength(20); + expect(toolCall?.inputKeys).toContain("privateKey0"); + expect(toolCall?.inputKeys).not.toContain("privateKey20"); + }); + + it("marks expired private transcripts as privacy redacted", async () => { + const { recordAgentTurnSessionSummary } = + await import("@/chat/state/turn-session"); + const { createJuniorReporting } = await import("@/reporting"); + + await recordAgentTurnSessionSummary({ + conversationId: "slack:D1:333", + sessionId: "turn-private-expired", + sliceId: 1, + state: "completed", + }); + + const report = + await createJuniorReporting().getConversation("slack:D1:333"); + + expect(report.turns[0]).toMatchObject({ + conversationTitle: "Direct Message", + channelName: "Direct Message", + id: "turn-private-expired", + transcriptAvailable: false, + transcriptMetadata: [], + transcriptRedacted: true, + transcriptRedactionReason: "non_public_conversation", + transcript: [], + }); + }); +}); diff --git a/packages/junior/tests/integration/example-build-discovery.test.ts b/packages/junior/tests/integration/example-build-discovery.test.ts index c99012868..a6fa90cbc 100644 --- a/packages/junior/tests/integration/example-build-discovery.test.ts +++ b/packages/junior/tests/integration/example-build-discovery.test.ts @@ -10,7 +10,14 @@ const originalCwd = process.cwd(); const repoRoot = path.resolve(import.meta.dirname, "../../../.."); const exampleRoot = path.join(repoRoot, "apps/example"); const exampleEntry = path.join(exampleRoot, "server.ts"); +const exampleNitroConfig = path.join(exampleRoot, "nitro.config.ts"); const exampleRequire = createRequire(exampleEntry); +const vercelEnvNames = [ + "VERCEL", + "VERCEL_ENV", + "VERCEL_URL", + "VERCEL_PROJECT_PRODUCTION_URL", +]; function isSamePath(left: string, right: string): boolean { try { @@ -69,6 +76,19 @@ async function importExampleApp() { }; } +async function importExampleNitroConfig() { + const href = `${pathToFileURL(exampleNitroConfig).href}?t=${Date.now()}`; + return (await import(href)) as { + exampleDashboardAuthRequired: () => boolean; + }; +} + +function clearVercelEnv(): void { + for (const name of vercelEnvNames) { + delete process.env[name]; + } +} + describe.sequential("example build discovery integration", () => { beforeAll(() => { buildJuniorPackage(); @@ -80,6 +100,30 @@ describe.sequential("example build discovery integration", () => { vi.resetModules(); }); + it("only disables dashboard auth for local development outside Vercel", async () => { + const config = await importExampleNitroConfig(); + + process.env = { ...originalEnv, NODE_ENV: "development" }; + clearVercelEnv(); + expect(config.exampleDashboardAuthRequired()).toBe(false); + + process.env = { + ...originalEnv, + NODE_ENV: "development", + VERCEL: "1", + }; + expect(config.exampleDashboardAuthRequired()).toBe(true); + + process.env = { ...originalEnv, NODE_ENV: "production" }; + clearVercelEnv(); + expect(config.exampleDashboardAuthRequired()).toBe(true); + + process.env = { ...originalEnv }; + delete process.env.NODE_ENV; + clearVercelEnv(); + expect(config.exampleDashboardAuthRequired()).toBe(true); + }); + it("serves built health and recognizes the sentry oauth callback route", async () => { process.chdir(exampleRoot); process.env.JUNIOR_PLUGIN_PACKAGES = JSON.stringify( @@ -102,7 +146,7 @@ describe.sequential("example build discovery integration", () => { expect(await oauth.text()).toContain("missing required parameters"); }, 15_000); - it("reports discovery state from the example app", async () => { + it("does not expose discovery state from the public example app", async () => { const packageNames = getExamplePluginPackages(); process.chdir(exampleRoot); process.env.JUNIOR_PLUGIN_PACKAGES = JSON.stringify(packageNames); @@ -110,56 +154,6 @@ describe.sequential("example build discovery integration", () => { const app = await importExampleApp(); const response = await app.fetch(new Request("http://localhost/api/info")); - expect(response.status).toBe(200); - const body = (await response.json()) as { - descriptionText?: string; - homeDir: string; - packagedContent: { - packageNames: string[]; - manifestRoots: string[]; - skillRoots: string[]; - }; - providers: string[]; - skills: Array<{ name: string }>; - }; - - expect(body.descriptionText).toBe( - "Junior helps your team make progress directly in Slack.", - ); - expect(body.homeDir).toBe(path.join(exampleRoot, "app")); - expect(body.skills.map((skill) => skill.name)).toEqual( - expect.arrayContaining(["example-local", "example-bundle-help"]), - ); - expect(body.providers).toEqual( - expect.arrayContaining([ - "agent-browser", - "example-bundle", - "github", - "notion", - "sentry", - ]), - ); - expect(body.packagedContent.packageNames).toEqual( - expect.arrayContaining(packageNames), - ); - expect(body.packagedContent.manifestRoots).toEqual( - expect.arrayContaining( - packageNames.map((packageName) => - path.join(exampleRoot, "node_modules", ...packageName.split("/")), - ), - ), - ); - expect(body.packagedContent.skillRoots).toEqual( - expect.arrayContaining( - packageNames.map((packageName) => - path.join( - exampleRoot, - "node_modules", - ...packageName.split("/"), - "skills", - ), - ), - ), - ); + expect(response.status).toBe(404); }, 15_000); }); diff --git a/packages/junior/tests/unit/chat/pi/traced-stream.test.ts b/packages/junior/tests/unit/chat/pi/traced-stream.test.ts index c49a0c497..ef04659cb 100644 --- a/packages/junior/tests/unit/chat/pi/traced-stream.test.ts +++ b/packages/junior/tests/unit/chat/pi/traced-stream.test.ts @@ -87,7 +87,7 @@ describe("createTracedStreamFn", () => { expect(opts.name).toBe("chat openai/gpt-5.4"); }); - it("sets gen_ai.input.messages and gen_ai.system_instructions on the chat span", async () => { + it("sets metadata-only input messages and system instructions when privacy is unknown", async () => { const { createTracedStreamFn } = await import("@/chat/pi/traced-stream"); const stream = createAssistantMessageEventStream(); const base = vi.fn(() => stream); @@ -106,16 +106,79 @@ describe("createTracedStreamFn", () => { attributes: Record; }; expect(opts.attributes["gen_ai.provider.name"]).toBe("vercel-ai-gateway"); + expect(opts.attributes["server.address"]).toBe("ai-gateway.vercel.sh"); + expect(opts.attributes["server.port"]).toBe(443); + expect(opts.attributes["gen_ai.request.stream"]).toBe(true); + expect(opts.attributes["gen_ai.output.type"]).toBe("text"); + expect(opts.attributes["app.ai.input.message_count"]).toBe(1); + expect(opts.attributes["app.ai.input.content_chars"]).toBe(5); + expect(opts.attributes["app.ai.input.roles"]).toEqual(["user"]); + expect(opts.attributes["app.ai.system_instructions.content_chars"]).toBe( + 14, + ); expect(typeof opts.attributes["gen_ai.input.messages"]).toBe("string"); - expect(opts.attributes["gen_ai.input.messages"]).toContain("hello"); + expect(opts.attributes["app.conversation.privacy"]).toBe("private"); + expect(opts.attributes["gen_ai.input.messages"]).toContain('"chars"'); + expect(opts.attributes["gen_ai.input.messages"]).not.toContain("hello"); expect(typeof opts.attributes["gen_ai.system_instructions"]).toBe("string"); - expect(opts.attributes["gen_ai.system_instructions"]).toContain( + expect(opts.attributes["gen_ai.system_instructions"]).toContain('"chars"'); + expect(opts.attributes["gen_ai.system_instructions"]).not.toContain( "you are junior", ); expect(opts.attributes["gen_ai.operation.name"]).toBe("chat"); expect(opts.attributes["gen_ai.request.model"]).toBe("openai/gpt-5.4"); }); + it("uses message metadata for private conversation chat spans", async () => { + const { createTracedStreamFn } = await import("@/chat/pi/traced-stream"); + const stream = createAssistantMessageEventStream(); + const base = vi.fn(() => stream); + + const traced = createTracedStreamFn({ + base: base as unknown as StreamFn, + conversationPrivacy: "private", + }); + await traced( + fakeModel("openai/gpt-5.4"), + { + systemPrompt: "private system", + messages: [{ role: "user", content: "private prompt", timestamp: 0 }], + }, + undefined, + ); + + const opts = startInactiveSpan.mock.calls[0]?.[0] as unknown as { + attributes: Record; + }; + expect(opts.attributes["app.conversation.privacy"]).toBe("private"); + expect(opts.attributes["app.ai.input.message_count"]).toBe(1); + expect(opts.attributes["app.ai.input.content_chars"]).toBe(14); + expect(opts.attributes["gen_ai.input.messages"]).toContain('"chars"'); + expect(opts.attributes["gen_ai.input.messages"]).not.toContain( + "private prompt", + ); + expect(opts.attributes["gen_ai.system_instructions"]).toContain('"chars"'); + expect(opts.attributes["gen_ai.system_instructions"]).not.toContain( + "private system", + ); + + stream.end({ + ...fakeMessage(), + content: [{ type: "text", text: "secret" }], + }); + await stream.result(); + await new Promise((r) => setImmediate(r)); + + const span = getSpan(); + const endAttributes = Object.fromEntries( + span.setAttribute.mock.calls.map((c) => [c[0], c[1]]), + ); + expect(endAttributes["app.ai.output.message_count"]).toBe(1); + expect(endAttributes["app.ai.output.content_chars"]).toBe(6); + expect(endAttributes["gen_ai.output.messages"]).toContain('"chars"'); + expect(endAttributes["gen_ai.output.messages"]).not.toContain("secret"); + }); + it("sets output.messages, usage tokens, finish_reasons, response.model after stream completion", async () => { const { createTracedStreamFn } = await import("@/chat/pi/traced-stream"); const stream = createAssistantMessageEventStream(); @@ -149,6 +212,31 @@ describe("createTracedStreamFn", () => { expect(span.end).toHaveBeenCalledTimes(1); }); + it("normalizes Pi toolUse finish reasons for telemetry", async () => { + const { createTracedStreamFn } = await import("@/chat/pi/traced-stream"); + const stream = createAssistantMessageEventStream(); + const base = vi.fn(() => stream); + + const traced = createTracedStreamFn(base as unknown as StreamFn); + await traced( + fakeModel("openai/gpt-5.4"), + { messages: [{ role: "user", content: "hi", timestamp: 0 }] }, + undefined, + ); + + stream.end({ ...fakeMessage(), stopReason: "toolUse" }); + await stream.result(); + await new Promise((r) => setImmediate(r)); + + const span = getSpan(); + const endAttributes = Object.fromEntries( + span.setAttribute.mock.calls.map((c) => [c[0], c[1]]), + ); + expect(endAttributes["gen_ai.response.finish_reasons"]).toEqual([ + "tool_use", + ]); + }); + it("inherits LogContext attributes (e.g. gen_ai.conversation.id) onto the chat span", async () => { const { withLogContext } = await import("@/chat/logging"); const { createTracedStreamFn } = await import("@/chat/pi/traced-stream"); diff --git a/packages/junior/tests/unit/logging/with-span.test.ts b/packages/junior/tests/unit/logging/with-span.test.ts index 5200f8e33..11f219ba9 100644 --- a/packages/junior/tests/unit/logging/with-span.test.ts +++ b/packages/junior/tests/unit/logging/with-span.test.ts @@ -1,12 +1,16 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -const { startSpan } = vi.hoisted(() => ({ +const { activeSpan, startSpan } = vi.hoisted(() => ({ + activeSpan: { + setAttribute: vi.fn(), + }, startSpan: vi.fn( async (_options: unknown, callback: () => Promise) => callback(), ), })); vi.mock("@/chat/sentry", () => ({ + getActiveSpan: () => activeSpan, startSpan, })); @@ -58,4 +62,24 @@ describe("withSpan", () => { "openai/gpt-4o-mini", ); }); + + it("normalizes Pi toolUse finish reasons on span attributes", async () => { + const { setSpanAttributes, withSpan } = await import("@/chat/logging"); + + await withSpan("chat openai/gpt-5.4", "gen_ai.chat", {}, async () => {}, { + "gen_ai.response.finish_reasons": ["toolUse"], + }); + setSpanAttributes({ finishReason: "toolUse" }); + + const spanOptions = startSpan.mock.calls[0]?.[0] as { + attributes: Record; + }; + expect(spanOptions.attributes["gen_ai.response.finish_reasons"]).toEqual([ + "tool_use", + ]); + expect(activeSpan.setAttribute).toHaveBeenCalledWith( + "gen_ai.response.finish_reasons", + ["tool_use"], + ); + }); }); diff --git a/packages/junior/tests/unit/pi/client.test.ts b/packages/junior/tests/unit/pi/client.test.ts index e7667f08b..8d1a77c35 100644 --- a/packages/junior/tests/unit/pi/client.test.ts +++ b/packages/junior/tests/unit/pi/client.test.ts @@ -80,7 +80,7 @@ describe("completeText", () => { Record, ]; - expect(name).toBe("ai.chat_completion"); + expect(name).toBe("chat openai/gpt-4o-mini"); expect(op).toBe("gen_ai.chat"); expect(context).toEqual({ modelId: "openai/gpt-4o-mini" }); expect(attributes).toEqual( @@ -88,6 +88,9 @@ describe("completeText", () => { "gen_ai.provider.name": GEN_AI_PROVIDER_NAME, "gen_ai.operation.name": "chat", "gen_ai.request.model": "openai/gpt-4o-mini", + "gen_ai.output.type": "text", + "server.address": "ai-gateway.vercel.sh", + "server.port": 443, "app.ai.reasoning_effort": "low", }), ); @@ -99,9 +102,73 @@ describe("completeText", () => { "gen_ai.provider.name": GEN_AI_PROVIDER_NAME, "gen_ai.operation.name": "chat", "gen_ai.request.model": "openai/gpt-4o-mini", + "gen_ai.output.type": "text", + "server.address": "ai-gateway.vercel.sh", + "server.port": 443, "gen_ai.output.messages": expect.any(String), "gen_ai.response.finish_reasons": ["stop"], }), ); }); + + it("uses message metadata for non-public conversation traces", async () => { + mocks.completeSimple.mockResolvedValue({ + content: [{ type: "text", text: "private answer" }], + stopReason: "stop", + usage: { input: 12, output: 4, totalTokens: 16 }, + }); + + const { completeText } = await import("@/chat/pi/client"); + + await completeText({ + modelId: "openai/gpt-4o-mini", + system: "private system", + messages: [ + { role: "user", content: "private question", timestamp: 1 }, + ] as any, + metadata: { + conversationId: "slack:D1:123", + channelId: "D1", + }, + }); + + const attributes = mocks.withSpan.mock.calls[0]?.[4] as Record< + string, + unknown + >; + const context = mocks.withSpan.mock.calls[0]?.[2] as Record< + string, + unknown + >; + expect(context).toMatchObject({ + conversationId: "slack:D1:123", + slackChannelId: "D1", + modelId: "openai/gpt-4o-mini", + }); + expect(attributes["app.conversation.privacy"]).toBe("private"); + expect(attributes["server.address"]).toBe("ai-gateway.vercel.sh"); + expect(attributes["server.port"]).toBe(443); + expect(attributes["gen_ai.output.type"]).toBe("text"); + expect(attributes["app.ai.input.message_count"]).toBe(1); + expect(attributes["app.ai.input.content_chars"]).toBe(16); + expect(attributes["gen_ai.system_instructions"]).toContain('"chars"'); + expect(attributes["gen_ai.system_instructions"]).not.toContain( + "private system", + ); + expect(attributes["gen_ai.input.messages"]).toContain('"chars"'); + expect(attributes["gen_ai.input.messages"]).not.toContain( + "private question", + ); + + const endAttributes = mocks.setSpanAttributes.mock.calls[0]?.[0] as Record< + string, + unknown + >; + expect(endAttributes["app.ai.output.message_count"]).toBe(1); + expect(endAttributes["app.ai.output.content_chars"]).toBe(14); + expect(endAttributes["gen_ai.output.messages"]).toContain('"chars"'); + expect(endAttributes["gen_ai.output.messages"]).not.toContain( + "private answer", + ); + }); }); diff --git a/packages/junior/tests/unit/privacy/conversation-privacy.test.ts b/packages/junior/tests/unit/privacy/conversation-privacy.test.ts new file mode 100644 index 000000000..255161f68 --- /dev/null +++ b/packages/junior/tests/unit/privacy/conversation-privacy.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { + toGenAiPayloadMetadata, + toGenAiPayloadTraceAttributes, +} from "@/chat/conversation-privacy"; + +describe("conversation privacy metadata", () => { + it("bounds top-level private payload keys", () => { + const payload = Object.fromEntries( + Array.from({ length: 25 }, (_, index) => [ + `privateKey${index}`, + `private value ${index}`, + ]), + ); + + const metadata = toGenAiPayloadMetadata(payload); + const attributes = toGenAiPayloadTraceAttributes( + "app.ai.tool.call.arguments", + payload, + ); + + expect(metadata.keys).toHaveLength(20); + expect(metadata.keys).toContain("privateKey0"); + expect(metadata.keys).not.toContain("privateKey20"); + expect(attributes["app.ai.tool.call.arguments.keys"]).toHaveLength(20); + expect(attributes["app.ai.tool.call.arguments.keys"]).toContain( + "privateKey0", + ); + expect(attributes["app.ai.tool.call.arguments.keys"]).not.toContain( + "privateKey20", + ); + expect(JSON.stringify(metadata)).not.toContain("private value"); + }); +}); diff --git a/packages/junior/tests/unit/runtime/respond-lazy-sandbox.test.ts b/packages/junior/tests/unit/runtime/respond-lazy-sandbox.test.ts index 36d845b29..90918ac3a 100644 --- a/packages/junior/tests/unit/runtime/respond-lazy-sandbox.test.ts +++ b/packages/junior/tests/unit/runtime/respond-lazy-sandbox.test.ts @@ -219,6 +219,8 @@ vi.mock("@/chat/config", () => ({ vi.mock("@/chat/pi/client", () => ({ GEN_AI_PROVIDER_NAME: "test-provider", + GEN_AI_SERVER_ADDRESS: "ai-gateway.vercel.sh", + GEN_AI_SERVER_PORT: 443, completeObject: async ({ prompt }: { prompt: string }) => { const instructionMatch = prompt.match( /\n([\s\S]*?)\n<\/current-instruction>/, diff --git a/packages/junior/tests/unit/runtime/respond-mcp-progressive-loading.test.ts b/packages/junior/tests/unit/runtime/respond-mcp-progressive-loading.test.ts index 43025a8af..ae89b3e1a 100644 --- a/packages/junior/tests/unit/runtime/respond-mcp-progressive-loading.test.ts +++ b/packages/junior/tests/unit/runtime/respond-mcp-progressive-loading.test.ts @@ -387,6 +387,8 @@ vi.mock("@/chat/mcp/oauth", () => ({ vi.mock("@/chat/pi/client", () => ({ GEN_AI_PROVIDER_NAME: "vercel-ai-gateway", + GEN_AI_SERVER_ADDRESS: "ai-gateway.vercel.sh", + GEN_AI_SERVER_PORT: 443, completeObject: async () => ({ object: { thinking_level: "medium", diff --git a/packages/junior/tests/unit/runtime/respond-provider-retry.test.ts b/packages/junior/tests/unit/runtime/respond-provider-retry.test.ts index ca052b1f9..557d86062 100644 --- a/packages/junior/tests/unit/runtime/respond-provider-retry.test.ts +++ b/packages/junior/tests/unit/runtime/respond-provider-retry.test.ts @@ -110,6 +110,8 @@ vi.mock("@/chat/capabilities/jr-rpc-command", () => ({ vi.mock("@/chat/pi/client", () => ({ GEN_AI_PROVIDER_NAME: "vercel-ai-gateway", + GEN_AI_SERVER_ADDRESS: "ai-gateway.vercel.sh", + GEN_AI_SERVER_PORT: 443, completeObject: async () => ({ object: { thinking_level: "medium", diff --git a/packages/junior/tests/unit/runtime/respond-timeout-resume.test.ts b/packages/junior/tests/unit/runtime/respond-timeout-resume.test.ts index f0772cf55..d20103510 100644 --- a/packages/junior/tests/unit/runtime/respond-timeout-resume.test.ts +++ b/packages/junior/tests/unit/runtime/respond-timeout-resume.test.ts @@ -94,6 +94,8 @@ vi.mock("@/chat/capabilities/jr-rpc-command", () => ({ vi.mock("@/chat/pi/client", () => ({ GEN_AI_PROVIDER_NAME: "vercel-ai-gateway", + GEN_AI_SERVER_ADDRESS: "ai-gateway.vercel.sh", + GEN_AI_SERVER_PORT: 443, completeObject: async () => ({ object: { thinking_level: "medium", diff --git a/packages/junior/tests/unit/tools/advisor-tool.test.ts b/packages/junior/tests/unit/tools/advisor-tool.test.ts new file mode 100644 index 000000000..3607b24fd --- /dev/null +++ b/packages/junior/tests/unit/tools/advisor-tool.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + Agent: vi.fn().mockImplementation(function (this: { + state: { messages: unknown[] }; + prompt: (message: unknown) => Promise; + }) { + this.state = { messages: [] }; + this.prompt = vi.fn(async (message: unknown) => { + this.state.messages.push(message); + this.state.messages.push({ + role: "assistant", + content: [{ type: "text", text: "private advisor memo" }], + stopReason: "stop", + usage: { input: 5, output: 6, totalTokens: 11 }, + }); + }); + }), + setSpanAttributes: vi.fn(), + setSpanStatus: vi.fn(), + withSpan: vi.fn( + async ( + _name: string, + _op: string, + _context: Record, + callback: () => Promise, + _attributes?: Record, + ) => callback(), + ), +})); + +vi.mock("@earendil-works/pi-agent-core", () => ({ + Agent: mocks.Agent, +})); + +vi.mock("@/chat/logging", async (importOriginal) => ({ + ...(await importOriginal()), + setSpanAttributes: mocks.setSpanAttributes, + setSpanStatus: mocks.setSpanStatus, + withSpan: mocks.withSpan, +})); + +vi.mock("@/chat/pi/client", () => ({ + GEN_AI_PROVIDER_NAME: "vercel-ai-gateway", + GEN_AI_SERVER_ADDRESS: "ai-gateway.vercel.sh", + GEN_AI_SERVER_PORT: 443, + getPiGatewayApiKeyOverride: vi.fn(() => undefined), + resolveGatewayModel: vi.fn((modelId: string) => ({ id: modelId })), +})); + +describe("createAdvisorTool", () => { + it("records privacy-safe advisor invoke-agent attributes", async () => { + const { createAdvisorTool } = await import("@/chat/tools/advisor/tool"); + const store = { + load: vi.fn(async () => []), + save: vi.fn(async () => undefined), + }; + const advisor = createAdvisorTool({ + config: { + modelId: "openai/gpt-5.4", + thinkingLevel: "low", + }, + conversationId: "slack:D1:123", + conversationPrivacy: "private", + getTools: () => [], + store, + }); + + const result = await advisor.execute!( + { + question: "private question", + context: "private context", + }, + {}, + ); + + expect(result).toMatchObject({ details: { ok: true } }); + const startAttributes = mocks.withSpan.mock.calls[0]?.[4] as Record< + string, + unknown + >; + expect(startAttributes).toMatchObject({ + "gen_ai.provider.name": "vercel-ai-gateway", + "gen_ai.operation.name": "invoke_agent", + "gen_ai.request.model": "openai/gpt-5.4", + "gen_ai.output.type": "text", + "server.address": "ai-gateway.vercel.sh", + "server.port": 443, + "app.conversation.privacy": "private", + "app.ai.input.message_count": 1, + }); + expect(startAttributes["gen_ai.input.messages"]).toContain('"chars"'); + expect(startAttributes["gen_ai.input.messages"]).not.toContain( + "private question", + ); + expect(startAttributes["gen_ai.input.messages"]).not.toContain( + "private context", + ); + + const endAttributes = mocks.setSpanAttributes.mock.calls[0]?.[0] as Record< + string, + unknown + >; + expect(endAttributes["app.ai.output.message_count"]).toBe(1); + expect(endAttributes["gen_ai.output.messages"]).toContain('"chars"'); + expect(endAttributes["gen_ai.output.messages"]).not.toContain( + "private advisor memo", + ); + }); +}); diff --git a/packages/junior/tests/unit/tools/agent-tools.test.ts b/packages/junior/tests/unit/tools/agent-tools.test.ts index a3bb5ded7..2750a213d 100644 --- a/packages/junior/tests/unit/tools/agent-tools.test.ts +++ b/packages/junior/tests/unit/tools/agent-tools.test.ts @@ -166,6 +166,12 @@ describe("createAgentTools", () => { }, sandbox, {}, + undefined, + undefined, + undefined, + undefined, + undefined, + "public", ); expect(editTool?.prepareArguments).toBe(prepareArguments); @@ -222,6 +228,67 @@ describe("createAgentTools", () => { ); }); + it("records only tool payload metadata for private conversations", async () => { + const sandbox = new SkillSandbox([], []); + const [bashTool] = createAgentTools( + { + bash: { + description: "bash", + inputSchema: {} as any, + execute: async () => ({ + ok: true, + stdout: "private result", + }), + }, + }, + sandbox, + { + conversationId: "slack:D123:123.456", + }, + undefined, + undefined, + undefined, + undefined, + undefined, + "private", + ); + + await bashTool!.execute("tool-bash", { + command: "private command", + }); + + const spanAttributes = withSpanMock.mock.calls[0]?.[4] as Record< + string, + unknown + >; + const resultCall = setSpanAttributesMock.mock.calls.find( + (call) => call[0] && "gen_ai.tool.call.result" in call[0], + ); + const resultAttribute = resultCall?.[0]?.[ + "gen_ai.tool.call.result" + ] as string; + + expect(spanAttributes["gen_ai.tool.call.arguments"]).toContain('"chars"'); + expect(spanAttributes["gen_ai.tool.call.arguments"]).toContain( + '"keys":["command"]', + ); + expect(spanAttributes["app.conversation.privacy"]).toBe("private"); + expect(spanAttributes["app.ai.tool.call.arguments.type"]).toBe("object"); + expect(spanAttributes["app.ai.tool.call.arguments.keys"]).toEqual([ + "command", + ]); + expect(spanAttributes["gen_ai.tool.call.arguments"]).not.toContain( + "private command", + ); + expect(resultAttribute).toContain('"chars"'); + expect(resultAttribute).toContain('"keys":["ok","stdout"]'); + expect(resultCall?.[0]).toMatchObject({ + "app.ai.tool.call.result.type": "object", + "app.ai.tool.call.result.keys": ["ok", "stdout"], + }); + expect(resultAttribute).not.toContain("private result"); + }); + it("records the raw tool result instead of the MCP envelope", async () => { const sandbox = new SkillSandbox([], []); const [mcpTool] = createAgentTools( @@ -244,6 +311,12 @@ describe("createAgentTools", () => { }, sandbox, {}, + undefined, + undefined, + undefined, + undefined, + undefined, + "public", ); await mcpTool!.execute("tool-mcp", { query: "hello" }); diff --git a/packages/junior/tsconfig.build.json b/packages/junior/tsconfig.build.json index 8376e6eef..b76f57faa 100644 --- a/packages/junior/tsconfig.build.json +++ b/packages/junior/tsconfig.build.json @@ -13,6 +13,7 @@ "src/handlers/**/*.ts", "src/instrumentation.ts", "src/nitro.ts", + "src/reporting.ts", "src/vercel.ts", "src/virtual-modules.d.ts" ] diff --git a/packages/junior/tsup.config.ts b/packages/junior/tsup.config.ts index b861ba10d..a0069f299 100644 --- a/packages/junior/tsup.config.ts +++ b/packages/junior/tsup.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ "cli/snapshot-warmup": "src/cli/snapshot-warmup.ts", instrumentation: "src/instrumentation.ts", nitro: "src/nitro.ts", + reporting: "src/reporting.ts", vercel: "src/vercel.ts", }, format: "esm", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2595fd69..c83aa9a33 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,15 @@ settings: excludeLinksFromLockfile: false injectWorkspacePackages: true +catalogs: + default: + "@sentry/node": + specifier: 10.53.1 + version: 10.53.1 + "@sentry/starlight-theme": + specifier: ^0.7.0 + version: 0.7.0 + overrides: ai: 6.0.190 "@swc/core": 1.15.33 @@ -37,10 +46,13 @@ importers: dependencies: "@sentry/junior": specifier: workspace:* - version: file:packages/junior(@aws-sdk/credential-provider-web-identity@3.972.43)(@opentelemetry/api@1.9.1)(@sentry/node@10.53.1) + version: file:packages/junior(@aws-sdk/credential-provider-web-identity@3.972.43)(@opentelemetry/api@1.9.1) "@sentry/junior-agent-browser": specifier: workspace:* version: link:../../packages/junior-agent-browser + "@sentry/junior-dashboard": + specifier: workspace:* + version: file:packages/junior-dashboard(@aws-sdk/credential-provider-web-identity@3.972.43)(@opentelemetry/api@1.9.1)(@vercel/blob@2.4.0)(@vercel/functions@3.6.0(@aws-sdk/credential-provider-web-identity@3.972.43))(chokidar@5.0.0)(jiti@2.7.0)(lru-cache@11.5.0)(react-is@19.2.6)(rollup@4.60.4)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vitest@4.1.7(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3))(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))) "@sentry/junior-datadog": specifier: workspace:* version: link:../../packages/junior-datadog @@ -59,9 +71,6 @@ importers: "@sentry/junior-sentry": specifier: workspace:* version: link:../../packages/junior-sentry - "@sentry/node": - specifier: 10.53.1 - version: 10.53.1 hono: specifier: ^4.12.22 version: 4.12.22 @@ -88,7 +97,7 @@ importers: specifier: ^0.39.2 version: 0.39.2(astro@6.3.7(@types/node@25.9.1)(@vercel/blob@2.4.0)(@vercel/functions@3.6.0(@aws-sdk/credential-provider-web-identity@3.972.43))(db0@0.3.4)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.4)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(typescript@6.0.3) "@sentry/starlight-theme": - specifier: ^0.7.0 + specifier: "catalog:" version: 0.7.0(@astrojs/starlight@0.39.2(astro@6.3.7(@types/node@25.9.1)(@vercel/blob@2.4.0)(@vercel/functions@3.6.0(@aws-sdk/credential-provider-web-identity@3.972.43))(db0@0.3.4)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.60.4)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(typescript@6.0.3)) astro: specifier: ^6.3.7 @@ -135,6 +144,9 @@ importers: "@sentry/junior-plugin-api": specifier: workspace:* version: link:../junior-plugin-api + "@sentry/node": + specifier: "catalog:" + version: 10.53.1 "@sinclair/typebox": specifier: ^0.34.49 version: 0.34.49 @@ -178,9 +190,6 @@ importers: "@sentry/junior-scheduler": specifier: workspace:* version: link:../junior-scheduler - "@sentry/node": - specifier: 10.53.1 - version: 10.53.1 "@types/node": specifier: ^25.9.1 version: 25.9.1 @@ -211,13 +220,71 @@ importers: packages/junior-agent-browser: {} + packages/junior-dashboard: + dependencies: + "@sentry/junior": + specifier: workspace:* + version: file:packages/junior(@aws-sdk/credential-provider-web-identity@3.972.43)(@opentelemetry/api@1.9.1) + "@tanstack/react-query": + specifier: ^5.100.14 + version: 5.100.14(react@19.2.6) + better-auth: + specifier: ^1.3.36 + version: 1.6.11(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.7(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3))(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))) + hono: + specifier: ^4.12.22 + version: 4.12.22 + nitro: + specifier: 3.0.260522-beta + version: 3.0.260522-beta(@vercel/blob@2.4.0)(@vercel/functions@3.6.0(@aws-sdk/credential-provider-web-identity@3.972.43))(chokidar@5.0.0)(jiti@2.7.0)(lru-cache@11.5.0)(rollup@4.60.4)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + react: + specifier: ^19.2.6 + version: 19.2.6 + react-dom: + specifier: ^19.2.6 + version: 19.2.6(react@19.2.6) + react-router: + specifier: ^7.16.0 + version: 7.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + recharts: + specifier: ^3.8.1 + version: 3.8.1(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.6)(react@19.2.6)(redux@5.0.1) + shiki: + specifier: 4.1.0 + version: 4.1.0 + devDependencies: + "@tailwindcss/cli": + specifier: ^4.3.0 + version: 4.3.0 + "@types/node": + specifier: ^25.9.1 + version: 25.9.1 + "@types/react": + specifier: ^19.2.15 + version: 19.2.15 + "@types/react-dom": + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.15) + tailwindcss: + specifier: ^4.3.0 + version: 4.3.0 + tsup: + specifier: ^8.5.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.7 + version: 4.1.7(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3))(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + packages/junior-datadog: {} packages/junior-evals: devDependencies: "@sentry/junior": specifier: workspace:* - version: file:packages/junior(@aws-sdk/credential-provider-web-identity@3.972.43)(@opentelemetry/api@1.9.1)(@sentry/node@10.53.1) + version: file:packages/junior(@aws-sdk/credential-provider-web-identity@3.972.43)(@opentelemetry/api@1.9.1) "@sentry/junior-github": specifier: workspace:* version: link:../junior-github @@ -638,6 +705,112 @@ packages: } engines: { node: ">=6.9.0" } + "@better-auth/core@1.6.11": + resolution: + { + integrity: sha512-LrwidLCV8azdMGjvtwp30nj9tIv1BwI3VhtC0UaGSjQkAVWw4bN42I8qwbxRziPeSQoj+zUVkOpxZzAWBDARtQ==, + } + peerDependencies: + "@better-auth/utils": 0.4.0 + "@better-fetch/fetch": 1.1.21 + "@cloudflare/workers-types": ">=4" + "@opentelemetry/api": ^1.9.0 + better-call: 1.3.5 + jose: ^6.1.0 + kysely: ^0.28.5 + nanostores: ^1.0.1 + peerDependenciesMeta: + "@cloudflare/workers-types": + optional: true + "@opentelemetry/api": + optional: true + + "@better-auth/drizzle-adapter@1.6.11": + resolution: + { + integrity: sha512-4jpkETIGZOHCf7BK4jnu22fdN6jjomH0/HhEzkaWy3+Eppi5PYlHTF/460jrTmA3Xc+Vqwp9t282ymHiEPypGw==, + } + peerDependencies: + "@better-auth/core": ^1.6.11 + "@better-auth/utils": 0.4.0 + drizzle-orm: ^0.45.2 + peerDependenciesMeta: + drizzle-orm: + optional: true + + "@better-auth/kysely-adapter@1.6.11": + resolution: + { + integrity: sha512-/g8M9RfIjdcZDnbstSUvQiINkvdNlCeZr248zwqx2/PVksQI1MhQofbzUn3RnQnbPKp0EPwpX/dR3oudRFenUg==, + } + peerDependencies: + "@better-auth/core": ^1.6.11 + "@better-auth/utils": 0.4.0 + kysely: ^0.28.17 + peerDependenciesMeta: + kysely: + optional: true + + "@better-auth/memory-adapter@1.6.11": + resolution: + { + integrity: sha512-hpdfw0BBf8MuzLkIdmbcUZICbY9r/bhLO2RxSnkzT5+/O+0I0u2I8+m0YUP7vNllP/ZCKASHOYgXPLO75Z0f9Q==, + } + peerDependencies: + "@better-auth/core": ^1.6.11 + "@better-auth/utils": 0.4.0 + + "@better-auth/mongo-adapter@1.6.11": + resolution: + { + integrity: sha512-3Tor8rSv8vSEIMEaV2PFpPEuVhqc1gNoZ6eGvoh3LwExXXuj8madew6ob+H1pH7Aphn3Ar5PQ08AguT8TbwFAA==, + } + peerDependencies: + "@better-auth/core": ^1.6.11 + "@better-auth/utils": 0.4.0 + mongodb: ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + mongodb: + optional: true + + "@better-auth/prisma-adapter@1.6.11": + resolution: + { + integrity: sha512-Pw+7q7zTp+VSci1V+CYMvuxIbAeVMZLe4lRo46LJoAKMHfjFl5T/ycsyFvWs/DkWC7n9gZZzRDEbHp0I5FiKKw==, + } + peerDependencies: + "@better-auth/core": ^1.6.11 + "@better-auth/utils": 0.4.0 + "@prisma/client": ^5.0.0 || ^6.0.0 || ^7.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + "@prisma/client": + optional: true + prisma: + optional: true + + "@better-auth/telemetry@1.6.11": + resolution: + { + integrity: sha512-hsjDHc8MZbm6/AHeNdtywrWedXevnBjmdvnHTcZub+rTVjOv+Td0roI8USKuC6uUibmrl//2rJfVCsGbopihNA==, + } + peerDependencies: + "@better-auth/core": ^1.6.11 + "@better-auth/utils": 0.4.0 + "@better-fetch/fetch": 1.1.21 + + "@better-auth/utils@0.4.0": + resolution: + { + integrity: sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA==, + } + + "@better-fetch/fetch@1.1.21": + resolution: + { + integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==, + } + "@borewit/text-codec@0.2.2": resolution: { @@ -1945,6 +2118,12 @@ packages: integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==, } + "@jridgewell/remapping@2.3.5": + resolution: + { + integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==, + } + "@jridgewell/resolve-uri@3.1.2": resolution: { @@ -2038,6 +2217,20 @@ packages: "@emnapi/core": ^1.7.1 "@emnapi/runtime": ^1.7.1 + "@noble/ciphers@2.2.0": + resolution: + { + integrity: sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==, + } + engines: { node: ">= 20.19.0" } + + "@noble/hashes@2.2.0": + resolution: + { + integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==, + } + engines: { node: ">= 20.19.0" } + "@nodable/entities@2.1.0": resolution: { @@ -2804,6 +2997,136 @@ packages: cpu: [x64] os: [win32] + "@parcel/watcher-android-arm64@2.5.6": + resolution: + { + integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==, + } + engines: { node: ">= 10.0.0" } + cpu: [arm64] + os: [android] + + "@parcel/watcher-darwin-arm64@2.5.6": + resolution: + { + integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==, + } + engines: { node: ">= 10.0.0" } + cpu: [arm64] + os: [darwin] + + "@parcel/watcher-darwin-x64@2.5.6": + resolution: + { + integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==, + } + engines: { node: ">= 10.0.0" } + cpu: [x64] + os: [darwin] + + "@parcel/watcher-freebsd-x64@2.5.6": + resolution: + { + integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==, + } + engines: { node: ">= 10.0.0" } + cpu: [x64] + os: [freebsd] + + "@parcel/watcher-linux-arm-glibc@2.5.6": + resolution: + { + integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==, + } + engines: { node: ">= 10.0.0" } + cpu: [arm] + os: [linux] + libc: [glibc] + + "@parcel/watcher-linux-arm-musl@2.5.6": + resolution: + { + integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==, + } + engines: { node: ">= 10.0.0" } + cpu: [arm] + os: [linux] + libc: [musl] + + "@parcel/watcher-linux-arm64-glibc@2.5.6": + resolution: + { + integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==, + } + engines: { node: ">= 10.0.0" } + cpu: [arm64] + os: [linux] + libc: [glibc] + + "@parcel/watcher-linux-arm64-musl@2.5.6": + resolution: + { + integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==, + } + engines: { node: ">= 10.0.0" } + cpu: [arm64] + os: [linux] + libc: [musl] + + "@parcel/watcher-linux-x64-glibc@2.5.6": + resolution: + { + integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==, + } + engines: { node: ">= 10.0.0" } + cpu: [x64] + os: [linux] + libc: [glibc] + + "@parcel/watcher-linux-x64-musl@2.5.6": + resolution: + { + integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==, + } + engines: { node: ">= 10.0.0" } + cpu: [x64] + os: [linux] + libc: [musl] + + "@parcel/watcher-win32-arm64@2.5.6": + resolution: + { + integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==, + } + engines: { node: ">= 10.0.0" } + cpu: [arm64] + os: [win32] + + "@parcel/watcher-win32-ia32@2.5.6": + resolution: + { + integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==, + } + engines: { node: ">= 10.0.0" } + cpu: [ia32] + os: [win32] + + "@parcel/watcher-win32-x64@2.5.6": + resolution: + { + integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==, + } + engines: { node: ">= 10.0.0" } + cpu: [x64] + os: [win32] + + "@parcel/watcher@2.5.6": + resolution: + { + integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==, + } + engines: { node: ">= 10.0.0" } + "@prisma/instrumentation@7.6.0": resolution: { @@ -2923,6 +3246,20 @@ packages: peerDependencies: "@redis/client": ^5.12.1 + "@reduxjs/toolkit@2.12.0": + resolution: + { + integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==, + } + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + "@renovatebot/pep440@4.2.1": resolution: { @@ -3434,14 +3771,15 @@ packages: } engines: { node: ">=18" } + "@sentry/junior-dashboard@file:packages/junior-dashboard": + resolution: { directory: packages/junior-dashboard, type: directory } + "@sentry/junior-plugin-api@file:packages/junior-plugin-api": resolution: { directory: packages/junior-plugin-api, type: directory } "@sentry/junior@file:packages/junior": resolution: { directory: packages/junior, type: directory } hasBin: true - peerDependencies: - "@sentry/node": ">=10.0.0" "@sentry/node-core@10.53.1": resolution: @@ -3664,27 +4002,185 @@ packages: { integrity: sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==, } - engines: { node: ">=18.0.0" } + engines: { node: ">=18.0.0" } + + "@smithy/util-buffer-from@2.2.0": + resolution: + { + integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==, + } + engines: { node: ">=14.0.0" } + + "@smithy/util-utf8@2.3.0": + resolution: + { + integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==, + } + engines: { node: ">=14.0.0" } + + "@standard-schema/spec@1.1.0": + resolution: + { + integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==, + } + + "@standard-schema/utils@0.3.0": + resolution: + { + integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==, + } + + "@tailwindcss/cli@4.3.0": + resolution: + { + integrity: sha512-X9kdlqyMopO9fewbgHsEeuy31YzMHbdZ9VsKt004tB+mxSg1CNbyhZYCzvhciN0AM4R4b5lvIprPjtNq7iQxpQ==, + } + hasBin: true + + "@tailwindcss/node@4.3.0": + resolution: + { + integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==, + } + + "@tailwindcss/oxide-android-arm64@4.3.0": + resolution: + { + integrity: sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==, + } + engines: { node: ">= 20" } + cpu: [arm64] + os: [android] + + "@tailwindcss/oxide-darwin-arm64@4.3.0": + resolution: + { + integrity: sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==, + } + engines: { node: ">= 20" } + cpu: [arm64] + os: [darwin] + + "@tailwindcss/oxide-darwin-x64@4.3.0": + resolution: + { + integrity: sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==, + } + engines: { node: ">= 20" } + cpu: [x64] + os: [darwin] + + "@tailwindcss/oxide-freebsd-x64@4.3.0": + resolution: + { + integrity: sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==, + } + engines: { node: ">= 20" } + cpu: [x64] + os: [freebsd] + + "@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0": + resolution: + { + integrity: sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==, + } + engines: { node: ">= 20" } + cpu: [arm] + os: [linux] + + "@tailwindcss/oxide-linux-arm64-gnu@4.3.0": + resolution: + { + integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==, + } + engines: { node: ">= 20" } + cpu: [arm64] + os: [linux] + libc: [glibc] + + "@tailwindcss/oxide-linux-arm64-musl@4.3.0": + resolution: + { + integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==, + } + engines: { node: ">= 20" } + cpu: [arm64] + os: [linux] + libc: [musl] + + "@tailwindcss/oxide-linux-x64-gnu@4.3.0": + resolution: + { + integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==, + } + engines: { node: ">= 20" } + cpu: [x64] + os: [linux] + libc: [glibc] + + "@tailwindcss/oxide-linux-x64-musl@4.3.0": + resolution: + { + integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==, + } + engines: { node: ">= 20" } + cpu: [x64] + os: [linux] + libc: [musl] + + "@tailwindcss/oxide-wasm32-wasi@4.3.0": + resolution: + { + integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==, + } + engines: { node: ">=14.0.0" } + cpu: [wasm32] + bundledDependencies: + - "@napi-rs/wasm-runtime" + - "@emnapi/core" + - "@emnapi/runtime" + - "@tybys/wasm-util" + - "@emnapi/wasi-threads" + - tslib + + "@tailwindcss/oxide-win32-arm64-msvc@4.3.0": + resolution: + { + integrity: sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==, + } + engines: { node: ">= 20" } + cpu: [arm64] + os: [win32] + + "@tailwindcss/oxide-win32-x64-msvc@4.3.0": + resolution: + { + integrity: sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==, + } + engines: { node: ">= 20" } + cpu: [x64] + os: [win32] - "@smithy/util-buffer-from@2.2.0": + "@tailwindcss/oxide@4.3.0": resolution: { - integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==, + integrity: sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==, } - engines: { node: ">=14.0.0" } + engines: { node: ">= 20" } - "@smithy/util-utf8@2.3.0": + "@tanstack/query-core@5.100.14": resolution: { - integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==, + integrity: sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==, } - engines: { node: ">=14.0.0" } - "@standard-schema/spec@1.1.0": + "@tanstack/react-query@5.100.14": resolution: { - integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==, + integrity: sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==, } + peerDependencies: + react: ^18 || ^19 "@tokenizer/inflate@0.4.1": resolution: @@ -3736,6 +4232,60 @@ packages: integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==, } + "@types/d3-array@3.2.2": + resolution: + { + integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==, + } + + "@types/d3-color@3.1.3": + resolution: + { + integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==, + } + + "@types/d3-ease@3.0.2": + resolution: + { + integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==, + } + + "@types/d3-interpolate@3.0.4": + resolution: + { + integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==, + } + + "@types/d3-path@3.1.1": + resolution: + { + integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==, + } + + "@types/d3-scale@4.0.9": + resolution: + { + integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==, + } + + "@types/d3-shape@3.1.8": + resolution: + { + integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==, + } + + "@types/d3-time@3.0.4": + resolution: + { + integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==, + } + + "@types/d3-timer@3.0.2": + resolution: + { + integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==, + } + "@types/debug@4.1.13": resolution: { @@ -3844,6 +4394,20 @@ packages: integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==, } + "@types/react-dom@19.2.3": + resolution: + { + integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==, + } + peerDependencies: + "@types/react": ^19.2.0 + + "@types/react@19.2.15": + resolution: + { + integrity: sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==, + } + "@types/retry@0.12.0": resolution: { @@ -3886,6 +4450,12 @@ packages: integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==, } + "@types/use-sync-external-store@0.0.6": + resolution: + { + integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==, + } + "@types/ws@8.18.1": resolution: { @@ -4605,6 +5175,82 @@ packages: integrity: sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w==, } + better-auth@1.6.11: + resolution: + { + integrity: sha512-Wwt6+q07dwIhsp6XiM7L1qSXVUWBEtNl+eZvwM778CguFqDZFBN9Pt6LtFaHl55t8Z+Zc//5kxcbgDY8/79vFQ==, + } + peerDependencies: + "@lynx-js/react": "*" + "@prisma/client": ^5.0.0 || ^6.0.0 || ^7.0.0 + "@sveltejs/kit": ^2.0.0 + "@tanstack/react-start": ^1.0.0 + "@tanstack/solid-start": ^1.0.0 + better-sqlite3: ^12.0.0 + drizzle-kit: ">=0.31.4" + drizzle-orm: ^0.45.2 + mongodb: ^6.0.0 || ^7.0.0 + mysql2: ^3.0.0 + next: ^14.0.0 || ^15.0.0 || ^16.0.0 + pg: ^8.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + solid-js: ^1.0.0 + svelte: ^4.0.0 || ^5.0.0 + vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + "@lynx-js/react": + optional: true + "@prisma/client": + optional: true + "@sveltejs/kit": + optional: true + "@tanstack/react-start": + optional: true + "@tanstack/solid-start": + optional: true + better-sqlite3: + optional: true + drizzle-kit: + optional: true + drizzle-orm: + optional: true + mongodb: + optional: true + mysql2: + optional: true + next: + optional: true + pg: + optional: true + prisma: + optional: true + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vitest: + optional: true + vue: + optional: true + + better-call@1.3.5: + resolution: + { + integrity: sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA==, + } + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + bignumber.js@9.3.1: resolution: { @@ -5135,6 +5781,89 @@ packages: } engines: { node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: ">=7.0.0" } + csstype@3.2.3: + resolution: + { + integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==, + } + + d3-array@3.2.4: + resolution: + { + integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==, + } + engines: { node: ">=12" } + + d3-color@3.1.0: + resolution: + { + integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==, + } + engines: { node: ">=12" } + + d3-ease@3.0.1: + resolution: + { + integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==, + } + engines: { node: ">=12" } + + d3-format@3.1.2: + resolution: + { + integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==, + } + engines: { node: ">=12" } + + d3-interpolate@3.0.1: + resolution: + { + integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==, + } + engines: { node: ">=12" } + + d3-path@3.1.0: + resolution: + { + integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==, + } + engines: { node: ">=12" } + + d3-scale@4.0.2: + resolution: + { + integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==, + } + engines: { node: ">=12" } + + d3-shape@3.2.0: + resolution: + { + integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==, + } + engines: { node: ">=12" } + + d3-time-format@4.1.0: + resolution: + { + integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==, + } + engines: { node: ">=12" } + + d3-time@3.1.0: + resolution: + { + integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==, + } + engines: { node: ">=12" } + + d3-timer@3.0.1: + resolution: + { + integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==, + } + engines: { node: ">=12" } + data-uri-to-buffer@4.0.1: resolution: { @@ -5199,6 +5928,12 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: + { + integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==, + } + decode-named-character-reference@1.3.0: resolution: { @@ -5495,6 +6230,12 @@ packages: } engines: { node: ">= 0.4" } + es-toolkit@1.47.0: + resolution: + { + integrity: sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==, + } + esast-util-from-estree@2.0.0: resolution: { @@ -6423,6 +7164,18 @@ packages: } engines: { node: ">= 4" } + immer@10.2.0: + resolution: + { + integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==, + } + + immer@11.1.8: + resolution: + { + integrity: sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==, + } + import-in-the-middle@2.0.6: resolution: { @@ -6468,6 +7221,13 @@ packages: integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==, } + internmap@2.0.3: + resolution: + { + integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==, + } + engines: { node: ">=12" } + interpret@3.1.1: resolution: { @@ -6793,6 +7553,13 @@ packages: } engines: { node: ">= 8" } + kysely@0.28.17: + resolution: + { + integrity: sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==, + } + engines: { node: ">=20.0.0" } + lightningcss-android-arm64@1.32.0: resolution: { @@ -7614,6 +8381,13 @@ packages: engines: { node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 } hasBin: true + nanostores@1.3.0: + resolution: + { + integrity: sha512-XPUa/jz+P1oJvN9VBxw4L9MtdFfaH3DAryqPssqhb2kXjmb9npz0dly6rCsgFWOPr4Yg9mTfM3MDZgZZ+7A3lA==, + } + engines: { node: ^20.0.0 || >=22.0.0 } + napi-build-utils@2.0.0: resolution: { @@ -7694,6 +8468,12 @@ packages: } engines: { node: ">=10" } + node-addon-api@7.1.1: + resolution: + { + integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==, + } + node-addon-api@8.8.0: resolution: { @@ -8474,6 +9254,55 @@ packages: integrity: sha512-s/I5zEAo79SUK0Qw4dpZKpiMwbQ6Gz0KU2NRr7eaO4x/p2g7Vvmn3hdeXDg8VsaUjfj/ora+e9oi27LX/C9+mw==, } + react-dom@19.2.6: + resolution: + { + integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==, + } + peerDependencies: + react: ^19.2.6 + + react-is@19.2.6: + resolution: + { + integrity: sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==, + } + + react-redux@9.3.0: + resolution: + { + integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==, + } + peerDependencies: + "@types/react": ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + redux: + optional: true + + react-router@7.16.0: + resolution: + { + integrity: sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==, + } + engines: { node: ">=20.0.0" } + peerDependencies: + react: ">=18" + react-dom: ">=18" + peerDependenciesMeta: + react-dom: + optional: true + + react@19.2.6: + resolution: + { + integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==, + } + engines: { node: ">=0.10.0" } + readable-stream@3.6.2: resolution: { @@ -8495,6 +9324,17 @@ packages: } engines: { node: ">= 20.19.0" } + recharts@3.8.1: + resolution: + { + integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==, + } + engines: { node: ">=18" } + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + rechoir@0.8.0: resolution: { @@ -8535,6 +9375,20 @@ packages: } engines: { node: ">= 18.19.0" } + redux-thunk@3.1.0: + resolution: + { + integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==, + } + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: + { + integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==, + } + regex-recursion@6.0.2: resolution: { @@ -8684,6 +9538,12 @@ packages: } engines: { node: ">=9.3.0 || >=8.10.0 <9.0.0" } + reselect@5.1.1: + resolution: + { + integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==, + } + resolve-from@5.0.0: resolution: { @@ -8793,6 +9653,12 @@ packages: engines: { node: ">=18.0.0", npm: ">=8.0.0" } hasBin: true + rou3@0.7.12: + resolution: + { + integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==, + } + rou3@0.8.1: resolution: { @@ -8844,6 +9710,12 @@ packages: } engines: { node: ">=11.0.0" } + scheduler@0.27.0: + resolution: + { + integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==, + } + seek-bzip@2.0.0: resolution: { @@ -8894,7 +9766,13 @@ packages: { integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==, } - engines: { node: ">= 18" } + engines: { node: ">= 18" } + + set-cookie-parser@2.7.2: + resolution: + { + integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==, + } set-cookie-parser@3.1.0: resolution: @@ -9349,6 +10227,12 @@ packages: } engines: { node: ">=20" } + tailwindcss@4.3.0: + resolution: + { + integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==, + } + tapable@2.3.3: resolution: { @@ -9437,6 +10321,12 @@ packages: integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==, } + tiny-invariant@1.3.3: + resolution: + { + integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==, + } + tinybench@2.9.0: resolution: { @@ -10028,6 +10918,14 @@ packages: integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==, } + use-sync-external-store@1.6.0: + resolution: + { + integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==, + } + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: resolution: { @@ -10067,6 +10965,12 @@ packages: integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==, } + victory-vendor@37.3.6: + resolution: + { + integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==, + } + vite@7.3.3: resolution: { @@ -11053,6 +11957,59 @@ snapshots: "@babel/helper-string-parser": 7.27.1 "@babel/helper-validator-identifier": 7.28.5 + "@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)": + dependencies: + "@better-auth/utils": 0.4.0 + "@better-fetch/fetch": 1.1.21 + "@opentelemetry/semantic-conventions": 1.41.1 + "@standard-schema/spec": 1.1.0 + better-call: 1.3.5(zod@4.4.3) + jose: 6.2.3 + kysely: 0.28.17 + nanostores: 1.3.0 + zod: 4.4.3 + optionalDependencies: + "@opentelemetry/api": 1.9.1 + + "@better-auth/drizzle-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)": + dependencies: + "@better-auth/core": 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + "@better-auth/utils": 0.4.0 + + "@better-auth/kysely-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17)": + dependencies: + "@better-auth/core": 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + "@better-auth/utils": 0.4.0 + optionalDependencies: + kysely: 0.28.17 + + "@better-auth/memory-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)": + dependencies: + "@better-auth/core": 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + "@better-auth/utils": 0.4.0 + + "@better-auth/mongo-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)": + dependencies: + "@better-auth/core": 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + "@better-auth/utils": 0.4.0 + + "@better-auth/prisma-adapter@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)": + dependencies: + "@better-auth/core": 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + "@better-auth/utils": 0.4.0 + + "@better-auth/telemetry@1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)": + dependencies: + "@better-auth/core": 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + "@better-auth/utils": 0.4.0 + "@better-fetch/fetch": 1.1.21 + + "@better-auth/utils@0.4.0": + dependencies: + "@noble/hashes": 2.2.0 + + "@better-fetch/fetch@1.1.21": {} + "@borewit/text-codec@0.2.2": {} "@bytecodealliance/preview2-shim@0.17.6": {} @@ -11665,6 +12622,11 @@ snapshots: "@jridgewell/sourcemap-codec": 1.5.5 "@jridgewell/trace-mapping": 0.3.31 + "@jridgewell/remapping@2.3.5": + dependencies: + "@jridgewell/gen-mapping": 0.3.13 + "@jridgewell/trace-mapping": 0.3.31 + "@jridgewell/resolve-uri@3.1.2": {} "@jridgewell/source-map@0.3.11": @@ -11780,6 +12742,10 @@ snapshots: "@tybys/wasm-util": 0.10.2 optional: true + "@noble/ciphers@2.2.0": {} + + "@noble/hashes@2.2.0": {} + "@nodable/entities@2.1.0": {} "@nodelib/fs.scandir@2.1.5": @@ -12182,6 +13148,66 @@ snapshots: "@pagefind/windows-x64@1.5.2": optional: true + "@parcel/watcher-android-arm64@2.5.6": + optional: true + + "@parcel/watcher-darwin-arm64@2.5.6": + optional: true + + "@parcel/watcher-darwin-x64@2.5.6": + optional: true + + "@parcel/watcher-freebsd-x64@2.5.6": + optional: true + + "@parcel/watcher-linux-arm-glibc@2.5.6": + optional: true + + "@parcel/watcher-linux-arm-musl@2.5.6": + optional: true + + "@parcel/watcher-linux-arm64-glibc@2.5.6": + optional: true + + "@parcel/watcher-linux-arm64-musl@2.5.6": + optional: true + + "@parcel/watcher-linux-x64-glibc@2.5.6": + optional: true + + "@parcel/watcher-linux-x64-musl@2.5.6": + optional: true + + "@parcel/watcher-win32-arm64@2.5.6": + optional: true + + "@parcel/watcher-win32-ia32@2.5.6": + optional: true + + "@parcel/watcher-win32-x64@2.5.6": + optional: true + + "@parcel/watcher@2.5.6": + dependencies: + detect-libc: 2.1.2 + is-glob: 4.0.3 + node-addon-api: 7.1.1 + picomatch: 4.0.4 + optionalDependencies: + "@parcel/watcher-android-arm64": 2.5.6 + "@parcel/watcher-darwin-arm64": 2.5.6 + "@parcel/watcher-darwin-x64": 2.5.6 + "@parcel/watcher-freebsd-x64": 2.5.6 + "@parcel/watcher-linux-arm-glibc": 2.5.6 + "@parcel/watcher-linux-arm-musl": 2.5.6 + "@parcel/watcher-linux-arm64-glibc": 2.5.6 + "@parcel/watcher-linux-arm64-musl": 2.5.6 + "@parcel/watcher-linux-x64-glibc": 2.5.6 + "@parcel/watcher-linux-x64-musl": 2.5.6 + "@parcel/watcher-win32-arm64": 2.5.6 + "@parcel/watcher-win32-ia32": 2.5.6 + "@parcel/watcher-win32-x64": 2.5.6 + "@prisma/instrumentation@7.6.0(@opentelemetry/api@1.9.1)": dependencies: "@opentelemetry/api": 1.9.1 @@ -12233,6 +13259,18 @@ snapshots: dependencies: "@redis/client": 5.12.1(@opentelemetry/api@1.9.1) + "@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.15)(react@19.2.6)(redux@5.0.1))(react@19.2.6)": + dependencies: + "@standard-schema/spec": 1.1.0 + "@standard-schema/utils": 0.3.0 + immer: 11.1.8 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.1.1 + optionalDependencies: + react: 19.2.6 + react-redux: 9.3.0(@types/react@19.2.15)(react@19.2.6)(redux@5.0.1) + "@renovatebot/pep440@4.2.1": {} "@rolldown/binding-android-arm64@1.0.0-rc.1": @@ -12417,9 +13455,89 @@ snapshots: "@sentry/core@10.53.1": {} + "@sentry/junior-dashboard@file:packages/junior-dashboard(@aws-sdk/credential-provider-web-identity@3.972.43)(@opentelemetry/api@1.9.1)(@vercel/blob@2.4.0)(@vercel/functions@3.6.0(@aws-sdk/credential-provider-web-identity@3.972.43))(chokidar@5.0.0)(jiti@2.7.0)(lru-cache@11.5.0)(react-is@19.2.6)(rollup@4.60.4)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))(vitest@4.1.7(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3))(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)))": + dependencies: + "@sentry/junior": file:packages/junior(@aws-sdk/credential-provider-web-identity@3.972.43)(@opentelemetry/api@1.9.1) + "@tanstack/react-query": 5.100.14(react@19.2.6) + better-auth: 1.6.11(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.7(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3))(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))) + hono: 4.12.22 + nitro: 3.0.260522-beta(@vercel/blob@2.4.0)(@vercel/functions@3.6.0(@aws-sdk/credential-provider-web-identity@3.972.43))(chokidar@5.0.0)(jiti@2.7.0)(lru-cache@11.5.0)(rollup@4.60.4)(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-router: 7.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + recharts: 3.8.1(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.6)(react@19.2.6)(redux@5.0.1) + shiki: 4.1.0 + transitivePeerDependencies: + - "@aws-sdk/credential-provider-web-identity" + - "@azure/app-configuration" + - "@azure/cosmos" + - "@azure/data-tables" + - "@azure/identity" + - "@azure/keyvault-secrets" + - "@azure/storage-blob" + - "@capacitor/preferences" + - "@cfworker/json-schema" + - "@cloudflare/workers-types" + - "@deno/kv" + - "@electric-sql/pglite" + - "@libsql/client" + - "@lynx-js/react" + - "@netlify/blobs" + - "@netlify/runtime" + - "@node-rs/xxhash" + - "@opentelemetry/api" + - "@opentelemetry/exporter-trace-otlp-http" + - "@planetscale/database" + - "@prisma/client" + - "@sveltejs/kit" + - "@tanstack/react-start" + - "@tanstack/solid-start" + - "@types/react" + - "@upstash/redis" + - "@vercel/blob" + - "@vercel/functions" + - "@vercel/kv" + - "@vercel/queue" + - aws4fetch + - bare-abort-controller + - better-sqlite3 + - bufferutil + - chokidar + - debug + - dotenv + - drizzle-kit + - drizzle-orm + - giget + - idb-keyval + - ioredis + - jiti + - lru-cache + - miniflare + - mongodb + - mysql2 + - next + - pg + - prisma + - react-is + - react-native-b4a + - redux + - rollup + - solid-js + - sqlite3 + - supports-color + - svelte + - uploadthing + - utf-8-validate + - vite + - vitest + - vue + - ws + - xml2js + - zephyr-agent + "@sentry/junior-plugin-api@file:packages/junior-plugin-api": {} - "@sentry/junior@file:packages/junior(@aws-sdk/credential-provider-web-identity@3.972.43)(@opentelemetry/api@1.9.1)(@sentry/node@10.53.1)": + "@sentry/junior@file:packages/junior(@aws-sdk/credential-provider-web-identity@3.972.43)(@opentelemetry/api@1.9.1)": dependencies: "@ai-sdk/gateway": 3.0.119(zod@4.4.3) "@chat-adapter/slack": 4.29.0(ai@6.0.190(zod@4.4.3))(zod@4.4.3) @@ -12449,6 +13567,7 @@ snapshots: - "@cfworker/json-schema" - "@node-rs/xxhash" - "@opentelemetry/api" + - "@opentelemetry/exporter-trace-otlp-http" - bare-abort-controller - bufferutil - debug @@ -12669,6 +13788,86 @@ snapshots: "@standard-schema/spec@1.1.0": {} + "@standard-schema/utils@0.3.0": {} + + "@tailwindcss/cli@4.3.0": + dependencies: + "@parcel/watcher": 2.5.6 + "@tailwindcss/node": 4.3.0 + "@tailwindcss/oxide": 4.3.0 + enhanced-resolve: 5.21.0 + mri: 1.2.0 + picocolors: 1.1.1 + tailwindcss: 4.3.0 + + "@tailwindcss/node@4.3.0": + dependencies: + "@jridgewell/remapping": 2.3.5 + enhanced-resolve: 5.21.0 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.0 + + "@tailwindcss/oxide-android-arm64@4.3.0": + optional: true + + "@tailwindcss/oxide-darwin-arm64@4.3.0": + optional: true + + "@tailwindcss/oxide-darwin-x64@4.3.0": + optional: true + + "@tailwindcss/oxide-freebsd-x64@4.3.0": + optional: true + + "@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0": + optional: true + + "@tailwindcss/oxide-linux-arm64-gnu@4.3.0": + optional: true + + "@tailwindcss/oxide-linux-arm64-musl@4.3.0": + optional: true + + "@tailwindcss/oxide-linux-x64-gnu@4.3.0": + optional: true + + "@tailwindcss/oxide-linux-x64-musl@4.3.0": + optional: true + + "@tailwindcss/oxide-wasm32-wasi@4.3.0": + optional: true + + "@tailwindcss/oxide-win32-arm64-msvc@4.3.0": + optional: true + + "@tailwindcss/oxide-win32-x64-msvc@4.3.0": + optional: true + + "@tailwindcss/oxide@4.3.0": + optionalDependencies: + "@tailwindcss/oxide-android-arm64": 4.3.0 + "@tailwindcss/oxide-darwin-arm64": 4.3.0 + "@tailwindcss/oxide-darwin-x64": 4.3.0 + "@tailwindcss/oxide-freebsd-x64": 4.3.0 + "@tailwindcss/oxide-linux-arm-gnueabihf": 4.3.0 + "@tailwindcss/oxide-linux-arm64-gnu": 4.3.0 + "@tailwindcss/oxide-linux-arm64-musl": 4.3.0 + "@tailwindcss/oxide-linux-x64-gnu": 4.3.0 + "@tailwindcss/oxide-linux-x64-musl": 4.3.0 + "@tailwindcss/oxide-wasm32-wasi": 4.3.0 + "@tailwindcss/oxide-win32-arm64-msvc": 4.3.0 + "@tailwindcss/oxide-win32-x64-msvc": 4.3.0 + + "@tanstack/query-core@5.100.14": {} + + "@tanstack/react-query@5.100.14(react@19.2.6)": + dependencies: + "@tanstack/query-core": 5.100.14 + react: 19.2.6 + "@tokenizer/inflate@0.4.1": dependencies: debug: 4.4.3 @@ -12703,6 +13902,30 @@ snapshots: dependencies: "@types/node": 25.9.1 + "@types/d3-array@3.2.2": {} + + "@types/d3-color@3.1.3": {} + + "@types/d3-ease@3.0.2": {} + + "@types/d3-interpolate@3.0.4": + dependencies: + "@types/d3-color": 3.1.3 + + "@types/d3-path@3.1.1": {} + + "@types/d3-scale@4.0.9": + dependencies: + "@types/d3-time": 3.0.4 + + "@types/d3-shape@3.1.8": + dependencies: + "@types/d3-path": 3.1.1 + + "@types/d3-time@3.0.4": {} + + "@types/d3-timer@3.0.2": {} + "@types/debug@4.1.13": dependencies: "@types/ms": 2.1.0 @@ -12763,6 +13986,14 @@ snapshots: pg-protocol: 1.14.0 pg-types: 2.2.0 + "@types/react-dom@19.2.3(@types/react@19.2.15)": + dependencies: + "@types/react": 19.2.15 + + "@types/react@19.2.15": + dependencies: + csstype: 3.2.3 + "@types/retry@0.12.0": {} "@types/sax@1.2.7": @@ -12783,6 +14014,8 @@ snapshots: "@types/unist@3.0.3": {} + "@types/use-sync-external-store@0.0.6": {} + "@types/ws@8.18.1": dependencies: "@types/node": 25.9.1 @@ -13484,6 +14717,42 @@ snapshots: is-alphanumerical: 2.0.1 is-decimal: 2.0.1 + better-auth@1.6.11(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.7(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3))(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0))): + dependencies: + "@better-auth/core": 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0) + "@better-auth/drizzle-adapter": 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) + "@better-auth/kysely-adapter": 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(kysely@0.28.17) + "@better-auth/memory-adapter": 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) + "@better-auth/mongo-adapter": 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) + "@better-auth/prisma-adapter": 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0) + "@better-auth/telemetry": 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21) + "@better-auth/utils": 0.4.0 + "@better-fetch/fetch": 1.1.21 + "@noble/ciphers": 2.2.0 + "@noble/hashes": 2.2.0 + better-call: 1.3.5(zod@4.4.3) + defu: 6.1.7 + jose: 6.2.3 + kysely: 0.28.17 + nanostores: 1.3.0 + zod: 4.4.3 + optionalDependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + vitest: 4.1.7(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3))(vite@8.0.14(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + transitivePeerDependencies: + - "@cloudflare/workers-types" + - "@opentelemetry/api" + + better-call@1.3.5(zod@4.4.3): + dependencies: + "@better-auth/utils": 0.4.0 + "@better-fetch/fetch": 1.1.21 + rou3: 0.7.12 + set-cookie-parser: 3.1.0 + optionalDependencies: + zod: 4.4.3 + bignumber.js@9.3.1: {} bindings@1.5.0: @@ -13742,6 +15011,46 @@ snapshots: dependencies: css-tree: 2.2.1 + csstype@3.2.3: {} + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + data-uri-to-buffer@4.0.1: {} data-uri-to-buffer@6.0.2: {} @@ -13756,6 +15065,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js-light@2.5.1: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -13921,6 +15232,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.3 + es-toolkit@1.47.0: {} + esast-util-from-estree@2.0.0: dependencies: "@types/estree-jsx": 1.0.5 @@ -14717,6 +16030,10 @@ snapshots: ignore@7.0.5: {} + immer@10.2.0: {} + + immer@11.1.8: {} + import-in-the-middle@2.0.6: dependencies: acorn: 8.16.0 @@ -14742,6 +16059,8 @@ snapshots: inline-style-parser@0.2.7: {} + internmap@2.0.3: {} + interpret@3.1.1: {} ip-address@10.2.0: {} @@ -14898,6 +16217,8 @@ snapshots: klona@2.0.6: {} + kysely@0.28.17: {} + lightningcss-android-arm64@1.32.0: optional: true @@ -15607,6 +16928,8 @@ snapshots: nanoid@3.3.12: {} + nanostores@1.3.0: {} + napi-build-utils@2.0.0: optional: true @@ -15729,6 +17052,8 @@ snapshots: semver: 7.8.1 optional: true + node-addon-api@7.1.1: {} + node-addon-api@8.8.0: optional: true @@ -16201,6 +17526,32 @@ snapshots: re2js@1.3.3: {} + react-dom@19.2.6(react@19.2.6): + dependencies: + react: 19.2.6 + scheduler: 0.27.0 + + react-is@19.2.6: {} + + react-redux@9.3.0(@types/react@19.2.15)(react@19.2.6)(redux@5.0.1): + dependencies: + "@types/use-sync-external-store": 0.0.6 + react: 19.2.6 + use-sync-external-store: 1.6.0(react@19.2.6) + optionalDependencies: + "@types/react": 19.2.15 + redux: 5.0.1 + + react-router@7.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + cookie: 1.1.1 + react: 19.2.6 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.6(react@19.2.6) + + react@19.2.6: {} + readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -16212,6 +17563,26 @@ snapshots: readdirp@5.0.0: {} + recharts@3.8.1(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.6)(react@19.2.6)(redux@5.0.1): + dependencies: + "@reduxjs/toolkit": 2.12.0(react-redux@9.3.0(@types/react@19.2.15)(react@19.2.6)(redux@5.0.1))(react@19.2.6) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.47.0 + eventemitter3: 5.0.4 + immer: 10.2.0 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-is: 19.2.6 + react-redux: 9.3.0(@types/react@19.2.15)(react@19.2.6)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@19.2.6) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - "@types/react" + - redux + rechoir@0.8.0: dependencies: resolve: 1.22.12 @@ -16256,6 +17627,12 @@ snapshots: - "@node-rs/xxhash" - "@opentelemetry/api" + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + regex-recursion@6.0.2: dependencies: regex-utilities: 2.3.0 @@ -16384,6 +17761,8 @@ snapshots: transitivePeerDependencies: - supports-color + reselect@5.1.1: {} + resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -16509,6 +17888,8 @@ snapshots: "@rollup/rollup-win32-x64-msvc": 4.60.4 fsevents: 2.3.3 + rou3@0.7.12: {} + rou3@0.8.1: {} router@2.2.0: @@ -16545,6 +17926,8 @@ snapshots: sax@1.6.0: {} + scheduler@0.27.0: {} + seek-bzip@2.0.0: dependencies: commander: 6.2.1 @@ -16584,6 +17967,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-cookie-parser@2.7.2: {} + set-cookie-parser@3.1.0: {} setprototypeof@1.1.1: {} @@ -16876,6 +18261,8 @@ snapshots: tagged-tag@1.0.0: {} + tailwindcss@4.3.0: {} + tapable@2.3.3: {} tar-fs@2.1.4: @@ -16950,6 +18337,8 @@ snapshots: tiny-inflate@1.0.3: {} + tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} tinyclip@0.1.12: {} @@ -17232,6 +18621,10 @@ snapshots: dependencies: punycode: 2.3.1 + use-sync-external-store@1.6.0(react@19.2.6): + dependencies: + react: 19.2.6 + util-deprecate@1.0.2: {} vary@1.1.2: {} @@ -17295,6 +18688,23 @@ snapshots: "@types/unist": 3.0.3 vfile-message: 4.0.3 + victory-vendor@37.3.6: + dependencies: + "@types/d3-array": 3.2.2 + "@types/d3-ease": 3.0.2 + "@types/d3-interpolate": 3.0.4 + "@types/d3-scale": 4.0.9 + "@types/d3-shape": 3.1.8 + "@types/d3-time": 3.0.4 + "@types/d3-timer": 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0): dependencies: esbuild: 0.27.7 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8e0e65050..184610e59 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,11 @@ packages: - "packages/*" - "apps/*" +catalog: + "@sentry/node": 10.53.1 + "@sentry/starlight-theme": ^0.7.0 +syncInjectedDepsAfterScripts: + - build minimumReleaseAge: 1440 minimumReleaseAgeExclude: - "@sentry/starlight-theme" diff --git a/policies/README.md b/policies/README.md index f949a6276..fcc064dac 100644 --- a/policies/README.md +++ b/policies/README.md @@ -8,6 +8,7 @@ without turning it into a full architecture document or feature spec. Good policy topics: - code comments and docstrings +- frontend component styling - testing expectations - naming conventions - interface design diff --git a/policies/frontend-components.md b/policies/frontend-components.md new file mode 100644 index 000000000..b2ca24f0a --- /dev/null +++ b/policies/frontend-components.md @@ -0,0 +1,30 @@ +# Frontend Components + +## Intent + +Frontend code should make layout and styling ownership obvious at the component +that renders the UI, instead of hiding product-specific presentation in large +stylesheets or semantic class contracts. + +## Policy + +- Prefer component-owned Tailwind utility classes over dashboard or feature + stylesheets. +- Prefer small named components for repeated UI surfaces, such as `Field`, + `Section`, `Toolbar`, `EmptyState`, or `StatusIndicator`, instead of repeated + `
` style hooks. +- Keep Tailwind classes colocated with the component or component-local helper + that owns the markup. +- Use stylesheets only for Tailwind entry files, minimal global resets, vendor + integration constraints, or selectors that cannot reasonably be represented + with utilities. +- Avoid visual gradients by default in product UI. Use solid surfaces, borders, + spacing, and status accents unless a gradient carries specific product meaning. +- Do not create broad semantic CSS class APIs for one-off feature UI. + +## Exceptions + +- Shared design-system packages may expose components whose internals are styled + elsewhere. +- Third-party rendered markup may need narrow wrapper selectors when utilities + cannot reach the generated DOM safely. diff --git a/scripts/bump-release-versions.mjs b/scripts/bump-release-versions.mjs index 2398828c6..46fff0578 100644 --- a/scripts/bump-release-versions.mjs +++ b/scripts/bump-release-versions.mjs @@ -12,6 +12,7 @@ const files = [ "packages/junior/package.json", "packages/junior-plugin-api/package.json", "packages/junior-agent-browser/package.json", + "packages/junior-dashboard/package.json", "packages/junior-datadog/package.json", "packages/junior-github/package.json", "packages/junior-hex/package.json", diff --git a/scripts/dev-server.mjs b/scripts/dev-server.mjs index c337b28d8..ce63caeb8 100644 --- a/scripts/dev-server.mjs +++ b/scripts/dev-server.mjs @@ -14,6 +14,11 @@ const workspaceRoot = path.resolve( const nodeEnv = process.env.NODE_ENV ?? "development"; const devPort = process.env.PORT?.trim() || "3000"; const juniorPackageDir = path.join(workspaceRoot, "packages", "junior"); +const dashboardPackageDir = path.join( + workspaceRoot, + "packages", + "junior-dashboard", +); const exampleDir = path.join(workspaceRoot, "apps", "example"); process.env.NODE_ENV = nodeEnv; @@ -85,19 +90,18 @@ function runRequiredChild(command, args, options = {}) { } } -function syncInjectedJuniorDist(options = {}) { +function syncInjectedPackageDist(packageName, packageDir, options = {}) { // `inject-workspace-packages=true` makes the example app resolve - // `@sentry/junior` from pnpm's injected package copy under - // `node_modules/.pnpm/...`, not directly from `packages/junior`. + // workspace dependencies from pnpm's injected package copies under + // `node_modules/.pnpm/...`, not directly from `packages/*`. // Point the injected package `dist` at the live workspace build output so // `pnpm dev` executes the latest local build without recursive copy races. - const injectedPackageDir = resolveInjectedPackageDir( - "@sentry/junior", - exampleDir, - ); - if (!injectedPackageDir) { + const injectedPackageDirs = [workspaceRoot, exampleDir] + .map((consumerDir) => resolveInjectedPackageDir(packageName, consumerDir)) + .filter((value, index, values) => value && values.indexOf(value) === index); + if (injectedPackageDirs.length === 0) { const error = new Error( - "Unable to resolve injected @sentry/junior package for apps/example dev runtime", + `Unable to resolve injected ${packageName} package for apps/example dev runtime`, ); if (options.strict ?? false) { throw error; @@ -106,10 +110,12 @@ function syncInjectedJuniorDist(options = {}) { return; } - linkDirectory( - path.join(juniorPackageDir, "dist"), - path.join(injectedPackageDir, "dist"), - ); + for (const injectedPackageDir of injectedPackageDirs) { + linkDirectory( + path.join(packageDir, "dist"), + path.join(injectedPackageDir, "dist"), + ); + } } const tunnelToken = process.env.CLOUDFLARE_TUNNEL_TOKEN?.trim(); @@ -174,14 +180,94 @@ function startLocalHeartbeat() { }); } +let nitroChild; +let restartingNitro = false; + +function clearExampleVercelOutput() { + fs.rmSync(path.join(exampleDir, ".vercel", "output"), { + force: true, + recursive: true, + }); +} + +function startNitroDev() { + nitroChild = spawnChild("pnpm", ["exec", "nitro", "dev"], { + cwd: exampleDir, + }); + + nitroChild.on("exit", (code, signal) => { + if (restartingNitro) { + return; + } + + terminateChildren(signal ?? "SIGTERM"); + + if (signal) { + process.kill(process.pid, signal); + return; + } + + process.exit(code ?? 1); + }); +} + +function restartNitroDev() { + if (!nitroChild || nitroChild.killed) { + clearExampleVercelOutput(); + startNitroDev(); + return; + } + + restartingNitro = true; + nitroChild.once("exit", () => { + clearExampleVercelOutput(); + restartingNitro = false; + startNitroDev(); + }); + nitroChild.kill("SIGTERM"); +} + +function watchDistForNitroRestart() { + let timer; + const scheduleRestart = () => { + clearTimeout(timer); + timer = setTimeout(restartNitroDev, 1500); + }; + + for (const distDir of [ + path.join(juniorPackageDir, "dist"), + path.join(dashboardPackageDir, "dist"), + ]) { + const watcher = fs.watch(distDir, scheduleRestart); + children.add({ + killed: false, + kill() { + clearTimeout(timer); + watcher.close(); + this.killed = true; + }, + }); + } +} + runRequiredChild("pnpm", ["build"], { cwd: juniorPackageDir, }); -syncInjectedJuniorDist({ strict: true }); +runRequiredChild("pnpm", ["build"], { + cwd: dashboardPackageDir, +}); +syncInjectedPackageDist("@sentry/junior", juniorPackageDir, { strict: true }); +syncInjectedPackageDist("@sentry/junior-dashboard", dashboardPackageDir, { + strict: true, +}); +clearExampleVercelOutput(); spawnChild("pnpm", ["exec", "tsup", "--watch", "--silent", "--no-clean"], { cwd: juniorPackageDir, }); +spawnChild("pnpm", ["exec", "tsup", "--watch", "--silent", "--no-clean"], { + cwd: dashboardPackageDir, +}); if (tunnelToken) { spawnChild("cloudflared", [ @@ -199,7 +285,8 @@ if (tunnelToken) { ]); } -const child = spawnChild("pnpm", ["dev"], { cwd: exampleDir }); +watchDistForNitroRestart(); +startNitroDev(); startLocalHeartbeat(); for (const signal of ["SIGINT", "SIGTERM"]) { @@ -207,14 +294,3 @@ for (const signal of ["SIGINT", "SIGTERM"]) { terminateChildren(signal); }); } - -child.on("exit", (code, signal) => { - terminateChildren(signal ?? "SIGTERM"); - - if (signal) { - process.kill(process.pid, signal); - return; - } - - process.exit(code ?? 1); -}); diff --git a/specs/advisor-tool.md b/specs/advisor-tool.md index c9fb7ba4b..1ce68fb7f 100644 --- a/specs/advisor-tool.md +++ b/specs/advisor-tool.md @@ -67,7 +67,7 @@ Advisor state is scoped to the parent conversation id and must survive process r - Store key: `junior::advisor_session` - Stored value: the advisor agent's own `PiMessage[]` -- TTL: same as parent thread state TTL +- TTL: same as Junior's one-week thread-state TTL The main Pi transcript stores only the bounded tool result object from normal Pi tool execution, not the advisor's private history. diff --git a/specs/agent-session-resumability.md b/specs/agent-session-resumability.md index 65326056a..c183be2cb 100644 --- a/specs/agent-session-resumability.md +++ b/specs/agent-session-resumability.md @@ -84,6 +84,7 @@ events. Each pause event identifies one safe resume boundary inside that log. the predictable `conversation_id` already identifies the model history. - Channel configuration is reloaded from the canonical state/configuration services on resume, not copied into the session log. - Sandbox and artifact state must be persisted eagerly as they change so the next slice can rebuild the same environment without depending on successful turn completion. +- Thread state, channel state, turn-session checkpoints, and Pi session messages share Junior's one-week Redis retention window. ### Ingress Queue Contract diff --git a/specs/dashboard.md b/specs/dashboard.md new file mode 100644 index 000000000..c9dca7bd7 --- /dev/null +++ b/specs/dashboard.md @@ -0,0 +1,296 @@ +# Dashboard Spec + +## Metadata + +- Created: 2026-05-29 +- Last Edited: 2026-05-30 + +## Purpose + +Define Junior's authenticated dashboard route, browser-session auth model, and read-only reporting boundary. + +## Scope + +- Dashboard route ownership for human-facing diagnostics. +- Better Auth configuration for browser sessions. +- Google domain and email authorization policy. +- In-process reporting interfaces exported by `@sentry/junior`. +- Nitro integration for mounting dashboard routes into the same deployment as Junior. + +## Non-Goals + +- Slack, provider OAuth, sandbox egress, or internal worker authentication. +- A dashboard-specific database, user table, or persistent session store. +- A remote reporting HTTP API. +- Model-facing access to dashboard data. +- Per-session or per-user revocation without storage. + +## Packages And Exports + +Dashboard functionality lives outside the core Junior runtime package. + +```txt +packages/junior/ + src/reporting/** + +packages/junior-dashboard/ + src/app.ts + src/auth.ts + src/client/** + src/config.ts + src/handler.ts + src/nitro.ts +``` + +`@sentry/junior` exports a read-only reporting surface: + +```ts +export interface JuniorReporting { + getHealth(): Promise; + getRuntimeInfo(): Promise; + getPlugins(): Promise; + getSkills(): Promise; + getSessions(): Promise; + getConversation(conversationId: string): Promise; +} + +export function createJuniorReporting(): JuniorReporting; +``` + +Every exported reporting function must have a brief JSDoc comment explaining why the data is exposed. + +`@sentry/junior-dashboard/nitro` exports: + +```ts +export interface JuniorDashboardNitroOptions { + basePath?: string; + authPath?: string; + authRequired?: boolean; + allowedGoogleDomains?: string[]; + allowedEmails?: string[]; + trustedOrigins?: string[]; + sessionMaxAgeSeconds?: number; + disabled?: boolean; +} + +export function juniorDashboardNitro(options: JuniorDashboardNitroOptions): { + nitro: { setup(nitro: unknown): void }; +}; +``` + +`authRequired` defaults to `true`. Setting `authRequired: false` is only for explicit local/demo deployments and must bypass dashboard auth only for dashboard routes. Production configuration must not silently disable dashboard auth. + +`disabled` disables route registration entirely and is only for explicit local/demo deployments. + +## Route Contract + +Junior health routes are machine-facing health checks: + +| Route | Auth | Contract | +| ------------- | ------ | --------------------------------------- | +| `GET /health` | public | Minimal health/readiness JSON response. | + +The dashboard package owns browser-facing routes: + +| Route | Auth | Contract | +| ----------------------- | ------------------------------------------------------ | ----------------------------------- | +| `GET /` | Better Auth session unless auth is explicitly disabled | React command-center UI. | +| `GET /conversations` | Better Auth session unless auth is explicitly disabled | React conversation-history UI. | +| `GET /conversations/**` | Better Auth session unless auth is explicitly disabled | React conversation-detail UI. | +| `GET /sessions/**` | Better Auth session unless auth is explicitly disabled | Compatibility redirect UI. | +| `GET /api/dashboard/**` | Better Auth session unless auth is explicitly disabled | Dashboard JSON APIs. | +| `/api/auth/**` | Better Auth | Better Auth social login callbacks. | + +Dashboard JSON APIs are split by view concern: + +| Route | Contract | +| ------------------------------------------------ | --------------------------------------------------- | +| `GET /api/dashboard/health` | Command-center health pulse. | +| `GET /api/dashboard/runtime` | Sanitized runtime paths, packages, and providers. | +| `GET /api/dashboard/plugins` | Loaded plugin inventory. | +| `GET /api/dashboard/skills` | Discovered skill inventory. | +| `GET /api/dashboard/sessions` | Conversation feed from recent turn-session records. | +| `GET /api/dashboard/conversations/:conversation` | Conversation transcript from expiring session logs. | +| `GET /api/dashboard/config` | Safe config counts, timezone, and feature signals. | +| `GET /api/dashboard/me` | Signed-in dashboard identity. | + +The current public diagnostics surfaces must move behind dashboard auth: + +- The HTML diagnostics page stays at `/` when the dashboard package is mounted, but requires dashboard auth. +- Runtime diagnostics JSON moves from `/api/info` to `/api/dashboard/info`. +- `/api/info` must not expose cwd, home directory, plugins, skills, packaged content, or other runtime discovery data publicly. + +Existing Junior runtime routes keep their existing auth models and must not be wrapped by dashboard auth: + +- `/api/webhooks/**` +- `/api/oauth/callback/**` +- `/api/internal/**` +- sandbox egress proxy requests +- `/health` + +## Better Auth Contract + +The dashboard uses Better Auth in stateless mode. + +Required properties: + +1. Do not configure a Better Auth database for the dashboard. +2. Store browser session state in cryptographically protected `HttpOnly` cookies. +3. Mark cookies `Secure` outside local development. +4. Use `SameSite=Lax` unless Better Auth requires a stricter provider-compatible setting. +5. Configure `baseURL`, `secret`, and `trustedOrigins`. +6. Configure Google as the only required social provider. +7. Do not persist Google access tokens, refresh tokens, user records, or account records for the dashboard. + +Required environment/config inputs when dashboard auth is enabled: + +- `JUNIOR_SECRET`, or optional `BETTER_AUTH_SECRET` override +- dashboard origin from optional `BETTER_AUTH_URL`, `JUNIOR_BASE_URL`, Vercel URL envs, or local dev +- `GOOGLE_CLIENT_ID` +- `GOOGLE_CLIENT_SECRET` +- dashboard origin or trusted origins +- `allowedGoogleDomains` from Nitro config or `JUNIOR_DASHBOARD_GOOGLE_DOMAINS` +- optional `allowedEmails` from Nitro config or `JUNIOR_DASHBOARD_ALLOWED_EMAILS` +- optional `JUNIOR_DASHBOARD_AUTH_REQUIRED=false` for explicit local auth bypass + +`JUNIOR_DASHBOARD_TRUSTED_ORIGINS` is allowed as the env equivalent of `trustedOrigins`. Dashboard list env vars may be comma-separated strings or JSON string arrays. + +Session lifetime defaults to eight hours. Session refresh is disabled unless a future spec adds a reason for long-lived dashboard sessions. + +## Authorization Policy + +Authentication proves the browser user completed Google login. Authorization is a separate dashboard check. + +After Better Auth resolves a session, dashboard middleware must allow the request only when one of these is true: + +1. The verified Google hosted-domain claim is in `allowedGoogleDomains`. +2. The verified email address is in `allowedEmails`. + +The Google `hd` authorization request parameter is only a login hint. It must not be treated as authorization by itself. + +Email suffix checks are not a substitute for the Google hosted-domain claim when domain authorization is configured. `allowedEmails` is the explicit exception path for individual accounts. + +If auth is enabled and no domains and no emails are configured, dashboard route setup must fail closed. + +## Reporting Contract + +The dashboard reads Junior data through in-process reporting interfaces. It must not import legacy diagnostics handlers or other private route handlers. + +Reporting interfaces are read-only and must not: + +- mutate runtime state +- issue provider credentials +- trigger agent turns +- call Slack APIs +- read or return secret values +- expose raw authorization URLs +- expose OAuth tokens, API keys, private keys, or Authorization headers + +Reporting data may include: + +- health status +- service/version metadata +- configured plugin names +- skill names and owning plugin provider +- conversation and turn summaries when provided by an in-process, read-only Junior reporting interface +- expiring raw conversation transcripts, including tool calls/results, only for public conversations while session-log messages are still present +- redacted private-conversation transcript metadata, such as message roles, timestamps, sizes, and tool names +- Sentry conversation links for conversation summaries when Sentry DSN and org slug configuration are present +- trace IDs for turns when the runtime captured an active Sentry trace +- packaged content summary +- sanitized runtime paths only when explicitly needed by an authenticated dashboard view + +Session reporting must not include conversation text, Pi messages, tool results, raw session-log payloads, or turn-session error messages. + +Dashboard transcript and title redaction must follow `./data-redaction-policy.md`. + +Public health responses must not include runtime discovery data such as cwd, home directory, plugin names, skill names, or packaged content. + +## Nitro Integration + +`juniorDashboardNitro()` mounts dashboard routes into the same Nitro deployment as `juniorNitro()`. + +The dashboard Nitro module must: + +1. Register only route-prefixed dashboard/auth handlers. +2. Avoid global middleware that can intercept Junior runtime routes. +3. Avoid changing `createApp()` runtime behavior. +4. Avoid requiring Junior to accept a dashboard plugin. +5. Register dashboard/auth routes with higher precedence than the existing Junior catch-all handler. +6. Work when mounted beside the existing Junior catch-all handler. + +Apps should configure the dashboard explicitly: + +```ts +export default defineConfig({ + preset: "vercel", + modules: [ + juniorNitro({ plugins }), + juniorDashboardNitro({ + authPath: "/api/auth", + allowedGoogleDomains: ["sentry.io"], + }), + ], +}); +``` + +## Failure Model + +- Missing Better Auth secret, Google client config, trusted origin, or allowlist fails startup. +- Unauthenticated dashboard requests redirect to Google login or return `401` for JSON routes. +- Authenticated users outside the configured domain/email allowlist receive `403`. +- Better Auth callback failures return a non-secret error page. +- Reporting read failures return dashboard-scoped errors without leaking secrets. +- Stateless sessions cannot be selectively revoked. Global invalidation uses secret rotation or a future cookie-version mechanism. + +## Security Invariants + +1. Dashboard auth is path-scoped. +2. Dashboard auth must never wrap Slack webhooks, provider OAuth callbacks, sandbox egress, internal queue/resume/heartbeat routes, or `/health`. +3. Dashboard sessions do not grant provider credentials or Slack permissions. +4. Dashboard APIs never return secret-bearing runtime values. +5. Browser session cookies are never model-visible and never passed into sandbox execution. +6. The dashboard package is not a Junior plugin and is not exposed to agent turns. + +## Observability + +Dashboard auth and reporting should emit safe metadata only: + +- auth success/failure reason category +- authorization denial reason category +- route family +- provider name (`google`) +- allowed-domain match as boolean + +Logs and spans must not include: + +- session cookie values +- OAuth state values +- ID tokens +- Google access tokens +- email addresses unless an existing privacy policy explicitly allows them + +## Verification + +Dashboard implementation requires integration tests for: + +1. unauthenticated `GET /` starts the Better Auth login flow when the dashboard package is mounted. +2. `GET /health` returns public minimal health JSON. +3. the dashboard Nitro module does not register a catch-all route when mounted at `/`. +4. unauthenticated `GET /api/dashboard/info` does not return diagnostics. +5. authenticated allowed-domain users can read `/api/dashboard/info`. +6. authenticated wrong-domain users receive `403`. +7. `allowedEmails` admits a configured individual account. +8. `/api/info` no longer exposes public runtime diagnostics. +9. Slack webhook, provider OAuth callback, internal, and sandbox egress routes are not intercepted by dashboard auth. +10. dashboard reporting cannot return secret-bearing values. + +Tests must follow `./testing.md`: route wiring and auth behavior belong in integration tests. + +## Related Specs + +- `./security-policy.md` +- `./oauth-flows.md` +- `./plugin-runtime.md` +- `./testing.md` +- `./integration-testing.md` diff --git a/specs/data-redaction-policy.md b/specs/data-redaction-policy.md new file mode 100644 index 000000000..febe8c00f --- /dev/null +++ b/specs/data-redaction-policy.md @@ -0,0 +1,117 @@ +# Data Redaction Policy + +## Purpose + +Define when Junior may expose raw conversation, model, and tool payloads across +dashboard reporting, logs, traces, and operational metadata. + +## Scope + +- Conversation visibility classification. +- Dashboard transcript redaction. +- GenAI tracing payload redaction. +- Safe metadata that may remain visible for private conversations. + +## Non-Goals + +- Slack message delivery formatting. +- Provider OAuth token redaction, which is owned by `./security-policy.md`. +- Long-term product analytics or metrics storage. + +## Conversation Privacy + +Junior classifies conversations as `public` or `private`. + +- Slack channels whose id starts with `C` are public. +- Slack direct messages whose id starts with `D` are private. +- Slack private channels and group DMs whose id starts with `G` are private. +- Unknown or unparsable conversation ids are private. + +Privacy checks must fail closed. A missing channel id, unknown conversation +shape, or unsupported platform must not expose raw payloads. + +## Raw Payloads + +Raw payloads include: + +- user message text +- assistant message text and thinking output +- model system instructions +- tool call arguments +- tool result payloads +- raw Pi messages or session-log payloads +- generated conversation titles for private conversations +- private Slack channel names or DM participant-derived titles + +Private conversations must not expose raw payloads through dashboard APIs, +logs, traces, or span attributes. + +## Safe Metadata + +Private conversations may expose bounded metadata when it is needed for +debuggability and does not reveal raw content: + +- conversation id and turn/session id +- requester identity used for audit/correlation +- message role and timestamp +- message count and tool-call count +- payload byte/character size +- part type +- tool name +- bounded top-level tool argument key names +- token usage, duration, outcome, trace id, and Sentry links + +Safe metadata must stay low-cardinality and bounded. Do not include arbitrary +payload previews or nested values. + +## Dashboard Reporting + +Dashboard reporting may return raw transcript content only for public +conversations. + +For private conversations: + +- `transcript` must be empty. +- `transcriptRedacted` must be true. +- `transcriptRedactionReason` must explain that the conversation is not public. +- `transcriptMetadata` may include safe metadata only. +- Conversation titles must use generic labels: + - `Direct Message` + - `Group DM` + - `Private Channel` +- Public Slack channel titles may use `#channel`. + +The dashboard UI must render private transcript metadata as redacted content, +not as approximated raw content. + +## GenAI Tracing + +For private conversations, GenAI spans must not set raw +`gen_ai.input.messages`, `gen_ai.output.messages`, or +`gen_ai.system_instructions` values. They may set metadata equivalents that +contain roles, part types, sizes, and counts. + +Tool execution spans in private conversations must not set raw +`gen_ai.tool.call.arguments` or raw `gen_ai.tool.call.result`. They may set +bounded `app.ai.tool.*` metadata such as type, size, and top-level keys. + +All GenAI spans should include `app.conversation.privacy` when the runtime can +derive it. + +## Verification + +- Private dashboard conversation APIs return no raw message text, thinking text, + tool arguments, or tool results. +- Public dashboard conversation APIs may return raw transcript content while the + session-log entry is still present. +- Private GenAI span tests assert metadata-only message attributes. +- Tool span tests cover metadata-only argument/result attributes for private + conversations. +- Unknown conversation ids are treated as private. + +## Related Specs + +- `./dashboard.md` +- `./security-policy.md` +- `./tracing.md` +- `./otel-semantics.md` diff --git a/specs/index.md b/specs/index.md index f8723bd0f..33a13a207 100644 --- a/specs/index.md +++ b/specs/index.md @@ -3,7 +3,7 @@ ## Metadata - Created: 2026-03-03 -- Last Edited: 2026-05-28 +- Last Edited: 2026-05-30 ## Purpose @@ -29,6 +29,7 @@ Define spec taxonomy, naming conventions, and canonical source-of-truth document ## Available Docs - `specs/security-policy.md` +- `specs/data-redaction-policy.md` - `specs/chat-architecture.md` - `specs/agent-turn-handling.md` - `specs/slack-agent-delivery.md` @@ -49,6 +50,7 @@ Define spec taxonomy, naming conventions, and canonical source-of-truth document - `specs/plugin-manifest.md` - `specs/plugin-runtime.md` - `specs/sandbox-snapshots.md` +- `specs/dashboard.md` - `specs/instrumentation.md` - `specs/logging.md` - `specs/tracing.md` diff --git a/specs/otel-semantics.md b/specs/otel-semantics.md index 47e6b3dd7..6fdac9601 100644 --- a/specs/otel-semantics.md +++ b/specs/otel-semantics.md @@ -3,7 +3,7 @@ ## Metadata - Created: 2026-02-25 -- Last Edited: 2026-05-11 +- Last Edited: 2026-05-30 ## Purpose @@ -60,7 +60,11 @@ This file is the canonical attribute and naming map for instrumentation in this - `gen_ai.provider.name` - `gen_ai.operation.name` - `gen_ai.request.model` +- `gen_ai.output.type` +- `gen_ai.request.stream` - `gen_ai.response.finish_reasons` (when available) +- `server.address` +- `server.port` when `server.address` is set - `gen_ai.system_instructions` (when captured and provided separately from chat history) - `gen_ai.input.messages` (when captured) - `gen_ai.output.messages` (when captured) @@ -75,6 +79,31 @@ This file is the canonical attribute and naming map for instrumentation in this - `gen_ai.tool.call.result` (when captured) - Prefer `gen_ai.input.messages` / `gen_ai.output.messages` over legacy names like `gen_ai.request.messages` / `gen_ai.response.text`. - Prefer `gen_ai.response.finish_reasons` over custom `app.ai.stop_reason`. + Normalize Pi's `toolUse` stop reason to `tool_use` at telemetry boundaries. + +### GenAI Custom Fallbacks + +Use `app.*` for bounded, non-content metadata with no current semantic key: + +- `app.conversation.privacy` (`public|private`) +- `app.ai.input.message_count` +- `app.ai.input.content_chars` +- `app.ai.input.roles` +- `app.ai.input.part_types` +- `app.ai.output.message_count` +- `app.ai.output.content_chars` +- `app.ai.output.roles` +- `app.ai.output.part_types` +- `app.ai.system_instructions.content_chars` +- `app.ai.tool.call.arguments.type` +- `app.ai.tool.call.arguments.size_chars` +- `app.ai.tool.call.arguments.keys` +- `app.ai.tool.call.result.type` +- `app.ai.tool.call.result.size_chars` +- `app.ai.tool.call.result.keys` + +Raw GenAI payload attributes are governed by `./data-redaction-policy.md`. +Private conversations must use metadata-only attributes. ## MCP Tool Calls diff --git a/specs/security-policy.md b/specs/security-policy.md index bb72b60b6..038b3abb5 100644 --- a/specs/security-policy.md +++ b/specs/security-policy.md @@ -103,6 +103,10 @@ This policy applies to: - Never log token values, private keys, or raw Authorization headers. - Log only safe metadata (skill, capability, target, outcome, expiry timestamp). +- Conversation, model, and tool payload redaction is governed by + `./data-redaction-policy.md`; private conversations must not expose raw + message text, thinking output, tool arguments, or tool results in logs, + traces, or dashboard APIs. ## Verification requirements diff --git a/specs/tracing.md b/specs/tracing.md index 725db030e..e6b2cd08f 100644 --- a/specs/tracing.md +++ b/specs/tracing.md @@ -3,7 +3,7 @@ ## Metadata - Created: 2026-02-25 -- Last Edited: 2026-05-28 +- Last Edited: 2026-05-30 ## Purpose @@ -57,13 +57,26 @@ Define the canonical tracing contract for span naming, boundaries, attributes, a - `gen_ai.conversation.id` on GenAI spans when the conversation/thread identifier is known. - `messaging.destination.name` for channel context when available. - `gen_ai.request.model` for model-level tracing. +- `gen_ai.output.type` for the requested response type when known. +- `gen_ai.request.stream` on streaming model calls. +- `server.address` for GenAI client/provider spans when known. +- `server.port` when `server.address` is set. - `gen_ai.response.finish_reasons` when available from provider responses. - `gen_ai.system_instructions` when provided separately from chat history and safely captured. - `gen_ai.input.messages` / `gen_ai.output.messages` when safely captured. +- `app.conversation.privacy` on GenAI spans. +- `app.ai.input.*` / `app.ai.output.*` bounded message shape metadata + (`message_count`, `content_chars`, `roles`, `part_types`) for transcript + reconstruction without raw content. - `gen_ai.usage.input_tokens` / `gen_ai.usage.output_tokens` when available from provider responses. - `gen_ai.usage.cache_read.input_tokens` / `gen_ai.usage.cache_creation.input_tokens` when available from provider responses. - `gen_ai.tool.description` when available on tool execution spans. - `gen_ai.tool.call.arguments` / `gen_ai.tool.call.result` on tool execution spans when captured. +- `app.ai.tool.call.arguments.*` / `app.ai.tool.call.result.*` bounded tool + payload metadata (`type`, `size_chars`, `keys`) on tool execution spans. +- Raw GenAI messages, system instructions, tool arguments, and tool results must + follow `./data-redaction-policy.md`; private conversations emit metadata-only + attributes. - Keep existing context keys aligned with `packages/junior/src/chat/logging.ts`. ### Error Attributes diff --git a/specs/trusted-plugin-dispatch.md b/specs/trusted-plugin-dispatch.md index a51164417..00c60fabf 100644 --- a/specs/trusted-plugin-dispatch.md +++ b/specs/trusted-plugin-dispatch.md @@ -151,7 +151,7 @@ Plugin-visible `Dispatch` is a projection, not the stored record. Dispatch ids should be deterministic from plugin name and idempotency key. Duplicate calls return the existing dispatch id and may re-fire the callback only when the record is incomplete. -Dispatch records use `THREAD_STATE_TTL_MS`. `ctx.agent.get(id)` is reconciliation, not permanent run history. +Dispatch records use Junior's one-week thread-state TTL. `ctx.agent.get(id)` is reconciliation, not permanent run history. ## Recovery