diff --git a/.craft.yml b/.craft.yml index 3b1b181b..80d164cf 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 febc4083..9c4ae8b0 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 bee8809e..f960a112 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 d8c57cf5..5b4ede4f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,6 +106,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/slack-agent-delivery.md` (Slack entry surfaces, reply delivery, continuation, files, images, and resume behavior contract) - `specs/slack-outbound-contract.md` (Slack outbound boundary, message/file/reaction safety rules, and markdown-to-`mrkdwn` ownership) @@ -124,5 +125,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 b7b40252..12b052df 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 d7b0bde8..bf65b42d 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/nitro.config.ts b/apps/example/nitro.config.ts index 4352440b..f5fa0cd7 100644 --- a/apps/example/nitro.config.ts +++ b/apps/example/nitro.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from "nitro"; +import { juniorDashboardNitro } from "@sentry/junior-dashboard/nitro"; import { juniorNitro } from "@sentry/junior/nitro"; import { examplePluginPackages } from "./plugin-packages"; @@ -10,6 +11,10 @@ export default defineConfig({ packages: examplePluginPackages, }, }), + juniorDashboardNitro({ + authRequired: false, + allowedGoogleDomains: ["sentry.io"], + }), ], routes: { "/**": { handler: "./server.ts" }, diff --git a/apps/example/package.json b/apps/example/package.json index de44cbc5..d610832c 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 aea853c6..335d9553 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 93a46d0f..ec0838dd 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 ef72be55..11ee5879 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 77109990..3ad1aa4f 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 ea90f36f..b88bec05 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 00000000..41428609 --- /dev/null +++ b/packages/docs/src/content/docs/operate/dashboard.md @@ -0,0 +1,119 @@ +--- +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 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` | Public minimal Junior 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 with tool calls/results. | +| `/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 0e0f9f68..7c09dd91 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 73f513fa..0520184e 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 ba4d5b33..7bcc57ef 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 966f85eb..48b7eda1 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 d47a0b28..af1e5452 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 00000000..cb07e3a7 --- /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 00000000..26bf5dd2 --- /dev/null +++ b/packages/junior-dashboard/src/app.ts @@ -0,0 +1,1988 @@ +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, + 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 (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(): Response { + 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( + ``, + { 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.set("dashboardSession", 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, + 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 00000000..61e2b948 --- /dev/null +++ b/packages/junior-dashboard/src/auth.ts @@ -0,0 +1,187 @@ +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; +} + +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: true, + 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 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 00000000..50b95bd9 --- /dev/null +++ b/packages/junior-dashboard/src/client.tsx @@ -0,0 +1,75 @@ +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 00000000..ea5451d3 --- /dev/null +++ b/packages/junior-dashboard/src/client/App.tsx @@ -0,0 +1,160 @@ +import { + Link, + Navigate, + NavLink, + Route, + Routes, + useParams, +} from "react-router"; + +import { useDashboardData } from "./api"; +import { LoadingView } from "./components"; +import { + conversationPath, + setDashboardTimeZone, + visualStatusForSession, +} from "./format"; +import { CommandCenter } from "./pages/CommandCenter"; +import { ConversationPage } from "./pages/ConversationPage"; +import { ConversationsPage } from "./pages/ConversationsPage"; + +/** 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("/api/auth/sign-out", { + credentials: "same-origin", + method: "POST", + }); + window.location.assign("/"); + } + + return ( +
+
+ +
Jr
+
+

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 00000000..f0e62429 --- /dev/null +++ b/packages/junior-dashboard/src/client/api.ts @@ -0,0 +1,68 @@ +import { QueryClient, useQuery } from "@tanstack/react-query"; + +import type { + ConversationDetailFeed, + DashboardConfig, + DashboardData, + Health, + Identity, + Plugin, + Runtime, + SessionFeed, + Skill, +} from "./types"; + +export const client = new QueryClient(); + +async function read(path: string): Promise { + const response = await fetch(path, { credentials: "same-origin" }); + if (!response.ok) throw new Error(`${path} returned ${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 => { + return read( + `/api/dashboard/conversations/${encodeURIComponent(conversationId!)}`, + ); + }, + refetchInterval: 5_000, + refetchIntervalInBackground: false, + retry: false, + }); +} diff --git a/packages/junior-dashboard/src/client/components.tsx b/packages/junior-dashboard/src/client/components.tsx new file mode 100644 index 00000000..a01228bd --- /dev/null +++ b/packages/junior-dashboard/src/client/components.tsx @@ -0,0 +1,550 @@ +import { useNavigate } from "react-router"; +import { + CartesianGrid, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; + +import { + conversationDisplayTitle, + conversationIdForSession, + conversationIdentityMeta, + conversationPath, + formatMs, + formatRelativeTime, + formatTime, + isFailedSession, + slackLocationLabel, + visualStatusForConversation, + visualStatusForSession, +} from "./format"; +import type { + Conversation, + DashboardData, + Session, + SessionFilter, + VisualStatus, +} from "./types"; + +/** Render the full-page loading treatment before the first dashboard payload lands. */ +export function LoadingView(props: { label: string }) { + return ( +
+
+
Jr
+
+
{props.label}
+
+
+
+
+ ); +} + +/** Render the shared active/idle visual indicator without duplicating status text. */ +export function ActivityIndicator(props: { + status: VisualStatus | undefined; + variant?: "compact" | "full"; +}) { + const activity = props.status ?? "idle"; + if (props.variant !== "full" && activity === "idle") { + return null; + } + return ( +
+ +
+ ); +} + +/** 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}
+
+ ); +} + +/** 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 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 dayTicks = Array.from({ length: 7 }, (_, index) => { + return rangeStartMs + index * 24 * 60 * 60 * 1000; + }); + const openPoint = (point: TurnDurationPoint) => { + navigate(conversationPath(conversationIdForSession(point.session))); + }; + + return ( +
+
+
+
7 Day Duration
+
Turns
+
+
+ Complete + Hung + Error +
+
+
+ + + + + bucketLabel(Number(value), props.timeZone) + } + tick={{ + fill: "var(--dim)", + fontFamily: "ui-monospace", + fontSize: 12, + }} + tickLine={false} + ticks={dayTicks} + type="number" + /> + formatMs(Number(value))} + tick={{ + fill: "var(--dim)", + fontFamily: "ui-monospace", + fontSize: 11, + }} + tickLine={false} + type="number" + /> + } + cursor={{ stroke: "rgba(64, 81, 107, 0.34)" }} + /> + + + + + +
+
+ {totals.total} turns · {totals.hung} hung · {totals.failed} errors +
+
+ ); +} + +type PlottedTurnStatus = Exclude; + +type TurnDurationPoint = { + completeDurationMs?: number; + durationMs: number; + failedDurationMs?: number; + hungDurationMs?: 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); + const point: TurnDurationPoint = { + durationMs, + session, + status, + tooltipLabel: new Date(startedAtMs).toLocaleString(undefined, { + timeZone, + }), + x: startedAtMs, + }; + if (status === "failed") { + point.failedDurationMs = durationMs; + } else if (status === "hung") { + point.hungDurationMs = durationMs; + } else { + point.completeDurationMs = durationMs; + } + return point; +} + +type DurationDotProps = { + cx?: number; + cy?: number; + payload?: TurnDurationPoint; +}; + +function durationDot( + fill: string, + radius: number, + onOpen: (point: TurnDurationPoint) => void, +) { + return (props: DurationDotProps) => { + if (props.cx == null || props.cy == null || !props.payload) { + return null; + } + + const point = props.payload; + return ( + onOpen(point)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onOpen(point); + } + }} + r={radius} + role="link" + stroke="rgba(12, 19, 32, 0.92)" + strokeWidth={1} + tabIndex={0} + /> + ); + }; +} + +function TurnDurationTooltip(props: { + active?: boolean; + payload?: Array<{ payload: TurnDurationPoint }>; +}) { + const point = props.payload?.[0]?.payload; + if (!props.active || !point) { + return null; + } + return ( +
+
{point.tooltipLabel}
+
{formatMs(point.durationMs)}
+
{point.status}
+
{point.session.title ?? point.session.id}
+
+ ); +} + +/** 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) => ( + + ))} +
+ ); +} + +/** 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) => ( + + ))} +
+ ); +} + +/** 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) => { + return ( + + ); + })} +
+ ); +} + +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 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 ConversationSummary(props: { conversation: Conversation }) { + return ( +
+
+ {conversationDisplayTitle(props.conversation)} +
+
+ {conversationIdentityMeta(props.conversation, props.conversation.id)} + {props.conversation.sentryConversationUrl ? ( + <> + {" · "} + event.stopPropagation()} + rel="noreferrer" + target="_blank" + > + View in Sentry + + + ) : null} +
+
+ ); +} + +function bucketLabel(timestampMs: number, timeZone: string): string { + return new Date(timestampMs).toLocaleDateString(undefined, { + timeZone, + weekday: "short", + }); +} + +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/format.ts b/packages/junior-dashboard/src/client/format.ts new file mode 100644 index 00000000..010a297a --- /dev/null +++ b/packages/junior-dashboard/src/client/format.ts @@ -0,0 +1,624 @@ +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 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.filter( + (conversation) => !isActiveConversation(conversation), + ); +} + +/** 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 00000000..3b8d856a --- /dev/null +++ b/packages/junior-dashboard/src/client/pages/CommandCenter.tsx @@ -0,0 +1,39 @@ +import { + CommandRail, + ConversationStack, + TurnDurationChart, +} from "../components"; +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 ( +
+ + +
+ + +
+
+
+
Recent
+
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 00000000..bc47bec5 --- /dev/null +++ b/packages/junior-dashboard/src/client/pages/ConversationPage.tsx @@ -0,0 +1,139 @@ +import { useParams } from "react-router"; + +import { useConversationData } from "../api"; +import { ActivityIndicator } from "../components"; +import { + buildConversations, + conversationDisplayTitle, + formatConversationDuration, + formatRelativeTime, + formatTime, + slackLocationLabel, + turnMessageCount, + turnToolCallCount, + visualStatusForConversation, +} from "../format"; +import { Transcript, TranscriptLoading } from "../transcript"; +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.join(" · ")} +
+ ); +} 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 00000000..1262b502 --- /dev/null +++ b/packages/junior-dashboard/src/client/pages/ConversationsPage.tsx @@ -0,0 +1,53 @@ +import { useSearchParams } from "react-router"; + +import { ConversationList, FilterTabs } from "../components"; +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_checkpoints" + ? `${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 ( +
+
+
+
+
+
Flight Recorder
+
Conversations
+
{feedMeta}
+
+ +
+
+ +
+
+
+
+ ); +} diff --git a/packages/junior-dashboard/src/client/transcript.tsx b/packages/junior-dashboard/src/client/transcript.tsx new file mode 100644 index 00000000..457a7a95 --- /dev/null +++ b/packages/junior-dashboard/src/client/transcript.tsx @@ -0,0 +1,994 @@ +import { useState, type ReactNode } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { codeToHtml, type BundledLanguage } from "shiki/bundle/web"; + +import { + canRenderStructuredMarkup, + detectLanguage, + formatBytes, + formatMessageOffset, + formatMessageTimestamp, + formatMs, + formatUsage, + parseMarkdownBlocks, + parseMarkupNodes, + requesterLabel, + stringifyPartValue, + turnMessageCount, + turnToolCallCount, + unavailableTranscriptLabel, + visualStatusForSession, +} from "./format"; +import { ActivityIndicator } from "./components"; +import type { + CodeBlock, + ConversationTurn, + MarkupNode, + TranscriptMessage, + TranscriptPart, +} from "./types"; + +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; +}; + +type TranscriptViewMode = "raw" | "rich"; + +/** Render a transcript-shaped loading state for route transitions. */ +export function TranscriptLoading() { + return ( +
+
+
+
+
+ ); +} + +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; +} + +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; +} + +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; +} + +/** 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 ( +
+ + {hasRedactedTurns ? : null} + {props.turns.map((turn) => ( + + ))} +
+ ); +} + +function TranscriptPrivacyNotice() { + return ( +
+ Transcript hidden because this conversation is not public. +
+ ); +} + +function TurnTranscript(props: { + turn: ConversationTurn; + view: TranscriptViewMode; +}) { + return ( +
+ + +
+ ); +} + +function TurnHeader(props: { turn: ConversationTurn }) { + return ( +
+
+
+ Turn {props.turn.traceId ?? "trace unavailable"} +
+
{turnActorLabel(props.turn)}
+
+ {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, + redactedMessageSummary(props.message), + ].filter(isString); + + return ( +
+
+ {props.message.role} + {meta.map((value) => ( + + {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: { label: string; meta?: string }) { + return ( +
+ {props.label} + {props.meta ? ( + {props.meta} + ) : null} +
+ ); +} + +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 redactedMessageSummary(message: TranscriptMessage): string { + return message.parts.length > 0 ? "redacted" : "content unavailable"; +} + +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 TranscriptToolbar(props: { + onChange(value: TranscriptViewMode): void; + value: TranscriptViewMode; +}) { + return ( +
+ +
+ ); +} + +function TranscriptViewToggle(props: { + onChange(value: TranscriptViewMode): void; + value: TranscriptViewMode; +}) { + const options: TranscriptViewMode[] = ["rich", "raw"]; + return ( +
+ {options.map((option) => ( + + ))} +
+ ); +} + +function turnActorLabel(turn: ConversationTurn): string { + return requesterLabel(turn.requesterIdentity, turn.requester) ?? "unknown"; +} + +function turnMeta(turn: ConversationTurn): string[] { + return [ + formatMs(turn.cumulativeDurationMs), + formatUsage(turn.cumulativeUsage), + `${turnMessageCount(turn)} messages`, + `${turnToolCallCount(turn)} tool calls`, + ].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(); + }} + > +
+ {props.message.role} + + {formatMessageTimestamp(props.message.timestamp)} + + {offset ? {offset} : null} +
+ {props.view === "raw" ? ( + + ) : ( +
+ {renderedParts.map((part, index) => { + const firstChildIndex = seenRenderedChildren; + seenRenderedChildren += countRenderedTranscriptChildren(part); + return ( + + ); + })} +
+ )} +
+ ); +} + +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 countStructuredBlockChildren(block: CodeBlock): number { + if (!canRenderStructuredMarkup(block.language)) return 1; + const rootCount = parseMarkupNodes(block.code, block.language).length; + return rootCount > 0 ? rootCount : 1; +} + +function countTextRenderedChildren(text: string): number { + return parseMarkdownBlocks(text).reduce((count, block) => { + return count + countStructuredBlockChildren(block); + }, 0); +} + +function countRenderedTranscriptChildren(part: RenderedTranscriptPart): number { + if (part.kind === "tool") return 1; + if (part.part.type === "text") { + return countTextRenderedChildren(part.part.text ?? ""); + } + return 1; +} + +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 TranscriptToolView(props: { + call?: TranscriptPart; + result?: TranscriptPart; + resultTimestamp?: number; + timestamp?: number; + view?: TranscriptViewMode; +}) { + 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 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} +
+ ); +} + +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 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 ( + + ); + })} +
+ ); +} + +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 ( +
+ + +
+ {children.map((child, index) => ( + + ))} +
+
+ </ + {props.node.tagName} + > +
+
+ ); +} + +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; +} + +function isPreviewableValue(value: unknown): boolean { + if (value == null || value === "") return false; + if (typeof value === "string") return value.trim().length > 0; + return true; +} + +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/types.ts b/packages/junior-dashboard/src/client/types.ts new file mode 100644 index 00000000..98f5794a --- /dev/null +++ b/packages/junior-dashboard/src/client/types.ts @@ -0,0 +1,152 @@ +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; + 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 00000000..e1bc3db0 --- /dev/null +++ b/packages/junior-dashboard/src/config.ts @@ -0,0 +1,65 @@ +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("[")) { + const parsed: unknown = JSON.parse(value); + 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 00000000..f9089979 --- /dev/null +++ b/packages/junior-dashboard/src/handler.ts @@ -0,0 +1,21 @@ +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; + }); + 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 00000000..8540f12f --- /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 00000000..8e2ec5f8 --- /dev/null +++ b/packages/junior-dashboard/src/tailwind.css @@ -0,0 +1,4 @@ +@import "tailwindcss/utilities"; + +@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 00000000..30d5535e --- /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/dashboard-routes.test.ts b/packages/junior-dashboard/tests/dashboard-routes.test.ts new file mode 100644 index 00000000..4350cd62 --- /dev/null +++ b/packages/junior-dashboard/tests/dashboard-routes.test.ts @@ -0,0 +1,675 @@ +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 { 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_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_checkpoints", + 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({ + user: { + email: "person@sentry.io", + emailVerified: true, + hostedDomain: "sentry.io", + }, + }); + + 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", + }, + }); + + 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_checkpoints", + }); + }); + + 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, + 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("allows explicitly configured email exceptions", async () => { + const app = dashboard({ + user: { + email: "admin@example.com", + emailVerified: true, + hostedDomain: "example.com", + }, + }); + + const response = await app.fetch( + new Request("http://localhost/api/dashboard/info"), + ); + + expect(response.status).toBe(200); + }); + + it("does not intercept Junior runtime routes with route-scoped dispatch", async () => { + const dashboardApp = dashboard(null); + const juniorApp = await createApp(); + const fetch = (request: Request) => { + const pathname = new URL(request.url).pathname; + if ( + pathname === "/" || + pathname === "/conversations" || + pathname.startsWith("/conversations/") || + pathname === "/sessions" || + pathname.startsWith("/sessions/") || + pathname.startsWith("/api/dashboard/") || + pathname.startsWith("/api/auth/") + ) { + return dashboardApp.fetch(request); + } + return juniorApp.fetch(request); + }; + + const health = await fetch(new Request("http://localhost/health")); + expect(health.status).toBe(200); + expect(await health.json()).toMatchObject({ + status: "ok", + service: "junior", + }); + + const oldInfo = await fetch(new Request("http://localhost/api/info")); + expect(oldInfo.status).toBe(404); + }); + + it("registers dashboard Nitro routes before an existing catch-all route", () => { + const nitro = { + options: { + routes: { + "/**": { handler: "./server.ts" }, + }, + virtual: {} as Record, + }, + }; + + juniorDashboardNitro({ + allowedGoogleDomains: ["sentry.io"], + trustedOrigins: ["https://junior.example.com"], + }).nitro.setup(nitro); + + expect(Object.keys(nitro.options.routes).slice(0, 8)).toEqual([ + "/", + "/conversations", + "/conversations/**", + "/sessions", + "/sessions/**", + "/api/dashboard/**", + "/api/auth", + "/api/auth/**", + ]); + expect(nitro.options.routes["/**"]).toEqual({ handler: "./server.ts" }); + expect(nitro.options.virtual["#junior-dashboard/config"]).toContain( + "sentry.io", + ); + expect(nitro.options.virtual["#junior-dashboard/handler"]).toContain( + "sentry.io", + ); + }); + + it("resolves auth policy from env when Nitro virtual config is unavailable", async () => { + process.env.JUNIOR_DASHBOARD_GOOGLE_DOMAINS = "sentry.io, example.com"; + process.env.JUNIOR_DASHBOARD_ALLOWED_EMAILS = JSON.stringify([ + "admin@example.com", + ]); + process.env.JUNIOR_DASHBOARD_TRUSTED_ORIGINS = "https://junior.example.com"; + + await expect(resolveDashboardConfig()).resolves.toEqual({ + authRequired: true, + allowedGoogleDomains: ["sentry.io", "example.com"], + allowedEmails: ["admin@example.com"], + trustedOrigins: ["https://junior.example.com"], + }); + }); + + it("uses JUNIOR_SECRET as the default Better Auth secret", () => { + process.env.JUNIOR_SECRET = "junior-secret"; + + expect(() => + createDashboardAuth({ + authPath: "/api/auth", + trustedOrigins: [], + }), + ).toThrow("GOOGLE_CLIENT_ID is required for Junior dashboard auth"); + }); + + it("does not require BETTER_AUTH_URL in local development", () => { + process.env.JUNIOR_SECRET = "junior-secret"; + process.env.GOOGLE_CLIENT_ID = "google-client-id"; + process.env.GOOGLE_CLIENT_SECRET = "google-client-secret"; + + expect(() => + createDashboardAuth({ + authPath: "/api/auth", + trustedOrigins: [], + }), + ).not.toThrow(); + }); + + it("derives the Better Auth base URL from Junior deployment env", () => { + process.env.JUNIOR_SECRET = "junior-secret"; + process.env.GOOGLE_CLIENT_ID = "google-client-id"; + process.env.GOOGLE_CLIENT_SECRET = "google-client-secret"; + process.env.JUNIOR_BASE_URL = "https://junior.example.com"; + + expect(() => + createDashboardAuth({ + authPath: "/api/auth", + trustedOrigins: [], + }), + ).not.toThrow(); + }); + + it("preserves the Better Auth OAuth state cookie during Google sign-in", async () => { + const auth = createDashboardAuth({ + authPath: "/api/auth", + googleClientId: "google-client-id", + googleClientSecret: "google-client-secret", + secret: "0123456789abcdef0123456789abcdef", + trustedOrigins: [], + }); + + const response = await auth.signInWithGoogle( + new Request("http://localhost/api/dashboard/login"), + "http://localhost/", + ); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toContain("accounts.google.com"); + expect(response.headers.get("set-cookie")).toContain("oauth_state"); + }); +}); diff --git a/packages/junior-dashboard/tsconfig.build.json b/packages/junior-dashboard/tsconfig.build.json new file mode 100644 index 00000000..4a55b3e4 --- /dev/null +++ b/packages/junior-dashboard/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "incremental": false, + "noEmit": false, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts", "src/**/*.tsx"] +} diff --git a/packages/junior-dashboard/tsconfig.json b/packages/junior-dashboard/tsconfig.json new file mode 100644 index 00000000..036c3934 --- /dev/null +++ b/packages/junior-dashboard/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "jsx": "react-jsx", + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "allowJs": false, + "noEmit": true, + "types": ["node", "vitest/globals"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"] +} diff --git a/packages/junior-dashboard/tsup.config.ts b/packages/junior-dashboard/tsup.config.ts new file mode 100644 index 00000000..b21ce853 --- /dev/null +++ b/packages/junior-dashboard/tsup.config.ts @@ -0,0 +1,34 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { + app: "src/app.ts", + client: "src/client.tsx", + handler: "src/handler.ts", + nitro: "src/nitro.ts", + }, + format: "esm", + tsconfig: "tsconfig.build.json", + dts: false, + outDir: "dist", + clean: true, + splitting: false, + define: { + "process.env.NODE_ENV": JSON.stringify("production"), + }, + external: [ + "#junior-dashboard/config", + "@sentry/junior", + "better-auth", + "hono", + "nitro", + ], + noExternal: [ + "@tanstack/react-query", + "react", + "react-dom", + "react-router", + "recharts", + "shiki", + ], +}); diff --git a/packages/junior-dashboard/vitest.config.ts b/packages/junior-dashboard/vitest.config.ts new file mode 100644 index 00000000..4e7916b8 --- /dev/null +++ b/packages/junior-dashboard/vitest.config.ts @@ -0,0 +1,24 @@ +import path from "node:path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: [ + { + find: /^@sentry\/junior$/, + replacement: path.resolve(import.meta.dirname, "../junior/src/app.ts"), + }, + { + find: /^@sentry\/junior\/reporting$/, + replacement: path.resolve( + import.meta.dirname, + "../junior/src/reporting.ts", + ), + }, + { + find: /^@\//, + replacement: `${path.resolve(import.meta.dirname, "../junior/src")}/`, + }, + ], + }, +}); diff --git a/packages/junior/package.json b/packages/junior/package.json index e9d62e21..c758d324 100644 --- a/packages/junior/package.json +++ b/packages/junior/package.json @@ -6,6 +6,7 @@ "access": "public" }, "type": "module", + "types": "./dist/app.d.ts", "repository": { "type": "git", "url": "git+https://github.com/getsentry/junior.git", @@ -16,10 +17,26 @@ "junior": "bin/junior.mjs" }, "exports": { - ".": "./dist/app.js", - "./instrumentation": "./dist/instrumentation.js", - "./nitro": "./dist/nitro.js", - "./vercel": "./dist/vercel.js" + ".": { + "types": "./dist/app.d.ts", + "default": "./dist/app.js" + }, + "./instrumentation": { + "types": "./dist/instrumentation.d.ts", + "default": "./dist/instrumentation.js" + }, + "./nitro": { + "types": "./dist/nitro.d.ts", + "default": "./dist/nitro.js" + }, + "./reporting": { + "types": "./dist/reporting.d.ts", + "default": "./dist/reporting.js" + }, + "./vercel": { + "types": "./dist/vercel.d.ts", + "default": "./dist/vercel.js" + } }, "files": [ "dist", @@ -50,6 +67,7 @@ "@modelcontextprotocol/sdk": "1.29.0", "@sinclair/typebox": "^0.34.49", "@slack/web-api": "^7.16.0", + "@sentry/node": "catalog:", "@vercel/functions": "^3.6.0", "@vercel/sandbox": "2.0.0", "ai": "^6.0.190", @@ -62,12 +80,8 @@ "yaml": "^2.9.0", "zod": "^4.4.3" }, - "peerDependencies": { - "@sentry/node": ">=10.0.0" - }, "devDependencies": { "@sentry/junior-scheduler": "workspace:*", - "@sentry/node": "10.53.1", "@types/node": "^25.9.1", "dependency-cruiser": "^17.4.0", "msw": "^2.14.6", diff --git a/packages/junior/src/app.ts b/packages/junior/src/app.ts index 088824e2..3e562899 100644 --- a/packages/junior/src/app.ts +++ b/packages/junior/src/app.ts @@ -14,8 +14,6 @@ import { } from "@/chat/plugins/agent-hooks"; import type { PluginConfig } from "@/chat/plugins/types"; import type { JuniorPlugin } from "@sentry/junior-plugin-api"; -import { GET as diagnosticsGET } from "@/handlers/diagnostics"; -import { GET as dashboardGET } from "@/handlers/diagnostics-dashboard"; import { GET as healthGET } from "@/handlers/health"; import { POST as agentDispatchPOST } from "@/handlers/agent-dispatch"; import { GET as heartbeatGET } from "@/handlers/heartbeat"; @@ -224,13 +222,9 @@ export async function createApp(options?: JuniorAppOptions): Promise { await next(); }); - app.get("/", () => dashboardGET()); + app.get("/", () => healthGET()); app.get("/health", () => healthGET()); - // Public route — returns plugin/skill names, cwd, and DESCRIPTION.md text. - // No credentials or PII. Understand what this discloses before deploying. - app.get("/api/info", () => diagnosticsGET()); - // MCP callback must be registered before the generic OAuth callback // because Hono matches routes top-down and `:provider` would swallow `mcp/`. app.get("/api/oauth/callback/mcp/:provider", (c) => { diff --git a/packages/junior/src/chat/agent-dispatch/store.ts b/packages/junior/src/chat/agent-dispatch/store.ts index 1d58ee35..43e38cce 100644 --- a/packages/junior/src/chat/agent-dispatch/store.ts +++ b/packages/junior/src/chat/agent-dispatch/store.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; -import { THREAD_STATE_TTL_MS } from "chat"; import type { Lock, StateAdapter } from "chat"; import { getStateAdapter } from "@/chat/state/adapter"; +import { JUNIOR_THREAD_STATE_TTL_MS } from "@/chat/state/ttl"; import type { DispatchCreateResult, DispatchOptions, @@ -151,7 +151,7 @@ async function syncIncompleteDispatchIndex( await state.set( incompleteDispatchIndexKey(), next.slice(-DISPATCH_INDEX_MAX_LENGTH), - THREAD_STATE_TTL_MS, + JUNIOR_THREAD_STATE_TTL_MS, ); }); } @@ -163,7 +163,7 @@ async function putRecord( await state.set( getDispatchStorageKey(record.id), record, - THREAD_STATE_TTL_MS, + JUNIOR_THREAD_STATE_TTL_MS, ); await syncIncompleteDispatchIndex(state, record); } diff --git a/packages/junior/src/chat/conversation-privacy.ts b/packages/junior/src/chat/conversation-privacy.ts new file mode 100644 index 00000000..b60457db --- /dev/null +++ b/packages/junior/src/chat/conversation-privacy.ts @@ -0,0 +1,203 @@ +import { parseSlackThreadId } from "@/chat/slack/context"; + +export type ConversationPrivacy = "public" | "private"; +type TraceAttributeValue = string | number | boolean | string[]; + +function conversationPrivacyFromChannelId( + channelId: string | undefined, +): ConversationPrivacy | undefined { + const normalized = channelId?.trim(); + if (!normalized) return undefined; + return normalized.startsWith("C") ? "public" : "private"; +} + +function conversationPrivacyFromConversationId( + conversationId: string | undefined, +): ConversationPrivacy | undefined { + if (!conversationId?.trim()) return undefined; + const slackThread = parseSlackThreadId(conversationId); + if (slackThread) { + return conversationPrivacyFromChannelId(slackThread.channelId); + } + return "private"; +} + +/** Resolve whether a conversation may expose raw payloads based on known Slack identity. */ +export function resolveConversationPrivacy(input: { + channelId?: string; + conversationId?: string; +}): ConversationPrivacy | undefined { + return ( + conversationPrivacyFromChannelId(input.channelId) ?? + conversationPrivacyFromConversationId(input.conversationId) + ); +} + +/** Gate raw transcript/tool payload exposure to conversations known to be public. */ +export function canExposeConversationPayload(input: { + channelId?: string; + conversationId?: string; +}): boolean { + return resolveConversationPrivacy(input) === "public"; +} + +function contentMetadata(content: unknown): unknown { + if (typeof content === "string") { + return [{ type: "text", chars: content.length }]; + } + if (!Array.isArray(content)) { + return { type: typeof content }; + } + return content.map((part) => { + if (!part || typeof part !== "object") { + return { type: typeof part }; + } + const record = part as Record; + const type = typeof record.type === "string" ? record.type : "unknown"; + return { + type, + ...(typeof record.text === "string" ? { chars: record.text.length } : {}), + ...(typeof record.mimeType === "string" + ? { mimeType: record.mimeType } + : {}), + ...(typeof record.mediaType === "string" + ? { mediaType: record.mediaType } + : {}), + ...(typeof record.data === "string" + ? { dataChars: record.data.length } + : {}), + }; + }); +} + +/** Convert a GenAI message into safe metadata for private trace contexts. */ +export function toGenAiMessageMetadata( + message: unknown, +): Record { + const record = + message && typeof message === "object" + ? (message as Record) + : {}; + return { + role: record.role, + content: contentMetadata(record.content), + }; +} + +/** Convert raw text into size-only metadata for private trace contexts. */ +export function toGenAiTextMetadata(text: string): Record { + return { type: "text", chars: text.length }; +} + +function payloadType(payload: unknown): string { + return Array.isArray(payload) ? "array" : typeof payload; +} + +function serializedLength(payload: unknown): number { + const serialized = + typeof payload === "string" ? payload : JSON.stringify(payload); + return serialized?.length ?? 0; +} + +/** Convert an arbitrary payload into safe structured metadata for trace data fields. */ +export function toGenAiPayloadMetadata( + payload: unknown, +): Record { + const base = { + type: payloadType(payload), + chars: serializedLength(payload), + }; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return base; + } + return { + ...base, + keys: Object.keys(payload as Record), + }; +} + +/** Convert an arbitrary payload into safe flattened trace attributes. */ +export function toGenAiPayloadTraceAttributes( + prefix: string, + payload: unknown, +): Record { + const attributes: Record = { + [`${prefix}.type`]: payloadType(payload), + [`${prefix}.size_chars`]: serializedLength(payload), + }; + if (payload && typeof payload === "object" && !Array.isArray(payload)) { + const keys = Object.keys(payload as Record); + if (keys.length > 0) { + attributes[`${prefix}.keys`] = keys; + } + } + return attributes; +} + +function summarizeContent(content: unknown): { + chars: number; + partTypes: string[]; +} { + if (typeof content === "string") { + return { chars: content.length, partTypes: ["text"] }; + } + if (!Array.isArray(content)) { + return { + chars: serializedLength(content), + partTypes: [payloadType(content)], + }; + } + + let chars = 0; + const partTypes = new Set(); + for (const part of content) { + if (!part || typeof part !== "object") { + chars += serializedLength(part); + partTypes.add(payloadType(part)); + continue; + } + const record = part as Record; + const type = typeof record.type === "string" ? record.type : "unknown"; + partTypes.add(type); + if (typeof record.text === "string") { + chars += record.text.length; + } else if (typeof record.data === "string") { + chars += record.data.length; + } else { + chars += serializedLength(part); + } + } + return { chars, partTypes: [...partTypes] }; +} + +/** Summarize a message list without exposing raw message content. */ +export function toGenAiMessagesTraceAttributes( + prefix: string, + messages: unknown[], +): Record { + let contentChars = 0; + const roles = new Set(); + const partTypes = new Set(); + for (const message of messages) { + if (!message || typeof message !== "object") { + contentChars += serializedLength(message); + continue; + } + const record = message as Record; + if (typeof record.role === "string") { + roles.add(record.role); + } + const summary = summarizeContent(record.content); + contentChars += summary.chars; + for (const partType of summary.partTypes) { + partTypes.add(partType); + } + } + + return { + [`${prefix}.message_count`]: messages.length, + [`${prefix}.content_chars`]: contentChars, + ...(roles.size > 0 ? { [`${prefix}.roles`]: [...roles] } : {}), + ...(partTypes.size > 0 ? { [`${prefix}.part_types`]: [...partTypes] } : {}), + }; +} diff --git a/packages/junior/src/chat/logging.ts b/packages/junior/src/chat/logging.ts index 80d66686..fb6f1b75 100644 --- a/packages/junior/src/chat/logging.ts +++ b/packages/junior/src/chat/logging.ts @@ -99,6 +99,23 @@ const LEGACY_KEY_MAP: Record = { inferredScore: "app.skill.score", }; +/** Normalize runtime finish reasons to the telemetry spelling we emit. */ +export function normalizeGenAiFinishReason(reason: string): string { + return reason === "toolUse" ? "tool_use" : reason; +} + +function normalizeGenAiFinishReasons(value: unknown): unknown { + if (typeof value === "string" && value.trim()) { + return [normalizeGenAiFinishReason(value)]; + } + if (!Array.isArray(value)) { + return value; + } + return value.map((reason) => + typeof reason === "string" ? normalizeGenAiFinishReason(reason) : reason, + ); +} + const contextStorage = new AsyncLocalStorage(); const logRecordSinks = new Set<(record: EmittedLogRecord) => void>(); type ConsoleTextStyle = Parameters[0]; @@ -434,10 +451,8 @@ function mergeAttributes( for (const [rawKey, rawValue] of Object.entries(map)) { const key = normalizeAttributeKey(rawKey); const value = sanitizeValue( - key === "gen_ai.response.finish_reasons" && - typeof rawValue === "string" && - rawValue.trim() - ? [rawValue] + key === "gen_ai.response.finish_reasons" + ? normalizeGenAiFinishReasons(rawValue) : rawValue, ); if (value !== undefined) { @@ -1444,9 +1459,14 @@ function normalizeSpanAttributes( ): Record { const normalized: Record = {}; for (const [rawKey, value] of Object.entries(attributes)) { - const normalizedValue = toSpanAttributeValue(value); + const key = normalizeAttributeKey(rawKey); + const normalizedValue = toSpanAttributeValue( + key === "gen_ai.response.finish_reasons" + ? normalizeGenAiFinishReasons(value) + : value, + ); if (normalizedValue !== undefined) { - normalized[normalizeAttributeKey(rawKey)] = normalizedValue; + normalized[key] = normalizedValue; } } return normalized; diff --git a/packages/junior/src/chat/pi/client.ts b/packages/junior/src/chat/pi/client.ts index 76aa5414..ecd8938a 100644 --- a/packages/junior/src/chat/pi/client.ts +++ b/packages/junior/src/chat/pi/client.ts @@ -31,11 +31,20 @@ import { logWarn, setSpanAttributes, withSpan, + type LogContext, } from "@/chat/logging"; import { toOptionalTrimmed } from "@/chat/optional-string"; +import { + resolveConversationPrivacy, + toGenAiMessageMetadata, + toGenAiMessagesTraceAttributes, + toGenAiTextMetadata, +} from "@/chat/conversation-privacy"; const GATEWAY_PROVIDER = "vercel-ai-gateway" as const; export const GEN_AI_PROVIDER_NAME = GATEWAY_PROVIDER; +export const GEN_AI_SERVER_ADDRESS = "ai-gateway.vercel.sh"; +export const GEN_AI_SERVER_PORT = 443; const GEN_AI_OPERATION_CHAT = "chat" as const; export const MISSING_GATEWAY_CREDENTIALS_ERROR = "Missing AI gateway credentials (AI_GATEWAY_API_KEY or VERCEL_OIDC_TOKEN)"; @@ -71,43 +80,6 @@ function extractText(message: { .trim(); } -function contentMetadata(content: unknown): unknown { - if (typeof content === "string") { - return [{ type: "text", chars: content.length }]; - } - if (!Array.isArray(content)) { - return { type: typeof content }; - } - return content.map((part) => { - if (!part || typeof part !== "object") { - return { type: typeof part }; - } - const record = part as Record; - const type = typeof record.type === "string" ? record.type : "unknown"; - return { - type, - ...(typeof record.text === "string" ? { chars: record.text.length } : {}), - ...(typeof record.mimeType === "string" - ? { mimeType: record.mimeType } - : {}), - ...(typeof record.mediaType === "string" - ? { mediaType: record.mediaType } - : {}), - ...(typeof record.data === "string" - ? { dataChars: record.data.length } - : {}), - }; - }); -} - -function toMessageMetadata(message: Message): Record { - const record = message as unknown as Record; - return { - role: record.role, - content: contentMetadata(record.content), - }; -} - function parseJsonCandidate(text: string): unknown { const trimmed = text.trim(); if (!trimmed) return undefined; @@ -200,16 +172,31 @@ export async function completeText(params: { }) { const model = resolveGatewayModel(params.modelId); const apiKey = getPiGatewayApiKeyOverride(); - const messageAttributeMode = params.messageAttributeMode ?? "content"; + const privacy = resolveConversationPrivacy({ + channelId: + typeof params.metadata?.channelId === "string" + ? params.metadata.channelId + : undefined, + conversationId: + typeof params.metadata?.conversationId === "string" + ? params.metadata.conversationId + : typeof params.metadata?.threadId === "string" + ? params.metadata.threadId + : undefined, + }); + const effectivePrivacy = privacy ?? "private"; + const messageAttributeMode = + params.messageAttributeMode ?? + (effectivePrivacy === "public" ? "content" : "metadata"); const requestMessagesAttribute = serializeGenAiAttribute( messageAttributeMode === "metadata" - ? params.messages.map(toMessageMetadata) + ? params.messages.map(toGenAiMessageMetadata) : params.messages, ); const systemInstructionsAttribute = params.system ? serializeGenAiAttribute( messageAttributeMode === "metadata" - ? [{ type: "text", chars: params.system.length }] + ? [toGenAiTextMetadata(params.system)] : [{ type: "text", content: params.system }], ) : undefined; @@ -217,12 +204,20 @@ export async function completeText(params: { "gen_ai.provider.name": GEN_AI_PROVIDER_NAME, "gen_ai.operation.name": GEN_AI_OPERATION_CHAT, "gen_ai.request.model": params.modelId, + "gen_ai.output.type": "text", + "server.address": GEN_AI_SERVER_ADDRESS, + "server.port": GEN_AI_SERVER_PORT, + "app.conversation.privacy": effectivePrivacy, ...(params.thinkingLevel ? { "app.ai.reasoning_effort": params.thinkingLevel } : {}), }; const startAttributes = { ...baseAttributes, + ...toGenAiMessagesTraceAttributes("app.ai.input", params.messages), + ...(params.system + ? { "app.ai.system_instructions.content_chars": params.system.length } + : {}), ...(systemInstructionsAttribute ? { "gen_ai.system_instructions": systemInstructionsAttribute } : {}), @@ -232,9 +227,9 @@ export async function completeText(params: { "app.ai.auth_mode": apiKey ? "oidc" : "api_key", }; return withSpan( - "ai.chat_completion", + `${GEN_AI_OPERATION_CHAT} ${params.modelId}`, "gen_ai.chat", - { modelId: params.modelId }, + logContextFromMetadata(params.modelId, params.metadata), async () => { const message = await completeSimple( model, @@ -257,9 +252,7 @@ export async function completeText(params: { ? [ { role: "assistant", - content: outputText - ? [{ type: "text", chars: outputText.length }] - : [], + content: outputText ? [toGenAiTextMetadata(outputText)] : [], }, ] : [ @@ -272,6 +265,12 @@ export async function completeText(params: { const usageAttributes = extractGenAiUsageAttributes(message); const endAttributes = { ...baseAttributes, + ...toGenAiMessagesTraceAttributes("app.ai.output", [ + { + role: "assistant", + content: outputText ? [{ type: "text", text: outputText }] : [], + }, + ]), ...(outputMessagesAttribute ? { "gen_ai.output.messages": outputMessagesAttribute } : {}), @@ -305,6 +304,32 @@ export async function completeText(params: { ); } +function logContextFromMetadata( + modelId: string, + metadata: Record | undefined, +): LogContext { + const conversationId = + typeof metadata?.conversationId === "string" + ? metadata.conversationId + : typeof metadata?.threadId === "string" + ? metadata.threadId + : undefined; + const slackThreadId = + typeof metadata?.threadId === "string" ? metadata.threadId : undefined; + const slackChannelId = + typeof metadata?.channelId === "string" ? metadata.channelId : undefined; + const runId = + typeof metadata?.runId === "string" ? metadata.runId : undefined; + + return { + modelId, + ...(conversationId ? { conversationId } : {}), + ...(slackThreadId ? { slackThreadId } : {}), + ...(slackChannelId ? { slackChannelId } : {}), + ...(runId ? { runId } : {}), + }; +} + /** Execute a schema-constrained completion using the traced text path above. */ export async function completeObject(params: { modelId: string; diff --git a/packages/junior/src/chat/pi/traced-stream.ts b/packages/junior/src/chat/pi/traced-stream.ts index 0214fcb3..a7deb75a 100644 --- a/packages/junior/src/chat/pi/traced-stream.ts +++ b/packages/junior/src/chat/pi/traced-stream.ts @@ -10,9 +10,29 @@ import * as Sentry from "@/chat/sentry"; import { extractGenAiUsageAttributes, getLogContextAttributes, + normalizeGenAiFinishReason, serializeGenAiAttribute, } from "@/chat/logging"; -import { GEN_AI_PROVIDER_NAME } from "@/chat/pi/client"; +import { + GEN_AI_PROVIDER_NAME, + GEN_AI_SERVER_ADDRESS, + GEN_AI_SERVER_PORT, +} from "@/chat/pi/client"; +import { + type ConversationPrivacy, + toGenAiMessageMetadata, + toGenAiMessagesTraceAttributes, + toGenAiTextMetadata, +} from "@/chat/conversation-privacy"; + +type GenAiAttributeMode = "content" | "metadata"; +type TraceAttributeValue = string | number | boolean | string[]; + +function attributeModeForPrivacy( + conversationPrivacy: ConversationPrivacy | undefined, +): GenAiAttributeMode { + return conversationPrivacy === "public" ? "content" : "metadata"; +} // Compose only the OTel GenAI attributes that are knowable at span start // (request-shape + system instructions). End-of-call attributes such as @@ -20,40 +40,60 @@ import { GEN_AI_PROVIDER_NAME } from "@/chat/pi/client"; function buildChatStartAttributes( model: Model, context: Context, -): Record { - const attributes: Record = { + mode: GenAiAttributeMode, + conversationPrivacy: ConversationPrivacy | undefined, +): Record { + const attributes: Record = { "gen_ai.operation.name": "chat", "gen_ai.provider.name": GEN_AI_PROVIDER_NAME, "gen_ai.request.model": model.id, + "gen_ai.request.stream": true, + "gen_ai.output.type": "text", + "server.address": GEN_AI_SERVER_ADDRESS, + "server.port": GEN_AI_SERVER_PORT, + ...(conversationPrivacy + ? { "app.conversation.privacy": conversationPrivacy } + : {}), + ...toGenAiMessagesTraceAttributes("app.ai.input", context.messages), }; - const inputMessages = serializeGenAiAttribute(context.messages); + const inputMessages = serializeGenAiAttribute( + mode === "metadata" + ? context.messages.map(toGenAiMessageMetadata) + : context.messages, + ); if (inputMessages) { attributes["gen_ai.input.messages"] = inputMessages; } if (context.systemPrompt) { const systemInstructions = serializeGenAiAttribute([ - { type: "text", content: context.systemPrompt }, + mode === "metadata" + ? toGenAiTextMetadata(context.systemPrompt) + : { type: "text", content: context.systemPrompt }, ]); if (systemInstructions) { attributes["gen_ai.system_instructions"] = systemInstructions; } + attributes["app.ai.system_instructions.content_chars"] = + context.systemPrompt.length; } return attributes; } // Composes post-stream attributes for the chat span. -// Known gap: `gen_ai.response.finish_reasons` emits pi-ai's raw StopReason -// values (e.g. "toolUse", "aborted") instead of the OTel canonical set -// ("tool_use", "max_tokens"). Tracked separately, out of scope here. function buildChatEndAttributes( message: AssistantMessage, -): Record { - const attributes: Record = {}; + mode: GenAiAttributeMode, +): Record { + const attributes: Record = { + ...toGenAiMessagesTraceAttributes("app.ai.output", [message]), + }; - const outputMessages = serializeGenAiAttribute([message]); + const outputMessages = serializeGenAiAttribute( + mode === "metadata" ? [toGenAiMessageMetadata(message)] : [message], + ); if (outputMessages) { attributes["gen_ai.output.messages"] = outputMessages; } @@ -61,7 +101,9 @@ function buildChatEndAttributes( Object.assign(attributes, extractGenAiUsageAttributes(message)); if (message.stopReason) { - attributes["gen_ai.response.finish_reasons"] = [message.stopReason]; + attributes["gen_ai.response.finish_reasons"] = [ + normalizeGenAiFinishReason(message.stopReason), + ]; } if (message.model) { @@ -78,14 +120,35 @@ function buildChatEndAttributes( * * The base argument exists so tests can inject a stub stream function. */ -export function createTracedStreamFn(base: StreamFn = streamSimple): StreamFn { +export function createTracedStreamFn( + baseOrOptions: + | StreamFn + | { + conversationPrivacy?: ConversationPrivacy; + base?: StreamFn; + } = streamSimple, +): StreamFn { + const base = + typeof baseOrOptions === "function" + ? baseOrOptions + : (baseOrOptions.base ?? streamSimple); + const mode = attributeModeForPrivacy( + typeof baseOrOptions === "function" + ? undefined + : baseOrOptions.conversationPrivacy, + ); + const conversationPrivacy = + typeof baseOrOptions === "function" + ? undefined + : baseOrOptions.conversationPrivacy; + const effectivePrivacy = conversationPrivacy ?? "private"; return async (model, context, options) => { const span = Sentry.startInactiveSpan({ name: `chat ${model.id}`, op: "gen_ai.chat", attributes: { ...getLogContextAttributes(), - ...buildChatStartAttributes(model, context), + ...buildChatStartAttributes(model, context, mode, effectivePrivacy), }, }); @@ -100,7 +163,7 @@ export function createTracedStreamFn(base: StreamFn = streamSimple): StreamFn { (finalMessage) => { try { for (const [key, value] of Object.entries( - buildChatEndAttributes(finalMessage), + buildChatEndAttributes(finalMessage, mode), )) { span.setAttribute(key, value); } diff --git a/packages/junior/src/chat/respond.ts b/packages/junior/src/chat/respond.ts index 85549f71..e6f596e7 100644 --- a/packages/junior/src/chat/respond.ts +++ b/packages/junior/src/chat/respond.ts @@ -47,6 +47,8 @@ import type { import { createAdvisorToolDefinitions } from "@/chat/tools/advisor/tool"; import { GEN_AI_PROVIDER_NAME, + GEN_AI_SERVER_ADDRESS, + GEN_AI_SERVER_PORT, completeObject, getPiGatewayApiKeyOverride, resolveGatewayModel, @@ -100,6 +102,7 @@ import { persistRunningCheckpoint, persistTimeoutCheckpoint, } from "@/chat/services/turn-checkpoint"; +import type { AgentTurnRequester } from "@/chat/state/turn-session-store"; import { createMcpAuthOrchestration } from "@/chat/services/mcp-auth-orchestration"; import { createPluginAuthOrchestration } from "@/chat/services/plugin-auth-orchestration"; import { @@ -107,6 +110,11 @@ import { AuthorizationPauseError, type AuthorizationFlowMode, } from "@/chat/services/auth-pause"; +import { + resolveConversationPrivacy, + toGenAiMessageMetadata, + toGenAiMessagesTraceAttributes, +} from "@/chat/conversation-privacy"; // Re-export types for backward compatibility with existing consumers. export type { AssistantReply, AgentTurnDiagnostics }; @@ -136,6 +144,7 @@ export interface ReplyRequestContext { turnId?: string; runId?: string; channelId?: string; + channelName?: string; teamId?: string; messageTs?: string; threadTs?: string; @@ -227,6 +236,21 @@ function extractSliceUsage( return hasAgentTurnUsage(usage) ? usage : undefined; } +function requesterFromContext( + requester: ReplyRequestContext["requester"], + requesterId: string | undefined, +): AgentTurnRequester | undefined { + const identity: AgentTurnRequester = { + ...(requester?.email ? { email: requester.email } : {}), + ...(requester?.fullName ? { fullName: requester.fullName } : {}), + ...((requesterId ?? requester?.userId) + ? { slackUserId: requesterId ?? requester?.userId } + : {}), + ...(requester?.userName ? { slackUserName: requester.userName } : {}), + }; + return Object.keys(identity).length > 0 ? identity : undefined; +} + function supportsRouterTextPreview(mediaType: string): boolean { const baseMediaType = mediaType.split(";", 1)[0]?.trim().toLowerCase(); if (!baseMediaType) { @@ -350,6 +374,17 @@ export async function generateAssistantReply( let timedOut = false; let turnUsage: AgentTurnUsage | undefined; let thinkingSelection: TurnThinkingSelection | undefined; + const requester = requesterFromContext( + context.requester, + context.correlation?.requesterId, + ); + const conversationPrivacy = resolveConversationPrivacy({ + channelId: context.correlation?.channelId, + conversationId: + context.correlation?.conversationId ?? + context.correlation?.threadId ?? + context.correlation?.runId, + }); const checkpointLogContext = { threadId: context.correlation?.threadId, requesterId: context.correlation?.requesterId, @@ -775,9 +810,10 @@ export async function generateAssistantReply( advisor: { config: botConfig.advisor, conversationId: sessionConversationId, + conversationPrivacy, logContext: spanContext, getTools: () => advisorTools, - streamFn: createTracedStreamFn(), + streamFn: createTracedStreamFn({ conversationPrivacy }), }, }, ); @@ -826,7 +862,7 @@ export async function generateAssistantReply( ...userContentParts, ]; - const inputMessagesAttribute = serializeGenAiAttribute([ + const inputMessages = [ { role: "system", content: [{ type: "text", text: baseInstructions }], @@ -835,7 +871,12 @@ export async function generateAssistantReply( role: "user", content: promptContentParts.map((part) => toObservablePromptPart(part)), }, - ]); + ]; + const inputMessagesAttribute = serializeGenAiAttribute( + conversationPrivacy !== "public" + ? inputMessages.map(toGenAiMessageMetadata) + : inputMessages, + ); // ── Agent tools ────────────────────────────────────────────────── const onToolCall = (toolName: string, params: Record) => { @@ -864,6 +905,7 @@ export async function generateAssistantReply( pluginAuth, onToolCall, agentPluginHooks, + conversationPrivacy, ); advisorTools = createAgentTools( createAdvisorToolDefinitions(tools), @@ -874,6 +916,7 @@ export async function generateAssistantReply( pluginAuth, onToolCall, agentPluginHooks, + conversationPrivacy, ); // Keep Pi's native tool schema static for the whole turn. Ideally this // would use provider-native tool loading/search APIs, but Pi's generic @@ -885,7 +928,7 @@ export async function generateAssistantReply( // ── Agent execution ────────────────────────────────────────────── agent = new Agent({ getApiKey: () => getPiGatewayApiKeyOverride(), - streamFn: createTracedStreamFn(), + streamFn: createTracedStreamFn({ conversationPrivacy }), initialState: { systemPrompt: baseInstructions, model: resolveGatewayModel(botConfig.modelId), @@ -907,12 +950,14 @@ export async function generateAssistantReply( } await persistRunningCheckpoint({ + channelName: context.correlation?.channelName, conversationId: sessionConversationId, sessionId, sliceId: currentSliceId, messages, loadedSkillNames: loadedSkillNamesForResume, logContext: checkpointLogContext, + requester, }); }; @@ -973,7 +1018,7 @@ export async function generateAssistantReply( beforeMessageCount = agent.state.messages.length; await withSpan( - "ai.generate_assistant_reply", + `invoke_agent ${botConfig.modelId}`, "gen_ai.invoke_agent", spanContext, async () => { @@ -1053,8 +1098,11 @@ export async function generateAssistantReply( newMessages = agent.state.messages.slice(beforeMessageCount); const outputMessages = newMessages.filter(isAssistantMessage); - const outputMessagesAttribute = - serializeGenAiAttribute(outputMessages); + const outputMessagesAttribute = serializeGenAiAttribute( + conversationPrivacy !== "public" + ? outputMessages.map(toGenAiMessageMetadata) + : outputMessages, + ); const usageSummary = extractGenAiUsageSummary( promptResult, agent.state, @@ -1068,6 +1116,10 @@ export async function generateAssistantReply( ...(outputMessagesAttribute ? { "gen_ai.output.messages": outputMessagesAttribute } : {}), + ...toGenAiMessagesTraceAttributes( + "app.ai.output", + outputMessages, + ), ...extractGenAiUsageAttributes(usageSummary), }); if (getPendingAuthPause()) { @@ -1108,7 +1160,21 @@ export async function generateAssistantReply( "gen_ai.provider.name": GEN_AI_PROVIDER_NAME, "gen_ai.operation.name": "invoke_agent", "gen_ai.request.model": botConfig.modelId, + "gen_ai.output.type": "text", + "server.address": GEN_AI_SERVER_ADDRESS, + "server.port": GEN_AI_SERVER_PORT, + ...(conversationPrivacy + ? { "app.conversation.privacy": conversationPrivacy } + : {}), + ...(sessionConversationId + ? { "app.ai.session.conversation_id": sessionConversationId } + : {}), + ...(sessionId ? { "app.ai.turn.session_id": sessionId } : {}), + ...(timeoutResumeSliceId + ? { "app.ai.turn.slice_id": timeoutResumeSliceId } + : {}), "app.ai.reasoning_effort": thinkingSelection.thinkingLevel, + ...toGenAiMessagesTraceAttributes("app.ai.input", inputMessages), ...(inputMessagesAttribute ? { "gen_ai.input.messages": inputMessagesAttribute } : {}), @@ -1124,6 +1190,7 @@ export async function generateAssistantReply( sessionId ) { await persistCompletedCheckpoint({ + channelName: context.correlation?.channelName, conversationId: sessionConversationId, currentDurationMs: Date.now() - replyStartedAtMs, currentUsage: turnUsage, @@ -1132,6 +1199,7 @@ export async function generateAssistantReply( allMessages: agent.state.messages, loadedSkillNames: activeSkills.map((skill) => skill.name), logContext: checkpointLogContext, + requester, }); } @@ -1160,6 +1228,7 @@ export async function generateAssistantReply( turnUsage ?? extractSliceUsage(timeoutResumeMessages, beforeMessageCount); const checkpoint = await persistTimeoutCheckpoint({ + channelName: context.correlation?.channelName, conversationId: timeoutResumeConversationId, sessionId: timeoutResumeSessionId, currentSliceId: timeoutResumeSliceId, @@ -1169,6 +1238,7 @@ export async function generateAssistantReply( loadedSkillNames: loadedSkillNamesForResume, errorMessage: error instanceof Error ? error.message : String(error), logContext: checkpointLogContext, + requester, }); if (checkpoint) { throw new RetryableTurnError( @@ -1197,6 +1267,7 @@ export async function generateAssistantReply( ); } const checkpoint = await persistAuthPauseCheckpoint({ + channelName: context.correlation?.channelName, conversationId: timeoutResumeConversationId, sessionId: timeoutResumeSessionId, currentSliceId: timeoutResumeSliceId, @@ -1206,6 +1277,7 @@ export async function generateAssistantReply( loadedSkillNames: loadedSkillNamesForResume, errorMessage: error.message, logContext: checkpointLogContext, + requester, }); if (checkpoint) { throw new RetryableTurnError( diff --git a/packages/junior/src/chat/runtime/reply-executor.ts b/packages/junior/src/chat/runtime/reply-executor.ts index 3bcf9f62..13be5e7e 100644 --- a/packages/junior/src/chat/runtime/reply-executor.ts +++ b/packages/junior/src/chat/runtime/reply-executor.ts @@ -4,6 +4,7 @@ import { botConfig } from "@/chat/config"; import { getSlackMessageTs } from "@/chat/slack/message"; import { logException, + getActiveTraceId, logInfo, logWarn, setSpanAttributes, @@ -76,6 +77,8 @@ import type { PiMessage } from "@/chat/pi/messages"; import { failAgentTurnSessionCheckpoint, getAgentTurnSessionCheckpoint, + recordAgentTurnSessionSummary, + type AgentTurnRequester, } from "@/chat/state/turn-session-store"; import { stripRuntimeTurnContext, @@ -91,6 +94,35 @@ function collectCanvasUrls(artifacts: Partial) { ); } +function turnRequester(args: { + email?: string; + fullName?: string; + userId?: string; + userName?: string; +}): AgentTurnRequester | undefined { + const requester: AgentTurnRequester = { + ...(args.email ? { email: args.email } : {}), + ...(args.fullName ? { fullName: args.fullName } : {}), + ...(args.userId ? { slackUserId: args.userId } : {}), + ...(args.userName ? { slackUserName: args.userName } : {}), + }; + return Object.keys(requester).length > 0 ? requester : undefined; +} + +async function resolveChannelName(thread: Thread): Promise { + const existingName = thread.channel.name?.trim(); + if (existingName) { + return existingName; + } + + try { + const metadata = await thread.channel.fetchMetadata(); + return metadata.name?.trim() || undefined; + } catch { + return undefined; + } +} + function getCurrentTurnCanvasUrl(args: { before: Partial; after: Partial; @@ -226,6 +258,9 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { const threadId = getThreadId(thread, message); const channelId = getChannelId(thread, message); + const channelName = channelId + ? await resolveChannelName(thread) + : undefined; const threadTs = getThreadTs(threadId); const assistantThreadContext = getAssistantThreadContext(message); const messageTs = getMessageTs(message); @@ -274,6 +309,15 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { const slackMessageTs = getSlackMessageTs(message); const turnId = buildDeterministicTurnId(message.id); + const fallbackIdentity = await deps.services.lookupSlackUser( + message.author.userId, + ); + const requester = turnRequester({ + email: fallbackIdentity?.email, + fullName: message.author.fullName ?? fallbackIdentity?.fullName, + userId: message.author.userId, + userName: message.author.userName ?? fallbackIdentity?.userName, + }); const turnTraceContext = { conversationId, slackThreadId: threadId, @@ -428,6 +472,20 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { nextTurnId: turnId, updateConversationStats, }); + if (conversationId) { + // Fire-and-forget: recording the "running" state is best-effort and + // must not delay reply generation. + void recordAgentTurnSessionSummary({ + channelName, + conversationId, + sessionId: turnId, + sliceId: 1, + startedAtMs: message.metadata.dateSent.getTime(), + state: "running", + requester, + traceId: getActiveTraceId(), + }); + } setTags({ conversationId, }); @@ -446,9 +504,6 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { conversation: preparedState.conversation, }); - const fallbackIdentity = await deps.services.lookupSlackUser( - message.author.userId, - ); const resolvedUserName = message.author.userName ?? fallbackIdentity?.userName; if (resolvedUserName) { @@ -582,6 +637,7 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { teamId, runId, channelId, + channelName, requesterId: message.author.userId, }, toolChannelId, @@ -727,7 +783,10 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { const titleUpdateResult = await assistantTitleTask; if (titleUpdateResult) { artifactStatePatch.assistantTitleSourceMessageId = - titleUpdateResult; + titleUpdateResult.sourceMessageId; + if (titleUpdateResult.title) { + artifactStatePatch.assistantTitle = titleUpdateResult.title; + } } const completedState = buildDeliveredTurnStatePatch({ @@ -741,6 +800,21 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { await persistThreadState(thread, { ...completedState, }); + if (conversationId) { + await recordAgentTurnSessionSummary({ + channelName, + conversationId, + cumulativeDurationMs: reply.diagnostics.durationMs, + cumulativeUsage: reply.diagnostics.usage, + sessionId: turnId, + sliceId: 1, + startedAtMs: message.metadata.dateSent.getTime(), + state: "completed", + conversationTitle: titleUpdateResult?.title, + requester, + traceId: getActiveTraceId(), + }); + } preparedState.conversation = completedState.conversation; persistedAtLeastOnce = true; if (shouldEmitDevAgentTrace()) { @@ -904,6 +978,16 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { }); if (conversationId) { try { + await recordAgentTurnSessionSummary({ + channelName, + conversationId, + sessionId: turnId, + sliceId: 1, + startedAtMs: message.metadata.dateSent.getTime(), + state: "failed", + requester, + traceId: getActiveTraceId(), + }); await failAgentTurnSessionCheckpoint({ conversationId, sessionId: turnId, diff --git a/packages/junior/src/chat/runtime/thread-state.ts b/packages/junior/src/chat/runtime/thread-state.ts index d7701919..0f5b47a7 100644 --- a/packages/junior/src/chat/runtime/thread-state.ts +++ b/packages/junior/src/chat/runtime/thread-state.ts @@ -1,4 +1,4 @@ -import { THREAD_STATE_TTL_MS, type Thread } from "chat"; +import type { Thread } from "chat"; import { toOptionalString } from "@/chat/coerce"; import { createChannelConfigurationService } from "@/chat/configuration/service"; import type { ChannelConfigurationService } from "@/chat/configuration/types"; @@ -9,6 +9,7 @@ import { type ThreadArtifactsState, } from "@/chat/state/artifacts"; import { getStateAdapter } from "@/chat/state/adapter"; +import { JUNIOR_THREAD_STATE_TTL_MS } from "@/chat/state/ttl"; export interface ThreadStatePatch { artifacts?: ThreadArtifactsState; @@ -132,7 +133,11 @@ export async function persistThreadStateById( await stateAdapter.connect(); const key = threadStateKey(threadId); const existing = (await stateAdapter.get>(key)) ?? {}; - await stateAdapter.set(key, { ...existing, ...payload }, THREAD_STATE_TTL_MS); + await stateAdapter.set( + key, + { ...existing, ...payload }, + JUNIOR_THREAD_STATE_TTL_MS, + ); } export function getChannelConfigurationService( @@ -164,7 +169,7 @@ export function getChannelConfigurationServiceById( await stateAdapter.set( key, { ...existing, configuration: state }, - THREAD_STATE_TTL_MS, + JUNIOR_THREAD_STATE_TTL_MS, ); }, }); diff --git a/packages/junior/src/chat/sentry-links.ts b/packages/junior/src/chat/sentry-links.ts new file mode 100644 index 00000000..6d71a5b6 --- /dev/null +++ b/packages/junior/src/chat/sentry-links.ts @@ -0,0 +1,79 @@ +import * as Sentry from "@/chat/sentry"; + +function getSentryOrgSlug(): string | undefined { + const slug = process.env.SENTRY_ORG_SLUG?.trim(); + return slug || undefined; +} + +function isSentrySaasDsnHost(host: string): boolean { + return host === "sentry.io" || host.endsWith(".sentry.io"); +} + +function buildSentryWebBaseUrl(dsn: { + host: string; + path?: string; + port?: string; + protocol: string; +}): string { + if (isSentrySaasDsnHost(dsn.host)) { + return "https://sentry.io"; + } + + const port = dsn.port ? `:${dsn.port}` : ""; + const path = dsn.path ? `/${dsn.path}` : ""; + return `${dsn.protocol}://${dsn.host}${port}${path}`; +} + +/** Build a Sentry conversation URL only when the runtime has enough Sentry config. */ +export function buildSentryConversationUrl( + conversationId: string, +): string | undefined { + const client = Sentry.getClient(); + const dsn = client?.getDsn(); + if (!dsn?.host || !dsn.projectId) { + return undefined; + } + + const orgSlug = getSentryOrgSlug(); + if (!orgSlug) { + return undefined; + } + + const encodedId = encodeURIComponent(conversationId); + const params = new URLSearchParams(); + params.set("project", dsn.projectId); + + const path = `explore/conversations/${encodedId}/?${params.toString()}`; + + if (isSentrySaasDsnHost(dsn.host)) { + return `https://${orgSlug}.sentry.io/${path}`; + } + + return `${buildSentryWebBaseUrl(dsn)}/organizations/${orgSlug}/${path}`; +} + +/** Build a Sentry trace URL only when the runtime has enough Sentry config. */ +export function buildSentryTraceUrl(traceId: string): string | undefined { + const client = Sentry.getClient(); + const dsn = client?.getDsn(); + if (!dsn?.host || !dsn.projectId) { + return undefined; + } + + const orgSlug = getSentryOrgSlug(); + if (!orgSlug) { + return undefined; + } + + const encodedTraceId = encodeURIComponent(traceId); + const params = new URLSearchParams(); + params.set("project", dsn.projectId); + + const path = `performance/trace/${encodedTraceId}/?${params.toString()}`; + + if (isSentrySaasDsnHost(dsn.host)) { + return `https://${orgSlug}.sentry.io/${path}`; + } + + return `${buildSentryWebBaseUrl(dsn)}/organizations/${orgSlug}/${path}`; +} diff --git a/packages/junior/src/chat/services/context-compaction.ts b/packages/junior/src/chat/services/context-compaction.ts index 0ca1b79b..033eb3b8 100644 --- a/packages/junior/src/chat/services/context-compaction.ts +++ b/packages/junior/src/chat/services/context-compaction.ts @@ -14,7 +14,7 @@ import { upsertAgentTurnSessionCheckpoint, } from "@/chat/state/turn-session-store"; import type { ThreadConversationState } from "@/chat/state/conversation"; -import { logWarn, setSpanAttributes } from "@/chat/logging"; +import { getActiveTraceId, logWarn, setSpanAttributes } from "@/chat/logging"; import { stripRuntimeTurnContext, trimTrailingAssistantMessages, @@ -401,6 +401,7 @@ async function writeCompactedThreadContext( sliceId: 1, state: "completed", piMessages: replacement, + traceId: getActiveTraceId(), }); args.conversation.processing.lastSessionId = nextSessionId; diff --git a/packages/junior/src/chat/services/turn-checkpoint.ts b/packages/junior/src/chat/services/turn-checkpoint.ts index 236d25d6..a01cac13 100644 --- a/packages/junior/src/chat/services/turn-checkpoint.ts +++ b/packages/junior/src/chat/services/turn-checkpoint.ts @@ -2,8 +2,9 @@ import { getAgentTurnSessionCheckpoint, upsertAgentTurnSessionCheckpoint, type AgentTurnSessionCheckpoint, + type AgentTurnRequester, } from "@/chat/state/turn-session-store"; -import { logException } from "@/chat/logging"; +import { getActiveTraceId, logException } from "@/chat/logging"; import type { PiMessage } from "@/chat/pi/messages"; import { getPiMessageRole, @@ -107,12 +108,14 @@ export async function loadTurnCheckpoint( /** Persist the latest safe in-progress boundary without scheduling continuation. */ export async function persistRunningCheckpoint(args: { + channelName?: string; conversationId: string; sessionId: string; sliceId: number; messages: PiMessage[]; loadedSkillNames: string[]; logContext: CheckpointLogContext; + requester?: AgentTurnRequester; }): Promise { if (args.messages.length === 0 || !isContinuableBoundary(args.messages)) { return; @@ -124,6 +127,7 @@ export async function persistRunningCheckpoint(args: { args.sessionId, ); await upsertAgentTurnSessionCheckpoint({ + channelName: args.channelName ?? latestCheckpoint?.channelName, conversationId: args.conversationId, cumulativeDurationMs: latestCheckpoint?.cumulativeDurationMs, cumulativeUsage: latestCheckpoint?.cumulativeUsage, @@ -132,6 +136,8 @@ export async function persistRunningCheckpoint(args: { state: "running", piMessages: args.messages, loadedSkillNames: args.loadedSkillNames, + requester: args.requester ?? latestCheckpoint?.requester, + traceId: getActiveTraceId() ?? latestCheckpoint?.traceId, }); } catch (checkpointError) { logCheckpointError( @@ -148,6 +154,7 @@ export async function persistRunningCheckpoint(args: { /** Persist a completed turn checkpoint. */ export async function persistCompletedCheckpoint(args: { + channelName?: string; conversationId: string; currentDurationMs?: number; currentUsage?: AgentTurnUsage; @@ -156,6 +163,7 @@ export async function persistCompletedCheckpoint(args: { allMessages: PiMessage[]; loadedSkillNames: string[]; logContext: CheckpointLogContext; + requester?: AgentTurnRequester; }): Promise { try { const latestCheckpoint = await getAgentTurnSessionCheckpoint( @@ -163,6 +171,7 @@ export async function persistCompletedCheckpoint(args: { args.sessionId, ); await upsertAgentTurnSessionCheckpoint({ + channelName: args.channelName ?? latestCheckpoint?.channelName, conversationId: args.conversationId, cumulativeDurationMs: addDurationMs( latestCheckpoint?.cumulativeDurationMs, @@ -177,6 +186,8 @@ export async function persistCompletedCheckpoint(args: { state: "completed", piMessages: args.allMessages, loadedSkillNames: args.loadedSkillNames, + requester: args.requester ?? latestCheckpoint?.requester, + traceId: getActiveTraceId() ?? latestCheckpoint?.traceId, }); } catch (checkpointError) { logCheckpointError( @@ -196,6 +207,7 @@ export async function persistCompletedCheckpoint(args: { * the caller can safely hand the user to an authorization resume flow. */ export async function persistAuthPauseCheckpoint(args: { + channelName?: string; conversationId: string; sessionId: string; currentSliceId: number; @@ -205,6 +217,7 @@ export async function persistAuthPauseCheckpoint(args: { loadedSkillNames: string[]; errorMessage: string; logContext: CheckpointLogContext; + requester?: AgentTurnRequester; }): Promise { const nextSliceId = args.currentSliceId + 1; try { @@ -218,6 +231,7 @@ export async function persistAuthPauseCheckpoint(args: { : (latestCheckpoint?.piMessages ?? []), ); return await upsertAgentTurnSessionCheckpoint({ + channelName: args.channelName ?? latestCheckpoint?.channelName, conversationId: args.conversationId, cumulativeDurationMs: addDurationMs( latestCheckpoint?.cumulativeDurationMs, @@ -235,6 +249,8 @@ export async function persistAuthPauseCheckpoint(args: { resumeReason: "auth", resumedFromSliceId: args.currentSliceId, errorMessage: args.errorMessage, + requester: args.requester ?? latestCheckpoint?.requester, + traceId: getActiveTraceId() ?? latestCheckpoint?.traceId, }); } catch (checkpointError) { logCheckpointError( @@ -256,6 +272,7 @@ export async function persistAuthPauseCheckpoint(args: { * checkpoint when persistence succeeds so callers can enqueue a continuation. */ export async function persistTimeoutCheckpoint(args: { + channelName?: string; conversationId: string; sessionId: string; currentSliceId: number; @@ -265,6 +282,7 @@ export async function persistTimeoutCheckpoint(args: { loadedSkillNames: string[]; errorMessage: string; logContext: CheckpointLogContext; + requester?: AgentTurnRequester; }): Promise { const nextSliceId = args.currentSliceId + 1; @@ -279,6 +297,7 @@ export async function persistTimeoutCheckpoint(args: { : (latestCheckpoint?.piMessages ?? []), ); return await upsertAgentTurnSessionCheckpoint({ + channelName: args.channelName ?? latestCheckpoint?.channelName, conversationId: args.conversationId, cumulativeDurationMs: addDurationMs( latestCheckpoint?.cumulativeDurationMs, @@ -296,6 +315,8 @@ export async function persistTimeoutCheckpoint(args: { resumeReason: "timeout", resumedFromSliceId: args.currentSliceId, errorMessage: args.errorMessage, + requester: args.requester ?? latestCheckpoint?.requester, + traceId: getActiveTraceId() ?? latestCheckpoint?.traceId, }); } catch (checkpointError) { logCheckpointError( diff --git a/packages/junior/src/chat/slack/assistant-thread/title.ts b/packages/junior/src/chat/slack/assistant-thread/title.ts index db18a520..9e759ac5 100644 --- a/packages/junior/src/chat/slack/assistant-thread/title.ts +++ b/packages/junior/src/chat/slack/assistant-thread/title.ts @@ -35,7 +35,7 @@ export function maybeUpdateAssistantTitle(args: { requesterId?: string; runId?: string; threadId?: string; -}): Promise { +}): Promise<{ sourceMessageId: string; title?: string } | undefined> { const assistantThreadContext = args.assistantThreadContext; if ( !assistantThreadContext?.channelId || @@ -63,7 +63,7 @@ export function maybeUpdateAssistantTitle(args: { assistantThreadContext.threadTs, title, ); - return titleSourceMessage.id; + return { sourceMessageId: titleSourceMessage.id, title }; } catch (error) { const slackErrorCode = getSlackApiErrorCode(error); const assistantTitleErrorAttributes = { @@ -91,7 +91,7 @@ export function maybeUpdateAssistantTitle(args: { assistantTitleErrorAttributes, "Skipping thread title update due to Slack permission error", ); - return titleSourceMessage.id; + return { sourceMessageId: titleSourceMessage.id }; } logWarn( "thread_title_generation_failed", diff --git a/packages/junior/src/chat/slack/footer.ts b/packages/junior/src/chat/slack/footer.ts index 3037a8a5..41f4a502 100644 --- a/packages/junior/src/chat/slack/footer.ts +++ b/packages/junior/src/chat/slack/footer.ts @@ -1,4 +1,4 @@ -import * as Sentry from "@/chat/sentry"; +import { buildSentryConversationUrl } from "@/chat/sentry-links"; import type { TurnThinkingSelection } from "@/chat/services/turn-thinking-level"; import type { AgentTurnUsage } from "@/chat/usage"; @@ -52,55 +52,6 @@ function escapeSlackLinkUrl(url: string): string { .replaceAll(">", "%3E"); } -function getSentryOrgSlug(): string | undefined { - const slug = process.env.SENTRY_ORG_SLUG?.trim(); - return slug || undefined; -} - -function isSentrySaasDsnHost(host: string): boolean { - return host === "sentry.io" || host.endsWith(".sentry.io"); -} - -function buildSentryWebBaseUrl(dsn: { - host: string; - path?: string; - port?: string; - protocol: string; -}): string { - if (isSentrySaasDsnHost(dsn.host)) { - return "https://sentry.io"; - } - - const port = dsn.port ? `:${dsn.port}` : ""; - const path = dsn.path ? `/${dsn.path}` : ""; - return `${dsn.protocol}://${dsn.host}${port}${path}`; -} - -function getSentryConversationUrl(conversationId: string): string | undefined { - const client = Sentry.getClient(); - const dsn = client?.getDsn(); - if (!dsn?.host || !dsn.projectId) { - return undefined; - } - - const orgSlug = getSentryOrgSlug(); - if (!orgSlug) { - return undefined; - } - - const encodedId = encodeURIComponent(conversationId); - const params = new URLSearchParams(); - params.set("project", dsn.projectId); - - const path = `explore/conversations/${encodedId}/?${params.toString()}`; - - if (isSentrySaasDsnHost(dsn.host)) { - return `https://${orgSlug}.sentry.io/${path}`; - } - - return `${buildSentryWebBaseUrl(dsn)}/organizations/${orgSlug}/${path}`; -} - function formatSlackTokenCount(value: number): string { if (value >= 1_000_000) { const millions = value / 1_000_000; @@ -182,7 +133,7 @@ export function buildSlackReplyFooter(args: { label: "ID", value: conversationId, }; - const conversationUrl = getSentryConversationUrl(conversationId); + const conversationUrl = buildSentryConversationUrl(conversationId); if (conversationUrl) { idItem.url = conversationUrl; } diff --git a/packages/junior/src/chat/state/artifacts.ts b/packages/junior/src/chat/state/artifacts.ts index 54e02489..16b749b8 100644 --- a/packages/junior/src/chat/state/artifacts.ts +++ b/packages/junior/src/chat/state/artifacts.ts @@ -14,6 +14,7 @@ export interface CanvasArtifactSummary { export interface ThreadArtifactsState { assistantContextChannelId?: string; + assistantTitle?: string; assistantTitleSourceMessageId?: string; lastCanvasId?: string; lastCanvasUrl?: string; @@ -35,6 +36,7 @@ export function coerceThreadArtifactsState( const raw = value as { artifacts?: { assistantContextChannelId?: unknown; + assistantTitle?: unknown; assistantTitleSourceMessageId?: unknown; lastCanvasId?: unknown; lastCanvasUrl?: unknown; @@ -89,6 +91,10 @@ export function coerceThreadArtifactsState( typeof artifacts.assistantContextChannelId === "string" ? artifacts.assistantContextChannelId : undefined, + assistantTitle: + typeof artifacts.assistantTitle === "string" + ? artifacts.assistantTitle + : undefined, assistantTitleSourceMessageId: typeof artifacts.assistantTitleSourceMessageId === "string" ? artifacts.assistantTitleSourceMessageId diff --git a/packages/junior/src/chat/state/ttl.ts b/packages/junior/src/chat/state/ttl.ts new file mode 100644 index 00000000..3ddf106b --- /dev/null +++ b/packages/junior/src/chat/state/ttl.ts @@ -0,0 +1,2 @@ +/** Retain expiring conversation/runtime state long enough for resume and dashboard history. */ +export const JUNIOR_THREAD_STATE_TTL_MS = 7 * 24 * 60 * 60 * 1000; diff --git a/packages/junior/src/chat/state/turn-session-store.ts b/packages/junior/src/chat/state/turn-session-store.ts index e853d688..36137509 100644 --- a/packages/junior/src/chat/state/turn-session-store.ts +++ b/packages/junior/src/chat/state/turn-session-store.ts @@ -1,4 +1,3 @@ -import { THREAD_STATE_TTL_MS } from "chat"; import { isRecord } from "@/chat/coerce"; import type { PiMessage } from "@/chat/pi/messages"; import { @@ -7,9 +6,12 @@ import { } from "./pi-session-message-store"; import type { AgentTurnUsage } from "@/chat/usage"; import { getStateAdapter } from "./adapter"; +import { JUNIOR_THREAD_STATE_TTL_MS } from "./ttl"; const AGENT_TURN_SESSION_PREFIX = "junior:agent_turn_session"; -const AGENT_TURN_SESSION_TTL_MS = THREAD_STATE_TTL_MS; +const AGENT_TURN_SESSION_INDEX_KEY = `${AGENT_TURN_SESSION_PREFIX}:index`; +const AGENT_TURN_SESSION_INDEX_MAX_LENGTH = 5_000; +const AGENT_TURN_SESSION_TTL_MS = JUNIOR_THREAD_STATE_TTL_MS; export type AgentTurnSessionStatus = | "running" @@ -20,19 +22,51 @@ export type AgentTurnSessionStatus = export type AgentTurnResumeReason = "timeout" | "auth"; +export interface AgentTurnRequester { + email?: string; + fullName?: string; + slackUserId?: string; + slackUserName?: string; +} + export interface AgentTurnSessionCheckpoint { + channelName?: string; checkpointVersion: number; + conversationTitle?: string; conversationId: string; cumulativeDurationMs?: number; cumulativeUsage?: AgentTurnUsage; errorMessage?: string; + lastProgressAtMs: number; loadedSkillNames?: string[]; piMessages: PiMessage[]; resumeReason?: AgentTurnResumeReason; resumedFromSliceId?: number; + requester?: AgentTurnRequester; sessionId: string; sliceId: number; + startedAtMs: number; state: AgentTurnSessionStatus; + traceId?: string; + updatedAtMs: number; +} + +export interface AgentTurnSessionSummary { + channelName?: string; + checkpointVersion: number; + conversationTitle?: string; + conversationId: string; + cumulativeDurationMs?: number; + cumulativeUsage?: AgentTurnUsage; + lastProgressAtMs: number; + loadedSkillNames?: string[]; + resumeReason?: AgentTurnResumeReason; + requester?: AgentTurnRequester; + sessionId: string; + sliceId: number; + startedAtMs: number; + state: AgentTurnSessionStatus; + traceId?: string; updatedAtMs: number; } @@ -78,6 +112,28 @@ function parseAgentTurnUsage(value: unknown): AgentTurnUsage | undefined { return Object.keys(usage).length > 0 ? usage : undefined; } +function parseAgentTurnRequester( + value: unknown, +): AgentTurnRequester | undefined { + if (!isRecord(value)) { + return undefined; + } + + const requester: AgentTurnRequester = {}; + for (const field of [ + "email", + "fullName", + "slackUserId", + "slackUserName", + ] as const) { + if (typeof value[field] === "string" && value[field].trim()) { + requester[field] = value[field].trim(); + } + } + + return Object.keys(requester).length > 0 ? requester : undefined; +} + function parseStoredRecord( value: unknown, ): Record | undefined { @@ -119,14 +175,26 @@ function parseAgentTurnSessionRecord(value: unknown): } const conversationId = parsed.conversationId; + const conversationTitle = + typeof parsed.conversationTitle === "string" && + parsed.conversationTitle.trim() + ? parsed.conversationTitle.trim() + : undefined; + const channelName = + typeof parsed.channelName === "string" && parsed.channelName.trim() + ? parsed.channelName.trim() + : undefined; const sessionId = parsed.sessionId; const sliceId = parsed.sliceId; const checkpointVersion = parsed.checkpointVersion; const updatedAtMs = parsed.updatedAtMs; + const startedAtMs = toFiniteNonNegativeNumber(parsed.startedAtMs); const cumulativeDurationMs = toFiniteNonNegativeNumber( parsed.cumulativeDurationMs, ); + const lastProgressAtMs = toFiniteNonNegativeNumber(parsed.lastProgressAtMs); const cumulativeUsage = parseAgentTurnUsage(parsed.cumulativeUsage); + const requester = parseAgentTurnRequester(parsed.requester); if ( typeof conversationId !== "string" || typeof sessionId !== "string" || @@ -146,15 +214,20 @@ function parseAgentTurnSessionRecord(value: unknown): return { legacyPiMessages, record: { + ...(channelName ? { channelName } : {}), checkpointVersion, + ...(conversationTitle ? { conversationTitle } : {}), conversationId, sessionId, sliceId, state: status, + startedAtMs: startedAtMs ?? updatedAtMs, + lastProgressAtMs: lastProgressAtMs ?? updatedAtMs, updatedAtMs, messageCount, ...(cumulativeDurationMs !== undefined ? { cumulativeDurationMs } : {}), ...(cumulativeUsage ? { cumulativeUsage } : {}), + ...(requester ? { requester } : {}), ...(Array.isArray(parsed.loadedSkillNames) ? { loadedSkillNames: parsed.loadedSkillNames.filter( @@ -171,10 +244,40 @@ function parseAgentTurnSessionRecord(value: unknown): ...(typeof parsed.resumedFromSliceId === "number" ? { resumedFromSliceId: parsed.resumedFromSliceId } : {}), + ...(typeof parsed.traceId === "string" + ? { traceId: parsed.traceId } + : {}), }, }; } +function parseAgentTurnSessionSummary( + value: unknown, +): AgentTurnSessionSummary | undefined { + const parsed = parseAgentTurnSessionRecord(value); + if (!parsed) { + return undefined; + } + + const { + errorMessage: _errorMessage, + messageCount: _messageCount, + ...record + } = parsed.record; + return record; +} + +async function appendAgentTurnSessionSummary( + summary: AgentTurnSessionSummary, + ttlMs: number, +): Promise { + const stateAdapter = getStateAdapter(); + await stateAdapter.appendToList(AGENT_TURN_SESSION_INDEX_KEY, summary, { + maxLength: AGENT_TURN_SESSION_INDEX_MAX_LENGTH, + ttlMs, + }); +} + function materializePiMessages( legacyPiMessages: PiMessage[], messageCount: number, @@ -229,7 +332,9 @@ export async function getAgentTurnSessionCheckpoint( /** Commit stable Pi session state and advance the turn-session checkpoint cursor. */ export async function upsertAgentTurnSessionCheckpoint(args: { + channelName?: string; conversationId: string; + conversationTitle?: string; cumulativeDurationMs?: number; cumulativeUsage?: AgentTurnUsage; sessionId: string; @@ -237,9 +342,12 @@ export async function upsertAgentTurnSessionCheckpoint(args: { state: AgentTurnSessionStatus; piMessages: PiMessage[]; loadedSkillNames?: string[]; + lastProgressAtMs?: number; resumeReason?: AgentTurnResumeReason; + requester?: AgentTurnRequester; errorMessage?: string; resumedFromSliceId?: number; + traceId?: string; ttlMs?: number; }): Promise { const stateAdapter = getStateAdapter(); @@ -250,6 +358,7 @@ export async function upsertAgentTurnSessionCheckpoint(args: { ); const existingRecord = parseAgentTurnSessionRecord(existingValue); const ttlMs = Math.max(1, args.ttlMs ?? AGENT_TURN_SESSION_TTL_MS); + const nowMs = Date.now(); await commitPiSessionMessages({ conversationId: args.conversationId, sessionId: args.sessionId, @@ -260,11 +369,24 @@ export async function upsertAgentTurnSessionCheckpoint(args: { const checkpoint: AgentTurnSessionRecord = { checkpointVersion: (existingRecord?.record.checkpointVersion ?? 0) + 1, + ...((args.channelName ?? existingRecord?.record.channelName) + ? { + channelName: args.channelName ?? existingRecord?.record.channelName, + } + : {}), + ...((args.conversationTitle ?? existingRecord?.record.conversationTitle) + ? { + conversationTitle: + args.conversationTitle ?? existingRecord?.record.conversationTitle, + } + : {}), conversationId: args.conversationId, sessionId: args.sessionId, sliceId: args.sliceId, state: args.state, - updatedAtMs: Date.now(), + startedAtMs: existingRecord?.record.startedAtMs ?? nowMs, + lastProgressAtMs: args.lastProgressAtMs ?? nowMs, + updatedAtMs: nowMs, messageCount: storedMessageCount, ...(typeof args.cumulativeDurationMs === "number" && Number.isFinite(args.cumulativeDurationMs) @@ -276,6 +398,9 @@ export async function upsertAgentTurnSessionCheckpoint(args: { } : {}), ...(args.cumulativeUsage ? { cumulativeUsage: args.cumulativeUsage } : {}), + ...((args.requester ?? existingRecord?.record.requester) + ? { requester: args.requester ?? existingRecord?.record.requester } + : {}), ...(Array.isArray(args.loadedSkillNames) ? { loadedSkillNames: args.loadedSkillNames.filter( @@ -288,6 +413,7 @@ export async function upsertAgentTurnSessionCheckpoint(args: { ...(typeof args.resumedFromSliceId === "number" ? { resumedFromSliceId: args.resumedFromSliceId } : {}), + ...(args.traceId ? { traceId: args.traceId } : {}), }; await stateAdapter.set( @@ -295,12 +421,117 @@ export async function upsertAgentTurnSessionCheckpoint(args: { checkpoint, ttlMs, ); + const { + errorMessage: _errorMessage, + messageCount: _messageCount, + ...summary + } = checkpoint; + await appendAgentTurnSessionSummary(summary, ttlMs); return { ...checkpoint, piMessages: [...args.piMessages], }; } +/** Record turn-session metadata without storing conversation messages. */ +export async function recordAgentTurnSessionSummary(args: { + channelName?: string; + conversationId: string; + conversationTitle?: string; + cumulativeDurationMs?: number; + cumulativeUsage?: AgentTurnUsage; + loadedSkillNames?: string[]; + lastProgressAtMs?: number; + resumeReason?: AgentTurnResumeReason; + requester?: AgentTurnRequester; + sessionId: string; + sliceId: number; + startedAtMs?: number; + state: AgentTurnSessionStatus; + traceId?: string; + ttlMs?: number; +}): Promise { + const stateAdapter = getStateAdapter(); + await stateAdapter.connect(); + const existing = await getAgentTurnSessionCheckpoint( + args.conversationId, + args.sessionId, + ); + const nowMs = Date.now(); + const ttlMs = Math.max(1, args.ttlMs ?? AGENT_TURN_SESSION_TTL_MS); + await appendAgentTurnSessionSummary( + { + checkpointVersion: existing?.checkpointVersion ?? 0, + ...((args.channelName ?? existing?.channelName) + ? { channelName: args.channelName ?? existing?.channelName } + : {}), + ...((args.conversationTitle ?? existing?.conversationTitle) + ? { + conversationTitle: + args.conversationTitle ?? existing?.conversationTitle, + } + : {}), + conversationId: args.conversationId, + sessionId: args.sessionId, + sliceId: args.sliceId, + startedAtMs: existing?.startedAtMs ?? args.startedAtMs ?? nowMs, + lastProgressAtMs: args.lastProgressAtMs ?? nowMs, + state: args.state, + updatedAtMs: nowMs, + ...(typeof args.cumulativeDurationMs === "number" && + Number.isFinite(args.cumulativeDurationMs) + ? { + cumulativeDurationMs: Math.max( + 0, + Math.floor(args.cumulativeDurationMs), + ), + } + : {}), + ...(args.cumulativeUsage + ? { cumulativeUsage: args.cumulativeUsage } + : {}), + ...((args.requester ?? existing?.requester) + ? { requester: args.requester ?? existing?.requester } + : {}), + ...(Array.isArray(args.loadedSkillNames) + ? { + loadedSkillNames: args.loadedSkillNames.filter( + (value): value is string => typeof value === "string", + ), + } + : {}), + ...(args.resumeReason ? { resumeReason: args.resumeReason } : {}), + ...(args.traceId ? { traceId: args.traceId } : {}), + }, + ttlMs, + ); +} + +/** List recent turn-session summaries for authenticated operational dashboards. */ +export async function listAgentTurnSessionSummaries( + limit = 50, +): Promise { + const stateAdapter = getStateAdapter(); + await stateAdapter.connect(); + const values = await stateAdapter.getList(AGENT_TURN_SESSION_INDEX_KEY); + const summaries = new Map(); + + for (const value of [...values].reverse()) { + const summary = parseAgentTurnSessionSummary(value); + if (!summary) { + continue; + } + const key = `${summary.conversationId}:${summary.sessionId}`; + if (!summaries.has(key)) { + summaries.set(key, summary); + } + } + + return [...summaries.values()] + .sort((left, right) => right.updatedAtMs - left.updatedAtMs) + .slice(0, Math.max(0, Math.floor(limit))); +} + /** Mark an unfinished turn-session checkpoint as superseded when a newer turn wins. */ export async function supersedeAgentTurnSessionCheckpoint(args: { conversationId: string; @@ -322,6 +553,8 @@ export async function supersedeAgentTurnSessionCheckpoint(args: { return await upsertAgentTurnSessionCheckpoint({ conversationId: existing.conversationId, + channelName: existing.channelName, + conversationTitle: existing.conversationTitle, sessionId: existing.sessionId, sliceId: existing.sliceId, state: "superseded", @@ -331,6 +564,8 @@ export async function supersedeAgentTurnSessionCheckpoint(args: { loadedSkillNames: existing.loadedSkillNames, resumeReason: existing.resumeReason, resumedFromSliceId: existing.resumedFromSliceId, + requester: existing.requester, + traceId: existing.traceId, errorMessage: args.errorMessage ?? existing.errorMessage, }); } @@ -359,6 +594,8 @@ export async function failAgentTurnSessionCheckpoint(args: { return await upsertAgentTurnSessionCheckpoint({ conversationId: existing.conversationId, + channelName: existing.channelName, + conversationTitle: existing.conversationTitle, sessionId: existing.sessionId, sliceId: existing.sliceId, state: "failed", @@ -368,6 +605,8 @@ export async function failAgentTurnSessionCheckpoint(args: { loadedSkillNames: existing.loadedSkillNames, resumeReason: existing.resumeReason, resumedFromSliceId: existing.resumedFromSliceId, + requester: existing.requester, + traceId: existing.traceId, errorMessage: args.errorMessage ?? existing.errorMessage, }); } diff --git a/packages/junior/src/chat/tools/advisor/session-store.ts b/packages/junior/src/chat/tools/advisor/session-store.ts index 2a0bbef1..1b3c2911 100644 --- a/packages/junior/src/chat/tools/advisor/session-store.ts +++ b/packages/junior/src/chat/tools/advisor/session-store.ts @@ -1,8 +1,8 @@ -import { THREAD_STATE_TTL_MS } from "chat"; import type { PiMessage } from "@/chat/pi/messages"; import { getStateAdapter } from "@/chat/state/adapter"; +import { JUNIOR_THREAD_STATE_TTL_MS } from "@/chat/state/ttl"; -const ADVISOR_SESSION_TTL_MS = THREAD_STATE_TTL_MS; +const ADVISOR_SESSION_TTL_MS = JUNIOR_THREAD_STATE_TTL_MS; export interface AdvisorSessionStore { load: (conversationId: string) => Promise; diff --git a/packages/junior/src/chat/tools/advisor/tool.ts b/packages/junior/src/chat/tools/advisor/tool.ts index 96b64e24..6d0a8c2d 100644 --- a/packages/junior/src/chat/tools/advisor/tool.ts +++ b/packages/junior/src/chat/tools/advisor/tool.ts @@ -5,14 +5,23 @@ import { } from "@earendil-works/pi-agent-core"; import { Type } from "@sinclair/typebox"; import type { AdvisorConfig } from "@/chat/config"; +import { + type ConversationPrivacy, + toGenAiMessageMetadata, + toGenAiMessagesTraceAttributes, +} from "@/chat/conversation-privacy"; import { extractGenAiUsageAttributes, + serializeGenAiAttribute, setSpanAttributes, setSpanStatus, withSpan, type LogContext, } from "@/chat/logging"; import { + GEN_AI_PROVIDER_NAME, + GEN_AI_SERVER_ADDRESS, + GEN_AI_SERVER_PORT, getPiGatewayApiKeyOverride, resolveGatewayModel, } from "@/chat/pi/client"; @@ -51,6 +60,7 @@ export interface AdvisorToolResult { export interface AdvisorToolRuntimeContext { config: AdvisorConfig; conversationId?: string; + conversationPrivacy?: ConversationPrivacy; getTools: () => AgentTool[]; logContext?: LogContext; store?: AdvisorSessionStore; @@ -170,22 +180,36 @@ export function createAdvisorTool(context: AdvisorToolRuntimeContext) { } const conversationId = context.conversationId; + const conversationPrivacy = context.conversationPrivacy ?? "private"; + const requestText = [ + "", + escapeXml(advisorQuestion), + "", + "", + "", + escapeXml(advisorContext), + "", + ].join("\n"); + const advisorInputMessage = { + role: "user", + content: [ + { + type: "text", + text: requestText, + }, + ], + }; + const advisorInputMessagesAttribute = serializeGenAiAttribute( + conversationPrivacy !== "public" + ? [toGenAiMessageMetadata(advisorInputMessage)] + : [advisorInputMessage], + ); return await withSpan( - "ai.invoke_advisor", + `invoke_agent ${context.config.modelId}`, "gen_ai.invoke_agent", spanContext, async () => { - const requestText = [ - "", - escapeXml(advisorQuestion), - "", - "", - "", - escapeXml(advisorContext), - "", - ].join("\n"); - const requestMessage: PiMessage = { role: "user", content: [{ type: "text", text: requestText }], @@ -230,7 +254,19 @@ export function createAdvisorTool(context: AdvisorToolRuntimeContext) { const assistant = lastAssistantMessage(advisorAgent.state.messages); const newAdvisorMessages = advisorAgent.state.messages.slice(beforeMessageCount); - setSpanAttributes(extractGenAiUsageAttributes(...newAdvisorMessages)); + const outputMessages = newAdvisorMessages.filter(isAssistantMessage); + const outputMessagesAttribute = serializeGenAiAttribute( + conversationPrivacy !== "public" + ? outputMessages.map(toGenAiMessageMetadata) + : outputMessages, + ); + setSpanAttributes({ + ...(outputMessagesAttribute + ? { "gen_ai.output.messages": outputMessagesAttribute } + : {}), + ...toGenAiMessagesTraceAttributes("app.ai.output", outputMessages), + ...extractGenAiUsageAttributes(...newAdvisorMessages), + }); if ( !assistant || @@ -258,9 +294,19 @@ export function createAdvisorTool(context: AdvisorToolRuntimeContext) { return success(memo); }, { - "gen_ai.provider.name": "vercel-ai-gateway", + "gen_ai.provider.name": GEN_AI_PROVIDER_NAME, "gen_ai.operation.name": "invoke_agent", "gen_ai.request.model": context.config.modelId, + "gen_ai.output.type": "text", + "server.address": GEN_AI_SERVER_ADDRESS, + "server.port": GEN_AI_SERVER_PORT, + "app.conversation.privacy": conversationPrivacy, + ...toGenAiMessagesTraceAttributes("app.ai.input", [ + advisorInputMessage, + ]), + ...(advisorInputMessagesAttribute + ? { "gen_ai.input.messages": advisorInputMessagesAttribute } + : {}), }, ); }, diff --git a/packages/junior/src/chat/tools/agent-tools.ts b/packages/junior/src/chat/tools/agent-tools.ts index 5f3f76f1..187f207e 100644 --- a/packages/junior/src/chat/tools/agent-tools.ts +++ b/packages/junior/src/chat/tools/agent-tools.ts @@ -1,4 +1,9 @@ import type { AgentTool } from "@earendil-works/pi-agent-core"; +import { + toGenAiPayloadMetadata, + toGenAiPayloadTraceAttributes, + type ConversationPrivacy, +} from "@/chat/conversation-privacy"; import { serializeGenAiAttribute } from "@/chat/logging"; import { setSpanAttributes, withSpan, type LogContext } from "@/chat/logging"; import { GEN_AI_PROVIDER_NAME } from "@/chat/pi/client"; @@ -28,8 +33,16 @@ export function createAgentTools( pluginAuthOrchestration?: PluginAuthOrchestration, onToolCall?: (toolName: string, params: Record) => void, agentHooks?: AgentPluginHookRunner, + conversationPrivacy?: ConversationPrivacy, ): AgentTool[] { const shouldTrace = shouldEmitDevAgentTrace(); + const effectiveConversationPrivacy = conversationPrivacy ?? "private"; + const serializeToolPayload = (payload: unknown) => + serializeGenAiAttribute( + effectiveConversationPrivacy === "private" + ? toGenAiPayloadMetadata(payload) + : payload, + ); return Object.entries(tools).map(([toolName, toolDef]) => ({ name: toolName, label: toolName, @@ -42,7 +55,11 @@ export function createAgentTools( typeof toolCallId === "string" && toolCallId.length > 0 ? toolCallId : undefined; - const toolArgumentsAttribute = serializeGenAiAttribute(params); + const toolArgumentsAttribute = serializeToolPayload(params); + const toolArgumentsMetadata = toGenAiPayloadTraceAttributes( + "app.ai.tool.call.arguments", + params, + ); if (toolName === "reportProgress") { const status = buildReportedProgressStatus(params); if (status) { @@ -59,11 +76,14 @@ export function createAgentTools( try { if (typeof toolDef.execute !== "function") { const resultDetails = { ok: true }; - const toolResultAttribute = - serializeGenAiAttribute(resultDetails); + const toolResultAttribute = serializeToolPayload(resultDetails); if (toolResultAttribute) { setSpanAttributes({ "gen_ai.tool.call.result": toolResultAttribute, + ...toGenAiPayloadTraceAttributes( + "app.ai.tool.call.result", + resultDetails, + ), }); } return { @@ -113,10 +133,14 @@ export function createAgentTools( ? (normalized.details as { rawResult: unknown }).rawResult : normalized.details; const toolResultAttribute = - serializeGenAiAttribute(resultAttributeValue); + serializeToolPayload(resultAttributeValue); if (toolResultAttribute) { setSpanAttributes({ "gen_ai.tool.call.result": toolResultAttribute, + ...toGenAiPayloadTraceAttributes( + "app.ai.tool.call.result", + resultAttributeValue, + ), }); } return normalized; @@ -139,8 +163,10 @@ export function createAgentTools( { "gen_ai.provider.name": GEN_AI_PROVIDER_NAME, "gen_ai.operation.name": "execute_tool", + "app.conversation.privacy": effectiveConversationPrivacy, "gen_ai.tool.name": toolName, "gen_ai.tool.description": toolDef.description, + ...toolArgumentsMetadata, ...(normalizedToolCallId ? { "gen_ai.tool.call.id": normalizedToolCallId } : {}), diff --git a/packages/junior/src/cli/init.ts b/packages/junior/src/cli/init.ts index 040d0a8e..f6af9c83 100644 --- a/packages/junior/src/cli/init.ts +++ b/packages/junior/src/cli/init.ts @@ -123,7 +123,6 @@ export async function runInit( }, dependencies: { "@sentry/junior": "latest", - "@sentry/node": "^10.0.0", hono: "^4.12.0", }, devDependencies: { diff --git a/packages/junior/src/handlers/diagnostics-dashboard.ts b/packages/junior/src/handlers/diagnostics-dashboard.ts deleted file mode 100644 index 753cd742..00000000 --- a/packages/junior/src/handlers/diagnostics-dashboard.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { escapeXml as esc } from "@/chat/xml"; -import { GET as diagnosticsGET } from "@/handlers/diagnostics"; -import { GET as healthGET } from "@/handlers/health"; - -/** Serve an HTML diagnostics dashboard showing health, plugins, and skills. */ -export async function GET(): Promise { - let health: { ok: boolean; data?: Record; error?: string }; - let discovery: { - ok: boolean; - data?: Record; - error?: string; - }; - - try { - const res = await healthGET(); - health = { - ok: res.ok, - data: (await res.json()) as Record, - }; - } catch (e: unknown) { - health = { ok: false, error: String(e) }; - } - - try { - const res = await diagnosticsGET(); - if (res.ok) { - discovery = { - ok: true, - data: (await res.json()) as Record, - }; - } else { - discovery = { ok: false, error: `${res.status} ${res.statusText}` }; - } - } catch (e: unknown) { - discovery = { ok: false, error: String(e) }; - } - - const d = discovery.ok ? discovery.data : null; - - let html = ` - - - - - Junior - - - -

> 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 e45946c6..00000000 --- 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 b6eeb42d..89d44f9b 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 00000000..4c02edcf --- /dev/null +++ b/packages/junior/src/reporting.ts @@ -0,0 +1,602 @@ +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 { + getAgentTurnSessionCheckpoint, + listAgentTurnSessionSummaries, + type AgentTurnRequester, + type AgentTurnSessionSummary, +} from "@/chat/state/turn-session-store"; +import { GET as healthGET } from "@/handlers/health"; + +const HUNG_TURN_PROGRESS_MS = 5 * 60 * 1000; + +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_checkpoints"; + 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 uses expiring Redis checkpoints, 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"; + } + 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; + const output = + Array.isArray(content) && content.length === 1 + ? recordField(content[0] as Record, [ + "text", + "content", + "output", + "result", + ]) + : 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); + 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_checkpoints", + generatedAt: new Date().toISOString(), + sessions: summaries.map(sessionReportFromSummary), + }; +} + +async function readConversation( + conversationId: string, +): Promise { + const summaries = (await listAgentTurnSessionSummaries(200)).filter( + (summary) => summary.conversationId === conversationId, + ); + summaries.sort((left, right) => left.startedAtMs - right.startedAtMs); + + const turns = await Promise.all( + summaries.map(async (summary): Promise => { + const checkpoint = await getAgentTurnSessionCheckpoint( + summary.conversationId, + summary.sessionId, + ); + const scopedMessages = checkpoint?.piMessages + ? turnScopedMessages(checkpoint.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 ?? + checkpoint?.traceId ?? + (canExposeTranscript ? traceIdFromTranscript(transcript) : undefined); + const sentryTraceUrl = traceId ? buildSentryTraceUrl(traceId) : undefined; + return { + ...sessionReportFromSummary(summary), + ...(traceId ? { traceId } : {}), + ...(sentryTraceUrl ? { sentryTraceUrl } : {}), + transcriptAvailable: Boolean(checkpoint) && canExposeTranscript, + ...(checkpoint && scopedMessages.length > 0 + ? { transcriptMessageCount: scopedMessages.length } + : {}), + ...(checkpoint && !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/example-build-discovery.test.ts b/packages/junior/tests/integration/example-build-discovery.test.ts index c9901286..5996f3b8 100644 --- a/packages/junior/tests/integration/example-build-discovery.test.ts +++ b/packages/junior/tests/integration/example-build-discovery.test.ts @@ -102,7 +102,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 +110,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 c49a0c49..ef04659c 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 5200f8e3..11f219ba 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 e7667f08..8d1a77c3 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/runtime/respond-lazy-sandbox.test.ts b/packages/junior/tests/unit/runtime/respond-lazy-sandbox.test.ts index 70ebe5e4..8d7a02df 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 40d43b0e..ac124abd 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 @@ -374,6 +374,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 4d6e1a3e..bcb57877 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 f31c5cc4..4974fa76 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/services/turn-checkpoint.test.ts b/packages/junior/tests/unit/services/turn-checkpoint.test.ts index 37574dd2..60948532 100644 --- a/packages/junior/tests/unit/services/turn-checkpoint.test.ts +++ b/packages/junior/tests/unit/services/turn-checkpoint.test.ts @@ -18,7 +18,9 @@ describe("persistAuthPauseCheckpoint", () => { const { disconnectStateAdapter } = await import("@/chat/state/adapter"); await disconnectStateAdapter(); vi.doUnmock("@/chat/logging"); + vi.doUnmock("@/chat/sentry"); vi.doUnmock("@/chat/state/turn-session-store"); + vi.useRealTimers(); vi.resetModules(); process.env = { ...ORIGINAL_ENV }; }); @@ -151,6 +153,337 @@ describe("persistAuthPauseCheckpoint", () => { }); }); + it("indexes recent turn sessions for dashboard reporting", async () => { + const { listAgentTurnSessionSummaries, upsertAgentTurnSessionCheckpoint } = + await import("@/chat/state/turn-session-store"); + + await upsertAgentTurnSessionCheckpoint({ + conversationId: "slack:C1:111", + sessionId: "turn-1", + sliceId: 1, + state: "running", + piMessages: [], + }); + + await upsertAgentTurnSessionCheckpoint({ + conversationId: "slack:C1:111", + sessionId: "turn-1", + sliceId: 2, + state: "completed", + piMessages: [], + cumulativeDurationMs: 1_200, + errorMessage: "provider failed with sensitive details", + loadedSkillNames: ["triage"], + }); + + await upsertAgentTurnSessionCheckpoint({ + 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 dashboard session summaries from turn checkpoints", async () => { + process.env.SENTRY_ORG_SLUG = "sentry"; + vi.doMock("@/chat/sentry", () => ({ + getClient: () => ({ + getDsn: () => ({ + protocol: "https", + host: "o123.ingest.sentry.io", + projectId: "4501", + }), + }), + })); + const { upsertAgentTurnSessionCheckpoint } = + await import("@/chat/state/turn-session-store"); + const { createJuniorReporting } = await import("@/reporting"); + + await upsertAgentTurnSessionCheckpoint({ + conversationId: "slack:C1:111", + sessionId: "turn-report", + sliceId: 1, + state: "awaiting_resume", + piMessages: [], + resumeReason: "auth", + }); + + const feed = await createJuniorReporting().getSessions(); + + expect(feed.source).toBe("turn_session_checkpoints"); + expect(feed.sessions).toContainEqual( + expect.objectContaining({ + id: "turn-report", + status: "active", + surface: "slack", + title: "Awaiting auth resume", + channel: "C1", + sentryConversationUrl: + "https://sentry.sentry.io/explore/conversations/slack%3AC1%3A111/?project=4501", + }), + ); + }); + + it("marks stale running turns as hung in dashboard summaries", async () => { + vi.useFakeTimers(); + const { upsertAgentTurnSessionCheckpoint } = + await import("@/chat/state/turn-session-store"); + const { createJuniorReporting } = await import("@/reporting"); + + vi.setSystemTime(new Date("2026-05-29T12:00:00.000Z")); + await upsertAgentTurnSessionCheckpoint({ + conversationId: "slack:C1:hung", + sessionId: "turn-hung", + sliceId: 1, + state: "running", + piMessages: [], + }); + + vi.setSystemTime(new Date("2026-05-29T12:06:00.000Z")); + const feed = await createJuniorReporting().getSessions(); + + expect(feed.sessions).toContainEqual( + expect.objectContaining({ + conversationId: "slack:C1:hung", + id: "turn-hung", + lastProgressAt: "2026-05-29T12:00:00.000Z", + status: "hung", + }), + ); + }); + + it("reports only the current turn transcript from full checkpoint context", async () => { + const { upsertAgentTurnSessionCheckpoint } = + await import("@/chat/state/turn-session-store"); + const { createJuniorReporting } = await import("@/reporting"); + + await upsertAgentTurnSessionCheckpoint({ + 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("redacts dashboard transcripts for non-public conversations", async () => { + const { upsertAgentTurnSessionCheckpoint } = + await import("@/chat/state/turn-session-store"); + const { createJuniorReporting } = await import("@/reporting"); + + await upsertAgentTurnSessionCheckpoint({ + 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: { query: "private lookup" }, + }, + ], + timestamp: 2, + }, + ] as PiMessage[], + traceId: "0123456789abcdef0123456789abcdef", + }); + + const report = + await createJuniorReporting().getConversation("slack:D1:222"); + + expect(report.turns).toHaveLength(1); + expect(report.turns[0]).toMatchObject({ + conversationTitle: "Direct Message", + channelName: "Direct Message", + id: "turn-private", + traceId: "0123456789abcdef0123456789abcdef", + transcriptAvailable: false, + transcriptMessageCount: 2, + transcriptMetadata: [ + { + role: "user", + timestamp: 1, + parts: [{ type: "text", redacted: true, chars: 16 }], + }, + { + role: "assistant", + timestamp: 2, + parts: [ + { type: "text", redacted: true, chars: 14 }, + { + type: "tool_call", + name: "search", + redacted: true, + inputKeys: ["query"], + inputSizeChars: 26, + inputType: "object", + }, + ], + }, + ], + 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 lookup"); + expect(JSON.stringify(report)).not.toContain( + "sensitive generated thread title", + ); + expect(JSON.stringify(report)).not.toContain("secret-dm-name"); + }); + + it("redacts private channel titles and names in dashboard reports", async () => { + const { upsertAgentTurnSessionCheckpoint } = + await import("@/chat/state/turn-session-store"); + const { createJuniorReporting } = await import("@/reporting"); + + await upsertAgentTurnSessionCheckpoint({ + conversationId: "slack:G1:222", + sessionId: "turn-private-channel", + sliceId: 1, + state: "completed", + channelName: "secret-channel", + conversationTitle: "sensitive private channel title", + piMessages: [], + }); + + const report = + await createJuniorReporting().getConversation("slack:G1:222"); + + expect(report.turns[0]).toMatchObject({ + conversationTitle: "Private Channel", + channelName: "Private Channel", + }); + expect(JSON.stringify(report)).not.toContain("secret-channel"); + expect(JSON.stringify(report)).not.toContain( + "sensitive private channel title", + ); + }); + + it("reports conversation turns in chronological order", async () => { + const { upsertAgentTurnSessionCheckpoint } = + await import("@/chat/state/turn-session-store"); + const { createJuniorReporting } = await import("@/reporting"); + + await upsertAgentTurnSessionCheckpoint({ + conversationId: "slack:C1:333", + sessionId: "turn-older", + sliceId: 1, + state: "completed", + piMessages: [ + { + role: "user", + content: [{ type: "text", text: "first" }], + timestamp: 100, + }, + ] as PiMessage[], + }); + await new Promise((resolve) => setTimeout(resolve, 1)); + await upsertAgentTurnSessionCheckpoint({ + conversationId: "slack:C1:333", + sessionId: "turn-newer", + sliceId: 1, + state: "completed", + piMessages: [ + { + role: "user", + content: [{ type: "text", text: "second" }], + timestamp: 300, + }, + ] as PiMessage[], + }); + + const report = + await createJuniorReporting().getConversation("slack:C1:333"); + + expect(report.turns.map((turn) => turn.id)).toEqual([ + "turn-older", + "turn-newer", + ]); + }); + it("does not fail a completed turn when checkpoint persistence fails", async () => { const logException = vi.fn(); vi.doMock("@/chat/logging", () => ({ 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 00000000..3607b24f --- /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 a3bb5ded..2750a213 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 8376e6ee..b76f57fa 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 b861ba10..a0069f29 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 d2595fd6..c83aa9a3 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 8e0e6505..184610e5 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/scripts/bump-release-versions.mjs b/scripts/bump-release-versions.mjs index 2398828c..46fff057 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 c337b28d..ce63caeb 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 c9fb7ba4..1ce68fb7 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 0603471a..a5502ab4 100644 --- a/specs/agent-session-resumability.md +++ b/specs/agent-session-resumability.md @@ -54,6 +54,7 @@ A conversation can have multiple sessions over time. Each checkpoint version ide - Durable thread state may point at the active or last completed agent session. It must not become a second source of truth for mid-turn Pi execution history. - Channel configuration is reloaded from the canonical state/configuration services on resume, not copied into the checkpoint payload. - 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. ### Session States diff --git a/specs/dashboard.md b/specs/dashboard.md new file mode 100644 index 00000000..645cb5ad --- /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 checkpoints. | +| `GET /api/dashboard/conversations/:conversation` | Conversation transcript from expiring checkpoints. | +| `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 checkpoint 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 checkpoint payloads, or checkpoint 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 00000000..6e8d4f39 --- /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 checkpoint 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 + checkpoint 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 b24325d5..08446dd5 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/slack-agent-delivery.md` - `specs/slack-outbound-contract.md` @@ -48,6 +49,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 47e6b3dd..6fdac960 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 bb72b60b..038b3abb 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 725db030..e6b2cd08 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 598db6ac..9d7174bf 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