From 88dfd5b993e3ee1cfd95f03783db8ce907bb7416 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 13:59:10 +0000 Subject: [PATCH 1/7] Phase 0: review CLI with Agent SDK runner, Playwright driver and Claudio tools Monorepo scaffold (pnpm, TypeScript) with the first end-to-end slice of the automated user review: - core: zod schemas (findings, evidence, acceptance results, review request and report), budget tiers, strictness/verbosity rendering, repo config - github: PR context with changed files, linked issues from body and branch name, PR comment posting - browser: Playwright driver that launches Chromium and attaches Playwright MCP over CDP so Claudio can capture console/network and take screenshots in the same browser; adaptive auto-login plus recipe-based login with credentials kept on the host; Chrome extension driver stub for live mode; file-backed run store; Claudio tools (login_as, capture_evidence, get_console, get_network, report_finding, record_ac_result, note, todo_*) usable in-process and as a stdio MCP server - agent: Agent SDK runner with budget-driven model/effort/turn limits, PreToolUse guard (host allowlist, step budget), prompts and subagent definitions, report writer - cli: `claudio review` with driver, budget, verbosity, strictness options and offline mode via a PR-context fixture - demo-target: tiny dispatch app with deliberate bugs, fixture PR and an example report from a real run - docs: architecture plan and README Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NirxwvzzAcZMDVkE3AYN2x --- .claude/agents/impact-analyst.md | 23 + .claude/agents/ux-reviewer.md | 19 + .../skills/adaptive-browser-testing/SKILL.md | 31 + .claude/skills/pr-review/SKILL.md | 33 + .env.example | 9 + .gitignore | 11 + .mcp.json | 15 + .npmrc | 2 + README.md | 74 +- apps/cli/package.json | 18 + apps/cli/src/main.ts | 93 + apps/cli/tsconfig.json | 5 + apps/demo-target/README.md | 19 + apps/demo-target/claudio.config.json | 8 + apps/demo-target/fixtures/example-report.md | 138 + apps/demo-target/fixtures/pr-context.json | 32 + apps/demo-target/package.json | 11 + apps/demo-target/src/server.mjs | 94 + claudio.config.example.json | 26 + docs/ARCHITECTURE.md | 157 ++ package.json | 20 + packages/agent/package.json | 21 + packages/agent/src/hooks.ts | 74 + packages/agent/src/index.ts | 4 + packages/agent/src/prompts.ts | 124 + packages/agent/src/runner.ts | 263 ++ packages/agent/src/sdk-tools.ts | 22 + packages/agent/test/prompts.test.ts | 48 + packages/agent/tsconfig.json | 5 + .../page-2026-10-10T13-43-33-002Z.yml | 1 + packages/browser/package.json | 21 + packages/browser/src/capture.ts | 159 ++ packages/browser/src/chrome-driver.ts | 37 + packages/browser/src/driver.ts | 52 + packages/browser/src/index.ts | 16 + packages/browser/src/login.ts | 131 + packages/browser/src/playwright-driver.ts | 133 + packages/browser/src/run-store.ts | 120 + packages/browser/src/tools/definitions.ts | 236 ++ packages/browser/src/tools/stdio-server.ts | 70 + .../playwright-driver.integration.test.ts | 105 + packages/browser/test/run-store.test.ts | 55 + packages/browser/tsconfig.json | 5 + packages/core/package.json | 17 + packages/core/src/budget.ts | 49 + packages/core/src/config.ts | 22 + packages/core/src/ids.ts | 10 + packages/core/src/index.ts | 5 + packages/core/src/report.ts | 223 ++ packages/core/src/schemas.ts | 172 ++ packages/core/test/config.test.ts | 20 + packages/core/test/report.test.ts | 62 + packages/core/tsconfig.json | 5 + packages/github/package.json | 19 + packages/github/src/client.ts | 105 + packages/github/src/index.ts | 2 + packages/github/src/links.ts | 24 + packages/github/test/links.test.ts | 17 + packages/github/tsconfig.json | 5 + pnpm-lock.yaml | 2308 +++++++++++++++++ pnpm-workspace.yaml | 3 + tsconfig.base.json | 17 + vitest.config.mts | 7 + 63 files changed, 5631 insertions(+), 1 deletion(-) create mode 100644 .claude/agents/impact-analyst.md create mode 100644 .claude/agents/ux-reviewer.md create mode 100644 .claude/skills/adaptive-browser-testing/SKILL.md create mode 100644 .claude/skills/pr-review/SKILL.md create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .mcp.json create mode 100644 .npmrc create mode 100644 apps/cli/package.json create mode 100644 apps/cli/src/main.ts create mode 100644 apps/cli/tsconfig.json create mode 100644 apps/demo-target/README.md create mode 100644 apps/demo-target/claudio.config.json create mode 100644 apps/demo-target/fixtures/example-report.md create mode 100644 apps/demo-target/fixtures/pr-context.json create mode 100644 apps/demo-target/package.json create mode 100644 apps/demo-target/src/server.mjs create mode 100644 claudio.config.example.json create mode 100644 docs/ARCHITECTURE.md create mode 100644 package.json create mode 100644 packages/agent/package.json create mode 100644 packages/agent/src/hooks.ts create mode 100644 packages/agent/src/index.ts create mode 100644 packages/agent/src/prompts.ts create mode 100644 packages/agent/src/runner.ts create mode 100644 packages/agent/src/sdk-tools.ts create mode 100644 packages/agent/test/prompts.test.ts create mode 100644 packages/agent/tsconfig.json create mode 100644 packages/browser/.playwright-mcp/page-2026-10-10T13-43-33-002Z.yml create mode 100644 packages/browser/package.json create mode 100644 packages/browser/src/capture.ts create mode 100644 packages/browser/src/chrome-driver.ts create mode 100644 packages/browser/src/driver.ts create mode 100644 packages/browser/src/index.ts create mode 100644 packages/browser/src/login.ts create mode 100644 packages/browser/src/playwright-driver.ts create mode 100644 packages/browser/src/run-store.ts create mode 100644 packages/browser/src/tools/definitions.ts create mode 100644 packages/browser/src/tools/stdio-server.ts create mode 100644 packages/browser/test/playwright-driver.integration.test.ts create mode 100644 packages/browser/test/run-store.test.ts create mode 100644 packages/browser/tsconfig.json create mode 100644 packages/core/package.json create mode 100644 packages/core/src/budget.ts create mode 100644 packages/core/src/config.ts create mode 100644 packages/core/src/ids.ts create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/src/report.ts create mode 100644 packages/core/src/schemas.ts create mode 100644 packages/core/test/config.test.ts create mode 100644 packages/core/test/report.test.ts create mode 100644 packages/core/tsconfig.json create mode 100644 packages/github/package.json create mode 100644 packages/github/src/client.ts create mode 100644 packages/github/src/index.ts create mode 100644 packages/github/src/links.ts create mode 100644 packages/github/test/links.test.ts create mode 100644 packages/github/tsconfig.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 tsconfig.base.json create mode 100644 vitest.config.mts diff --git a/.claude/agents/impact-analyst.md b/.claude/agents/impact-analyst.md new file mode 100644 index 0000000..479dac9 --- /dev/null +++ b/.claude/agents/impact-analyst.md @@ -0,0 +1,23 @@ +--- +name: impact-analyst +description: Read-only code analyst. Given a PR diff and a repository checkout, determines which routes, screens, flows and shared components are affected and what regression checks are worth running. Use it before browser testing when the diff touches shared code. +tools: Read, Grep, Glob +model: claude-sonnet-5-5 +--- + +You analyse the impact of a code change on a web application so a reviewer knows what to test. + +Input: the list of changed files (with patches) and the path of the repository checkout. + +Steps: +1. For every changed file, find where it is used: imports, route files, layouts, stores, shared components, server endpoints. For SvelteKit, map `src/routes/**/+page.svelte`, `+page.server.ts`, `+layout*.svelte` and `+server.ts` files to URL paths; components under `src/lib` are shared. +2. Decide which user-visible screens and flows can change behaviour because of the diff. Distinguish "directly changed" from "indirectly affected through a shared module". +3. For each affected area, name one concrete regression check a tester could perform in the browser (what to do, what should still happen). + +Output a compact report: +- Directly changed areas: route or screen, why. +- Indirectly affected areas: route or screen, through which shared module. +- Suggested regression checks: numbered, one line each, ordered by risk. +- Risk notes: data migrations, permission or role checks, API contract changes, anything that could break other tenants or roles. + +Keep it factual and short. Cite file paths. Say explicitly when a change is isolated and no regression checks are needed. diff --git a/.claude/agents/ux-reviewer.md b/.claude/agents/ux-reviewer.md new file mode 100644 index 0000000..06d4f8c --- /dev/null +++ b/.claude/agents/ux-reviewer.md @@ -0,0 +1,19 @@ +--- +name: ux-reviewer +description: Reviews screenshots of changed screens for UX and UI quality and consistency. Use after the functional checks, passing the evidence file paths and a short description of each screen's purpose. Read-only; it reports, the orchestrator records findings. +tools: Read, Glob +model: claude-opus-5-5 +--- + +You are a senior product designer reviewing screenshots of a business web application (dispatch and transport planning software used by local authorities and transport companies). + +Input: screenshot file paths under the run's evidence directory and a one-line purpose per screen. Read each image with the Read tool. + +Judge, per screen: +- Clarity: is the purpose and the next action obvious? Are labels and wording consistent with the rest of the app (same terms for the same things)? +- Feedback: do actions show success, loading and error states? Are validation messages near the field and understandable? +- Consistency: spacing, alignment, button hierarchy, typography and colours in line with the other screenshots. +- Accessibility basics: contrast, focus visibility, touch target size, text size. +- Data presentation: tables, dates, numbers and units formatted consistently (German locale where the app is German). + +Output a list of observations, each with: screen, severity proposal (major, minor, suggestion), what is wrong, what you would change, and the evidence file. Do not invent problems to fill the list; say "no issues" for a screen when that is the honest verdict. Be concrete enough that a developer can act without asking. diff --git a/.claude/skills/adaptive-browser-testing/SKILL.md b/.claude/skills/adaptive-browser-testing/SKILL.md new file mode 100644 index 0000000..917d89b --- /dev/null +++ b/.claude/skills/adaptive-browser-testing/SKILL.md @@ -0,0 +1,31 @@ +--- +name: adaptive-browser-testing +description: How to test an unfamiliar web application without relying on prepared test data: discovering structure, creating and tracking your own data, handling SvelteKit apps, and keeping within the step budget. +--- + +# Adaptive browser testing + +## Discover before acting +- Start from the page after login. Snapshot, note the main navigation, and only open the areas related to the change. +- Read empty states and placeholder texts: they tell you what the app expects. +- When a flow needs an existing record (a passenger for a trip, a vehicle for a tour), check whether one exists; otherwise create one first and note it. + +## Your own test data +- Name everything you create `claudio--` so people can find and delete it. Use plausible but clearly synthetic values (no real names or addresses). +- Record each created record with `note` (type, name, where). Do not delete data unless the test requires it; cleanup is a human decision. +- Never change settings that affect other users (tenant settings, user roles, integrations) unless the acceptance criteria require it. If they do, revert afterwards and note it. + +## SvelteKit specifics +- Navigation is client-side: after clicking a link, wait for the snapshot to show the new content rather than relying on a full page load. +- Forms often use progressive enhancement: a submit may not reload the page. Check the result in the snapshot and in `get_network` (look for the POST and its status). +- Server load errors show as error pages or toasts; check `get_console` for hydration warnings after navigating to a changed route. +- Pending states can be brief; if a check depends on them, say that you could not observe them rather than asserting they are missing. + +## Budget discipline +- Each browser tool call is a step. Plan the list first, then execute. Avoid re-snapshotting a page you have not changed. +- Combine checks: one visit to a list screen can verify sorting, filtering and the new column. +- When the step budget warning arrives, stop exploring, record what you have, and write the summary. + +## Evidence +- Screenshot the moment that proves the point: validation message visible, success toast visible, wrong value highlighted. +- One evidence id can back several findings or criteria; reuse it instead of taking the same screenshot twice. diff --git a/.claude/skills/pr-review/SKILL.md b/.claude/skills/pr-review/SKILL.md new file mode 100644 index 0000000..d1a091c --- /dev/null +++ b/.claude/skills/pr-review/SKILL.md @@ -0,0 +1,33 @@ +--- +name: pr-review +description: Procedure for reviewing a pull request as a user-acceptance tester in the running app: acceptance criteria, regression checks on affected areas, UX/UI judgement, evidence and reporting through the Claudio tools. +--- + +# PR review procedure + +## 1. Acceptance criteria +- Read the linked issue(s) and PR description. Extract criteria as testable statements ("A dispatcher can ... and sees ..."). +- Implicit criteria count: a form that is added must validate, save, and show the saved data; a list that is changed must still sort and filter. +- Put each criterion on the todo list (`todo_add`). Record the outcome of each with `record_ac_result`, with evidence ids. + +## 2. Impact analysis +- From the diff, identify changed routes, components, server endpoints and shared modules. Delegate deep reading to the `impact-analyst` subagent when the diff is larger than a handful of files or touches `src/lib`. +- Add one regression todo per affected area. Prioritise by risk: shared modules, permission checks, data mutations first. + +## 3. Browser work +- Log in with `login_as`. Snapshot before acting. Prefer accessibility snapshots over screenshots to read state. +- After each flow: `get_console` (level error) and `get_network` (onlyErrors). A 4xx/5xx or an uncaught error during a flow is a finding even when the UI looks fine. +- Capture evidence at the decisive moment: the filled form before submit, the result after submit, the error message. +- Test with the role the feature is for. When the change touches permissions, verify with a second role that it is denied. + +## 4. Findings +- `report_finding` for every problem. Steps to reproduce, expected, actual, evidence. +- Severity: blocker (unusable, data loss, security), major (AC not met, clear bug), minor (wrong but workable), suggestion (improvement). +- One finding per root cause. Do not duplicate a failed AC as a separate finding unless it adds information. + +## 5. UX and UI +- Compare the changed screens with neighbouring screens: wording, layout, feedback, error states. +- Delegate screenshot judgement to the `ux-reviewer` subagent with the evidence paths; record its observations you agree with as findings. + +## 6. Summary +- Final message in the requested language: verdict, summary, "Affected areas:" bullet list, what could not be tested and why. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b0dac82 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# Anthropic API key (Console). Used for unattended runs with the playwright driver. +ANTHROPIC_API_KEY= + +# GitHub token with read access to the target repos (Phase 0: fine-grained PAT; later: GitHub App). +GITHUB_TOKEN= + +# Credentials for target-app roles are referenced from claudio.config.json via env var names, e.g.: +# CLAUDIO_ROLE_ADMIN_USER= +# CLAUDIO_ROLE_ADMIN_PASSWORD= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d8a8604 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +dist/ +runs/ +.env +.env.* +!.env.example +*.log +.DS_Store +claudio.config.local.json + +.scratch/ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..67ba587 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,15 @@ +{ + "mcpServers": { + "claudio": { + "command": "node", + "args": ["--import", "tsx", "packages/browser/src/tools/stdio-server.ts"], + "env": { + "CLAUDIO_RUN_DIR": "${CLAUDIO_RUN_DIR}", + "CLAUDIO_INSTANCE_URL": "${CLAUDIO_INSTANCE_URL}", + "CLAUDIO_CONFIG": "${CLAUDIO_CONFIG}", + "CLAUDIO_DRIVER": "${CLAUDIO_DRIVER}", + "CLAUDIO_CDP_ENDPOINT": "${CLAUDIO_CDP_ENDPOINT}" + } + } + } +} diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..4c2f52b --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +auto-install-peers=true +strict-peer-dependencies=false diff --git a/README.md b/README.md index 2b4cd81..23d8b92 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,74 @@ # Claudio -Agentic frontend test suite. + +Agentic user review for web apps. Claudio takes over the manual acceptance review of a feature branch: it reads the pull request and its linked GitHub issues, derives the acceptance criteria, logs into a running instance of the branch, verifies the criteria in the browser, checks the areas the diff affects for regressions, judges UX and UI, and writes a report with evidence. + +Built on the Claude Agent SDK, Playwright MCP and the Claude in Chrome extension. See `docs/ARCHITECTURE.md` for the full plan. + +## Status + +Phase 0 (spike): the `claudio review` CLI runs one PR review end to end with the Playwright driver and writes a Markdown/JSON report. No web UI yet. + +## Setup + +```sh +pnpm install +cp .env.example .env # ANTHROPIC_API_KEY, GITHUB_TOKEN, role credentials +cp claudio.config.example.json claudio.config.json +``` + +`claudio.config.json` describes the target repository: the roles the agent may log in as (credentials are referenced by environment variable name and never reach the model), optional login recipes, and notes about the application. + +Chromium: Playwright's bundled Chromium is used by default (`pnpm exec playwright install chromium`). Set `CLAUDIO_CHROMIUM_PATH` to use another binary. + +## Running a review + +```sh +pnpm claudio review \ + --repo vanderlars/some-app \ + --pr 123 \ + --url https://pr-123.preview.example.de \ + --role dispatcher \ + --budget standard \ + --verbosity normal \ + --strictness normal \ + --comment # optional: post a TL;DR comment to the PR +``` + +Options: + +| Flag | Values | Default | +|---|---|---| +| `--driver` | `playwright` (unattended, headless), `chrome` (live mode, experimental) | `playwright` | +| `--budget` | `quick`, `standard`, `thorough` | `standard` | +| `--verbosity` | `tldr`, `normal`, `detailed` | `normal` | +| `--strictness` | `lenient`, `normal`, `strict` | `normal` | +| `--lang` | `de`, `en` | `de` | +| `--headed` | show the browser window | off | +| `--no-clone` | skip checking out the PR branch for code analysis | clone | + +Every run writes to `runs//`: `report.md` (at the chosen verbosity), `report-detailed.md`, `report.json`, `findings.json`, `acceptance.json`, `evidence/`, `console.json`, `network.json`, `trace.zip` (Playwright trace), `log.jsonl` (agent transcript) and `prompt.md`. + +## Live mode with Claude in Chrome + +The live driver lets a person watch and step in. It runs through the Claude Code harness with the Chrome extension and needs a Claude subscription login on the machine. From this repository: + +```sh +export CLAUDIO_RUN_DIR=$PWD/runs/live-1 CLAUDIO_INSTANCE_URL=https://pr-123.preview.example.de CLAUDIO_CONFIG=$PWD/claudio.config.json CLAUDIO_DRIVER=chrome +claude --chrome +``` + +The `.mcp.json` in this repository registers the Claudio tool server, and `.claude/agents` and `.claude/skills` hold the same review procedure the unattended driver uses. Whether the Agent SDK can start this mode itself (`--driver chrome`) is still being verified. + +## Trying it without a real deployment + +`apps/demo-target` is a tiny dispatch app with deliberate bugs plus a fixture PR context. See its README; the review runs fully offline except for the model calls. + +## Development + +```sh +pnpm typecheck +pnpm test # unit tests +CLAUDIO_INTEGRATION=1 pnpm test # also the Playwright driver integration test (needs Chromium) +``` + +Packages: `core` (schemas, budget tiers, report rendering), `github` (PR and issue context), `browser` (drivers, capture, login, Claudio tools), `agent` (Agent SDK runner, prompts, hooks), `apps/cli`. diff --git a/apps/cli/package.json b/apps/cli/package.json new file mode 100644 index 0000000..d20a5fe --- /dev/null +++ b/apps/cli/package.json @@ -0,0 +1,18 @@ +{ + "name": "@claudio/cli", + "version": "0.1.0", + "private": true, + "type": "module", + "bin": { + "claudio": "./src/main.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@claudio/agent": "workspace:*", + "@claudio/browser": "workspace:*", + "@claudio/core": "workspace:*", + "commander": "^15.0.0" + } +} diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts new file mode 100644 index 0000000..8488bc9 --- /dev/null +++ b/apps/cli/src/main.ts @@ -0,0 +1,93 @@ +#!/usr/bin/env node +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { Command, InvalidArgumentError } from "commander"; +import { runReview } from "@claudio/agent"; +import { BudgetTierSchema, DriverNameSchema, LanguageSchema, StrictnessSchema, VerbositySchema, blockingFindings, verdict } from "@claudio/core"; + +if (existsSync(".env")) { + process.loadEnvFile(".env"); +} + +const program = new Command(); +program.name("claudio").description("Agentic user review for web apps").version("0.1.0"); + +function enumParser(name: string, values: readonly string[]) { + return (v: string) => { + if (!values.includes(v)) throw new InvalidArgumentError(`${name} must be one of ${values.join(", ")}`); + return v; + }; +} + +program + .command("review") + .description("Review a pull request against a running instance of the app") + .requiredOption("--repo ", "GitHub repository") + .requiredOption("--pr ", "Pull request number", (v) => { + const n = Number(v); + if (!Number.isInteger(n) || n <= 0) throw new InvalidArgumentError("pr must be a positive integer"); + return n; + }) + .requiredOption("--url ", "Base URL of the deployed PR branch") + .option("--role ", "Role(s) to start with (must be configured in claudio.config.json)") + .option("--driver ", "playwright (unattended) or chrome (live, Claude in Chrome)", enumParser("driver", DriverNameSchema.options), "playwright") + .option("--budget ", "quick | standard | thorough", enumParser("budget", BudgetTierSchema.options), "standard") + .option("--verbosity ", "tldr | normal | detailed", enumParser("verbosity", VerbositySchema.options), "normal") + .option("--strictness ", "lenient | normal | strict", enumParser("strictness", StrictnessSchema.options), "normal") + .option("--lang ", "Report language: de | en", enumParser("lang", LanguageSchema.options), "de") + .option("--headed", "Show the browser window (playwright driver)", false) + .option("--comment", "Post a TL;DR comment to the pull request", false) + .option("--no-clone", "Skip cloning the PR branch for code analysis") + .option("--model ", "Override the orchestrator model") + .option("--instructions ", "Extra instructions for this run") + .option("--pr-context ", "Use a PullRequestContext JSON file instead of GitHub (offline runs)") + .option("--repo-dir ", "Use an existing checkout instead of cloning the PR branch") + .option("--config ", "Path to claudio.config.json", "claudio.config.json") + .option("--runs ", "Directory for run outputs", "runs") + .action(async (opts) => { + const abort = new AbortController(); + process.on("SIGINT", () => { + console.error("\naborting run..."); + abort.abort(); + }); + const report = await runReview( + { + repo: opts.repo, + pr: opts.pr, + instanceUrl: opts.url, + roles: opts.role ?? [], + driver: opts.driver, + budget: opts.budget, + verbosity: opts.verbosity, + strictness: opts.strictness, + language: opts.lang, + headed: opts.headed, + postComment: opts.comment, + cloneRepo: opts.clone, + modelOverride: opts.model, + extraInstructions: opts.instructions, + }, + { + runsRoot: resolve(opts.runs), + configPath: opts.config, + prContextFile: opts.prContext, + repoDir: opts.repoDir, + projectRoot: process.cwd(), + abortController: abort, + onProgress: (line) => console.error(`[claudio] ${line}`), + }, + ); + const blocking = blockingFindings(report); + console.log(""); + console.log(`Verdict: ${verdict(report)} (${report.status})`); + console.log(`Acceptance criteria: ${report.acceptanceCriteria.filter((a) => a.status === "passed").length}/${report.acceptanceCriteria.length} passed`); + console.log(`Findings: ${report.findings.length} (blocking: ${blocking.length})`); + console.log(`Cost: ${report.cost.usd.toFixed(2)} USD, ${report.cost.turns} turns, ${report.cost.browserSteps} browser steps, ${Math.round(report.cost.durationMs / 1000)}s`); + console.log(`Report: ${resolve(opts.runs)}/${report.runId}/report.md`); + process.exitCode = report.status === "completed" ? 0 : 2; + }); + +program.parseAsync(process.argv).catch((e) => { + console.error(e instanceof Error ? e.message : e); + process.exit(1); +}); diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json new file mode 100644 index 0000000..404aab4 --- /dev/null +++ b/apps/cli/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "noEmit": true, "rootDir": "src" }, + "include": ["src"] +} diff --git a/apps/demo-target/README.md b/apps/demo-target/README.md new file mode 100644 index 0000000..b2bd8cb --- /dev/null +++ b/apps/demo-target/README.md @@ -0,0 +1,19 @@ +# Demo target + +A deliberately buggy mini dispatch app used to verify Claudio end to end without a real repository or deployment. + +```sh +node apps/demo-target/src/server.mjs # http://127.0.0.1:4310 +export DEMO_DISPATCHER_USER=dispo@example.test DEMO_DISPATCHER_PASSWORD=dispo +export DEMO_ADMIN_USER=admin@example.test DEMO_ADMIN_PASSWORD=admin +pnpm claudio review --repo vanderlars/dispo-demo --pr 17 --url http://127.0.0.1:4310 \ + --pr-context apps/demo-target/fixtures/pr-context.json --repo-dir apps/demo-target \ + --config apps/demo-target/claudio.config.json --role dispatcher --budget quick +``` + +Bugs a good review must find (see `fixtures/pr-context.json` for the PR and issue): + +1. Search in the trip list returns HTTP 500 once a trip without a vehicle exists (every trip created through the new form). +2. The new form lacks the required vehicle field, allows past dates, has unlabeled Von/Nach inputs and a button labelled "Senden" instead of "Speichern". +3. The server does not validate required fields. +4. The trip list logs a console error on every load. diff --git a/apps/demo-target/claudio.config.json b/apps/demo-target/claudio.config.json new file mode 100644 index 0000000..07adc17 --- /dev/null +++ b/apps/demo-target/claudio.config.json @@ -0,0 +1,8 @@ +{ + "repo": "vanderlars/dispo-demo", + "notes": "Demo-Dispositionsapp. Rollen: Disponent (legt Fahrten an), Admin. Datum im Format JJJJ-MM-TT.", + "roles": { + "dispatcher": { "description": "Disponent", "usernameEnv": "DEMO_DISPATCHER_USER", "passwordEnv": "DEMO_DISPATCHER_PASSWORD", "loginPath": "/login", "successUrlContains": "/dashboard" }, + "admin": { "description": "Administrator", "usernameEnv": "DEMO_ADMIN_USER", "passwordEnv": "DEMO_ADMIN_PASSWORD", "loginPath": "/login", "successUrlContains": "/dashboard" } + } +} diff --git a/apps/demo-target/fixtures/example-report.md b/apps/demo-target/fixtures/example-report.md new file mode 100644 index 0000000..85d5be7 --- /dev/null +++ b/apps/demo-target/fixtures/example-report.md @@ -0,0 +1,138 @@ +# Claudio Review: PR #17 Fahrten: Neue Fahrt anlegen und Suche in der Fahrtenliste + +**Änderungen nötig** (Status: completed, strictness: normal) + +## Zusammenfassung + +**Urteil:** PR #17 sollte nicht gemergt werden. Von den 6 Akzeptanzkriterien sind 2 erfüllt, 3 nicht erfüllt, und 1 ließ sich nicht prüfen. Die Suche ist durch einen Serverfehler (HTTP 500) unbrauchbar. + +**Zusammenfassung** + +Ich habe den Code in `src/server.mjs` gelesen und die Kriterien als Todo-Liste angelegt. Dann habe ich im Browser als Disponent die Fahrtenliste geöffnet, das Formular geprüft und eine Fahrt mit Datum 2020-01-01 gespeichert. Danach habe ich die Suche aufgerufen, mich als Admin angemeldet und einen POST mit leeren Feldern abgeschickt. + +- **Button "Neue Fahrt" (erfüllt):** Er ist in der Liste vorhanden und führt zum Formular `/trips/new`. Der Klick per Tool schlug technisch fehl, deshalb habe ich die URL direkt aufgerufen. Im Code ist der Button verlinkt. +- **Felder und Pflichtfelder (nicht erfüllt):** Das Feld "Fahrzeug" fehlt komplett. Fahrten werden immer mit `vehicle: null` gespeichert und in der Liste mit "-" angezeigt. Von und Nach haben nur einen Placeholder und kein Label. +- **Datum nicht in der Vergangenheit (nicht erfüllt):** 2020-01-01 wurde ohne Fehlermeldung gespeichert, weder im Browser noch auf dem Server geprüft. +- **Speichern, Liste und Erfolgsmeldung (erfüllt):** "Fahrt #3 angelegt." erscheint und die Zeile steht in der Liste, allerdings ohne Fahrzeug. +- **Suche (nicht erfüllt, Blocker):** Der Filter ruft `t.vehicle.toLowerCase()` auf, aber neue Fahrten haben `vehicle = null`. Sobald eine solche Fahrt existiert, liefert jede Suche HTTP 500. Im Browser habe ich das mit `?q=mia` bestätigt. Eine Suche vor dem Anlegen habe ich nicht gemacht. +- **Server ohne Validierung:** Ein POST mit leeren Feldern hat Fahrt #4 angelegt. Die Rollenprüfung sitzt nur im GET-Handler, nicht im POST. +- **Konsole:** Jeder Aufruf von `/trips` erzeugt `console.error("TripList: deprecated prop 'rows' used")`. Dazu kommt ein favicon-404. +- **Suchbegriff und Fahrgastdaten:** Sie werden ohne Escaping ausgegeben, was reflektiertes und gespeichertes XSS ermöglicht. Das habe ich nur im Code gesehen, nicht im Browser ausgenutzt. +- **Formular-UX:** Der Submit-Button heißt "Senden" statt "Speichern", und es gibt keine Fehlerdarstellung am Feld. +- **Verbesserungsideen:** + - Fahrzeug als Auswahlfeld mit Pflicht ergänzen. + - Datum clientseitig mit `min=heute` und serverseitig prüfen, Fehler am Feld anzeigen. + - Suche `null`-sicher machen (`t.vehicle?.toLowerCase()`). + - Alle Ausgaben escapen. + - Meldung nach dem Speichern aussagekräftiger formulieren. + +**Affected areas:** +- Fahrtenliste `/trips`: Suche (500-Fehler), Erfolgsmeldung, Konsolenfehler. +- Formular `/trips/new`: Felder, Validierung, Labels, Rollenprüfung. +- `POST /trips/new`: keine Validierung, `vehicle` immer `null`. +- Übersicht `/dashboard`: lädt unverändert, kein Fehler festgestellt. + +**Nicht getestet:** +- **Rollenbeschränkung:** Es gibt nur die Rollen Disponent und Admin, beide dürfen anlegen. Den Negativfall konnte ich nicht prüfen. +- **Suche vor dem Anlegen:** Das Verhalten mit den ursprünglichen Seed-Daten habe ich nicht gemessen. +- **XSS:** Nicht im Browser ausprobiert. +- **Aufräumen:** Meine Testdaten sind Fahrt #3 (`claudio-review-pr17-20261010-135737-slu9 Past`) und die leere Fahrt #4. Sie liegen nur im Speicher und verschwinden beim Neustart des Servers. + +## Acceptance Criteria + +| # | Criterion | Status | Notes | +|---|---|---|---| +| 1 | Button "Neue Fahrt" in der Fahrtenliste öffnet ein Formular | erfüllt | Button vorhanden (Link-Button auf /trips/new); Formular öffnet. Klick per Tool scheiterte technisch, daher Navigation direkt; im Code verlinkt. | +| 2 | Felder Fahrgast, Datum, Uhrzeit, Von, Nach, Fahrzeug; Pflicht: Fahrgast, Datum, Uhrzeit, Fahrzeug | nicht erfüllt | Fahrzeugfeld fehlt komplett; Von/Nach nur Placeholder ohne Label. Fahrt wird mit Fahrzeug "-" gespeichert. | +| 3 | Datum in der Vergangenheit → Fehlermeldung am Feld | nicht erfüllt | 2020-01-01 wurde ohne Fehlermeldung gespeichert. | +| 4 | Nach Speichern erscheint Fahrt in Liste und Erfolgsmeldung | erfüllt | Meldung "Fahrt #3 angelegt." und Zeile in Liste vorhanden (aber Fahrzeug leer, siehe AC2). | +| 5 | Fahrtenliste nach Fahrgast oder Fahrzeug durchsuchbar | nicht erfüllt | Sobald eine Fahrt ohne Fahrzeug existiert (jede neu angelegte), liefert jede Suche mit Text HTTP 500 (null.toLowerCase). Zuvor nicht getestet, da Suche mit q=mia erst danach erfolgte; Code-Analyse zeigt gleichen Fehler. | +| 6 | Nur Disponenten und Admins dürfen Fahrten anlegen | nicht testbar | Nur die Rollen dispatcher und admin stehen zur Verfügung, beide dürfen. Negativfall nicht prüfbar; Code prüft Rolle nur beim GET, nicht beim POST (Hinweis). | + +## Findings + +### Blockierend (4) + +### [Blocker] Suche in Fahrtenliste → HTTP 500, sobald eine Fahrt ohne Fahrzeug existiert + +_network_ · http://127.0.0.1:4310/trips?q=mia + +Filter ruft t.vehicle.toLowerCase() auf; neu angelegte Fahrten haben vehicle=null. + +**Schritte** +1. Fahrt anlegen +2. /trips?q=mia aufrufen + +**Erwartet:** Gefilterte Liste +**Beobachtet:** 500 Interner Fehler + +**Evidenz:** [Suche liefert 500 nach Anlegen einer Fahrt ohne Fahrzeug](evidence/E4-Suche_liefert_500_nach_Anlegen_einer_Fahrt_ohne_Fahrzeug.png) + +### [Major] Formular hat kein Pflichtfeld "Fahrzeug" + +_acceptance_ · http://127.0.0.1:4310/trips/new + +Fahrzeug wird weder abgefragt noch gespeichert (immer null, Anzeige "-"). + +**Erwartet:** Pflichtfeld Fahrzeug +**Beobachtet:** Feld fehlt + +**Evidenz:** [Formular Neue Fahrt: kein Fahrzeugfeld, keine Labels Von/Nach](evidence/E2-Formular_Neue_Fahrt_kein_Fahrzeugfeld_keine_Labels_Von_Nach.png), [Vergangenes Datum akzeptiert, Fahrt ohne Fahrzeug, Erfolgsmeldung](evidence/E3-Vergangenes_Datum_akzeptiert_Fahrt_ohne_Fahrzeug_Erfolgsmeld.png) + +### [Major] Datum in der Vergangenheit wird ohne Fehlermeldung akzeptiert + +_acceptance_ · http://127.0.0.1:4310/trips/new + +2020-01-01 wurde gespeichert; weder Client- noch Server-Prüfung. + +**Erwartet:** Fehlermeldung am Datumsfeld +**Beobachtet:** Fahrt angelegt + +**Evidenz:** [Vergangenes Datum akzeptiert, Fahrt ohne Fahrzeug, Erfolgsmeldung](evidence/E3-Vergangenes_Datum_akzeptiert_Fahrt_ohne_Fahrzeug_Erfolgsmeld.png) + +### [Major] Keine serverseitige Validierung; leere Fahrt anlegbar + +_other_ · http://127.0.0.1:4310/trips/new + +POST /trips/new mit leeren Feldern legte Fahrt #4 an. Rollenprüfung fehlt außerdem im POST-Handler. + +**Erwartet:** Ablehnung mit Fehlermeldung +**Beobachtet:** Leerer Datensatz in Liste + +**Evidenz:** [Liste mit leerer Fahrt #4 (Server ohne Validierung) und Fahrt #3](evidence/E6-Liste_mit_leerer_Fahrt_4_Server_ohne_Validierung_und_Fahrt_3.png) + +### Nicht blockierend (3) + +### [Minor] Formular: Von/Nach ohne Labels, Button "Senden", ID-Meldung wenig informativ + +_ux_ · http://127.0.0.1:4310/trips/new + +Von/Nach nur Placeholder, inkonsistent zu Labels der übrigen Felder; Submit heißt "Senden" statt "Speichern"; Erfolgsmeldung "Fahrt #3 angelegt." ohne Details. Keine Fehlerdarstellung vorgesehen. + +**Evidenz:** [Formular Neue Fahrt: kein Fahrzeugfeld, keine Labels Von/Nach](evidence/E2-Formular_Neue_Fahrt_kein_Fahrzeugfeld_keine_Labels_Von_Nach.png) + +### [Minor] Konsolenfehler auf /trips: "deprecated prop 'rows' used"; favicon 404 + +_console_ · http://127.0.0.1:4310/trips + +Jeder Aufruf der Fahrtenliste erzeugt console.error. + +**Evidenz:** [Fahrtenliste mit Suche und Button](evidence/E1-Fahrtenliste_mit_Suche_und_Button.png) + +### [Minor] Suchbegriff unescaped im value-Attribut (reflektiertes XSS) + +_security_ · http://127.0.0.1:4310/trips + +Im Code wird q ohne Escaping in value="${q}" ausgegeben; Fahrgast/Von/Nach ebenfalls ungeescaped in der Tabelle (gespeichertes XSS). Nur per Code-Review festgestellt, nicht im Browser ausgenutzt. + +## Kosten + +| USD | Input tokens | Output tokens | Turns | Browser steps | Duration | +|---|---|---|---|---|---| +| 0.38 | 720368 | 7421 | 34 | 8 | 61s | + +## Notes + +- 2026-10-10T13:57:45.138Z login_as(dispatcher) ok → http://127.0.0.1:4310/dashboard +- 2026-10-10T13:57:57.226Z login_as(admin) ok → http://127.0.0.1:4310/dashboard +- 2026-10-10T13:58:04.460Z Angelegt: Fahrt #3 (claudio-review-pr17-20261010-135737-slu9 Past, 2020-01-01) und Fahrt #4 (leerer Datensatz via POST). In-Memory-Daten, bitte Server neu starten zum Aufräumen. diff --git a/apps/demo-target/fixtures/pr-context.json b/apps/demo-target/fixtures/pr-context.json new file mode 100644 index 0000000..bb52e83 --- /dev/null +++ b/apps/demo-target/fixtures/pr-context.json @@ -0,0 +1,32 @@ +{ + "owner": "vanderlars", + "repo": "dispo-demo", + "number": 17, + "title": "Fahrten: Neue Fahrt anlegen und Suche in der Fahrtenliste", + "body": "Closes #16\n\nFügt das Formular zum Anlegen einer Fahrt hinzu und eine Suche (Fahrgast/Fahrzeug) in der Fahrtenliste.", + "url": "https://github.com/vanderlars/dispo-demo/pull/17", + "author": "dev", + "headRef": "feature/16-neue-fahrt", + "headSha": "abc123", + "baseRef": "main", + "draft": false, + "files": [ + { + "filename": "src/server.mjs", + "status": "modified", + "additions": 38, + "deletions": 4, + "patch": "@@ -40,6 +40,20 @@\n if (p === \"/trips\" && req.method === \"GET\") {\n+ const q = (url.searchParams.get(\"q\") ?? \"\").toLowerCase();\n+ const rows = trips.filter((t) => !q || t.passenger.toLowerCase().includes(q) || t.vehicle.toLowerCase().includes(q));\n+ const created = url.searchParams.get(\"created\");\n return html(res, page(\"Fahrten\", `

Fahrten

\n+
\n+

\n@@ -60,0 +75,22 @@\n+ if (p === \"/trips/new\" && req.method === \"GET\") {\n+ return html(res, page(\"Neue Fahrt\", `

Neue Fahrt

\n+\n+\n+\n+ \n+

`, user));\n+ }\n+ if (p === \"/trips/new\" && req.method === \"POST\") {\n+ const b = await readBody(req);\n+ const trip = { id: trips.length + 1, passenger: b.get(\"passenger\") ?? \"\", date: b.get(\"date\") ?? \"\", time: b.get(\"time\") ?? \"\", from: b.get(\"from\") ?? \"\", to: b.get(\"to\") ?? \"\", vehicle: null };\n+ trips.push(trip);\n+ return redirect(res, `/trips?created=${trip.id}`);\n+ }" + } + ], + "issues": [ + { + "number": 16, + "title": "Disponent kann eine neue Fahrt anlegen", + "body": "Als Disponent möchte ich eine neue Fahrt anlegen, damit sie in der Fahrtenliste erscheint.\n\n**Acceptance Criteria**\n- In der Fahrtenliste gibt es einen Button \"Neue Fahrt\", der ein Formular öffnet.\n- Das Formular hat die Felder Fahrgast, Datum, Uhrzeit, Von, Nach und Fahrzeug. Fahrgast, Datum, Uhrzeit und Fahrzeug sind Pflichtfelder.\n- Das Datum darf nicht in der Vergangenheit liegen; sonst erscheint eine Fehlermeldung am Feld.\n- Nach dem Speichern erscheint die neue Fahrt in der Liste und eine Erfolgsmeldung wird angezeigt.\n- Die Fahrtenliste lässt sich nach Fahrgast oder Fahrzeug durchsuchen.\n- Nur Disponenten und Admins dürfen Fahrten anlegen.", + "url": "https://github.com/vanderlars/dispo-demo/issues/16", + "labels": ["feature"], + "state": "open" + } + ] +} diff --git a/apps/demo-target/package.json b/apps/demo-target/package.json new file mode 100644 index 0000000..eff139c --- /dev/null +++ b/apps/demo-target/package.json @@ -0,0 +1,11 @@ +{ + "name": "@claudio/demo-target", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Tiny dispatch app with deliberate bugs, used to verify Claudio itself", + "scripts": { + "start": "node src/server.mjs", + "typecheck": "echo skip" + } +} diff --git a/apps/demo-target/src/server.mjs b/apps/demo-target/src/server.mjs new file mode 100644 index 0000000..e1f496d --- /dev/null +++ b/apps/demo-target/src/server.mjs @@ -0,0 +1,94 @@ +// Minimal "Disposition" app: login, trip list, trip creation. Contains deliberate bugs +// that a review must find (see fixtures/pr-context.json for the PR under review). +import { createServer } from "node:http"; + +const USERS = { "dispo@example.test": { password: "dispo", role: "dispatcher", name: "Dana Dispo" }, "admin@example.test": { password: "admin", role: "admin", name: "Alex Admin" } }; +const sessions = new Map(); +let trips = [ + { id: 1, passenger: "Mia Muster", date: "2026-10-12", time: "07:30", from: "Hauptstr. 1", to: "Grundschule Nord", vehicle: "SLN-1" }, + { id: 2, passenger: "Ben Beispiel", date: "2026-10-12", time: "13:15", from: "Grundschule Nord", to: "Hauptstr. 1", vehicle: "SLN-1" }, +]; + +const page = (title, body, user) => `${title} · Dispo Demo + +
Dispo DemoÜbersichtFahrten${user ? `${user.name} (${user.role})Abmelden` : ""}
${body}
`; + +function parseCookies(req) { + return Object.fromEntries((req.headers.cookie ?? "").split(";").map((c) => c.trim().split("=")).filter((p) => p[0])); +} +function currentUser(req) { + const sid = parseCookies(req).sid; + return sid ? sessions.get(sid) : undefined; +} +function readBody(req) { + return new Promise((resolve) => { let b = ""; req.on("data", (c) => (b += c)); req.on("end", () => resolve(new URLSearchParams(b))); }); +} +function redirect(res, to) { res.statusCode = 302; res.setHeader("location", to); res.end(); } +function html(res, s, status = 200) { res.statusCode = status; res.setHeader("content-type", "text/html; charset=utf-8"); res.end(s); } +function json(res, data, status = 200) { res.statusCode = status; res.setHeader("content-type", "application/json"); res.end(JSON.stringify(data)); } + +const server = createServer(async (req, res) => { + const url = new URL(req.url ?? "/", "http://x"); + const user = currentUser(req); + const p = url.pathname; + + if (p === "/" ) return redirect(res, user ? "/dashboard" : "/login"); + if (p === "/login" && req.method === "GET") { + return html(res, page("Anmelden", `

Anmelden

${url.searchParams.get("error") ? `` : ""}

`)); + } + if (p === "/login" && req.method === "POST") { + const b = await readBody(req); + const u = USERS[b.get("email") ?? ""]; + if (!u || u.password !== b.get("password")) return redirect(res, "/login?error=1"); + const sid = Math.random().toString(36).slice(2); + sessions.set(sid, u); + res.setHeader("set-cookie", `sid=${sid}; Path=/; HttpOnly`); + return redirect(res, "/dashboard"); + } + if (p === "/logout") { res.setHeader("set-cookie", "sid=; Path=/; Max-Age=0"); return redirect(res, "/login"); } + if (!user) return redirect(res, "/login"); + + if (p === "/dashboard") { + return html(res, page("Übersicht", `

Übersicht

Heute: ${trips.filter((t) => t.date === "2026-10-12").length} Fahrten.

Zu den Fahrten

`, user)); + } + if (p === "/api/stats") return json(res, { trips: trips.length }); + if (p === "/trips" && req.method === "GET") { + const q = (url.searchParams.get("q") ?? "").toLowerCase(); + // BUG 1: the new search filter throws on trips without a vehicle (null.toLowerCase) → 500 + let rows; + try { + rows = trips.filter((t) => !q || t.passenger.toLowerCase().includes(q) || t.vehicle.toLowerCase().includes(q)); + } catch (e) { + return html(res, page("Fehler", `

Interner Fehler

${e.message}
`, user), 500); + } + const created = url.searchParams.get("created"); + return html(res, page("Fahrten", `

Fahrten

${created ? `

Fahrt #${created} angelegt.

` : ""} +
+

+ +${rows.map((t) => ``).join("")} +
#FahrgastDatumUhrzeitVonNachFahrzeug
${t.id}${t.passenger}${t.date}${t.time}${t.from}${t.to}${t.vehicle ?? "-"}
`, user)); + } + if (p === "/trips/new" && req.method === "GET") { + if (user.role !== "dispatcher" && user.role !== "admin") return html(res, page("Verboten", "

Kein Zugriff

", user), 403); + // BUG 2 (UX): no field labels for Von/Nach, button says "Senden", no vehicle field although AC requires it, date accepts past dates + return html(res, page("Neue Fahrt", `

Neue Fahrt

+ + + + +

`, user)); + } + if (p === "/trips/new" && req.method === "POST") { + const b = await readBody(req); + const trip = { id: trips.length + 1, passenger: b.get("passenger") ?? "", date: b.get("date") ?? "", time: b.get("time") ?? "", from: b.get("from") ?? "", to: b.get("to") ?? "", vehicle: null }; + // BUG 3: server does not validate; empty passenger is accepted when the browser validation is bypassed + trips.push(trip); + return redirect(res, `/trips?created=${trip.id}`); + } + if (p === "/api/trips") return json(res, trips); + return html(res, page("404", "

Seite nicht gefunden

", user), 404); +}); + +const port = Number(process.env.PORT ?? 4310); +server.listen(port, "127.0.0.1", () => console.log(`demo target on http://127.0.0.1:${port} (dispo@example.test/dispo, admin@example.test/admin)`)); diff --git a/claudio.config.example.json b/claudio.config.example.json new file mode 100644 index 0000000..3b62694 --- /dev/null +++ b/claudio.config.example.json @@ -0,0 +1,26 @@ +{ + "repo": "owner/name", + "notes": "Dispositions-Software für Schüler- und Personenbeförderung. Hauptobjekte: Fahrten, Touren, Fahrgäste, Fahrzeuge, Fahrer. Datumsfelder erwarten TT.MM.JJJJ.", + "extraAllowedHosts": [], + "roles": { + "admin": { + "description": "Vollzugriff, Mandantenverwaltung", + "usernameEnv": "CLAUDIO_ROLE_ADMIN_USER", + "passwordEnv": "CLAUDIO_ROLE_ADMIN_PASSWORD", + "loginPath": "/login", + "successUrlContains": "/dashboard" + }, + "dispatcher": { + "description": "Disponent: plant Fahrten und Touren", + "usernameEnv": "CLAUDIO_ROLE_DISPATCHER_USER", + "passwordEnv": "CLAUDIO_ROLE_DISPATCHER_PASSWORD", + "recipe": [ + { "action": "goto", "url": "/login" }, + { "action": "fill", "selector": "input[name=email]", "value": "$USER" }, + { "action": "fill", "selector": "input[name=password]", "value": "$PASSWORD" }, + { "action": "click", "selector": "button[type=submit]" }, + { "action": "waitForUrl", "pattern": "/dashboard" } + ] + } + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..51137c6 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,157 @@ +# Claudio: Plan für den automatisierten User-Review-Agenten + +> Stand: Phase 0 umgesetzt (CLI `claudio review`, Playwright-Treiber, Claudio-Tools, Demo-Target). Offen: Chrome-Live-Modus über das SDK verifizieren, Phasen 1 bis 5. + +## Kontext + +Stadt.Land.Netz prüft Feature-Branches heute manuell: ein Reviewer liest das Ticket, verifiziert die Acceptance Criteria im Browser, bewertet UX/UI, schaut sich den geänderten Code an, leitet daraus betroffene Stellen ab und testet diese auf Regressionen. Claudio soll diesen Prozess weitgehend automatisieren. Das Repo `vanderlars/claudio` ist leer (nur README), wir starten auf der grünen Wiese. + +## Getroffene Entscheidungen + +| Thema | Entscheidung | +|---|---| +| Laufzeit | Lokal / eigene Server, **Claude Agent SDK** (`@anthropic-ai/claude-agent-sdk`, TypeScript). Managed Agents vorerst nicht. | +| Anthropic-Auth | API-Key aus der Console für unbeaufsichtigte Runs (Kosten pro Run messbar). Der Live-Modus mit Chrome-Extension braucht laut Doku ein Abo-Login (Pro/Max/Team/Enterprise) auf dem Entwickler-Rechner. Beides wird unterstützt. | +| Browser | **Zwei Treiber hinter einer Abstraktion.** (1) **Playwright MCP** (`@playwright/mcp`) für unbeaufsichtigte Runs: headless, isoliert, Accessibility-Snapshots, Console, Network, Screenshots, Tracing, Storage-State. Optional headed mit Live-Vorschau in der Claudio-UI. (2) **Claude-Chrome-Extension** als **Live-Modus**: läuft über Claude Code auf dem Entwickler-Rechner in sichtbarem Chrome, man kann zusehen und eingreifen. Beide nutzen dieselben Agent-Definitionen, Skills und Claudio-Tools. | +| Stack | TypeScript-Monorepo (pnpm), SvelteKit für UI + API, MySQL (Drizzle ORM), ein Worker-Prozess für Runs. | +| Zielapps | Beliebige Webapps, zuerst SvelteKit. Login per Secrets, Rechte über App-Rollen. | +| PR-Instanz | URL wird beim Trigger angegeben (Pflichtfeld). URL-Templates pro Repo als spätere Erweiterung. | +| Testdaten | Keine Annahmen über Testdaten. Agent legt benötigte Daten selbst an, markiert sie (`claudio-`) und dokumentiert sie. | +| Ticket-Quelle | GitHub Issues (Verknüpfung über PR-Body, `closes #123`, Branch-Name). | +| Reports | Eigene Oberfläche. Im PR nur ein Kommentar mit TL;DR und Link. | +| Budget | Option pro Run (Stufen), nicht fest. | +| "Gleiches Ergebnis" | Pro Script wird beim ersten Run ein Determinismus-Grad festgelegt (deterministisch / semi / explorativ). Verglichen werden Funktionsergebnisse, nicht Pixel. | + +## Architektur + +### Monorepo-Struktur + +``` +claudio/ + apps/ + web/ SvelteKit: UI + API-Routen (Repos, Runs, Reports, Trigger, Secrets, Settings) + worker/ Run-Executor: pollt Jobs aus MySQL, startet Agent-Runs, schreibt Ergebnisse + packages/ + db/ Drizzle-Schema + Migrationen (MySQL), Repository-Funktionen + core/ Domänentypen, Finding/Expectation-Schemas (zod), Budget-Stufen, Report-Rendering + agent/ Agent-SDK-Runner, Hooks, Kostenzähler, Treiber-Auswahl (playwright | chrome) + browser/ Browser-Treiber: Playwright-MCP-Wiring, Chrome-Extension-Launcher, Claudio-Tools + (login_as, evidence, network/console) als eigenständiger stdio-MCP-Server + .claude/ + agents/ Subagent-Definitionen als Markdown (von SDK und Claude Code CLI gleichermaßen geladen) + skills/ Review-, Explorer-, Script-Skills (dito) + .mcp.json Claudio-Tool-Server + Playwright für den CLI-Live-Modus + github/ GitHub-App-Client (Octokit): PR-Diff, Issues, Kommentare, Webhook-Verifikation + knowledge/ Lese-/Schreibzugriff auf die Repo-Wissensbasis (.claudio/ im Zielrepo + DB-Spiegel) + docs/ + ARCHITECTURE.md +``` + +### Domänenmodell (MySQL) + +- **Repo**: GitHub-Owner/Name, Installation-ID, Default-Branch, Status der Analyse. +- **RepoSecret**: Name, verschlüsselter Wert (AES-GCM, Key aus Env), Rolle (z.B. `admin`, `dispatcher`). Werte verlassen den Worker nie Richtung Modell. +- **KnowledgeBase** (pro Repo, versioniert nach Commit): Code-Notizen, Modulkarte, Index Route/Screen zu Dateien, App-Map (Screens, Flows, Rollen, Soll-Verhalten), Agent-Notizen aus früheren Runs. +- **Review**: PR-Nummer, Branch, Instanz-URL, Verbosity, Strictness, Budget-Stufe, Status, Kosten, TL;DR. +- **TestScript**: Name, NL-Beschreibung, strukturierte Erwartung, Determinismus-Grad, kompilierte Schritte (Cache), Rollen. +- **Run**: Typ (analysis / review / script), Bezug, Status, Logs, Kosten, Session-ID des SDK. +- **Finding**: Run, Kategorie (AC, Regression, UX, UI, Console, Network, Security), Severity, Titel, Beschreibung, Schritte, Status (vorgeschlagen / bestätigt / verworfen). +- **Evidence**: Finding oder Run, Typ (Screenshot, Trace, HAR-Ausschnitt, Console-Dump), Dateipfad. + +Findings werden immer vollständig gespeichert. **Strictness** ist ein Severity-Schwellenwert (welche Findings blockieren und in den TL;DR kommen). **Verbosity** steuert nur das Rendering des Reports. Beides ist nachträglich umschaltbar. + +### Agenten (Agent SDK `agents`-Option, ein `query()` pro Run) + +| Agent | Modell | Aufgabe | +|---|---|---| +| Orchestrator (Haupt-Query) | `claude-opus-5-5` | Plant, delegiert, fasst zusammen. | +| `code-scanner` | Sonnet 5.5 | Liest das Zielrepo (Read/Glob/Grep), schreibt Modulkarte und Routen-Index. | +| `explorer` | Sonnet 5.5 | Klickt durch die App, baut die Todo-Liste der Screens/Funktionen in einer Datei. | +| `tester` | Sonnet 5.5 | Arbeitet Todo-Einträge ab, dokumentiert Soll-Verhalten (App-Map). Mehrfach parallel spawnbar. | +| `impact-analyst` | Opus 5.5 | Diff + Routen-Index → betroffene Screens und Flows. | +| `ac-verifier` | Opus 5.5 | Prüft jedes Acceptance Criterion im Browser, mit Evidenz. | +| `regression-tester` | Sonnet 5.5 | Prüft betroffene Screens gegen App-Map-Soll. | +| `ux-reviewer` | Opus 5.5 | Konsistenz, Usability, Verbesserungsvorschläge auf Screenshot-Basis. | +| `judge` | Opus 5.5, Structured Output | Vergleicht Script-Ergebnis gegen Erwartung, unabhängig vom Tester-Kontext. | +| `reporter` | Sonnet 5.5, read-only | Erzeugt Report und TL;DR aus den strukturierten Findings. | + +Budget-Stufen (Option pro Run) setzen Modell-Tier der Worker, `effort`, `maxTurns`, maximale Browser-Schritte und ein Kosten-Limit. Der Worker summiert `usage` aus den SDK-Messages und bricht bei Überschreitung sauber ab (Teilreport bleibt erhalten). + +### Browser-Schicht (`packages/browser`) + +**Grundsatz:** Agent-Definitionen, Skills und Prompts sind treiberneutral ("nutze die verfügbaren Browser-Tools"). Sie liegen als Dateien in `.claude/agents` und `.claude/skills`, damit das Agent SDK (über `settingSources`) und die Claude Code CLI dieselben Definitionen laden. Die Claudio-Tools laufen als eigenständiger stdio-MCP-Server, den beide Wege einbinden. + +**Treiber 1: Playwright (unbeaufsichtigt, Standard)** +- Playwright MCP per stdio: `--isolated --storage-state /state.json`, standardmäßig `--headless`. Ein Browser-Profil pro Run, keine geteilten Profile. +- Option "headed": sichtbares Fenster auf dem Worker-Rechner. Zusätzlich Live-Vorschau in der Claudio-UI: der Worker streamt den jeweils letzten Screenshot und den aktuellen Agent-Schritt per SSE. So kann man von überall zusehen, ohne Extension. +- `disallowedTools: ["mcp__playwright__browser_run_code_unsafe"]`. +- `PreToolUse`-Hook auf `^mcp__playwright__`: Host-Allowlist (nur Instanz-URL des Runs), kein `javascript:`-Navigieren, Schrittzähler fürs Budget. + +**Treiber 2: Chrome-Extension (Live-Modus zum Zusehen und Eingreifen)** +- Läuft auf dem Entwickler-Rechner. Claudio erzeugt einen Run-Auftrag (Prompt, Kontext, Run-ID) und startet Claude Code mit Chrome-Integration gegen das Claudio-Projekt (`.claude/`, `.mcp.json`). Der Entwickler sieht die Aktionen live in Chrome und kann pausieren oder selbst eingreifen (z.B. Login, CAPTCHA). +- Ergebnisse (Findings, Evidence) fließen über die Claudio-Tools zurück in die DB, so dass der Report identisch aussieht. +- Einschränkungen laut Doku: nur sichtbares Chrome, kein WSL, Abo-Login statt API-Key, Network-Logs nicht dokumentiert. Findings aus dem Live-Modus werden entsprechend gekennzeichnet. +- Spike-Punkt: ob das SDK den Chrome-Modus über `extraArgs` durchreicht. Falls nicht, startet der Worker die CLI als Kindprozess und liest die Ergebnisse über den Claudio-MCP-Server und die Session-Transkripte. + +**Claudio-Tools (eigener MCP-Server, von beiden Treibern genutzt):** + - `login_as(role)`: Playwright-Treiber: führt ein pro Repo gespeichertes Login-Rezept aus (Playwright direkt, nicht über das Modell), schreibt Storage-State, liefert nur "eingeloggt als Rolle X" zurück. Beim ersten Mal lässt der Agent sich den Login-Flow zeigen und speichert das Rezept, Credentials bleiben im Worker. Chrome-Treiber: nutzt die Login-Session des Entwicklers; auf Login-Seiten pausiert der Agent, der Mensch loggt ein. + - `capture_evidence(label)`: Screenshot + aktueller Snapshot in das Run-Verzeichnis, gibt Evidence-ID zurück. + - `get_console(filter)`, `get_network(filter)`: gefilterte Sichten auf Console und Requests (Fehler, 4xx/5xx, bestimmte Endpunkte), gekürzt, damit der Kontext klein bleibt. + - `assert_request(pattern, expectation)`: deterministische Prüfung eines Netzwerk-Calls. + - `note(text)` / `todo_add` / `todo_next`: strukturiertes Arbeitsgedächtnis für Explorer/Tester (Datei im Run-Verzeichnis). +- Nach jedem Run: Playwright-Trace und HAR ins Run-Verzeichnis, verknüpft mit Findings. + +### Pipelines + +1. **Repo-Analyse** (manuell gestartet, später bei Merge auf main): Zielrepo klonen (flach) → `code-scanner` → `explorer` baut Todo → mehrere `tester` arbeiten ab → Orchestrator schreibt App-Map und Notizen. Ergebnis landet in `.claudio/` (Markdown, diffbar) und als Spiegel in der DB. +2. **PR-Review** (Trigger aus UI mit PR-Nummer + Instanz-URL + Optionen): PR-Diff und verknüpfte Issues laden → AC extrahieren → `impact-analyst` → parallel `ac-verifier`, `regression-tester`, `ux-reviewer` → `reporter` → Findings + Evidence speichern → TL;DR-Kommentar in den PR. +3. **Test-Scripts**: NL-Script + Erwartung anlegen. Erster Run: Tester führt aus, dokumentiert Ablauf, speichert kompilierte Schritte und schlägt Determinismus-Grad vor. Folgeruns: deterministische Schritte laufen ohne Modell (Playwright-Replay), Modell greift nur ein, wenn ein Schritt bricht (self-healing, wird als Finding dokumentiert). `judge` bewertet das Ergebnis gegen die Erwartung mit Structured Output (`passed`, `deviations[]`, `confidence`). + +### GitHub-Anbindung + +GitHub App (Installation pro Org): PR lesen, Issues lesen, Kommentar schreiben, Repo klonen. Webhook für `pull_request`-Events vorbereitet, aber Reviews bleiben vorerst manuell. Bot-Reviewer-Trigger (`review_requested` für den Bot) als Phase 5. + +### Oberfläche (SvelteKit) + +Repos verwalten, Secrets (Rolle → Credentials), Analyse starten und Wissensbasis einsehen, Review starten (PR, URL, Verbosity, Strictness, Budget), Run-Status live (SSE), Report mit Findings, Evidenz und Bestätigen/Verwerfen, Test-Scripts anlegen und Runs vergleichen. + +## Phasen + +### Phase 0: Spike (zuerst, ohne UI) +Ziel: Machbarkeit, Qualität und Kosten pro Review messen. +- Monorepo-Gerüst (pnpm, TypeScript, `packages/agent`, `packages/browser`). +- CLI-Skript `claudio review --repo --pr --url --role --driver playwright|chrome`: lädt PR + Issue, startet eine Agent-Session mit dem gewählten Treiber, `login_as`, `capture_evidence`, schreibt Report als Markdown in `runs//`. +- Ergebnis: ein echter PR aus eurem Hauptprodukt mit beiden Treibern reviewt, Kosten und Dauer protokolliert. +- Nebenbei prüfen: parallele `query()`-Aufrufe im selben Prozess (laut Doku nicht spezifiziert), sonst ein Prozess pro Run. Und: läuft der Chrome-Modus über das SDK (`extraArgs`) oder nur über die CLI als Kindprozess? + +### Phase 1: Fundament +`packages/db` (Schema, Migrationen), `apps/worker` (Job-Polling, Run-Verzeichnisse, Kostenzähler, Budget-Abbruch), Secrets-Verschlüsselung, GitHub-App-Client. + +### Phase 2: Repo-Analyse +`code-scanner`, `explorer`/`tester`-Paar, App-Map-Format, `.claudio/`-Ablage, Aktualisierung bei neuem Commit auf main. + +### Phase 3: PR-Review +Impact-Analyse über Routen-Index, AC-Extraktion, drei Prüfagenten, Findings/Evidence-Modell, Verbosity/Strictness, TL;DR-Kommentar. + +### Phase 4: Test-Scripts +Erwartungs-Schema, erster Run mit Dokumentation, kompilierte Schritte, Replay, self-healing, `judge`, Run-Vergleich. + +### Phase 5: Oberfläche und Bot +SvelteKit-UI für alles oben, Live-Status, Bestätigen/Verwerfen von Findings mit Rückfluss in die Notizen, Bot-Reviewer per Webhook, URL-Templates pro Repo. + +## Verifikation + +- Phase 0: ein Review gegen eine reale Instanz, manuell geprüft: stimmen AC-Bewertung, betroffene Screens, Findings? Kosten unter der gewählten Budget-Stufe? +- Unit-Tests (vitest) für Schemas, Budget-Logik, Finding-Filter, Login-Rezept-Replay. +- Integrationstest mit einer kleinen SvelteKit-Demo-App im Repo (`apps/demo-target`) mit Login, Rollen und absichtlichen Bugs: Analyse, Review und ein Script müssen die eingebauten Bugs finden. Dient auch als Regressionssuite für Claudio selbst. +- Jeder Run hinterlässt Trace, HAR, Console-Dump und SDK-Session-ID, damit ein Mensch nachvollziehen kann, was der Agent getan hat. + +## Risiken und offene Punkte + +- Parallelität von `query()` im selben Prozess ist nicht dokumentiert. Fallback: Kindprozess pro Run. +- Chrome-Extension ist für CLI und VS Code dokumentiert, nicht für das SDK. Der Live-Modus ist daher an einen Entwickler-Rechner mit Abo-Login gebunden und läuft nicht parallel. Für Zusehen ohne diese Einschränkung gibt es die Live-Vorschau des Playwright-Treibers. +- Zwei Treiber bedeuten zwei Tool-Vokabulare. Prompts bleiben treiberneutral; treiberspezifische Hinweise kommen aus einem kleinen Präfix, das der Runner je nach Treiber einfügt. +- Login-Flows mit 2FA oder CAPTCHA lassen sich nicht automatisieren. Secrets-Modell sieht pro Rolle optional einen vorab erzeugten Storage-State vor. +- Datenschutz: Snapshots und Screenshots gehen an Anthropic. Nur Instanzen mit synthetischen Daten reviewen. Wird in der UI pro Repo als Bestätigung abgefragt. +- Testdaten-Adaptivität kostet Tokens: der Agent muss erst herausfinden, was existiert. Die App-Map aus der Analyse reduziert das ab dem zweiten Run. +- SvelteKit-Hydration und Client-Navigation: Snapshots müssen nach `networkidle` erfolgen. Playwright MCP wartet selbst, eigene Tools müssen es auch tun. diff --git a/package.json b/package.json new file mode 100644 index 0000000..f408476 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "claudio", + "private": true, + "version": "0.1.0", + "description": "Agentic user-review and frontend test suite built on the Claude Agent SDK", + "packageManager": "pnpm@10.28.0", + "engines": { "node": ">=22" }, + "scripts": { + "build": "pnpm -r --filter './packages/*' --filter './apps/*' run build", + "typecheck": "pnpm -r run typecheck", + "test": "vitest run", + "claudio": "tsx apps/cli/src/main.ts" + }, + "devDependencies": { + "@types/node": "^22.19.0", + "tsx": "^4.23.15", + "typescript": "^5.9.3", + "vitest": "^5.0.3" + } +} diff --git a/packages/agent/package.json b/packages/agent/package.json new file mode 100644 index 0000000..4bd7a8a --- /dev/null +++ b/packages/agent/package.json @@ -0,0 +1,21 @@ +{ + "name": "@claudio/agent", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.3.296", + "@claudio/browser": "workspace:*", + "@claudio/core": "workspace:*", + "@claudio/github": "workspace:*", + "zod": "^4.6.5" + } +} diff --git a/packages/agent/src/hooks.ts b/packages/agent/src/hooks.ts new file mode 100644 index 0000000..218ff43 --- /dev/null +++ b/packages/agent/src/hooks.ts @@ -0,0 +1,74 @@ +import type { HookCallback, HookCallbackMatcher, PreToolUseHookInput } from "@anthropic-ai/claude-agent-sdk"; + +export type BrowserGuardOptions = { + allowedHosts: string[]; + maxBrowserSteps: number; + onStep?: (count: number, toolName: string) => void; +}; + +export type BrowserGuard = { + hooks: HookCallbackMatcher[]; + steps(): number; +}; + +const NAVIGATION_TOOLS = new Set(["mcp__playwright__browser_navigate", "mcp__claude-in-chrome__navigate"]); + +/** + * PreToolUse guard for browser tools: keeps navigation on the allowed hosts, + * blocks javascript: URLs and enforces the budget's browser step limit. + */ +export function createBrowserGuard(opts: BrowserGuardOptions): BrowserGuard { + let steps = 0; + const allowed = opts.allowedHosts.map((h) => h.toLowerCase()); + + const guard: HookCallback = async (input) => { + const i = input as PreToolUseHookInput; + if (i.hook_event_name !== "PreToolUse") return {}; + const name = i.tool_name; + if (!name.startsWith("mcp__playwright__") && !name.includes("chrome")) return {}; + if (name.startsWith("mcp__claudio__")) return {}; + + steps += 1; + opts.onStep?.(steps, name); + if (steps > opts.maxBrowserSteps) { + return deny(`Browser step budget of ${opts.maxBrowserSteps} exhausted. Stop testing, record what you have with report_finding / record_ac_result, and write the final summary now.`); + } + + if (NAVIGATION_TOOLS.has(name)) { + const url = String((i.tool_input as { url?: string })?.url ?? ""); + const verdictMsg = checkUrl(url, allowed); + if (verdictMsg) return deny(verdictMsg); + } + return {}; + }; + + return { + hooks: [{ matcher: "^mcp__", hooks: [guard] }], + steps: () => steps, + }; +} + +function checkUrl(url: string, allowedHosts: string[]): string | null { + if (!url) return null; + if (/^javascript:/i.test(url)) return "javascript: URLs are not allowed."; + if (/^(about:blank|data:)/i.test(url)) return null; + let host: string; + try { + host = new URL(url).hostname.toLowerCase(); + } catch { + // Relative URLs resolve against the current page, which is already on an allowed host. + return null; + } + const ok = allowedHosts.some((h) => host === h || host.endsWith(`.${h}`)); + return ok ? null : `Navigation to ${host} is outside the instance under test (allowed: ${allowedHosts.join(", ")}). Stay on the app.`; +} + +function deny(reason: string) { + return { + hookSpecificOutput: { + hookEventName: "PreToolUse" as const, + permissionDecision: "deny" as const, + permissionDecisionReason: reason, + }, + }; +} diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts new file mode 100644 index 0000000..fba3fa4 --- /dev/null +++ b/packages/agent/src/index.ts @@ -0,0 +1,4 @@ +export * from "./runner.js"; +export * from "./prompts.js"; +export * from "./hooks.js"; +export * from "./sdk-tools.js"; diff --git a/packages/agent/src/prompts.ts b/packages/agent/src/prompts.ts new file mode 100644 index 0000000..704d21f --- /dev/null +++ b/packages/agent/src/prompts.ts @@ -0,0 +1,124 @@ +import type { BudgetProfile, RepoConfig, ReviewRequest } from "@claudio/core"; +import type { PullRequestContext } from "@claudio/github"; + +export const SYSTEM_APPEND = ` +You are Claudio, an automated user-acceptance reviewer for web applications. You replace the manual review a QA engineer performs before a feature branch is merged: verify the ticket's acceptance criteria in the running app, check that existing behaviour did not regress, judge UX and UI consistency, and document everything with evidence. + +Working rules: +- Work adaptively. Do not assume specific test data exists. Discover the app's state from the UI, create what you need, and prefix names of records you create with "claudio-" so people can find and clean them up. Note every record you create with the note tool. +- Evidence first. Every finding and every acceptance-criterion result must reference evidence captured with capture_evidence. A claim without evidence is worth little to the humans reading the report. +- Record results through the Claudio tools (report_finding, record_ac_result), not only in prose. The report is generated from those records; the prose is the summary. +- Severity: blocker = feature unusable or data loss; major = acceptance criterion not met or a clear functional bug; minor = wrong but workable; suggestion = improvement idea. Do not inflate. +- Be precise about what you observed versus what you infer. If something could not be tested (missing data, missing permission, broken login), record it as blocked or not_testable rather than guessing. +- Never type credentials into the browser. Use login_as. +- Stay inside the instance under test. Do not interact with production systems or external services. +- Read the repository with Read/Grep/Glob to understand what the diff touches; you cannot run commands. +`.trim(); + +export type PromptInput = { + runId: string; + request: ReviewRequest; + pr: PullRequestContext; + config: RepoConfig | null; + repoDir: string | null; + profile: BudgetProfile; + driverPrefix: string; + maxDiffChars?: number; +}; + +export function buildReviewPrompt(input: PromptInput): string { + const { request, pr, config, repoDir, profile, driverPrefix } = input; + const roles = Object.entries(config?.roles ?? {}); + const lang = request.language === "de" ? "German" : "English"; + + const verbosityGuide = { + tldr: "Keep the final summary to a few sentences: verdict, what works, what blocks.", + normal: "Write a concise summary (one or two paragraphs) plus a short list of the most important observations.", + detailed: "Write a thorough summary: what you tested in which order, what you could not test and why, observations beyond the findings, and concrete improvement ideas.", + }[request.verbosity]; + + const strictnessGuide = { + lenient: "Report only real functional problems as blocker/major; treat polish and consistency issues as suggestions.", + normal: "Report functional problems and clear UX/UI inconsistencies; use minor for cosmetic issues and suggestion for ideas.", + strict: "Be thorough and exacting: inconsistent wording, spacing, missing feedback, missing loading or error states and accessibility problems are findings (minor), not suggestions.", + }[request.strictness]; + + const sections: string[] = []; + sections.push(driverPrefix); + sections.push(`# Review task\n\nRun id: ${input.runId}\nReport language: ${lang}\nVerbosity: ${request.verbosity}. ${verbosityGuide}\nStrictness: ${request.strictness}. ${strictnessGuide}\nBudget: about ${profile.maxTurns} turns and ${profile.maxBrowserSteps} browser steps. Plan accordingly: cover acceptance criteria first, then regressions on the affected areas, then UX/UI.`); + + sections.push(`# Pull request #${pr.number}: ${pr.title}\n\nURL: ${pr.url}\nBranch: ${pr.headRef} → ${pr.baseRef}${pr.author ? `\nAuthor: ${pr.author}` : ""}${pr.draft ? "\nDraft PR" : ""}\n\n## Description\n\n${pr.body.trim() || "(empty)"}`); + + if (pr.issues.length > 0) { + sections.push( + `# Linked issues\n\n` + + pr.issues + .map((i) => `## #${i.number}: ${i.title}${i.labels.length ? ` [${i.labels.join(", ")}]` : ""}\n${i.url}\n\n${i.body.trim() || "(empty)"}`) + .join("\n\n"), + ); + } else { + sections.push("# Linked issues\n\nNone found. Derive the acceptance criteria from the PR description and the diff, and say in the summary that no ticket was linked."); + } + + sections.push(renderDiff(pr, input.maxDiffChars ?? 60_000)); + + sections.push( + `# Instance under test\n\nBase URL: ${request.instanceUrl}\n` + + (roles.length > 0 + ? `Roles available via login_as: ${roles.map(([n, r]) => `${n}${r.description ? ` (${r.description})` : ""}`).join(", ")}.` + + (request.roles.length > 0 ? ` Start with: ${request.roles.join(", ")}.` : "") + : "No roles are configured. If the app needs a login, record the affected criteria as blocked."), + ); + + if (repoDir) { + sections.push(`# Repository checkout\n\nThe PR branch is checked out at ${repoDir}. Use Read/Grep/Glob there for impact analysis (routes, components, shared modules the diff touches). You may delegate the code reading to the impact-analyst subagent.`); + } + + if (config?.notes) sections.push(`# Notes about this application\n\n${config.notes.trim()}`); + if (request.extraInstructions) sections.push(`# Additional instructions for this run\n\n${request.extraInstructions.trim()}`); + + sections.push(`# Procedure + +1. Extract the acceptance criteria from the issue(s) and PR description. If they are implicit, formulate them explicitly. Add them to the todo list with todo_add, one item per criterion. +2. Impact analysis: from the diff (and the checkout, if available) determine which screens, routes, flows and shared components are affected. Add regression checks for those to the todo list. Keep this proportional to the size of the change. +3. Log in with login_as and build a mental map of the app around the affected areas (snapshot, navigate, read). Do not click through unrelated areas. +4. Work the todo list with todo_next / todo_done. For each criterion: perform the flow, capture_evidence, check get_console and get_network for errors, then record_ac_result. For each regression item: verify the existing behaviour still works and record findings when it does not. +5. Judge UX and UI of the changed screens: consistency with the rest of the app, feedback on actions, error states, wording, layout. You may delegate screenshot review to the ux-reviewer subagent. Record findings with report_finding. +6. Finish with a summary in ${lang} as your final message. Structure it as: + - A verdict sentence. + - Summary (per the verbosity setting). + - A section titled "Affected areas:" with one bullet per affected screen or flow (one line each, keep this heading in English). + - Anything you could not test and why. +Do not repeat every finding in the summary; they are rendered from the records.`); + + return sections.join("\n\n"); +} + +function renderDiff(pr: PullRequestContext, maxChars: number): string { + const lines: string[] = [`# Changed files (${pr.files.length})`, ""]; + for (const f of pr.files) lines.push(`- ${f.status} ${f.filename}${f.previousFilename ? ` (was ${f.previousFilename})` : ""} (+${f.additions} -${f.deletions})`); + lines.push("", "## Patches", ""); + let used = 0; + let omitted = 0; + for (const f of pr.files) { + if (!f.patch) continue; + const block = `### ${f.filename}\n\`\`\`diff\n${f.patch}\n\`\`\`\n`; + if (used + block.length > maxChars) { + omitted++; + continue; + } + used += block.length; + lines.push(block); + } + if (omitted > 0) lines.push(`(${omitted} patches omitted for size; read those files in the checkout if relevant.)`); + return lines.join("\n"); +} + +export function extractAffectedAreas(summary: string): string[] { + const m = summary.match(/affected areas:?\s*\n([\s\S]*?)(?:\n\s*\n|$)/i); + if (!m) return []; + return m[1]! + .split("\n") + .map((l) => l.replace(/^\s*[-*•]\s*/, "").trim()) + .filter((l) => l.length > 0 && !/^#/.test(l)); +} diff --git a/packages/agent/src/runner.ts b/packages/agent/src/runner.ts new file mode 100644 index 0000000..a31759d --- /dev/null +++ b/packages/agent/src/runner.ts @@ -0,0 +1,263 @@ +import { execFile } from "node:child_process"; +import { appendFileSync, readFileSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { query, type Options, type SDKMessage, type SDKResultMessage } from "@anthropic-ai/claude-agent-sdk"; +import { RunStore, createClaudioTools, createDriver, type DriverSession } from "@claudio/browser"; +import { + ReviewRequestSchema, + budgetProfile, + loadRepoConfig, + newRunId, + nowIso, + renderMarkdown, + renderPrComment, + type ReviewReport, + type ReviewRequest, + type RunStatus, +} from "@claudio/core"; +import { createGithubClient, fetchPullRequestContext, postPullRequestComment, type PullRequestContext } from "@claudio/github"; +import { createBrowserGuard } from "./hooks.js"; +import { SYSTEM_APPEND, buildReviewPrompt, extractAffectedAreas } from "./prompts.js"; +import { createClaudioSdkServer } from "./sdk-tools.js"; + +const execFileAsync = promisify(execFile); + +export type RunnerDeps = { + /** Directory that holds one sub-directory per run. Default: ./runs */ + runsRoot?: string; + /** Path to claudio.config.json. Default: ./claudio.config.json */ + configPath?: string; + /** Claudio project root (where .claude/agents and skills live). Default: process.cwd() */ + projectRoot?: string; + githubToken?: string; + /** Use a PullRequestContext JSON file instead of fetching from GitHub (offline runs, demo target). */ + prContextFile?: string; + /** Use an existing checkout instead of cloning the PR branch. */ + repoDir?: string; + abortController?: AbortController; + onProgress?: (line: string) => void; +}; + +export type RunHandle = { runId: string; runDir: string }; + +export async function runReview(input: ReviewRequest, deps: RunnerDeps = {}): Promise { + const request = ReviewRequestSchema.parse(input); + const log = deps.onProgress ?? (() => undefined); + const runId = newRunId(`review-pr${request.pr}`); + const runsRoot = resolve(deps.runsRoot ?? "runs"); + const runDir = join(runsRoot, runId); + const store = new RunStore(runDir); + const started = Date.now(); + const errors: string[] = []; + log(`run ${runId} → ${runDir}`); + + const config = loadRepoConfig(deps.configPath); + if (config && config.repo !== request.repo) log(`warning: claudio.config.json is for ${config.repo}, reviewing ${request.repo}`); + const roleEnvVars = Object.values(config?.roles ?? {}).flatMap((r) => [r.usernameEnv, r.passwordEnv]); + + const token = deps.githubToken ?? process.env.GITHUB_TOKEN; + let pr: PullRequestContext; + if (deps.prContextFile) { + pr = JSON.parse(readFileSync(deps.prContextFile, "utf8")) as PullRequestContext; + log(`using PR context from ${deps.prContextFile}`); + } else { + log(`fetching PR #${request.pr} of ${request.repo}`); + pr = await fetchPullRequestContext(createGithubClient(token), request.repo, request.pr); + } + store.writeFile("pr-context.json", pr); + log(`PR "${pr.title}" (${pr.headRef} → ${pr.baseRef}), ${pr.files.length} files, ${pr.issues.length} linked issues`); + + let repoDir: string | null = null; + if (deps.repoDir) { + repoDir = resolve(deps.repoDir); + } else if (request.cloneRepo && token) { + repoDir = join(runDir, "repo"); + try { + await cloneBranch(request.repo, pr.headRef, repoDir, token); + log(`checked out ${pr.headRef} at ${repoDir}`); + } catch (e) { + repoDir = null; + const msg = `clone failed: ${scrub((e as Error).message, token)}`; + errors.push(msg); + log(msg); + } + } + + const profile = budgetProfile(request.budget, request.modelOverride); + const driver = createDriver(request.driver); + const session: DriverSession = await driver.start({ + runDir, + instanceUrl: request.instanceUrl, + headed: request.headed, + viewport: { width: 1280, height: 800 }, + language: request.language, + }); + log(`browser driver: ${session.name}${session.attachment ? ` (cdp ${session.attachment.cdpEndpoint})` : ""}`); + + const tools = createClaudioTools({ + runDir, + store, + attachment: session.attachment, + instanceUrl: request.instanceUrl, + roles: config?.roles ?? {}, + driver: session.name, + }); + const claudioServer = createClaudioSdkServer(tools); + + const instanceHost = new URL(request.instanceUrl).hostname; + const guard = createBrowserGuard({ + allowedHosts: [instanceHost, ...(config?.extraAllowedHosts ?? [])], + maxBrowserSteps: profile.maxBrowserSteps, + onStep: (n) => { + if (n % 25 === 0) log(`browser steps: ${n}/${profile.maxBrowserSteps}`); + }, + }); + + const prompt = buildReviewPrompt({ runId, request, pr, config, repoDir, profile, driverPrefix: session.promptPrefix }); + store.writeFile("prompt.md", prompt); + + const abortController = deps.abortController ?? new AbortController(); + const options: Options = { + cwd: resolve(deps.projectRoot ?? process.cwd()), + settingSources: ["project"], + systemPrompt: { type: "preset", preset: "claude_code", append: SYSTEM_APPEND }, + model: profile.orchestratorModel, + effort: profile.effort, + thinking: { type: "adaptive" }, + maxTurns: profile.maxTurns, + maxBudgetUsd: profile.maxBudgetUsd, + mcpServers: { ...session.mcpServers, claudio: claudioServer }, + allowedTools: [...session.allowedTools, "Read", "Grep", "Glob", "Agent", "TodoWrite"], + disallowedTools: [...session.disallowedTools, "Bash", "Write", "Edit", "MultiEdit", "NotebookEdit", "WebFetch", "WebSearch", "KillShell", "BashOutput"], + permissionMode: "bypassPermissions", + allowDangerouslySkipPermissions: true, + hooks: { PreToolUse: guard.hooks }, + additionalDirectories: repoDir ? [repoDir] : [], + extraArgs: session.extraArgs, + abortController, + env: envWithout(process.env, roleEnvVars), + stderr: (data) => appendFileSync(join(runDir, "claude-stderr.log"), data), + }; + + const logFile = join(runDir, "log.jsonl"); + let result: SDKResultMessage | undefined; + let sessionId: string | undefined; + let status: RunStatus = "failed"; + try { + for await (const message of query({ prompt, options })) { + appendFileSync(logFile, JSON.stringify(message) + "\n"); + sessionId ??= (message as { session_id?: string }).session_id; + describe(message, log); + if (message.type === "result") result = message; + } + status = mapStatus(result); + if (result && result.subtype !== "success") errors.push(...result.errors); + } catch (e) { + status = abortController.signal.aborted ? "aborted" : "failed"; + errors.push(`agent error: ${(e as Error).message}`); + log(`agent error: ${(e as Error).message}`); + } finally { + await session.close().catch((e) => errors.push(`driver close: ${(e as Error).message}`)); + } + + const summary = result && result.subtype === "success" ? result.result : ""; + const report: ReviewReport = { + runId, + status, + request, + pr: { number: pr.number, title: pr.title, url: pr.url, headRef: pr.headRef, baseRef: pr.baseRef, author: pr.author }, + issues: pr.issues.map((i) => ({ number: i.number, title: i.title, url: i.url })), + summary, + affectedAreas: extractAffectedAreas(summary), + acceptanceCriteria: store.acResults(), + findings: store.findings(), + evidence: store.evidence(), + notes: store.notes(), + cost: { + usd: result?.total_cost_usd ?? 0, + inputTokens: (result?.usage.input_tokens ?? 0) + (result?.usage.cache_read_input_tokens ?? 0) + (result?.usage.cache_creation_input_tokens ?? 0), + outputTokens: result?.usage.output_tokens ?? 0, + turns: result?.num_turns ?? 0, + durationMs: Date.now() - started, + browserSteps: guard.steps(), + }, + sessionId, + errors, + createdAt: nowIso(), + }; + + writeFileSync(join(runDir, "report.json"), JSON.stringify(report, null, 2)); + writeFileSync(join(runDir, "report.md"), renderMarkdown(report, request.verbosity)); + writeFileSync(join(runDir, "report-detailed.md"), renderMarkdown(report, "detailed")); + log(`report: ${join(runDir, "report.md")} (status ${status}, ${report.cost.usd.toFixed(2)} USD, ${report.findings.length} findings)`); + + if (request.postComment) { + try { + const url = await postPullRequestComment(createGithubClient(token), request.repo, request.pr, renderPrComment(report)); + log(`PR comment posted: ${url}`); + } catch (e) { + const msg = `PR comment failed: ${(e as Error).message}`; + report.errors.push(msg); + log(msg); + writeFileSync(join(runDir, "report.json"), JSON.stringify(report, null, 2)); + } + } + return report; +} + +function mapStatus(result: SDKResultMessage | undefined): RunStatus { + if (!result) return "failed"; + switch (result.subtype) { + case "success": + return "completed"; + case "error_max_budget_usd": + return "budget_exceeded"; + case "error_max_turns": + return "max_turns"; + default: + return "failed"; + } +} + +function describe(message: SDKMessage, log: (line: string) => void): void { + if (message.type === "system" && message.subtype === "init") { + log(`session ${message.session_id} model=${message.model} mcp=${message.mcp_servers.map((s) => `${s.name}:${s.status}`).join(",")}`); + return; + } + if (message.type === "assistant") { + for (const block of message.message.content) { + if (block.type === "tool_use") { + log(`→ ${block.name} ${short(JSON.stringify(block.input), 100)}`); + } else if (block.type === "text" && block.text.trim()) { + log(`· ${short(block.text.trim().replace(/\s+/g, " "), 160)}`); + } + } + return; + } + if (message.type === "result") { + log(`result: ${message.subtype}, ${message.num_turns} turns, ${message.total_cost_usd.toFixed(2)} USD, ${Math.round(message.duration_ms / 1000)}s`); + } +} + +function short(text: string, max: number): string { + return text.length <= max ? text : text.slice(0, max - 1) + "…"; +} + +async function cloneBranch(repo: string, branch: string, dir: string, token: string): Promise { + const url = `https://x-access-token:${token}@github.com/${repo}.git`; + await execFileAsync("git", ["clone", "--depth", "1", "--branch", branch, "--single-branch", url, dir], { + env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, + maxBuffer: 10 * 1024 * 1024, + }); +} + +function scrub(text: string, secret?: string): string { + return secret ? text.split(secret).join("***") : text; +} + +function envWithout(env: NodeJS.ProcessEnv, keys: string[]): Record { + const out: Record = { ...env }; + for (const k of keys) delete out[k]; + return out; +} diff --git a/packages/agent/src/sdk-tools.ts b/packages/agent/src/sdk-tools.ts new file mode 100644 index 0000000..49680e5 --- /dev/null +++ b/packages/agent/src/sdk-tools.ts @@ -0,0 +1,22 @@ +import { createSdkMcpServer, tool, type McpSdkServerConfigWithInstance } from "@anthropic-ai/claude-agent-sdk"; +import type { ClaudioTool } from "@claudio/browser"; + +/** Wraps the neutral Claudio tool definitions as an in-process MCP server for the Agent SDK. */ +export function createClaudioSdkServer(tools: ClaudioTool[]): McpSdkServerConfigWithInstance { + return createSdkMcpServer({ + name: "claudio", + version: "0.1.0", + alwaysLoad: true, + instructions: + "Claudio tools record the review: login_as for authentication, capture_evidence for screenshots, report_finding and record_ac_result for results, todo_* and note for working memory, get_console / get_network for runtime errors.", + tools: tools.map((t) => + tool( + t.name, + t.description, + t.schema, + async (args) => ({ content: [{ type: "text" as const, text: await t.handler(args as never) }] }), + { annotations: { readOnlyHint: t.readOnly ?? false }, alwaysLoad: true }, + ), + ), + }); +} diff --git a/packages/agent/test/prompts.test.ts b/packages/agent/test/prompts.test.ts new file mode 100644 index 0000000..9510e6c --- /dev/null +++ b/packages/agent/test/prompts.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { extractAffectedAreas } from "../src/prompts.js"; +import { createBrowserGuard } from "../src/hooks.js"; + +describe("extractAffectedAreas", () => { + it("reads the bullet list under the heading", () => { + const s = "Fazit.\n\nAffected areas:\n- Fahrten anlegen\n* Tourenplanung\n\nNicht getestet: nichts"; + expect(extractAffectedAreas(s)).toEqual(["Fahrten anlegen", "Tourenplanung"]); + }); + it("returns empty when missing", () => { + expect(extractAffectedAreas("nothing")).toEqual([]); + }); +}); + +describe("browser guard", () => { + const input = (tool: string, url?: string) => ({ + hook_event_name: "PreToolUse" as const, + tool_name: tool, + tool_input: url ? { url } : {}, + tool_use_id: "t", + session_id: "s", + transcript_path: "", + cwd: "", + }); + it("denies navigation outside the instance host", async () => { + const g = createBrowserGuard({ allowedHosts: ["pr-1.example.test"], maxBrowserSteps: 10 }); + const hook = g.hooks[0]!.hooks[0]!; + const ok = await hook(input("mcp__playwright__browser_navigate", "https://pr-1.example.test/fahrten"), "t", { signal: new AbortController().signal }); + expect(ok).toEqual({}); + const sub = await hook(input("mcp__playwright__browser_navigate", "https://api.pr-1.example.test/x"), "t", { signal: new AbortController().signal }); + expect(sub).toEqual({}); + const bad = await hook(input("mcp__playwright__browser_navigate", "https://evil.test/"), "t", { signal: new AbortController().signal }); + expect((bad as { hookSpecificOutput?: { permissionDecision?: string } }).hookSpecificOutput?.permissionDecision).toBe("deny"); + const js = await hook(input("mcp__playwright__browser_navigate", "javascript:alert(1)"), "t", { signal: new AbortController().signal }); + expect((js as { hookSpecificOutput?: { permissionDecision?: string } }).hookSpecificOutput?.permissionDecision).toBe("deny"); + }); + it("counts steps and denies past the budget, ignoring claudio tools", async () => { + const g = createBrowserGuard({ allowedHosts: ["h"], maxBrowserSteps: 2 }); + const hook = g.hooks[0]!.hooks[0]!; + const sig = { signal: new AbortController().signal }; + await hook(input("mcp__claudio__note"), "t", sig); + await hook(input("mcp__playwright__browser_snapshot"), "t", sig); + await hook(input("mcp__playwright__browser_click"), "t", sig); + expect(g.steps()).toBe(2); + const over = await hook(input("mcp__playwright__browser_click"), "t", sig); + expect((over as { hookSpecificOutput?: { permissionDecision?: string } }).hookSpecificOutput?.permissionDecision).toBe("deny"); + }); +}); diff --git a/packages/agent/tsconfig.json b/packages/agent/tsconfig.json new file mode 100644 index 0000000..404aab4 --- /dev/null +++ b/packages/agent/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "noEmit": true, "rootDir": "src" }, + "include": ["src"] +} diff --git a/packages/browser/.playwright-mcp/page-2026-10-10T13-43-33-002Z.yml b/packages/browser/.playwright-mcp/page-2026-10-10T13-43-33-002Z.yml new file mode 100644 index 0000000..efa2040 --- /dev/null +++ b/packages/browser/.playwright-mcp/page-2026-10-10T13-43-33-002Z.yml @@ -0,0 +1 @@ +- button "Click me" [ref=e2] \ No newline at end of file diff --git a/packages/browser/package.json b/packages/browser/package.json new file mode 100644 index 0000000..390cf7e --- /dev/null +++ b/packages/browser/package.json @@ -0,0 +1,21 @@ +{ + "name": "@claudio/browser", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@claudio/core": "workspace:*", + "@modelcontextprotocol/sdk": "^1.32.1", + "@playwright/mcp": "^0.0.83", + "playwright": "^1.64.0", + "zod": "^4.6.5" + } +} diff --git a/packages/browser/src/capture.ts b/packages/browser/src/capture.ts new file mode 100644 index 0000000..5d565c2 --- /dev/null +++ b/packages/browser/src/capture.ts @@ -0,0 +1,159 @@ +import type { BrowserContext, Page, Request as PwRequest } from "playwright"; + +export type ConsoleEntry = { + seq: number; + ts: string; + level: string; + text: string; + pageUrl: string; + location?: string; +}; + +export type NetworkEntry = { + seq: number; + ts: string; + method: string; + url: string; + resourceType: string; + status?: number; + ok?: boolean; + failed?: string; + durationMs?: number; + requestBody?: string; + responseBody?: string; + pageUrl: string; +}; + +const BODY_LIMIT = 4_000; +const API_TYPES = new Set(["xhr", "fetch"]); + +/** + * Collects console messages and network traffic for every page of a browser + * context, including pages opened later by Playwright MCP. Bodies are only + * kept for XHR/fetch requests and truncated, so the buffer stays small. + */ +export class CaptureBuffer { + readonly console: ConsoleEntry[] = []; + readonly network: NetworkEntry[] = []; + private seq = 0; + private pending = new Map(); + private attachedPages = new WeakSet(); + + attach(context: BrowserContext): void { + context.pages().forEach((p) => this.attachPage(p)); + context.on("page", (p) => this.attachPage(p)); + } + + private attachPage(page: Page): void { + if (this.attachedPages.has(page)) return; + this.attachedPages.add(page); + page.on("console", (msg) => { + const loc = msg.location(); + this.console.push({ + seq: ++this.seq, + ts: new Date().toISOString(), + level: msg.type(), + text: msg.text(), + pageUrl: safeUrl(page), + location: loc.url ? `${loc.url}:${loc.lineNumber}` : undefined, + }); + }); + page.on("pageerror", (err) => { + this.console.push({ + seq: ++this.seq, + ts: new Date().toISOString(), + level: "pageerror", + text: err.message, + pageUrl: safeUrl(page), + }); + }); + page.on("request", (req) => { + const entry: NetworkEntry = { + seq: ++this.seq, + ts: new Date().toISOString(), + method: req.method(), + url: req.url(), + resourceType: req.resourceType(), + pageUrl: safeUrl(page), + }; + if (API_TYPES.has(req.resourceType())) { + const body = req.postData(); + if (body) entry.requestBody = truncate(body, BODY_LIMIT); + } + this.pending.set(req, entry); + this.network.push(entry); + }); + page.on("response", async (res) => { + const entry = this.pending.get(res.request()); + if (!entry) return; + entry.status = res.status(); + entry.ok = res.ok(); + entry.durationMs = Date.now() - Date.parse(entry.ts); + if (API_TYPES.has(entry.resourceType) || !res.ok()) { + try { + const ct = res.headers()["content-type"] ?? ""; + if (/json|text/.test(ct)) entry.responseBody = truncate(await res.text(), BODY_LIMIT); + } catch { + // body may be unavailable (redirect, aborted); keep status only + } + } + this.pending.delete(res.request()); + }); + page.on("requestfailed", (req) => { + const entry = this.pending.get(req); + if (!entry) return; + entry.failed = req.failure()?.errorText ?? "failed"; + entry.ok = false; + this.pending.delete(req); + }); + } + + queryConsole(opts: { level?: string; contains?: string; sinceSeq?: number; limit?: number } = {}): ConsoleEntry[] { + const limit = opts.limit ?? 50; + const minLevel = opts.level ?? "all"; + return this.console + .filter((e) => (opts.sinceSeq ? e.seq > opts.sinceSeq : true)) + .filter((e) => matchesLevel(e.level, minLevel)) + .filter((e) => (opts.contains ? e.text.toLowerCase().includes(opts.contains.toLowerCase()) : true)) + .slice(-limit); + } + + queryNetwork(opts: { urlContains?: string; onlyErrors?: boolean; onlyApi?: boolean; sinceSeq?: number; limit?: number } = {}): NetworkEntry[] { + const limit = opts.limit ?? 50; + return this.network + .filter((e) => (opts.sinceSeq ? e.seq > opts.sinceSeq : true)) + .filter((e) => (opts.onlyApi ? API_TYPES.has(e.resourceType) || e.resourceType === "document" : true)) + .filter((e) => (opts.onlyErrors ? e.failed !== undefined || (e.status !== undefined && e.status >= 400) : true)) + .filter((e) => (opts.urlContains ? e.url.toLowerCase().includes(opts.urlContains.toLowerCase()) : true)) + .slice(-limit); + } + + get lastSeq(): number { + return this.seq; + } + + snapshot(): { console: ConsoleEntry[]; network: NetworkEntry[] } { + return { console: [...this.console], network: [...this.network] }; + } +} + +function matchesLevel(level: string, min: string): boolean { + if (min === "all") return true; + const errorLike = new Set(["error", "pageerror", "assert"]); + const warnLike = new Set(["warning", "warn"]); + if (min === "error") return errorLike.has(level); + if (min === "warning") return errorLike.has(level) || warnLike.has(level); + return true; +} + +function safeUrl(page: Page): string { + try { + return page.url(); + } catch { + return ""; + } +} + +function truncate(text: string, max: number): string { + return text.length <= max ? text : text.slice(0, max) + `... [truncated ${text.length - max} chars]`; +} diff --git a/packages/browser/src/chrome-driver.ts b/packages/browser/src/chrome-driver.ts new file mode 100644 index 0000000..77b9cff --- /dev/null +++ b/packages/browser/src/chrome-driver.ts @@ -0,0 +1,37 @@ +import type { BrowserDriver, DriverSession, DriverStartOptions } from "./driver.js"; + +/** + * Live driver: the Claude in Chrome extension drives the developer's visible + * Chrome window through the Claude Code harness (`--chrome`). People can watch + * and step in (log in, solve a CAPTCHA). Documented for the CLI and VS Code; + * whether the Agent SDK forwards the flag is a Phase 0 spike item. + * + * Limitations compared to the Playwright driver: no shared browser handle, so + * Claudio cannot capture console/network itself or run login recipes. The + * agent uses the extension's own tools for screenshots and console reads and + * registers evidence paths with Claudio. + */ +export class ChromeExtensionDriver implements BrowserDriver { + readonly name = "chrome" as const; + + async start(opts: DriverStartOptions): Promise { + const promptPrefix = [ + "Browser driver: Claude in Chrome (live mode). A person is watching the browser window and can step in.", + "Use the Chrome browser tools for navigation, reading pages, clicking, typing and screenshots.", + "When a login page appears, stop and ask the person to log in (say which role), then continue. Never type credentials yourself.", + "Save screenshots to disk and register each one with `mcp__claudio__capture_evidence` (pass the file path). Network traffic is not captured in this mode; say so in findings where it matters.", + `Only navigate within ${opts.instanceUrl}.`, + ].join("\n"); + + return { + name: "chrome", + mcpServers: {}, + allowedTools: ["mcp__claudio__*", "mcp__claude-in-chrome__*", "mcp__chrome__*"], + disallowedTools: [], + extraArgs: { chrome: null }, + promptPrefix, + attachment: null, + close: async () => undefined, + }; + } +} diff --git a/packages/browser/src/driver.ts b/packages/browser/src/driver.ts new file mode 100644 index 0000000..645ef9e --- /dev/null +++ b/packages/browser/src/driver.ts @@ -0,0 +1,52 @@ +import type { Browser, BrowserContext, Page } from "playwright"; +import type { DriverName } from "@claudio/core"; +import type { CaptureBuffer } from "./capture.js"; + +/** Structurally compatible with the Agent SDK's McpStdioServerConfig. */ +export type StdioServerSpec = { + type?: "stdio"; + command: string; + args?: string[]; + env?: Record; + alwaysLoad?: boolean; +}; + +/** Claudio's own handle on the browser that the agent drives. */ +export type BrowserAttachment = { + browser: Browser; + context: BrowserContext; + capture: CaptureBuffer; + cdpEndpoint: string; + /** The page the agent most recently interacted with (best effort: last page in the context). */ + activePage(): Page | undefined; +}; + +export type DriverSession = { + name: DriverName; + mcpServers: Record; + /** Tool patterns the agent may call without a permission prompt. */ + allowedTools: string[]; + /** Tools removed from the agent entirely. */ + disallowedTools: string[]; + /** Extra CLI flags for the Claude Code harness (e.g. `--chrome`). */ + extraArgs?: Record; + /** Driver-specific instructions prepended to the task prompt. */ + promptPrefix: string; + attachment: BrowserAttachment | null; + close(): Promise; +}; + +export type DriverStartOptions = { + runDir: string; + instanceUrl: string; + headed: boolean; + /** Explicit Chromium binary; defaults to Playwright's bundled browser or CLAUDIO_CHROMIUM_PATH. */ + chromiumPath?: string; + viewport?: { width: number; height: number }; + language?: "de" | "en"; +}; + +export interface BrowserDriver { + readonly name: DriverName; + start(opts: DriverStartOptions): Promise; +} diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts new file mode 100644 index 0000000..55bdaa1 --- /dev/null +++ b/packages/browser/src/index.ts @@ -0,0 +1,16 @@ +export * from "./driver.js"; +export * from "./playwright-driver.js"; +export * from "./chrome-driver.js"; +export * from "./capture.js"; +export * from "./run-store.js"; +export * from "./login.js"; +export * from "./tools/definitions.js"; + +import type { DriverName } from "@claudio/core"; +import { ChromeExtensionDriver } from "./chrome-driver.js"; +import type { BrowserDriver } from "./driver.js"; +import { PlaywrightDriver } from "./playwright-driver.js"; + +export function createDriver(name: DriverName): BrowserDriver { + return name === "chrome" ? new ChromeExtensionDriver() : new PlaywrightDriver(); +} diff --git a/packages/browser/src/login.ts b/packages/browser/src/login.ts new file mode 100644 index 0000000..9b2de2a --- /dev/null +++ b/packages/browser/src/login.ts @@ -0,0 +1,131 @@ +import type { BrowserContext, Page } from "playwright"; +import type { Credentials, RoleConfig } from "@claudio/core"; + +export type LoginResult = { ok: true; finalUrl: string; page: Page } | { ok: false; error: string; finalUrl?: string }; + +/** + * Logs a role in. Uses the explicit recipe when the config has one, otherwise + * an adaptive heuristic that works for most username/password forms. + * Credentials never appear in returned messages. + */ +export async function loginWithRole( + context: BrowserContext, + instanceUrl: string, + role: RoleConfig, + creds: Credentials, +): Promise { + const page = context.pages().find((p) => !p.isClosed()) ?? (await context.newPage()); + try { + if (role.recipe && role.recipe.length > 0) { + await runRecipe(page, instanceUrl, role, creds); + } else { + await autoLogin(page, instanceUrl, role, creds); + } + await page.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => undefined); + const finalUrl = page.url(); + if (role.successUrlContains && !finalUrl.includes(role.successUrlContains)) { + return { ok: false, error: `Login did not reach a URL containing "${role.successUrlContains}"`, finalUrl }; + } + if (looksLikeLoginPage(finalUrl, role)) { + const err = await visibleErrorText(page); + return { ok: false, error: `Still on the login page after submitting${err ? `: ${err}` : ""}`, finalUrl }; + } + return { ok: true, finalUrl, page }; + } catch (e) { + return { ok: false, error: scrub((e as Error).message, creds), finalUrl: safeUrl(page) }; + } +} + +async function runRecipe(page: Page, instanceUrl: string, role: RoleConfig, creds: Credentials): Promise { + const subst = (v: string) => v.replace(/\$USER\b/g, creds.username).replace(/\$PASSWORD\b/g, creds.password); + for (const step of role.recipe ?? []) { + switch (step.action) { + case "goto": + await page.goto(new URL(step.url, instanceUrl).toString(), { waitUntil: "domcontentloaded" }); + break; + case "fill": + await page.locator(step.selector).first().fill(subst(step.value)); + break; + case "click": + await page.locator(step.selector).first().click(); + break; + case "press": + await page.keyboard.press(step.key); + break; + case "waitForUrl": + await page.waitForURL((u) => u.toString().includes(step.pattern), { timeout: 20_000 }); + break; + case "waitForSelector": + await page.locator(step.selector).first().waitFor({ timeout: 20_000 }); + break; + } + } +} + +async function autoLogin(page: Page, instanceUrl: string, role: RoleConfig, creds: Credentials): Promise { + await page.goto(new URL(role.loginPath, instanceUrl).toString(), { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle", { timeout: 10_000 }).catch(() => undefined); + + const password = page.locator('input[type="password"]').first(); + await password.waitFor({ timeout: 15_000 }); + + const userCandidates = [ + 'input[autocomplete="username"]', + 'input[type="email"]', + 'input[name*="user" i]', + 'input[name*="mail" i]', + 'input[name*="login" i]', + 'input[type="text"]', + ]; + let filledUser = false; + for (const sel of userCandidates) { + const loc = page.locator(sel).first(); + if ((await loc.count()) > 0 && (await loc.isVisible().catch(() => false))) { + await loc.fill(creds.username); + filledUser = true; + break; + } + } + if (!filledUser) throw new Error("Could not find a username/email field on the login page"); + await password.fill(creds.password); + + const submit = page.locator('button[type="submit"], input[type="submit"], form button').first(); + const startUrl = page.url(); + if ((await submit.count()) > 0) { + await submit.click(); + } else { + await password.press("Enter"); + } + await Promise.race([ + page.waitForURL((u) => u.toString() !== startUrl, { timeout: 20_000 }), + page.waitForLoadState("networkidle", { timeout: 20_000 }), + ]).catch(() => undefined); +} + +function looksLikeLoginPage(url: string, role: RoleConfig): boolean { + const loginPath = role.loginPath.replace(/\/+$/, ""); + return loginPath.length > 1 && new URL(url).pathname.replace(/\/+$/, "") === loginPath; +} + +async function visibleErrorText(page: Page): Promise { + const candidates = page.locator('[role="alert"], .error, .alert, [class*="error" i], [data-testid*="error" i]'); + const n = Math.min(await candidates.count(), 3); + const texts: string[] = []; + for (let i = 0; i < n; i++) { + const t = (await candidates.nth(i).innerText().catch(() => "")).trim(); + if (t) texts.push(t.slice(0, 200)); + } + return texts.join(" | "); +} + +function scrub(text: string, creds: Credentials): string { + return text.split(creds.password).join("***").split(creds.username).join(""); +} + +function safeUrl(page: Page): string | undefined { + try { + return page.url(); + } catch { + return undefined; + } +} diff --git a/packages/browser/src/playwright-driver.ts b/packages/browser/src/playwright-driver.ts new file mode 100644 index 0000000..57cb422 --- /dev/null +++ b/packages/browser/src/playwright-driver.ts @@ -0,0 +1,133 @@ +import { createRequire } from "node:module"; +import { createServer } from "node:net"; +import { dirname, join } from "node:path"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { chromium, type Browser } from "playwright"; +import { CaptureBuffer } from "./capture.js"; +import type { BrowserAttachment, BrowserDriver, DriverSession, DriverStartOptions } from "./driver.js"; + +const require = createRequire(import.meta.url); + +/** + * Unattended driver. Claudio launches Chromium itself and lets Playwright MCP + * attach over CDP, so both the agent (via MCP) and Claudio's own tools + * (login, evidence, console/network capture) share one browser. + */ +export class PlaywrightDriver implements BrowserDriver { + readonly name = "playwright" as const; + + async start(opts: DriverStartOptions): Promise { + const port = await freePort(); + const executablePath = opts.chromiumPath ?? process.env.CLAUDIO_CHROMIUM_PATH; + let launched: Browser; + try { + launched = await chromium.launch({ + headless: !opts.headed, + executablePath, + args: [`--remote-debugging-port=${port}`, "--remote-debugging-address=127.0.0.1"], + }); + } catch (e) { + const msg = (e as Error).message; + if (/Executable doesn't exist/i.test(msg)) { + throw new Error( + `Chromium not found${executablePath ? ` at ${executablePath}` : ""}. Run "pnpm exec playwright install chromium" or set CLAUDIO_CHROMIUM_PATH to a Chromium/Chrome binary.`, + ); + } + throw e; + } + const cdpEndpoint = `http://127.0.0.1:${port}`; + + const attached = await chromium.connectOverCDP(cdpEndpoint); + const context = attached.contexts()[0] ?? (await attached.newContext()); + const capture = new CaptureBuffer(); + capture.attach(context); + if (opts.viewport) { + context.on("page", (p) => p.setViewportSize(opts.viewport!).catch(() => undefined)); + } + + let tracing = false; + try { + await context.tracing.start({ screenshots: true, snapshots: true, sources: false }); + tracing = true; + } catch { + // tracing is best effort over CDP + } + + const attachment: BrowserAttachment = { + browser: attached, + context, + capture, + cdpEndpoint, + activePage: () => { + const pages = context.pages().filter((p) => !p.isClosed()); + return pages[pages.length - 1]; + }, + }; + + const mcpOutputDir = join(opts.runDir, "mcp-output"); + mkdirSync(mcpOutputDir, { recursive: true }); + // The package exports only its index; the CLI sits next to it. + const cliPath = join(dirname(require.resolve("@playwright/mcp")), "cli.js"); + const args = [ + cliPath, + "--cdp-endpoint", + cdpEndpoint, + "--caps", + "devtools", + "--output-dir", + mcpOutputDir, + "--image-responses", + "allow", + "--timeout-action", + "8000", + "--timeout-settle", + "800", + ]; + if (opts.viewport) args.push("--viewport-size", `${opts.viewport.width}x${opts.viewport.height}`); + + const promptPrefix = [ + "Browser driver: Playwright MCP (headless, unattended).", + "Use `mcp__playwright__browser_snapshot` to read the page (accessibility tree with element refs); take screenshots only when you need visual judgement or evidence.", + "Use `mcp__claudio__capture_evidence` for every observation you will report, and `mcp__claudio__get_console` / `mcp__claudio__get_network` after each flow to check for errors.", + `Only navigate within ${opts.instanceUrl}. Login is done with \`mcp__claudio__login_as\`, never by typing credentials yourself.`, + ].join("\n"); + + return { + name: "playwright", + mcpServers: { + playwright: { type: "stdio", command: process.execPath, args, alwaysLoad: true }, + }, + allowedTools: ["mcp__playwright__*", "mcp__claudio__*"], + disallowedTools: [ + "mcp__playwright__browser_run_code_unsafe", + "mcp__playwright__browser_file_upload", + "mcp__playwright__browser_start_recording", + "mcp__playwright__browser_stop_recording", + ], + promptPrefix, + attachment, + close: async () => { + const snap = capture.snapshot(); + writeFileSync(join(opts.runDir, "console.json"), JSON.stringify(snap.console, null, 2)); + writeFileSync(join(opts.runDir, "network.json"), JSON.stringify(snap.network, null, 2)); + if (tracing) { + await context.tracing.stop({ path: join(opts.runDir, "trace.zip") }).catch(() => undefined); + } + await attached.close().catch(() => undefined); + await launched.close().catch(() => undefined); + }, + }; + } +} + +function freePort(): Promise { + return new Promise((resolve, reject) => { + const srv = createServer(); + srv.listen(0, "127.0.0.1", () => { + const addr = srv.address(); + const port = typeof addr === "object" && addr ? addr.port : 0; + srv.close(() => (port ? resolve(port) : reject(new Error("no free port")))); + }); + srv.on("error", reject); + }); +} diff --git a/packages/browser/src/run-store.ts b/packages/browser/src/run-store.ts new file mode 100644 index 0000000..822aa59 --- /dev/null +++ b/packages/browser/src/run-store.ts @@ -0,0 +1,120 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from "node:fs"; +import { join } from "node:path"; +import { + AcceptanceCriterionResultSchema, + EvidenceSchema, + FindingSchema, + TodoItemSchema, + nowIso, + type AcceptanceCriterionResult, + type Evidence, + type Finding, + type TodoItem, +} from "@claudio/core"; +import { z } from "zod"; + +/** + * File-backed state of one run. Every write goes straight to disk, so a + * crashed or aborted run still leaves findings, evidence and notes behind. + */ +export class RunStore { + readonly evidenceDir: string; + private counters = { finding: 0, evidence: 0, todo: 0 }; + + constructor(readonly runDir: string) { + mkdirSync(runDir, { recursive: true }); + this.evidenceDir = join(runDir, "evidence"); + mkdirSync(this.evidenceDir, { recursive: true }); + this.counters.finding = this.findings().length; + this.counters.evidence = this.evidence().length; + this.counters.todo = this.todos().length; + } + + private file(name: string): string { + return join(this.runDir, name); + } + + private readJson(name: string, schema: z.ZodType): T[] { + const f = this.file(name); + if (!existsSync(f)) return []; + const raw = JSON.parse(readFileSync(f, "utf8")); + return z.array(schema).parse(raw); + } + + private writeJson(name: string, data: unknown): void { + writeFileSync(this.file(name), JSON.stringify(data, null, 2)); + } + + findings(): Finding[] { + return this.readJson("findings.json", FindingSchema); + } + + addFinding(input: Omit): Finding { + const finding: Finding = { ...input, id: `F${++this.counters.finding}`, createdAt: nowIso() }; + this.writeJson("findings.json", [...this.findings(), finding]); + return finding; + } + + evidence(): Evidence[] { + return this.readJson("evidence.json", EvidenceSchema); + } + + nextEvidenceId(): string { + return `E${++this.counters.evidence}`; + } + + addEvidence(input: Omit): Evidence { + const ev: Evidence = { ...input, createdAt: nowIso() }; + this.writeJson("evidence.json", [...this.evidence(), ev]); + return ev; + } + + acResults(): AcceptanceCriterionResult[] { + return this.readJson("acceptance.json", AcceptanceCriterionResultSchema); + } + + recordAcResult(input: Omit): AcceptanceCriterionResult { + const existing = this.acResults().filter((a) => a.criterion !== input.criterion); + const result: AcceptanceCriterionResult = { ...input, createdAt: nowIso() }; + this.writeJson("acceptance.json", [...existing, result]); + return result; + } + + addNote(text: string): void { + appendFileSync(this.file("notes.md"), `- ${nowIso()} ${text.trim().replace(/\n/g, "\n ")}\n`); + } + + notes(): string { + const f = this.file("notes.md"); + return existsSync(f) ? readFileSync(f, "utf8") : ""; + } + + todos(): TodoItem[] { + return this.readJson("todo.json", TodoItemSchema); + } + + todoAdd(items: string[]): TodoItem[] { + const added = items.map((text) => ({ id: ++this.counters.todo, text, status: "open" as const })); + this.writeJson("todo.json", [...this.todos(), ...added]); + return added; + } + + todoNext(): TodoItem | undefined { + return this.todos().find((t) => t.status === "open"); + } + + todoDone(id: number, result: string, status: "done" | "skipped" = "done"): TodoItem | undefined { + const all = this.todos(); + const item = all.find((t) => t.id === id); + if (!item) return undefined; + item.status = status; + item.result = result; + this.writeJson("todo.json", all); + return item; + } + + /** Arbitrary JSON side files (pr-context.json, cost.json, ...). */ + writeFile(name: string, content: string | object): void { + writeFileSync(this.file(name), typeof content === "string" ? content : JSON.stringify(content, null, 2)); + } +} diff --git a/packages/browser/src/tools/definitions.ts b/packages/browser/src/tools/definitions.ts new file mode 100644 index 0000000..d9f99a4 --- /dev/null +++ b/packages/browser/src/tools/definitions.ts @@ -0,0 +1,236 @@ +import { join, relative, isAbsolute } from "node:path"; +import { existsSync } from "node:fs"; +import { z } from "zod"; +import { + AcStatusSchema, + FindingCategorySchema, + SeveritySchema, + resolveCredentials, + type DriverName, + type RoleConfig, +} from "@claudio/core"; +import type { BrowserAttachment } from "../driver.js"; +import { loginWithRole } from "../login.js"; +import type { RunStore } from "../run-store.js"; + +export type ToolContext = { + runDir: string; + store: RunStore; + attachment: BrowserAttachment | null; + instanceUrl: string; + roles: Record; + driver: DriverName; + env?: NodeJS.ProcessEnv; +}; + +/** A tool definition that both the Agent SDK adapter and the stdio MCP server can consume. */ +export type ClaudioTool = { + name: string; + description: string; + schema: S; + readOnly?: boolean; + handler: (args: z.infer>) => Promise; +}; + +function def(t: ClaudioTool): ClaudioTool { + return t; +} + +export function createClaudioTools(ctx: ToolContext): ClaudioTool[] { + const { store } = ctx; + const roleNames = Object.keys(ctx.roles); + + const loginAs = def({ + name: "login_as", + description: `Log the browser in as one of the configured application roles (${roleNames.join(", ") || "none configured"}). Credentials stay on the host; you only get the result. Call this before testing anything that needs authentication, and again to switch roles.`, + schema: { role: z.string().describe("Role name as configured for this repository") }, + handler: async ({ role }) => { + const cfg = ctx.roles[role]; + if (!cfg) return `Unknown role "${role}". Available roles: ${roleNames.join(", ") || "none"}.`; + if (ctx.driver === "chrome" || !ctx.attachment) { + return `Live mode: ask the person watching to log in as "${role}"${cfg.description ? ` (${cfg.description})` : ""} in the browser window, then continue.`; + } + const creds = resolveCredentials(cfg, ctx.env ?? process.env); + if (!creds) return `Credentials for role "${role}" are missing (env vars ${cfg.usernameEnv} / ${cfg.passwordEnv}). Report this as blocked and continue with what you can test without login.`; + await ctx.attachment.context.clearCookies().catch(() => undefined); + const result = await loginWithRole(ctx.attachment.context, ctx.instanceUrl, cfg, creds); + if (!result.ok) { + store.addNote(`login_as(${role}) failed: ${result.error}`); + return `Login as "${role}" failed: ${result.error}${result.finalUrl ? ` (at ${result.finalUrl})` : ""}. You may inspect the login page yourself, but do not type credentials.`; + } + store.addNote(`login_as(${role}) ok → ${result.finalUrl}`); + return `Logged in as "${role}"${cfg.description ? ` (${cfg.description})` : ""}. Current URL: ${result.finalUrl}. Take a fresh snapshot before continuing.`; + }, + }); + + const captureEvidence = def({ + name: "capture_evidence", + description: + "Record evidence for something you observed: takes a screenshot of the current page (Playwright driver) or registers a screenshot you already saved (pass `path`). Returns an evidence id to reference in findings and acceptance results.", + schema: { + label: z.string().describe("Short label, e.g. 'Fahrt anlegen: Validierungsfehler'"), + note: z.string().optional().describe("What the evidence shows"), + path: z.string().optional().describe("Path of an existing screenshot file (live mode); omit to capture now"), + }, + handler: async ({ label, note, path }) => { + const id = store.nextEvidenceId(); + const safe = label.replace(/[^\w.-]+/g, "_").slice(0, 60); + if (path) { + const abs = isAbsolute(path) ? path : join(ctx.runDir, path); + if (!existsSync(abs)) return `File not found: ${abs}. Save the screenshot first, then register it.`; + store.addEvidence({ id, kind: "screenshot", label, path: relative(ctx.runDir, abs), note, pageUrl: undefined }); + return `Evidence ${id} registered (${relative(ctx.runDir, abs)}).`; + } + const page = ctx.attachment?.activePage(); + if (!page) { + store.addEvidence({ id, kind: "note", label, note }); + return `Evidence ${id} recorded as a note (no browser attachment available for a screenshot).`; + } + const file = join(store.evidenceDir, `${id}-${safe}.png`); + await page.screenshot({ path: file, fullPage: false }); + store.addEvidence({ id, kind: "screenshot", label, path: relative(ctx.runDir, file), note, pageUrl: page.url() }); + return `Evidence ${id} captured: ${relative(ctx.runDir, file)} (${page.url()}).`; + }, + }); + + const getConsole = def({ + name: "get_console", + description: "Return browser console messages and uncaught page errors captured since the run started (Playwright driver). Use after each flow to check for JavaScript errors.", + readOnly: true, + schema: { + level: z.enum(["error", "warning", "all"]).default("error"), + contains: z.string().optional().describe("Only messages containing this text"), + sinceSeq: z.number().int().optional().describe("Only entries after this sequence number (from a previous call)"), + limit: z.number().int().min(1).max(200).default(50), + }, + handler: async (args) => { + if (!ctx.attachment) return "Console capture is not available in this driver mode. Use the browser's own console tool instead."; + const entries = ctx.attachment.capture.queryConsole(args); + if (entries.length === 0) return `No console entries (level=${args.level}). Last seq: ${ctx.attachment.capture.lastSeq}.`; + return [ + `${entries.length} entries (level=${args.level}, last seq ${ctx.attachment.capture.lastSeq}):`, + ...entries.map((e) => `#${e.seq} [${e.level}] ${e.text}${e.location ? ` (${e.location})` : ""} @ ${e.pageUrl}`), + ].join("\n"); + }, + }); + + const getNetwork = def({ + name: "get_network", + description: + "Return network requests captured since the run started (Playwright driver): method, URL, status, timing, and truncated bodies for API calls. Use it to verify that the right requests were sent and to spot 4xx/5xx responses.", + readOnly: true, + schema: { + urlContains: z.string().optional().describe("Only requests whose URL contains this text"), + onlyErrors: z.boolean().default(false).describe("Only failed requests and HTTP status >= 400"), + onlyApi: z.boolean().default(true).describe("Only document/XHR/fetch requests (skip assets)"), + sinceSeq: z.number().int().optional(), + limit: z.number().int().min(1).max(200).default(40), + includeBodies: z.boolean().default(false), + }, + handler: async (args) => { + if (!ctx.attachment) return "Network capture is not available in this driver mode. Use the browser's own network tool instead."; + const entries = ctx.attachment.capture.queryNetwork(args); + if (entries.length === 0) return `No matching requests. Last seq: ${ctx.attachment.capture.lastSeq}.`; + return [ + `${entries.length} requests (last seq ${ctx.attachment.capture.lastSeq}):`, + ...entries.map((e) => { + const status = e.failed ? `FAILED ${e.failed}` : (e.status ?? "pending"); + let line = `#${e.seq} ${e.method} ${e.url} → ${status}${e.durationMs !== undefined ? ` ${e.durationMs}ms` : ""} [${e.resourceType}]`; + if (args.includeBodies) { + if (e.requestBody) line += `\n request: ${e.requestBody}`; + if (e.responseBody) line += `\n response: ${e.responseBody}`; + } + return line; + }), + ].join("\n"); + }, + }); + + const reportFinding = def({ + name: "report_finding", + description: + "Record a finding (bug, regression, UX/UI issue, console or network error, suggestion). Every finding you intend to mention in the report must go through this tool so it is stored with severity, steps and evidence.", + schema: { + category: FindingCategorySchema, + severity: SeveritySchema.describe("blocker: feature unusable or data loss; major: AC not met or clear bug; minor: wrong but workable; suggestion: improvement"), + title: z.string().max(140), + description: z.string(), + steps: z.array(z.string()).default([]).describe("Steps to reproduce"), + expected: z.string().optional(), + actual: z.string().optional(), + url: z.string().optional(), + acceptanceCriterion: z.string().optional().describe("The AC this finding relates to, if any"), + evidenceIds: z.array(z.string()).default([]), + }, + handler: async (args) => { + const f = store.addFinding(args); + return `Finding ${f.id} recorded ([${f.severity}] ${f.title}).`; + }, + }); + + const recordAc = def({ + name: "record_ac_result", + description: "Record the verification result for one acceptance criterion. Call it once per criterion; a later call for the same criterion replaces the earlier one.", + schema: { + criterion: z.string().describe("The criterion text, verbatim or lightly shortened"), + status: AcStatusSchema, + notes: z.string().default("").describe("How you verified it and what you saw"), + evidenceIds: z.array(z.string()).default([]), + }, + handler: async (args) => { + store.recordAcResult(args); + return `Recorded: "${args.criterion}" → ${args.status}.`; + }, + }); + + const note = def({ + name: "note", + description: "Write a working note into the run log (observations, data you created, assumptions). Notes are kept with the report.", + schema: { text: z.string() }, + handler: async ({ text }) => { + store.addNote(text); + return "Noted."; + }, + }); + + const todoAdd = def({ + name: "todo_add", + description: "Add items to the run's todo list (things to test). Build the list first, then work through it with todo_next / todo_done.", + schema: { items: z.array(z.string()).min(1) }, + handler: async ({ items }) => { + const added = store.todoAdd(items); + return `Added ${added.length} items: ${added.map((t) => `#${t.id} ${t.text}`).join("; ")}`; + }, + }); + + const todoNext = def({ + name: "todo_next", + description: "Return the next open todo item, or a summary when the list is done.", + readOnly: true, + schema: {}, + handler: async () => { + const next = store.todoNext(); + if (next) return `#${next.id} ${next.text}`; + const all = store.todos(); + return `No open items. ${all.filter((t) => t.status === "done").length} done, ${all.filter((t) => t.status === "skipped").length} skipped.`; + }, + }); + + const todoDone = def({ + name: "todo_done", + description: "Mark a todo item as done or skipped with a one-line result.", + schema: { + id: z.number().int(), + result: z.string(), + status: z.enum(["done", "skipped"]).default("done"), + }, + handler: async ({ id, result, status }) => { + const item = store.todoDone(id, result, status); + if (!item) return `No todo item #${id}.`; + const next = store.todoNext(); + return `#${id} ${status}. ${next ? `Next: #${next.id} ${next.text}` : "List complete."}`; + }, + }); + + return [loginAs, captureEvidence, getConsole, getNetwork, reportFinding, recordAc, note, todoAdd, todoNext, todoDone]; +} diff --git a/packages/browser/src/tools/stdio-server.ts b/packages/browser/src/tools/stdio-server.ts new file mode 100644 index 0000000..6856dd1 --- /dev/null +++ b/packages/browser/src/tools/stdio-server.ts @@ -0,0 +1,70 @@ +/** + * Stand-alone MCP server exposing the Claudio tools over stdio. + * Used by the Claude Code CLI live mode (see .mcp.json). Configuration comes + * from environment variables so the same server works for any run: + * + * CLAUDIO_RUN_DIR directory for findings/evidence/notes (required) + * CLAUDIO_INSTANCE_URL base URL of the app under test (required) + * CLAUDIO_CONFIG path to claudio.config.json (optional) + * CLAUDIO_DRIVER playwright | chrome (default chrome) + * CLAUDIO_CDP_ENDPOINT attach to a running Chromium for screenshots/capture (optional) + */ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { chromium } from "playwright"; +import { z } from "zod"; +import { loadRepoConfig } from "@claudio/core"; +import { CaptureBuffer } from "../capture.js"; +import type { BrowserAttachment } from "../driver.js"; +import { RunStore } from "../run-store.js"; +import { createClaudioTools, type ToolContext } from "./definitions.js"; + +async function main(): Promise { + const runDir = process.env.CLAUDIO_RUN_DIR; + const instanceUrl = process.env.CLAUDIO_INSTANCE_URL; + if (!runDir || !instanceUrl) { + console.error("CLAUDIO_RUN_DIR and CLAUDIO_INSTANCE_URL must be set"); + process.exit(2); + } + const config = loadRepoConfig(process.env.CLAUDIO_CONFIG); + const driver = process.env.CLAUDIO_DRIVER === "playwright" ? "playwright" : "chrome"; + + let attachment: BrowserAttachment | null = null; + if (process.env.CLAUDIO_CDP_ENDPOINT) { + const browser = await chromium.connectOverCDP(process.env.CLAUDIO_CDP_ENDPOINT); + const context = browser.contexts()[0] ?? (await browser.newContext()); + const capture = new CaptureBuffer(); + capture.attach(context); + attachment = { + browser, + context, + capture, + cdpEndpoint: process.env.CLAUDIO_CDP_ENDPOINT, + activePage: () => context.pages().filter((p) => !p.isClosed()).at(-1), + }; + } + + const ctx: ToolContext = { + runDir, + store: new RunStore(runDir), + attachment, + instanceUrl, + roles: config?.roles ?? {}, + driver, + }; + + const server = new McpServer({ name: "claudio", version: "0.1.0" }); + for (const t of createClaudioTools(ctx)) { + server.registerTool( + t.name, + { description: t.description, inputSchema: z.object(t.schema).shape, annotations: { readOnlyHint: t.readOnly ?? false } }, + async (args: Record) => ({ content: [{ type: "text" as const, text: await t.handler(args as never) }] }), + ); + } + await server.connect(new StdioServerTransport()); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/packages/browser/test/playwright-driver.integration.test.ts b/packages/browser/test/playwright-driver.integration.test.ts new file mode 100644 index 0000000..0f935af --- /dev/null +++ b/packages/browser/test/playwright-driver.integration.test.ts @@ -0,0 +1,105 @@ +/** + * Integration test for the Playwright driver against a throwaway local app. + * Runs only when CLAUDIO_INTEGRATION=1 (needs a Chromium: bundled or CLAUDIO_CHROMIUM_PATH). + */ +import { createServer, type Server } from "node:http"; +import { existsSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { PlaywrightDriver } from "../src/playwright-driver.js"; +import { RunStore } from "../src/run-store.js"; +import { createClaudioTools, type ClaudioTool } from "../src/tools/definitions.js"; +import type { DriverSession } from "../src/driver.js"; + +const enabled = process.env.CLAUDIO_INTEGRATION === "1"; + +describe.skipIf(!enabled)("PlaywrightDriver (integration)", () => { + let server: Server; + let instanceUrl: string; + let session: DriverSession; + let runDir: string; + let tools: Record>; + + beforeAll(async () => { + server = createServer((req, res) => { + const url = new URL(req.url ?? "/", "http://x"); + if (url.pathname === "/login" && req.method === "GET") { + res.setHeader("content-type", "text/html"); + res.end(`
`); + } else if (url.pathname === "/login" && req.method === "POST") { + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => { + const p = new URLSearchParams(body); + if (p.get("email") === "u@test" && p.get("password") === "p") { + res.statusCode = 302; + res.setHeader("location", "/dashboard"); + res.end(); + } else { + res.setHeader("content-type", "text/html"); + res.end(`
Falsche Zugangsdaten
`); + } + }); + } else if (url.pathname === "/dashboard") { + res.setHeader("content-type", "text/html"); + res.end(`

Dashboard

`); + } else if (url.pathname === "/api/broken") { + res.statusCode = 500; + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ error: "kaputt" })); + } else { + res.statusCode = 404; + res.end("nope"); + } + }); + await new Promise((r) => server.listen(0, "127.0.0.1", () => r())); + instanceUrl = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + runDir = mkdtempSync(join(tmpdir(), "claudio-it-")); + session = await new PlaywrightDriver().start({ runDir, instanceUrl, headed: false }); + process.env.IT_USER = "u@test"; + process.env.IT_PASS = "p"; + process.env.IT_BAD = "nope"; + const store = new RunStore(runDir); + tools = Object.fromEntries( + createClaudioTools({ + runDir, + store, + attachment: session.attachment, + instanceUrl, + driver: "playwright", + roles: { + admin: { usernameEnv: "IT_USER", passwordEnv: "IT_PASS", loginPath: "/login", successUrlContains: "/dashboard" }, + wrong: { usernameEnv: "IT_USER", passwordEnv: "IT_BAD", loginPath: "/login" }, + }, + }).map((t) => [t.name, t]), + ); + }, 60_000); + + afterAll(async () => { + await session?.close(); + server?.close(); + }); + + it("logs in with the adaptive heuristic and captures evidence, console and network", async () => { + expect(await tools.login_as!.handler({ role: "admin" })).toMatch(/Logged in as "admin"/); + await new Promise((r) => setTimeout(r, 800)); + expect(await tools.capture_evidence!.handler({ label: "Dashboard" })).toMatch(/Evidence E1 captured/); + expect(await tools.get_console!.handler({ level: "error", limit: 10 })).toMatch(/boom/); + expect(await tools.get_network!.handler({ onlyErrors: true, onlyApi: true, limit: 10, includeBodies: true })).toMatch(/api\/broken → 500/); + }, 60_000); + + it("reports a failed login without leaking credentials", async () => { + const msg = await tools.login_as!.handler({ role: "wrong" }); + expect(msg).toMatch(/failed/); + expect(msg).toMatch(/Falsche Zugangsdaten/); + expect(msg).not.toContain("nope"); + }, 60_000); + + it("writes console, network and trace files on close", async () => { + await session.close(); + expect(existsSync(join(runDir, "console.json"))).toBe(true); + expect(existsSync(join(runDir, "network.json"))).toBe(true); + expect(existsSync(join(runDir, "trace.zip"))).toBe(true); + }, 60_000); +}); diff --git a/packages/browser/test/run-store.test.ts b/packages/browser/test/run-store.test.ts new file mode 100644 index 0000000..7edebac --- /dev/null +++ b/packages/browser/test/run-store.test.ts @@ -0,0 +1,55 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { RunStore } from "../src/run-store.js"; +import { createClaudioTools } from "../src/tools/definitions.js"; + +function tmp(): string { + return mkdtempSync(join(tmpdir(), "claudio-test-")); +} + +describe("RunStore", () => { + it("persists findings, evidence, AC results and todos", () => { + const dir = tmp(); + const s = new RunStore(dir); + s.addFinding({ category: "ux", severity: "minor", title: "t", description: "d", steps: [], evidenceIds: [] }); + s.addEvidence({ id: s.nextEvidenceId(), kind: "note", label: "l" }); + s.recordAcResult({ criterion: "c", status: "passed", notes: "", evidenceIds: [] }); + s.recordAcResult({ criterion: "c", status: "failed", notes: "later", evidenceIds: [] }); + s.todoAdd(["a", "b"]); + expect(s.todoNext()?.text).toBe("a"); + s.todoDone(1, "ok"); + expect(s.todoNext()?.text).toBe("b"); + + const reopened = new RunStore(dir); + expect(reopened.findings()).toHaveLength(1); + expect(reopened.evidence()[0]!.id).toBe("E1"); + expect(reopened.acResults()).toEqual([expect.objectContaining({ criterion: "c", status: "failed" })]); + expect(reopened.addFinding({ category: "ui", severity: "minor", title: "t2", description: "d", steps: [], evidenceIds: [] }).id).toBe("F2"); + }); +}); + +describe("claudio tools without a browser attachment", () => { + it("handles login, evidence and todo flows", async () => { + const dir = tmp(); + const store = new RunStore(dir); + const tools = createClaudioTools({ + runDir: dir, + store, + attachment: null, + instanceUrl: "https://app.test", + roles: { admin: { usernameEnv: "U", passwordEnv: "P", loginPath: "/login" } }, + driver: "chrome", + }); + const byName = Object.fromEntries(tools.map((t) => [t.name, t])); + expect(await byName.login_as!.handler({ role: "nope" })).toMatch(/Unknown role/); + expect(await byName.login_as!.handler({ role: "admin" })).toMatch(/Live mode/); + expect(await byName.capture_evidence!.handler({ label: "x" })).toMatch(/E1 recorded as a note/); + expect(await byName.get_console!.handler({ level: "error", limit: 10 })).toMatch(/not available/); + expect(await byName.todo_add!.handler({ items: ["eins", "zwei"] })).toMatch(/Added 2/); + expect(await byName.todo_done!.handler({ id: 1, result: "r", status: "done" })).toMatch(/Next: #2/); + expect(await byName.report_finding!.handler({ category: "ux", severity: "major", title: "T", description: "D", steps: [], evidenceIds: ["E1"] })).toMatch(/F1 recorded/); + expect(store.findings()[0]!.evidenceIds).toEqual(["E1"]); + }); +}); diff --git a/packages/browser/tsconfig.json b/packages/browser/tsconfig.json new file mode 100644 index 0000000..404aab4 --- /dev/null +++ b/packages/browser/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "noEmit": true, "rootDir": "src" }, + "include": ["src"] +} diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..332e235 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,17 @@ +{ + "name": "@claudio/core", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "zod": "^4.6.5" + } +} diff --git a/packages/core/src/budget.ts b/packages/core/src/budget.ts new file mode 100644 index 0000000..ddaf044 --- /dev/null +++ b/packages/core/src/budget.ts @@ -0,0 +1,49 @@ +import type { BudgetTier } from "./schemas.js"; + +export type EffortLevel = "low" | "medium" | "high" | "xhigh" | "max"; + +export type BudgetProfile = { + orchestratorModel: string; + workerModel: string; + effort: EffortLevel; + maxTurns: number; + maxBudgetUsd: number; + maxBrowserSteps: number; +}; + +/** + * Budget tiers map one user-facing option onto every cost lever at once. + * Numbers are starting points for the Phase 0 spike and are meant to be tuned + * from measured runs. + */ +export const BUDGET_TIERS: Record = { + quick: { + orchestratorModel: "claude-sonnet-5-5", + workerModel: "claude-haiku-5-5", + effort: "medium", + maxTurns: 60, + maxBudgetUsd: 2, + maxBrowserSteps: 80, + }, + standard: { + orchestratorModel: "claude-opus-5-5", + workerModel: "claude-sonnet-5-5", + effort: "high", + maxTurns: 150, + maxBudgetUsd: 8, + maxBrowserSteps: 250, + }, + thorough: { + orchestratorModel: "claude-opus-5-5", + workerModel: "claude-sonnet-5-5", + effort: "xhigh", + maxTurns: 400, + maxBudgetUsd: 25, + maxBrowserSteps: 700, + }, +}; + +export function budgetProfile(tier: BudgetTier, modelOverride?: string): BudgetProfile { + const base = BUDGET_TIERS[tier]; + return modelOverride ? { ...base, orchestratorModel: modelOverride } : base; +} diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts new file mode 100644 index 0000000..17cac25 --- /dev/null +++ b/packages/core/src/config.ts @@ -0,0 +1,22 @@ +import { readFileSync, existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { RepoConfigSchema, type RepoConfig, type RoleConfig } from "./schemas.js"; + +export const DEFAULT_CONFIG_FILE = "claudio.config.json"; + +export function loadRepoConfig(path?: string): RepoConfig | null { + const file = resolve(path ?? DEFAULT_CONFIG_FILE); + if (!existsSync(file)) return null; + const raw = JSON.parse(readFileSync(file, "utf8")); + return RepoConfigSchema.parse(raw); +} + +export type Credentials = { username: string; password: string }; + +/** Resolves a role's credentials from the process environment. Returns null when either variable is missing. */ +export function resolveCredentials(role: RoleConfig, env: NodeJS.ProcessEnv = process.env): Credentials | null { + const username = env[role.usernameEnv]; + const password = env[role.passwordEnv]; + if (!username || !password) return null; + return { username, password }; +} diff --git a/packages/core/src/ids.ts b/packages/core/src/ids.ts new file mode 100644 index 0000000..f586c90 --- /dev/null +++ b/packages/core/src/ids.ts @@ -0,0 +1,10 @@ +export function newRunId(prefix = "run"): string { + const d = new Date(); + const pad = (n: number) => String(n).padStart(2, "0"); + const stamp = `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; + return `${prefix}-${stamp}-${Math.random().toString(36).slice(2, 6)}`; +} + +export function nowIso(): string { + return new Date().toISOString(); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..6127c88 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,5 @@ +export * from "./schemas.js"; +export * from "./budget.js"; +export * from "./report.js"; +export * from "./config.js"; +export * from "./ids.js"; diff --git a/packages/core/src/report.ts b/packages/core/src/report.ts new file mode 100644 index 0000000..b7d2ab8 --- /dev/null +++ b/packages/core/src/report.ts @@ -0,0 +1,223 @@ +import type { Finding, ReviewReport, Severity, Strictness, Verbosity } from "./schemas.js"; + +export const SEVERITY_RANK: Record = { + blocker: 4, + major: 3, + minor: 2, + suggestion: 1, +}; + +/** Minimum severity that blocks a merge under a strictness level. */ +export const STRICTNESS_THRESHOLD: Record = { + lenient: "blocker", + normal: "major", + strict: "minor", +}; + +export function isBlocking(finding: Finding, strictness: Strictness): boolean { + return SEVERITY_RANK[finding.severity] >= SEVERITY_RANK[STRICTNESS_THRESHOLD[strictness]]; +} + +export function blockingFindings(report: ReviewReport, strictness: Strictness = report.request.strictness): Finding[] { + return sortBySeverity(report.findings.filter((f) => isBlocking(f, strictness))); +} + +export function sortBySeverity(findings: Finding[]): Finding[] { + return [...findings].sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]); +} + +export function verdict(report: ReviewReport, strictness: Strictness = report.request.strictness): "approve" | "request_changes" | "inconclusive" { + if (report.status !== "completed") return "inconclusive"; + const failedAc = report.acceptanceCriteria.some((ac) => ac.status === "failed"); + if (failedAc || blockingFindings(report, strictness).length > 0) return "request_changes"; + return "approve"; +} + +const L = { + de: { + verdict: { approve: "Keine blockierenden Findings", request_changes: "Änderungen nötig", inconclusive: "Review unvollständig" }, + summary: "Zusammenfassung", + ac: "Acceptance Criteria", + findings: "Findings", + blocking: "Blockierend", + nonBlocking: "Nicht blockierend", + affected: "Betroffene Bereiche", + evidence: "Evidenz", + steps: "Schritte", + expected: "Erwartet", + actual: "Beobachtet", + cost: "Kosten", + status: "Status", + none: "keine", + acStatus: { passed: "erfüllt", failed: "nicht erfüllt", blocked: "blockiert", not_testable: "nicht testbar" }, + severity: { blocker: "Blocker", major: "Major", minor: "Minor", suggestion: "Vorschlag" }, + fullReport: "Vollständiger Report", + errors: "Fehler während des Runs", + }, + en: { + verdict: { approve: "No blocking findings", request_changes: "Changes requested", inconclusive: "Review incomplete" }, + summary: "Summary", + ac: "Acceptance criteria", + findings: "Findings", + blocking: "Blocking", + nonBlocking: "Non-blocking", + affected: "Affected areas", + evidence: "Evidence", + steps: "Steps", + expected: "Expected", + actual: "Actual", + cost: "Cost", + status: "Status", + none: "none", + acStatus: { passed: "passed", failed: "failed", blocked: "blocked", not_testable: "not testable" }, + severity: { blocker: "Blocker", major: "Major", minor: "Minor", suggestion: "Suggestion" }, + fullReport: "Full report", + errors: "Errors during the run", + }, +} as const; + +type Labels = (typeof L)[keyof typeof L]; + +function labels(report: ReviewReport): Labels { + return L[report.request.language]; +} + +export function renderMarkdown(report: ReviewReport, verbosity: Verbosity = report.request.verbosity): string { + const t = labels(report); + const strictness = report.request.strictness; + const v = verdict(report, strictness); + const blocking = blockingFindings(report, strictness); + const nonBlocking = sortBySeverity(report.findings.filter((f) => !isBlocking(f, strictness))); + const evidenceById = new Map(report.evidence.map((e) => [e.id, e])); + + const out: string[] = []; + out.push(`# Claudio Review: PR #${report.pr.number} ${report.pr.title}`); + out.push(""); + out.push(`**${t.verdict[v]}** (${t.status}: ${report.status}, strictness: ${strictness})`); + out.push(""); + out.push(`## ${t.summary}`); + out.push(""); + out.push(report.summary.trim() || "-"); + out.push(""); + + if (report.acceptanceCriteria.length > 0) { + out.push(`## ${t.ac}`); + out.push(""); + out.push("| # | Criterion | Status | Notes |"); + out.push("|---|---|---|---|"); + report.acceptanceCriteria.forEach((ac, i) => { + const notes = verbosity === "tldr" ? "" : ac.notes.replace(/\n/g, " "); + out.push(`| ${i + 1} | ${ac.criterion} | ${t.acStatus[ac.status]} | ${notes} |`); + }); + out.push(""); + } + + const renderFinding = (f: Finding) => { + out.push(`### [${t.severity[f.severity]}] ${f.title}`); + out.push(""); + out.push(`_${f.category}_${f.url ? ` · ${f.url}` : ""}${f.acceptanceCriterion ? ` · AC: ${f.acceptanceCriterion}` : ""}`); + out.push(""); + out.push(f.description.trim()); + out.push(""); + if (verbosity === "detailed") { + if (f.steps.length > 0) { + out.push(`**${t.steps}**`); + f.steps.forEach((s, i) => out.push(`${i + 1}. ${s}`)); + out.push(""); + } + if (f.expected) out.push(`**${t.expected}:** ${f.expected}`); + if (f.actual) out.push(`**${t.actual}:** ${f.actual}`); + if (f.expected || f.actual) out.push(""); + const ev = f.evidenceIds.map((id) => evidenceById.get(id)).filter(Boolean); + if (ev.length > 0) { + out.push(`**${t.evidence}:** ${ev.map((e) => (e!.path ? `[${e!.label}](${e!.path})` : e!.label)).join(", ")}`); + out.push(""); + } + } + }; + + out.push(`## ${t.findings}`); + out.push(""); + out.push(`### ${t.blocking} (${blocking.length})`); + out.push(""); + if (blocking.length === 0) out.push(`_${t.none}_`), out.push(""); + if (verbosity === "tldr") { + blocking.forEach((f) => out.push(`- [${t.severity[f.severity]}] ${f.title}`)); + out.push(""); + out.push(`${t.nonBlocking}: ${nonBlocking.length}`); + out.push(""); + } else { + blocking.forEach(renderFinding); + out.push(`### ${t.nonBlocking} (${nonBlocking.length})`); + out.push(""); + if (nonBlocking.length === 0) out.push(`_${t.none}_`), out.push(""); + nonBlocking.forEach(renderFinding); + } + + if (verbosity !== "tldr" && report.affectedAreas.length > 0) { + out.push(`## ${t.affected}`); + out.push(""); + report.affectedAreas.forEach((a) => out.push(`- ${a}`)); + out.push(""); + } + + if (verbosity === "detailed") { + out.push(`## ${t.cost}`); + out.push(""); + out.push(`| USD | Input tokens | Output tokens | Turns | Browser steps | Duration |`); + out.push(`|---|---|---|---|---|---|`); + out.push( + `| ${report.cost.usd.toFixed(2)} | ${report.cost.inputTokens} | ${report.cost.outputTokens} | ${report.cost.turns} | ${report.cost.browserSteps} | ${Math.round(report.cost.durationMs / 1000)}s |`, + ); + out.push(""); + if (report.notes.trim()) { + out.push("## Notes"); + out.push(""); + out.push(report.notes.trim()); + out.push(""); + } + } + + if (report.errors.length > 0) { + out.push(`## ${t.errors}`); + out.push(""); + report.errors.forEach((e) => out.push(`- ${e}`)); + out.push(""); + } + + return out.join("\n"); +} + +/** Short comment for the pull request: verdict, counts, blocking titles, link to the full report. */ +export function renderPrComment(report: ReviewReport, reportUrl?: string): string { + const t = labels(report); + const strictness = report.request.strictness; + const v = verdict(report, strictness); + const blocking = blockingFindings(report, strictness); + const acPassed = report.acceptanceCriteria.filter((a) => a.status === "passed").length; + const lines: string[] = []; + lines.push(`**Claudio Review: ${t.verdict[v]}**`); + lines.push(""); + lines.push(`${t.ac}: ${acPassed}/${report.acceptanceCriteria.length} ${t.acStatus.passed}. ${t.findings}: ${report.findings.length} (${t.blocking.toLowerCase()}: ${blocking.length}).`); + if (blocking.length > 0) { + lines.push(""); + blocking.slice(0, 5).forEach((f) => lines.push(`- [${t.severity[f.severity]}] ${f.title}`)); + if (blocking.length > 5) lines.push(`- … +${blocking.length - 5}`); + } + if (report.summary.trim()) { + lines.push(""); + lines.push(firstParagraph(report.summary)); + } + if (reportUrl) { + lines.push(""); + lines.push(`${t.fullReport}: ${reportUrl}`); + } + lines.push(""); + lines.push(`run ${report.runId} · ${report.cost.usd.toFixed(2)} USD · strictness ${strictness}`); + return lines.join("\n"); +} + +function firstParagraph(text: string): string { + const p = text.trim().split(/\n\s*\n/)[0] ?? ""; + return p.length > 600 ? p.slice(0, 597) + "..." : p; +} diff --git a/packages/core/src/schemas.ts b/packages/core/src/schemas.ts new file mode 100644 index 0000000..714ede2 --- /dev/null +++ b/packages/core/src/schemas.ts @@ -0,0 +1,172 @@ +import { z } from "zod"; + +export const SeveritySchema = z.enum(["blocker", "major", "minor", "suggestion"]); +export type Severity = z.infer; + +export const FindingCategorySchema = z.enum([ + "acceptance", + "regression", + "ux", + "ui", + "console", + "network", + "security", + "performance", + "other", +]); +export type FindingCategory = z.infer; + +export const BudgetTierSchema = z.enum(["quick", "standard", "thorough"]); +export type BudgetTier = z.infer; + +export const VerbositySchema = z.enum(["tldr", "normal", "detailed"]); +export type Verbosity = z.infer; + +export const StrictnessSchema = z.enum(["lenient", "normal", "strict"]); +export type Strictness = z.infer; + +export const DriverNameSchema = z.enum(["playwright", "chrome"]); +export type DriverName = z.infer; + +export const LanguageSchema = z.enum(["de", "en"]); +export type Language = z.infer; + +export const EvidenceKindSchema = z.enum(["screenshot", "trace", "console", "network", "note"]); + +export const EvidenceSchema = z.object({ + id: z.string(), + kind: EvidenceKindSchema, + label: z.string(), + path: z.string().optional(), + pageUrl: z.string().optional(), + note: z.string().optional(), + createdAt: z.string(), +}); +export type Evidence = z.infer; + +export const FindingSchema = z.object({ + id: z.string(), + category: FindingCategorySchema, + severity: SeveritySchema, + title: z.string(), + description: z.string(), + steps: z.array(z.string()).default([]), + expected: z.string().optional(), + actual: z.string().optional(), + url: z.string().optional(), + acceptanceCriterion: z.string().optional(), + evidenceIds: z.array(z.string()).default([]), + createdAt: z.string(), +}); +export type Finding = z.infer; + +export const AcStatusSchema = z.enum(["passed", "failed", "blocked", "not_testable"]); + +export const AcceptanceCriterionResultSchema = z.object({ + criterion: z.string(), + status: AcStatusSchema, + notes: z.string().default(""), + evidenceIds: z.array(z.string()).default([]), + createdAt: z.string(), +}); +export type AcceptanceCriterionResult = z.infer; + +export const TodoItemSchema = z.object({ + id: z.number(), + text: z.string(), + status: z.enum(["open", "done", "skipped"]), + result: z.string().optional(), +}); +export type TodoItem = z.infer; + +export const ReviewRequestSchema = z.object({ + repo: z.string().regex(/^[\w.-]+\/[\w.-]+$/, "repo must be owner/name"), + pr: z.number().int().positive(), + instanceUrl: z.string().url(), + roles: z.array(z.string()).default([]), + driver: DriverNameSchema.default("playwright"), + budget: BudgetTierSchema.default("standard"), + verbosity: VerbositySchema.default("normal"), + strictness: StrictnessSchema.default("normal"), + language: LanguageSchema.default("de"), + headed: z.boolean().default(false), + postComment: z.boolean().default(false), + cloneRepo: z.boolean().default(true), + modelOverride: z.string().optional(), + extraInstructions: z.string().optional(), +}); +export type ReviewRequest = z.infer; + +export const RunCostSchema = z.object({ + usd: z.number(), + inputTokens: z.number(), + outputTokens: z.number(), + turns: z.number(), + durationMs: z.number(), + browserSteps: z.number(), +}); +export type RunCost = z.infer; + +export const RunStatusSchema = z.enum(["completed", "budget_exceeded", "max_turns", "failed", "aborted"]); +export type RunStatus = z.infer; + +export const ReviewReportSchema = z.object({ + runId: z.string(), + status: RunStatusSchema, + request: ReviewRequestSchema, + pr: z.object({ + number: z.number(), + title: z.string(), + url: z.string(), + headRef: z.string(), + baseRef: z.string(), + author: z.string().optional(), + }), + issues: z.array(z.object({ number: z.number(), title: z.string(), url: z.string() })), + summary: z.string(), + affectedAreas: z.array(z.string()).default([]), + acceptanceCriteria: z.array(AcceptanceCriterionResultSchema).default([]), + findings: z.array(FindingSchema).default([]), + evidence: z.array(EvidenceSchema).default([]), + notes: z.string().default(""), + cost: RunCostSchema, + sessionId: z.string().optional(), + errors: z.array(z.string()).default([]), + createdAt: z.string(), +}); +export type ReviewReport = z.infer; + +/** One step of a login recipe. `$USER` and `$PASSWORD` are substituted from the role's env vars. */ +export const LoginStepSchema = z.discriminatedUnion("action", [ + z.object({ action: z.literal("goto"), url: z.string() }), + z.object({ action: z.literal("fill"), selector: z.string(), value: z.string() }), + z.object({ action: z.literal("click"), selector: z.string() }), + z.object({ action: z.literal("press"), key: z.string() }), + z.object({ action: z.literal("waitForUrl"), pattern: z.string() }), + z.object({ action: z.literal("waitForSelector"), selector: z.string() }), +]); +export type LoginStep = z.infer; + +export const RoleConfigSchema = z.object({ + description: z.string().optional(), + usernameEnv: z.string(), + passwordEnv: z.string(), + /** Explicit login recipe. When omitted, the adaptive auto-login heuristic is used. */ + recipe: z.array(LoginStepSchema).optional(), + /** Path of the login page relative to the instance URL, for the auto-login heuristic. */ + loginPath: z.string().default("/login"), + /** URL fragment or path that indicates a successful login (e.g. "/dashboard"). */ + successUrlContains: z.string().optional(), +}); +export type RoleConfig = z.infer; + +/** claudio.config.json: per-target-repo settings. */ +export const RepoConfigSchema = z.object({ + repo: z.string(), + roles: z.record(z.string(), RoleConfigSchema).default({}), + /** Free-form notes for the agent: domain vocabulary, known quirks, areas to be careful with. */ + notes: z.string().optional(), + /** Hosts the browser may visit in addition to the instance host (e.g. an auth provider). */ + extraAllowedHosts: z.array(z.string()).default([]), +}); +export type RepoConfig = z.infer; diff --git a/packages/core/test/config.test.ts b/packages/core/test/config.test.ts new file mode 100644 index 0000000..30c4479 --- /dev/null +++ b/packages/core/test/config.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { RepoConfigSchema, resolveCredentials, budgetProfile } from "../src/index.js"; + +describe("config", () => { + it("parses roles with defaults", () => { + const cfg = RepoConfigSchema.parse({ repo: "o/r", roles: { admin: { usernameEnv: "U", passwordEnv: "P" } } }); + expect(cfg.roles.admin!.loginPath).toBe("/login"); + expect(cfg.extraAllowedHosts).toEqual([]); + }); + it("resolves credentials only when both vars exist", () => { + const role = RepoConfigSchema.parse({ repo: "o/r", roles: { a: { usernameEnv: "U", passwordEnv: "P" } } }).roles.a!; + expect(resolveCredentials(role, { U: "x" })).toBeNull(); + expect(resolveCredentials(role, { U: "x", P: "y" })).toEqual({ username: "x", password: "y" }); + }); + it("applies a model override to the orchestrator only", () => { + const p = budgetProfile("standard", "claude-fable-5-1"); + expect(p.orchestratorModel).toBe("claude-fable-5-1"); + expect(p.workerModel).toBe("claude-sonnet-5-5"); + }); +}); diff --git a/packages/core/test/report.test.ts b/packages/core/test/report.test.ts new file mode 100644 index 0000000..9e33b42 --- /dev/null +++ b/packages/core/test/report.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { ReviewReportSchema, blockingFindings, renderMarkdown, renderPrComment, verdict, type ReviewReport } from "../src/index.js"; + +function report(overrides: Partial = {}): ReviewReport { + return ReviewReportSchema.parse({ + runId: "review-pr1-x", + status: "completed", + request: { repo: "o/r", pr: 1, instanceUrl: "https://pr-1.example.test", strictness: "normal", verbosity: "normal", language: "de" }, + pr: { number: 1, title: "Fahrt anlegen", url: "https://github.com/o/r/pull/1", headRef: "f", baseRef: "main" }, + issues: [], + summary: "Alles gut.\n\nAffected areas:\n- Fahrten", + acceptanceCriteria: [{ criterion: "Fahrt speichern", status: "passed", notes: "", evidenceIds: [], createdAt: "t" }], + findings: [ + { id: "F1", category: "ux", severity: "minor", title: "Button zu klein", description: "d", steps: [], evidenceIds: [], createdAt: "t" }, + { id: "F2", category: "acceptance", severity: "major", title: "Speichern schlägt fehl", description: "d", steps: ["a"], evidenceIds: ["E1"], createdAt: "t" }, + { id: "F3", category: "ui", severity: "suggestion", title: "Farbe", description: "d", steps: [], evidenceIds: [], createdAt: "t" }, + ], + evidence: [{ id: "E1", kind: "screenshot", label: "fail", path: "evidence/E1.png", createdAt: "t" }], + cost: { usd: 1.23, inputTokens: 10, outputTokens: 5, turns: 3, durationMs: 4000, browserSteps: 12 }, + createdAt: "t", + ...overrides, + }); +} + +describe("strictness", () => { + it("normal blocks major and above", () => { + expect(blockingFindings(report()).map((f) => f.id)).toEqual(["F2"]); + }); + it("strict also blocks minor", () => { + expect(blockingFindings(report(), "strict").map((f) => f.id)).toEqual(["F2", "F1"]); + }); + it("lenient blocks only blockers", () => { + expect(blockingFindings(report(), "lenient")).toEqual([]); + expect(verdict(report(), "lenient")).toBe("approve"); + expect(verdict(report(), "normal")).toBe("request_changes"); + }); + it("is inconclusive when the run did not complete", () => { + expect(verdict(report({ status: "budget_exceeded" }))).toBe("inconclusive"); + }); +}); + +describe("rendering", () => { + it("tldr lists only blocking titles", () => { + const md = renderMarkdown(report(), "tldr"); + expect(md).toContain("Speichern schlägt fehl"); + expect(md).not.toContain("Button zu klein"); + expect(md).not.toContain("Kosten"); + }); + it("detailed includes steps, evidence and cost", () => { + const md = renderMarkdown(report(), "detailed"); + expect(md).toContain("Button zu klein"); + expect(md).toContain("evidence/E1.png"); + expect(md).toContain("## Kosten"); + expect(md).toContain("Fahrten"); + }); + it("PR comment carries verdict and run id", () => { + const c = renderPrComment(report(), "https://claudio.example/runs/1"); + expect(c).toContain("Änderungen nötig"); + expect(c).toContain("review-pr1-x"); + expect(c).toContain("https://claudio.example/runs/1"); + }); +}); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..404aab4 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "noEmit": true, "rootDir": "src" }, + "include": ["src"] +} diff --git a/packages/github/package.json b/packages/github/package.json new file mode 100644 index 0000000..ca59301 --- /dev/null +++ b/packages/github/package.json @@ -0,0 +1,19 @@ +{ + "name": "@claudio/github", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@claudio/core": "workspace:*", + "octokit": "^5.0.5", + "zod": "^4.6.5" + } +} diff --git a/packages/github/src/client.ts b/packages/github/src/client.ts new file mode 100644 index 0000000..c942a39 --- /dev/null +++ b/packages/github/src/client.ts @@ -0,0 +1,105 @@ +import { Octokit } from "octokit"; +import { extractLinkedIssueNumbers, parseRepo } from "./links.js"; + +export type ChangedFile = { + filename: string; + status: string; + additions: number; + deletions: number; + patch?: string; + previousFilename?: string; +}; + +export type LinkedIssue = { + number: number; + title: string; + body: string; + url: string; + labels: string[]; + state: string; +}; + +export type PullRequestContext = { + owner: string; + repo: string; + number: number; + title: string; + body: string; + url: string; + author?: string; + headRef: string; + headSha: string; + baseRef: string; + draft: boolean; + files: ChangedFile[]; + issues: LinkedIssue[]; +}; + +export function createGithubClient(token: string | undefined = process.env.GITHUB_TOKEN): Octokit { + if (!token) throw new Error("GITHUB_TOKEN is not set"); + return new Octokit({ auth: token }); +} + +export async function fetchPullRequestContext( + octokit: Octokit, + repoFullName: string, + number: number, + opts: { maxPatchChars?: number } = {}, +): Promise { + const { owner, repo } = parseRepo(repoFullName); + const maxPatchChars = opts.maxPatchChars ?? 12_000; + const { data: pr } = await octokit.rest.pulls.get({ owner, repo, pull_number: number }); + const files = await octokit.paginate(octokit.rest.pulls.listFiles, { owner, repo, pull_number: number, per_page: 100 }); + + const issueNumbers = extractLinkedIssueNumbers(pr.body, pr.head.ref).filter((n) => n !== number); + const issues: LinkedIssue[] = []; + for (const n of issueNumbers) { + try { + const { data } = await octokit.rest.issues.get({ owner, repo, issue_number: n }); + if (data.pull_request) continue; // a PR reference, not an issue + issues.push({ + number: data.number, + title: data.title, + body: data.body ?? "", + url: data.html_url, + labels: data.labels.map((l) => (typeof l === "string" ? l : (l.name ?? ""))).filter(Boolean), + state: data.state, + }); + } catch { + // Unresolvable reference (deleted issue, no access): skip silently, the reviewer will see fewer issues. + } + } + + return { + owner, + repo, + number, + title: pr.title, + body: pr.body ?? "", + url: pr.html_url, + author: pr.user?.login, + headRef: pr.head.ref, + headSha: pr.head.sha, + baseRef: pr.base.ref, + draft: Boolean(pr.draft), + files: files.map((f) => ({ + filename: f.filename, + status: f.status, + additions: f.additions, + deletions: f.deletions, + patch: f.patch ? truncate(f.patch, maxPatchChars) : undefined, + previousFilename: f.previous_filename, + })), + issues, + }; +} + +export async function postPullRequestComment(octokit: Octokit, repoFullName: string, number: number, body: string): Promise { + const { owner, repo } = parseRepo(repoFullName); + const { data } = await octokit.rest.issues.createComment({ owner, repo, issue_number: number, body }); + return data.html_url; +} + +function truncate(text: string, max: number): string { + return text.length <= max ? text : text.slice(0, max) + `\n... [truncated ${text.length - max} chars]`; +} diff --git a/packages/github/src/index.ts b/packages/github/src/index.ts new file mode 100644 index 0000000..96b9ff8 --- /dev/null +++ b/packages/github/src/index.ts @@ -0,0 +1,2 @@ +export * from "./client.js"; +export * from "./links.js"; diff --git a/packages/github/src/links.ts b/packages/github/src/links.ts new file mode 100644 index 0000000..d6b2fde --- /dev/null +++ b/packages/github/src/links.ts @@ -0,0 +1,24 @@ +/** Extracts GitHub issue numbers linked from a PR body and its head branch name. */ +export function extractLinkedIssueNumbers(body: string | null | undefined, headRef?: string): number[] { + const found = new Set(); + const text = body ?? ""; + // closes #12, fixes #12, resolves #12 (case-insensitive), and owner/repo#12 forms + const keyword = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s*(?:[\w.-]+\/[\w.-]+)?#(\d+)/gi; + for (const m of text.matchAll(keyword)) found.add(Number(m[1])); + // plain #12 references in the body + for (const m of text.matchAll(/(?:^|[\s(])#(\d+)\b/g)) found.add(Number(m[1])); + // issue links + for (const m of text.matchAll(/github\.com\/[\w.-]+\/[\w.-]+\/issues\/(\d+)/g)) found.add(Number(m[1])); + // branch names like 123-feature, feature/123-foo, issue-123 + if (headRef) { + const b = headRef.match(/(?:^|[\/_-])(?:issue-?)?(\d{1,6})(?=[\/_-]|$)/); + if (b) found.add(Number(b[1])); + } + return [...found].filter((n) => n > 0); +} + +export function parseRepo(full: string): { owner: string; repo: string } { + const m = full.match(/^([\w.-]+)\/([\w.-]+)$/); + if (!m) throw new Error(`Invalid repository "${full}", expected owner/name`); + return { owner: m[1]!, repo: m[2]! }; +} diff --git a/packages/github/test/links.test.ts b/packages/github/test/links.test.ts new file mode 100644 index 0000000..b78b3a2 --- /dev/null +++ b/packages/github/test/links.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { extractLinkedIssueNumbers, parseRepo } from "../src/links.js"; + +describe("extractLinkedIssueNumbers", () => { + it("finds closing keywords, plain refs, links and branch numbers", () => { + const body = "Closes #12\nSee also #7 and https://github.com/o/r/issues/99\nFixes: o/r#5"; + expect(extractLinkedIssueNumbers(body, "feature/42-neue-fahrt").sort((a, b) => a - b)).toEqual([5, 7, 12, 42, 99]); + }); + it("ignores branch names without issue numbers", () => { + expect(extractLinkedIssueNumbers("", "main")).toEqual([]); + expect(extractLinkedIssueNumbers(null, "feature/foo")).toEqual([]); + }); + it("parses owner/name", () => { + expect(parseRepo("vanderlars/claudio")).toEqual({ owner: "vanderlars", repo: "claudio" }); + expect(() => parseRepo("nope")).toThrow(); + }); +}); diff --git a/packages/github/tsconfig.json b/packages/github/tsconfig.json new file mode 100644 index 0000000..404aab4 --- /dev/null +++ b/packages/github/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "noEmit": true, "rootDir": "src" }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..888599d --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2308 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@types/node': + specifier: ^22.19.0 + version: 22.20.5 + tsx: + specifier: ^4.23.15 + version: 4.23.15 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^5.0.3 + version: 5.0.3(@types/node@22.20.5)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15)) + + apps/cli: + dependencies: + '@claudio/agent': + specifier: workspace:* + version: link:../../packages/agent + '@claudio/browser': + specifier: workspace:* + version: link:../../packages/browser + '@claudio/core': + specifier: workspace:* + version: link:../../packages/core + commander: + specifier: ^15.0.0 + version: 15.0.0 + + packages/agent: + dependencies: + '@anthropic-ai/claude-agent-sdk': + specifier: ^0.3.296 + version: 0.3.296(@anthropic-ai/sdk@0.133.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.32.1(zod@4.6.5))(zod@4.6.5) + '@claudio/browser': + specifier: workspace:* + version: link:../browser + '@claudio/core': + specifier: workspace:* + version: link:../core + '@claudio/github': + specifier: workspace:* + version: link:../github + zod: + specifier: ^4.6.5 + version: 4.6.5 + + packages/browser: + dependencies: + '@claudio/core': + specifier: workspace:* + version: link:../core + '@modelcontextprotocol/sdk': + specifier: ^1.32.1 + version: 1.32.1(zod@4.6.5) + '@playwright/mcp': + specifier: ^0.0.83 + version: 0.0.83 + playwright: + specifier: ^1.64.0 + version: 1.64.0 + zod: + specifier: ^4.6.5 + version: 4.6.5 + + packages/core: + dependencies: + zod: + specifier: ^4.6.5 + version: 4.6.5 + + packages/github: + dependencies: + '@claudio/core': + specifier: workspace:* + version: link:../core + octokit: + specifier: ^5.0.5 + version: 5.0.5 + zod: + specifier: ^4.6.5 + version: 4.6.5 + +packages: + + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.296': + resolution: {integrity: sha512-Wrhy7rxQVUNowQRDpSZD+3MFSBAlN4TINZSpKD8PKpEValCG6x05N855MuITvwgkH0EeoyNLRCsowJaIHa8V9Q==} + cpu: [arm64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.296': + resolution: {integrity: sha512-klMOQ1TdA7u6sWZJYw9l5byWYxMiWzbuPhzCgVVY+LJOs4FlhsS3qLXn2RZmIFhQv1u65zwX+2CRSWEJddqfVQ==} + cpu: [x64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.296': + resolution: {integrity: sha512-8DEazoBU4I8HsPrXgSJ1Vq30nCBF7PaKjHz1o/igCz+HZu3qepHztWpoKJKQee5Mt4XsvZLwtKuxQo1F+SC6yQ==} + cpu: [arm64] + os: [linux] + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.296': + resolution: {integrity: sha512-+tC1KlUlp/3BQS+EfG8RBrmru4xKwF6hNEm15gNct8k9tNVRLYOftbkNCGJPTKkoBoq0sQ+Omojzwr/W2Dg60A==} + cpu: [arm64] + os: [linux] + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.296': + resolution: {integrity: sha512-lMquemenM5CDNZv6g1+xBSfQpat2xiwOQUnY2mwSQKjT+IfE5/F8CgRd38ZBljwGnedPIuKs/DAZMt4YslLF5w==} + cpu: [x64] + os: [linux] + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.296': + resolution: {integrity: sha512-TStCnbg2iBtWJBQeMdO7Xwpx5IYWICmdcf7J9eXhIi4JbahWD+VLRaH5sxB+LLZjdBXekp8piKv6Ne2+arag0w==} + cpu: [x64] + os: [linux] + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.296': + resolution: {integrity: sha512-/epluPyoLm1NQAcHV0LUierKog7c9yyU8BgpZOtGWiM54nqTZKKBuCwANraV3ROfXYFfJdMV4Vehk/8fqxoOqQ==} + cpu: [arm64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.296': + resolution: {integrity: sha512-T71Y/hp0v7VtRzTOoxAsIkPa68JpB1d/VvCKTTE9GzTkbjQZHjs3S1DM06LDxSpwjITF7OCVJq14+XjN8M9t/g==} + cpu: [x64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk@0.3.296': + resolution: {integrity: sha512-Y+cZ2BT8X94mQ9GJOVyIqhjC/nWGvwozUVLjdkzX7aSt+zuo5tgk7Hdv9V7tEUQ9fKnEuiL6moYNWDeHqCD4Lw==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@anthropic-ai/sdk': '>=0.93.0' + '@modelcontextprotocol/sdk': ^1.29.0 + zod: ^4.0.0 + + '@anthropic-ai/sdk@0.133.0': + resolution: {integrity: sha512-jlqUN8p8ESTJj+GJnA4KjzbEfF1waEoGzRT8jwytlYGFFgv+/DUQOxlYGvYw10UzhD1kwLqYzxvA3FrTB2YWuQ==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + '@babel/runtime@7.29.10': + resolution: {integrity: sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@hono/node-server@2.1.4': + resolution: {integrity: sha512-2g1qeHl4BduwDn6L8nkAvDrPUWPMlkQ//a5NhP/zIyaXKMjZNRV+cG9Csu+BiJFf6+68pqzjf4Hi5sJGKHuS1g==} + engines: {node: '>=20'} + peerDependencies: + hono: ^4 || ^5.0.0-0 + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@modelcontextprotocol/sdk@1.32.1': + resolution: {integrity: sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@octokit/app@16.1.4': + resolution: {integrity: sha512-g70WONQyGoBgqIJtv4O0MUEfOF1iJpTfFt3qAW7HaiwiJ6k607xg/gBezCa4tuq6BpO6qXn7qvq8v9tjWtgGQg==} + engines: {node: '>= 20'} + + '@octokit/auth-app@8.3.1': + resolution: {integrity: sha512-XHWxZsF4mvGTQCv7AOxgby9t9OjVO19FBuKq+QmyBxFrf+B2hR/P5j/MTF8g95bITf8cRtR17KIXThpFNqiKbg==} + engines: {node: '>= 20'} + + '@octokit/auth-oauth-app@9.0.5': + resolution: {integrity: sha512-Hn2yAlIDjFLGjAz7t+CRu7e9VYoFW1W3LLyOLyahFoJGlCf4PtDkHKtRET8w/2CJpsrC0FxIF5g5Xtx9E4O78w==} + engines: {node: '>= 20'} + + '@octokit/auth-oauth-device@8.0.5': + resolution: {integrity: sha512-kdJtjRhykMLJrMdcoC56XUBDfoUAuWEFKpkDxwYim+bNwYw1MreLolk+PLnwEYT8djm589YepeMuJ8+ECAIzVQ==} + engines: {node: '>= 20'} + + '@octokit/auth-oauth-user@6.0.4': + resolution: {integrity: sha512-eya2pMJ9pCBq1jrm1Rf6J3NI+NemEpzDMLo9J/GpRoK0KgdxIVXWX4LESIfwJ+GYeoq6QFIlUJNi1lVp5FBgmw==} + engines: {node: '>= 20'} + + '@octokit/auth-token@6.0.0': + resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} + engines: {node: '>= 20'} + + '@octokit/auth-unauthenticated@7.0.5': + resolution: {integrity: sha512-KBzGMM8hRgtGKDe8JqvDdhHj+7PWHRo1qxz9pQ5WT1162DLdC6O5jL+kRFk3VfpCnEfc3bUYZ2vOwI9vx8yUXA==} + engines: {node: '>= 20'} + + '@octokit/core@7.0.8': + resolution: {integrity: sha512-L7y8eYc+AwxGr2PWI4WFt1VG4TiJ66c26BD16mXpYIlXxG0SMigM1+m4aTSlYyBr5BlQsGAlz8uDCoZN4SEMcg==} + engines: {node: '>= 20'} + + '@octokit/endpoint@11.0.5': + resolution: {integrity: sha512-iXa654H3yFafF/ieHkukfbgWo2rmXD2ceD0ZOtrPhw1bc3FDch1d9N/TNs0FQ1/cIbwb7kspUX8jzIs8nzb9DQ==} + engines: {node: '>= 20'} + + '@octokit/graphql@9.0.5': + resolution: {integrity: sha512-bt/hm03LeU6Vy7FwTrkkC9p3XGT/lBwClglMqxBSe5/q0E5CdJTXeAqEI0vlw89/LF/G6tryTIH8HirZ3prMVg==} + engines: {node: '>= 20'} + + '@octokit/oauth-app@8.0.4': + resolution: {integrity: sha512-Ji5JpRwAJcbOJkO4ij0tW6+GrQ8efpICnAca3o5xI8/HUNjqJu3hhG4cCZdXAHMKaOaQTFX0Yl+cpu61Rsx94A==} + engines: {node: '>= 20'} + + '@octokit/oauth-authorization-url@8.0.0': + resolution: {integrity: sha512-7QoLPRh/ssEA/HuHBHdVdSgF8xNLz/Bc5m9fZkArJE5bb6NmVkDm3anKxXPmN1zh6b5WKZPRr3697xKT/yM3qQ==} + engines: {node: '>= 20'} + + '@octokit/oauth-methods@6.0.5': + resolution: {integrity: sha512-/mAz7taDZD7DcV4zcG6TDw3Kr6TOldmYBRpvA62C3WIxRMmcgX8XxsTvVQomqKE7VEk/BgYhUao1aR2tYGyIKg==} + engines: {node: '>= 20'} + + '@octokit/openapi-types@27.0.0': + resolution: {integrity: sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==} + + '@octokit/openapi-types@28.0.0': + resolution: {integrity: sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ==} + + '@octokit/openapi-types@29.0.1': + resolution: {integrity: sha512-9qWOMFNxxLokERcms42rU0PTLqQmVs7g5E41TI4mCOxmpFayD1rfC7XxOL55cG9MBZLFlC31BrR37myMKardwg==} + + '@octokit/openapi-webhooks-types@12.1.0': + resolution: {integrity: sha512-WiuzhOsiOvb7W3Pvmhf8d2C6qaLHXrWiLBP4nJ/4kydu+wpagV5Fkz9RfQwV2afYzv3PB+3xYgp4mAdNGjDprA==} + + '@octokit/plugin-paginate-graphql@6.0.0': + resolution: {integrity: sha512-crfpnIoFiBtRkvPqOyLOsw12XsveYuY2ieP6uYDosoUegBJpSVxGwut9sxUgFFcll3VTOTqpUf8yGd8x1OmAkQ==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/plugin-paginate-rest@14.0.0': + resolution: {integrity: sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/plugin-paginate-rest@15.0.0': + resolution: {integrity: sha512-lw9A9YL5s4VPj+VEx8uMxaxQm2YTgrPDkrLEuZuu18R8TK3oPcXF4K6t52nx6xn1RcogZC9i188sAr/4XYJaQQ==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/plugin-rest-endpoint-methods@17.0.0': + resolution: {integrity: sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/plugin-retry@8.1.1': + resolution: {integrity: sha512-VCVvZ/R1+u3WuiBWpNavZ0mY4aaJNAsENrpBP9aLSR2QyOpQgd7DhM5j4AW7z4MQpnJYgwBPf0XqPQoNBRdQwg==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=7' + + '@octokit/plugin-throttling@11.0.5': + resolution: {integrity: sha512-LIdrkrUv+DWbKeg/49rGuFJ3SU0d3hUS+B4MhNZLepBoNUFXms8Ic9edJjrlx+zycqJHjrMRudVpVb/bAXM2Lw==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': ^7.0.0 + + '@octokit/request-error@7.1.2': + resolution: {integrity: sha512-XZRuT3xZ84D3gYErI1DZvhJ33dCWVV6uzBtWkaBB4TvA/L6eOeTZodxLFVB44bBEEo3vEx7y00UfX1tBLrtLRg==} + engines: {node: '>= 20'} + + '@octokit/request@10.0.16': + resolution: {integrity: sha512-A0zWGjHzISIb+9ccG8s0dq7LKO5zVpJLRICjgUb+sJxEWqn8RUHB1rD3AE51+PECvXHIxqZ1VVvs4fHTSD9nUQ==} + engines: {node: '>= 20'} + + '@octokit/types@16.0.0': + resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} + + '@octokit/types@17.0.0': + resolution: {integrity: sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q==} + + '@octokit/types@18.0.0': + resolution: {integrity: sha512-l6bAF43PNxkJp6g+W4PjoUSSkxHomXw2nOum5CTftJz1NlV3vu93NImgOYtLf6CbBUb5j+fiuzW0PPQ5JTSvZA==} + + '@octokit/webhooks-methods@6.0.0': + resolution: {integrity: sha512-MFlzzoDJVw/GcbfzVC1RLR36QqkTLUf79vLVO3D+xn7r0QgxnFoLZgtrzxiQErAjFUOdH6fas2KeQJ1yr/qaXQ==} + engines: {node: '>= 20'} + + '@octokit/webhooks@14.2.0': + resolution: {integrity: sha512-da6KbdNCV5sr1/txD896V+6W0iamFWrvVl8cHkBSPT+YlvmT3DwXa4jxZnQc+gnuTEqSWbBeoSZYTayXH9wXcw==} + engines: {node: '>= 20'} + + '@oxc-project/types@0.153.0': + resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==} + + '@playwright/mcp@0.0.83': + resolution: {integrity: sha512-oNcl+Ae2/IAjhfPeP46BfIkSakfmprY+aOtkv5MjrQ4lPav4/yNtPhL0iq8SlIM90oApWgBDUxaNKvktazUKOg==} + engines: {node: '>=18'} + hasBin: true + + '@rolldown/binding-android-arm-eabi@1.2.13': + resolution: {integrity: sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.13': + resolution: {integrity: sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.13': + resolution: {integrity: sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.13': + resolution: {integrity: sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.13': + resolution: {integrity: sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.13': + resolution: {integrity: sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.13': + resolution: {integrity: sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.2.13': + resolution: {integrity: sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.2.13': + resolution: {integrity: sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.2.13': + resolution: {integrity: sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.2.13': + resolution: {integrity: sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.2.13': + resolution: {integrity: sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.2.13': + resolution: {integrity: sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.13': + resolution: {integrity: sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.13': + resolution: {integrity: sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + + '@types/aws-lambda@8.10.164': + resolution: {integrity: sha512-XOnrazWcOd6yWPnR7DxqCBPPYciDjHq+NN8LPwwqBCJNXLyPJgKo47fZpJ113+oyAn9Zti0Cf6H4k6yPcE3WFg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@22.20.5': + resolution: {integrity: sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==} + + '@vitest/mocker@5.0.3': + resolution: {integrity: sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.3': + resolution: {integrity: sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + before-after-hook@4.0.0: + resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + bottleneck@2.19.5: + resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} + engines: {node: '>=18'} + + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} + + content-disposition@2.0.1: + resolution: {integrity: sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + + content-type@3.1.1: + resolution: {integrity: sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA==} + engines: {node: '>=22'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + express-rate-limit@8.7.1: + resolution: {integrity: sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.3.0: + resolution: {integrity: sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==} + engines: {node: '>= 18'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hono@4.13.13: + resolution: {integrity: sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==} + engines: {node: '>=16.9.0'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ip-address@10.7.3: + resolution: {integrity: sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} + + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + json-with-bigint@3.5.12: + resolution: {integrity: sha512-uwbF/wSSuOgC7qqlq27Xp5B6a2MHVug3t0idZdTqu0JnlFvgJuH7ju+KAk/J06C7GfhoYy2gnb9wz2INqcne7w==} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + magic-string@1.4.3: + resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + + octokit@5.0.5: + resolution: {integrity: sha512-4+/OFSqOjoyULo7eN7EA97DE0Xydj/PW5aIckxqQIoFjFwqXKuFCvXUJObyJfBF9Khu4RL/jlDRI9FPaMGfPnw==} + engines: {node: '>= 20'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + playwright-core@1.64.0: + resolution: {integrity: sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==} + engines: {node: '>=20'} + hasBin: true + + playwright-core@1.64.0-alpha-1790635538000: + resolution: {integrity: sha512-pNwaXirhXMRLaRQs4NQ18EpTdtDoFwyPH9FOaaDC7YXG4hpwE2xmwCic/1srbuExVQC7zT9LJSJpDBAru+Vo9A==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.64.0: + resolution: {integrity: sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.64.0-alpha-1790635538000: + resolution: {integrity: sha512-/5XDUMxpOd/9AojJtWDFIRV3EJZNQ0AwvK69wgt3CPzy8wTgiBG9pX45VfxtqtvIRGwfWzMhrvawNKreG69Snw==} + engines: {node: '>=20'} + hasBin: true + + postcss@8.5.29: + resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==} + engines: {node: ^10 || ^12 || >=14} + + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} + engines: {node: '>= 0.10'} + + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} + engines: {node: '>=0.6'} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + rolldown@1.2.13: + resolution: {integrity: sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + standardwebhooks@1.1.1: + resolution: {integrity: sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + + tinybench@6.2.1: + resolution: {integrity: sha512-IAbQaPJIIdhVxi0mqy9lez1wEwje0UQf9F1vwtbelEG5Nbgn0nPOxCvU+mNeNS9gyJTAhrHp74CbUaSynQpZcQ==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + toad-cache@3.7.4: + resolution: {integrity: sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==} + engines: {node: '>=20'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + + tsx@4.23.15: + resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + universal-github-app-jwt@2.2.2: + resolution: {integrity: sha512-dcmbeSrOdTnsjGjUfAlqNDJrhxXizjAz94ija9Qw8YkZ1uu0d+GoZzyH+Jb9tIIqvGsadUfwg+22k5aDqqwzbw==} + + universal-user-agent@7.0.3: + resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite@8.3.4: + resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@5.0.3: + resolution: {integrity: sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.3 + '@vitest/browser-preview': 5.0.3 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.3 + '@vitest/coverage-v8': 5.0.3 + '@vitest/ui': 5.0.3 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@3.2.1: + resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==} + engines: {node: '>=20.11'} + hasBin: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + +snapshots: + + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.296': + optional: true + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.296': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.296': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.296': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.296': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.296': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.296': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.296': + optional: true + + '@anthropic-ai/claude-agent-sdk@0.3.296(@anthropic-ai/sdk@0.133.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.32.1(zod@4.6.5))(zod@4.6.5)': + dependencies: + '@anthropic-ai/sdk': 0.133.0(zod@4.6.5) + '@modelcontextprotocol/sdk': 1.32.1(zod@4.6.5) + zod: 4.6.5 + optionalDependencies: + '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.296 + '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.296 + '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.296 + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.296 + '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.296 + '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.296 + '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.296 + '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.296 + + '@anthropic-ai/sdk@0.133.0(zod@4.6.5)': + dependencies: + json-schema-to-ts: 3.1.1 + standardwebhooks: 1.1.1 + optionalDependencies: + zod: 4.6.5 + + '@babel/runtime@7.29.10': {} + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@hono/node-server@2.1.4(hono@4.13.13)': + dependencies: + hono: 4.13.13 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@modelcontextprotocol/sdk@1.32.1(zod@4.6.5)': + dependencies: + '@hono/node-server': 2.1.4(hono@4.13.13) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + express: 5.3.0 + express-rate-limit: 8.7.1(express@5.3.0) + hono: 4.13.13 + jose: 6.2.12 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.6.5 + zod-to-json-schema: 3.25.2(zod@4.6.5) + transitivePeerDependencies: + - supports-color + + '@octokit/app@16.1.4': + dependencies: + '@octokit/auth-app': 8.3.1 + '@octokit/auth-unauthenticated': 7.0.5 + '@octokit/core': 7.0.8 + '@octokit/oauth-app': 8.0.4 + '@octokit/plugin-paginate-rest': 15.0.0(@octokit/core@7.0.8) + '@octokit/types': 17.0.0 + '@octokit/webhooks': 14.2.0 + + '@octokit/auth-app@8.3.1': + dependencies: + '@octokit/auth-oauth-app': 9.0.5 + '@octokit/auth-oauth-user': 6.0.4 + '@octokit/request': 10.0.16 + '@octokit/request-error': 7.1.2 + '@octokit/types': 18.0.0 + toad-cache: 3.7.4 + universal-github-app-jwt: 2.2.2 + universal-user-agent: 7.0.3 + + '@octokit/auth-oauth-app@9.0.5': + dependencies: + '@octokit/auth-oauth-device': 8.0.5 + '@octokit/auth-oauth-user': 6.0.4 + '@octokit/request': 10.0.16 + '@octokit/types': 18.0.0 + universal-user-agent: 7.0.3 + + '@octokit/auth-oauth-device@8.0.5': + dependencies: + '@octokit/oauth-methods': 6.0.5 + '@octokit/request': 10.0.16 + '@octokit/types': 18.0.0 + universal-user-agent: 7.0.3 + + '@octokit/auth-oauth-user@6.0.4': + dependencies: + '@octokit/auth-oauth-device': 8.0.5 + '@octokit/oauth-methods': 6.0.5 + '@octokit/request': 10.0.16 + '@octokit/types': 18.0.0 + universal-user-agent: 7.0.3 + + '@octokit/auth-token@6.0.0': {} + + '@octokit/auth-unauthenticated@7.0.5': + dependencies: + '@octokit/request-error': 7.1.2 + '@octokit/types': 18.0.0 + + '@octokit/core@7.0.8': + dependencies: + '@octokit/auth-token': 6.0.0 + '@octokit/graphql': 9.0.5 + '@octokit/request': 10.0.16 + '@octokit/request-error': 7.1.2 + '@octokit/types': 18.0.0 + before-after-hook: 4.0.0 + universal-user-agent: 7.0.3 + + '@octokit/endpoint@11.0.5': + dependencies: + '@octokit/types': 18.0.0 + universal-user-agent: 7.0.3 + + '@octokit/graphql@9.0.5': + dependencies: + '@octokit/request': 10.0.16 + '@octokit/types': 18.0.0 + universal-user-agent: 7.0.3 + + '@octokit/oauth-app@8.0.4': + dependencies: + '@octokit/auth-oauth-app': 9.0.5 + '@octokit/auth-oauth-user': 6.0.4 + '@octokit/auth-unauthenticated': 7.0.5 + '@octokit/core': 7.0.8 + '@octokit/oauth-authorization-url': 8.0.0 + '@octokit/oauth-methods': 6.0.5 + '@types/aws-lambda': 8.10.164 + universal-user-agent: 7.0.3 + + '@octokit/oauth-authorization-url@8.0.0': {} + + '@octokit/oauth-methods@6.0.5': + dependencies: + '@octokit/oauth-authorization-url': 8.0.0 + '@octokit/request': 10.0.16 + '@octokit/request-error': 7.1.2 + '@octokit/types': 18.0.0 + + '@octokit/openapi-types@27.0.0': {} + + '@octokit/openapi-types@28.0.0': {} + + '@octokit/openapi-types@29.0.1': {} + + '@octokit/openapi-webhooks-types@12.1.0': {} + + '@octokit/plugin-paginate-graphql@6.0.0(@octokit/core@7.0.8)': + dependencies: + '@octokit/core': 7.0.8 + + '@octokit/plugin-paginate-rest@14.0.0(@octokit/core@7.0.8)': + dependencies: + '@octokit/core': 7.0.8 + '@octokit/types': 16.0.0 + + '@octokit/plugin-paginate-rest@15.0.0(@octokit/core@7.0.8)': + dependencies: + '@octokit/core': 7.0.8 + '@octokit/types': 17.0.0 + + '@octokit/plugin-rest-endpoint-methods@17.0.0(@octokit/core@7.0.8)': + dependencies: + '@octokit/core': 7.0.8 + '@octokit/types': 16.0.0 + + '@octokit/plugin-retry@8.1.1(@octokit/core@7.0.8)': + dependencies: + '@octokit/core': 7.0.8 + '@octokit/request-error': 7.1.2 + '@octokit/types': 17.0.0 + bottleneck: 2.19.5 + + '@octokit/plugin-throttling@11.0.5(@octokit/core@7.0.8)': + dependencies: + '@octokit/core': 7.0.8 + '@octokit/types': 17.0.0 + bottleneck: 2.19.5 + + '@octokit/request-error@7.1.2': + dependencies: + '@octokit/types': 18.0.0 + + '@octokit/request@10.0.16': + dependencies: + '@octokit/endpoint': 11.0.5 + '@octokit/request-error': 7.1.2 + '@octokit/types': 18.0.0 + content-type: 3.1.1 + json-with-bigint: 3.5.12 + universal-user-agent: 7.0.3 + + '@octokit/types@16.0.0': + dependencies: + '@octokit/openapi-types': 27.0.0 + + '@octokit/types@17.0.0': + dependencies: + '@octokit/openapi-types': 28.0.0 + + '@octokit/types@18.0.0': + dependencies: + '@octokit/openapi-types': 29.0.1 + + '@octokit/webhooks-methods@6.0.0': {} + + '@octokit/webhooks@14.2.0': + dependencies: + '@octokit/openapi-webhooks-types': 12.1.0 + '@octokit/request-error': 7.1.2 + '@octokit/webhooks-methods': 6.0.0 + + '@oxc-project/types@0.153.0': {} + + '@playwright/mcp@0.0.83': + dependencies: + playwright: 1.64.0-alpha-1790635538000 + playwright-core: 1.64.0-alpha-1790635538000 + + '@rolldown/binding-android-arm-eabi@1.2.13': + optional: true + + '@rolldown/binding-android-arm64@1.2.13': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.13': + optional: true + + '@rolldown/binding-darwin-x64@1.2.13': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.13': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.13': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.13': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.13': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.13': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.13': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.13': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@stablelib/base64@1.0.1': {} + + '@types/aws-lambda@8.10.164': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@22.20.5': + dependencies: + undici-types: 6.21.0 + + '@vitest/mocker@5.0.3(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.3 + estree-walker: 3.0.3 + magic-string: 1.4.3 + optionalDependencies: + vite: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15) + + '@vitest/spy@5.0.3': {} + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.1.0 + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.8 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + assertion-error@2.0.1: {} + + before-after-hook@4.0.0: {} + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.16.0 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + bottleneck@2.19.5: {} + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + chai@6.3.0: {} + + commander@15.0.0: {} + + content-disposition@2.0.1: {} + + content-type@1.0.5: {} + + content-type@2.1.0: {} + + content-type@3.1.1: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + depd@2.0.0: {} + + detect-libc@2.1.2: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.3.2: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escape-html@1.0.3: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + etag@1.8.1: {} + + eventsource-parser@3.1.1: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.1 + + expect-type@1.4.0: {} + + express-rate-limit@8.7.1(express@5.3.0): + dependencies: + debug: 4.4.3 + express: 5.3.0 + ip-address: 10.7.3 + transitivePeerDependencies: + - supports-color + + express@5.3.0: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 2.0.1 + content-type: 2.1.0 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.8 + qs: 6.16.0 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-sha256@1.3.0: {} + + fast-uri@3.1.8: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hono@4.13.13: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ip-address@10.7.3: {} + + ipaddr.js@1.9.1: {} + + is-promise@4.0.0: {} + + isexe@2.0.0: {} + + jose@6.2.12: {} + + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.10 + ts-algebra: 2.0.0 + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + json-with-bigint@3.5.12: {} + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + magic-string@1.4.3: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + math-intrinsics@1.1.0: {} + + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + ms@2.1.3: {} + + nanoid@3.3.20: {} + + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + obug@2.2.1: {} + + octokit@5.0.5: + dependencies: + '@octokit/app': 16.1.4 + '@octokit/core': 7.0.8 + '@octokit/oauth-app': 8.0.4 + '@octokit/plugin-paginate-graphql': 6.0.0(@octokit/core@7.0.8) + '@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.8) + '@octokit/plugin-rest-endpoint-methods': 17.0.0(@octokit/core@7.0.8) + '@octokit/plugin-retry': 8.1.1(@octokit/core@7.0.8) + '@octokit/plugin-throttling': 11.0.5(@octokit/core@7.0.8) + '@octokit/request-error': 7.1.2 + '@octokit/types': 16.0.0 + '@octokit/webhooks': 14.2.0 + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parseurl@1.3.3: {} + + path-key@3.1.1: {} + + path-to-regexp@8.4.2: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + pkce-challenge@5.0.1: {} + + playwright-core@1.64.0: {} + + playwright-core@1.64.0-alpha-1790635538000: {} + + playwright@1.64.0: + dependencies: + playwright-core: 1.64.0 + + playwright@1.64.0-alpha-1790635538000: + dependencies: + playwright-core: 1.64.0-alpha-1790635538000 + + postcss@8.5.29: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + proxy-addr@2.0.8: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.16.0: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + require-from-string@2.0.2: {} + + rolldown@1.2.13: + dependencies: + '@oxc-project/types': 0.153.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.13 + '@rolldown/binding-android-arm64': 1.2.13 + '@rolldown/binding-darwin-arm64': 1.2.13 + '@rolldown/binding-darwin-x64': 1.2.13 + '@rolldown/binding-freebsd-x64': 1.2.13 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.13 + '@rolldown/binding-linux-arm64-gnu': 1.2.13 + '@rolldown/binding-linux-arm64-musl': 1.2.13 + '@rolldown/binding-linux-ppc64-gnu': 1.2.13 + '@rolldown/binding-linux-s390x-gnu': 1.2.13 + '@rolldown/binding-linux-x64-gnu': 1.2.13 + '@rolldown/binding-linux-x64-musl': 1.2.13 + '@rolldown/binding-openharmony-arm64': 1.2.13 + '@rolldown/binding-win32-arm64-msvc': 1.2.13 + '@rolldown/binding-win32-x64-msvc': 1.2.13 + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + safer-buffer@2.1.2: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + source-map-js@1.2.2: {} + + standardwebhooks@1.1.1: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + + statuses@2.0.2: {} + + std-env@4.3.0: {} + + tinybench@6.2.1: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + toad-cache@3.7.4: {} + + toidentifier@1.0.1: {} + + ts-algebra@2.0.0: {} + + tsx@4.23.15: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + universal-github-app-jwt@2.2.2: {} + + universal-user-agent@7.0.3: {} + + unpipe@1.0.0: {} + + vary@1.1.2: {} + + vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.29 + rolldown: 1.2.13 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.5 + esbuild: 0.28.2 + fsevents: 2.3.3 + tsx: 4.23.15 + + vitest@5.0.3(@types/node@22.20.5)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.3(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15)) + chai: 6.3.0 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.3 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.2.1 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15) + why-is-node-running: 3.2.1 + optionalDependencies: + '@types/node': 22.20.5 + transitivePeerDependencies: + - msw + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@3.2.1: {} + + wrappy@1.0.2: {} + + zod-to-json-schema@3.25.2(zod@4.6.5): + dependencies: + zod: 4.6.5 + + zod@4.6.5: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..3ff5faa --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "apps/*" + - "packages/*" diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..5660f5b --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "sourceMap": true, + "isolatedModules": true, + "noUncheckedIndexedAccess": true + } +} diff --git a/vitest.config.mts b/vitest.config.mts new file mode 100644 index 0000000..6104657 --- /dev/null +++ b/vitest.config.mts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["packages/*/test/**/*.test.ts", "apps/*/test/**/*.test.ts"], + }, +}); From ea27680754cf5b06529c43b504946e27ddf4a33c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 18:31:34 +0000 Subject: [PATCH 2/7] Fix MCP name collision with .mcp.json and keep the stdio tool server alive unconfigured - runner: strictMcpConfig so the repo's .mcp.json (same server name "claudio", meant for the Chrome live mode) is not loaded next to the in-process tools; the project entry won the name and left the agent without Claudio tools - stdio tool server: start without CLAUDIO_RUN_DIR / CLAUDIO_INSTANCE_URL and let the tools explain what is missing, instead of exiting at startup when Claude Code loads .mcp.json for an ordinary session Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NirxwvzzAcZMDVkE3AYN2x --- packages/agent/src/runner.ts | 2 ++ packages/browser/src/tools/definitions.ts | 1 + packages/browser/src/tools/stdio-server.ts | 22 +++++++++++++--------- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/agent/src/runner.ts b/packages/agent/src/runner.ts index a31759d..5429335 100644 --- a/packages/agent/src/runner.ts +++ b/packages/agent/src/runner.ts @@ -128,6 +128,8 @@ export async function runReview(input: ReviewRequest, deps: RunnerDeps = {}): Pr maxTurns: profile.maxTurns, maxBudgetUsd: profile.maxBudgetUsd, mcpServers: { ...session.mcpServers, claudio: claudioServer }, + // Only the servers above: the repo's .mcp.json (live-mode server, same name) must not be loaded here. + strictMcpConfig: true, allowedTools: [...session.allowedTools, "Read", "Grep", "Glob", "Agent", "TodoWrite"], disallowedTools: [...session.disallowedTools, "Bash", "Write", "Edit", "MultiEdit", "NotebookEdit", "WebFetch", "WebSearch", "KillShell", "BashOutput"], permissionMode: "bypassPermissions", diff --git a/packages/browser/src/tools/definitions.ts b/packages/browser/src/tools/definitions.ts index d9f99a4..29a11cc 100644 --- a/packages/browser/src/tools/definitions.ts +++ b/packages/browser/src/tools/definitions.ts @@ -47,6 +47,7 @@ export function createClaudioTools(ctx: ToolContext): ClaudioTool[] { handler: async ({ role }) => { const cfg = ctx.roles[role]; if (!cfg) return `Unknown role "${role}". Available roles: ${roleNames.join(", ") || "none"}.`; + if (!ctx.instanceUrl) return "No instance URL configured (CLAUDIO_INSTANCE_URL). Ask the person for the URL of the app under test."; if (ctx.driver === "chrome" || !ctx.attachment) { return `Live mode: ask the person watching to log in as "${role}"${cfg.description ? ` (${cfg.description})` : ""} in the browser window, then continue.`; } diff --git a/packages/browser/src/tools/stdio-server.ts b/packages/browser/src/tools/stdio-server.ts index 6856dd1..24c501d 100644 --- a/packages/browser/src/tools/stdio-server.ts +++ b/packages/browser/src/tools/stdio-server.ts @@ -3,8 +3,8 @@ * Used by the Claude Code CLI live mode (see .mcp.json). Configuration comes * from environment variables so the same server works for any run: * - * CLAUDIO_RUN_DIR directory for findings/evidence/notes (required) - * CLAUDIO_INSTANCE_URL base URL of the app under test (required) + * CLAUDIO_RUN_DIR directory for findings/evidence/notes (default: runs/live-) + * CLAUDIO_INSTANCE_URL base URL of the app under test (default: unset; login_as then reports it) * CLAUDIO_CONFIG path to claudio.config.json (optional) * CLAUDIO_DRIVER playwright | chrome (default chrome) * CLAUDIO_CDP_ENDPOINT attach to a running Chromium for screenshots/capture (optional) @@ -13,20 +13,24 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { chromium } from "playwright"; import { z } from "zod"; -import { loadRepoConfig } from "@claudio/core"; +import { resolve } from "node:path"; +import { loadRepoConfig, newRunId } from "@claudio/core"; import { CaptureBuffer } from "../capture.js"; import type { BrowserAttachment } from "../driver.js"; import { RunStore } from "../run-store.js"; import { createClaudioTools, type ToolContext } from "./definitions.js"; async function main(): Promise { - const runDir = process.env.CLAUDIO_RUN_DIR; - const instanceUrl = process.env.CLAUDIO_INSTANCE_URL; - if (!runDir || !instanceUrl) { - console.error("CLAUDIO_RUN_DIR and CLAUDIO_INSTANCE_URL must be set"); - process.exit(2); + // Stay up even when unconfigured (Claude Code loads .mcp.json for every session in this repo); + // the tools then explain what is missing instead of the server dying at startup. + const runDir = process.env.CLAUDIO_RUN_DIR ?? resolve("runs", `live-${newRunId("session")}`); + const instanceUrl = process.env.CLAUDIO_INSTANCE_URL ?? ""; + let config = null as ReturnType; + try { + config = loadRepoConfig(process.env.CLAUDIO_CONFIG); + } catch (e) { + console.error(`claudio: could not load config: ${(e as Error).message}`); } - const config = loadRepoConfig(process.env.CLAUDIO_CONFIG); const driver = process.env.CLAUDIO_DRIVER === "playwright" ? "playwright" : "chrome"; let attachment: BrowserAttachment | null = null; From 8e58583122f8ca617b6696122ada74c9a6d043f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 18:34:44 +0000 Subject: [PATCH 3/7] Replace example report with a standard-budget run (Opus 5.5) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01NirxwvzzAcZMDVkE3AYN2x --- apps/demo-target/fixtures/example-report.md | 191 ++++++++++++-------- 1 file changed, 117 insertions(+), 74 deletions(-) diff --git a/apps/demo-target/fixtures/example-report.md b/apps/demo-target/fixtures/example-report.md index 85d5be7..aac6c96 100644 --- a/apps/demo-target/fixtures/example-report.md +++ b/apps/demo-target/fixtures/example-report.md @@ -4,135 +4,178 @@ ## Zusammenfassung -**Urteil:** PR #17 sollte nicht gemergt werden. Von den 6 Akzeptanzkriterien sind 2 erfüllt, 3 nicht erfüllt, und 1 ließ sich nicht prüfen. Die Suche ist durch einen Serverfehler (HTTP 500) unbrauchbar. +**Urteil:** PR #17 sollte nicht gemergt werden. Drei von sechs Akzeptanzkriterien sind nicht erfüllt, und die neue Suche bricht mit einem Serverfehler ab, sobald jemand über das neue Formular eine Fahrt angelegt hat. **Zusammenfassung** -Ich habe den Code in `src/server.mjs` gelesen und die Kriterien als Todo-Liste angelegt. Dann habe ich im Browser als Disponent die Fahrtenliste geöffnet, das Formular geprüft und eine Fahrt mit Datum 2020-01-01 gespeichert. Danach habe ich die Suche aufgerufen, mich als Admin angemeldet und einen POST mit leeren Feldern abgeschickt. - -- **Button "Neue Fahrt" (erfüllt):** Er ist in der Liste vorhanden und führt zum Formular `/trips/new`. Der Klick per Tool schlug technisch fehl, deshalb habe ich die URL direkt aufgerufen. Im Code ist der Button verlinkt. -- **Felder und Pflichtfelder (nicht erfüllt):** Das Feld "Fahrzeug" fehlt komplett. Fahrten werden immer mit `vehicle: null` gespeichert und in der Liste mit "-" angezeigt. Von und Nach haben nur einen Placeholder und kein Label. -- **Datum nicht in der Vergangenheit (nicht erfüllt):** 2020-01-01 wurde ohne Fehlermeldung gespeichert, weder im Browser noch auf dem Server geprüft. -- **Speichern, Liste und Erfolgsmeldung (erfüllt):** "Fahrt #3 angelegt." erscheint und die Zeile steht in der Liste, allerdings ohne Fahrzeug. -- **Suche (nicht erfüllt, Blocker):** Der Filter ruft `t.vehicle.toLowerCase()` auf, aber neue Fahrten haben `vehicle = null`. Sobald eine solche Fahrt existiert, liefert jede Suche HTTP 500. Im Browser habe ich das mit `?q=mia` bestätigt. Eine Suche vor dem Anlegen habe ich nicht gemacht. -- **Server ohne Validierung:** Ein POST mit leeren Feldern hat Fahrt #4 angelegt. Die Rollenprüfung sitzt nur im GET-Handler, nicht im POST. -- **Konsole:** Jeder Aufruf von `/trips` erzeugt `console.error("TripList: deprecated prop 'rows' used")`. Dazu kommt ein favicon-404. -- **Suchbegriff und Fahrgastdaten:** Sie werden ohne Escaping ausgegeben, was reflektiertes und gespeichertes XSS ermöglicht. Das habe ich nur im Code gesehen, nicht im Browser ausgenutzt. -- **Formular-UX:** Der Submit-Button heißt "Senden" statt "Speichern", und es gibt keine Fehlerdarstellung am Feld. -- **Verbesserungsideen:** - - Fahrzeug als Auswahlfeld mit Pflicht ergänzen. - - Datum clientseitig mit `min=heute` und serverseitig prüfen, Fehler am Feld anzeigen. - - Suche `null`-sicher machen (`t.vehicle?.toLowerCase()`). - - Alle Ausgaben escapen. - - Meldung nach dem Speichern aussagekräftiger formulieren. +Was funktioniert: Der Button „Neue Fahrt“ öffnet das Formular. Nach dem Speichern erscheint die Fahrt mit Erfolgsmeldung in der Liste, getestet als Disponent und als Admin. Ohne Anmeldung leitet die App beim Anlegen auf den Login um. Mit den beiden vorhandenen Fahrten lieferte die Suche nach Fahrgast und Fahrzeug zunächst richtige Ergebnisse. + +Das Formular hat aber kein Feld „Fahrzeug“, deshalb wird jede neue Fahrt ohne Fahrzeug gespeichert. Die Suche verkraftet das nicht: Ab der ersten neuen Fahrt endet sie mit HTTP 500 („Interner Fehler“). Seit einer Fahrt mit leerem Fahrgast scheitert sogar jede Suche, egal mit welchem Begriff. Daneben fehlt die Prüfung auf Daten in der Vergangenheit, und der Server prüft die Eingaben überhaupt nicht. Eingaben werden außerdem ungeprüft als HTML in die Liste geschrieben, wodurch fremder Code eingeschleust werden kann (Stored XSS). + +Wichtigste Beobachtungen: +- **Blocker:** Die Suche liefert HTTP 500, sobald eine Fahrt ohne Fahrzeug existiert. Die interne Fehlermeldung wird dem Nutzer angezeigt (F1). +- **Major:** Das Pflichtfeld „Fahrzeug“ fehlt im Formular (F3). +- **Major:** Ein Datum in der Vergangenheit wird ohne Fehlermeldung gespeichert (F2). +- **Major:** Der Server prüft die Eingaben nicht. Eine Fahrt ohne Fahrgast, Datum und Uhrzeit wurde gespeichert (F5). +- **Major (Sicherheit):** Fahrgast, Von und Nach werden ohne HTML-Escaping in die Liste geschrieben; das ist im Browser nachgewiesen. Laut Code gilt das auch für den Suchbegriff im Suchfeld (F4). +- **Minor:** Von und Nach haben keine Beschriftung, die übrigen Beschriftungen sind nicht mit ihren Feldern verknüpft, und der Button heißt „Senden“ (F6). Der Suchbegriff wird kleingeschrieben zurück ins Feld gesetzt (F7). Bei jedem Aufruf der Liste erscheint ein Konsolenfehler (F8). **Affected areas:** -- Fahrtenliste `/trips`: Suche (500-Fehler), Erfolgsmeldung, Konsolenfehler. -- Formular `/trips/new`: Felder, Validierung, Labels, Rollenprüfung. -- `POST /trips/new`: keine Validierung, `vehicle` immer `null`. -- Übersicht `/dashboard`: lädt unverändert, kein Fehler festgestellt. +- Fahrtenliste `/trips`: neue Suche, Erfolgsmeldung und Button „Neue Fahrt“ +- Formular „Neue Fahrt“ `GET /trips/new` +- Speichern einer Fahrt `POST /trips/new`: Validierung und Rechteprüfung +- Fahrtenliste als Anzeige aller Fahrten, über die sich eingeschleuster Code auswirkt + +**Nicht oder nur eingeschränkt getestet** +- **Rechte für weitere Rollen:** Die Instanz hat nur Disponent und Admin, deshalb ließ sich nicht prüfen, ob andere Rollen abgewiesen werden. Im Code prüft nur der Formularaufruf die Rolle, das Speichern nicht. +- **Code im Suchbegriff:** Im Browser ließ sich das nicht vorführen, weil die Suche inzwischen immer mit 500 abbricht. Der Befund stützt sich hier auf den Code. -**Nicht getestet:** -- **Rollenbeschränkung:** Es gibt nur die Rollen Disponent und Admin, beide dürfen anlegen. Den Negativfall konnte ich nicht prüfen. -- **Suche vor dem Anlegen:** Das Verhalten mit den ursprünglichen Seed-Daten habe ich nicht gemessen. -- **XSS:** Nicht im Browser ausprobiert. -- **Aufräumen:** Meine Testdaten sind Fahrt #3 (`claudio-review-pr17-20261010-135737-slu9 Past`) und die leere Fahrt #4. Sie liegen nur im Speicher und verschwinden beim Neustart des Servers. +**Testdaten:** Ich habe die Fahrten #3 bis #6 angelegt, erkennbar am Präfix `claudio-review-pr17-20261010-183117-7fng`; Fahrt #4 hat absichtlich einen leeren Fahrgast. Solange sie bestehen, liefert jede Suche HTTP 500. Die App kann keine Fahrten löschen, deshalb verschwinden sie erst, wenn der Server neu gestartet wird. ## Acceptance Criteria | # | Criterion | Status | Notes | |---|---|---|---| -| 1 | Button "Neue Fahrt" in der Fahrtenliste öffnet ein Formular | erfüllt | Button vorhanden (Link-Button auf /trips/new); Formular öffnet. Klick per Tool scheiterte technisch, daher Navigation direkt; im Code verlinkt. | -| 2 | Felder Fahrgast, Datum, Uhrzeit, Von, Nach, Fahrzeug; Pflicht: Fahrgast, Datum, Uhrzeit, Fahrzeug | nicht erfüllt | Fahrzeugfeld fehlt komplett; Von/Nach nur Placeholder ohne Label. Fahrt wird mit Fahrzeug "-" gespeichert. | -| 3 | Datum in der Vergangenheit → Fehlermeldung am Feld | nicht erfüllt | 2020-01-01 wurde ohne Fehlermeldung gespeichert. | -| 4 | Nach Speichern erscheint Fahrt in Liste und Erfolgsmeldung | erfüllt | Meldung "Fahrt #3 angelegt." und Zeile in Liste vorhanden (aber Fahrzeug leer, siehe AC2). | -| 5 | Fahrtenliste nach Fahrgast oder Fahrzeug durchsuchbar | nicht erfüllt | Sobald eine Fahrt ohne Fahrzeug existiert (jede neu angelegte), liefert jede Suche mit Text HTTP 500 (null.toLowerCase). Zuvor nicht getestet, da Suche mit q=mia erst danach erfolgte; Code-Analyse zeigt gleichen Fehler. | -| 6 | Nur Disponenten und Admins dürfen Fahrten anlegen | nicht testbar | Nur die Rollen dispatcher und admin stehen zur Verfügung, beide dürfen. Negativfall nicht prüfbar; Code prüft Rolle nur beim GET, nicht beim POST (Hinweis). | +| 1 | In der Fahrtenliste gibt es einen Button "Neue Fahrt", der ein Formular öffnet. | erfüllt | Button "Neue Fahrt" über der Tabelle vorhanden, Klick öffnet /trips/new mit Formular. | +| 2 | Das Formular hat die Felder Fahrgast, Datum, Uhrzeit, Von, Nach und Fahrzeug. Fahrgast, Datum, Uhrzeit und Fahrzeug sind Pflichtfelder. | nicht erfüllt | Feld Fahrzeug fehlt komplett (F3). Fahrgast/Datum/Uhrzeit sind nur clientseitig über required Pflicht, ein leeres Absenden blieb im Browser auf der Seite. Serverseitig gibt es keine Prüfung (F5). | +| 3 | Das Datum darf nicht in der Vergangenheit liegen; sonst erscheint eine Fehlermeldung am Feld. | nicht erfüllt | 2026-10-01 (heute 2026-10-10) wurde ohne Fehlermeldung gespeichert (F2). | +| 4 | Nach dem Speichern erscheint die neue Fahrt in der Liste und eine Erfolgsmeldung wird angezeigt. | erfüllt | Als Disponent (#3) und als Admin (#6) geprüft: Weiterleitung auf die Liste, neue Zeile sichtbar, Meldung "Fahrt #n angelegt.". Fahrzeug bleibt dabei "-" (F3). | +| 5 | Die Fahrtenliste lässt sich nach Fahrgast oder Fahrzeug durchsuchen. | nicht erfüllt | Mit den Bestandsdaten funktionierte die Suche nach Fahrgast und Fahrzeug (E1, E2). Sobald eine über das Formular angelegte Fahrt (Fahrzeug null) existiert, endet die Suche mit HTTP 500 (F1). Seit einer Fahrt mit leerem Fahrgast scheitert jede nicht-leere Suche. | +| 6 | Nur Disponenten und Admins dürfen Fahrten anlegen. | erfüllt | Disponent und Admin können anlegen. Ohne Anmeldung werden GET und POST /trips/new auf /login umgeleitet, es entstand keine Fahrt (per /api/trips geprüft). Einschränkung: Andere Rollen gibt es in der Instanz nicht. POST /trips/new hat keine eigene Rollenprüfung (nur GET), das würde bei weiteren Rollen zur Lücke. | ## Findings -### Blockierend (4) +### Blockierend (5) + +### [Blocker] Suche in der Fahrtenliste liefert HTTP 500, sobald eine Fahrt ohne Fahrzeug existiert + +_acceptance_ · http://127.0.0.1:4310/trips?q=mia · AC: Die Fahrtenliste lässt sich nach Fahrgast oder Fahrzeug durchsuchen. + +Über das neue Formular angelegte Fahrten haben immer vehicle = null (kein Fahrzeugfeld). Der neue Suchfilter ruft t.vehicle.toLowerCase() ohne Null-Prüfung auf (src/server.mjs:60). Jede Suche, deren Begriff nicht schon beim Fahrgast dieser Fahrt trifft, endet daher mit "Interner Fehler – Cannot read properties of null (reading 'toLowerCase')". Da jede neue Fahrt dieses Problem auslöst, ist die Suche nach der ersten angelegten Fahrt praktisch unbenutzbar. Zusätzlich wird die interne Fehlermeldung dem Nutzer angezeigt. -### [Blocker] Suche in Fahrtenliste → HTTP 500, sobald eine Fahrt ohne Fahrzeug existiert +**Schritte** +1. Als Disponent einloggen +2. Fahrten > Neue Fahrt, Pflichtfelder ausfüllen, Senden +3. In der Fahrtenliste nach "mia" suchen (vorher funktionierte das) + +**Erwartet:** Fahrt #1 Mia Muster wird gefunden; Fahrten ohne Fahrzeug werden einfach nicht gematcht. +**Beobachtet:** HTTP 500, Seite "Interner Fehler" mit JS-Fehlermeldung. + +**Evidenz:** [Suche nach Fahrgast "mia" (vor Anlegen neuer Fahrten)](evidence/E1-Suche_nach_Fahrgast_mia_vor_Anlegen_neuer_Fahrten_.png), [Suche "mia" nach Anlegen einer Fahrt: HTTP 500](evidence/E5-Suche_mia_nach_Anlegen_einer_Fahrt_HTTP_500.png) -_network_ · http://127.0.0.1:4310/trips?q=mia +### [Major] Datum in der Vergangenheit wird ohne Fehlermeldung gespeichert -Filter ruft t.vehicle.toLowerCase() auf; neu angelegte Fahrten haben vehicle=null. +_acceptance_ · http://127.0.0.1:4310/trips/new · AC: Das Datum darf nicht in der Vergangenheit liegen; sonst erscheint eine Fehlermeldung am Feld. + +Das Datumsfeld hat kein min-Attribut und der Server prüft das Datum nicht. Eine Fahrt mit Datum 2026-10-01 (heute: 2026-10-10) wurde gespeichert und als Fahrt #3 in der Liste angezeigt. **Schritte** -1. Fahrt anlegen -2. /trips?q=mia aufrufen +1. Neue Fahrt öffnen +2. Fahrgast, Datum 2026-10-01, Uhrzeit 08:00 eingeben +3. Senden + +**Erwartet:** Fehlermeldung am Feld Datum, Fahrt wird nicht gespeichert. +**Beobachtet:** Fahrt wird gespeichert, Erfolgsmeldung "Fahrt #3 angelegt." + +**Evidenz:** [Fahrt mit Vergangenheitsdatum gespeichert](evidence/E4-Fahrt_mit_Vergangenheitsdatum_gespeichert.png) -**Erwartet:** Gefilterte Liste -**Beobachtet:** 500 Interner Fehler +### [Major] Pflichtfeld "Fahrzeug" fehlt im Formular "Neue Fahrt" -**Evidenz:** [Suche liefert 500 nach Anlegen einer Fahrt ohne Fahrzeug](evidence/E4-Suche_liefert_500_nach_Anlegen_einer_Fahrt_ohne_Fahrzeug.png) +_acceptance_ · http://127.0.0.1:4310/trips/new · AC: Das Formular hat die Felder Fahrgast, Datum, Uhrzeit, Von, Nach und Fahrzeug. -### [Major] Formular hat kein Pflichtfeld "Fahrzeug" +Das Formular enthält nur Fahrgast, Datum, Uhrzeit, Von, Nach. Ein Fahrzeugfeld existiert nicht; der Server setzt vehicle immer auf null, in der Liste erscheint "-". Damit kann eine Fahrt nicht vollständig angelegt werden, und genau diese Fahrten lösen den 500-Fehler der Suche aus. -_acceptance_ · http://127.0.0.1:4310/trips/new +**Erwartet:** Pflichtfeld Fahrzeug im Formular. +**Beobachtet:** Kein Fahrzeugfeld, Fahrzeug immer leer. -Fahrzeug wird weder abgefragt noch gespeichert (immer null, Anzeige "-"). +**Evidenz:** [Formular Neue Fahrt](evidence/E3-Formular_Neue_Fahrt.png), [Fahrt mit Vergangenheitsdatum gespeichert](evidence/E4-Fahrt_mit_Vergangenheitsdatum_gespeichert.png) -**Erwartet:** Pflichtfeld Fahrzeug -**Beobachtet:** Feld fehlt +### [Major] Kein HTML-Escaping: Eingaben aus "Neue Fahrt" werden als HTML in der Fahrtenliste gerendert (Stored XSS) -**Evidenz:** [Formular Neue Fahrt: kein Fahrzeugfeld, keine Labels Von/Nach](evidence/E2-Formular_Neue_Fahrt_kein_Fahrzeugfeld_keine_Labels_Von_Nach.png), [Vergangenes Datum akzeptiert, Fahrt ohne Fahrzeug, Erfolgsmeldung](evidence/E3-Vergangenes_Datum_akzeptiert_Fahrt_ohne_Fahrzeug_Erfolgsmeld.png) +_security_ · http://127.0.0.1:4310/trips -### [Major] Datum in der Vergangenheit wird ohne Fehlermeldung akzeptiert +Fahrgast, Von und Nach werden ungefiltert in die Tabelle der Fahrtenliste eingesetzt (src/server.mjs:69). Ein Fahrgast mit Markup <i id="claudio-xss">…</i> erscheint als echtes <i>-Element im DOM. Damit kann jeder, der Fahrten anlegen darf, Skripte einschleusen, die bei allen anderen Nutzern der Fahrtenliste laufen. Gleiches gilt laut Code für den Suchbegriff q, der unescaped in das value-Attribut des Suchfelds geschrieben wird (Reflected XSS); im Browser konnte das nicht vorgeführt werden, weil die Suche derzeit mit 500 abbricht (F1). -_acceptance_ · http://127.0.0.1:4310/trips/new +**Schritte** +1. Fahrt mit Fahrgast Test anlegen +2. Fahrtenliste öffnen +3. document.getElementById('claudio-xss') existiert / Text wird kursiv dargestellt -2020-01-01 wurde gespeichert; weder Client- noch Server-Prüfung. +**Erwartet:** Eingaben werden HTML-kodiert und als Text angezeigt. +**Beobachtet:** Markup wird als HTML interpretiert. -**Erwartet:** Fehlermeldung am Datumsfeld -**Beobachtet:** Fahrt angelegt +**Evidenz:** [Liste mit leerer Fahrt #4 und HTML-injizierter Fahrt #5](evidence/E6-Liste_mit_leerer_Fahrt_4_und_HTML-injizierter_Fahrt_5.png) -**Evidenz:** [Vergangenes Datum akzeptiert, Fahrt ohne Fahrzeug, Erfolgsmeldung](evidence/E3-Vergangenes_Datum_akzeptiert_Fahrt_ohne_Fahrzeug_Erfolgsmeld.png) +### [Major] Keine serverseitige Validierung: Fahrt ohne Fahrgast, Datum und Uhrzeit wird gespeichert -### [Major] Keine serverseitige Validierung; leere Fahrt anlegbar +_acceptance_ · http://127.0.0.1:4310/trips/new · AC: Fahrgast, Datum, Uhrzeit und Fahrzeug sind Pflichtfelder. -_other_ · http://127.0.0.1:4310/trips/new +POST /trips/new prüft weder Pflichtfelder noch Datum. Die Pflichtfelder werden nur über das HTML-Attribut required im Browser erzwungen. Ein direkter POST mit leerem Fahrgast, Datum und Uhrzeit wurde als Fahrt #4 gespeichert. Diese leere Fahrt sorgt außerdem dafür, dass jede Suche mit 500 abbricht (siehe F1). -POST /trips/new mit leeren Feldern legte Fahrt #4 an. Rollenprüfung fehlt außerdem im POST-Handler. +**Schritte** +1. Eingeloggt POST /trips/new mit passenger=&date=&time= senden +2. Fahrtenliste öffnen -**Erwartet:** Ablehnung mit Fehlermeldung -**Beobachtet:** Leerer Datensatz in Liste +**Erwartet:** Server lehnt ab und zeigt Feldfehler an. +**Beobachtet:** Redirect auf /trips?created=4, leere Zeile in der Liste. -**Evidenz:** [Liste mit leerer Fahrt #4 (Server ohne Validierung) und Fahrt #3](evidence/E6-Liste_mit_leerer_Fahrt_4_Server_ohne_Validierung_und_Fahrt_3.png) +**Evidenz:** [Liste mit leerer Fahrt #4 und HTML-injizierter Fahrt #5](evidence/E6-Liste_mit_leerer_Fahrt_4_und_HTML-injizierter_Fahrt_5.png), [Suche "claudio" ebenfalls HTTP 500](evidence/E7-Suche_claudio_ebenfalls_HTTP_500.png) -### Nicht blockierend (3) +### Nicht blockierend (4) -### [Minor] Formular: Von/Nach ohne Labels, Button "Senden", ID-Meldung wenig informativ +### [Minor] Formular "Neue Fahrt": Von/Nach ohne Labels, Labels nicht mit Feldern verknüpft, Button heißt "Senden" _ux_ · http://127.0.0.1:4310/trips/new -Von/Nach nur Placeholder, inkonsistent zu Labels der übrigen Felder; Submit heißt "Senden" statt "Speichern"; Erfolgsmeldung "Fahrt #3 angelegt." ohne Details. Keine Fehlerdarstellung vorgesehen. +Von und Nach haben nur Platzhalter und stehen nebeneinander statt wie die anderen Felder untereinander mit Label. Die Labels Fahrgast/Datum/Uhrzeit sind nicht per for/id verknüpft (input.labels.length = 0), Screenreader nennen die Felder nicht, und ein Klick aufs Label setzt keinen Fokus. Der Absenden-Button heißt "Senden", passender für das Anlegen wäre "Speichern" bzw. "Fahrt anlegen". Pflichtfelder sind nicht markiert. "Neue Fahrt" und "Zurück" sind + {/each} + +
+ {@html shown.html} +
+ +{/if} + + diff --git a/apps/web/src/lib/components/LiveLog.svelte b/apps/web/src/lib/components/LiveLog.svelte new file mode 100644 index 0000000..16ac956 --- /dev/null +++ b/apps/web/src/lib/components/LiveLog.svelte @@ -0,0 +1,126 @@ + + +
+ +

Verlauf des Agenten

+ {log.length} Einträge{#if streaming}, live{/if} +
+
box && (follow = box.scrollTop + box.clientHeight >= box.scrollHeight - 24)}> + {#each log as entry (entry.seq)} +

{entry.line}

+ {:else} +

Noch keine Einträge.

+ {/each} +
+
+ + diff --git a/apps/web/src/lib/components/LiveView.svelte b/apps/web/src/lib/components/LiveView.svelte new file mode 100644 index 0000000..214ca5c --- /dev/null +++ b/apps/web/src/lib/components/LiveView.svelte @@ -0,0 +1,86 @@ + + +
+
+ +

Live-Ansicht

+ {url} +
+
+ Aktueller Bildschirm des Browsers, den Claudio steuert (hasFrame = true)} + onerror={() => (failed = true)} + /> + {#if !hasFrame} +

+ {failed ? 'Die Live-Ansicht ist gerade nicht erreichbar.' : 'Wartet auf das erste Bild, sobald der Browser startet …'} +

+ {/if} +
+
+ + diff --git a/apps/web/src/lib/components/OutcomeBadge.svelte b/apps/web/src/lib/components/OutcomeBadge.svelte new file mode 100644 index 0000000..27106d2 --- /dev/null +++ b/apps/web/src/lib/components/OutcomeBadge.svelte @@ -0,0 +1,55 @@ + + + + {#if live}{/if} + {label}{#if healed && !live} · repariert{/if} + + + diff --git a/apps/web/src/lib/components/ReviewTable.svelte b/apps/web/src/lib/components/ReviewTable.svelte new file mode 100644 index 0000000..4e00f87 --- /dev/null +++ b/apps/web/src/lib/components/ReviewTable.svelte @@ -0,0 +1,81 @@ + + +
+ + + + + + + + + + + + + + {#each reviews as r (r.id)} + + + + + + + + + + {/each} + +
Pull RequestErgebnisKriterienFindingsTestsKostenGestartet
+ + {#if showRepo}{r.repo}{/if} + #{r.prNumber} {r.prTitle ?? ''} + + {r.acTotal ? `${r.acPassed} / ${r.acTotal}` : '–'} + {#if r.status === 'completed' || r.findings > 0} + {r.findings}{#if r.blocking > 0}, {r.blocking} blockierend{/if} + {:else}–{/if} + {r.testsTotal ? `${r.testsPassed} / ${r.testsTotal}` : '–'}{formatUsd(r.costUsd)} + {formatDate(r.createdAt)} + von {r.startedBy} +
+
+ + diff --git a/apps/web/src/lib/components/Severity.svelte b/apps/web/src/lib/components/Severity.svelte new file mode 100644 index 0000000..1db6d6b --- /dev/null +++ b/apps/web/src/lib/components/Severity.svelte @@ -0,0 +1,35 @@ + + +{SEVERITY_LABEL[severity]} + + diff --git a/apps/web/src/lib/components/StatusBadge.svelte b/apps/web/src/lib/components/StatusBadge.svelte new file mode 100644 index 0000000..b04042d --- /dev/null +++ b/apps/web/src/lib/components/StatusBadge.svelte @@ -0,0 +1,65 @@ + + + + {#if tone === 'live'}{/if} + {label} + + + diff --git a/apps/web/src/lib/index.ts b/apps/web/src/lib/index.ts new file mode 100644 index 0000000..73c55ef --- /dev/null +++ b/apps/web/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `#lib` alias in this folder. diff --git a/apps/web/src/lib/labels.ts b/apps/web/src/lib/labels.ts new file mode 100644 index 0000000..1583637 --- /dev/null +++ b/apps/web/src/lib/labels.ts @@ -0,0 +1,116 @@ +import type { Severity, Strictness, Verdict } from '@claudio/core'; + +export const STATUS_LABEL: Record = { + queued: 'Wartet', + running: 'Läuft', + completed: 'Abgeschlossen', + budget_exceeded: 'Budget erschöpft', + max_turns: 'Schrittlimit erreicht', + failed: 'Fehlgeschlagen', + aborted: 'Abgebrochen' +}; + +export const VERDICT_LABEL: Record = { + approve: 'Abnahme empfohlen', + request_changes: 'Änderungen nötig', + inconclusive: 'Kein Urteil' +}; + +export const SEVERITY_LABEL: Record = { + blocker: 'Blocker', + major: 'Major', + minor: 'Minor', + suggestion: 'Vorschlag' +}; + +export const CATEGORY_LABEL: Record = { + acceptance: 'Akzeptanzkriterium', + regression: 'Regression', + ux: 'UX', + ui: 'UI', + console: 'Konsole', + network: 'Netzwerk', + security: 'Sicherheit', + performance: 'Performance', + other: 'Sonstiges' +}; + +export const AC_LABEL: Record = { + passed: 'Erfüllt', + failed: 'Nicht erfüllt', + blocked: 'Blockiert', + not_testable: 'Nicht testbar' +}; + +export const STRICTNESS_LABEL: Record = { + lenient: 'Nachsichtig (nur Blocker blockieren)', + normal: 'Normal (ab Major)', + strict: 'Streng (ab Minor)' +}; + +export const BUDGET_LABEL: Record = { + quick: 'Schnell (bis 2 USD)', + standard: 'Standard (bis 8 USD)', + thorough: 'Gründlich (bis 25 USD)' +}; + +export const ANALYSIS_BUDGET_LABEL: Record = { + quick: 'Schnell (bis 4 USD)', + standard: 'Standard (bis 15 USD)', + thorough: 'Gründlich (bis 40 USD)' +}; + +export const VERBOSITY_LABEL: Record = { + tldr: 'Nur Kurzfassung', + normal: 'Normal', + detailed: 'Ausführlich' +}; + +const dateFmt = new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium', timeStyle: 'short' }); + +export function formatDate(d: Date | string | null | undefined): string { + if (!d) return '–'; + return dateFmt.format(typeof d === 'string' ? new Date(d) : d); +} + +export function formatUsd(v: string | number | null | undefined): string { + if (v === null || v === undefined || v === '') return '–'; + return `${Number(v).toFixed(2).replace('.', ',')} USD`; +} + +export function formatDuration(ms: number): string { + const s = Math.round(ms / 1000); + return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min ${s % 60} s`; +} + +export const isActive = (status: string) => status === 'queued' || status === 'running'; + +export const OUTCOME_LABEL: Record = { + passed: 'Bestanden', + failed: 'Fehlgeschlagen', + inconclusive: 'Unklar' +}; + +export const MODE_LABEL: Record = { + record: 'Aufzeichnung', + replay: 'Wiedergabe', + explore: 'Erkundung' +}; + +export const DETERMINISM_LABEL: Record = { + deterministic: 'Deterministisch', + semi: 'Halb-deterministisch', + explorative: 'Explorativ' +}; + +export const DETERMINISM_HELP: Record = { + deterministic: 'Die aufgezeichneten Prüfungen entscheiden allein. Wiederholungen laufen ohne Modell und kosten nichts.', + semi: 'Die Schritte laufen ohne Modell, das Ergebnis bewertet danach ein Prüf-Agent.', + explorative: 'Ein Agent führt den Test jedes Mal neu aus, weil sich der Ablauf je nach Daten ändert.' +}; + +export const TEST_BUDGET_LABEL: Record = { + quick: 'Schnell (bis 1 USD)', + standard: 'Standard (bis 3 USD)', + thorough: 'Gründlich (bis 8 USD)' +}; diff --git a/apps/web/src/lib/markdown.ts b/apps/web/src/lib/markdown.ts new file mode 100644 index 0000000..d6fd4df --- /dev/null +++ b/apps/web/src/lib/markdown.ts @@ -0,0 +1,22 @@ +import { Marked } from 'marked'; + +const escape = (s: string) => s.replace(/&/g, '&').replace(//g, '>'); + +/** Markdown from the model; raw HTML is shown as text, links open in a new tab. */ +const marked = new Marked({ + gfm: true, + breaks: false, + renderer: { + html: ({ text }) => escape(text), + link({ href, tokens }) { + const label = this.parser.parseInline(tokens); + if (!/^https?:\/\//i.test(href)) return label; + return `
${label}`; + }, + image: ({ text }) => escape(text) + } +}); + +export function renderMarkdown(md: string): string { + return marked.parse(md, { async: false }); +} diff --git a/apps/web/src/lib/server/auth.ts b/apps/web/src/lib/server/auth.ts new file mode 100644 index 0000000..59a1a23 --- /dev/null +++ b/apps/web/src/lib/server/auth.ts @@ -0,0 +1,44 @@ +import type { Cookies } from '@sveltejs/kit'; +import { createSession, deleteSession, getUser, userAccessToken, type User } from '@claudio/db'; +import { db } from './db.js'; +import { allowedOrgs, allowedUsers } from './env.js'; +import { fetchUserOrgs } from './github.js'; + +export const SESSION_COOKIE = 'claudio_session'; +export const STATE_COOKIE = 'claudio_oauth_state'; + +export type SessionUser = Pick; + +export async function startSession(cookies: Cookies, userId: number, secure: boolean): Promise { + const { token, expiresAt } = await createSession(db(), userId); + cookies.set(SESSION_COOKIE, token, { path: '/', httpOnly: true, sameSite: 'lax', secure, expires: expiresAt }); +} + +export async function endSession(cookies: Cookies): Promise { + const token = cookies.get(SESSION_COOKIE); + if (token) await deleteSession(db(), token); + cookies.delete(SESSION_COOKIE, { path: '/' }); +} + +/** Applies CLAUDIO_ALLOWED_GITHUB_USERS / _ORGS. With neither set, every GitHub user may sign in. */ +export async function isAllowed(login: string, token: string): Promise { + const users = allowedUsers(); + const orgs = allowedOrgs(); + if (users.length === 0 && orgs.length === 0) return true; + if (users.includes(login.toLowerCase())) return true; + if (orgs.length === 0) return false; + const memberOf = await fetchUserOrgs(token); + return memberOf.some((o) => orgs.includes(o)); +} + +/** The signed-in user's GitHub token, or null for the offline demo user. */ +export async function userToken(user: SessionUser): Promise { + const row = await getUser(db(), user.id); + if (!row) return null; + const token = userAccessToken(row); + return token || null; +} + +export function safeNext(next: string | null): string { + return next && next.startsWith('/') && !next.startsWith('//') ? next : '/'; +} diff --git a/apps/web/src/lib/server/db.ts b/apps/web/src/lib/server/db.ts new file mode 100644 index 0000000..2603fab --- /dev/null +++ b/apps/web/src/lib/server/db.ts @@ -0,0 +1,4 @@ +import './env.js'; +import { getDb } from '@claudio/db'; + +export const db = () => getDb(); diff --git a/apps/web/src/lib/server/demo.ts b/apps/web/src/lib/server/demo.ts new file mode 100644 index 0000000..3175d99 --- /dev/null +++ b/apps/web/src/lib/server/demo.ts @@ -0,0 +1,27 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { addRepo, saveRole, updateRepoSettings, type Db } from '@claudio/db'; +import type { PullRequestContext } from '@claudio/github'; +import { projectRoot } from './env.js'; + +/** The offline demo: apps/demo-target with its fixture PR. Only used with CLAUDIO_DEV_FIXTURES=1. */ +export const DEMO_REPO = 'vanderlars/dispo-demo'; +export const DEMO_FIXTURE = 'demo'; +export const DEMO_URL = 'http://127.0.0.1:4310'; + +export function demoPullRequest(): PullRequestContext { + return JSON.parse(readFileSync(resolve(projectRoot, 'apps/demo-target/fixtures/pr-context.json'), 'utf8')); +} + +export async function createDemoRepo(db: Db, userId: number): Promise { + const repo = await addRepo(db, { fullName: DEMO_REPO, isPrivate: false, defaultBranch: 'main', createdBy: userId }); + await updateRepoSettings(db, repo.id, { + notes: 'Demo-Dispositionsapp. Rollen: Disponent (legt Fahrten an), Admin. Datum im Format JJJJ-MM-TT.', + extraAllowedHosts: [], + dataConfirmed: true, + defaultInstanceUrl: DEMO_URL + }); + const base = { loginPath: '/login', successUrlContains: '/dashboard', recipe: null }; + await saveRole(db, repo.id, { ...base, name: 'dispatcher', description: 'Disponent', username: 'dispo@example.test', password: 'dispo' }); + await saveRole(db, repo.id, { ...base, name: 'admin', description: 'Administrator', username: 'admin@example.test', password: 'admin' }); +} diff --git a/apps/web/src/lib/server/env.ts b/apps/web/src/lib/server/env.ts new file mode 100644 index 0000000..8a9a29c --- /dev/null +++ b/apps/web/src/lib/server/env.ts @@ -0,0 +1,38 @@ +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; + +/** Monorepo root: apps/web is two levels down. Override with CLAUDIO_ROOT. */ +export const projectRoot = resolve(process.env.CLAUDIO_ROOT ?? resolve(process.cwd(), '../..')); + +// Web, worker and CLI share one .env at the repo root. Vite does not put it into process.env. +const envFile = resolve(projectRoot, '.env'); +if (existsSync(envFile)) process.loadEnvFile(envFile); + +export const runsRoot = resolve(projectRoot, process.env.CLAUDIO_RUNS_DIR ?? 'runs'); + +export function env(name: string): string | undefined { + const value = process.env[name]; + return value === undefined || value === '' ? undefined : value; +} + +export const devFixtures = () => process.env.CLAUDIO_DEV_FIXTURES === '1'; + +export function githubOAuthConfig(): { clientId: string; clientSecret: string } | null { + const clientId = env('GITHUB_CLIENT_ID'); + const clientSecret = env('GITHUB_CLIENT_SECRET'); + return clientId && clientSecret ? { clientId, clientSecret } : null; +} + +export function publicBaseUrl(fallback: URL): string { + return (env('PUBLIC_BASE_URL') ?? fallback.origin).replace(/\/$/, ''); +} + +function list(name: string): string[] { + return (env(name) ?? '') + .split(',') + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); +} + +export const allowedUsers = () => list('CLAUDIO_ALLOWED_GITHUB_USERS'); +export const allowedOrgs = () => list('CLAUDIO_ALLOWED_GITHUB_ORGS'); diff --git a/apps/web/src/lib/server/files.ts b/apps/web/src/lib/server/files.ts new file mode 100644 index 0000000..fdbd576 --- /dev/null +++ b/apps/web/src/lib/server/files.ts @@ -0,0 +1,33 @@ +import { isAbsolute, relative, resolve, sep } from 'node:path'; + +/** + * Resolves a file inside one run directory. Returns null for anything that + * would leave it: "..", absolute paths, odd run ids. + */ +export function resolveRunFile(runsRoot: string, runId: string, file: string): string | null { + if (!/^[\w.-]+$/.test(runId) || runId === '.' || runId === '..') return null; + if (!file || file.includes('\0') || isAbsolute(file)) return null; + const runDir = resolve(runsRoot, runId); + const target = resolve(runDir, file); + const rel = relative(runDir, target); + if (!rel || rel.startsWith('..') || isAbsolute(rel) || rel.split(sep).includes('..')) return null; + return target; +} + +const TYPES: Record = { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + webp: 'image/webp', + json: 'application/json; charset=utf-8', + md: 'text/markdown; charset=utf-8', + txt: 'text/plain; charset=utf-8', + log: 'text/plain; charset=utf-8', + yml: 'text/plain; charset=utf-8', + jsonl: 'text/plain; charset=utf-8', + zip: 'application/zip' +}; + +export function contentType(file: string): string { + return TYPES[file.split('.').pop()?.toLowerCase() ?? ''] ?? 'application/octet-stream'; +} diff --git a/apps/web/src/lib/server/github.ts b/apps/web/src/lib/server/github.ts new file mode 100644 index 0000000..72362aa --- /dev/null +++ b/apps/web/src/lib/server/github.ts @@ -0,0 +1,109 @@ +import { Octokit } from 'octokit'; + +export type GithubRepo = { + fullName: string; + description: string | null; + isPrivate: boolean; + defaultBranch: string; + pushedAt: string | null; + owner: string; +}; + +export type GithubPull = { + number: number; + title: string; + author: string | null; + headRef: string; + draft: boolean; + updatedAt: string; + url: string; +}; + +export const octokit = (token: string) => new Octokit({ auth: token }); + +/** All repos the user can access (own, collaborator, org member), most recently pushed first. */ +export async function listUserRepos(token: string, max = 500): Promise { + const out: GithubRepo[] = []; + for await (const { data } of octokit(token).paginate.iterator(octokit(token).rest.repos.listForAuthenticatedUser, { + per_page: 100, + sort: 'pushed', + affiliation: 'owner,collaborator,organization_member' + })) { + for (const r of data) { + out.push({ + fullName: r.full_name, + description: r.description, + isPrivate: r.private, + defaultBranch: r.default_branch, + pushedAt: r.pushed_at ?? null, + owner: r.owner.login + }); + } + if (out.length >= max) break; + } + return out; +} + +export async function getRepoInfo(token: string, fullName: string) { + const [owner, repo] = fullName.split('/') as [string, string]; + const { data } = await octokit(token).rest.repos.get({ owner, repo }); + return { fullName: data.full_name, isPrivate: data.private, defaultBranch: data.default_branch }; +} + +export async function listOpenPulls(token: string, fullName: string): Promise { + const [owner, repo] = fullName.split('/') as [string, string]; + const { data } = await octokit(token).rest.pulls.list({ owner, repo, state: 'open', sort: 'updated', direction: 'desc', per_page: 50 }); + return data.map((p) => ({ + number: p.number, + title: p.title, + author: p.user?.login ?? null, + headRef: p.head.ref, + draft: Boolean(p.draft), + updatedAt: p.updated_at, + url: p.html_url + })); +} + +export async function getPull(token: string, fullName: string, number: number): Promise { + const [owner, repo] = fullName.split('/') as [string, string]; + const { data: p } = await octokit(token).rest.pulls.get({ owner, repo, pull_number: number }); + return { + number: p.number, + title: p.title, + author: p.user?.login ?? null, + headRef: p.head.ref, + draft: Boolean(p.draft), + updatedAt: p.updated_at, + url: p.html_url + }; +} + +export async function exchangeCode(clientId: string, clientSecret: string, code: string, redirectUri: string): Promise { + const res = await fetch('https://github.com/login/oauth/access_token', { + method: 'POST', + headers: { accept: 'application/json', 'content-type': 'application/json' }, + body: JSON.stringify({ client_id: clientId, client_secret: clientSecret, code, redirect_uri: redirectUri }) + }); + const body = (await res.json()) as { access_token?: string; error_description?: string; error?: string }; + if (!body.access_token) throw new Error(body.error_description ?? body.error ?? `GitHub antwortete mit ${res.status}`); + return body.access_token; +} + +export async function fetchGithubUser(token: string) { + const { data } = await octokit(token).rest.users.getAuthenticated(); + return { githubId: data.id, login: data.login, name: data.name ?? null, avatarUrl: data.avatar_url ?? null }; +} + +export async function fetchUserOrgs(token: string): Promise { + const orgs = await octokit(token).paginate(octokit(token).rest.orgs.listForAuthenticatedUser, { per_page: 100 }); + return orgs.map((o) => o.login.toLowerCase()); +} + +/** Human-readable message for GitHub API errors (expired token, missing access). */ +export function githubErrorMessage(e: unknown): string { + const status = (e as { status?: number }).status; + if (status === 401) return 'Dein GitHub-Zugang ist abgelaufen. Melde dich ab und wieder an.'; + if (status === 403) return 'GitHub verweigert den Zugriff (fehlende Rechte oder Rate-Limit).'; + if (status === 404) return 'GitHub kennt dieses Repository nicht oder du hast keinen Zugriff.'; + return `GitHub-Fehler: ${(e as Error).message}`; +} diff --git a/apps/web/src/lib/server/live.ts b/apps/web/src/lib/server/live.ts new file mode 100644 index 0000000..82955b2 --- /dev/null +++ b/apps/web/src/lib/server/live.ts @@ -0,0 +1,59 @@ +import { readFile, stat } from 'node:fs/promises'; +import { runsRoot } from './env.js'; +import { resolveRunFile } from './files.js'; + +const BOUNDARY = 'claudio-frame'; +const ACTIVE = new Set(['queued', 'running']); + +/** + * MJPEG stream of a run's browser (runDir/live.jpg, written by the worker's + * screencast). An plays it like a video. Ends when the run is over. + */ +export function liveViewStream(request: Request, lookup: () => Promise<{ status: string; runId: string | null } | null>): Response { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller) { + let closed = false; + request.signal.addEventListener('abort', () => (closed = true)); + let lastMtime = 0; + let lastCheck = 0; + let run = await lookup(); + try { + while (!closed && run && ACTIVE.has(run.status)) { + const file = run.runId ? resolveRunFile(runsRoot, run.runId, 'live.jpg') : null; + if (file) { + const info = await stat(file).catch(() => null); + if (info && info.mtimeMs !== lastMtime) { + const jpeg = await readFile(file).catch(() => null); + if (jpeg && jpeg.length > 0) { + lastMtime = info.mtimeMs; + controller.enqueue(encoder.encode(`--${BOUNDARY}\r\nContent-Type: image/jpeg\r\nContent-Length: ${jpeg.length}\r\n\r\n`)); + controller.enqueue(new Uint8Array(jpeg)); + controller.enqueue(encoder.encode('\r\n')); + } + } + } + await new Promise((r) => setTimeout(r, 250)); + if (Date.now() - lastCheck > 2000) { + lastCheck = Date.now(); + run = await lookup(); + } + } + } catch { + // client went away mid-write + } + try { + controller.close(); + } catch { + // already closed + } + } + }); + return new Response(stream, { + headers: { + 'content-type': `multipart/x-mixed-replace; boundary=${BOUNDARY}`, + 'cache-control': 'no-store', + 'x-content-type-options': 'nosniff' + } + }); +} diff --git a/apps/web/src/lib/server/run-files.ts b/apps/web/src/lib/server/run-files.ts new file mode 100644 index 0000000..d3ecbda --- /dev/null +++ b/apps/web/src/lib/server/run-files.ts @@ -0,0 +1,23 @@ +import { readFileSync } from 'node:fs'; +import { EvidenceSchema, FindingSchema, type Evidence, type Finding } from '@claudio/core'; +import { z } from 'zod'; +import { runsRoot } from './env.js'; +import { resolveRunFile } from './files.js'; + +function readJson(runId: string, file: string, schema: z.ZodType): T[] { + const path = resolveRunFile(runsRoot, runId, file); + if (!path) return []; + try { + return z.array(schema).parse(JSON.parse(readFileSync(path, 'utf8'))); + } catch { + return []; + } +} + +/** URL under which the web app serves a file of a run. */ +export const runFileUrl = (runId: string, path: string) => `/runs/${runId}/${path.split('/').map(encodeURIComponent).join('/')}`; + +export const runEvidence = (runId: string): (Evidence & { url: string | null })[] => + readJson(runId, 'evidence.json', EvidenceSchema).map((e) => ({ ...e, url: e.path ? runFileUrl(runId, e.path) : null })); + +export const runFindings = (runId: string): Finding[] => readJson(runId, 'findings.json', FindingSchema); diff --git a/apps/web/src/lib/server/sse.ts b/apps/web/src/lib/server/sse.ts new file mode 100644 index 0000000..d68ea5d --- /dev/null +++ b/apps/web/src/lib/server/sse.ts @@ -0,0 +1,55 @@ +import { eventsSince, type EventTarget } from '@claudio/db'; +import { db } from './db.js'; + +const ACTIVE = new Set(['queued', 'running']); + +/** + * Server-sent events for a run's live log: `line` per log entry, `status` on + * status changes, `done` once the run has finished. + */ +export function runEventStream(request: Request, url: URL, target: EventTarget, currentStatus: () => Promise): Response { + // On reconnect the browser sends Last-Event-ID; it is newer than the ?after of the original URL. + let after = Number(request.headers.get('last-event-id') ?? url.searchParams.get('after') ?? 0) || 0; + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + async start(controller) { + const send = (chunk: string) => controller.enqueue(encoder.encode(chunk)); + let closed = false; + let lastStatus = ''; + request.signal.addEventListener('abort', () => (closed = true)); + try { + while (!closed) { + const events = await eventsSince(db(), target, after); + for (const e of events) { + send(`id: ${e.seq}\nevent: line\ndata: ${JSON.stringify(e.line)}\n\n`); + after = e.seq; + } + if (events.length > 0) continue; + const status = (await currentStatus()) ?? 'failed'; + if (status !== lastStatus) { + lastStatus = status; + send(`event: status\ndata: ${JSON.stringify(status)}\n\n`); + } + if (!ACTIVE.has(status)) { + send(`event: done\ndata: ${JSON.stringify(status)}\n\n`); + break; + } + send(': keep-alive\n\n'); + await new Promise((r) => setTimeout(r, 1000)); + } + } catch (e) { + if (!closed) send(`event: failure\ndata: ${JSON.stringify((e as Error).message)}\n\n`); + } + try { + controller.close(); + } catch { + // client already gone + } + } + }); + + return new Response(stream, { + headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' } + }); +} diff --git a/apps/web/src/lib/server/summary.ts b/apps/web/src/lib/server/summary.ts new file mode 100644 index 0000000..6872ed4 --- /dev/null +++ b/apps/web/src/lib/server/summary.ts @@ -0,0 +1,30 @@ +import { blockingFindings, verdict, withTests, type ReviewTestResult, type Strictness } from '@claudio/core'; +import type { ReviewListItem } from '@claudio/db'; +import type { ReviewSummary } from '#lib/types.js'; + +export type { ReviewSummary }; + + +/** List-row view of a review. The verdict honours dismissed findings and the chosen strictness. */ +export function summarize(review: ReviewListItem, dismissed: string[] = [], strictness?: Strictness, tests: ReviewTestResult[] = []): ReviewSummary { + const report = review.report; + const s = strictness ?? report?.request.strictness; + return { + id: review.id, + repo: review.repoFullName, + prNumber: review.prNumber, + prTitle: review.prTitle, + status: review.status, + verdict: report ? withTests(verdict(report, s, dismissed), tests) : null, + testsPassed: tests.filter((t) => t.outcome === 'passed').length, + testsTotal: tests.length, + acPassed: report?.acceptanceCriteria.filter((a) => a.status === 'passed').length ?? 0, + acTotal: report?.acceptanceCriteria.length ?? 0, + findings: report ? report.findings.length - report.findings.filter((f) => dismissed.includes(f.id)).length : 0, + blocking: report ? blockingFindings(report, s, dismissed).length : 0, + costUsd: review.costUsd, + startedBy: review.startedByLogin, + createdAt: review.createdAt, + finishedAt: review.finishedAt + }; +} diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts new file mode 100644 index 0000000..1d02156 --- /dev/null +++ b/apps/web/src/lib/types.ts @@ -0,0 +1,20 @@ +import type { Verdict } from '@claudio/core'; + +export type ReviewSummary = { + id: number; + repo: string; + prNumber: number; + prTitle: string | null; + status: string; + verdict: Verdict | null; + acPassed: number; + acTotal: number; + findings: number; + testsPassed: number; + testsTotal: number; + blocking: number; + costUsd: string | null; + startedBy: string; + createdAt: Date; + finishedAt: Date | null; +}; diff --git a/apps/web/src/routes/+error.svelte b/apps/web/src/routes/+error.svelte new file mode 100644 index 0000000..48e6501 --- /dev/null +++ b/apps/web/src/routes/+error.svelte @@ -0,0 +1,21 @@ + + +{page.status} · Claudio + +
+

{page.status === 404 ? 'Nicht gefunden' : 'Etwas ist schiefgelaufen'}

+

{page.error?.message}

+ Zur Übersicht +
+ + diff --git a/apps/web/src/routes/+layout.server.ts b/apps/web/src/routes/+layout.server.ts new file mode 100644 index 0000000..d8083fd --- /dev/null +++ b/apps/web/src/routes/+layout.server.ts @@ -0,0 +1,3 @@ +import type { LayoutServerLoad } from './$types'; + +export const load: LayoutServerLoad = ({ locals }) => ({ user: locals.user }); diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte new file mode 100644 index 0000000..34f7235 --- /dev/null +++ b/apps/web/src/routes/+layout.svelte @@ -0,0 +1,163 @@ + + + + + Claudio + + +{#if data.user} +
+
+ + + Claudio + + +
+ {#if data.user.avatarUrl} + + {/if} + {data.user.login} +
+ +
+
+
+
+ {@render children()} +
+
+{:else} + {@render children()} +{/if} + + diff --git a/apps/web/src/routes/+page.server.ts b/apps/web/src/routes/+page.server.ts new file mode 100644 index 0000000..efa19fa --- /dev/null +++ b/apps/web/src/routes/+page.server.ts @@ -0,0 +1,21 @@ +import { dismissedFindingIds, listAnalyses, listRepos, listReviews, testResultsByReview } from '@claudio/db'; +import { db } from '#lib/server/db.js'; +import { summarize } from '#lib/server/summary.js'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async () => { + const [reviews, repos, analyses] = await Promise.all([ + listReviews(db(), { limit: 40 }), + listRepos(db()), + listAnalyses(db(), { activeOnly: true }) + ]); + const [dismissed, tests] = await Promise.all([ + dismissedFindingIds(db(), reviews.map((r) => r.id)), + testResultsByReview(db(), reviews.map((r) => r.id)) + ]); + return { + reviews: reviews.map((r) => summarize(r, dismissed.get(r.id), undefined, tests.get(r.id))), + repoCount: repos.length, + analyses: analyses.map((a) => ({ id: a.id, repo: a.repoFullName, status: a.status })) + }; +}; diff --git a/apps/web/src/routes/+page.svelte b/apps/web/src/routes/+page.svelte new file mode 100644 index 0000000..b0b0a8d --- /dev/null +++ b/apps/web/src/routes/+page.svelte @@ -0,0 +1,74 @@ + + +Übersicht · Claudio + +
+
+

Reviews

+

+ {#if running > 0} + {running === 1 ? 'Ein Review läuft gerade.' : `${running} Reviews laufen gerade.`} + {:else} + Alle Abnahmen, die Claudio durchgeführt hat, die neuesten zuerst. + {/if} +

+
+ Review starten +
+ +{#each data.analyses as a (a.id)} +

+ Analyse von {a.repo} {a.status === 'queued' ? 'wartet auf den Worker' : 'läuft'}. Verlauf ansehen +

+{/each} + +{#if data.reviews.length > 0} +
+ +
+{:else} +
+

Noch keine Reviews

+ {#if data.repoCount === 0} +

Füge zuerst ein Repository hinzu. Danach kannst du für jeden offenen Pull Request einen Review starten.

+ Repository hinzufügen + {:else} +

Wähle ein Repository und einen offenen Pull Request, um den ersten Review zu starten.

+ Repository wählen + {/if} +
+{/if} + + diff --git a/apps/web/src/routes/analyses/[id]/+page.server.ts b/apps/web/src/routes/analyses/[id]/+page.server.ts new file mode 100644 index 0000000..36a8a53 --- /dev/null +++ b/apps/web/src/routes/analyses/[id]/+page.server.ts @@ -0,0 +1,51 @@ +import { error } from '@sveltejs/kit'; +import { KNOWLEDGE_DOCS, sortBySeverity } from '@claudio/core'; +import { eventsSince, getAnalysis, knowledgeDocsOf, requestAnalysisAbort } from '@claudio/db'; +import { db } from '#lib/server/db.js'; +import { runEvidence, runFindings } from '#lib/server/run-files.js'; +import { renderMarkdown } from '#lib/markdown.js'; +import type { Actions, PageServerLoad } from './$types'; + +async function loadAnalysis(idParam: string) { + const id = Number(idParam); + const analysis = Number.isInteger(id) ? await getAnalysis(db(), id) : undefined; + if (!analysis) error(404, 'Diese Analyse gibt es nicht.'); + return analysis; +} + +export const load: PageServerLoad = async ({ params }) => { + const a = await loadAnalysis(params.id); + const [events, docs] = await Promise.all([eventsSince(db(), { analysisId: a.id }, 0, 5000), knowledgeDocsOf(db(), a.id)]); + return { + analysis: { + id: a.id, + repo: a.repoFullName, + status: a.status, + instanceUrl: a.instanceUrl, + request: a.request, + branch: a.branch, + commitSha: a.commitSha, + previousCommitSha: a.report?.previousCommitSha ?? null, + summaryHtml: a.report?.summary ? renderMarkdown(a.report.summary) : '', + cost: a.report?.cost ?? null, + errors: a.report?.errors ?? (a.error ? [a.error] : []), + startedBy: a.startedByLogin, + createdAt: a.createdAt, + finishedAt: a.finishedAt, + abortRequested: a.abortRequested, + fixture: a.fixture + }, + docs: docs.map((d) => ({ name: d.name, title: KNOWLEDGE_DOCS[d.name].title, html: renderMarkdown(d.content), chars: d.content.length })), + evidence: a.runId ? runEvidence(a.runId).filter((e) => e.url && e.kind === 'screenshot') : [], + findings: a.runId ? sortBySeverity(runFindings(a.runId)) : [], + events: events.map((e) => ({ seq: e.seq, line: e.line })) + }; +}; + +export const actions: Actions = { + abort: async ({ params }) => { + const a = await loadAnalysis(params.id); + await requestAnalysisAbort(db(), a.id); + return { aborted: true }; + } +}; diff --git a/apps/web/src/routes/analyses/[id]/+page.svelte b/apps/web/src/routes/analyses/[id]/+page.svelte new file mode 100644 index 0000000..5df42ca --- /dev/null +++ b/apps/web/src/routes/analyses/[id]/+page.svelte @@ -0,0 +1,273 @@ + + +Analyse {a.repo} · Claudio + +

{a.repo}

+
+
+

{isUpdate ? 'Wissensbasis aktualisieren' : 'Repository analysieren'}

+

+ {#if a.commitSha} + Commit {a.commitSha.slice(0, 7)}{a.branch ? ` auf ${a.branch}` : ''}{#if isUpdate}, aufbauend auf {a.previousCommitSha?.slice(0, 7)}{/if} + {:else} + Instanz {a.instanceUrl} + {/if} +

+
+ {#if active} +
+ +
+ {/if} +
+ +
+
+ {#if active} +

+ {a.status === 'queued' + ? 'Wartet auf den Worker. Läuft kein Worker, starte ihn mit pnpm worker.' + : 'Claudio liest den Code und geht die App Bildschirm für Bildschirm durch. Die Dokumente erscheinen, sobald die Analyse fertig ist.'} +

+ {/if} + + {#if active} + + {/if} + + {#if isActive(data.analysis.status) || data.events.length > 0} + (liveStatus = s)} + /> + {/if} + + {#if a.summaryHtml} +
+

Zusammenfassung

+
{@html a.summaryHtml}
+
+ {/if} + + {#if data.docs.length} +
+

Wissensbasis aus diesem Lauf

+ +
+ {:else if !active} +

Diese Analyse hat keine Dokumente geschrieben.

+ {/if} + + {#if data.findings.length} +
+

Nebenbei gefundene Fehler

+

Bei der Analyse aufgefallen. Sie sind kein Ergebnis eines Reviews und blockieren nichts.

+
    + {#each data.findings as f (f.id)} +
  • + +
    + {f.title} + {CATEGORY_LABEL[f.category] ?? f.category}{f.url ? `, ${f.url}` : ''} +

    {f.description}

    +
    +
  • + {/each} +
+
+ {/if} + + {#if data.evidence.length} +
+

Screenshots

+
+ {#each data.evidence as ev (ev.id)} + + {ev.label} + {ev.id} {ev.label} + + {/each} +
+
+ {/if} + + {#if a.errors.length} +
+

Probleme während des Runs

+
    + {#each a.errors as err, i (i)}
  • {err}
  • {/each} +
+
+ {/if} +
+ + +
+ + diff --git a/apps/web/src/routes/analyses/[id]/events/+server.ts b/apps/web/src/routes/analyses/[id]/events/+server.ts new file mode 100644 index 0000000..47af09f --- /dev/null +++ b/apps/web/src/routes/analyses/[id]/events/+server.ts @@ -0,0 +1,11 @@ +import { error } from '@sveltejs/kit'; +import { getAnalysis } from '@claudio/db'; +import { db } from '#lib/server/db.js'; +import { runEventStream } from '#lib/server/sse.js'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params, url, request }) => { + const id = Number(params.id); + if (!Number.isInteger(id) || !(await getAnalysis(db(), id))) error(404, 'Diese Analyse gibt es nicht.'); + return runEventStream(request, url, { analysisId: id }, async () => (await getAnalysis(db(), id))?.status ?? null); +}; diff --git a/apps/web/src/routes/analyses/[id]/live/+server.ts b/apps/web/src/routes/analyses/[id]/live/+server.ts new file mode 100644 index 0000000..5e900bf --- /dev/null +++ b/apps/web/src/routes/analyses/[id]/live/+server.ts @@ -0,0 +1,14 @@ +import { error } from '@sveltejs/kit'; +import { getAnalysis } from '@claudio/db'; +import { db } from '#lib/server/db.js'; +import { liveViewStream } from '#lib/server/live.js'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params, request }) => { + const id = Number(params.id); + if (!Number.isInteger(id) || !(await getAnalysis(db(), id))) error(404, 'Diese Analyse gibt es nicht.'); + return liveViewStream(request, async () => { + const a = await getAnalysis(db(), id); + return a ? { status: a.status, runId: a.runId } : null; + }); +}; diff --git a/apps/web/src/routes/auth/dev/+server.ts b/apps/web/src/routes/auth/dev/+server.ts new file mode 100644 index 0000000..4d18584 --- /dev/null +++ b/apps/web/src/routes/auth/dev/+server.ts @@ -0,0 +1,15 @@ +import { error, redirect } from '@sveltejs/kit'; +import { dev } from '$app/env'; +import { upsertUser } from '@claudio/db'; +import { startSession } from '#lib/server/auth.js'; +import { db } from '#lib/server/db.js'; +import { devFixtures } from '#lib/server/env.js'; +import type { RequestHandler } from './$types'; + +/** Offline demo login without GitHub. Only on the dev server with CLAUDIO_DEV_FIXTURES=1. */ +export const POST: RequestHandler = async ({ cookies }) => { + if (!dev || !devFixtures()) error(404, 'Nicht gefunden'); + const user = await upsertUser(db(), { githubId: 0, login: 'demo', name: 'Demo (ohne GitHub)', avatarUrl: null, accessToken: '' }); + await startSession(cookies, user.id, false); + redirect(303, '/'); +}; diff --git a/apps/web/src/routes/auth/github/+server.ts b/apps/web/src/routes/auth/github/+server.ts new file mode 100644 index 0000000..60edc4f --- /dev/null +++ b/apps/web/src/routes/auth/github/+server.ts @@ -0,0 +1,25 @@ +import { randomBytes } from 'node:crypto'; +import { redirect } from '@sveltejs/kit'; +import { STATE_COOKIE, safeNext } from '#lib/server/auth.js'; +import { githubOAuthConfig, publicBaseUrl } from '#lib/server/env.js'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = ({ url, cookies }) => { + const config = githubOAuthConfig(); + if (!config) redirect(303, '/login?error=config'); + const state = randomBytes(16).toString('base64url'); + const next = safeNext(url.searchParams.get('next')); + cookies.set(STATE_COOKIE, `${state}|${next}`, { + path: '/auth', + httpOnly: true, + sameSite: 'lax', + secure: url.protocol === 'https:', + maxAge: 600 + }); + const authorize = new URL('https://github.com/login/oauth/authorize'); + authorize.searchParams.set('client_id', config.clientId); + authorize.searchParams.set('redirect_uri', `${publicBaseUrl(url)}/auth/github/callback`); + authorize.searchParams.set('scope', 'repo read:user read:org'); + authorize.searchParams.set('state', state); + redirect(303, authorize.toString(), { external: true }); +}; diff --git a/apps/web/src/routes/auth/github/callback/+server.ts b/apps/web/src/routes/auth/github/callback/+server.ts new file mode 100644 index 0000000..bb4cbcb --- /dev/null +++ b/apps/web/src/routes/auth/github/callback/+server.ts @@ -0,0 +1,34 @@ +import { redirect } from '@sveltejs/kit'; +import { upsertUser } from '@claudio/db'; +import { STATE_COOKIE, isAllowed, safeNext, startSession } from '#lib/server/auth.js'; +import { db } from '#lib/server/db.js'; +import { githubOAuthConfig, publicBaseUrl } from '#lib/server/env.js'; +import { exchangeCode, fetchGithubUser } from '#lib/server/github.js'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ url, cookies }) => { + const config = githubOAuthConfig(); + if (!config) redirect(303, '/login?error=config'); + + const [expected, next] = (cookies.get(STATE_COOKIE) ?? '').split('|'); + cookies.delete(STATE_COOKIE, { path: '/auth' }); + const code = url.searchParams.get('code'); + if (url.searchParams.get('error') === 'access_denied') redirect(303, '/login?error=denied'); + if (!code || !expected || url.searchParams.get('state') !== expected) redirect(303, '/login?error=state'); + + let target = '/login?error=github'; + try { + const token = await exchangeCode(config.clientId, config.clientSecret, code, `${publicBaseUrl(url)}/auth/github/callback`); + const profile = await fetchGithubUser(token); + if (!(await isAllowed(profile.login, token))) { + target = '/login?error=forbidden'; + } else { + const user = await upsertUser(db(), { ...profile, accessToken: token }); + await startSession(cookies, user.id, url.protocol === 'https:'); + target = safeNext(next ?? null); + } + } catch (e) { + console.error('GitHub login failed', e); + } + redirect(303, target); +}; diff --git a/apps/web/src/routes/auth/logout/+server.ts b/apps/web/src/routes/auth/logout/+server.ts new file mode 100644 index 0000000..6803125 --- /dev/null +++ b/apps/web/src/routes/auth/logout/+server.ts @@ -0,0 +1,8 @@ +import { redirect } from '@sveltejs/kit'; +import { endSession } from '#lib/server/auth.js'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ cookies }) => { + await endSession(cookies); + redirect(303, '/login'); +}; diff --git a/apps/web/src/routes/login/+page.server.ts b/apps/web/src/routes/login/+page.server.ts new file mode 100644 index 0000000..e8814a1 --- /dev/null +++ b/apps/web/src/routes/login/+page.server.ts @@ -0,0 +1,25 @@ +import { redirect } from '@sveltejs/kit'; +import { dev } from '$app/env'; +import { safeNext } from '#lib/server/auth.js'; +import { devFixtures, githubOAuthConfig } from '#lib/server/env.js'; +import type { PageServerLoad } from './$types'; + +const ERRORS: Record = { + config: 'Der GitHub-Login ist noch nicht eingerichtet. Trage GITHUB_CLIENT_ID und GITHUB_CLIENT_SECRET in die .env ein und starte Claudio neu.', + state: 'Die Anmeldung ist abgelaufen oder wurde unterbrochen. Starte sie bitte noch einmal.', + denied: 'Du hast den Zugriff bei GitHub abgelehnt. Ohne Freigabe kann Claudio deine Repositories nicht laden.', + forbidden: 'Dein GitHub-Konto ist für Claudio nicht freigeschaltet. Wende dich an die Person, die Claudio betreibt.', + github: 'GitHub hat die Anmeldung nicht bestätigt. Versuche es erneut.' +}; + +export const load: PageServerLoad = ({ locals, url }) => { + const next = safeNext(url.searchParams.get('next')); + if (locals.user) redirect(303, next); + const code = url.searchParams.get('error'); + return { + next, + error: code ? (ERRORS[code] ?? ERRORS.github) : null, + configured: githubOAuthConfig() !== null, + devLogin: dev && devFixtures() + }; +}; diff --git a/apps/web/src/routes/login/+page.svelte b/apps/web/src/routes/login/+page.svelte new file mode 100644 index 0000000..2a31c24 --- /dev/null +++ b/apps/web/src/routes/login/+page.svelte @@ -0,0 +1,99 @@ + + +Anmelden · Claudio + +
+
+
+ + Claudio +
+

Pull Requests abnehmen lassen, bevor sie jemand von Hand prüft.

+

+ Claudio liest PR und Ticket, testet die laufende Instanz im Browser und schreibt ein Prüfprotokoll mit Belegen. +

+ + {#if data.error} + + {/if} + + + + Mit GitHub anmelden + + {#if !data.configured} +

+ GitHub-Login ist noch nicht eingerichtet: Lege eine OAuth App an und trage Client-ID und Secret in die + .env ein (siehe README). +

+ {/if} + + {#if data.devLogin} +
+ +

Nur auf dem Entwicklungsserver mit CLAUDIO_DEV_FIXTURES=1.

+
+ {/if} +
+
+ + diff --git a/apps/web/src/routes/repos/+page.server.ts b/apps/web/src/routes/repos/+page.server.ts new file mode 100644 index 0000000..91f3fd3 --- /dev/null +++ b/apps/web/src/routes/repos/+page.server.ts @@ -0,0 +1,62 @@ +import { fail, redirect } from '@sveltejs/kit'; +import { addRepo, listRepos, listReviews } from '@claudio/db'; +import { userToken } from '#lib/server/auth.js'; +import { db } from '#lib/server/db.js'; +import { createDemoRepo, DEMO_REPO } from '#lib/server/demo.js'; +import { devFixtures } from '#lib/server/env.js'; +import { getRepoInfo, githubErrorMessage, listUserRepos, type GithubRepo } from '#lib/server/github.js'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ locals }) => { + const [repos, reviews] = await Promise.all([listRepos(db()), listReviews(db(), { limit: 500 })]); + const lastReview = new Map(); + for (const r of reviews) if (!lastReview.has(r.repoId)) lastReview.set(r.repoId, r.createdAt); + + const token = await userToken(locals.user!); + let github: GithubRepo[] = []; + let githubError: string | null = null; + if (token) { + try { + github = await listUserRepos(token); + } catch (e) { + githubError = githubErrorMessage(e); + } + } else { + githubError = 'Du bist ohne GitHub angemeldet. Melde dich mit GitHub an, um deine Repositories zu laden.'; + } + + const added = new Set(repos.map((r) => r.fullName)); + return { + repos: repos.map((r) => ({ + fullName: r.fullName, + isPrivate: r.isPrivate, + dataConfirmed: r.dataConfirmed, + lastReview: lastReview.get(r.id) ?? null + })), + github: github.map((g) => ({ ...g, added: added.has(g.fullName) })), + githubError, + demoAvailable: devFixtures() && !added.has(DEMO_REPO) + }; +}; + +export const actions: Actions = { + add: async ({ request, locals }) => { + const fullName = String((await request.formData()).get('fullName') ?? '').trim(); + if (!/^[\w.-]+\/[\w.-]+$/.test(fullName)) return fail(400, { error: 'Gib das Repository als besitzer/name an.' }); + const token = await userToken(locals.user!); + if (!token) return fail(400, { error: 'Zum Hinzufügen brauchst du eine Anmeldung mit GitHub.' }); + let info; + try { + info = await getRepoInfo(token, fullName); + } catch (e) { + return fail(400, { error: githubErrorMessage(e) }); + } + await addRepo(db(), { ...info, createdBy: locals.user!.id }); + redirect(303, `/repos/${info.fullName}?tab=settings&new=1`); + }, + demo: async ({ locals }) => { + if (!devFixtures()) return fail(404, { error: 'Die Demo ist nicht aktiviert.' }); + await createDemoRepo(db(), locals.user!.id); + redirect(303, `/repos/${DEMO_REPO}`); + } +}; diff --git a/apps/web/src/routes/repos/+page.svelte b/apps/web/src/routes/repos/+page.svelte new file mode 100644 index 0000000..790a470 --- /dev/null +++ b/apps/web/src/routes/repos/+page.svelte @@ -0,0 +1,157 @@ + + +Repositories · Claudio + +

Repositories

+

Claudio prüft Pull Requests der Repositories, die hier eingerichtet sind.

+ +{#if form?.error} + +{/if} + +
+

Eingerichtet

+ {#if data.repos.length > 0} +
    + {#each data.repos as repo (repo.fullName)} +
  • + {repo.fullName} + + {repo.lastReview ? `Letzter Review ${formatDate(repo.lastReview)}` : 'Noch kein Review'} + + {#if !repo.dataConfirmed} + Einrichtung abschließen + {/if} +
  • + {/each} +
+ {:else} +

Noch keins. Wähle unten ein Repository aus deinem GitHub-Konto.

+ {/if} + {#if data.demoAvailable} +
+ + Die Dispo-Demo aus apps/demo-target mit fertigem Beispiel-PR, ganz ohne GitHub. +
+ {/if} +
+ +
+
+

Aus GitHub hinzufügen

+ {#if data.github.length > 0} + + {/if} +
+ + {#if data.githubError} +

{data.githubError}

+ {:else if filtered.length === 0} +

Kein Repository passt zu „{query}“.

+ {:else} +
    + {#each filtered as repo (repo.fullName)} +
  • +
    + {repo.fullName} + {#if repo.isPrivate}privat{/if} + {#if repo.description}

    {repo.description}

    {/if} +
    + {#if repo.added} + Öffnen + {:else} +
    + + +
    + {/if} +
  • + {/each} +
+ {#if data.github.length > filtered.length && !query} +

Die {filtered.length} zuletzt geänderten von {data.github.length} Repositories. Suche, um weitere zu finden.

+ {/if} + {/if} +
+ + diff --git a/apps/web/src/routes/repos/[owner]/[name]/+page.server.ts b/apps/web/src/routes/repos/[owner]/[name]/+page.server.ts new file mode 100644 index 0000000..ab800b4 --- /dev/null +++ b/apps/web/src/routes/repos/[owner]/[name]/+page.server.ts @@ -0,0 +1,281 @@ +import { error, fail, redirect } from '@sveltejs/kit'; +import { z } from 'zod'; +import { randomUUID } from 'node:crypto'; +import { AnalysisRequestSchema, KNOWLEDGE_DOCS, LoginStepSchema, TestRunRequestSchema } from '@claudio/core'; +import { + createAnalysis, + createTestRuns, + createTestScript, + deleteRole, + dismissedFindingIds, + getRepo, + latestKnowledge, + listAnalyses, + listReviews, + listRoles, + listTestRuns, + listTestScripts, + testResultsByReview, + removeRepo, + roleUsername, + saveRole, + updateRepoSettings +} from '@claudio/db'; +import { userToken } from '#lib/server/auth.js'; +import { db } from '#lib/server/db.js'; +import { DEMO_FIXTURE, DEMO_REPO, demoPullRequest } from '#lib/server/demo.js'; +import { createGithubClient, getBranchHead } from '@claudio/github'; +import { renderMarkdown } from '#lib/markdown.js'; +import { devFixtures } from '#lib/server/env.js'; +import { githubErrorMessage, listOpenPulls, type GithubPull } from '#lib/server/github.js'; +import { summarize } from '#lib/server/summary.js'; +import type { Actions, PageServerLoad } from './$types'; + +async function loadRepo(params: { owner: string; name: string }) { + const repo = await getRepo(db(), `${params.owner}/${params.name}`); + if (!repo) error(404, 'Dieses Repository ist in Claudio nicht eingerichtet.'); + return repo; +} + +export const load: PageServerLoad = async ({ params, locals, url }) => { + const repo = await loadRepo(params); + const [roles, reviews] = await Promise.all([listRoles(db(), repo.id), listReviews(db(), { repoId: repo.id, limit: 100 })]); + const [dismissed, reviewTests] = await Promise.all([ + dismissedFindingIds(db(), reviews.map((r) => r.id)), + testResultsByReview(db(), reviews.map((r) => r.id)) + ]); + + let pulls: (GithubPull & { fixture?: boolean })[] = []; + let pullsError: string | null = null; + if (repo.fullName === DEMO_REPO && devFixtures()) { + const pr = demoPullRequest(); + pulls = [ + { number: pr.number, title: pr.title, author: pr.author ?? null, headRef: pr.headRef, draft: false, updatedAt: new Date().toISOString(), url: pr.url, fixture: true } + ]; + } else { + const token = await userToken(locals.user!); + if (!token) pullsError = 'Ohne GitHub-Anmeldung kann Claudio keine Pull Requests laden.'; + else + try { + pulls = await listOpenPulls(token, repo.fullName); + } catch (e) { + pullsError = githubErrorMessage(e); + } + } + + const isDemo = repo.fullName === DEMO_REPO && devFixtures(); + const [knowledge, analyses] = await Promise.all([latestKnowledge(db(), repo.id), listAnalyses(db(), { repoId: repo.id, limit: 20 })]); + + // Is the knowledge base older than the head of the analysed branch? + let headSha: string | null = null; + if (knowledge && !isDemo && knowledge.branch) { + const token = await userToken(locals.user!); + if (token) headSha = await getBranchHead(createGithubClient(token), repo.fullName, knowledge.branch).catch(() => null); + } + + const batchId = url.searchParams.get('batch'); + const [tests, activeTestRuns, batchRuns] = await Promise.all([ + listTestScripts(db(), repo.id), + listTestRuns(db(), { repoId: repo.id, activeOnly: true }, 100), + batchId ? listTestRuns(db(), { batchId, repoId: repo.id }, 200) : Promise.resolve([]) + ]); + + const tab = url.searchParams.get('tab'); + return { + repo: { + fullName: repo.fullName, + isPrivate: repo.isPrivate, + notes: repo.notes ?? '', + extraAllowedHosts: repo.extraAllowedHosts, + dataConfirmed: repo.dataConfirmed, + defaultInstanceUrl: repo.defaultInstanceUrl ?? '' + }, + roles: roles.map((r) => ({ + name: r.name, + description: r.description ?? '', + username: roleUsername(r), + loginPath: r.loginPath, + successUrlContains: r.successUrlContains ?? '', + recipe: r.recipe ? JSON.stringify(r.recipe, null, 2) : '' + })), + reviews: reviews.map((r) => summarize(r, dismissed.get(r.id), undefined, reviewTests.get(r.id))), + pulls, + pullsError, + knowledge: knowledge + ? { + analysisId: knowledge.analysisId, + commitSha: knowledge.commitSha, + branch: knowledge.branch, + analyzedAt: knowledge.analyzedAt, + startedBy: knowledge.startedByLogin, + complete: knowledge.status === 'completed', + stale: headSha !== null && headSha !== knowledge.commitSha, + headSha, + docs: knowledge.docs.map((d) => ({ name: d.name, title: KNOWLEDGE_DOCS[d.name].title, html: renderMarkdown(d.content), chars: d.content.length })) + } + : null, + analyses: analyses.map((a) => ({ + id: a.id, + status: a.status, + commitSha: a.commitSha, + costUsd: a.costUsd, + docCount: a.docCount, + startedBy: a.startedByLogin, + createdAt: a.createdAt + })), + tests: tests.map((t) => ({ + id: t.id, + name: t.name, + determinism: t.determinism, + stepCount: t.steps?.length ?? 0, + runInReviews: t.runInReviews, + lastOutcome: t.lastOutcome, + lastStatus: t.lastStatus, + lastRunAt: t.lastRunAt, + lastHealed: t.lastHealed + })), + activeTestRuns: activeTestRuns.length, + batch: batchId + ? batchRuns.map((r) => ({ id: r.id, scriptName: r.scriptName, status: r.status, outcome: r.outcome, healed: r.healed, mode: r.mode, costUsd: r.costUsd })) + : null, + tab: tab === 'settings' || tab === 'reviews' || tab === 'knowledge' || tab === 'tests' ? tab : 'pulls', + isNew: url.searchParams.has('new') + }; +}; + +const text = (v: FormDataEntryValue | null) => String(v ?? '').trim(); + +const RoleForm = z.object({ + name: z.string().regex(/^[a-z][a-z0-9_-]{0,39}$/, 'Rollenname: Kleinbuchstaben, Ziffern, - oder _, beginnt mit einem Buchstaben.'), + description: z.string().max(300), + username: z.string().max(300), + password: z.string().max(300), + loginPath: z.string().regex(/^\//, 'Der Login-Pfad beginnt mit /.'), + successUrlContains: z.string().max(300), + recipe: z.string() +}); + +export const actions: Actions = { + settings: async ({ params, request }) => { + const repo = await loadRepo(params); + const f = await request.formData(); + const defaultInstanceUrl = text(f.get('defaultInstanceUrl')); + if (defaultInstanceUrl && !URL.canParse(defaultInstanceUrl)) return fail(400, { error: 'Die Standard-URL ist keine gültige Adresse.', form: 'settings' }); + const hosts = text(f.get('extraAllowedHosts')) + .split(/[\s,]+/) + .map((h) => h.trim()) + .filter(Boolean); + await updateRepoSettings(db(), repo.id, { + notes: text(f.get('notes')) || null, + extraAllowedHosts: hosts, + dataConfirmed: f.get('dataConfirmed') === 'on', + defaultInstanceUrl: defaultInstanceUrl || null + }); + return { saved: 'settings' }; + }, + + role: async ({ params, request }) => { + const repo = await loadRepo(params); + const f = await request.formData(); + const parsed = RoleForm.safeParse(Object.fromEntries(['name', 'description', 'username', 'password', 'loginPath', 'successUrlContains', 'recipe'].map((k) => [k, text(f.get(k))]))); + if (!parsed.success) return fail(400, { error: parsed.error.issues[0]?.message ?? 'Eingaben prüfen.', form: 'role' }); + const v = parsed.data; + let recipe = null; + if (v.recipe) { + try { + recipe = z.array(LoginStepSchema).parse(JSON.parse(v.recipe)); + } catch { + return fail(400, { error: 'Das Login-Rezept ist kein gültiges JSON-Array aus Login-Schritten.', form: 'role' }); + } + } + try { + await saveRole(db(), repo.id, { + name: v.name, + description: v.description || null, + username: v.username || undefined, + password: v.password || undefined, + loginPath: v.loginPath, + successUrlContains: v.successUrlContains || null, + recipe + }); + } catch (e) { + return fail(400, { error: (e as Error).message, form: 'role' }); + } + return { saved: `role:${v.name}` }; + }, + + deleteRole: async ({ params, request }) => { + const repo = await loadRepo(params); + await deleteRole(db(), repo.id, text((await request.formData()).get('name'))); + return { saved: 'deleted' }; + }, + + analyze: async ({ params, request, locals }) => { + const repo = await loadRepo(params); + const roles = await listRoles(db(), repo.id); + if (!repo.dataConfirmed || roles.length === 0) return fail(400, { error: 'Richte zuerst Rollen und die Datenschutz-Bestätigung ein.', form: 'analyze' }); + if ((await listAnalyses(db(), { repoId: repo.id, activeOnly: true })).length > 0) { + return fail(400, { error: 'Für dieses Repository läuft schon eine Analyse.', form: 'analyze' }); + } + const f = await request.formData(); + const fixture = repo.fullName === DEMO_REPO && devFixtures() ? DEMO_FIXTURE : null; + const parsed = AnalysisRequestSchema.safeParse({ + repo: repo.fullName, + instanceUrl: text(f.get('instanceUrl')), + roles: f.getAll('roles').map(String), + budget: f.get('budget'), + language: 'de', + extraInstructions: text(f.get('extraInstructions')) || undefined + }); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + return fail(400, { error: issue?.path[0] === 'instanceUrl' ? 'Gib die vollständige Adresse einer laufenden Instanz an.' : (issue?.message ?? 'Eingaben prüfen.'), form: 'analyze' }); + } + if (!fixture && !(await userToken(locals.user!))) return fail(400, { error: 'Zum Lesen des Codes brauchst du eine Anmeldung mit GitHub.', form: 'analyze' }); + const id = await createAnalysis(db(), { repoId: repo.id, request: parsed.data, fixture, startedBy: locals.user!.id }); + redirect(303, `/analyses/${id}`); + }, + + createTest: async ({ params, request, locals }) => { + const repo = await loadRepo(params); + const f = await request.formData(); + const input = { + name: text(f.get('name')), + description: text(f.get('description')), + expectation: text(f.get('expectation')), + roles: f.getAll('roles').map(String), + runInReviews: f.get('runInReviews') === 'on' + }; + if (!input.name || !input.description || !input.expectation) { + return fail(400, { error: 'Name, Ablauf und erwartetes Ergebnis sind Pflicht.', form: 'createTest' }); + } + const id = await createTestScript(db(), repo.id, input, locals.user!.id); + redirect(303, `/tests/${id}?new=1`); + }, + + runTests: async ({ params, request, locals }) => { + const repo = await loadRepo(params); + if (!repo.dataConfirmed) return fail(400, { error: 'Bestätige zuerst in den Einstellungen, dass die Instanz nur Testdaten enthält.', form: 'runTests' }); + const f = await request.formData(); + const parsed = TestRunRequestSchema.safeParse({ repo: repo.fullName, instanceUrl: text(f.get('instanceUrl')), budget: f.get('budget') }); + if (!parsed.success) return fail(400, { error: 'Gib die vollständige Adresse einer laufenden Instanz an.', form: 'runTests' }); + const scripts = await listTestScripts(db(), repo.id); + if (scripts.length === 0) return fail(400, { error: 'Es gibt noch keine Tests.', form: 'runTests' }); + const batchId = randomUUID().slice(0, 8); + await createTestRuns( + db(), + scripts.map((s) => ({ scriptId: s.id, repoId: repo.id, request: parsed.data, batchId, startedBy: locals.user!.id })) + ); + redirect(303, `/repos/${repo.fullName}?tab=tests&batch=${batchId}`); + }, + + remove: async ({ params }) => { + const repo = await loadRepo(params); + const [reviews, analyses] = await Promise.all([listReviews(db(), { repoId: repo.id, limit: 1 }), listAnalyses(db(), { repoId: repo.id, limit: 1 })]); + if (reviews.length > 0 || analyses.length > 0) { + return fail(400, { error: 'Repositories mit Reviews oder Analysen bleiben erhalten, damit Prüfprotokolle und Wissensbasis nicht verloren gehen.', form: 'remove' }); + } + await removeRepo(db(), repo.id); + redirect(303, '/repos'); + } +}; diff --git a/apps/web/src/routes/repos/[owner]/[name]/+page.svelte b/apps/web/src/routes/repos/[owner]/[name]/+page.svelte new file mode 100644 index 0000000..d556fb5 --- /dev/null +++ b/apps/web/src/routes/repos/[owner]/[name]/+page.svelte @@ -0,0 +1,676 @@ + + +{data.repo.fullName} · Claudio + +

Repositories

+

{data.repo.fullName}

+ + + +{#if data.tab === 'pulls'} + {#if !ready} +

+ Bevor Claudio Pull Requests prüfen kann, fehlen noch + {#if !data.repo.dataConfirmed && data.roles.length === 0}die Datenschutz-Bestätigung und mindestens eine Rolle{:else if !data.repo.dataConfirmed}die Datenschutz-Bestätigung{:else}Zugangsdaten für mindestens eine Rolle{/if}. + Einstellungen öffnen +

+ {/if} + {#if data.pullsError} +

{data.pullsError}

+ {:else if data.pulls.length === 0} +

Keine offenen Pull Requests.

+ {:else} +
    + {#each data.pulls as pr (pr.number)} +
  • +
    + #{pr.number} {pr.title} + + {pr.headRef}{pr.author ? `, von ${pr.author}` : ''}, aktualisiert {formatDate(pr.updatedAt)} + {#if pr.draft}Entwurf{/if} + {#if pr.fixture}Demo-Fixture{/if} + +
    + {#if ready} + Review starten + {/if} +
  • + {/each} +
+ {/if} +{:else if data.tab === 'tests'} + {#if data.batch} + {@const done = data.batch.filter((r) => !isActive(r.status)).length} +
+

Suite-Lauf

+

+ {done} von {data.batch.length} Tests fertig{#if done === data.batch.length}: + {data.batch.filter((r) => r.outcome === 'passed').length} bestanden, + {data.batch.filter((r) => r.outcome === 'failed').length} fehlgeschlagen{/if}. +

+
+ + + + {#each data.batch as run (run.id)} + + + + + + + {/each} + +
TestErgebnisArtKosten
{run.scriptName}{run.mode ? MODE_LABEL[run.mode] : '–'}{formatUsd(run.costUsd)}
+
+
+ {/if} + + {#if data.tests.length} +
+ + + + + + {#each data.tests as test (test.id)} + + + + + + + + {/each} + +
TestArtSchritteLetzter LaufBei Reviews
{test.name}{test.determinism ? DETERMINISM_LABEL[test.determinism] : 'Noch nicht aufgezeichnet'}{test.stepCount || '–'} + {#if test.lastStatus} + + {formatDate(test.lastRunAt)} + {:else}–{/if} + {test.runInReviews ? 'Ja' : 'Nein'}
+
+ {:else} +
+

Noch keine Tests

+

+ Beschreibe einen Ablauf und was am Ende stimmen muss. Beim ersten Lauf führt Claudio ihn im Browser aus und zeichnet die Schritte + auf. Danach laufen sie ohne Modell in Sekunden, auch automatisch bei jedem PR-Review. +

+
+ {/if} + + {#if data.tests.length && !data.activeTestRuns} +
+ Alle Tests ausführen +
(runningTests = true)}> + {#if formError('runTests')}{/if} + + +
+ +
+
+
+ {:else if data.activeTestRuns} +

{data.activeTestRuns} Testläufe warten oder laufen gerade.

+ {/if} + +
+ Neuer Test +
+ {#if formError('createTest')}{/if} + + + +
+ Rollen +
+ {#each data.roles as role (role.name)} + + {/each} +
+
+ +
+
+
+{:else if data.tab === 'knowledge'} + {#if runningAnalysis} +

+ Eine Analyse {runningAnalysis.status === 'queued' ? 'wartet auf den Worker' : 'läuft gerade'}. + Verlauf ansehen +

+ {/if} + + {#if data.knowledge} +
+

+ Stand: Commit {data.knowledge.commitSha.slice(0, 7)}{data.knowledge.branch ? ` auf ${data.knowledge.branch}` : ''}, + analysiert {formatDate(data.knowledge.analyzedAt)} von {data.knowledge.startedBy}. + Lauf ansehen +

+ {#if data.knowledge.stale} +

+ Seit der Analyse hat sich {data.knowledge.branch} geändert (jetzt {data.knowledge.headSha?.slice(0, 7)}). Aktualisiere die + Wissensbasis, damit Reviews vom aktuellen Stand ausgehen. Ein Update prüft nur, was sich geändert hat. +

+ {/if} + {#if !data.knowledge.complete} +

Die letzte Analyse wurde nicht vollständig abgeschlossen. Die Dokumente können Lücken haben.

+ {/if} +
+ +

Jeder Review dieses Repositorys bekommt diese Wissensbasis mit.

+ {:else if !runningAnalysis} +
+

Noch keine Wissensbasis

+

+ Bei einer Analyse liest Claudio den Code und geht die laufende App Bildschirm für Bildschirm durch. Heraus kommen fünf Dokumente: + Überblick, Module, Routen, App-Karte mit dem erwarteten Verhalten jedes Bildschirms und Notizen. Reviews nutzen sie, um betroffene + Bereiche schneller zu finden und Abweichungen vom bisherigen Verhalten zu erkennen. +

+
+ {/if} + + {#if !runningAnalysis} + {#if !ready} +

+ Für eine Analyse braucht Claudio Rollen und die Datenschutz-Bestätigung. Einstellungen öffnen +

+ {:else} +
+ {data.knowledge ? 'Wissensbasis aktualisieren' : 'Analyse starten'} +
(analyzing = true)}> + {#if formError('analyze')}{/if} + +
+ Rollen +
+ {#each data.roles as role (role.name)} + + {/each} +
+ Claudio schaut sich die App mit jeder gewählten Rolle an. +
+
+ +
+ +
+ +
+
+
+ {/if} + {/if} + + {#if data.analyses.length} +
+

Bisherige Analysen

+
+ + + + + + {#each data.analyses as an (an.id)} + + + + + + + + {/each} + +
GestartetStatusCommitDokumenteKosten
{formatDate(an.createdAt)} von {an.startedBy}{#if an.commitSha}{an.commitSha.slice(0, 7)}{:else}–{/if}{an.docCount || '–'}{formatUsd(an.costUsd)}
+
+
+ {/if} +{:else if data.tab === 'reviews'} + {#if data.reviews.length} +
+ {:else} +

Für dieses Repository gibt es noch keine Reviews.

+ {/if} +{:else} + {#if data.isNew} +

Repository hinzugefügt. Lege jetzt fest, als wer Claudio sich in der App anmeldet.

+ {/if} + +
+
+

Allgemein

+

Hinweise für den Agenten und wo die Instanzen der Pull Requests laufen.

+
+
({ update }) => update({ reset: false })} class="sheet fields"> + {#if formError('settings')}{/if} + + + + +
+ + {#if saved === 'settings'}Gespeichert{/if} +
+
+
+ +
+
+

Rollen

+

+ Benutzerkonten, mit denen Claudio sich anmeldet. Die Zugangsdaten werden verschlüsselt gespeichert und nie an das Modell + gegeben. +

+
+
+ {#if formError('role')}{/if} + {#each data.roles as role (role.name)} +
+ + {role.name} + {role.description || 'ohne Beschreibung'}, {role.username} + {#if saved === `role:${role.name}`}Gespeichert{/if} + + {@render roleForm(role)} +
+ + +
+
+ {/each} +
+ Neue Rolle + {@render roleForm(null)} +
+
+
+ +
+
+

Entfernen

+

Nimmt das Repository aus Claudio heraus. Auf GitHub ändert sich nichts.

+
+
+ {#if formError('remove')}{/if} +
+
+
+{/if} + +{#snippet roleForm(role: (typeof data.roles)[number] | null)} +
({ update }) => update({ reset: !role })} class="fields"> +
+ + + + + + +
+ +
+
+{/snippet} + + diff --git a/apps/web/src/routes/reviews/[id]/+page.server.ts b/apps/web/src/routes/reviews/[id]/+page.server.ts new file mode 100644 index 0000000..66ea1d9 --- /dev/null +++ b/apps/web/src/routes/reviews/[id]/+page.server.ts @@ -0,0 +1,125 @@ +import { error, fail } from '@sveltejs/kit'; +import { StrictnessSchema, blockingFindings, renderPrComment, sortBySeverity, verdict, withTests, type ReviewTestResult } from '@claudio/core'; +import { FINDING_STATUSES, eventsSince, getReview, listFindings, listTestRuns, rateFinding, requestAbort, setReviewCommentUrl } from '@claudio/db'; +import { createGithubClient, postPullRequestComment } from '@claudio/github'; +import { userToken } from '#lib/server/auth.js'; +import { db } from '#lib/server/db.js'; +import { publicBaseUrl } from '#lib/server/env.js'; +import { runFileUrl } from '#lib/server/run-files.js'; +import { renderMarkdown } from '#lib/markdown.js'; +import type { Actions, PageServerLoad } from './$types'; + +async function loadReview(idParam: string) { + const id = Number(idParam); + const review = Number.isInteger(id) ? await getReview(db(), id) : undefined; + if (!review) error(404, 'Diesen Review gibt es nicht.'); + return review; +} + +const dismissedOf = (rows: Awaited>) => + rows.filter((r) => r.finding.status === 'dismissed').map((r) => r.finding.findingId); + +export const load: PageServerLoad = async ({ params, url }) => { + const review = await loadReview(params.id); + const [rows, events, testRuns] = await Promise.all([ + listFindings(db(), review.id), + eventsSince(db(), { reviewId: review.id }, 0, 5000), + listTestRuns(db(), { reviewId: review.id }, 100) + ]); + const tests: ReviewTestResult[] = testRuns.map((t) => ({ name: t.scriptName, outcome: t.outcome })); + const report = review.report; + + const ratings = new Map(rows.map((r) => [r.finding.findingId, { status: r.finding.status, comment: r.finding.comment ?? '', by: r.ratedByLogin, at: r.finding.ratedAt }])); + const dismissed = dismissedOf(rows); + const requested = StrictnessSchema.safeParse(url.searchParams.get('strictness')); + const strictness = requested.success ? requested.data : (report?.request.strictness ?? review.request.strictness); + const blocking = report ? new Set(blockingFindings(report, strictness, dismissed).map((f) => f.id)) : new Set(); + + const fileUrl = (path: string) => (review.runId ? runFileUrl(review.runId, path) : null); + + return { + review: { + id: review.id, + repo: review.repoFullName, + prNumber: review.prNumber, + prTitle: review.prTitle, + status: review.status, + instanceUrl: review.instanceUrl, + request: review.request, + startedBy: review.startedByLogin, + createdAt: review.createdAt, + startedAt: review.startedAt, + finishedAt: review.finishedAt, + error: review.error, + abortRequested: review.abortRequested, + fixture: review.fixture, + commentUrl: review.commentUrl + }, + commentPreview: report + ? renderMarkdown(renderPrComment(report, `${publicBaseUrl(url)}/reviews/${review.id}`, { strictness, dismissedIds: dismissed, tests })) + : null, + strictness, + report: report + ? { + pr: report.pr, + issues: report.issues, + summaryHtml: report.summary ? renderMarkdown(report.summary) : '', + verdict: withTests(verdict(report, strictness, dismissed), tests), + acceptanceCriteria: report.acceptanceCriteria, + findings: sortBySeverity(report.findings).map((f) => ({ + ...f, + blocking: blocking.has(f.id), + rating: ratings.get(f.id) ?? { status: 'proposed' as const, comment: '', by: null, at: null } + })), + evidence: Object.fromEntries( + report.evidence.map((e) => [e.id, { ...e, url: e.path ? fileUrl(e.path) : null }]) + ), + cost: report.cost, + errors: report.errors, + files: { trace: fileUrl('trace.zip') } + } + : null, + events: events.map((e) => ({ seq: e.seq, line: e.line })), + tests: testRuns + .map((t) => ({ id: t.id, scriptId: t.scriptId, name: t.scriptName, status: t.status, outcome: t.outcome, healed: t.healed, mode: t.mode, summary: t.report?.summary ?? '' })) + .reverse() + }; +}; + +export const actions: Actions = { + rate: async ({ params, request, locals }) => { + const review = await loadReview(params.id); + const f = await request.formData(); + const findingId = String(f.get('findingId') ?? ''); + const status = String(f.get('status') ?? ''); + if (!(FINDING_STATUSES as readonly string[]).includes(status)) return fail(400, { error: 'Unbekannte Bewertung.' }); + if (!review.report?.findings.some((x) => x.id === findingId)) return fail(400, { error: 'Unbekanntes Finding.' }); + const comment = String(f.get('comment') ?? '').trim().slice(0, 2000) || null; + await rateFinding(db(), review.id, findingId, { status: status as (typeof FINDING_STATUSES)[number], comment, userId: locals.user!.id }); + return { rated: findingId }; + }, + comment: async ({ params, request, locals, url }) => { + const review = await loadReview(params.id); + if (!review.report) return fail(400, { error: 'Dieser Review hat keinen Bericht.', form: 'comment' }); + if (review.fixture) return fail(400, { error: 'Der Demo-PR existiert nicht auf GitHub, deshalb gibt es dort nichts zu kommentieren.', form: 'comment' }); + const token = await userToken(locals.user!); + if (!token) return fail(400, { error: 'Zum Kommentieren brauchst du eine Anmeldung mit GitHub.', form: 'comment' }); + const requested = StrictnessSchema.safeParse((await request.formData()).get('strictness')); + const strictness = requested.success ? requested.data : review.report.request.strictness; + const dismissed = dismissedOf(await listFindings(db(), review.id)); + const tests = (await listTestRuns(db(), { reviewId: review.id }, 100)).map((t) => ({ name: t.scriptName, outcome: t.outcome })); + const body = renderPrComment(review.report, `${publicBaseUrl(url)}/reviews/${review.id}`, { strictness, dismissedIds: dismissed, tests }); + try { + const commentUrl = await postPullRequestComment(createGithubClient(token), review.repoFullName, review.prNumber, body); + await setReviewCommentUrl(db(), review.id, commentUrl); + return { commented: commentUrl }; + } catch (e) { + return fail(400, { error: `GitHub hat den Kommentar abgelehnt: ${(e as Error).message}`, form: 'comment' }); + } + }, + abort: async ({ params }) => { + const review = await loadReview(params.id); + await requestAbort(db(), review.id); + return { aborted: true }; + } +}; diff --git a/apps/web/src/routes/reviews/[id]/+page.svelte b/apps/web/src/routes/reviews/[id]/+page.svelte new file mode 100644 index 0000000..abf0796 --- /dev/null +++ b/apps/web/src/routes/reviews/[id]/+page.svelte @@ -0,0 +1,761 @@ + + +#{review.prNumber} {review.prTitle ?? ''} · Claudio + +

{review.repo}

+
+

#{review.prNumber} {review.prTitle ?? ''}

+ {#if active} +
+ +
+ {/if} +
+ +{#if report} + +
+
+

{VERDICT_LABEL[report.verdict]}

+

+ {acPassed} von {report.acceptanceCriteria.length} Akzeptanzkriterien erfüllt, + {counts.blocking === 0 ? 'kein blockierendes Finding' : counts.blocking === 1 ? 'ein blockierendes Finding' : `${counts.blocking} blockierende Findings`}{#if data.tests.length}, + {testsActive ? `Regressionstests laufen (${testsPassed} von ${data.tests.length} bestanden)` : `${testsPassed} von ${data.tests.length} Regressionstests bestanden`}{/if}{#if review.status !== 'completed'}, Run: {STATUS_LABEL[review.status]}{/if}. +

+
+
+ + +
+
+{:else} +
+ +

+ {#if review.status === 'queued'} + Wartet auf den Worker. Läuft kein Worker, starte ihn mit pnpm worker. + {:else if review.status === 'running'} + Claudio prüft gerade {review.instanceUrl}. + {:else} + Dieser Review hat keinen Bericht erzeugt. + {/if} +

+
+{/if} + +{#if review.error && !report} +

{review.error}

+{/if} + +
+
+ {#if active} + + {/if} + + {#if isActive(data.review.status) || data.events.length > 0} + (liveStatus = s)} + /> + {/if} + + {#if report} + {#if report.summaryHtml} +
+

Zusammenfassung

+
{@html report.summaryHtml}
+
+ {/if} + + {#if data.tests.length} +
+

Regressionstests

+
    + {#each data.tests as t (t.id)} +
  • + +
    + {t.name} + {#if t.summary && t.outcome !== 'passed'}

    {t.summary}

    {/if} +
    +
  • + {/each} +
+
+ {/if} + +
+

Akzeptanzkriterien

+ {#if report.acceptanceCriteria.length} +
    + {#each report.acceptanceCriteria as ac, i (i)} +
  • + {AC_LABEL[ac.status]} +
    +

    {ac.criterion}

    + {#if ac.notes}

    {ac.notes}

    {/if} + {#if ac.evidenceIds.length} + + {/if} +
    +
  • + {/each} +
+ {:else} +

Claudio hat keine Akzeptanzkriterien bewertet.

+ {/if} +
+ +
+
+

Findings

+ {#if counts.dismissed > 0} + + {/if} +
+ {#if report.findings.length === 0} +

Keine Findings.

+ {/if} + {#each report.findings as f (f.id)} + {#if showDismissed || f.rating.status !== 'dismissed'} +
+
+ + {CATEGORY_LABEL[f.category] ?? f.category} + {#if f.blocking}blockiert die Abnahme{/if} + {f.id} +
+

{f.title}

+

{f.description}

+ {#if f.steps.length} +
    + {#each f.steps as step, i (i)}
  1. {step}
  2. {/each} +
+ {/if} + {#if f.expected || f.actual} +
+ {#if f.expected}
Erwartet
{f.expected}
{/if} + {#if f.actual}
Beobachtet
{f.actual}
{/if} +
+ {/if} + {#if f.url}

{f.url}

{/if} + {#if f.evidenceIds.length} +
+ {#each f.evidenceIds as eid (eid)} + {@const ev = report.evidence[eid]} + {#if ev?.url && ev.kind === 'screenshot'} + + {/if} + {/each} +
+ {/if} + +
({ update }) => update({ reset: false })}> + + +
+ + + {#if f.rating.status !== 'proposed'} + + {/if} +
+ {#if f.rating.by} +

+ {f.rating.status === 'confirmed' ? 'Bestätigt' : f.rating.status === 'dismissed' ? 'Verworfen' : 'Zurückgesetzt'} von {f.rating.by}{f.rating.at ? `, ${formatDate(f.rating.at)}` : ''} +

+ {/if} +
+
+ {/if} + {/each} +
+ + {#if !review.fixture && review.status !== 'queued' && review.status !== 'running'} +
+

Kommentar im Pull Request

+ {#if commentUrl} +

Kurzfassung gepostet. Kommentar auf GitHub öffnen

+ {:else} +

+ Bewerte die Findings zuerst. Der Kommentar übernimmt die Strenge von oben und lässt verworfene Findings weg. +

+ {/if} + {#if commentError}{/if} +
+ Vorschau +
{@html data.commentPreview}
+
+
{ posting = true; return async ({ update }) => { await update({ reset: false }); posting = false; }; }}> + + +
+
+ {/if} + + {#if report.errors.length} +
+

Probleme während des Runs

+
    + {#each report.errors as err, i (i)}
  • {err}
  • {/each} +
+
+ {/if} + {/if} +
+ + +
+ + (lightbox = null)} onclick={(e) => e.target === dialog && dialog?.close()}> + {#if lightbox} +
+ {lightbox.label} +
+ {lightbox.label} + In neuem Tab öffnen + +
+
+ {/if} +
+ + diff --git a/apps/web/src/routes/reviews/[id]/events/+server.ts b/apps/web/src/routes/reviews/[id]/events/+server.ts new file mode 100644 index 0000000..915ffa0 --- /dev/null +++ b/apps/web/src/routes/reviews/[id]/events/+server.ts @@ -0,0 +1,11 @@ +import { error } from '@sveltejs/kit'; +import { getReview } from '@claudio/db'; +import { db } from '#lib/server/db.js'; +import { runEventStream } from '#lib/server/sse.js'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params, url, request }) => { + const id = Number(params.id); + if (!Number.isInteger(id) || !(await getReview(db(), id))) error(404, 'Diesen Review gibt es nicht.'); + return runEventStream(request, url, { reviewId: id }, async () => (await getReview(db(), id))?.status ?? null); +}; diff --git a/apps/web/src/routes/reviews/[id]/live/+server.ts b/apps/web/src/routes/reviews/[id]/live/+server.ts new file mode 100644 index 0000000..85658c3 --- /dev/null +++ b/apps/web/src/routes/reviews/[id]/live/+server.ts @@ -0,0 +1,14 @@ +import { error } from '@sveltejs/kit'; +import { getReview } from '@claudio/db'; +import { db } from '#lib/server/db.js'; +import { liveViewStream } from '#lib/server/live.js'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params, request }) => { + const id = Number(params.id); + if (!Number.isInteger(id) || !(await getReview(db(), id))) error(404, 'Diesen Review gibt es nicht.'); + return liveViewStream(request, async () => { + const r = await getReview(db(), id); + return r ? { status: r.status, runId: r.runId } : null; + }); +}; diff --git a/apps/web/src/routes/reviews/[id]/report.md/+server.ts b/apps/web/src/routes/reviews/[id]/report.md/+server.ts new file mode 100644 index 0000000..bbeb23c --- /dev/null +++ b/apps/web/src/routes/reviews/[id]/report.md/+server.ts @@ -0,0 +1,20 @@ +import { error } from '@sveltejs/kit'; +import { StrictnessSchema, VerbositySchema, renderMarkdown } from '@claudio/core'; +import { getReview, listFindings } from '@claudio/db'; +import { db } from '#lib/server/db.js'; +import type { RequestHandler } from './$types'; + +/** The report as Markdown, with the strictness and verbosity chosen in the UI and without dismissed findings. */ +export const GET: RequestHandler = async ({ params, url }) => { + const id = Number(params.id); + const review = Number.isInteger(id) ? await getReview(db(), id) : undefined; + if (!review?.report) error(404, 'Für diesen Review gibt es keinen Bericht.'); + const strictness = StrictnessSchema.catch(review.report.request.strictness).parse(url.searchParams.get('strictness')); + const verbosity = VerbositySchema.catch(review.report.request.verbosity).parse(url.searchParams.get('verbosity')); + const dismissedIds = (await listFindings(db(), review.id)).filter((r) => r.finding.status === 'dismissed').map((r) => r.finding.findingId); + const markdown = renderMarkdown(review.report, verbosity, { strictness, dismissedIds }); + const name = `claudio-${review.repoFullName.replace('/', '-')}-pr${review.prNumber}-${verbosity}.md`; + return new Response(markdown, { + headers: { 'content-type': 'text/markdown; charset=utf-8', 'content-disposition': `attachment; filename="${name}"` } + }); +}; diff --git a/apps/web/src/routes/reviews/new/+page.server.ts b/apps/web/src/routes/reviews/new/+page.server.ts new file mode 100644 index 0000000..3b92042 --- /dev/null +++ b/apps/web/src/routes/reviews/new/+page.server.ts @@ -0,0 +1,101 @@ +import { error, fail, redirect } from '@sveltejs/kit'; +import { ReviewRequestSchema } from '@claudio/core'; +import { createReview, createTestRuns, getRepo, listRoles, listTestScripts } from '@claudio/db'; +import { userToken } from '#lib/server/auth.js'; +import { db } from '#lib/server/db.js'; +import { DEMO_FIXTURE, DEMO_REPO, demoPullRequest } from '#lib/server/demo.js'; +import { devFixtures } from '#lib/server/env.js'; +import { getPull, githubErrorMessage } from '#lib/server/github.js'; +import type { Actions, PageServerLoad } from './$types'; + +async function context(url: URL, user: App.Locals['user']) { + const fullName = url.searchParams.get('repo') ?? ''; + const prNumber = Number(url.searchParams.get('pr')); + const repo = await getRepo(db(), fullName); + if (!repo) error(404, 'Dieses Repository ist in Claudio nicht eingerichtet.'); + if (!Number.isInteger(prNumber) || prNumber <= 0) error(400, 'Pull-Request-Nummer fehlt.'); + const roles = await listRoles(db(), repo.id); + if (!repo.dataConfirmed || roles.length === 0) redirect(303, `/repos/${repo.fullName}?tab=settings`); + + const fixture = repo.fullName === DEMO_REPO && devFixtures() ? DEMO_FIXTURE : null; + let pr: { title: string; headRef: string; url: string } | null = null; + let prError: string | null = null; + if (fixture) { + const p = demoPullRequest(); + if (p.number === prNumber) pr = { title: p.title, headRef: p.headRef, url: p.url }; + } else { + const token = await userToken(user!); + if (!token) prError = 'Ohne GitHub-Anmeldung kann Claudio den Pull Request nicht laden.'; + else + try { + pr = await getPull(token, repo.fullName, prNumber); + } catch (e) { + prError = githubErrorMessage(e); + } + } + return { repo, roles, prNumber, pr, prError, fixture }; +} + +export const load: PageServerLoad = async ({ url, locals }) => { + const { repo, roles, prNumber, pr, prError } = await context(url, locals.user); + const tests = (await listTestScripts(db(), repo.id)).filter((t) => t.runInReviews); + return { + reviewTests: tests.map((t) => t.name), + repo: repo.fullName, + prNumber, + pr, + prError, + roles: roles.map((r) => ({ name: r.name, description: r.description })), + defaultInstanceUrl: repo.defaultInstanceUrl ?? '' + }; +}; + +export const actions: Actions = { + default: async ({ url, request, locals }) => { + const { repo, prNumber, pr, fixture } = await context(url, locals.user); + if (!pr) return fail(400, { error: 'Der Pull Request konnte nicht geladen werden.' }); + const f = await request.formData(); + const parsed = ReviewRequestSchema.safeParse({ + repo: repo.fullName, + pr: prNumber, + instanceUrl: String(f.get('instanceUrl') ?? '').trim(), + roles: f.getAll('roles').map(String), + driver: 'playwright', + budget: f.get('budget'), + verbosity: f.get('verbosity'), + strictness: f.get('strictness'), + language: f.get('language'), + postComment: f.get('postComment') === 'on' && !fixture, + cloneRepo: !fixture, + extraInstructions: String(f.get('extraInstructions') ?? '').trim() || undefined + }); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + const message = issue?.path[0] === 'instanceUrl' ? 'Gib die vollständige Adresse der Instanz an, z. B. https://pr-17.preview.example.de.' : (issue?.message ?? 'Eingaben prüfen.'); + return fail(400, { error: message }); + } + const id = await createReview(db(), { + repoId: repo.id, + prNumber, + prTitle: pr.title, + request: parsed.data, + fixture, + startedBy: locals.user!.id + }); + // Regression tests of the repo run against the same instance, after the review (worker order). + if (f.get('runTests') === 'on') { + const tests = (await listTestScripts(db(), repo.id)).filter((t) => t.runInReviews); + await createTestRuns( + db(), + tests.map((t) => ({ + scriptId: t.id, + repoId: repo.id, + reviewId: id, + request: { repo: repo.fullName, instanceUrl: parsed.data.instanceUrl, budget: 'quick' as const, language: parsed.data.language, headed: false, rerecord: false }, + startedBy: locals.user!.id + })) + ); + } + redirect(303, `/reviews/${id}`); + } +}; diff --git a/apps/web/src/routes/reviews/new/+page.svelte b/apps/web/src/routes/reviews/new/+page.svelte new file mode 100644 index 0000000..4205b25 --- /dev/null +++ b/apps/web/src/routes/reviews/new/+page.svelte @@ -0,0 +1,160 @@ + + +Review starten · Claudio + +

{data.repo}

+

Review starten

+ +{#if data.pr} +

+ #{data.prNumber} {data.pr.title} + Branch {data.pr.headRef} +

+{:else} +

{data.prError ?? `Pull Request #${data.prNumber} wurde nicht gefunden.`}

+{/if} + +
(submitting = true)}> + {#if form?.error}{/if} + + + +
+ Anmelden als +
+ {#each data.roles as role, i (role.name)} + + {/each} +
+ Claudio startet mit diesen Rollen und darf bei Bedarf zu den anderen wechseln. +
+ +
+ + + + +
+ + + + {#if data.reviewTests.length} + + {/if} + + + +
+ + Strenge und Ausführlichkeit lassen sich nach dem Review noch ändern. +
+
+ + diff --git a/apps/web/src/routes/test-runs/[id]/+page.server.ts b/apps/web/src/routes/test-runs/[id]/+page.server.ts new file mode 100644 index 0000000..c4e3cbd --- /dev/null +++ b/apps/web/src/routes/test-runs/[id]/+page.server.ts @@ -0,0 +1,77 @@ +import { error } from '@sveltejs/kit'; +import { describeStep, resolveStepTemplates, sortBySeverity } from '@claudio/core'; +import { eventsSince, getTestRun, requestTestRunAbort } from '@claudio/db'; +import { db } from '#lib/server/db.js'; +import { runFileUrl } from '#lib/server/run-files.js'; +import type { Actions, PageServerLoad } from './$types'; + +async function loadRun(idParam: string) { + const id = Number(idParam); + const run = Number.isInteger(id) ? await getTestRun(db(), id) : undefined; + if (!run) error(404, 'Diesen Testlauf gibt es nicht.'); + return run; +} + +/** Same tag the runner used for {{run}}, so the page shows the actual test data. */ +const runTagOf = (runId: string) => runId.split('-').slice(-2).join(''); + +export const load: PageServerLoad = async ({ params }) => { + const run = await loadRun(params.id); + const events = await eventsSince(db(), { testRunId: run.id }, 0, 5000); + const r = run.report; + const tplCtx = r ? { runTag: runTagOf(r.runId), now: new Date(r.createdAt) } : null; + return { + run: { + id: run.id, + scriptId: run.scriptId, + scriptName: run.scriptName, + repo: run.repoFullName, + status: run.status, + outcome: run.outcome, + healed: run.healed, + instanceUrl: run.instanceUrl, + budget: run.request.budget, + rerecord: run.request.rerecord, + reviewId: run.reviewId, + stepsVersion: run.stepsVersion, + startedBy: run.startedByLogin, + createdAt: run.createdAt, + finishedAt: run.finishedAt, + abortRequested: run.abortRequested, + error: run.error + }, + report: r + ? { + mode: r.mode, + summary: r.summary, + healedAt: r.healedAt ?? null, + judge: r.judge ?? null, + finalUrl: r.finalUrl ?? '', + savedSteps: r.compiledSteps?.length ?? 0, + determinismProposal: r.determinismProposal ?? null, + steps: r.steps.map((s) => ({ + index: s.index, + text: describeStep(tplCtx ? resolveStepTemplates(s.step, tplCtx) : s.step), + ok: s.ok, + error: s.error ?? null, + durationMs: s.durationMs, + healed: s.healed, + assertion: s.step.action.startsWith('expect_') + })), + evidence: r.evidence.filter((e) => e.path && e.kind === 'screenshot').map((e) => ({ id: e.id, label: e.label, url: runFileUrl(r.runId, e.path!) })), + findings: sortBySeverity(r.findings), + cost: r.cost, + errors: r.errors + } + : null, + events: events.map((e) => ({ seq: e.seq, line: e.line })) + }; +}; + +export const actions: Actions = { + abort: async ({ params }) => { + const run = await loadRun(params.id); + await requestTestRunAbort(db(), run.id); + return { aborted: true }; + } +}; diff --git a/apps/web/src/routes/test-runs/[id]/+page.svelte b/apps/web/src/routes/test-runs/[id]/+page.svelte new file mode 100644 index 0000000..f723fda --- /dev/null +++ b/apps/web/src/routes/test-runs/[id]/+page.svelte @@ -0,0 +1,375 @@ + + +{run.scriptName} · Testlauf · Claudio + +

{run.repo} / {run.scriptName}

+
+

Testlauf: {run.scriptName}

+ {#if active} +
+ +
+ {/if} +
+ +{#if r && !active} +
+

{run.outcome ? OUTCOME_LABEL[run.outcome] : STATUS_LABEL[run.status]}

+

+ {MODE_LABEL[r.mode]}{r.mode === 'replay' && run.stepsVersion ? ` von Version ${run.stepsVersion}` : ''}, + {r.steps.filter((s) => s.ok).length} von {r.steps.length} Schritten ausgeführt, {formatDuration(r.cost.durationMs)}, + {r.cost.usd > 0 ? formatUsd(r.cost.usd) : 'ohne Modell'}. +

+ {#if r.summary}

{r.summary}

{/if} +
+{:else if !active} +

{run.error ?? 'Dieser Lauf hat kein Ergebnis erzeugt.'}

+{/if} + +
+
+ {#if active} + + {/if} + {#if isActive(data.run.status) || data.events.length > 0} + (liveStatus = s)} + /> + {/if} + + {#if r} + {#if r.healedAt !== null} +

+ Schritt {r.healedAt + 1} ließ sich nicht mehr ausführen. Claudio hat den Ablauf ab dort repariert{r.savedSteps ? ' und als neue Version gespeichert' : ''}. Prüfe unten das Finding: Ein umbenanntes oder verschobenes Element kann gewollt sein oder eine Regression. +

+ {:else if r.mode === 'record' && r.savedSteps} +

+ {r.savedSteps} Schritte aufgezeichnet{r.determinismProposal ? `, eingestuft als ${DETERMINISM_LABEL[r.determinismProposal].toLowerCase()}` : ''}. Folgeläufe spielen sie ohne Modell ab. +

+ {/if} + + {#if r.judge} +
+

Bewertung durch den Prüf-Agenten

+
+

+ {r.judge.passed ? 'Erwartung erfüllt' : 'Erwartung nicht erfüllt'} + Konfidenz {Math.round(r.judge.confidence * 100)} % +

+ {#if r.judge.deviations.length} +
    + {#each r.judge.deviations as d, i (i)}
  • {d}
  • {/each} +
+ {/if} +

{r.judge.reasoning}

+
+
+ {/if} + +
+

Schritte

+
    + {#each r.steps as step (step.index)} +
  1. + {step.ok ? '✓' : '✗'} + + {step.text} + {#if step.healed}repariert{/if} + {#if step.error}{step.error}{/if} + + {step.durationMs} ms +
  2. + {/each} +
+ {#if r.mode === 'record'} +

Beim Aufzeichnen verworfene Versuche (✗) werden nicht gespeichert.

+ {/if} +
+ + {#if r.findings.length} +
+

Findings

+
    + {#each r.findings as f (f.id)} +
  • + +
    + {f.title} + {CATEGORY_LABEL[f.category] ?? f.category} +

    {f.description}

    +
    +
  • + {/each} +
+
+ {/if} + + {#if r.evidence.length} +
+

Screenshots

+
+ {#each r.evidence as ev (ev.id)} + + {ev.label} + {ev.label} + + {/each} +
+
+ {/if} + + {#if r.errors.length} +
+

Probleme während des Laufs

+
    {#each r.errors as e, i (i)}
  • {e}
  • {/each}
+
+ {/if} + {/if} +
+ + +
+ + diff --git a/apps/web/src/routes/test-runs/[id]/events/+server.ts b/apps/web/src/routes/test-runs/[id]/events/+server.ts new file mode 100644 index 0000000..a84b443 --- /dev/null +++ b/apps/web/src/routes/test-runs/[id]/events/+server.ts @@ -0,0 +1,11 @@ +import { error } from '@sveltejs/kit'; +import { getTestRun } from '@claudio/db'; +import { db } from '#lib/server/db.js'; +import { runEventStream } from '#lib/server/sse.js'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params, url, request }) => { + const id = Number(params.id); + if (!Number.isInteger(id) || !(await getTestRun(db(), id))) error(404, 'Diesen Testlauf gibt es nicht.'); + return runEventStream(request, url, { testRunId: id }, async () => (await getTestRun(db(), id))?.status ?? null); +}; diff --git a/apps/web/src/routes/test-runs/[id]/live/+server.ts b/apps/web/src/routes/test-runs/[id]/live/+server.ts new file mode 100644 index 0000000..17fdaac --- /dev/null +++ b/apps/web/src/routes/test-runs/[id]/live/+server.ts @@ -0,0 +1,14 @@ +import { error } from '@sveltejs/kit'; +import { getTestRun } from '@claudio/db'; +import { db } from '#lib/server/db.js'; +import { liveViewStream } from '#lib/server/live.js'; +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ params, request }) => { + const id = Number(params.id); + if (!Number.isInteger(id) || !(await getTestRun(db(), id))) error(404, 'Diesen Testlauf gibt es nicht.'); + return liveViewStream(request, async () => { + const r = await getTestRun(db(), id); + return r ? { status: r.status, runId: r.runId } : null; + }); +}; diff --git a/apps/web/src/routes/tests/[id]/+page.server.ts b/apps/web/src/routes/tests/[id]/+page.server.ts new file mode 100644 index 0000000..8f4e626 --- /dev/null +++ b/apps/web/src/routes/tests/[id]/+page.server.ts @@ -0,0 +1,104 @@ +import { error, fail, redirect } from '@sveltejs/kit'; +import { DeterminismSchema, TestRunRequestSchema, describeStep } from '@claudio/core'; +import { + createTestRun, + deleteTestScript, + getRepoById, + getTestScript, + listRoles, + listTestRuns, + resetTestSteps, + updateTestScript +} from '@claudio/db'; +import { db } from '#lib/server/db.js'; +import type { Actions, PageServerLoad } from './$types'; + +async function loadScript(idParam: string) { + const id = Number(idParam); + const script = Number.isInteger(id) ? await getTestScript(db(), id) : undefined; + if (!script) error(404, 'Diesen Test gibt es nicht.'); + return script; +} + +const text = (v: FormDataEntryValue | null) => String(v ?? '').trim(); + +export const load: PageServerLoad = async ({ params, url }) => { + const script = await loadScript(params.id); + const [repo, roles, runs] = await Promise.all([getRepoById(db(), script.repoId), listRoles(db(), script.repoId), listTestRuns(db(), { scriptId: script.id }, 50)]); + return { + script: { + id: script.id, + repo: script.repoFullName, + name: script.name, + description: script.description, + expectation: script.expectation, + roles: script.roles ?? [], + determinism: script.determinism, + runInReviews: script.runInReviews, + stepsVersion: script.stepsVersion, + stepsUpdatedAt: script.stepsUpdatedAt, + steps: (script.steps ?? []).map((s) => ({ text: describeStep(s), assertion: s.action.startsWith('expect_') })) + }, + roles: roles.map((r) => r.name), + defaultInstanceUrl: repo?.defaultInstanceUrl ?? '', + dataConfirmed: repo?.dataConfirmed ?? false, + runs: runs.map((r) => ({ + id: r.id, + status: r.status, + outcome: r.outcome, + mode: r.mode, + healed: r.healed, + stepsVersion: r.stepsVersion, + costUsd: r.costUsd, + durationMs: r.report?.cost.durationMs ?? null, + reviewId: r.reviewId, + startedBy: r.startedByLogin, + createdAt: r.createdAt + })), + isNew: url.searchParams.has('new') + }; +}; + +export const actions: Actions = { + update: async ({ params, request }) => { + const script = await loadScript(params.id); + const f = await request.formData(); + const determinism = DeterminismSchema.safeParse(f.get('determinism')); + const input = { + name: text(f.get('name')), + description: text(f.get('description')), + expectation: text(f.get('expectation')), + roles: f.getAll('roles').map(String), + runInReviews: f.get('runInReviews') === 'on', + determinism: determinism.success ? determinism.data : script.determinism + }; + if (!input.name || !input.description || !input.expectation) return fail(400, { error: 'Name, Ablauf und erwartetes Ergebnis sind Pflicht.', form: 'update' }); + await updateTestScript(db(), script.id, input); + return { saved: true }; + }, + run: async ({ params, request, locals }) => { + const script = await loadScript(params.id); + const repo = await getRepoById(db(), script.repoId); + if (!repo?.dataConfirmed) return fail(400, { error: 'Bestätige zuerst in den Repo-Einstellungen, dass die Instanz nur Testdaten enthält.', form: 'run' }); + const f = await request.formData(); + const parsed = TestRunRequestSchema.safeParse({ + repo: script.repoFullName, + instanceUrl: text(f.get('instanceUrl')), + budget: f.get('budget'), + rerecord: f.get('rerecord') === 'on' + }); + if (!parsed.success) return fail(400, { error: 'Gib die vollständige Adresse einer laufenden Instanz an.', form: 'run' }); + const id = await createTestRun(db(), { scriptId: script.id, repoId: script.repoId, request: parsed.data, startedBy: locals.user!.id }); + redirect(303, `/test-runs/${id}`); + }, + reset: async ({ params }) => { + const script = await loadScript(params.id); + await resetTestSteps(db(), script.id); + return { saved: true }; + }, + delete: async ({ params }) => { + const script = await loadScript(params.id); + await deleteTestScript(db(), script.id); + redirect(303, `/repos/${script.repoFullName}?tab=tests`); + } +}; diff --git a/apps/web/src/routes/tests/[id]/+page.svelte b/apps/web/src/routes/tests/[id]/+page.svelte new file mode 100644 index 0000000..fed0355 --- /dev/null +++ b/apps/web/src/routes/tests/[id]/+page.svelte @@ -0,0 +1,321 @@ + + +{s.name} · Tests · Claudio + +

{s.repo}

+

{s.name}

+

+ {#if s.determinism} + {DETERMINISM_LABEL[s.determinism]}. {DETERMINISM_HELP[s.determinism]} + {:else} + Noch nicht aufgezeichnet. Der erste Lauf zeichnet den Ablauf mit einem Agenten auf. + {/if} + {#if s.runInReviews}läuft bei PR-Reviews mit{/if} +

+ +{#if data.isNew} +

Test angelegt. Starte den ersten Lauf, damit Claudio den Ablauf aufzeichnet.

+{/if} + +
+
+
+

Ablauf

+

{s.description}

+

Erwartetes Ergebnis

+

{s.expectation}

+
+ +
+
+

Aufgezeichnete Schritte

+ {#if s.steps.length} + Version {s.stepsVersion}, {formatDate(s.stepsUpdatedAt)} + {/if} +
+ {#if s.steps.length} +
    + {#each s.steps as step, i (i)} +
  1. {step.text}
  2. + {/each} +
+
+ + Der nächste Lauf zeichnet dann neu auf. +
+ {:else} +

Noch keine. Sie entstehen beim ersten Lauf.

+ {/if} +
+ +
+
+

Läufe

+ {#if data.runs.length > 1} + + Ausgewählte vergleichen + + {/if} +
+ {#if data.runs.length} +
+ + + + + + + + + + + + + {#each data.runs as run (run.id)} + + + + + + + + + {/each} + +
VergleichenGestartetErgebnisArtDauerKosten
+ toggleCompare(run.id, e.currentTarget.checked)} + /> + + {formatDate(run.createdAt)} + {run.reviewId ? `im Review #${run.reviewId}` : `von ${run.startedBy}`} + {run.mode ? MODE_LABEL[run.mode] : '–'}{run.mode === 'replay' && run.stepsVersion ? ` v${run.stepsVersion}` : ''}{run.durationMs !== null ? formatDuration(run.durationMs) : '–'}{formatUsd(run.costUsd)}
+
+ {:else} +

Noch keine Läufe.

+ {/if} +
+
+ + +
+ +
+ Test bearbeiten +
({ update }) => update({ reset: false })}> + {#if formError('update')}{/if} + + + +
+ Rollen +
+ {#each data.roles as role (role)} + + {/each} +
+
+ + +
+
+
{ if (!confirm('Test und alle Läufe löschen?')) e.preventDefault(); }}> + +
+
+ + diff --git a/apps/web/src/routes/tests/[id]/compare/+page.server.ts b/apps/web/src/routes/tests/[id]/compare/+page.server.ts new file mode 100644 index 0000000..2bcf4a9 --- /dev/null +++ b/apps/web/src/routes/tests/[id]/compare/+page.server.ts @@ -0,0 +1,45 @@ +import { error } from '@sveltejs/kit'; +import { describeStep, resolveStepTemplates } from '@claudio/core'; +import { getTestRun, getTestScript } from '@claudio/db'; +import { db } from '#lib/server/db.js'; +import { runFileUrl } from '#lib/server/run-files.js'; +import type { PageServerLoad } from './$types'; + +const runTagOf = (runId: string) => runId.split('-').slice(-2).join(''); + +export const load: PageServerLoad = async ({ params, url }) => { + const script = await getTestScript(db(), Number(params.id)); + if (!script) error(404, 'Diesen Test gibt es nicht.'); + const ids = [Number(url.searchParams.get('a')), Number(url.searchParams.get('b'))]; + const runs = await Promise.all(ids.map((id) => (Number.isInteger(id) ? getTestRun(db(), id) : undefined))); + if (runs.some((r) => !r || r.scriptId !== script.id)) error(404, 'Wähle zwei Läufe dieses Tests aus.'); + + const view = runs.map((run) => { + const r = run!.report; + const ctx = r ? { runTag: runTagOf(r.runId), now: new Date(r.createdAt) } : null; + const final = r?.evidence.find((e) => e.label === 'Endzustand' && e.path); + return { + id: run!.id, + createdAt: run!.createdAt, + status: run!.status, + outcome: run!.outcome, + healed: run!.healed, + mode: run!.mode, + stepsVersion: run!.stepsVersion, + durationMs: r?.cost.durationMs ?? null, + costUsd: run!.costUsd, + judge: r?.judge ?? null, + finalShot: r && final ? runFileUrl(r.runId, final.path!) : null, + // Only executed steps line up between runs; discarded recording attempts would shift the rows. + steps: (r?.steps ?? []) + .filter((s) => s.ok || s.step.action.startsWith('expect_') || r?.mode === 'replay') + .map((s) => ({ text: describeStep(ctx ? resolveStepTemplates(s.step, ctx) : s.step), ok: s.ok, error: s.error ?? null, durationMs: s.durationMs })) + }; + }); + + const rows = Array.from({ length: Math.max(view[0]!.steps.length, view[1]!.steps.length) }, (_, i) => ({ + a: view[0]!.steps[i] ?? null, + b: view[1]!.steps[i] ?? null + })); + return { script: { id: script.id, name: script.name, repo: script.repoFullName }, a: view[0]!, b: view[1]!, rows }; +}; diff --git a/apps/web/src/routes/tests/[id]/compare/+page.svelte b/apps/web/src/routes/tests/[id]/compare/+page.svelte new file mode 100644 index 0000000..0084772 --- /dev/null +++ b/apps/web/src/routes/tests/[id]/compare/+page.svelte @@ -0,0 +1,146 @@ + + +Vergleich · {data.script.name} · Claudio + +

{data.script.repo} / {data.script.name}

+

Läufe vergleichen

+ +
+ + + + + {#each runs as run (run.id)} + + {/each} + + + + + + {#each runs as run (run.id)}{/each} + + + + {#each runs as run (run.id)}{/each} + + + + {#each runs as run (run.id)}{/each} + + + + {#each runs as run (run.id)} + + {/each} + + +
Lauf vom {formatDate(run.createdAt)}
Ergebnis
Art{run.mode ? MODE_LABEL[run.mode] : '–'}{run.mode === 'replay' && run.stepsVersion ? ` von Version ${run.stepsVersion}` : ''}
Dauer, Kosten{run.durationMs !== null ? formatDuration(run.durationMs) : '–'}, {formatUsd(run.costUsd)}
Prüf-Agent + {#if run.judge} + {run.judge.passed ? 'Erwartung erfüllt' : 'Nicht erfüllt'} ({Math.round(run.judge.confidence * 100)} %) + {#each run.judge.deviations as d, i (i)}{d}{/each} + {:else}nicht nötig{/if} +
+
+ +

Schritte

+
+ + + + {#each data.rows as row, i (i)} + + + {#each [row.a, row.b] as s, k (k)} + + {/each} + + {/each} + +
#Lauf ALauf B
{i + 1} + {#if s} + {s.ok ? '✓' : '✗'} + {s.text} + {s.durationMs} ms + {#if s.error}{s.error}{/if} + {:else}–{/if} +
+
+ +

Endzustand

+
+ {#each runs as run (run.id)} + {#if run.finalShot} + Endzustand des Laufs vom {formatDate(run.createdAt)} + {:else} +

Kein Screenshot.

+ {/if} + {/each} +
+ + diff --git a/apps/web/static/robots.txt b/apps/web/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/apps/web/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/apps/web/test/files.test.ts b/apps/web/test/files.test.ts new file mode 100644 index 0000000..5a16709 --- /dev/null +++ b/apps/web/test/files.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { contentType, resolveRunFile } from '../src/lib/server/files.js'; + +const root = '/srv/claudio/runs'; + +describe('resolveRunFile', () => { + it('resolves files inside the run directory', () => { + expect(resolveRunFile(root, 'review-pr17-20261010-210308-clh8', 'evidence/E1-Fahrtenliste.png')).toBe( + '/srv/claudio/runs/review-pr17-20261010-210308-clh8/evidence/E1-Fahrtenliste.png' + ); + }); + it.each([ + ['review-1', '../review-2/report.json'], + ['review-1', 'evidence/../../review-2/x.png'], + ['review-1', '/etc/passwd'], + ['review-1', ''], + ['review-1', 'a\0b'], + ['..', 'runs/report.json'], + ['review/1', 'report.json'], + ['.', 'report.json'] + ])('rejects run %s file %s', (runId, file) => { + expect(resolveRunFile(root, runId, file)).toBeNull(); + }); +}); + +describe('contentType', () => { + it('maps known extensions and falls back to octet-stream', () => { + expect(contentType('x/E1.png')).toBe('image/png'); + expect(contentType('trace.zip')).toBe('application/zip'); + expect(contentType('x.html')).toBe('application/octet-stream'); + }); +}); diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..62f2df9 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "$app/tsconfig", + "compilerOptions": { + "strict": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..5cb99b5 --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,18 @@ +import adapter from '@sveltejs/adapter-node'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + // The monorepo's .env lives two levels up. + envDir: '../..', + plugins: [ + sveltekit({ + compilerOptions: { + // Force runes mode for the project, except for libraries. Can be removed in svelte 6. + runes: ({ filename }) => + filename.split(/[/\\]/).includes('node_modules') ? undefined : true + }, + adapter: adapter() + }) + ] +}); diff --git a/apps/worker/package.json b/apps/worker/package.json new file mode 100644 index 0000000..ed972a9 --- /dev/null +++ b/apps/worker/package.json @@ -0,0 +1,17 @@ +{ + "name": "@claudio/worker", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Runs queued reviews from the database", + "scripts": { + "start": "tsx src/main.ts", + "typecheck": "tsc --noEmit -p tsconfig.json", + "dev": "tsx src/main.ts" + }, + "dependencies": { + "@claudio/agent": "workspace:*", + "@claudio/core": "workspace:*", + "@claudio/db": "workspace:*" + } +} diff --git a/apps/worker/src/main.ts b/apps/worker/src/main.ts new file mode 100644 index 0000000..12dacc7 --- /dev/null +++ b/apps/worker/src/main.ts @@ -0,0 +1,246 @@ +#!/usr/bin/env node +import { existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { runAnalysis, runReview, runTest, type RunHandle } from "@claudio/agent"; +import { verdict } from "@claudio/core"; +import { + appendEvent, + claimNextAnalysis, + claimNextReview, + claimNextTestRun, + closeDb, + failStaleRuns, + finishAnalysis, + finishReview, + finishTestRun, + getDb, + getRepoById, + getTestScript, + getUser, + isAbortRequested, + isAnalysisAbortRequested, + isTestRunAbortRequested, + latestKnowledge, + listRoles, + saveTestSteps, + setAnalysisRunId, + setReviewRunId, + setTestRunStarted, + toRepoConfig, + toTestScript, + userAccessToken, + type Analysis, + type Db, + type EventTarget, + type Review, + type ReviewStatus, + type TestRun, +} from "@claudio/db"; + +const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); +if (existsSync(resolve(projectRoot, ".env"))) process.loadEnvFile(resolve(projectRoot, ".env")); + +const runsRoot = resolve(projectRoot, process.env.CLAUDIO_RUNS_DIR ?? "runs"); +const concurrency = Math.max(1, Number(process.env.CLAUDIO_WORKER_CONCURRENCY ?? 1)); +const pollMs = 2000; +/** Base URL of the web UI, for links in PR comments. */ +const publicBaseUrl = (process.env.PUBLIC_BASE_URL ?? "").replace(/\/$/, ""); + +/** Offline fixtures a run may use instead of GitHub (CLAUDIO_DEV_FIXTURES=1 only). */ +const FIXTURES: Record = { + demo: { + prContextFile: resolve(projectRoot, "apps/demo-target/fixtures/pr-context.json"), + repoDir: resolve(projectRoot, "apps/demo-target"), + }, +}; + +const log = (msg: string) => console.log(`[worker] ${new Date().toISOString()} ${msg}`); + +type Job = + | { kind: "review"; row: Review } + | { kind: "analysis"; row: Analysis } + | { kind: "test"; row: TestRun & { fixture?: null } }; + +/** Live log, abort polling and the shared repo/user context of one run. */ +async function prepare(db: Db, job: Job) { + const target: EventTarget = + job.kind === "review" ? { reviewId: job.row.id } : job.kind === "analysis" ? { analysisId: job.row.id } : { testRunId: job.row.id }; + let seq = 0; + let pending: Promise = Promise.resolve(); + // onProgress is synchronous; chain the inserts so the live log keeps its order. + const emit = (line: string) => { + const n = ++seq; + pending = pending.then(() => appendEvent(db, target, n, line)).catch((e) => log(`event write failed: ${(e as Error).message}`)); + }; + const after = (fn: () => Promise) => { + pending = pending.then(fn).catch((e) => log(`write failed: ${(e as Error).message}`)); + }; + + const abort = new AbortController(); + const abortRequested = job.kind === "review" ? isAbortRequested : job.kind === "analysis" ? isAnalysisAbortRequested : isTestRunAbortRequested; + const abortPoll = setInterval(() => { + abortRequested(db, job.row.id) + .then((yes) => { + if (yes && !abort.signal.aborted) { + emit("Abbruch angefordert, Run wird beendet …"); + abort.abort(); + } + }) + .catch(() => undefined); + }, pollMs); + + const repo = await getRepoById(db, job.row.repoId); + if (!repo) throw new Error(`Repo ${job.row.repoId} existiert nicht mehr.`); + const user = await getUser(db, job.row.startedBy); + const githubToken = user ? userAccessToken(user) || undefined : undefined; + const { config, roleEnv } = toRepoConfig(repo, await listRoles(db, repo.id)); + const knowledge = await latestKnowledge(db, repo.id); + + let fixture: (typeof FIXTURES)[string] | undefined; + if (job.row.fixture) { + if (process.env.CLAUDIO_DEV_FIXTURES !== "1") throw new Error("Fixture-Runs sind nur mit CLAUDIO_DEV_FIXTURES=1 erlaubt."); + fixture = FIXTURES[job.row.fixture]; + if (!fixture) throw new Error(`Unbekannte Fixture "${job.row.fixture}".`); + } + + return { + emit, + after, + flush: () => pending, + abort, + stop: () => clearInterval(abortPoll), + common: { runsRoot, projectRoot, config, roleEnv, githubToken, abortController: abort, onProgress: emit }, + knowledge, + fixture, + }; +} + +async function execute(db: Db, job: Job): Promise { + let ctx: Awaited> | undefined; + const finish = (status: ReviewStatus, error: string | null) => + job.kind === "review" + ? finishReview(db, job.row.id, { status, report: null, verdict: null, error }) + : job.kind === "analysis" + ? finishAnalysis(db, job.row.id, { status, report: null, error }) + : finishTestRun(db, job.row.id, { status, report: null, error }); + try { + ctx = await prepare(db, job); + const c = ctx; + if (job.kind === "review") { + if (c.knowledge) c.emit(`Wissensbasis von Commit ${c.knowledge.commitSha.slice(0, 7)} wird mitgegeben.`); + const report = await runReview(job.row.request, { + ...c.common, + knowledge: c.knowledge, + reportUrl: publicBaseUrl ? `${publicBaseUrl}/reviews/${job.row.id}` : undefined, + prContextFile: c.fixture?.prContextFile, + repoDir: c.fixture?.repoDir, + onRunStarted: ({ runId }: RunHandle) => c.after(() => setReviewRunId(db, job.row.id, runId)), + }); + await c.flush(); + await finishReview(db, job.row.id, { + status: report.status, + report, + verdict: verdict(report), + error: report.errors.length ? report.errors.join("\n") : null, + }); + log(`review ${job.row.id} finished: ${report.status}, ${report.cost.usd.toFixed(2)} USD`); + } else if (job.kind === "test") { + const row = await getTestScript(db, job.row.scriptId); + if (!row) throw new Error("Der Test existiert nicht mehr."); + const script = toTestScript(row); + const report = await runTest(script, job.row.request, { + ...c.common, + knowledge: c.knowledge, + onRunStarted: ({ runId }: RunHandle) => c.after(() => setTestRunStarted(db, job.row.id, runId, script.steps?.length && !job.row.request.rerecord ? row.stepsVersion : 0)), + }); + await c.flush(); + await finishTestRun(db, job.row.id, { status: report.status, report, error: report.errors.length ? report.errors.join("\n") : null }); + // A new recording or a healed replay becomes the script's next version. + if (report.compiledSteps?.length) { + // A new recording brings its own classification; a healed replay keeps the script's. + const determinism = report.mode === "record" ? (report.determinismProposal ?? "deterministic") : undefined; + await saveTestSteps(db, script.id, report.compiledSteps, determinism); + c.emit(`Ablauf gespeichert (${report.compiledSteps.length} Schritte, Version ${row.stepsVersion + 1}).`); + await c.flush(); + } + log(`test run ${job.row.id} (${script.name}) finished: ${report.outcome}, ${report.mode}, ${report.cost.usd.toFixed(2)} USD`); + } else { + if (c.knowledge) c.emit(`Aktualisiert die Wissensbasis von Commit ${c.knowledge.commitSha.slice(0, 7)}.`); + const report = await runAnalysis(job.row.request, { + ...c.common, + previous: c.knowledge, + repoDir: c.fixture?.repoDir, + commitSha: c.fixture ? "demo" : undefined, + onRunStarted: ({ runId }: RunHandle) => c.after(() => setAnalysisRunId(db, job.row.id, runId)), + }); + await c.flush(); + await finishAnalysis(db, job.row.id, { + status: report.status, + report, + error: report.errors.length ? report.errors.join("\n") : null, + }); + log(`analysis ${job.row.id} finished: ${report.status}, ${report.docs.length} docs, ${report.cost.usd.toFixed(2)} USD`); + } + } catch (e) { + const message = (e as Error).message; + ctx?.emit(`Fehler: ${message}`); + await ctx?.flush(); + await finish(ctx?.abort.signal.aborted ? "aborted" : "failed", message); + log(`${job.kind} ${job.row.id} failed: ${message}`); + } finally { + ctx?.stop(); + } +} + +/** Reviews first: they block a merge, an analysis can wait. */ +async function claimNext(db: Db): Promise { + const review = await claimNextReview(db); + if (review) return { kind: "review", row: review }; + const test = await claimNextTestRun(db); + if (test) return { kind: "test", row: test }; + const analysis = await claimNextAnalysis(db); + if (analysis) return { kind: "analysis", row: analysis }; + return null; +} + +async function main(): Promise { + const db = getDb(); + const stale = await failStaleRuns(db); + if (stale) log(`marked ${stale} interrupted run(s) as failed`); + log(`polling for reviews and analyses (concurrency ${concurrency}, runs in ${runsRoot})`); + + const active = new Set>(); + let stopping = false; + const stop = () => { + if (stopping) process.exit(1); + stopping = true; + log("stopping after the current run(s); press Ctrl+C again to force"); + }; + process.on("SIGINT", stop); + process.on("SIGTERM", stop); + + while (!stopping) { + if (active.size < concurrency) { + const job = await claimNext(db).catch((e) => { + log(`poll failed: ${(e as Error).message}`); + return null; + }); + if (job) { + const what = job.kind === "review" ? `${job.row.request.repo}#${job.row.prNumber}` : job.kind === "test" ? `test script ${job.row.scriptId}` : job.row.request.repo; + log(`${job.kind} ${job.row.id}: ${what} → ${job.row.instanceUrl}`); + const run = execute(db, job).finally(() => active.delete(run)); + active.add(run); + continue; + } + } + await new Promise((r) => setTimeout(r, pollMs)); + } + await Promise.all(active); + await closeDb(); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/apps/worker/tsconfig.json b/apps/worker/tsconfig.json new file mode 100644 index 0000000..404aab4 --- /dev/null +++ b/apps/worker/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "noEmit": true, "rootDir": "src" }, + "include": ["src"] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a10d0be --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +# MySQL for the Claudio web UI and worker (development). +services: + db: + image: mysql:8.4 + restart: unless-stopped + environment: + MYSQL_DATABASE: claudio + MYSQL_USER: claudio + MYSQL_PASSWORD: claudio + MYSQL_ROOT_PASSWORD: claudio-root + ports: + - "${CLAUDIO_DB_PORT:-3310}:3306" + volumes: + - claudio-db:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uclaudio", "-pclaudio"] + interval: 5s + retries: 20 + +volumes: + claudio-db: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 51137c6..a5074be 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Claudio: Plan für den automatisierten User-Review-Agenten -> Stand: Phase 0 umgesetzt (CLI `claudio review`, Playwright-Treiber, Claudio-Tools, Demo-Target). Offen: Chrome-Live-Modus über das SDK verifizieren, Phasen 1 bis 5. +> Stand: Phase 0 umgesetzt (CLI `claudio review`, Playwright-Treiber, Claudio-Tools, Demo-Target). Phase 1 und der Review-Teil von Phase 5 umgesetzt: `packages/db` (MySQL/Drizzle, AES-GCM-Secrets), `apps/worker` (Job-Polling, Abbruch, Live-Events), `apps/web` (SvelteKit 3: GitHub-OAuth-Login, Repos aus GitHub, Rollen, Review starten, Live-Verlauf per SSE, Report mit Evidenz, Findings bestätigen/verwerfen, Strenge nachträglich). Abweichung: Login über eine GitHub **OAuth App** statt GitHub App; Reviews nutzen das Token der startenden Person. Phase 2 umgesetzt: Repo-Analyse mit `code-scanner`-Subagent und Browser-Rundgang durch den Orchestrator, Wissensbasis aus fünf Markdown-Dokumenten (overview, modules, routes, app-map, notes) pro Analyse in MySQL (`knowledge_docs`), inkrementelles Update über die GitHub-Compare-API, Veraltet-Anzeige gegen den Branch-Head, Wissensbasis in jedem Review (klein: im Prompt, groß: als Dateien). Abweichungen: kein Schreiben nach `.claudio/` im Zielrepo (nur DB und Run-Verzeichnis); der Rundgang läuft sequenziell im Orchestrator statt mit parallelen `tester`-Agents, weil sich alle Agents eines Runs einen Browser teilen; Aktualisierung bei Merge auf main noch manuell (Webhook folgt mit Phase 5). Phase 3 umgesetzt: drei Prüf-Subagenten (`ac-verifier`, `regression-tester`, `ux-reviewer`) ab Budget standard, sequenziell wegen des gemeinsamen Browsers; Live-Vorschau per CDP-Screencast (`runDir/live.jpg`, in der UI als MJPEG); PR-Kommentar mit Link auf den Review, nachträglich aus der UI mit gewählter Strenge und ohne verworfene Findings; Markdown-Bericht in jeder Ausführlichkeit. Phase 4 umgesetzt: Test-Scripts (`test_scripts`, `test_runs`) mit eigener Schritt-DSL in `core/testing.ts`; Aufzeichnung über das Claudio-Tool `test_step` (Claudio führt jeden Schritt selbst mit Playwright aus, das Modell bekommt nur `snapshot`, den Accessibility-Tree), Replay ohne Modell, Selbstheilung ab dem gebrochenen Schritt mit neuer Version und Finding, `judge` als eigene SDK-Session mit Structured Output, Determinismus-Grad als Vorschlag der Aufzeichnung, Läufe einzeln, als Suite oder mit PR-Reviews, Vergleich zweier Läufe. Abweichung vom Plan: kein Playwright-MCP in Testläufen, damit Aufzeichnung und Replay garantiert dieselben Aktionen ausführen. Offen: Chrome-Live-Modus über das SDK, Bot und Webhooks. ## Kontext diff --git a/package.json b/package.json index f408476..82655a5 100644 --- a/package.json +++ b/package.json @@ -4,12 +4,21 @@ "version": "0.1.0", "description": "Agentic user-review and frontend test suite built on the Claude Agent SDK", "packageManager": "pnpm@10.28.0", - "engines": { "node": ">=22" }, + "engines": { + "node": ">=22" + }, "scripts": { "build": "pnpm -r --filter './packages/*' --filter './apps/*' run build", "typecheck": "pnpm -r run typecheck", "test": "vitest run", - "claudio": "tsx apps/cli/src/main.ts" + "claudio": "tsx apps/cli/src/main.ts", + "dev": "pnpm --parallel --filter @claudio/web --filter @claudio/worker run dev", + "web": "pnpm --filter @claudio/web run dev", + "worker": "pnpm --filter @claudio/worker run start", + "db:up": "docker compose up -d --wait", + "db:migrate": "pnpm --filter @claudio/db run migrate", + "db:generate": "pnpm --filter @claudio/db run generate", + "demo-target": "node apps/demo-target/src/server.mjs" }, "devDependencies": { "@types/node": "^22.19.0", diff --git a/packages/agent/src/analysis-prompt.ts b/packages/agent/src/analysis-prompt.ts new file mode 100644 index 0000000..1fd359a --- /dev/null +++ b/packages/agent/src/analysis-prompt.ts @@ -0,0 +1,93 @@ +import type { AnalysisRequest, BudgetProfile, KnowledgeBase, RepoConfig } from "@claudio/core"; +import type { ChangedSince } from "@claudio/github"; +import { renderKnowledgeSection } from "./prompts.js"; + +export const ANALYSIS_SYSTEM_APPEND = ` +You are Claudio, building the knowledge base for a web application so that later automated reviews know how the app is built and how it is supposed to behave. You read the code, walk through the running app and write what you learn into a small set of Markdown documents with write_knowledge. + +Working rules: +- The documents are for agents and people who review pull requests later. Be concrete: URL paths, file paths, field names, button labels, validation messages, which role sees what. Skip generic statements that would be true of any app. +- Describe observed behaviour as the expected behaviour unless it is clearly a bug. Clear bugs (errors, HTTP 5xx, broken pages) go to report_finding and get a short mention in notes. +- Do not change data that other people rely on. Prefer reading over writing. When a flow can only be understood by submitting a form, create clearly synthetic records named "claudio--" and note each one. Never delete records, change settings, send messages or trigger payments. +- Never type credentials. Use login_as. +- Stay inside the instance under test. +- Write documents early and extend them as you go (write_knowledge with mode "append" for app-map), so a run that hits its budget still leaves a useful knowledge base. +`.trim(); + +export type AnalysisPromptInput = { + runId: string; + request: AnalysisRequest; + config: RepoConfig | null; + repoDir: string | null; + branch: string; + commitSha: string; + knowledgeDir: string; + previous: KnowledgeBase | null; + changedSince: ChangedSince | null; + profile: BudgetProfile; + driverPrefix: string; +}; + +export function buildAnalysisPrompt(input: AnalysisPromptInput): string { + const { request, config, repoDir, profile, previous, changedSince } = input; + const lang = request.language === "de" ? "German" : "English"; + const roles = Object.entries(config?.roles ?? {}); + const sections: string[] = []; + + sections.push(input.driverPrefix); + sections.push( + `# Analysis task\n\nRun id: ${input.runId}\nRepository: ${request.repo}, branch ${input.branch}, commit ${input.commitSha}\nWrite the documents in ${lang}; keep code identifiers, paths and UI labels verbatim.\nBudget: about ${profile.maxTurns} turns and ${profile.maxBrowserSteps} browser steps. ` + + (previous ? "This is an update: spend the budget on what changed and on screens the previous analysis did not cover." : "Cover the main screens and flows first; depth on rarely used admin screens comes last."), + ); + + sections.push( + repoDir + ? `# Repository checkout\n\nThe branch is checked out at ${repoDir}. Read it with Read/Grep/Glob. Delegate the full code scan to the code-scanner subagent so your own context stays free for the browser walk.` + : "# Repository checkout\n\nNo checkout is available. Build modules and routes from what the running app reveals (URLs, navigation, network requests) and say in overview.md that the code was not read.", + ); + + sections.push( + `# Instance\n\nBase URL: ${request.instanceUrl}\n` + + (roles.length + ? `Roles available via login_as: ${roles.map(([n, r]) => `${n}${r.description ? ` (${r.description})` : ""}`).join(", ")}.${request.roles.length ? ` Start with: ${request.roles.join(", ")}.` : ""} Visit the app with every role at least briefly to record which screens and actions each role has.` + : "No roles are configured. Explore what is reachable without login and note in overview.md that authenticated areas were not analysed."), + ); + + sections.push(`# Knowledge base directory\n\nwrite_knowledge stores the documents in ${input.knowledgeDir}. You may Read them there to check what you already wrote.`); + + if (previous) { + sections.push(renderKnowledgeSection(input.knowledgeDir, previous, "analysis")); + if (changedSince) { + const list = changedSince.files.slice(0, 300).map((f) => `- ${f.status} ${f.filename}`).join("\n"); + sections.push( + `# Changes since the previous analysis\n\n${changedSince.totalCommits} commits, ${changedSince.files.length} files${changedSince.truncated ? " (list truncated by GitHub)" : ""}:\n\n${list || "(none)"}\n\nUpdate modules.md and routes.md for these files, then re-check the screens they affect and update their app-map entries. Spot-check a few unaffected screens to confirm the rest still holds.`, + ); + } else { + sections.push("# Changes since the previous analysis\n\nThe list of changed files is not available. Re-check the code structure briefly, then verify the app map screen by screen and correct what changed."); + } + } + + if (config?.notes) sections.push(`# Notes about this application\n\n${config.notes.trim()}`); + if (request.extraInstructions) sections.push(`# Additional instructions for this run\n\n${request.extraInstructions.trim()}`); + + sections.push(`# Procedure + +1. Code: ${repoDir ? `ask the code-scanner subagent to map ${repoDir}${previous && changedSince ? " (focus on the changed files, but return the complete module and route lists)" : ""}. From its report` : "from the app itself"}, write modules.md and routes.md with write_knowledge, and a first version of overview.md. +2. Plan the walk: add one todo_add item per screen or flow from routes.md, ordered by importance for everyday users. Include role-specific areas. +3. Walk the app with todo_next / todo_done. For each screen: log in with a role that can see it, open it, take a snapshot, capture_evidence once for the main state, and try the main actions in a harmless way (open dialogs, sort, filter, submit an empty form to see validation). Check get_console and get_network for errors. Then append one section to app-map with write_knowledge (mode "append") in this format (translate the field labels into ${lang}): + + ## () + - Purpose: one sentence. + - Roles: who sees it, what differs per role. + - Elements: main lists, columns, fields (with required/optional and format), buttons. + - Actions and expected behaviour: one bullet per action, with the feedback the user gets (messages verbatim), validation rules, navigation after success. + - Code: route file and key components (from routes.md). + - Evidence: the evidence id. + +4. Record clear bugs you encounter with report_finding; do not go looking for bugs, that is the reviews' job. +5. Write notes.md: test data that exists and what you created, quirks, slow or flaky areas, what you could not explore and why. +6. Finalise overview.md: purpose, users, stack, domain objects, roles and permissions, login, navigation structure. +7. Finish with a short summary in ${lang} as your final message: what is covered, what is missing, and anything a reviewer should know.`); + + return sections.join("\n\n"); +} diff --git a/packages/agent/src/analysis-runner.ts b/packages/agent/src/analysis-runner.ts new file mode 100644 index 0000000..b83d1f2 --- /dev/null +++ b/packages/agent/src/analysis-runner.ts @@ -0,0 +1,216 @@ +import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { query, type Options, type SDKResultMessage } from "@anthropic-ai/claude-agent-sdk"; +import { RunStore, createClaudioTools, createDriver, startLiveView, type DriverSession } from "@claudio/browser"; +import { + AnalysisRequestSchema, + KNOWLEDGE_DOCS, + KnowledgeDocNameSchema, + analysisProfile, + loadRepoConfig, + newRunId, + nowIso, + type AnalysisReport, + type AnalysisRequest, + type KnowledgeBase, + type KnowledgeDoc, + type RunStatus, +} from "@claudio/core"; +import { changedFilesBetween, createGithubClient, getDefaultBranch, type ChangedSince } from "@claudio/github"; +import { ANALYSIS_SYSTEM_APPEND, buildAnalysisPrompt } from "./analysis-prompt.js"; +import { createBrowserGuard } from "./hooks.js"; +import { cloneBranch, describe, envWithout, mapStatus, scrub, writeKnowledgeFiles, type RunnerDeps } from "./runner.js"; +import { createClaudioSdkServer } from "./sdk-tools.js"; + +export type AnalysisDeps = Omit & { + /** Knowledge base of the previous analysis; the run updates it instead of starting from scratch. */ + previous?: KnowledgeBase | null; + /** Commit sha to record when repoDir is given (no clone). */ + commitSha?: string; +}; + +export async function runAnalysis(input: AnalysisRequest, deps: AnalysisDeps = {}): Promise { + const request = AnalysisRequestSchema.parse(input); + const log = deps.onProgress ?? (() => undefined); + const runId = newRunId(`analysis-${request.repo.split("/")[1]}`); + const runDir = join(resolve(deps.runsRoot ?? "runs"), runId); + const store = new RunStore(runDir); + const knowledgeDir = join(runDir, "knowledge"); + const started = Date.now(); + const errors: string[] = []; + log(`run ${runId} → ${runDir}`); + deps.onRunStarted?.({ runId, runDir }); + + const config = deps.config !== undefined ? deps.config : loadRepoConfig(deps.configPath); + const roleEnvVars = Object.values(config?.roles ?? {}).flatMap((r) => [r.usernameEnv, r.passwordEnv]); + const token = deps.githubToken ?? process.env.GITHUB_TOKEN; + + // Checkout: an existing directory (demo, local) or a fresh clone of the branch. + let repoDir: string | null = null; + let branch = request.branch ?? ""; + let commitSha = deps.commitSha ?? "unknown"; + if (deps.repoDir) { + repoDir = resolve(deps.repoDir); + branch ||= "local"; + } else if (token) { + try { + const github = createGithubClient(token); + branch ||= await getDefaultBranch(github, request.repo); + repoDir = join(runDir, "repo"); + commitSha = await cloneBranch(request.repo, branch, repoDir, token); + log(`checked out ${branch} at ${commitSha.slice(0, 7)}`); + } catch (e) { + repoDir = null; + const msg = `clone failed: ${scrub((e as Error).message, token)}`; + errors.push(msg); + log(msg); + } + } else { + errors.push("No GitHub token: the code was not read."); + } + + // Update mode: start from the previous documents and the list of files changed since. + const previous = deps.previous?.docs.length ? deps.previous : null; + let changedSince: ChangedSince | null = null; + if (previous) { + writeKnowledgeFiles(knowledgeDir, previous); + if (token && !deps.repoDir && commitSha !== "unknown" && previous.commitSha !== commitSha) { + try { + changedSince = await changedFilesBetween(createGithubClient(token), request.repo, previous.commitSha, commitSha); + log(`update from ${previous.commitSha.slice(0, 7)}: ${changedSince.files.length} changed files`); + } catch (e) { + log(`compare failed: ${scrub((e as Error).message, token)}`); + } + } + } + + const profile = analysisProfile(request.budget); + const driver = createDriver("playwright"); + const session: DriverSession = await driver.start({ + runDir, + instanceUrl: request.instanceUrl, + headed: request.headed, + viewport: { width: 1280, height: 800 }, + language: request.language, + }); + log(`browser driver: ${session.name}`); + // Screencast for the live view in the web UI (runDir/live.jpg). + const liveView = session.attachment && deps.liveView !== false ? startLiveView(session.attachment, join(runDir, "live.jpg")) : null; + + const tools = createClaudioTools({ + runDir, + store, + attachment: session.attachment, + instanceUrl: request.instanceUrl, + roles: config?.roles ?? {}, + driver: session.name, + env: deps.roleEnv ? { ...process.env, ...deps.roleEnv } : undefined, + knowledgeDir, + }); + + const guard = createBrowserGuard({ + allowedHosts: [new URL(request.instanceUrl).hostname, ...(config?.extraAllowedHosts ?? [])], + maxBrowserSteps: profile.maxBrowserSteps, + onStep: (n) => { + if (n % 25 === 0) log(`browser steps: ${n}/${profile.maxBrowserSteps}`); + }, + }); + + const prompt = buildAnalysisPrompt({ + runId, + request, + config, + repoDir, + branch, + commitSha, + knowledgeDir, + previous, + changedSince, + profile, + driverPrefix: session.promptPrefix, + }); + store.writeFile("prompt.md", prompt); + + const abortController = deps.abortController ?? new AbortController(); + const options: Options = { + cwd: resolve(deps.projectRoot ?? process.cwd()), + settingSources: ["project"], + systemPrompt: { type: "preset", preset: "claude_code", append: ANALYSIS_SYSTEM_APPEND }, + model: profile.orchestratorModel, + effort: profile.effort, + thinking: { type: "adaptive" }, + maxTurns: profile.maxTurns, + maxBudgetUsd: profile.maxBudgetUsd, + mcpServers: { ...session.mcpServers, claudio: createClaudioSdkServer(tools) }, + strictMcpConfig: true, + allowedTools: [...session.allowedTools, "Read", "Grep", "Glob", "Agent", "TodoWrite"], + disallowedTools: [...session.disallowedTools, "Bash", "Write", "Edit", "MultiEdit", "NotebookEdit", "WebFetch", "WebSearch", "KillShell", "BashOutput"], + permissionMode: "bypassPermissions", + allowDangerouslySkipPermissions: true, + hooks: { PreToolUse: guard.hooks }, + additionalDirectories: [repoDir, knowledgeDir].filter((d): d is string => Boolean(d)), + extraArgs: session.extraArgs, + abortController, + env: envWithout(process.env, roleEnvVars), + stderr: (data) => appendFileSync(join(runDir, "claude-stderr.log"), data), + }; + + const logFile = join(runDir, "log.jsonl"); + let result: SDKResultMessage | undefined; + let sessionId: string | undefined; + let status: RunStatus = "failed"; + try { + for await (const message of query({ prompt, options })) { + appendFileSync(logFile, JSON.stringify(message) + "\n"); + sessionId ??= (message as { session_id?: string }).session_id; + describe(message, log); + if (message.type === "result") result = message; + } + status = mapStatus(result); + if (result && result.subtype !== "success") errors.push(...result.errors); + } catch (e) { + status = abortController.signal.aborted ? "aborted" : "failed"; + errors.push(`agent error: ${(e as Error).message}`); + log(`agent error: ${(e as Error).message}`); + } finally { + await liveView?.stop().catch(() => undefined); + await session.close().catch((e) => errors.push(`driver close: ${(e as Error).message}`)); + } + + const report: AnalysisReport = { + runId, + status, + request, + commitSha, + branch, + previousCommitSha: previous?.commitSha, + summary: result && result.subtype === "success" ? result.result : "", + docs: readKnowledgeFiles(knowledgeDir), + cost: { + usd: result?.total_cost_usd ?? 0, + inputTokens: (result?.usage.input_tokens ?? 0) + (result?.usage.cache_read_input_tokens ?? 0) + (result?.usage.cache_creation_input_tokens ?? 0), + outputTokens: result?.usage.output_tokens ?? 0, + turns: result?.num_turns ?? 0, + durationMs: Date.now() - started, + browserSteps: guard.steps(), + }, + sessionId, + errors, + createdAt: nowIso(), + }; + writeFileSync(join(runDir, "analysis.json"), JSON.stringify(report, null, 2)); + log(`analysis: status ${status}, ${report.docs.length} documents, ${report.cost.usd.toFixed(2)} USD`); + return report; +} + +/** Reads whatever knowledge documents exist in a directory, in the canonical order. */ +export function readKnowledgeFiles(dir: string): KnowledgeDoc[] { + const docs: KnowledgeDoc[] = []; + for (const name of KnowledgeDocNameSchema.options) { + const file = join(dir, KNOWLEDGE_DOCS[name].file); + if (!existsSync(file)) continue; + const content = readFileSync(file, "utf8").trim(); + if (content) docs.push({ name, content }); + } + return docs; +} diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index fba3fa4..ea4f8e2 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -2,3 +2,7 @@ export * from "./runner.js"; export * from "./prompts.js"; export * from "./hooks.js"; export * from "./sdk-tools.js"; +export * from "./analysis-prompt.js"; +export * from "./analysis-runner.js"; +export * from "./judge.js"; +export * from "./test-runner.js"; diff --git a/packages/agent/src/judge.ts b/packages/agent/src/judge.ts new file mode 100644 index 0000000..26a215f --- /dev/null +++ b/packages/agent/src/judge.ts @@ -0,0 +1,102 @@ +import { query, type SDKResultMessage } from "@anthropic-ai/claude-agent-sdk"; +import { JudgeVerdictSchema, describeStep, isAssertion, resolveStepTemplates, type JudgeVerdict, type Language, type StepResult, type TestScript } from "@claudio/core"; + +export type JudgeInput = { + script: Pick; + steps: StepResult[]; + /** Value of {{run}} in this run, so the judge sees the actual test data. */ + runTag: string; + finalUrl: string; + /** Accessibility tree of the final page. */ + finalSnapshot: string; + /** Absolute path of a screenshot of the final state. */ + screenshotPath: string | null; + errors: string[]; + /** What the agent that ran the test reported, if one ran. */ + runnerSummary?: string; + language: Language; + model: string; + runDir: string; + abortController?: AbortController; +}; + +export type JudgeResult = { verdict: JudgeVerdict | null; costUsd: number; error?: string }; + +const VERDICT_JSON_SCHEMA = { + type: "object", + additionalProperties: false, + required: ["passed", "confidence", "deviations", "reasoning"], + properties: { + passed: { type: "boolean", description: "true if every part of the expectation holds in the final state" }, + confidence: { type: "number", minimum: 0, maximum: 1, description: "how sure you are, 0 to 1" }, + deviations: { type: "array", items: { type: "string" }, description: "each part of the expectation that does not hold, concretely" }, + reasoning: { type: "string", description: "two to four sentences on how you checked each part of the expectation" }, + }, +} as const; + +/** + * Independent judge: sees only the expectation and the evidence of the run, not + * the tester's reasoning, and answers with structured output. + */ +export async function runJudge(input: JudgeInput): Promise { + const lang = input.language === "de" ? "German" : "English"; + const show = (r: StepResult) => describeStep(resolveStepTemplates(r.step, { runTag: input.runTag })); + // Failed actions while recording are discarded attempts (wrong locator, retried); only failed checks carry information. + const steps = input.steps.filter((r) => r.ok).map((r, i) => `${i + 1}. ${show(r)}`).join("\n"); + const failedChecks = input.steps.filter((r) => !r.ok && isAssertion(r.step)).map((r) => `- ${show(r)}: ${r.error}`).join("\n"); + const prompt = `You judge whether an automated UI test met its expectation. Decide only from the evidence below${input.screenshotPath ? " and the screenshot (Read it)" : ""}. Do not assume anything that is not shown. + +# Test "${input.script.name}" + +What should be done: +${input.script.description} + +Expectation (what must be true at the end): +${input.script.expectation} + +# Steps that were executed successfully +${steps || "(none)"} + +# Checks that did not hold +${failedChecks || "none"} + +# Final state +URL: ${input.finalUrl} +${input.screenshotPath ? `Screenshot: ${input.screenshotPath}\n` : ""} +Accessibility tree: +${input.finalSnapshot} + +# Errors during the run +${input.errors.length ? input.errors.map((e) => `- ${e}`).join("\n") : "none"} +${input.runnerSummary ? `\n# Note from the tester (may be wrong)\n${input.runnerSummary}\n` : ""} +Rules: passed only if every part of the expectation is evidently satisfied in the final state and the steps. A failed check counts against the test when it concerns the expectation; console noise or checks unrelated to the expectation do not fail the test, but mention them in reasoning. An error page or HTTP 5xx in the flow means not passed. If the evidence is insufficient to decide, set passed false, confidence below 0.5 and say what is missing. Write deviations and reasoning in ${lang}.`; + + let result: SDKResultMessage | undefined; + try { + for await (const m of query({ + prompt, + options: { + model: input.model, + cwd: input.runDir, + settingSources: [], + systemPrompt: "You are a strict, fair QA judge. You answer only with the requested structured verdict.", + outputFormat: { type: "json_schema", schema: VERDICT_JSON_SCHEMA as unknown as Record }, + allowedTools: ["Read"], + disallowedTools: ["Bash", "Write", "Edit", "MultiEdit", "NotebookEdit", "WebFetch", "WebSearch", "Agent", "Glob", "Grep"], + permissionMode: "bypassPermissions", + allowDangerouslySkipPermissions: true, + maxTurns: 6, + maxBudgetUsd: 1, + abortController: input.abortController, + }, + })) { + if (m.type === "result") result = m; + } + } catch (e) { + return { verdict: null, costUsd: 0, error: `judge failed: ${(e as Error).message}` }; + } + const costUsd = result?.total_cost_usd ?? 0; + if (!result || result.subtype !== "success") return { verdict: null, costUsd, error: `judge ended with ${result?.subtype ?? "no result"}` }; + const parsed = JudgeVerdictSchema.safeParse(result.structured_output); + return parsed.success ? { verdict: parsed.data, costUsd } : { verdict: null, costUsd, error: "judge returned an invalid verdict" }; +} diff --git a/packages/agent/src/prompts.ts b/packages/agent/src/prompts.ts index 704d21f..10628dc 100644 --- a/packages/agent/src/prompts.ts +++ b/packages/agent/src/prompts.ts @@ -1,4 +1,4 @@ -import type { BudgetProfile, RepoConfig, ReviewRequest } from "@claudio/core"; +import { KNOWLEDGE_DOCS, type BudgetProfile, type KnowledgeBase, type RepoConfig, type ReviewRequest } from "@claudio/core"; import type { PullRequestContext } from "@claudio/github"; export const SYSTEM_APPEND = ` @@ -24,6 +24,8 @@ export type PromptInput = { profile: BudgetProfile; driverPrefix: string; maxDiffChars?: number; + /** Knowledge base files written for this run. */ + knowledge?: { dir: string; base: KnowledgeBase } | null; }; export function buildReviewPrompt(input: PromptInput): string { @@ -74,24 +76,78 @@ export function buildReviewPrompt(input: PromptInput): string { sections.push(`# Repository checkout\n\nThe PR branch is checked out at ${repoDir}. Use Read/Grep/Glob there for impact analysis (routes, components, shared modules the diff touches). You may delegate the code reading to the impact-analyst subagent.`); } + if (input.knowledge) sections.push(renderKnowledgeSection(input.knowledge.dir, input.knowledge.base, "review")); + if (config?.notes) sections.push(`# Notes about this application\n\n${config.notes.trim()}`); if (request.extraInstructions) sections.push(`# Additional instructions for this run\n\n${request.extraInstructions.trim()}`); - sections.push(`# Procedure + sections.push(profile.delegateChecks ? delegatedProcedure(lang) : singleAgentProcedure(lang)); + + return sections.join("\n\n"); +} + +/** Routes and app map are inlined for reviews up to this size; larger knowledge bases stay files. */ +export const KNOWLEDGE_INLINE_LIMIT = 24_000; + +/** + * Knowledge base section. For reviews, routes.md and app-map.md are inlined when + * they are small, so the impact analysis does not depend on the agent choosing to + * read them; everything else is referenced by path. + */ +export function renderKnowledgeSection(dir: string, base: KnowledgeBase, purpose: "review" | "analysis"): string { + const header = `# Knowledge base (commit ${base.commitSha.slice(0, 7)} on ${base.branch}, analysed ${base.analyzedAt.slice(0, 10)})`; + const files = base.docs.map((d) => `- ${dir}/${KNOWLEDGE_DOCS[d.name].file}: ${KNOWLEDGE_DOCS[d.name].purpose}`).join("\n"); + if (purpose === "analysis") { + return `${header}\n\n${files}\n\nThis is the knowledge base from the previous analysis. Update it rather than starting over: keep what is still true, correct what changed, add what is new.`; + } + + const core = base.docs.filter((d) => d.name === "routes" || d.name === "app-map"); + const inline = core.length > 0 && core.reduce((n, d) => n + d.content.length, 0) <= KNOWLEDGE_INLINE_LIMIT; + const caveat = + "The code may have changed since this analysis; trust the running app and the diff over the knowledge base when they disagree, and note outdated entries with the note tool."; + if (inline) { + const body = core.map((d) => `## ${KNOWLEDGE_DOCS[d.name].file}\n\n${d.content.trim()}`).join("\n\n"); + return `${header}\n\nUse the route index below to map the changed files to screens, and the app map as the expected behaviour of those screens: a deviation from it is a regression candidate. ${caveat}\n\nFurther documents (read when useful):\n${files}\n\n${body}`; + } + return `${header}\n\n${files}\n\nBefore the impact analysis, Read routes.md to map the changed files to screens. Before testing a screen, Read its app-map.md section: it is the expected behaviour, so a deviation is a regression candidate. Read notes.md for test data and quirks. ${caveat}`; +} + +const SUMMARY_STEP = (lang: string) => `Finish with a summary in ${lang} as your final message. Structure it as: + - A verdict sentence. + - Summary (per the verbosity setting). + - A section titled "Affected areas:" with one bullet per affected screen or flow (one line each, keep this heading in English). + - Anything you could not test and why. +Do not repeat every finding in the summary; they are rendered from the records.`; + +/** Quick budget: one agent does everything. */ +function singleAgentProcedure(lang: string): string { + return `# Procedure 1. Extract the acceptance criteria from the issue(s) and PR description. If they are implicit, formulate them explicitly. Add them to the todo list with todo_add, one item per criterion. 2. Impact analysis: from the diff (and the checkout, if available) determine which screens, routes, flows and shared components are affected. Add regression checks for those to the todo list. Keep this proportional to the size of the change. 3. Log in with login_as and build a mental map of the app around the affected areas (snapshot, navigate, read). Do not click through unrelated areas. 4. Work the todo list with todo_next / todo_done. For each criterion: perform the flow, capture_evidence, check get_console and get_network for errors, then record_ac_result. For each regression item: verify the existing behaviour still works and record findings when it does not. 5. Judge UX and UI of the changed screens: consistency with the rest of the app, feedback on actions, error states, wording, layout. You may delegate screenshot review to the ux-reviewer subagent. Record findings with report_finding. -6. Finish with a summary in ${lang} as your final message. Structure it as: - - A verdict sentence. - - Summary (per the verbosity setting). - - A section titled "Affected areas:" with one bullet per affected screen or flow (one line each, keep this heading in English). - - Anything you could not test and why. -Do not repeat every finding in the summary; they are rendered from the records.`); +6. ${SUMMARY_STEP(lang)}`; +} - return sections.join("\n\n"); +/** + * Standard and thorough budgets: separate subagents verify the criteria, check + * regressions and judge the UI, each with a fresh context. They share one + * browser, so they run one after the other. + */ +function delegatedProcedure(lang: string): string { + return `# Procedure + +You coordinate specialised subagents. The browser subagents (ac-verifier, regression-tester) share one browser: run them one after the other, never in parallel. They record their results with the Claudio tools themselves; do not record the same results again. + +1. Extract the acceptance criteria from the issue(s) and PR description. If they are implicit, formulate them explicitly as testable statements, numbered. +2. Impact analysis: map the changed files to screens and flows (route index from the knowledge base, if available). For diffs larger than a handful of files or touching shared modules, delegate this to the impact-analyst subagent. Result: affected areas, each with direct/indirect and one concrete regression check, ordered by risk. +3. Delegate to ac-verifier. Give it: run id, instance URL, roles, the numbered criteria, the app-map entries of the screens involved, and notes on test data. +4. Delegate to regression-tester. Give it: run id, instance URL, roles, the affected areas with their checks, and the app-map entries of those screens. Skip this step when the change is isolated and no area beyond the new feature is affected; say so in the summary. +5. Delegate to ux-reviewer: the evidence file paths of the changed screens (from the evidence directory of this run) with one line on each screen's purpose. Record the observations you agree with via report_finding (category ux or ui), with the evidence ids. +6. Check the records: every criterion has a result (record_ac_result for any the ac-verifier missed, as blocked with the reason). Do not re-test what the subagents covered. +7. ${SUMMARY_STEP(lang)}`; } function renderDiff(pr: PullRequestContext, maxChars: number): string { diff --git a/packages/agent/src/runner.ts b/packages/agent/src/runner.ts index 5429335..b452442 100644 --- a/packages/agent/src/runner.ts +++ b/packages/agent/src/runner.ts @@ -1,9 +1,9 @@ import { execFile } from "node:child_process"; -import { appendFileSync, readFileSync, writeFileSync } from "node:fs"; +import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { promisify } from "node:util"; import { query, type Options, type SDKMessage, type SDKResultMessage } from "@anthropic-ai/claude-agent-sdk"; -import { RunStore, createClaudioTools, createDriver, type DriverSession } from "@claudio/browser"; +import { RunStore, createClaudioTools, createDriver, startLiveView, type DriverSession } from "@claudio/browser"; import { ReviewRequestSchema, budgetProfile, @@ -12,6 +12,9 @@ import { nowIso, renderMarkdown, renderPrComment, + KNOWLEDGE_DOCS, + type KnowledgeBase, + type RepoConfig, type ReviewReport, type ReviewRequest, type RunStatus, @@ -28,6 +31,10 @@ export type RunnerDeps = { runsRoot?: string; /** Path to claudio.config.json. Default: ./claudio.config.json */ configPath?: string; + /** Repo config passed in directly (web worker); takes precedence over configPath. */ + config?: RepoConfig | null; + /** Role credentials keyed by the env var names in the config. Only the Claudio tools see them, never the agent process. */ + roleEnv?: Record; /** Claudio project root (where .claude/agents and skills live). Default: process.cwd() */ projectRoot?: string; githubToken?: string; @@ -37,6 +44,14 @@ export type RunnerDeps = { repoDir?: string; abortController?: AbortController; onProgress?: (line: string) => void; + /** Latest knowledge base of the repository (from a repo analysis), handed to the agent as files. */ + knowledge?: KnowledgeBase | null; + /** Write the browser screencast to runDir/live.jpg for the web UI. Default: true. */ + liveView?: boolean; + /** Link to this review in the web UI, used in the PR comment. */ + reportUrl?: string; + /** Called once the run directory exists, before any network or model call. */ + onRunStarted?: (run: RunHandle) => void; }; export type RunHandle = { runId: string; runDir: string }; @@ -51,8 +66,9 @@ export async function runReview(input: ReviewRequest, deps: RunnerDeps = {}): Pr const started = Date.now(); const errors: string[] = []; log(`run ${runId} → ${runDir}`); + deps.onRunStarted?.({ runId, runDir }); - const config = loadRepoConfig(deps.configPath); + const config = deps.config !== undefined ? deps.config : loadRepoConfig(deps.configPath); if (config && config.repo !== request.repo) log(`warning: claudio.config.json is for ${config.repo}, reviewing ${request.repo}`); const roleEnvVars = Object.values(config?.roles ?? {}).flatMap((r) => [r.usernameEnv, r.passwordEnv]); @@ -94,6 +110,8 @@ export async function runReview(input: ReviewRequest, deps: RunnerDeps = {}): Pr language: request.language, }); log(`browser driver: ${session.name}${session.attachment ? ` (cdp ${session.attachment.cdpEndpoint})` : ""}`); + // Screencast for the live view in the web UI (runDir/live.jpg). + const liveView = session.attachment && deps.liveView !== false ? startLiveView(session.attachment, join(runDir, "live.jpg")) : null; const tools = createClaudioTools({ runDir, @@ -102,6 +120,7 @@ export async function runReview(input: ReviewRequest, deps: RunnerDeps = {}): Pr instanceUrl: request.instanceUrl, roles: config?.roles ?? {}, driver: session.name, + env: deps.roleEnv ? { ...process.env, ...deps.roleEnv } : undefined, }); const claudioServer = createClaudioSdkServer(tools); @@ -114,7 +133,19 @@ export async function runReview(input: ReviewRequest, deps: RunnerDeps = {}): Pr }, }); - const prompt = buildReviewPrompt({ runId, request, pr, config, repoDir, profile, driverPrefix: session.promptPrefix }); + const knowledgeDir = deps.knowledge?.docs.length ? writeKnowledgeFiles(join(runDir, "knowledge"), deps.knowledge) : null; + if (knowledgeDir && deps.knowledge) log(`knowledge base from ${deps.knowledge.commitSha.slice(0, 7)} (${deps.knowledge.docs.length} documents)`); + + const prompt = buildReviewPrompt({ + runId, + request, + pr, + config, + repoDir, + profile, + driverPrefix: session.promptPrefix, + knowledge: knowledgeDir && deps.knowledge ? { dir: knowledgeDir, base: deps.knowledge } : null, + }); store.writeFile("prompt.md", prompt); const abortController = deps.abortController ?? new AbortController(); @@ -135,7 +166,7 @@ export async function runReview(input: ReviewRequest, deps: RunnerDeps = {}): Pr permissionMode: "bypassPermissions", allowDangerouslySkipPermissions: true, hooks: { PreToolUse: guard.hooks }, - additionalDirectories: repoDir ? [repoDir] : [], + additionalDirectories: [repoDir, knowledgeDir].filter((d): d is string => Boolean(d)), extraArgs: session.extraArgs, abortController, env: envWithout(process.env, roleEnvVars), @@ -160,6 +191,7 @@ export async function runReview(input: ReviewRequest, deps: RunnerDeps = {}): Pr errors.push(`agent error: ${(e as Error).message}`); log(`agent error: ${(e as Error).message}`); } finally { + await liveView?.stop().catch(() => undefined); await session.close().catch((e) => errors.push(`driver close: ${(e as Error).message}`)); } @@ -196,7 +228,9 @@ export async function runReview(input: ReviewRequest, deps: RunnerDeps = {}): Pr if (request.postComment) { try { - const url = await postPullRequestComment(createGithubClient(token), request.repo, request.pr, renderPrComment(report)); + const url = await postPullRequestComment(createGithubClient(token), request.repo, request.pr, renderPrComment(report, deps.reportUrl)); + report.commentUrl = url; + writeFileSync(join(runDir, "report.json"), JSON.stringify(report, null, 2)); log(`PR comment posted: ${url}`); } catch (e) { const msg = `PR comment failed: ${(e as Error).message}`; @@ -208,7 +242,14 @@ export async function runReview(input: ReviewRequest, deps: RunnerDeps = {}): Pr return report; } -function mapStatus(result: SDKResultMessage | undefined): RunStatus { +/** Writes the knowledge documents as Markdown files and returns the directory. */ +export function writeKnowledgeFiles(dir: string, knowledge: KnowledgeBase): string { + mkdirSync(dir, { recursive: true }); + for (const doc of knowledge.docs) writeFileSync(join(dir, KNOWLEDGE_DOCS[doc.name].file), doc.content); + return dir; +} + +export function mapStatus(result: SDKResultMessage | undefined): RunStatus { if (!result) return "failed"; switch (result.subtype) { case "success": @@ -222,7 +263,7 @@ function mapStatus(result: SDKResultMessage | undefined): RunStatus { } } -function describe(message: SDKMessage, log: (line: string) => void): void { +export function describe(message: SDKMessage, log: (line: string) => void): void { if (message.type === "system" && message.subtype === "init") { log(`session ${message.session_id} model=${message.model} mcp=${message.mcp_servers.map((s) => `${s.name}:${s.status}`).join(",")}`); return; @@ -242,23 +283,26 @@ function describe(message: SDKMessage, log: (line: string) => void): void { } } -function short(text: string, max: number): string { +export function short(text: string, max: number): string { return text.length <= max ? text : text.slice(0, max - 1) + "…"; } -async function cloneBranch(repo: string, branch: string, dir: string, token: string): Promise { +/** Shallow clone of one branch. Returns the checked-out commit sha. */ +export async function cloneBranch(repo: string, branch: string, dir: string, token: string): Promise { const url = `https://x-access-token:${token}@github.com/${repo}.git`; - await execFileAsync("git", ["clone", "--depth", "1", "--branch", branch, "--single-branch", url, dir], { - env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, - maxBuffer: 10 * 1024 * 1024, - }); + const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" }; + await execFileAsync("git", ["clone", "--depth", "1", "--branch", branch, "--single-branch", url, dir], { env, maxBuffer: 10 * 1024 * 1024 }); + // The token is part of the remote URL; drop it so it does not linger in .git/config. + await execFileAsync("git", ["-C", dir, "remote", "set-url", "origin", `https://github.com/${repo}.git`], { env }); + const { stdout } = await execFileAsync("git", ["-C", dir, "rev-parse", "HEAD"], { env }); + return stdout.trim(); } -function scrub(text: string, secret?: string): string { +export function scrub(text: string, secret?: string): string { return secret ? text.split(secret).join("***") : text; } -function envWithout(env: NodeJS.ProcessEnv, keys: string[]): Record { +export function envWithout(env: NodeJS.ProcessEnv, keys: string[]): Record { const out: Record = { ...env }; for (const k of keys) delete out[k]; return out; diff --git a/packages/agent/src/test-runner.ts b/packages/agent/src/test-runner.ts new file mode 100644 index 0000000..62d5480 --- /dev/null +++ b/packages/agent/src/test-runner.ts @@ -0,0 +1,392 @@ +import { appendFileSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { query, type Options, type SDKResultMessage } from "@anthropic-ai/claude-agent-sdk"; +import { + RunStore, + StepRecorder, + ariaSnapshot, + createClaudioTools, + createDriver, + createTestTools, + executeStep, + startLiveView, + testPage, + type StepContext, +} from "@claudio/browser"; +import { + TestRunRequestSchema, + describeStep, + newRunId, + nowIso, + testProfile, + type BudgetProfile, + type JudgeVerdict, + type KnowledgeBase, + type RepoConfig, + type StepResult, + type TestMode, + type TestOutcome, + type TestRunReport, + type TestRunRequest, + type TestScript, + type TestStep, +} from "@claudio/core"; +import { runJudge } from "./judge.js"; +import { renderKnowledgeSection } from "./prompts.js"; +import { describe, envWithout, mapStatus, writeKnowledgeFiles, type RunHandle } from "./runner.js"; +import { createClaudioSdkServer } from "./sdk-tools.js"; + +export type TestRunnerDeps = { + runsRoot?: string; + projectRoot?: string; + config: RepoConfig | null; + roleEnv?: Record; + knowledge?: KnowledgeBase | null; + abortController?: AbortController; + onProgress?: (line: string) => void; + onRunStarted?: (run: RunHandle) => void; + liveView?: boolean; +}; + +const TEST_SYSTEM_APPEND = ` +You are Claudio, running an automated UI test of a web application. You perform the test exactly as described, through the test_step tool only: Claudio executes every step itself and records the successful ones as a replayable script. Use snapshot to see the page's roles and accessible names and pick targets from it. + +Rules for good recordings: +- Prefer {role, name} targets exactly as they appear in the snapshot; fall back to label, placeholder, text, testId, and css only when nothing else identifies the element uniquely. +- Start with login (if the flow needs a role) and goto, so a replay starts from a clean state. +- Put the expectation into assertion steps (expect_text, expect_visible, expect_url, expect_no_errors) wherever it can be checked mechanically. These assertions are what a replay checks without a model. +- Assertions must hold on every future run. Never assert values that change between runs: generated ids ("Fahrt #10"), timestamps, counters, totals. Assert the stable part ("angelegt") or the values you entered yourself (with {{run}}), e.g. expect_text "claudio-{{run}}" in the list. +- Locate rows and cells by the text you entered, not by their full content: {text: "claudio-{{run}}"} rather than a row name that includes ids or dates. +- Use {{run}} in names of records you create so repeated runs do not collide, and {{date:+N:FORMAT}} for dates (FORMAT as the app expects, e.g. DD.MM.YYYY or YYYY-MM-DD). +- Do not explore beyond the flow. Never type credentials; use login. +- When the flow is done, call finish_test, then end with a short summary. +`.trim(); + +/** Short tag for {{run}}: time of day plus the run id's random suffix. */ +function runTagOf(runId: string): string { + return runId.split("-").slice(-2).join(""); +} + +export async function runTest(script: TestScript, input: TestRunRequest, deps: TestRunnerDeps): Promise { + const request = TestRunRequestSchema.parse(input); + const log = deps.onProgress ?? (() => undefined); + const runId = newRunId(`test-${script.id}`); + const runDir = join(resolve(deps.runsRoot ?? "runs"), runId); + const store = new RunStore(runDir); + const started = Date.now(); + const errors: string[] = []; + const profile = testProfile(request.budget); + const abortController = deps.abortController ?? new AbortController(); + log(`run ${runId} → ${runDir}`); + deps.onRunStarted?.({ runId, runDir }); + + const mode: TestMode = request.rerecord || !script.steps?.length ? "record" : script.determinism === "explorative" ? "explore" : "replay"; + log(`mode: ${mode === "record" ? "record (agent performs and records the flow)" : mode === "explore" ? "explore (agent runs the flow freely)" : `replay of ${script.steps!.length} steps without a model`}`); + + const session = await createDriver("playwright").start({ + runDir, + instanceUrl: request.instanceUrl, + headed: request.headed, + viewport: { width: 1280, height: 800 }, + language: request.language, + }); + const attachment = session.attachment; + if (!attachment) throw new Error("The Playwright driver did not provide a browser attachment."); + const liveView = deps.liveView !== false ? startLiveView(attachment, join(runDir, "live.jpg")) : null; + + const ctx: StepContext = { + attachment, + instanceUrl: request.instanceUrl, + roles: deps.config?.roles ?? {}, + env: { ...process.env, ...(deps.roleEnv ?? {}) }, + runTag: runTagOf(runId), + store, + baselineSeq: attachment.capture.lastSeq, + }; + await testPage(attachment); + + let steps: StepResult[] = []; + let compiledSteps: TestStep[] | undefined; + let healedAt: number | undefined; + let determinismProposal: TestRunReport["determinismProposal"]; + let runnerSummary: string | undefined; + let agentCost = 0; + let agentTurns = 0; + let status: TestRunReport["status"] = "completed"; + let assertionFailure: StepResult | undefined; + + const agentRun = async (prompt: string, recorder: StepRecorder, healing: boolean) => { + const res = await runTestAgent({ prompt, recorder, healing, ctx, store, runDir, profile, deps, abortController, log, language: request.language }); + agentCost += res.costUsd; + agentTurns += res.turns; + if (res.status !== "completed") status = res.status; + errors.push(...res.errors); + }; + + try { + if (mode === "replay") { + const planned = script.steps!; + for (let i = 0; i < planned.length; i++) { + if (abortController.signal.aborted) { + status = "aborted"; + break; + } + const step = planned[i]!; + const out = await executeStep(step, ctx); + const result: StepResult = { index: i, step, ok: out.ok, error: out.error, durationMs: out.durationMs, healed: false }; + steps.push(result); + log(`${out.ok ? "✓" : "✗"} ${i + 1}/${planned.length} ${describeStep(step)}${out.error ? `: ${out.error}` : ""}`); + if (out.ok) continue; + if (out.assertionFailed) { + // Semi-deterministic: the judge decides, a single brittle check must not end the run. + if (script.determinism === "semi") continue; + assertionFailure = result; + break; + } + // A step that cannot be executed (element gone, renamed, moved): let the agent heal from here. + healedAt = i; + log(`step ${i + 1} broke, the agent takes over from here`); + const recorder = new StepRecorder(planned.slice(0, i)); + await agentRun(healPrompt(script, planned, i, out.error ?? "", request, deps), recorder, true); + steps = [...steps.slice(0, i), ...recorder.results]; + runnerSummary = recorder.finish?.summary; + if (recorder.finish?.complete) compiledSteps = recorder.compiled(); + store.addFinding({ + category: "regression", + severity: "minor", + title: `Testablauf musste angepasst werden: ${describeStep(step)}`, + description: `Schritt ${i + 1} ließ sich nicht mehr ausführen (${out.error}). Claudio hat den Ablauf ab dort neu aufgezeichnet. Prüfe, ob die Änderung an der Oberfläche beabsichtigt war.`, + steps: planned.slice(0, i + 1).map(describeStep), + expected: describeStep(step), + actual: out.error, + evidenceIds: [], + }); + break; + } + } else { + const recorder = new StepRecorder(); + await agentRun(mode === "record" ? recordPrompt(script, request, deps) : explorePrompt(script, request, deps), recorder, false); + steps = recorder.results; + runnerSummary = recorder.finish?.summary; + determinismProposal = mode === "record" ? recorder.finish?.determinism : undefined; + if (mode === "record" && recorder.finish?.complete && recorder.recorded.length > 0) compiledSteps = recorder.compiled(); + } + } catch (e) { + status = abortController.signal.aborted ? "aborted" : "failed"; + errors.push(`test error: ${(e as Error).message}`); + log(`test error: ${(e as Error).message}`); + } + + // Final state as evidence for people and for the judge. + let finalUrl = ""; + let finalSnapshot = ""; + let screenshotPath: string | null = null; + try { + const page = await testPage(attachment); + finalUrl = page.url(); + finalSnapshot = await ariaSnapshot(attachment, 12_000); + const id = store.nextEvidenceId(); + screenshotPath = join(store.evidenceDir, `${id}-Endzustand.png`); + await page.screenshot({ path: screenshotPath }); + store.addEvidence({ id, kind: "screenshot", label: "Endzustand", path: `evidence/${id}-Endzustand.png`, pageUrl: finalUrl }); + } catch { + // browser already gone (abort) + } + const consoleNetwork = [ + ...attachment.capture.queryConsole({ level: "error", sinceSeq: ctx.baselineSeq, limit: 10 }).map((e) => `console: ${e.text.slice(0, 200)}`), + ...attachment.capture.queryNetwork({ onlyErrors: true, onlyApi: true, sinceSeq: ctx.baselineSeq, limit: 10 }).map((e) => `${e.method} ${e.url} → ${e.failed ?? e.status}`), + ]; + + await liveView?.stop().catch(() => undefined); + await session.close().catch((e) => errors.push(`driver close: ${(e as Error).message}`)); + + // Outcome. A deterministic replay without failures needs no model at all. + let outcome: TestOutcome = "inconclusive"; + let judge: JudgeVerdict | undefined; + const allOk = steps.length > 0 && steps.every((s) => s.ok); + const needsJudge = status === "completed" && !assertionFailure && (mode !== "replay" || healedAt !== undefined || script.determinism === "semi"); + if (status !== "completed") { + outcome = "inconclusive"; + } else if (assertionFailure) { + outcome = "failed"; + } else if (!needsJudge) { + outcome = allOk ? "passed" : "failed"; + } else { + log("judge: comparing the final state with the expectation"); + const j = await runJudge({ + script, + steps, + runTag: ctx.runTag, + finalUrl, + finalSnapshot, + screenshotPath, + errors: consoleNetwork, + runnerSummary, + language: request.language, + model: profile.orchestratorModel, + runDir, + abortController, + }); + agentCost += j.costUsd; + if (j.error) errors.push(j.error); + judge = j.verdict ?? undefined; + outcome = !judge ? "inconclusive" : judge.passed ? (judge.confidence >= 0.5 ? "passed" : "inconclusive") : judge.confidence >= 0.5 ? "failed" : "inconclusive"; + if (judge) log(`judge: ${judge.passed ? "passed" : "not passed"} (confidence ${judge.confidence.toFixed(2)})`); + } + + const summary = + runnerSummary ?? + (assertionFailure + ? `Prüfung in Schritt ${assertionFailure.index + 1} fehlgeschlagen: ${assertionFailure.error}` + : outcome === "passed" + ? `Alle ${steps.length} Schritte und Prüfungen bestanden.` + : ""); + + const report: TestRunReport = { + runId, + status, + mode, + outcome, + healedAt, + steps, + compiledSteps, + determinismProposal, + judge, + summary, + findings: store.findings(), + evidence: store.evidence(), + finalUrl, + cost: { + usd: agentCost, + inputTokens: 0, + outputTokens: 0, + turns: agentTurns, + durationMs: Date.now() - started, + browserSteps: steps.length, + }, + errors, + createdAt: nowIso(), + }; + writeFileSync(join(runDir, "test-run.json"), JSON.stringify(report, null, 2)); + log(`test: ${outcome} (${mode}${healedAt !== undefined ? ", healed" : ""}), ${agentCost.toFixed(2)} USD`); + return report; +} + +// ---------------------------------------------------------------- agent session + +async function runTestAgent(a: { + prompt: string; + recorder: StepRecorder; + healing: boolean; + ctx: StepContext; + store: RunStore; + runDir: string; + profile: BudgetProfile; + deps: TestRunnerDeps; + abortController: AbortController; + log: (line: string) => void; + language: "de" | "en"; +}): Promise<{ status: TestRunReport["status"]; costUsd: number; turns: number; errors: string[] }> { + const base = createClaudioTools({ + runDir: a.runDir, + store: a.store, + attachment: a.ctx.attachment, + instanceUrl: a.ctx.instanceUrl, + roles: a.ctx.roles, + driver: "playwright", + env: a.ctx.env, + }).filter((t) => ["capture_evidence", "get_console", "get_network", "note", "report_finding"].includes(t.name)); + const tools = [...createTestTools(a.ctx, a.recorder, { healing: a.healing }), ...base]; + + let knowledgeDir: string | null = null; + if (a.deps.knowledge?.docs.length) knowledgeDir = writeKnowledgeFiles(join(a.runDir, "knowledge"), a.deps.knowledge); + const roleEnvVars = Object.values(a.deps.config?.roles ?? {}).flatMap((r) => [r.usernameEnv, r.passwordEnv]); + + const options: Options = { + cwd: resolve(a.deps.projectRoot ?? process.cwd()), + settingSources: ["project"], + systemPrompt: { type: "preset", preset: "claude_code", append: TEST_SYSTEM_APPEND }, + model: a.profile.orchestratorModel, + effort: a.profile.effort, + thinking: { type: "adaptive" }, + maxTurns: a.profile.maxTurns, + maxBudgetUsd: a.profile.maxBudgetUsd, + mcpServers: { claudio: createClaudioSdkServer(tools) }, + strictMcpConfig: true, + allowedTools: ["mcp__claudio__*", "Read", "Grep", "Glob"], + disallowedTools: ["Bash", "Write", "Edit", "MultiEdit", "NotebookEdit", "WebFetch", "WebSearch", "KillShell", "BashOutput", "Agent"], + permissionMode: "bypassPermissions", + allowDangerouslySkipPermissions: true, + additionalDirectories: knowledgeDir ? [knowledgeDir] : [], + abortController: a.abortController, + env: envWithout(process.env, roleEnvVars), + stderr: (data) => appendFileSync(join(a.runDir, "claude-stderr.log"), data), + }; + const fullPrompt = knowledgeDir && a.deps.knowledge ? `${a.prompt}\n\n${renderKnowledgeSection(knowledgeDir, a.deps.knowledge, "review")}` : a.prompt; + a.store.writeFile(a.healing ? "prompt-heal.md" : "prompt.md", fullPrompt); + + let result: SDKResultMessage | undefined; + const errors: string[] = []; + let status: TestRunReport["status"] = "failed"; + try { + for await (const m of query({ prompt: fullPrompt, options })) { + appendFileSync(join(a.runDir, "log.jsonl"), JSON.stringify(m) + "\n"); + describe(m, a.log); + if (m.type === "result") result = m; + } + status = mapStatus(result); + if (result && result.subtype !== "success") errors.push(...result.errors); + } catch (e) { + status = a.abortController.signal.aborted ? "aborted" : "failed"; + errors.push(`agent error: ${(e as Error).message}`); + } + return { status, costUsd: result?.total_cost_usd ?? 0, turns: result?.num_turns ?? 0, errors }; +} + +// ---------------------------------------------------------------- prompts + +function scriptBlock(script: TestScript, request: TestRunRequest, deps: TestRunnerDeps): string { + const roles = Object.entries(deps.config?.roles ?? {}); + return `# Test "${script.name}" + +## What to do +${script.description.trim()} + +## Expectation (what must be true at the end) +${script.expectation.trim()} + +## Instance +Base URL: ${request.instanceUrl} +Roles for login: ${roles.length ? roles.map(([n, r]) => `${n}${r.description ? ` (${r.description})` : ""}`).join(", ") : "none configured"}${script.roles.length ? `. Use: ${script.roles.join(", ")}` : ""}. +${deps.config?.notes ? `\n## Notes about the application\n${deps.config.notes.trim()}\n` : ""} +Write summaries in ${request.language === "de" ? "German" : "English"}.`; +} + +function recordPrompt(script: TestScript, request: TestRunRequest, deps: TestRunnerDeps): string { + return `${scriptBlock(script, request, deps)} + +# Your task: record this test +Perform the flow with test_step, step by step, starting from login/goto. After each action take a snapshot when you need to see the result. Express every checkable part of the expectation as an assertion step. If the app does not behave as expected, still finish the flow as far as possible, report_finding for the bug with evidence, and call finish_test with outcome failed. Then call finish_test with your determinism proposal.`; +} + +function explorePrompt(script: TestScript, request: TestRunRequest, deps: TestRunnerDeps): string { + const hint = script.steps?.length ? `\n\nA previous run did it like this (adapt as needed):\n${script.steps.map((s, i) => `${i + 1}. ${describeStep(s)}`).join("\n")}` : ""; + return `${scriptBlock(script, request, deps)} + +# Your task: run this test +This test is explorative: the flow may need different choices each time. Perform it with test_step, check the expectation with assertion steps where possible, report_finding for bugs, and call finish_test.${hint}`; +} + +function healPrompt(script: TestScript, planned: TestStep[], brokenAt: number, error: string, request: TestRunRequest, deps: TestRunnerDeps): string { + const list = planned.map((s, i) => `${i + 1}. ${i < brokenAt ? "(done) " : i === brokenAt ? "(BROKE) " : ""}${describeStep(s)}`).join("\n"); + return `${scriptBlock(script, request, deps)} + +# Your task: repair a broken replay +This test was recorded earlier. Replaying it, steps 1 to ${brokenAt} worked, step ${brokenAt + 1} failed: +${error} + +Recorded steps: +${list} + +The browser is in the state after step ${brokenAt}. Take a snapshot, find out what changed (renamed or moved element, extra dialog, different navigation), and continue the flow from here with test_step until the end, including the assertions. Keep the intent of the original steps. If the change looks like a bug rather than an intended UI change, report_finding. Then call finish_test (complete: true if you reached the end of the flow).`; +} diff --git a/packages/agent/test/analysis.test.ts b/packages/agent/test/analysis.test.ts new file mode 100644 index 0000000..75b1344 --- /dev/null +++ b/packages/agent/test/analysis.test.ts @@ -0,0 +1,85 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, describe, expect, it } from "vitest"; +import { analysisProfile, AnalysisRequestSchema, type KnowledgeBase } from "@claudio/core"; +import { writeKnowledgeTool } from "@claudio/browser"; +import { buildAnalysisPrompt } from "../src/analysis-prompt.js"; +import { readKnowledgeFiles } from "../src/analysis-runner.js"; +import { KNOWLEDGE_INLINE_LIMIT, renderKnowledgeSection } from "../src/prompts.js"; +import { writeKnowledgeFiles } from "../src/runner.js"; + +const tmp = mkdtempSync(join(tmpdir(), "claudio-knowledge-")); +afterAll(() => rmSync(tmp, { recursive: true, force: true })); + +const base: KnowledgeBase = { + commitSha: "abc1234def", + branch: "main", + analyzedAt: "2026-10-01T10:00:00.000Z", + docs: [ + { name: "routes", content: "- `/trips` → src/routes/trips/+page.svelte" }, + { name: "app-map", content: "## Fahrten (/trips)" }, + ], +}; + +const prompt = (overrides: Partial[0]> = {}) => + buildAnalysisPrompt({ + runId: "analysis-x", + request: AnalysisRequestSchema.parse({ repo: "o/r", instanceUrl: "https://staging.example.test", roles: ["dispatcher"] }), + config: { repo: "o/r", roles: { dispatcher: { usernameEnv: "U", passwordEnv: "P", loginPath: "/login", description: "Disponent" } }, extraAllowedHosts: [] }, + repoDir: "/tmp/repo", + branch: "main", + commitSha: "fff0000", + knowledgeDir: "/tmp/run/knowledge", + previous: null, + changedSince: null, + profile: analysisProfile("quick"), + driverPrefix: "", + ...overrides, + }); + +describe("analysis prompt", () => { + it("delegates the code scan and names roles for a first analysis", () => { + const p = prompt(); + expect(p).toContain("code-scanner subagent to map /tmp/repo"); + expect(p).toContain("dispatcher (Disponent)"); + expect(p).not.toContain("Changes since the previous analysis"); + }); + it("lists changed files when updating", () => { + const p = prompt({ previous: base, changedSince: { files: [{ filename: "src/routes/trips/+page.svelte", status: "modified" }], totalCommits: 3, truncated: false } }); + expect(p).toContain("commit abc1234 on main"); + expect(p).toContain("- modified src/routes/trips/+page.svelte"); + expect(p).toContain("Update it rather than starting over"); + }); + it("falls back to the app when there is no checkout", () => { + expect(prompt({ repoDir: null })).toContain("No checkout is available"); + }); +}); + +describe("knowledge files", () => { + it("round-trips documents in canonical order", () => { + const dir = writeKnowledgeFiles(join(tmp, "a"), base); + expect(readKnowledgeFiles(dir).map((d) => d.name)).toEqual(["routes", "app-map"]); + }); + it("write_knowledge replaces and appends", async () => { + const dir = join(tmp, "b"); + const tool = writeKnowledgeTool(dir); + await tool.handler({ doc: "app-map", content: "## A", mode: "replace" }); + await tool.handler({ doc: "app-map", content: "## B", mode: "append" }); + expect(readFileSync(join(dir, "app-map.md"), "utf8")).toBe("## A\n\n## B\n"); + await tool.handler({ doc: "app-map", content: "## C", mode: "replace" }); + expect(readFileSync(join(dir, "app-map.md"), "utf8")).toBe("## C\n"); + }); + it("review section inlines small route index and app map", () => { + const s = renderKnowledgeSection("/run/knowledge", base, "review"); + expect(s).toContain("## routes.md"); + expect(s).toContain("src/routes/trips/+page.svelte"); + expect(s).toContain("/run/knowledge/app-map.md"); + }); + it("review section only references large knowledge bases", () => { + const big = { ...base, docs: [{ name: "app-map" as const, content: "x".repeat(KNOWLEDGE_INLINE_LIMIT + 1) }] }; + const s = renderKnowledgeSection("/run/knowledge", big, "review"); + expect(s).not.toContain("## app-map.md"); + expect(s).toContain("Read routes.md"); + }); +}); diff --git a/packages/agent/test/prompts.test.ts b/packages/agent/test/prompts.test.ts index 9510e6c..31ead2e 100644 --- a/packages/agent/test/prompts.test.ts +++ b/packages/agent/test/prompts.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { extractAffectedAreas } from "../src/prompts.js"; +import { ReviewRequestSchema, budgetProfile } from "@claudio/core"; +import { buildReviewPrompt, extractAffectedAreas } from "../src/prompts.js"; import { createBrowserGuard } from "../src/hooks.js"; describe("extractAffectedAreas", () => { @@ -46,3 +47,26 @@ describe("browser guard", () => { expect((over as { hookSpecificOutput?: { permissionDecision?: string } }).hookSpecificOutput?.permissionDecision).toBe("deny"); }); }); + +describe("review procedure", () => { + const input = (tier: "quick" | "standard") => ({ + runId: "r1", + request: ReviewRequestSchema.parse({ repo: "o/r", pr: 1, instanceUrl: "https://pr-1.example.test", budget: tier }), + pr: { owner: "o", repo: "r", number: 1, title: "T", body: "", url: "u", headRef: "f", headSha: "s", baseRef: "main", draft: false, files: [], issues: [] }, + config: null, + repoDir: null, + profile: budgetProfile(tier), + driverPrefix: "", + }); + it("keeps one agent on the quick budget", () => { + const p = buildReviewPrompt(input("quick")); + expect(p).not.toContain("Delegate to ac-verifier"); + expect(p).toContain("Work the todo list"); + }); + it("delegates checks one after the other on the standard budget", () => { + const p = buildReviewPrompt(input("standard")); + expect(p).toContain("Delegate to ac-verifier"); + expect(p).toContain("Delegate to regression-tester"); + expect(p).toContain("never in parallel"); + }); +}); diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 55bdaa1..0b09455 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -4,6 +4,9 @@ export * from "./chrome-driver.js"; export * from "./capture.js"; export * from "./run-store.js"; export * from "./login.js"; +export * from "./live-view.js"; +export * from "./steps.js"; +export * from "./tools/test-tools.js"; export * from "./tools/definitions.js"; import type { DriverName } from "@claudio/core"; diff --git a/packages/browser/src/live-view.ts b/packages/browser/src/live-view.ts new file mode 100644 index 0000000..b6890ef --- /dev/null +++ b/packages/browser/src/live-view.ts @@ -0,0 +1,86 @@ +import { renameSync, rmSync, writeFileSync } from "node:fs"; +import type { CDPSession, Page } from "playwright"; +import type { BrowserAttachment } from "./driver.js"; + +export type LiveView = { stop(): Promise }; + +export type LiveViewOptions = { + /** Upper bound for writes to disk; Chromium only sends frames when the page changes. */ + maxFps?: number; + quality?: number; + maxWidth?: number; + maxHeight?: number; +}; + +/** + * Mirrors what the agent's browser shows into one JPEG file (Chromium screencast + * over CDP). The file is replaced atomically, so a reader never sees half a frame. + * Follows the active tab. Costs no model tokens. + */ +export function startLiveView(attachment: BrowserAttachment, file: string, opts: LiveViewOptions = {}): LiveView { + const minInterval = 1000 / (opts.maxFps ?? 4); + const tmp = `${file}.tmp`; + let session: CDPSession | null = null; + let page: Page | null = null; + let lastWrite = 0; + let stopped = false; + + const attach = async (next: Page) => { + if (stopped || next === page) return; + await detach(); + page = next; + try { + const s = await attachment.context.newCDPSession(next); + session = s; + s.on("Page.screencastFrame", (frame: { data: string; sessionId: number }) => { + s.send("Page.screencastFrameAck", { sessionId: frame.sessionId }).catch(() => undefined); + const now = Date.now(); + if (now - lastWrite < minInterval) return; + lastWrite = now; + try { + writeFileSync(tmp, Buffer.from(frame.data, "base64")); + renameSync(tmp, file); + } catch { + // disk hiccup: skip this frame + } + }); + await s.send("Page.startScreencast", { + format: "jpeg", + quality: opts.quality ?? 60, + maxWidth: opts.maxWidth ?? 1280, + maxHeight: opts.maxHeight ?? 800, + everyNthFrame: 1, + }); + } catch { + // page closed before the session was ready; the poll below picks the next one + session = null; + page = null; + } + }; + + const detach = async () => { + const s = session; + session = null; + page = null; + if (!s) return; + await s.send("Page.stopScreencast").catch(() => undefined); + await s.detach().catch(() => undefined); + }; + + // The agent opens and closes tabs through its own CDP connection; follow the newest one. + const poll = setInterval(() => { + const active = attachment.activePage(); + if (active && active !== page) void attach(active); + }, 1000); + const first = attachment.activePage(); + if (first) void attach(first); + + return { + async stop() { + stopped = true; + clearInterval(poll); + await detach(); + rmSync(tmp, { force: true }); + }, + }; +} diff --git a/packages/browser/src/steps.ts b/packages/browser/src/steps.ts new file mode 100644 index 0000000..887725c --- /dev/null +++ b/packages/browser/src/steps.ts @@ -0,0 +1,169 @@ +import { join } from "node:path"; +import type { Locator, Page } from "playwright"; +import { describeStep, resolveCredentials, resolveStepTemplates, type RoleConfig, type Target, type TestStep } from "@claudio/core"; +import type { BrowserAttachment } from "./driver.js"; +import { loginWithRole } from "./login.js"; +import type { RunStore } from "./run-store.js"; + +export type StepContext = { + attachment: BrowserAttachment; + instanceUrl: string; + roles: Record; + env: NodeJS.ProcessEnv; + /** Short unique tag of this run, substituted for {{run}}. */ + runTag: string; + store: RunStore; + /** Console/network sequence number at the start of the test, for expect_no_errors. */ + baselineSeq: number; + actionTimeoutMs?: number; + expectTimeoutMs?: number; +}; + +export type StepOutcome = { + ok: boolean; + error?: string; + /** The failure is an assertion (the app behaves differently), not a missing element. */ + assertionFailed?: boolean; + durationMs: number; +}; + +/** The page the test drives: the newest open tab, or a fresh one. */ +export async function testPage(attachment: BrowserAttachment): Promise { + return attachment.activePage() ?? (await attachment.context.newPage()); +} + +export function locate(page: Page, t: Target): Locator { + if ("role" in t) return page.getByRole(t.role as Parameters[0], { name: t.name, exact: t.exact }); + if ("label" in t) return page.getByLabel(t.label, { exact: t.exact }); + if ("placeholder" in t) return page.getByPlaceholder(t.placeholder); + if ("text" in t) return page.getByText(t.text, { exact: t.exact }); + if ("testId" in t) return page.getByTestId(t.testId); + return page.locator(t.css); +} + +/** + * Executes one step with Playwright directly (no model). Recording and replay + * both go through here, so a recorded step replays exactly as it ran. + */ +export async function executeStep(recorded: TestStep, ctx: StepContext): Promise { + const started = Date.now(); + const actionTimeout = ctx.actionTimeoutMs ?? 8_000; + const expectTimeout = ctx.expectTimeoutMs ?? 5_000; + // Templates ({{run}}, {{date:…}}) may appear in values, texts and target names. + const step = resolveStepTemplates(recorded, { runTag: ctx.runTag }); + const done = (ok: boolean, error?: string, assertionFailed = false): StepOutcome => ({ ok, error, assertionFailed, durationMs: Date.now() - started }); + + try { + const page = await testPage(ctx.attachment); + switch (step.action) { + case "login": { + const role = ctx.roles[step.role]; + if (!role) return done(false, `Unknown role "${step.role}". Configured: ${Object.keys(ctx.roles).join(", ") || "none"}.`); + const creds = resolveCredentials(role, ctx.env); + if (!creds) return done(false, `Credentials for role "${step.role}" are missing.`); + await ctx.attachment.context.clearCookies().catch(() => undefined); + const r = await loginWithRole(ctx.attachment.context, ctx.instanceUrl, role, creds); + return r.ok ? done(true) : done(false, `Login failed: ${r.error}`); + } + case "goto": { + const url = new URL(step.path, ctx.instanceUrl); + if (url.host !== new URL(ctx.instanceUrl).host) return done(false, `goto leaves the instance (${url.host}).`); + await page.goto(url.toString(), { timeout: actionTimeout * 2 }); + await page.waitForLoadState("networkidle", { timeout: 5_000 }).catch(() => undefined); + return done(true); + } + case "click": + await locate(page, step.target).click({ timeout: actionTimeout }); + await page.waitForLoadState("networkidle", { timeout: 5_000 }).catch(() => undefined); + return done(true); + case "fill": + await locate(page, step.target).fill(step.value, { timeout: actionTimeout }); + return done(true); + case "select": + await locate(page, step.target).selectOption({ label: step.value }, { timeout: actionTimeout }).catch(async () => { + await locate(page, step.target).selectOption(step.value, { timeout: actionTimeout }); + }); + return done(true); + case "check": + await locate(page, step.target).check({ timeout: actionTimeout }); + return done(true); + case "uncheck": + await locate(page, step.target).uncheck({ timeout: actionTimeout }); + return done(true); + case "press": + if (step.target) await locate(page, step.target).press(step.key, { timeout: actionTimeout }); + else await page.keyboard.press(step.key); + await page.waitForLoadState("networkidle", { timeout: 5_000 }).catch(() => undefined); + return done(true); + case "expect_visible": + try { + await locate(page, step.target).first().waitFor({ state: "visible", timeout: expectTimeout }); + return done(true); + } catch { + return done(false, `Not visible: ${describeStep(step)}`, true); + } + case "expect_hidden": + try { + await locate(page, step.target).first().waitFor({ state: "hidden", timeout: expectTimeout }); + return done(true); + } catch { + return done(false, `Still visible: ${describeStep(step)}`, true); + } + case "expect_text": { + const text = step.text; + const scope = step.target ? locate(page, step.target).first() : page.locator("body"); + const deadline = Date.now() + expectTimeout; + let seen = ""; + while (Date.now() < deadline) { + seen = (await scope.innerText({ timeout: expectTimeout }).catch(() => "")) ?? ""; + if (seen.includes(text)) return done(true); + await page.waitForTimeout(250); + } + return done(false, `Text "${text}" not found${step.target ? " in the target" : " on the page"}. Visible text starts with: ${seen.replace(/\s+/g, " ").slice(0, 200)}`, true); + } + case "expect_url": { + const want = step.contains; + const deadline = Date.now() + expectTimeout; + while (Date.now() < deadline) { + if (page.url().includes(want)) return done(true); + await page.waitForTimeout(200); + } + return done(false, `URL ${page.url()} does not contain "${want}".`, true); + } + case "expect_no_errors": { + const capture = ctx.attachment.capture; + const consoleErrors = capture.queryConsole({ level: "error", sinceSeq: ctx.baselineSeq, limit: 5 }); + const networkErrors = capture.queryNetwork({ onlyErrors: true, onlyApi: true, sinceSeq: ctx.baselineSeq, limit: 5 }); + if (consoleErrors.length === 0 && networkErrors.length === 0) return done(true); + const parts = [ + ...consoleErrors.map((e) => `console: ${e.text.slice(0, 160)}`), + ...networkErrors.map((e) => `${e.method} ${e.url} → ${e.failed ?? e.status}`), + ]; + return done(false, `Errors during the test: ${parts.join("; ")}`, true); + } + case "screenshot": { + const id = ctx.store.nextEvidenceId(); + const safe = step.label.replace(/[^\w.-]+/g, "_").slice(0, 60); + const file = join(ctx.store.evidenceDir, `${id}-${safe}.png`); + await page.screenshot({ path: file }); + ctx.store.addEvidence({ id, kind: "screenshot", label: step.label, path: `evidence/${id}-${safe}.png`, pageUrl: page.url() }); + return done(true); + } + } + } catch (e) { + return done(false, cleanError((e as Error).message)); + } +} + +/** Playwright errors carry call logs; keep the first lines, which say what went wrong. */ +function cleanError(message: string): string { + return message.split("\n").slice(0, 3).join(" ").replace(/\s+/g, " ").slice(0, 400); +} + +/** Accessibility tree of the current page (roles and names), for choosing step targets. */ +export async function ariaSnapshot(attachment: BrowserAttachment, maxChars = 15_000): Promise { + const page = await testPage(attachment); + const tree = await page.locator("body").ariaSnapshot({ timeout: 5_000 }).catch((e: Error) => `(snapshot failed: ${e.message})`); + const head = `URL: ${page.url()}\nTitle: ${await page.title().catch(() => "")}\n\n`; + return head + (tree.length > maxChars ? tree.slice(0, maxChars) + "\n… (truncated)" : tree); +} diff --git a/packages/browser/src/tools/definitions.ts b/packages/browser/src/tools/definitions.ts index 29a11cc..6395928 100644 --- a/packages/browser/src/tools/definitions.ts +++ b/packages/browser/src/tools/definitions.ts @@ -1,9 +1,11 @@ import { join, relative, isAbsolute } from "node:path"; -import { existsSync } from "node:fs"; +import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { z } from "zod"; import { AcStatusSchema, FindingCategorySchema, + KNOWLEDGE_DOCS, + KnowledgeDocNameSchema, SeveritySchema, resolveCredentials, type DriverName, @@ -21,6 +23,8 @@ export type ToolContext = { roles: Record; driver: DriverName; env?: NodeJS.ProcessEnv; + /** Repository analysis only: directory the knowledge documents are written to. Enables write_knowledge. */ + knowledgeDir?: string; }; /** A tool definition that both the Agent SDK adapter and the stdio MCP server can consume. */ @@ -32,7 +36,7 @@ export type ClaudioTool = { handler: (args: z.infer>) => Promise; }; -function def(t: ClaudioTool): ClaudioTool { +export function def(t: ClaudioTool): ClaudioTool { return t; } @@ -233,5 +237,32 @@ export function createClaudioTools(ctx: ToolContext): ClaudioTool[] { }, }); - return [loginAs, captureEvidence, getConsole, getNetwork, reportFinding, recordAc, note, todoAdd, todoNext, todoDone]; + const tools: ClaudioTool[] = [loginAs, captureEvidence, getConsole, getNetwork, reportFinding, recordAc, note, todoAdd, todoNext, todoDone]; + if (ctx.knowledgeDir) tools.push(writeKnowledgeTool(ctx.knowledgeDir)); + return tools; +} + +/** Writes one document of the repository knowledge base (Markdown). */ +export function writeKnowledgeTool(dir: string): ClaudioTool { + const docList = Object.entries(KNOWLEDGE_DOCS) + .map(([name, d]) => `- ${name}: ${d.purpose}`) + .join("\n"); + return def({ + name: "write_knowledge", + description: `Write a document of the repository knowledge base (Markdown). mode "replace" overwrites the document, "append" adds a section at the end (use it to add one screen at a time to app-map). Documents:\n${docList}`, + schema: { + doc: KnowledgeDocNameSchema, + content: z.string().min(1).max(200_000), + mode: z.enum(["replace", "append"]).default("replace"), + }, + handler: async ({ doc, content, mode }) => { + mkdirSync(dir, { recursive: true }); + const file = join(dir, KNOWLEDGE_DOCS[doc as keyof typeof KNOWLEDGE_DOCS].file); + const text = content.trim() + "\n"; + if (mode === "append" && existsSync(file)) appendFileSync(file, "\n" + text); + else writeFileSync(file, text); + const size = readFileSync(file, "utf8").length; + return `${doc} ${mode === "append" ? "extended" : "written"} (${size} characters).`; + }, + }); } diff --git a/packages/browser/src/tools/test-tools.ts b/packages/browser/src/tools/test-tools.ts new file mode 100644 index 0000000..4d4fb51 --- /dev/null +++ b/packages/browser/src/tools/test-tools.ts @@ -0,0 +1,74 @@ +import { z } from "zod"; +import { DeterminismSchema, TestOutcomeSchema, TestStepSchema, describeStep, type Determinism, type StepResult, type TestOutcome, type TestStep } from "@claudio/core"; +import { ariaSnapshot, executeStep, type StepContext } from "../steps.js"; +import { def, type ClaudioTool } from "./definitions.js"; + +export type TestFinish = { complete: boolean; outcome: TestOutcome; determinism: Determinism; summary: string }; + +/** Collects what the agent did in a test run: every attempted step, and the ones that worked (the recording). */ +export class StepRecorder { + readonly results: StepResult[] = []; + readonly recorded: TestStep[] = []; + finish?: TestFinish; + + constructor( + /** Steps replayed before the agent took over (healing); they count as recorded. */ + readonly prefix: TestStep[] = [], + ) {} + + add(step: TestStep, ok: boolean, durationMs: number, error: string | undefined, healed: boolean): StepResult { + const r: StepResult = { index: this.prefix.length + this.results.length, step, ok, error, durationMs, healed }; + this.results.push(r); + if (ok) this.recorded.push(step); + return r; + } + + compiled(): TestStep[] { + return [...this.prefix, ...this.recorded]; + } +} + +/** + * Tools for recording and healing test scripts. All browser actions go through + * test_step, which Claudio executes itself, so what is recorded is exactly what ran. + */ +export function createTestTools(ctx: StepContext, recorder: StepRecorder, opts: { healing: boolean }): ClaudioTool[] { + const testStep = def({ + name: "test_step", + description: + "Execute one test step in the browser and record it. Actions: login(role), goto(path), click/fill/select/check/uncheck/press on a target, and assertions expect_visible, expect_hidden, expect_text, expect_url, expect_no_errors, plus screenshot(label). Targets, most robust first: {role, name} as in the snapshot (e.g. {role:'button', name:'Speichern'}), {label}, {placeholder}, {text}, {testId}, {css}. Use {{run}} in values for unique test data and {{date:+N:DD.MM.YYYY}} for dates relative to today. Only successful steps are recorded.", + schema: { step: TestStepSchema }, + handler: async ({ step }) => { + const out = await executeStep(step, ctx); + const r = recorder.add(step, out.ok, out.durationMs, out.error, opts.healing); + if (out.ok) return `#${r.index} ok: ${describeStep(step)} (${out.durationMs} ms)`; + return `#${r.index} FAILED: ${describeStep(step)}: ${out.error}${out.assertionFailed ? "" : " Take a snapshot to find the right target, then retry; failed steps are not recorded."}`; + }, + }); + + const snapshot = def({ + name: "snapshot", + description: "Accessibility tree of the current page (roles and accessible names) plus URL and title. Use it to choose targets for test_step.", + readOnly: true, + schema: {}, + handler: async () => ariaSnapshot(ctx.attachment), + }); + + const finishTest = def({ + name: "finish_test", + description: + "Finish the test run. complete: you performed the whole flow from the description (regardless of whether the expectation held). outcome: your reading of the expectation. determinism: deterministic if the recorded assertions alone decide pass/fail; semi if replaying the steps works but judging the result needs a person or model (e.g. 'list looks correct'); explorative if the flow itself must adapt each time (data-dependent choices).", + schema: { + complete: z.boolean(), + outcome: TestOutcomeSchema, + determinism: DeterminismSchema, + summary: z.string().describe("Two or three sentences: what you did, what you observed, how the expectation was checked"), + }, + handler: async (args) => { + recorder.finish = args; + return "Recorded. End your turn now with a one-paragraph summary."; + }, + }); + + return [testStep, snapshot, finishTest] as ClaudioTool[]; +} diff --git a/packages/browser/test/step-recorder.test.ts b/packages/browser/test/step-recorder.test.ts new file mode 100644 index 0000000..d3f356c --- /dev/null +++ b/packages/browser/test/step-recorder.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { StepRecorder } from "../src/tools/test-tools.js"; + +describe("StepRecorder", () => { + it("records only successful steps and continues after a replayed prefix", () => { + const r = new StepRecorder([{ action: "login", role: "dispatcher" }]); + r.add({ action: "click", target: { text: "Alt" } }, false, 10, "not found", true); + r.add({ action: "click", target: { role: "button", name: "Neu" } }, true, 12, undefined, true); + expect(r.results.map((x) => [x.index, x.ok])).toEqual([[1, false], [2, true]]); + expect(r.compiled()).toEqual([ + { action: "login", role: "dispatcher" }, + { action: "click", target: { role: "button", name: "Neu" } }, + ]); + }); +}); diff --git a/packages/core/src/analysis.ts b/packages/core/src/analysis.ts new file mode 100644 index 0000000..725864e --- /dev/null +++ b/packages/core/src/analysis.ts @@ -0,0 +1,78 @@ +import { z } from "zod"; +import { BudgetTierSchema, LanguageSchema, RunCostSchema, RunStatusSchema } from "./schemas.js"; + +/** + * The knowledge base Claudio keeps per repository. Each document is Markdown + * so it can be read by people, diffed between commits and handed to agents. + */ +export const KNOWLEDGE_DOCS = { + overview: { + file: "overview.md", + title: "Überblick", + purpose: "What the application is for, who uses it, tech stack, main domain objects, roles and permissions, how login works.", + }, + modules: { + file: "modules.md", + title: "Module", + purpose: "Module map of the code: directories and key files, what each is responsible for, shared components and stores, server endpoints, data access.", + }, + routes: { + file: "routes.md", + title: "Routen", + purpose: "Route index: one entry per URL path with the files that implement it (page, server load/actions, layout, endpoints) and the shared modules it depends on. Used for impact analysis of pull requests.", + }, + "app-map": { + file: "app-map.md", + title: "App-Karte", + purpose: "Screens and flows as observed in the running app: per screen the URL, purpose, which roles see it, main elements and actions, and the expected (correct) behaviour including validation and feedback. This is the reference for regression checks.", + }, + notes: { + file: "notes.md", + title: "Notizen", + purpose: "Practical notes for future runs: test data that exists or must be created, quirks, slow or flaky areas, things that could not be explored and why.", + }, +} as const; + +export const KnowledgeDocNameSchema = z.enum(["overview", "modules", "routes", "app-map", "notes"]); +export type KnowledgeDocName = z.infer; + +export const KnowledgeDocSchema = z.object({ name: KnowledgeDocNameSchema, content: z.string() }); +export type KnowledgeDoc = z.infer; + +/** A knowledge base as handed to a run: the documents plus where they came from. */ +export type KnowledgeBase = { + commitSha: string; + branch: string; + analyzedAt: string; + docs: KnowledgeDoc[]; +}; + +export const AnalysisRequestSchema = z.object({ + repo: z.string().regex(/^[\w.-]+\/[\w.-]+$/, "repo must be owner/name"), + /** Branch to analyse; the default branch when omitted. */ + branch: z.string().optional(), + instanceUrl: z.string().url(), + roles: z.array(z.string()).default([]), + budget: BudgetTierSchema.default("standard"), + language: LanguageSchema.default("de"), + headed: z.boolean().default(false), + extraInstructions: z.string().optional(), +}); +export type AnalysisRequest = z.infer; + +export const AnalysisReportSchema = z.object({ + runId: z.string(), + status: RunStatusSchema, + request: AnalysisRequestSchema, + commitSha: z.string(), + branch: z.string(), + /** Analysis this one built on, if it was an update. */ + previousCommitSha: z.string().optional(), + summary: z.string(), + docs: z.array(KnowledgeDocSchema), + cost: RunCostSchema, + sessionId: z.string().optional(), + errors: z.array(z.string()).default([]), + createdAt: z.string(), +}); +export type AnalysisReport = z.infer; diff --git a/packages/core/src/budget.ts b/packages/core/src/budget.ts index ddaf044..f251618 100644 --- a/packages/core/src/budget.ts +++ b/packages/core/src/budget.ts @@ -9,6 +9,8 @@ export type BudgetProfile = { maxTurns: number; maxBudgetUsd: number; maxBrowserSteps: number; + /** Delegate AC verification, regression checks and UX review to separate subagents (reviews only). */ + delegateChecks: boolean; }; /** @@ -24,6 +26,7 @@ export const BUDGET_TIERS: Record = { maxTurns: 60, maxBudgetUsd: 2, maxBrowserSteps: 80, + delegateChecks: false, }, standard: { orchestratorModel: "claude-opus-5-5", @@ -32,6 +35,7 @@ export const BUDGET_TIERS: Record = { maxTurns: 150, maxBudgetUsd: 8, maxBrowserSteps: 250, + delegateChecks: true, }, thorough: { orchestratorModel: "claude-opus-5-5", @@ -40,9 +44,86 @@ export const BUDGET_TIERS: Record = { maxTurns: 400, maxBudgetUsd: 25, maxBrowserSteps: 700, + delegateChecks: true, }, }; +/** + * Repository analysis reads the whole code base and walks through every screen, + * so it gets more turns and browser steps than a review of the same tier. + */ +export const ANALYSIS_TIERS: Record = { + quick: { + orchestratorModel: "claude-sonnet-5-5", + workerModel: "claude-haiku-5-5", + effort: "medium", + maxTurns: 120, + maxBudgetUsd: 4, + maxBrowserSteps: 150, + delegateChecks: false, + }, + standard: { + orchestratorModel: "claude-opus-5-5", + workerModel: "claude-sonnet-5-5", + effort: "high", + maxTurns: 300, + maxBudgetUsd: 15, + maxBrowserSteps: 400, + delegateChecks: false, + }, + thorough: { + orchestratorModel: "claude-opus-5-5", + workerModel: "claude-sonnet-5-5", + effort: "xhigh", + maxTurns: 600, + maxBudgetUsd: 40, + maxBrowserSteps: 900, + delegateChecks: false, + }, +}; + +export function analysisProfile(tier: BudgetTier): BudgetProfile { + return ANALYSIS_TIERS[tier]; +} + +/** + * Test scripts: one flow per run. Only recording, exploring and healing use the + * model; a deterministic replay costs nothing. + */ +export const TEST_TIERS: Record = { + quick: { + orchestratorModel: "claude-sonnet-5-5", + workerModel: "claude-haiku-5-5", + effort: "medium", + maxTurns: 50, + maxBudgetUsd: 1, + maxBrowserSteps: 60, + delegateChecks: false, + }, + standard: { + orchestratorModel: "claude-opus-5-5", + workerModel: "claude-sonnet-5-5", + effort: "high", + maxTurns: 90, + maxBudgetUsd: 3, + maxBrowserSteps: 120, + delegateChecks: false, + }, + thorough: { + orchestratorModel: "claude-opus-5-5", + workerModel: "claude-sonnet-5-5", + effort: "xhigh", + maxTurns: 160, + maxBudgetUsd: 8, + maxBrowserSteps: 250, + delegateChecks: false, + }, +}; + +export function testProfile(tier: BudgetTier): BudgetProfile { + return TEST_TIERS[tier]; +} + export function budgetProfile(tier: BudgetTier, modelOverride?: string): BudgetProfile { const base = BUDGET_TIERS[tier]; return modelOverride ? { ...base, orchestratorModel: modelOverride } : base; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6127c88..8c7cbf5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,3 +3,5 @@ export * from "./budget.js"; export * from "./report.js"; export * from "./config.js"; export * from "./ids.js"; +export * from "./analysis.js"; +export * from "./testing.js"; diff --git a/packages/core/src/report.ts b/packages/core/src/report.ts index b7d2ab8..a7798c9 100644 --- a/packages/core/src/report.ts +++ b/packages/core/src/report.ts @@ -18,18 +18,25 @@ export function isBlocking(finding: Finding, strictness: Strictness): boolean { return SEVERITY_RANK[finding.severity] >= SEVERITY_RANK[STRICTNESS_THRESHOLD[strictness]]; } -export function blockingFindings(report: ReviewReport, strictness: Strictness = report.request.strictness): Finding[] { - return sortBySeverity(report.findings.filter((f) => isBlocking(f, strictness))); +/** Findings that block a merge. Findings a person dismissed (by id) never block. */ +export function blockingFindings( + report: ReviewReport, + strictness: Strictness = report.request.strictness, + dismissedIds: readonly string[] = [], +): Finding[] { + return sortBySeverity(report.findings.filter((f) => !dismissedIds.includes(f.id) && isBlocking(f, strictness))); } export function sortBySeverity(findings: Finding[]): Finding[] { return [...findings].sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]); } -export function verdict(report: ReviewReport, strictness: Strictness = report.request.strictness): "approve" | "request_changes" | "inconclusive" { +export type Verdict = "approve" | "request_changes" | "inconclusive"; + +export function verdict(report: ReviewReport, strictness: Strictness = report.request.strictness, dismissedIds: readonly string[] = []): Verdict { if (report.status !== "completed") return "inconclusive"; const failedAc = report.acceptanceCriteria.some((ac) => ac.status === "failed"); - if (failedAc || blockingFindings(report, strictness).length > 0) return "request_changes"; + if (failedAc || blockingFindings(report, strictness, dismissedIds).length > 0) return "request_changes"; return "approve"; } @@ -53,6 +60,10 @@ const L = { severity: { blocker: "Blocker", major: "Major", minor: "Minor", suggestion: "Vorschlag" }, fullReport: "Vollständiger Report", errors: "Fehler während des Runs", + dismissed: "Von Menschen verworfene Findings (nicht aufgeführt)", + tests: "Regressionstests", + testsPassed: "bestanden", + testsFailed: "Fehlgeschlagen", }, en: { verdict: { approve: "No blocking findings", request_changes: "Changes requested", inconclusive: "Review incomplete" }, @@ -73,6 +84,10 @@ const L = { severity: { blocker: "Blocker", major: "Major", minor: "Minor", suggestion: "Suggestion" }, fullReport: "Full report", errors: "Errors during the run", + dismissed: "Findings dismissed by a person (not listed)", + tests: "Regression tests", + testsPassed: "passed", + testsFailed: "Failed", }, } as const; @@ -82,12 +97,24 @@ function labels(report: ReviewReport): Labels { return L[report.request.language]; } -export function renderMarkdown(report: ReviewReport, verbosity: Verbosity = report.request.verbosity): string { +/** Regression tests that ran with the review. */ +export type ReviewTestResult = { name: string; outcome: "passed" | "failed" | "inconclusive" | null }; + +/** How a person adjusted a finished report: strictness and findings they dismissed, plus the review's regression tests. */ +export type RenderOptions = { strictness?: Strictness; dismissedIds?: readonly string[]; tests?: readonly ReviewTestResult[] }; + +/** A failed regression test turns an approval into "changes requested". */ +export function withTests(v: Verdict, tests: readonly ReviewTestResult[] = []): Verdict { + return v === "approve" && tests.some((t) => t.outcome === "failed") ? "request_changes" : v; +} + +export function renderMarkdown(report: ReviewReport, verbosity: Verbosity = report.request.verbosity, opts: RenderOptions = {}): string { const t = labels(report); - const strictness = report.request.strictness; - const v = verdict(report, strictness); - const blocking = blockingFindings(report, strictness); - const nonBlocking = sortBySeverity(report.findings.filter((f) => !isBlocking(f, strictness))); + const strictness = opts.strictness ?? report.request.strictness; + const dismissed = opts.dismissedIds ?? []; + const v = verdict(report, strictness, dismissed); + const blocking = blockingFindings(report, strictness, dismissed); + const nonBlocking = sortBySeverity(report.findings.filter((f) => !dismissed.includes(f.id) && !isBlocking(f, strictness))); const evidenceById = new Map(report.evidence.map((e) => [e.id, e])); const out: string[] = []; @@ -154,6 +181,11 @@ export function renderMarkdown(report: ReviewReport, verbosity: Verbosity = repo nonBlocking.forEach(renderFinding); } + if (dismissed.length > 0) { + out.push(`_${t.dismissed}: ${dismissed.length}_`); + out.push(""); + } + if (verbosity !== "tldr" && report.affectedAreas.length > 0) { out.push(`## ${t.affected}`); out.push(""); @@ -189,16 +221,23 @@ export function renderMarkdown(report: ReviewReport, verbosity: Verbosity = repo } /** Short comment for the pull request: verdict, counts, blocking titles, link to the full report. */ -export function renderPrComment(report: ReviewReport, reportUrl?: string): string { +export function renderPrComment(report: ReviewReport, reportUrl?: string, opts: RenderOptions = {}): string { const t = labels(report); - const strictness = report.request.strictness; - const v = verdict(report, strictness); - const blocking = blockingFindings(report, strictness); + const strictness = opts.strictness ?? report.request.strictness; + const dismissed = opts.dismissedIds ?? []; + const tests = opts.tests ?? []; + const v = withTests(verdict(report, strictness, dismissed), tests); + const blocking = blockingFindings(report, strictness, dismissed); const acPassed = report.acceptanceCriteria.filter((a) => a.status === "passed").length; const lines: string[] = []; lines.push(`**Claudio Review: ${t.verdict[v]}**`); lines.push(""); - lines.push(`${t.ac}: ${acPassed}/${report.acceptanceCriteria.length} ${t.acStatus.passed}. ${t.findings}: ${report.findings.length} (${t.blocking.toLowerCase()}: ${blocking.length}).`); + lines.push(`${t.ac}: ${acPassed}/${report.acceptanceCriteria.length} ${t.acStatus.passed}. ${t.findings}: ${report.findings.length - dismissed.length} (${t.blocking.toLowerCase()}: ${blocking.length}).`); + if (tests.length > 0) { + const passed = tests.filter((x) => x.outcome === "passed").length; + const failed = tests.filter((x) => x.outcome === "failed"); + lines.push(`${t.tests}: ${passed}/${tests.length} ${t.testsPassed}.${failed.length ? ` ${t.testsFailed}: ${failed.map((x) => x.name).join(", ")}.` : ""}`); + } if (blocking.length > 0) { lines.push(""); blocking.slice(0, 5).forEach((f) => lines.push(`- [${t.severity[f.severity]}] ${f.title}`)); diff --git a/packages/core/src/schemas.ts b/packages/core/src/schemas.ts index 714ede2..b7b0faa 100644 --- a/packages/core/src/schemas.ts +++ b/packages/core/src/schemas.ts @@ -132,6 +132,8 @@ export const ReviewReportSchema = z.object({ cost: RunCostSchema, sessionId: z.string().optional(), errors: z.array(z.string()).default([]), + /** URL of the TL;DR comment posted to the pull request, if any. */ + commentUrl: z.string().optional(), createdAt: z.string(), }); export type ReviewReport = z.infer; diff --git a/packages/core/src/testing.ts b/packages/core/src/testing.ts new file mode 100644 index 0000000..c068267 --- /dev/null +++ b/packages/core/src/testing.ts @@ -0,0 +1,191 @@ +import { z } from "zod"; +import { BudgetTierSchema, EvidenceSchema, FindingSchema, LanguageSchema, RunCostSchema, RunStatusSchema } from "./schemas.js"; + +/** + * How Claudio finds an element. Role + accessible name first: it survives + * restyling and is what a user sees. CSS is the last resort. + */ +export const TargetSchema = z.union([ + z.object({ role: z.string(), name: z.string().optional(), exact: z.boolean().optional() }), + z.object({ label: z.string(), exact: z.boolean().optional() }), + z.object({ placeholder: z.string() }), + z.object({ text: z.string(), exact: z.boolean().optional() }), + z.object({ testId: z.string() }), + z.object({ css: z.string() }), +]); +export type Target = z.infer; + +/** + * One recorded step of a test script. Values may contain templates: + * {{run}} (short id of the run, for unique test data) and + * {{date:+N:FORMAT}} (today plus N days, FORMAT DD.MM.YYYY or YYYY-MM-DD). + */ +export const TestStepSchema = z.discriminatedUnion("action", [ + z.object({ action: z.literal("login"), role: z.string() }), + z.object({ action: z.literal("goto"), path: z.string() }), + z.object({ action: z.literal("click"), target: TargetSchema }), + z.object({ action: z.literal("fill"), target: TargetSchema, value: z.string() }), + z.object({ action: z.literal("select"), target: TargetSchema, value: z.string() }), + z.object({ action: z.literal("check"), target: TargetSchema }), + z.object({ action: z.literal("uncheck"), target: TargetSchema }), + z.object({ action: z.literal("press"), key: z.string(), target: TargetSchema.optional() }), + z.object({ action: z.literal("expect_visible"), target: TargetSchema }), + z.object({ action: z.literal("expect_hidden"), target: TargetSchema }), + z.object({ action: z.literal("expect_text"), text: z.string(), target: TargetSchema.optional() }), + z.object({ action: z.literal("expect_url"), contains: z.string() }), + z.object({ action: z.literal("expect_no_errors") }), + z.object({ action: z.literal("screenshot"), label: z.string() }), +]); +export type TestStep = z.infer; + +export const isAssertion = (step: TestStep) => step.action.startsWith("expect_"); + +export const DeterminismSchema = z.enum(["deterministic", "semi", "explorative"]); +export type Determinism = z.infer; + +export const TestOutcomeSchema = z.enum(["passed", "failed", "inconclusive"]); +export type TestOutcome = z.infer; + +export const TestModeSchema = z.enum(["record", "replay", "explore"]); +export type TestMode = z.infer; + +/** A test script as the runner needs it. */ +export const TestScriptSchema = z.object({ + id: z.number(), + name: z.string(), + /** What to do, in plain language. */ + description: z.string(), + /** What must be true at the end, in plain language. */ + expectation: z.string(), + roles: z.array(z.string()).default([]), + determinism: DeterminismSchema.nullable(), + steps: z.array(TestStepSchema).nullable(), +}); +export type TestScript = z.infer; + +export const TestRunRequestSchema = z.object({ + repo: z.string().regex(/^[\w.-]+\/[\w.-]+$/, "repo must be owner/name"), + instanceUrl: z.string().url(), + budget: BudgetTierSchema.default("quick"), + language: LanguageSchema.default("de"), + headed: z.boolean().default(false), + /** Force a fresh recording even when steps exist. */ + rerecord: z.boolean().default(false), +}); +export type TestRunRequest = z.infer; + +export const StepResultSchema = z.object({ + index: z.number(), + step: TestStepSchema, + ok: z.boolean(), + error: z.string().optional(), + durationMs: z.number(), + /** Step was added by the agent while healing a broken replay. */ + healed: z.boolean().default(false), +}); +export type StepResult = z.infer; + +export const JudgeVerdictSchema = z.object({ + passed: z.boolean(), + confidence: z.number().min(0).max(1), + deviations: z.array(z.string()), + reasoning: z.string(), +}); +export type JudgeVerdict = z.infer; + +export const TestRunReportSchema = z.object({ + runId: z.string(), + status: RunStatusSchema, + mode: TestModeSchema, + outcome: TestOutcomeSchema, + /** Index of the replayed step that broke and was healed by the agent. */ + healedAt: z.number().optional(), + steps: z.array(StepResultSchema), + /** Steps to store on the script after this run (new recording or healed replay). */ + compiledSteps: z.array(TestStepSchema).optional(), + determinismProposal: DeterminismSchema.optional(), + judge: JudgeVerdictSchema.optional(), + summary: z.string(), + findings: z.array(FindingSchema).default([]), + evidence: z.array(EvidenceSchema).default([]), + finalUrl: z.string().optional(), + cost: RunCostSchema, + errors: z.array(z.string()).default([]), + createdAt: z.string(), +}); +export type TestRunReport = z.infer; + +// ---------------------------------------------------------------- templates + +export type TemplateContext = { runTag: string; now?: Date }; + +/** Resolves {{run}} and {{date:+N:FORMAT}} in a step value. Unknown templates are left as they are. */ +export function resolveTemplate(text: string, ctx: TemplateContext): string { + const now = ctx.now ?? new Date(); + return text.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (whole, expr: string) => { + if (expr === "run") return ctx.runTag; + const m = expr.match(/^date(?::([+-]\d+))?(?::(.+))?$/); + if (!m) return whole; + const d = new Date(now); + d.setDate(d.getDate() + Number(m[1] ?? 0)); + const pad = (n: number) => String(n).padStart(2, "0"); + const parts: Record = { YYYY: String(d.getFullYear()), MM: pad(d.getMonth() + 1), DD: pad(d.getDate()) }; + return (m[2] ?? "YYYY-MM-DD").replace(/YYYY|MM|DD/g, (k) => parts[k]!); + }); +} + +/** Resolves templates everywhere in a step: values, texts, and target names. */ +export function resolveStepTemplates(step: TestStep, ctx: TemplateContext): TestStep { + const walk = (v: unknown): unknown => { + if (typeof v === "string") return resolveTemplate(v, ctx); + if (Array.isArray(v)) return v.map(walk); + if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)])); + return v; + }; + return walk(step) as TestStep; +} + +// ---------------------------------------------------------------- human-readable steps + +export function describeTarget(t: Target): string { + if ("role" in t) return `${t.role}${t.name ? ` „${t.name}“` : ""}`; + if ("label" in t) return `Feld „${t.label}“`; + if ("placeholder" in t) return `Feld mit Platzhalter „${t.placeholder}“`; + if ("text" in t) return `Text „${t.text}“`; + if ("testId" in t) return `Test-ID ${t.testId}`; + return `CSS ${t.css}`; +} + +/** German one-liner for the UI. */ +export function describeStep(s: TestStep): string { + switch (s.action) { + case "login": + return `Anmelden als ${s.role}`; + case "goto": + return `Öffnen ${s.path}`; + case "click": + return `Klicken auf ${describeTarget(s.target)}`; + case "fill": + return `${describeTarget(s.target)} ausfüllen mit „${s.value}“`; + case "select": + return `In ${describeTarget(s.target)} „${s.value}“ wählen`; + case "check": + return `${describeTarget(s.target)} ankreuzen`; + case "uncheck": + return `${describeTarget(s.target)} abwählen`; + case "press": + return `Taste ${s.key}${s.target ? ` in ${describeTarget(s.target)}` : ""}`; + case "expect_visible": + return `Prüfen: ${describeTarget(s.target)} ist sichtbar`; + case "expect_hidden": + return `Prüfen: ${describeTarget(s.target)} ist nicht sichtbar`; + case "expect_text": + return `Prüfen: ${s.target ? `${describeTarget(s.target)} enthält` : "Seite enthält"} „${s.text}“`; + case "expect_url": + return `Prüfen: Adresse enthält ${s.contains}`; + case "expect_no_errors": + return "Prüfen: keine Fehler in Konsole und Netzwerk"; + case "screenshot": + return `Screenshot „${s.label}“`; + } +} diff --git a/packages/core/test/report.test.ts b/packages/core/test/report.test.ts index 9e33b42..6c0a33a 100644 --- a/packages/core/test/report.test.ts +++ b/packages/core/test/report.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { ReviewReportSchema, blockingFindings, renderMarkdown, renderPrComment, verdict, type ReviewReport } from "../src/index.js"; +import { ReviewReportSchema, blockingFindings, renderMarkdown, renderPrComment, verdict, withTests, type ReviewReport } from "../src/index.js"; function report(overrides: Partial = {}): ReviewReport { return ReviewReportSchema.parse({ @@ -34,6 +34,11 @@ describe("strictness", () => { expect(verdict(report(), "lenient")).toBe("approve"); expect(verdict(report(), "normal")).toBe("request_changes"); }); + it("ignores dismissed findings", () => { + expect(blockingFindings(report(), "normal", ["F2"])).toEqual([]); + expect(verdict(report(), "normal", ["F2"])).toBe("approve"); + expect(verdict(report(), "strict", ["F2"])).toBe("request_changes"); + }); it("is inconclusive when the run did not complete", () => { expect(verdict(report({ status: "budget_exceeded" }))).toBe("inconclusive"); }); @@ -60,3 +65,29 @@ describe("rendering", () => { expect(c).toContain("https://claudio.example/runs/1"); }); }); + +describe("rendering with human adjustments", () => { + it("leaves dismissed findings out of the Markdown report and the PR comment", () => { + const md = renderMarkdown(report(), "normal", { dismissedIds: ["F2"] }); + expect(md).not.toContain("Speichern schlägt fehl"); + expect(md).toContain("Button zu klein"); + expect(md).toContain("verworfene Findings"); + const comment = renderPrComment(report(), "https://claudio.example.test/reviews/1", { dismissedIds: ["F2"] }); + expect(comment).toContain("Keine blockierenden Findings"); + expect(comment).toContain("https://claudio.example.test/reviews/1"); + }); + it("applies a stricter level chosen afterwards", () => { + expect(renderPrComment(report(), undefined, { strictness: "strict" })).toContain("Button zu klein"); + }); +}); + +describe("regression tests in the verdict", () => { + it("a failed test turns an approval into changes requested and shows in the comment", () => { + expect(withTests("approve", [{ name: "Fahrt anlegen", outcome: "failed" }])).toBe("request_changes"); + expect(withTests("approve", [{ name: "Fahrt anlegen", outcome: "passed" }])).toBe("approve"); + expect(withTests("inconclusive", [{ name: "x", outcome: "failed" }])).toBe("inconclusive"); + const c = renderPrComment(report(), undefined, { dismissedIds: ["F2"], tests: [{ name: "Fahrt anlegen", outcome: "failed" }, { name: "Suche", outcome: "passed" }] }); + expect(c).toContain("Änderungen nötig"); + expect(c).toContain("Regressionstests: 1/2 bestanden. Fehlgeschlagen: Fahrt anlegen."); + }); +}); diff --git a/packages/core/test/testing.test.ts b/packages/core/test/testing.test.ts new file mode 100644 index 0000000..038285c --- /dev/null +++ b/packages/core/test/testing.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { TestStepSchema, describeStep, resolveStepTemplates, resolveTemplate } from "../src/index.js"; + +const now = new Date(2026, 9, 12, 10, 0, 0); + +describe("templates", () => { + it("resolves run tag and relative dates", () => { + expect(resolveTemplate("claudio-{{run}}-Fahrt", { runTag: "abc1", now })).toBe("claudio-abc1-Fahrt"); + expect(resolveTemplate("{{date:+1:DD.MM.YYYY}}", { runTag: "x", now })).toBe("13.10.2026"); + expect(resolveTemplate("{{date:-12}}", { runTag: "x", now })).toBe("2026-09-30"); + expect(resolveTemplate("{{ date }}", { runTag: "x", now })).toBe("2026-10-12"); + }); + it("leaves unknown templates alone", () => { + expect(resolveTemplate("{{user}}", { runTag: "x", now })).toBe("{{user}}"); + }); + it("resolves inside targets too", () => { + const step = TestStepSchema.parse({ action: "expect_visible", target: { role: "cell", name: "claudio-{{run}}" } }); + expect(resolveStepTemplates(step, { runTag: "r9", now })).toEqual({ action: "expect_visible", target: { role: "cell", name: "claudio-r9" } }); + }); +}); + +describe("describeStep", () => { + it("reads like an instruction", () => { + expect(describeStep({ action: "click", target: { role: "button", name: "Speichern" } })).toBe("Klicken auf button „Speichern“"); + expect(describeStep({ action: "fill", target: { label: "Datum" }, value: "{{date:+1}}" })).toBe("Feld „Datum“ ausfüllen mit „{{date:+1}}“"); + expect(describeStep({ action: "expect_no_errors" })).toBe("Prüfen: keine Fehler in Konsole und Netzwerk"); + }); +}); diff --git a/packages/db/drizzle.config.ts b/packages/db/drizzle.config.ts new file mode 100644 index 0000000..8c004bf --- /dev/null +++ b/packages/db/drizzle.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + dialect: "mysql", + schema: "./src/schema.ts", + out: "./migrations", +}); diff --git a/packages/db/migrations/0000_init.sql b/packages/db/migrations/0000_init.sql new file mode 100644 index 0000000..3dc1a92 --- /dev/null +++ b/packages/db/migrations/0000_init.sql @@ -0,0 +1,99 @@ +CREATE TABLE `findings` ( + `id` bigint unsigned AUTO_INCREMENT NOT NULL, + `review_id` bigint unsigned NOT NULL, + `finding_id` varchar(20) NOT NULL, + `data` json NOT NULL, + `status` enum('proposed','confirmed','dismissed') NOT NULL DEFAULT 'proposed', + `comment` text, + `rated_by` bigint unsigned, + `rated_at` datetime, + CONSTRAINT `findings_id` PRIMARY KEY(`id`), + CONSTRAINT `findings_review_finding_idx` UNIQUE(`review_id`,`finding_id`) +); +--> statement-breakpoint +CREATE TABLE `repo_roles` ( + `id` bigint unsigned AUTO_INCREMENT NOT NULL, + `repo_id` bigint unsigned NOT NULL, + `name` varchar(60) NOT NULL, + `description` varchar(300), + `username_enc` text NOT NULL, + `password_enc` text NOT NULL, + `login_path` varchar(300) NOT NULL DEFAULT '/login', + `success_url_contains` varchar(300), + `recipe` json, + `created_at` datetime NOT NULL, + CONSTRAINT `repo_roles_id` PRIMARY KEY(`id`), + CONSTRAINT `repo_roles_repo_name_idx` UNIQUE(`repo_id`,`name`) +); +--> statement-breakpoint +CREATE TABLE `repos` ( + `id` bigint unsigned AUTO_INCREMENT NOT NULL, + `full_name` varchar(200) NOT NULL, + `is_private` boolean NOT NULL DEFAULT false, + `default_branch` varchar(200), + `notes` text, + `extra_allowed_hosts` json NOT NULL, + `data_confirmed` boolean NOT NULL DEFAULT false, + `default_instance_url` varchar(500), + `created_by` bigint unsigned NOT NULL, + `created_at` datetime NOT NULL, + CONSTRAINT `repos_id` PRIMARY KEY(`id`), + CONSTRAINT `repos_full_name_unique` UNIQUE(`full_name`) +); +--> statement-breakpoint +CREATE TABLE `reviews` ( + `id` bigint unsigned AUTO_INCREMENT NOT NULL, + `repo_id` bigint unsigned NOT NULL, + `pr_number` int NOT NULL, + `pr_title` varchar(500), + `instance_url` varchar(500) NOT NULL, + `request` json NOT NULL, + `fixture` varchar(60), + `status` enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted') NOT NULL DEFAULT 'queued', + `run_id` varchar(120), + `report` json, + `verdict` varchar(30), + `cost_usd` varchar(20), + `error` text, + `abort_requested` boolean NOT NULL DEFAULT false, + `started_by` bigint unsigned NOT NULL, + `created_at` datetime NOT NULL, + `started_at` datetime, + `finished_at` datetime, + CONSTRAINT `reviews_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `run_events` ( + `id` bigint unsigned AUTO_INCREMENT NOT NULL, + `review_id` bigint unsigned NOT NULL, + `seq` int NOT NULL, + `line` mediumtext NOT NULL, + `created_at` datetime NOT NULL, + CONSTRAINT `run_events_id` PRIMARY KEY(`id`), + CONSTRAINT `run_events_review_seq_idx` UNIQUE(`review_id`,`seq`) +); +--> statement-breakpoint +CREATE TABLE `sessions` ( + `id` varchar(64) NOT NULL, + `user_id` bigint unsigned NOT NULL, + `expires_at` datetime NOT NULL, + `created_at` datetime NOT NULL, + CONSTRAINT `sessions_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `users` ( + `id` bigint unsigned AUTO_INCREMENT NOT NULL, + `github_id` bigint unsigned NOT NULL, + `login` varchar(100) NOT NULL, + `name` varchar(200), + `avatar_url` varchar(500), + `access_token_enc` text NOT NULL, + `created_at` datetime NOT NULL, + `last_login_at` datetime NOT NULL, + CONSTRAINT `users_id` PRIMARY KEY(`id`), + CONSTRAINT `users_github_id_unique` UNIQUE(`github_id`) +); +--> statement-breakpoint +CREATE INDEX `reviews_repo_idx` ON `reviews` (`repo_id`);--> statement-breakpoint +CREATE INDEX `reviews_status_idx` ON `reviews` (`status`);--> statement-breakpoint +CREATE INDEX `sessions_user_idx` ON `sessions` (`user_id`); \ No newline at end of file diff --git a/packages/db/migrations/0001_analyses.sql b/packages/db/migrations/0001_analyses.sql new file mode 100644 index 0000000..759ae16 --- /dev/null +++ b/packages/db/migrations/0001_analyses.sql @@ -0,0 +1,38 @@ +CREATE TABLE `analyses` ( + `id` bigint unsigned AUTO_INCREMENT NOT NULL, + `repo_id` bigint unsigned NOT NULL, + `request` json NOT NULL, + `instance_url` varchar(500) NOT NULL, + `fixture` varchar(60), + `status` enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted') NOT NULL DEFAULT 'queued', + `run_id` varchar(120), + `branch` varchar(200), + `commit_sha` varchar(64), + `report` json, + `cost_usd` varchar(20), + `error` text, + `abort_requested` boolean NOT NULL DEFAULT false, + `started_by` bigint unsigned NOT NULL, + `created_at` datetime NOT NULL, + `started_at` datetime, + `finished_at` datetime, + CONSTRAINT `analyses_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `knowledge_docs` ( + `id` bigint unsigned AUTO_INCREMENT NOT NULL, + `analysis_id` bigint unsigned NOT NULL, + `repo_id` bigint unsigned NOT NULL, + `name` varchar(40) NOT NULL, + `content` mediumtext NOT NULL, + `created_at` datetime NOT NULL, + CONSTRAINT `knowledge_docs_id` PRIMARY KEY(`id`), + CONSTRAINT `knowledge_docs_analysis_name_idx` UNIQUE(`analysis_id`,`name`) +); +--> statement-breakpoint +ALTER TABLE `run_events` MODIFY COLUMN `review_id` bigint unsigned;--> statement-breakpoint +ALTER TABLE `run_events` ADD `analysis_id` bigint unsigned;--> statement-breakpoint +ALTER TABLE `run_events` ADD CONSTRAINT `run_events_analysis_seq_idx` UNIQUE(`analysis_id`,`seq`);--> statement-breakpoint +CREATE INDEX `analyses_repo_idx` ON `analyses` (`repo_id`);--> statement-breakpoint +CREATE INDEX `analyses_status_idx` ON `analyses` (`status`);--> statement-breakpoint +CREATE INDEX `knowledge_docs_repo_idx` ON `knowledge_docs` (`repo_id`); \ No newline at end of file diff --git a/packages/db/migrations/0002_review_comment.sql b/packages/db/migrations/0002_review_comment.sql new file mode 100644 index 0000000..112fe40 --- /dev/null +++ b/packages/db/migrations/0002_review_comment.sql @@ -0,0 +1 @@ +ALTER TABLE `reviews` ADD `comment_url` varchar(500); \ No newline at end of file diff --git a/packages/db/migrations/0003_tests.sql b/packages/db/migrations/0003_tests.sql new file mode 100644 index 0000000..a82c2d2 --- /dev/null +++ b/packages/db/migrations/0003_tests.sql @@ -0,0 +1,50 @@ +CREATE TABLE `test_runs` ( + `id` bigint unsigned AUTO_INCREMENT NOT NULL, + `script_id` bigint unsigned NOT NULL, + `repo_id` bigint unsigned NOT NULL, + `review_id` bigint unsigned, + `batch_id` varchar(40), + `request` json NOT NULL, + `instance_url` varchar(500) NOT NULL, + `status` enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted') NOT NULL DEFAULT 'queued', + `run_id` varchar(120), + `mode` varchar(20), + `outcome` enum('passed','failed','inconclusive'), + `healed` boolean NOT NULL DEFAULT false, + `steps_version` int, + `report` json, + `cost_usd` varchar(20), + `error` text, + `abort_requested` boolean NOT NULL DEFAULT false, + `started_by` bigint unsigned NOT NULL, + `created_at` datetime NOT NULL, + `started_at` datetime, + `finished_at` datetime, + CONSTRAINT `test_runs_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `test_scripts` ( + `id` bigint unsigned AUTO_INCREMENT NOT NULL, + `repo_id` bigint unsigned NOT NULL, + `name` varchar(200) NOT NULL, + `description` text NOT NULL, + `expectation` text NOT NULL, + `roles` json NOT NULL, + `determinism` enum('deterministic','semi','explorative'), + `steps` json, + `steps_version` int NOT NULL DEFAULT 0, + `steps_updated_at` datetime, + `run_in_reviews` boolean NOT NULL DEFAULT false, + `created_by` bigint unsigned NOT NULL, + `created_at` datetime NOT NULL, + `updated_at` datetime NOT NULL, + CONSTRAINT `test_scripts_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +ALTER TABLE `run_events` ADD `test_run_id` bigint unsigned;--> statement-breakpoint +ALTER TABLE `run_events` ADD CONSTRAINT `run_events_test_seq_idx` UNIQUE(`test_run_id`,`seq`);--> statement-breakpoint +CREATE INDEX `test_runs_script_idx` ON `test_runs` (`script_id`);--> statement-breakpoint +CREATE INDEX `test_runs_review_idx` ON `test_runs` (`review_id`);--> statement-breakpoint +CREATE INDEX `test_runs_status_idx` ON `test_runs` (`status`);--> statement-breakpoint +CREATE INDEX `test_runs_batch_idx` ON `test_runs` (`batch_id`);--> statement-breakpoint +CREATE INDEX `test_scripts_repo_idx` ON `test_scripts` (`repo_id`); \ No newline at end of file diff --git a/packages/db/migrations/meta/0000_snapshot.json b/packages/db/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..875d9a8 --- /dev/null +++ b/packages/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,648 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "98d2c0eb-e5b8-4b70-8e5c-e22c9501921e", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "findings": { + "name": "findings", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finding_id": { + "name": "finding_id", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('proposed','confirmed','dismissed')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'proposed'" + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_by": { + "name": "rated_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_at": { + "name": "rated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "findings_review_finding_idx": { + "name": "findings_review_finding_idx", + "columns": [ + "review_id", + "finding_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "findings_id": { + "name": "findings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "repo_roles": { + "name": "repo_roles", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(60)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username_enc": { + "name": "username_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_enc": { + "name": "password_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "login_path": { + "name": "login_path", + "type": "varchar(300)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'/login'" + }, + "success_url_contains": { + "name": "success_url_contains", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipe": { + "name": "recipe", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "repo_roles_repo_name_idx": { + "name": "repo_roles_repo_name_idx", + "columns": [ + "repo_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "repo_roles_id": { + "name": "repo_roles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "repos": { + "name": "repos", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "full_name": { + "name": "full_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_branch": { + "name": "default_branch", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extra_allowed_hosts": { + "name": "extra_allowed_hosts", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data_confirmed": { + "name": "data_confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_instance_url": { + "name": "default_instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "repos_id": { + "name": "repos_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "repos_full_name_unique": { + "name": "repos_full_name_unique", + "columns": [ + "full_name" + ] + } + }, + "checkConstraint": {} + }, + "reviews": { + "name": "reviews", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_title": { + "name": "pr_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fixture": { + "name": "fixture", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verdict": { + "name": "verdict", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "reviews_repo_idx": { + "name": "reviews_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + }, + "reviews_status_idx": { + "name": "reviews_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "reviews_id": { + "name": "reviews_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "run_events": { + "name": "run_events", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "line": { + "name": "line", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "run_events_review_seq_idx": { + "name": "run_events_review_seq_idx", + "columns": [ + "review_id", + "seq" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "run_events_id": { + "name": "run_events_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "github_id": { + "name": "github_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "login": { + "name": "login", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_enc": { + "name": "access_token_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_login_at": { + "name": "last_login_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_github_id_unique": { + "name": "users_github_id_unique", + "columns": [ + "github_id" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/0001_snapshot.json b/packages/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..8a443b4 --- /dev/null +++ b/packages/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,891 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "22e9ff97-02ca-4450-b819-652c1b212131", + "prevId": "98d2c0eb-e5b8-4b70-8e5c-e22c9501921e", + "tables": { + "analyses": { + "name": "analyses", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fixture": { + "name": "fixture", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_sha": { + "name": "commit_sha", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "analyses_repo_idx": { + "name": "analyses_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + }, + "analyses_status_idx": { + "name": "analyses_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "analyses_id": { + "name": "analyses_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "findings": { + "name": "findings", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finding_id": { + "name": "finding_id", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('proposed','confirmed','dismissed')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'proposed'" + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_by": { + "name": "rated_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_at": { + "name": "rated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "findings_review_finding_idx": { + "name": "findings_review_finding_idx", + "columns": [ + "review_id", + "finding_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "findings_id": { + "name": "findings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "knowledge_docs": { + "name": "knowledge_docs", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "analysis_id": { + "name": "analysis_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "knowledge_docs_analysis_name_idx": { + "name": "knowledge_docs_analysis_name_idx", + "columns": [ + "analysis_id", + "name" + ], + "isUnique": true + }, + "knowledge_docs_repo_idx": { + "name": "knowledge_docs_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "knowledge_docs_id": { + "name": "knowledge_docs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "repo_roles": { + "name": "repo_roles", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(60)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username_enc": { + "name": "username_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_enc": { + "name": "password_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "login_path": { + "name": "login_path", + "type": "varchar(300)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'/login'" + }, + "success_url_contains": { + "name": "success_url_contains", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipe": { + "name": "recipe", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "repo_roles_repo_name_idx": { + "name": "repo_roles_repo_name_idx", + "columns": [ + "repo_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "repo_roles_id": { + "name": "repo_roles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "repos": { + "name": "repos", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "full_name": { + "name": "full_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_branch": { + "name": "default_branch", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extra_allowed_hosts": { + "name": "extra_allowed_hosts", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data_confirmed": { + "name": "data_confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_instance_url": { + "name": "default_instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "repos_id": { + "name": "repos_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "repos_full_name_unique": { + "name": "repos_full_name_unique", + "columns": [ + "full_name" + ] + } + }, + "checkConstraint": {} + }, + "reviews": { + "name": "reviews", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_title": { + "name": "pr_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fixture": { + "name": "fixture", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verdict": { + "name": "verdict", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "reviews_repo_idx": { + "name": "reviews_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + }, + "reviews_status_idx": { + "name": "reviews_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "reviews_id": { + "name": "reviews_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "run_events": { + "name": "run_events", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "analysis_id": { + "name": "analysis_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "line": { + "name": "line", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "run_events_review_seq_idx": { + "name": "run_events_review_seq_idx", + "columns": [ + "review_id", + "seq" + ], + "isUnique": true + }, + "run_events_analysis_seq_idx": { + "name": "run_events_analysis_seq_idx", + "columns": [ + "analysis_id", + "seq" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "run_events_id": { + "name": "run_events_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "github_id": { + "name": "github_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "login": { + "name": "login", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_enc": { + "name": "access_token_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_login_at": { + "name": "last_login_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_github_id_unique": { + "name": "users_github_id_unique", + "columns": [ + "github_id" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/0002_snapshot.json b/packages/db/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..0df4ef8 --- /dev/null +++ b/packages/db/migrations/meta/0002_snapshot.json @@ -0,0 +1,898 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "82729a48-32af-4e22-9c3d-74ed1f74195a", + "prevId": "22e9ff97-02ca-4450-b819-652c1b212131", + "tables": { + "analyses": { + "name": "analyses", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fixture": { + "name": "fixture", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_sha": { + "name": "commit_sha", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "analyses_repo_idx": { + "name": "analyses_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + }, + "analyses_status_idx": { + "name": "analyses_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "analyses_id": { + "name": "analyses_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "findings": { + "name": "findings", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finding_id": { + "name": "finding_id", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('proposed','confirmed','dismissed')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'proposed'" + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_by": { + "name": "rated_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_at": { + "name": "rated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "findings_review_finding_idx": { + "name": "findings_review_finding_idx", + "columns": [ + "review_id", + "finding_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "findings_id": { + "name": "findings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "knowledge_docs": { + "name": "knowledge_docs", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "analysis_id": { + "name": "analysis_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "knowledge_docs_analysis_name_idx": { + "name": "knowledge_docs_analysis_name_idx", + "columns": [ + "analysis_id", + "name" + ], + "isUnique": true + }, + "knowledge_docs_repo_idx": { + "name": "knowledge_docs_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "knowledge_docs_id": { + "name": "knowledge_docs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "repo_roles": { + "name": "repo_roles", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(60)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username_enc": { + "name": "username_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_enc": { + "name": "password_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "login_path": { + "name": "login_path", + "type": "varchar(300)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'/login'" + }, + "success_url_contains": { + "name": "success_url_contains", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipe": { + "name": "recipe", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "repo_roles_repo_name_idx": { + "name": "repo_roles_repo_name_idx", + "columns": [ + "repo_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "repo_roles_id": { + "name": "repo_roles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "repos": { + "name": "repos", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "full_name": { + "name": "full_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_branch": { + "name": "default_branch", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extra_allowed_hosts": { + "name": "extra_allowed_hosts", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data_confirmed": { + "name": "data_confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_instance_url": { + "name": "default_instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "repos_id": { + "name": "repos_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "repos_full_name_unique": { + "name": "repos_full_name_unique", + "columns": [ + "full_name" + ] + } + }, + "checkConstraint": {} + }, + "reviews": { + "name": "reviews", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_title": { + "name": "pr_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fixture": { + "name": "fixture", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verdict": { + "name": "verdict", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comment_url": { + "name": "comment_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "reviews_repo_idx": { + "name": "reviews_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + }, + "reviews_status_idx": { + "name": "reviews_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "reviews_id": { + "name": "reviews_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "run_events": { + "name": "run_events", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "analysis_id": { + "name": "analysis_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "line": { + "name": "line", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "run_events_review_seq_idx": { + "name": "run_events_review_seq_idx", + "columns": [ + "review_id", + "seq" + ], + "isUnique": true + }, + "run_events_analysis_seq_idx": { + "name": "run_events_analysis_seq_idx", + "columns": [ + "analysis_id", + "seq" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "run_events_id": { + "name": "run_events_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "github_id": { + "name": "github_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "login": { + "name": "login", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_enc": { + "name": "access_token_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_login_at": { + "name": "last_login_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_github_id_unique": { + "name": "users_github_id_unique", + "columns": [ + "github_id" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/0003_snapshot.json b/packages/db/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..a5182f7 --- /dev/null +++ b/packages/db/migrations/meta/0003_snapshot.json @@ -0,0 +1,1234 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "2c0284bc-a18c-41bf-8a11-b84ac6d3a38c", + "prevId": "82729a48-32af-4e22-9c3d-74ed1f74195a", + "tables": { + "analyses": { + "name": "analyses", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fixture": { + "name": "fixture", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commit_sha": { + "name": "commit_sha", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "analyses_repo_idx": { + "name": "analyses_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + }, + "analyses_status_idx": { + "name": "analyses_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "analyses_id": { + "name": "analyses_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "findings": { + "name": "findings", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "finding_id": { + "name": "finding_id", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('proposed','confirmed','dismissed')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'proposed'" + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_by": { + "name": "rated_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_at": { + "name": "rated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "findings_review_finding_idx": { + "name": "findings_review_finding_idx", + "columns": [ + "review_id", + "finding_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "findings_id": { + "name": "findings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "knowledge_docs": { + "name": "knowledge_docs", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "analysis_id": { + "name": "analysis_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "knowledge_docs_analysis_name_idx": { + "name": "knowledge_docs_analysis_name_idx", + "columns": [ + "analysis_id", + "name" + ], + "isUnique": true + }, + "knowledge_docs_repo_idx": { + "name": "knowledge_docs_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "knowledge_docs_id": { + "name": "knowledge_docs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "repo_roles": { + "name": "repo_roles", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(60)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username_enc": { + "name": "username_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_enc": { + "name": "password_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "login_path": { + "name": "login_path", + "type": "varchar(300)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'/login'" + }, + "success_url_contains": { + "name": "success_url_contains", + "type": "varchar(300)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipe": { + "name": "recipe", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "repo_roles_repo_name_idx": { + "name": "repo_roles_repo_name_idx", + "columns": [ + "repo_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "repo_roles_id": { + "name": "repo_roles_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "repos": { + "name": "repos", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "full_name": { + "name": "full_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_private": { + "name": "is_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_branch": { + "name": "default_branch", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extra_allowed_hosts": { + "name": "extra_allowed_hosts", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data_confirmed": { + "name": "data_confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "default_instance_url": { + "name": "default_instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "repos_id": { + "name": "repos_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "repos_full_name_unique": { + "name": "repos_full_name_unique", + "columns": [ + "full_name" + ] + } + }, + "checkConstraint": {} + }, + "reviews": { + "name": "reviews", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_title": { + "name": "pr_title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fixture": { + "name": "fixture", + "type": "varchar(60)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verdict": { + "name": "verdict", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comment_url": { + "name": "comment_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "reviews_repo_idx": { + "name": "reviews_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + }, + "reviews_status_idx": { + "name": "reviews_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "reviews_id": { + "name": "reviews_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "run_events": { + "name": "run_events", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "analysis_id": { + "name": "analysis_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "test_run_id": { + "name": "test_run_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "line": { + "name": "line", + "type": "mediumtext", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "run_events_review_seq_idx": { + "name": "run_events_review_seq_idx", + "columns": [ + "review_id", + "seq" + ], + "isUnique": true + }, + "run_events_analysis_seq_idx": { + "name": "run_events_analysis_seq_idx", + "columns": [ + "analysis_id", + "seq" + ], + "isUnique": true + }, + "run_events_test_seq_idx": { + "name": "run_events_test_seq_idx", + "columns": [ + "test_run_id", + "seq" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "run_events_id": { + "name": "run_events_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_user_idx": { + "name": "sessions_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sessions_id": { + "name": "sessions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "test_runs": { + "name": "test_runs", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "script_id": { + "name": "script_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "review_id": { + "name": "review_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "batch_id": { + "name": "batch_id", + "type": "varchar(40)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "instance_url": { + "name": "instance_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('queued','running','completed','budget_exceeded','max_turns','failed','aborted')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "run_id": { + "name": "run_id", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "outcome": { + "name": "outcome", + "type": "enum('passed','failed','inconclusive')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "healed": { + "name": "healed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "steps_version": { + "name": "steps_version", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report": { + "name": "report", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "abort_requested": { + "name": "abort_requested", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "started_by": { + "name": "started_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "finished_at": { + "name": "finished_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "test_runs_script_idx": { + "name": "test_runs_script_idx", + "columns": [ + "script_id" + ], + "isUnique": false + }, + "test_runs_review_idx": { + "name": "test_runs_review_idx", + "columns": [ + "review_id" + ], + "isUnique": false + }, + "test_runs_status_idx": { + "name": "test_runs_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "test_runs_batch_idx": { + "name": "test_runs_batch_idx", + "columns": [ + "batch_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "test_runs_id": { + "name": "test_runs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "test_scripts": { + "name": "test_scripts", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "repo_id": { + "name": "repo_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expectation": { + "name": "expectation", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "roles": { + "name": "roles", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "determinism": { + "name": "determinism", + "type": "enum('deterministic','semi','explorative')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "steps": { + "name": "steps", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "steps_version": { + "name": "steps_version", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "steps_updated_at": { + "name": "steps_updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "run_in_reviews": { + "name": "run_in_reviews", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "test_scripts_repo_idx": { + "name": "test_scripts_repo_idx", + "columns": [ + "repo_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "test_scripts_id": { + "name": "test_scripts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "github_id": { + "name": "github_id", + "type": "bigint unsigned", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "login": { + "name": "login", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_enc": { + "name": "access_token_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_login_at": { + "name": "last_login_at", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_github_id_unique": { + "name": "users_github_id_unique", + "columns": [ + "github_id" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json new file mode 100644 index 0000000..1749ed7 --- /dev/null +++ b/packages/db/migrations/meta/_journal.json @@ -0,0 +1,34 @@ +{ + "version": "7", + "dialect": "mysql", + "entries": [ + { + "idx": 0, + "version": "5", + "when": 1791659909171, + "tag": "0000_init", + "breakpoints": true + }, + { + "idx": 1, + "version": "5", + "when": 1791662748567, + "tag": "0001_analyses", + "breakpoints": true + }, + { + "idx": 2, + "version": "5", + "when": 1791664551504, + "tag": "0002_review_comment", + "breakpoints": true + }, + { + "idx": 3, + "version": "5", + "when": 1791798175090, + "tag": "0003_tests", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/packages/db/package.json b/packages/db/package.json new file mode 100644 index 0000000..ae11f46 --- /dev/null +++ b/packages/db/package.json @@ -0,0 +1,24 @@ +{ + "name": "@claudio/db", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json", + "generate": "drizzle-kit generate", + "migrate": "tsx src/migrate.ts" + }, + "dependencies": { + "@claudio/core": "workspace:*", + "drizzle-orm": "^0.45.4", + "mysql2": "^3.24.5" + }, + "devDependencies": { + "drizzle-kit": "^0.31.11" + } +} diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts new file mode 100644 index 0000000..149bfe0 --- /dev/null +++ b/packages/db/src/client.ts @@ -0,0 +1,27 @@ +import { drizzle, type MySql2Database } from "drizzle-orm/mysql2"; +import mysql from "mysql2/promise"; +import * as schema from "./schema.js"; + +export type Db = MySql2Database; + +let cached: { db: Db; pool: mysql.Pool } | undefined; + +export function databaseUrl(): string { + const url = process.env.DATABASE_URL; + if (!url) throw new Error("DATABASE_URL is not set"); + return url; +} + +/** One pool per process. */ +export function getDb(url: string = databaseUrl()): Db { + if (!cached) { + const pool = mysql.createPool({ uri: url, connectionLimit: 10, timezone: "Z" }); + cached = { pool, db: drizzle(pool, { schema, mode: "default" }) }; + } + return cached.db; +} + +export async function closeDb(): Promise { + await cached?.pool.end(); + cached = undefined; +} diff --git a/packages/db/src/crypto.ts b/packages/db/src/crypto.ts new file mode 100644 index 0000000..797816d --- /dev/null +++ b/packages/db/src/crypto.ts @@ -0,0 +1,33 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto"; + +const VERSION = "v1"; + +/** + * Derives the 32-byte AES key from CLAUDIO_SECRET_KEY. Any string works; a + * random 32+ character value is expected (`openssl rand -base64 32`). + */ +export function secretKey(raw: string | undefined = process.env.CLAUDIO_SECRET_KEY): Buffer { + if (!raw || raw.length < 16) throw new Error("CLAUDIO_SECRET_KEY is not set (min. 16 characters)"); + return createHash("sha256").update(raw).digest(); +} + +/** AES-256-GCM. Output: v1..., all base64url. */ +export function encrypt(plain: string, key: Buffer = secretKey()): string { + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const data = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return [VERSION, iv.toString("base64url"), tag.toString("base64url"), data.toString("base64url")].join("."); +} + +export function decrypt(token: string, key: Buffer = secretKey()): string { + const [version, iv, tag, data] = token.split("."); + if (version !== VERSION || !iv || !tag || data === undefined) throw new Error("unsupported ciphertext"); + const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "base64url")); + decipher.setAuthTag(Buffer.from(tag, "base64url")); + return Buffer.concat([decipher.update(Buffer.from(data, "base64url")), decipher.final()]).toString("utf8"); +} + +export function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts new file mode 100644 index 0000000..f36747b --- /dev/null +++ b/packages/db/src/index.ts @@ -0,0 +1,5 @@ +export * from "./schema.js"; +export * from "./client.js"; +export * from "./crypto.js"; +export * from "./repo-config.js"; +export * from "./queries.js"; diff --git a/packages/db/src/migrate.ts b/packages/db/src/migrate.ts new file mode 100644 index 0000000..bf40615 --- /dev/null +++ b/packages/db/src/migrate.ts @@ -0,0 +1,13 @@ +import { existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { migrate } from "drizzle-orm/mysql2/migrator"; +import { closeDb, getDb } from "./client.js"; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = resolve(here, "../../.."); +if (existsSync(resolve(root, ".env"))) process.loadEnvFile(resolve(root, ".env")); + +await migrate(getDb(), { migrationsFolder: resolve(here, "../migrations") }); +await closeDb(); +console.log("migrations applied"); diff --git a/packages/db/src/queries.ts b/packages/db/src/queries.ts new file mode 100644 index 0000000..e202329 --- /dev/null +++ b/packages/db/src/queries.ts @@ -0,0 +1,712 @@ +import { randomBytes } from "node:crypto"; +import { and, asc, desc, eq, gt, inArray, lt, sql } from "drizzle-orm"; +import type { + AnalysisReport, + AnalysisRequest, + Determinism, + KnowledgeBase, + KnowledgeDoc, + LoginStep, + ReviewReport, + ReviewRequest, + TestRunReport, + TestRunRequest, + TestScript, + TestStep, +} from "@claudio/core"; +import type { Db } from "./client.js"; +import { decrypt, encrypt, sha256 } from "./crypto.js"; +import { + analyses, + findings, + knowledgeDocs, + repoRoles, + repos, + reviews, + runEvents, + sessions, + testRuns, + testScripts, + users, + type Analysis, + type FindingStatus, + type Repo, + type Review, + type ReviewStatus, + type TestRun, + type TestScriptRow, + type User, +} from "./schema.js"; + +// ---------------------------------------------------------------- users & sessions + +export async function upsertUser( + db: Db, + input: { githubId: number; login: string; name: string | null; avatarUrl: string | null; accessToken: string }, +): Promise { + const values = { + githubId: input.githubId, + login: input.login, + name: input.name, + avatarUrl: input.avatarUrl, + accessTokenEnc: encrypt(input.accessToken), + lastLoginAt: new Date(), + }; + await db + .insert(users) + .values(values) + .onDuplicateKeyUpdate({ set: { login: values.login, name: values.name, avatarUrl: values.avatarUrl, accessTokenEnc: values.accessTokenEnc, lastLoginAt: values.lastLoginAt } }); + const [user] = await db.select().from(users).where(eq(users.githubId, input.githubId)); + if (!user) throw new Error("user upsert failed"); + return user; +} + +export function userAccessToken(user: Pick): string { + return decrypt(user.accessTokenEnc); +} + +export async function getUser(db: Db, id: number): Promise { + const [user] = await db.select().from(users).where(eq(users.id, id)); + return user; +} + +/** Creates a session and returns the raw cookie value (only its hash is stored). */ +export async function createSession(db: Db, userId: number, ttlDays = 14): Promise<{ token: string; expiresAt: Date }> { + const token = randomBytes(32).toString("base64url"); + const expiresAt = new Date(Date.now() + ttlDays * 86_400_000); + await db.insert(sessions).values({ id: sha256(token), userId, expiresAt }); + return { token, expiresAt }; +} + +export async function getSessionUser(db: Db, token: string): Promise { + const [row] = await db + .select({ user: users, expiresAt: sessions.expiresAt }) + .from(sessions) + .innerJoin(users, eq(users.id, sessions.userId)) + .where(eq(sessions.id, sha256(token))); + if (!row) return null; + if (row.expiresAt.getTime() < Date.now()) { + await deleteSession(db, token); + return null; + } + return row.user; +} + +export async function deleteSession(db: Db, token: string): Promise { + await db.delete(sessions).where(eq(sessions.id, sha256(token))); +} + +export async function deleteExpiredSessions(db: Db): Promise { + await db.delete(sessions).where(lt(sessions.expiresAt, new Date())); +} + +// ---------------------------------------------------------------- repos & roles + +export async function listRepos(db: Db): Promise { + return db.select().from(repos).orderBy(asc(repos.fullName)); +} + +export async function getRepo(db: Db, fullName: string): Promise { + const [repo] = await db.select().from(repos).where(eq(repos.fullName, fullName)); + return repo; +} + +export async function getRepoById(db: Db, id: number): Promise { + const [repo] = await db.select().from(repos).where(eq(repos.id, id)); + return repo; +} + +export async function addRepo( + db: Db, + input: { fullName: string; isPrivate: boolean; defaultBranch: string | null; createdBy: number }, +): Promise { + await db.insert(repos).values(input).onDuplicateKeyUpdate({ set: { isPrivate: input.isPrivate, defaultBranch: input.defaultBranch } }); + const repo = await getRepo(db, input.fullName); + if (!repo) throw new Error("repo insert failed"); + return repo; +} + +export async function updateRepoSettings( + db: Db, + id: number, + input: { notes: string | null; extraAllowedHosts: string[]; dataConfirmed: boolean; defaultInstanceUrl: string | null }, +): Promise { + await db.update(repos).set(input).where(eq(repos.id, id)); +} + +export async function removeRepo(db: Db, id: number): Promise { + await db.delete(repoRoles).where(eq(repoRoles.repoId, id)); + await db.delete(repos).where(eq(repos.id, id)); +} + +export async function listRoles(db: Db, repoId: number) { + return db.select().from(repoRoles).where(eq(repoRoles.repoId, repoId)).orderBy(asc(repoRoles.name)); +} + +export type RoleInput = { + name: string; + description: string | null; + /** Omit to keep the stored value (password fields are write-only in the UI). */ + username?: string; + password?: string; + loginPath: string; + successUrlContains: string | null; + recipe: LoginStep[] | null; +}; + +export async function saveRole(db: Db, repoId: number, input: RoleInput): Promise { + const [existing] = await db + .select() + .from(repoRoles) + .where(and(eq(repoRoles.repoId, repoId), eq(repoRoles.name, input.name))); + const base = { + description: input.description, + loginPath: input.loginPath, + successUrlContains: input.successUrlContains, + recipe: input.recipe, + }; + if (existing) { + await db + .update(repoRoles) + .set({ + ...base, + ...(input.username ? { usernameEnc: encrypt(input.username) } : {}), + ...(input.password ? { passwordEnc: encrypt(input.password) } : {}), + }) + .where(eq(repoRoles.id, existing.id)); + return; + } + if (!input.username || !input.password) throw new Error("Benutzername und Passwort sind für eine neue Rolle Pflicht."); + await db.insert(repoRoles).values({ + ...base, + repoId, + name: input.name, + usernameEnc: encrypt(input.username), + passwordEnc: encrypt(input.password), + }); +} + +export async function deleteRole(db: Db, repoId: number, name: string): Promise { + await db.delete(repoRoles).where(and(eq(repoRoles.repoId, repoId), eq(repoRoles.name, name))); +} + +/** Stored username in clear text, for display in the settings form. */ +export function roleUsername(role: { usernameEnc: string }): string { + try { + return decrypt(role.usernameEnc); + } catch { + return ""; + } +} + +// ---------------------------------------------------------------- reviews + +export type ReviewListItem = Review & { repoFullName: string; startedByLogin: string }; + +export async function createReview( + db: Db, + input: { repoId: number; prNumber: number; prTitle: string | null; request: ReviewRequest; fixture: string | null; startedBy: number }, +): Promise { + const [res] = await db.insert(reviews).values({ + repoId: input.repoId, + prNumber: input.prNumber, + prTitle: input.prTitle, + instanceUrl: input.request.instanceUrl, + request: input.request, + fixture: input.fixture, + startedBy: input.startedBy, + }); + return res.insertId; +} + +export async function listReviews(db: Db, opts: { repoId?: number; limit?: number } = {}): Promise { + const rows = await db + .select({ review: reviews, repoFullName: repos.fullName, startedByLogin: users.login }) + .from(reviews) + .innerJoin(repos, eq(repos.id, reviews.repoId)) + .innerJoin(users, eq(users.id, reviews.startedBy)) + .where(opts.repoId ? eq(reviews.repoId, opts.repoId) : undefined) + .orderBy(desc(reviews.id)) + .limit(opts.limit ?? 50); + return rows.map((r) => ({ ...r.review, repoFullName: r.repoFullName, startedByLogin: r.startedByLogin })); +} + +export async function getReview(db: Db, id: number): Promise { + const [row] = await db + .select({ review: reviews, repoFullName: repos.fullName, startedByLogin: users.login }) + .from(reviews) + .innerJoin(repos, eq(repos.id, reviews.repoId)) + .innerJoin(users, eq(users.id, reviews.startedBy)) + .where(eq(reviews.id, id)); + return row ? { ...row.review, repoFullName: row.repoFullName, startedByLogin: row.startedByLogin } : undefined; +} + +export async function requestAbort(db: Db, id: number): Promise { + // A queued review is aborted right away; a running one is stopped by the worker. + await db.update(reviews).set({ status: "aborted", finishedAt: new Date() }).where(and(eq(reviews.id, id), eq(reviews.status, "queued"))); + await db.update(reviews).set({ abortRequested: true }).where(and(eq(reviews.id, id), eq(reviews.status, "running"))); +} + +export async function isAbortRequested(db: Db, id: number): Promise { + const [row] = await db.select({ abort: reviews.abortRequested }).from(reviews).where(eq(reviews.id, id)); + return Boolean(row?.abort); +} + +/** Atomically moves the oldest queued review to running. Safe with several workers. */ +export async function claimNextReview(db: Db): Promise { + return db.transaction(async (tx) => { + const [next] = await tx + .select() + .from(reviews) + .where(eq(reviews.status, "queued")) + .orderBy(asc(reviews.id)) + .limit(1) + .for("update", { skipLocked: true }); + if (!next) return null; + const startedAt = new Date(); + await tx.update(reviews).set({ status: "running", startedAt }).where(eq(reviews.id, next.id)); + return { ...next, status: "running" as const, startedAt }; + }); +} + +export async function setReviewCommentUrl(db: Db, id: number, commentUrl: string): Promise { + await db.update(reviews).set({ commentUrl }).where(eq(reviews.id, id)); +} + +export async function setReviewRunId(db: Db, id: number, runId: string): Promise { + await db.update(reviews).set({ runId }).where(eq(reviews.id, id)); +} + +export async function finishReview( + db: Db, + id: number, + input: { status: ReviewStatus; report: ReviewReport | null; verdict: string | null; error: string | null }, +): Promise { + await db + .update(reviews) + .set({ + status: input.status, + report: input.report, + verdict: input.verdict, + costUsd: input.report ? input.report.cost.usd.toFixed(4) : null, + error: input.error, + finishedAt: new Date(), + ...(input.report ? { prTitle: input.report.pr.title } : {}), + ...(input.report?.commentUrl ? { commentUrl: input.report.commentUrl } : {}), + }) + .where(eq(reviews.id, id)); + if (input.report && input.report.findings.length > 0) { + await db + .insert(findings) + .values(input.report.findings.map((f) => ({ reviewId: id, findingId: f.id, data: f }))) + .onDuplicateKeyUpdate({ set: { data: sql`values(${findings.data})` } }); + } +} + +/** Reviews and analyses left in "running" by a crashed worker. */ +export async function failStaleRuns(db: Db): Promise { + const set = { status: "failed" as const, error: "Worker wurde während des Runs beendet.", finishedAt: new Date() }; + const [a] = await db.update(reviews).set(set).where(eq(reviews.status, "running")); + const [b] = await db.update(analyses).set(set).where(eq(analyses.status, "running")); + const [c] = await db.update(testRuns).set(set).where(eq(testRuns.status, "running")); + return a.affectedRows + b.affectedRows + c.affectedRows; +} + +// ---------------------------------------------------------------- live events + +/** Which run a live log line belongs to. */ +export type EventTarget = { reviewId: number } | { analysisId: number } | { testRunId: number }; + +const eventFilter = (t: EventTarget) => + "reviewId" in t ? eq(runEvents.reviewId, t.reviewId) : "analysisId" in t ? eq(runEvents.analysisId, t.analysisId) : eq(runEvents.testRunId, t.testRunId); + +export async function appendEvent(db: Db, target: EventTarget, seq: number, line: string): Promise { + await db.insert(runEvents).values({ ...target, seq, line }); +} + +export async function eventsSince(db: Db, target: EventTarget, afterSeq: number, limit = 500) { + return db + .select({ seq: runEvents.seq, line: runEvents.line, createdAt: runEvents.createdAt }) + .from(runEvents) + .where(and(eventFilter(target), gt(runEvents.seq, afterSeq))) + .orderBy(asc(runEvents.seq)) + .limit(limit); +} + +// ---------------------------------------------------------------- analyses & knowledge + +export type AnalysisListItem = Analysis & { repoFullName: string; startedByLogin: string; docCount: number }; + +export async function createAnalysis( + db: Db, + input: { repoId: number; request: AnalysisRequest; fixture: string | null; startedBy: number }, +): Promise { + const [res] = await db.insert(analyses).values({ + repoId: input.repoId, + request: input.request, + instanceUrl: input.request.instanceUrl, + fixture: input.fixture, + startedBy: input.startedBy, + }); + return res.insertId; +} + +const analysisSelect = { + analysis: analyses, + repoFullName: repos.fullName, + startedByLogin: users.login, + docCount: sql`(select count(*) from ${knowledgeDocs} where ${knowledgeDocs.analysisId} = ${analyses.id})`.mapWith(Number), +}; + +export async function listAnalyses(db: Db, opts: { repoId?: number; limit?: number; activeOnly?: boolean } = {}): Promise { + const rows = await db + .select(analysisSelect) + .from(analyses) + .innerJoin(repos, eq(repos.id, analyses.repoId)) + .innerJoin(users, eq(users.id, analyses.startedBy)) + .where(and(opts.repoId ? eq(analyses.repoId, opts.repoId) : undefined, opts.activeOnly ? inArray(analyses.status, ["queued", "running"]) : undefined)) + .orderBy(desc(analyses.id)) + .limit(opts.limit ?? 50); + return rows.map((r) => ({ ...r.analysis, repoFullName: r.repoFullName, startedByLogin: r.startedByLogin, docCount: r.docCount })); +} + +export async function getAnalysis(db: Db, id: number): Promise { + const [r] = await db + .select(analysisSelect) + .from(analyses) + .innerJoin(repos, eq(repos.id, analyses.repoId)) + .innerJoin(users, eq(users.id, analyses.startedBy)) + .where(eq(analyses.id, id)); + return r ? { ...r.analysis, repoFullName: r.repoFullName, startedByLogin: r.startedByLogin, docCount: r.docCount } : undefined; +} + +export async function claimNextAnalysis(db: Db): Promise { + return db.transaction(async (tx) => { + const [next] = await tx + .select() + .from(analyses) + .where(eq(analyses.status, "queued")) + .orderBy(asc(analyses.id)) + .limit(1) + .for("update", { skipLocked: true }); + if (!next) return null; + const startedAt = new Date(); + await tx.update(analyses).set({ status: "running", startedAt }).where(eq(analyses.id, next.id)); + return { ...next, status: "running" as const, startedAt }; + }); +} + +export async function setAnalysisRunId(db: Db, id: number, runId: string): Promise { + await db.update(analyses).set({ runId }).where(eq(analyses.id, id)); +} + +export async function requestAnalysisAbort(db: Db, id: number): Promise { + await db.update(analyses).set({ status: "aborted", finishedAt: new Date() }).where(and(eq(analyses.id, id), eq(analyses.status, "queued"))); + await db.update(analyses).set({ abortRequested: true }).where(and(eq(analyses.id, id), eq(analyses.status, "running"))); +} + +export async function isAnalysisAbortRequested(db: Db, id: number): Promise { + const [row] = await db.select({ abort: analyses.abortRequested }).from(analyses).where(eq(analyses.id, id)); + return Boolean(row?.abort); +} + +export async function finishAnalysis( + db: Db, + id: number, + input: { status: ReviewStatus; report: AnalysisReport | null; error: string | null }, +): Promise { + const analysis = await db.select({ repoId: analyses.repoId }).from(analyses).where(eq(analyses.id, id)); + const repoId = analysis[0]?.repoId; + const { docs, ...report } = input.report ?? { docs: [] as KnowledgeDoc[] }; + await db + .update(analyses) + .set({ + status: input.status, + report: input.report ? (report as Omit) : null, + branch: input.report?.branch ?? null, + commitSha: input.report?.commitSha ?? null, + costUsd: input.report ? input.report.cost.usd.toFixed(4) : null, + error: input.error, + finishedAt: new Date(), + }) + .where(eq(analyses.id, id)); + if (repoId && docs.length > 0) { + await db.insert(knowledgeDocs).values(docs.map((d) => ({ analysisId: id, repoId, name: d.name, content: d.content }))); + } +} + +export type KnowledgeWithSource = KnowledgeBase & { analysisId: number; status: ReviewStatus; startedByLogin: string }; + +/** The repo's current knowledge base: documents of the newest analysis that produced any. */ +export async function latestKnowledge(db: Db, repoId: number): Promise { + const [latest] = await db + .select({ analysis: analyses, startedByLogin: users.login }) + .from(analyses) + .innerJoin(users, eq(users.id, analyses.startedBy)) + .where(and(eq(analyses.repoId, repoId), sql`exists (select 1 from ${knowledgeDocs} where ${knowledgeDocs.analysisId} = ${analyses.id})`)) + .orderBy(desc(analyses.id)) + .limit(1); + if (!latest) return null; + const docs = await knowledgeDocsOf(db, latest.analysis.id); + return { + analysisId: latest.analysis.id, + status: latest.analysis.status, + startedByLogin: latest.startedByLogin, + commitSha: latest.analysis.commitSha ?? "unknown", + branch: latest.analysis.branch ?? "", + analyzedAt: (latest.analysis.finishedAt ?? latest.analysis.createdAt).toISOString(), + docs, + }; +} + +export async function knowledgeDocsOf(db: Db, analysisId: number): Promise { + const order = ["overview", "modules", "routes", "app-map", "notes"]; + const rows = await db.select({ name: knowledgeDocs.name, content: knowledgeDocs.content }).from(knowledgeDocs).where(eq(knowledgeDocs.analysisId, analysisId)); + return rows.sort((a, b) => order.indexOf(a.name) - order.indexOf(b.name)); +} + +// ---------------------------------------------------------------- test scripts & runs + +export type TestScriptInput = { name: string; description: string; expectation: string; roles: string[]; runInReviews: boolean }; + +export async function createTestScript(db: Db, repoId: number, input: TestScriptInput, createdBy: number): Promise { + const [res] = await db.insert(testScripts).values({ ...input, repoId, createdBy }); + return res.insertId; +} + +export async function updateTestScript(db: Db, id: number, input: TestScriptInput & { determinism: Determinism | null }): Promise { + await db.update(testScripts).set({ ...input, updatedAt: new Date() }).where(eq(testScripts.id, id)); +} + +/** Stores a new recording (or healed version) of the steps. */ +export async function saveTestSteps(db: Db, id: number, steps: TestStep[], determinism?: Determinism): Promise { + await db + .update(testScripts) + .set({ + steps, + stepsVersion: sql`${testScripts.stepsVersion} + 1`, + stepsUpdatedAt: new Date(), + ...(determinism ? { determinism } : {}), + }) + .where(eq(testScripts.id, id)); +} + +/** Clears the recording, so the next run records from scratch. */ +export async function resetTestSteps(db: Db, id: number): Promise { + await db.update(testScripts).set({ steps: null, stepsUpdatedAt: new Date() }).where(eq(testScripts.id, id)); +} + +export async function deleteTestScript(db: Db, id: number): Promise { + await db.delete(runEvents).where(inArray(runEvents.testRunId, db.select({ id: testRuns.id }).from(testRuns).where(eq(testRuns.scriptId, id)))); + await db.delete(testRuns).where(eq(testRuns.scriptId, id)); + await db.delete(testScripts).where(eq(testScripts.id, id)); +} + +export type TestScriptListItem = TestScriptRow & { + repoFullName: string; + lastOutcome: string | null; + lastStatus: string | null; + lastRunAt: Date | null; + lastHealed: boolean; +}; + +export async function listTestScripts(db: Db, repoId: number): Promise { + const rows = await db + .select({ script: testScripts, repoFullName: repos.fullName }) + .from(testScripts) + .innerJoin(repos, eq(repos.id, testScripts.repoId)) + .where(eq(testScripts.repoId, repoId)) + .orderBy(asc(testScripts.name)); + const last = await lastRunsByScript(db, rows.map((r) => r.script.id)); + return rows.map((r) => { + const l = last.get(r.script.id); + return { ...r.script, repoFullName: r.repoFullName, lastOutcome: l?.outcome ?? null, lastStatus: l?.status ?? null, lastRunAt: l?.createdAt ?? null, lastHealed: l?.healed ?? false }; + }); +} + +async function lastRunsByScript(db: Db, scriptIds: number[]): Promise> { + const out = new Map(); + if (scriptIds.length === 0) return out; + const rows = await db.select().from(testRuns).where(inArray(testRuns.scriptId, scriptIds)).orderBy(desc(testRuns.id)); + for (const r of rows) if (!out.has(r.scriptId)) out.set(r.scriptId, r); + return out; +} + +export async function getTestScript(db: Db, id: number): Promise<(TestScriptRow & { repoFullName: string }) | undefined> { + const [r] = await db.select({ script: testScripts, repoFullName: repos.fullName }).from(testScripts).innerJoin(repos, eq(repos.id, testScripts.repoId)).where(eq(testScripts.id, id)); + return r ? { ...r.script, repoFullName: r.repoFullName } : undefined; +} + +export function toTestScript(row: TestScriptRow): TestScript { + return { + id: row.id, + name: row.name, + description: row.description, + expectation: row.expectation, + roles: row.roles ?? [], + determinism: row.determinism ?? null, + steps: row.steps ?? null, + }; +} + +export async function createTestRuns( + db: Db, + runs: { scriptId: number; repoId: number; request: TestRunRequest; reviewId?: number | null; batchId?: string | null; startedBy: number }[], +): Promise { + if (runs.length === 0) return; + await db.insert(testRuns).values( + runs.map((r) => ({ + scriptId: r.scriptId, + repoId: r.repoId, + reviewId: r.reviewId ?? null, + batchId: r.batchId ?? null, + request: r.request, + instanceUrl: r.request.instanceUrl, + startedBy: r.startedBy, + })), + ); +} + +export async function createTestRun( + db: Db, + run: { scriptId: number; repoId: number; request: TestRunRequest; startedBy: number }, +): Promise { + const [res] = await db.insert(testRuns).values({ ...run, instanceUrl: run.request.instanceUrl }); + return res.insertId; +} + +export type TestRunListItem = TestRun & { scriptName: string; repoFullName: string; startedByLogin: string }; + +const testRunSelect = { run: testRuns, scriptName: testScripts.name, repoFullName: repos.fullName, startedByLogin: users.login }; + +export async function listTestRuns( + db: Db, + filter: { scriptId?: number; reviewId?: number; batchId?: string; repoId?: number; activeOnly?: boolean }, + limit = 50, +): Promise { + const rows = await db + .select(testRunSelect) + .from(testRuns) + .innerJoin(testScripts, eq(testScripts.id, testRuns.scriptId)) + .innerJoin(repos, eq(repos.id, testRuns.repoId)) + .innerJoin(users, eq(users.id, testRuns.startedBy)) + .where( + and( + filter.scriptId ? eq(testRuns.scriptId, filter.scriptId) : undefined, + filter.reviewId ? eq(testRuns.reviewId, filter.reviewId) : undefined, + filter.batchId ? eq(testRuns.batchId, filter.batchId) : undefined, + filter.repoId ? eq(testRuns.repoId, filter.repoId) : undefined, + filter.activeOnly ? inArray(testRuns.status, ["queued", "running"]) : undefined, + ), + ) + .orderBy(desc(testRuns.id)) + .limit(limit); + return rows.map((r) => ({ ...r.run, scriptName: r.scriptName, repoFullName: r.repoFullName, startedByLogin: r.startedByLogin })); +} + +export async function getTestRun(db: Db, id: number): Promise { + const [r] = await db + .select(testRunSelect) + .from(testRuns) + .innerJoin(testScripts, eq(testScripts.id, testRuns.scriptId)) + .innerJoin(repos, eq(repos.id, testRuns.repoId)) + .innerJoin(users, eq(users.id, testRuns.startedBy)) + .where(eq(testRuns.id, id)); + return r ? { ...r.run, scriptName: r.scriptName, repoFullName: r.repoFullName, startedByLogin: r.startedByLogin } : undefined; +} + +/** Regression test results per review, for verdicts in lists. */ +export async function testResultsByReview(db: Db, reviewIds: number[]): Promise> { + const out = new Map(); + if (reviewIds.length === 0) return out; + const rows = await db + .select({ reviewId: testRuns.reviewId, name: testScripts.name, outcome: testRuns.outcome, status: testRuns.status }) + .from(testRuns) + .innerJoin(testScripts, eq(testScripts.id, testRuns.scriptId)) + .where(inArray(testRuns.reviewId, reviewIds)); + for (const r of rows) { + if (r.reviewId === null) continue; + out.set(r.reviewId, [...(out.get(r.reviewId) ?? []), { name: r.name, outcome: r.outcome, status: r.status }]); + } + return out; +} + +export async function claimNextTestRun(db: Db): Promise { + return db.transaction(async (tx) => { + const [next] = await tx + .select() + .from(testRuns) + .where(eq(testRuns.status, "queued")) + .orderBy(asc(testRuns.id)) + .limit(1) + .for("update", { skipLocked: true }); + if (!next) return null; + const startedAt = new Date(); + await tx.update(testRuns).set({ status: "running", startedAt }).where(eq(testRuns.id, next.id)); + return { ...next, status: "running" as const, startedAt }; + }); +} + +export async function setTestRunStarted(db: Db, id: number, runId: string, stepsVersion: number): Promise { + await db.update(testRuns).set({ runId, stepsVersion }).where(eq(testRuns.id, id)); +} + +export async function finishTestRun(db: Db, id: number, input: { status: ReviewStatus; report: TestRunReport | null; error: string | null }): Promise { + await db + .update(testRuns) + .set({ + status: input.status, + report: input.report, + mode: input.report?.mode ?? null, + outcome: input.report?.outcome ?? null, + healed: input.report?.healedAt !== undefined && input.report !== null, + costUsd: input.report ? input.report.cost.usd.toFixed(4) : null, + error: input.error, + finishedAt: new Date(), + }) + .where(eq(testRuns.id, id)); +} + +export async function requestTestRunAbort(db: Db, id: number): Promise { + await db.update(testRuns).set({ status: "aborted", finishedAt: new Date() }).where(and(eq(testRuns.id, id), eq(testRuns.status, "queued"))); + await db.update(testRuns).set({ abortRequested: true }).where(and(eq(testRuns.id, id), eq(testRuns.status, "running"))); +} + +export async function isTestRunAbortRequested(db: Db, id: number): Promise { + const [row] = await db.select({ abort: testRuns.abortRequested }).from(testRuns).where(eq(testRuns.id, id)); + return Boolean(row?.abort); +} + +// ---------------------------------------------------------------- findings + +export async function listFindings(db: Db, reviewId: number) { + return db + .select({ finding: findings, ratedByLogin: users.login }) + .from(findings) + .leftJoin(users, eq(users.id, findings.ratedBy)) + .where(eq(findings.reviewId, reviewId)) + .orderBy(asc(findings.id)); +} + +export async function rateFinding( + db: Db, + reviewId: number, + findingId: string, + input: { status: FindingStatus; comment: string | null; userId: number }, +): Promise { + await db + .update(findings) + .set({ status: input.status, comment: input.comment, ratedBy: input.userId, ratedAt: new Date() }) + .where(and(eq(findings.reviewId, reviewId), eq(findings.findingId, findingId))); +} + +export async function dismissedFindingIds(db: Db, reviewIds: number[]): Promise> { + const out = new Map(); + if (reviewIds.length === 0) return out; + const rows = await db + .select({ reviewId: findings.reviewId, findingId: findings.findingId }) + .from(findings) + .where(and(inArray(findings.reviewId, reviewIds), eq(findings.status, "dismissed"))); + for (const r of rows) out.set(r.reviewId, [...(out.get(r.reviewId) ?? []), r.findingId]); + return out; +} diff --git a/packages/db/src/repo-config.ts b/packages/db/src/repo-config.ts new file mode 100644 index 0000000..88120f5 --- /dev/null +++ b/packages/db/src/repo-config.ts @@ -0,0 +1,42 @@ +import { RepoConfigSchema, type RepoConfig } from "@claudio/core"; +import { decrypt } from "./crypto.js"; +import type { Repo, RepoRole } from "./schema.js"; + +/** Env var names under which a role's decrypted credentials are handed to the Claudio tools. */ +export function roleEnvNames(roleName: string): { usernameEnv: string; passwordEnv: string } { + const key = roleName.toUpperCase().replace(/[^A-Z0-9]+/g, "_"); + return { usernameEnv: `CLAUDIO_ROLE_${key}_USER`, passwordEnv: `CLAUDIO_ROLE_${key}_PASSWORD` }; +} + +/** + * Builds the runner's RepoConfig from the stored repo and roles, plus the env + * map that carries the decrypted credentials. The env map goes only to the + * Claudio tools (login_as), never to the agent process. + */ +export function toRepoConfig( + repo: Pick, + roles: RepoRole[], + decryptFn: (value: string) => string = decrypt, +): { config: RepoConfig; roleEnv: Record } { + const roleEnv: Record = {}; + const configRoles: Record = {}; + for (const role of roles) { + const names = roleEnvNames(role.name); + roleEnv[names.usernameEnv] = decryptFn(role.usernameEnc); + roleEnv[names.passwordEnv] = decryptFn(role.passwordEnc); + configRoles[role.name] = { + description: role.description ?? undefined, + ...names, + loginPath: role.loginPath, + successUrlContains: role.successUrlContains ?? undefined, + recipe: role.recipe ?? undefined, + }; + } + const config = RepoConfigSchema.parse({ + repo: repo.fullName, + notes: repo.notes ?? undefined, + extraAllowedHosts: repo.extraAllowedHosts ?? [], + roles: configRoles, + }); + return { config, roleEnv }; +} diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts new file mode 100644 index 0000000..891d488 --- /dev/null +++ b/packages/db/src/schema.ts @@ -0,0 +1,255 @@ +import { bigint, boolean, datetime, index, int, json, mediumtext, mysqlEnum, mysqlTable, text, uniqueIndex, varchar } from "drizzle-orm/mysql-core"; +import type { + AnalysisReport, + AnalysisRequest, + Finding, + KnowledgeDocName, + LoginStep, + ReviewReport, + ReviewRequest, + TestRunReport, + TestRunRequest, + TestStep, +} from "@claudio/core"; + +const id = () => bigint("id", { mode: "number", unsigned: true }).autoincrement().primaryKey(); +const ref = (name: string) => bigint(name, { mode: "number", unsigned: true }); +const createdAt = () => datetime("created_at", { mode: "date" }).notNull().$defaultFn(() => new Date()); + +export const users = mysqlTable("users", { + id: id(), + githubId: bigint("github_id", { mode: "number", unsigned: true }).notNull().unique(), + login: varchar("login", { length: 100 }).notNull(), + name: varchar("name", { length: 200 }), + avatarUrl: varchar("avatar_url", { length: 500 }), + /** AES-GCM encrypted OAuth access token. */ + accessTokenEnc: text("access_token_enc").notNull(), + createdAt: createdAt(), + lastLoginAt: datetime("last_login_at", { mode: "date" }).notNull().$defaultFn(() => new Date()), +}); + +export const sessions = mysqlTable( + "sessions", + { + /** SHA-256 of the cookie value, so a leaked table does not hand out sessions. */ + id: varchar("id", { length: 64 }).primaryKey(), + userId: ref("user_id").notNull(), + expiresAt: datetime("expires_at", { mode: "date" }).notNull(), + createdAt: createdAt(), + }, + (t) => [index("sessions_user_idx").on(t.userId)], +); + +export const repos = mysqlTable("repos", { + id: id(), + /** owner/name as on GitHub. */ + fullName: varchar("full_name", { length: 200 }).notNull().unique(), + isPrivate: boolean("is_private").notNull().default(false), + defaultBranch: varchar("default_branch", { length: 200 }), + notes: text("notes"), + extraAllowedHosts: json("extra_allowed_hosts").$type().notNull().$defaultFn(() => []), + /** Confirmation that review instances only hold synthetic data (screenshots go to Anthropic). */ + dataConfirmed: boolean("data_confirmed").notNull().default(false), + defaultInstanceUrl: varchar("default_instance_url", { length: 500 }), + createdBy: ref("created_by").notNull(), + createdAt: createdAt(), +}); + +export const repoRoles = mysqlTable( + "repo_roles", + { + id: id(), + repoId: ref("repo_id").notNull(), + name: varchar("name", { length: 60 }).notNull(), + description: varchar("description", { length: 300 }), + usernameEnc: text("username_enc").notNull(), + passwordEnc: text("password_enc").notNull(), + loginPath: varchar("login_path", { length: 300 }).notNull().default("/login"), + successUrlContains: varchar("success_url_contains", { length: 300 }), + recipe: json("recipe").$type(), + createdAt: createdAt(), + }, + (t) => [uniqueIndex("repo_roles_repo_name_idx").on(t.repoId, t.name)], +); + +export const REVIEW_STATUSES = ["queued", "running", "completed", "budget_exceeded", "max_turns", "failed", "aborted"] as const; +export type ReviewStatus = (typeof REVIEW_STATUSES)[number]; + +export const reviews = mysqlTable( + "reviews", + { + id: id(), + repoId: ref("repo_id").notNull(), + prNumber: int("pr_number").notNull(), + prTitle: varchar("pr_title", { length: 500 }), + instanceUrl: varchar("instance_url", { length: 500 }).notNull(), + request: json("request").$type().notNull(), + /** Use the demo fixture instead of GitHub (only honoured with CLAUDIO_DEV_FIXTURES=1). */ + fixture: varchar("fixture", { length: 60 }), + status: mysqlEnum("status", REVIEW_STATUSES).notNull().default("queued"), + runId: varchar("run_id", { length: 120 }), + report: json("report").$type(), + verdict: varchar("verdict", { length: 30 }), + costUsd: varchar("cost_usd", { length: 20 }), + error: text("error"), + /** TL;DR comment on the pull request (posted by the run or later from the UI). */ + commentUrl: varchar("comment_url", { length: 500 }), + abortRequested: boolean("abort_requested").notNull().default(false), + startedBy: ref("started_by").notNull(), + createdAt: createdAt(), + startedAt: datetime("started_at", { mode: "date" }), + finishedAt: datetime("finished_at", { mode: "date" }), + }, + (t) => [index("reviews_repo_idx").on(t.repoId), index("reviews_status_idx").on(t.status)], +); + +export const analyses = mysqlTable( + "analyses", + { + id: id(), + repoId: ref("repo_id").notNull(), + request: json("request").$type().notNull(), + instanceUrl: varchar("instance_url", { length: 500 }).notNull(), + /** Use the demo checkout instead of cloning (only honoured with CLAUDIO_DEV_FIXTURES=1). */ + fixture: varchar("fixture", { length: 60 }), + status: mysqlEnum("status", REVIEW_STATUSES).notNull().default("queued"), + runId: varchar("run_id", { length: 120 }), + branch: varchar("branch", { length: 200 }), + commitSha: varchar("commit_sha", { length: 64 }), + /** Report without the documents (those live in knowledge_docs). */ + report: json("report").$type>(), + costUsd: varchar("cost_usd", { length: 20 }), + error: text("error"), + abortRequested: boolean("abort_requested").notNull().default(false), + startedBy: ref("started_by").notNull(), + createdAt: createdAt(), + startedAt: datetime("started_at", { mode: "date" }), + finishedAt: datetime("finished_at", { mode: "date" }), + }, + (t) => [index("analyses_repo_idx").on(t.repoId), index("analyses_status_idx").on(t.status)], +); + +/** Knowledge documents written by an analysis. The latest analysis with documents is the repo's knowledge base. */ +export const knowledgeDocs = mysqlTable( + "knowledge_docs", + { + id: id(), + analysisId: ref("analysis_id").notNull(), + repoId: ref("repo_id").notNull(), + name: varchar("name", { length: 40 }).$type().notNull(), + content: mediumtext("content").notNull(), + createdAt: createdAt(), + }, + (t) => [uniqueIndex("knowledge_docs_analysis_name_idx").on(t.analysisId, t.name), index("knowledge_docs_repo_idx").on(t.repoId)], +); + +export const DETERMINISM = ["deterministic", "semi", "explorative"] as const; + +/** A test workflow: plain-language flow and expectation, plus the recorded steps once it has run. */ +export const testScripts = mysqlTable( + "test_scripts", + { + id: id(), + repoId: ref("repo_id").notNull(), + name: varchar("name", { length: 200 }).notNull(), + description: text("description").notNull(), + expectation: text("expectation").notNull(), + roles: json("roles").$type().notNull().$defaultFn(() => []), + /** Null until the first recording proposes one. */ + determinism: mysqlEnum("determinism", DETERMINISM), + steps: json("steps").$type(), + stepsVersion: int("steps_version").notNull().default(0), + stepsUpdatedAt: datetime("steps_updated_at", { mode: "date" }), + /** Run as a regression test with every PR review of the repo. */ + runInReviews: boolean("run_in_reviews").notNull().default(false), + createdBy: ref("created_by").notNull(), + createdAt: createdAt(), + updatedAt: datetime("updated_at", { mode: "date" }).notNull().$defaultFn(() => new Date()), + }, + (t) => [index("test_scripts_repo_idx").on(t.repoId)], +); + +export const TEST_OUTCOMES = ["passed", "failed", "inconclusive"] as const; + +export const testRuns = mysqlTable( + "test_runs", + { + id: id(), + scriptId: ref("script_id").notNull(), + repoId: ref("repo_id").notNull(), + /** Set when the test ran as part of a PR review. */ + reviewId: ref("review_id"), + /** Groups the runs of one suite start. */ + batchId: varchar("batch_id", { length: 40 }), + request: json("request").$type().notNull(), + instanceUrl: varchar("instance_url", { length: 500 }).notNull(), + status: mysqlEnum("status", REVIEW_STATUSES).notNull().default("queued"), + runId: varchar("run_id", { length: 120 }), + mode: varchar("mode", { length: 20 }), + outcome: mysqlEnum("outcome", TEST_OUTCOMES), + healed: boolean("healed").notNull().default(false), + /** Steps version that was replayed (0 = recording). */ + stepsVersion: int("steps_version"), + report: json("report").$type(), + costUsd: varchar("cost_usd", { length: 20 }), + error: text("error"), + abortRequested: boolean("abort_requested").notNull().default(false), + startedBy: ref("started_by").notNull(), + createdAt: createdAt(), + startedAt: datetime("started_at", { mode: "date" }), + finishedAt: datetime("finished_at", { mode: "date" }), + }, + (t) => [ + index("test_runs_script_idx").on(t.scriptId), + index("test_runs_review_idx").on(t.reviewId), + index("test_runs_status_idx").on(t.status), + index("test_runs_batch_idx").on(t.batchId), + ], +); + +/** Live log lines of a review, an analysis or a test run (exactly one id is set). */ +export const runEvents = mysqlTable( + "run_events", + { + id: id(), + reviewId: ref("review_id"), + analysisId: ref("analysis_id"), + testRunId: ref("test_run_id"), + seq: int("seq").notNull(), + line: mediumtext("line").notNull(), + createdAt: createdAt(), + }, + (t) => [ + uniqueIndex("run_events_review_seq_idx").on(t.reviewId, t.seq), + uniqueIndex("run_events_analysis_seq_idx").on(t.analysisId, t.seq), + uniqueIndex("run_events_test_seq_idx").on(t.testRunId, t.seq), + ], +); + +export const FINDING_STATUSES = ["proposed", "confirmed", "dismissed"] as const; +export type FindingStatus = (typeof FINDING_STATUSES)[number]; + +export const findings = mysqlTable( + "findings", + { + id: id(), + reviewId: ref("review_id").notNull(), + /** Finding id from the run (F1, F2, ...). */ + findingId: varchar("finding_id", { length: 20 }).notNull(), + data: json("data").$type().notNull(), + status: mysqlEnum("status", FINDING_STATUSES).notNull().default("proposed"), + comment: text("comment"), + ratedBy: ref("rated_by"), + ratedAt: datetime("rated_at", { mode: "date" }), + }, + (t) => [uniqueIndex("findings_review_finding_idx").on(t.reviewId, t.findingId)], +); + +export type User = typeof users.$inferSelect; +export type Repo = typeof repos.$inferSelect; +export type RepoRole = typeof repoRoles.$inferSelect; +export type Review = typeof reviews.$inferSelect; +export type FindingRow = typeof findings.$inferSelect; +export type Analysis = typeof analyses.$inferSelect; +export type TestScriptRow = typeof testScripts.$inferSelect; +export type TestRun = typeof testRuns.$inferSelect; diff --git a/packages/db/test/crypto.test.ts b/packages/db/test/crypto.test.ts new file mode 100644 index 0000000..3d405a9 --- /dev/null +++ b/packages/db/test/crypto.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { decrypt, encrypt, secretKey } from "../src/crypto.js"; +import { roleEnvNames, toRepoConfig } from "../src/repo-config.js"; +import type { RepoRole } from "../src/schema.js"; + +const key = secretKey("test-key-with-enough-length"); + +describe("crypto", () => { + it("round-trips and uses a fresh IV per call", () => { + const a = encrypt("geheim", key); + const b = encrypt("geheim", key); + expect(a).not.toBe(b); + expect(decrypt(a, key)).toBe("geheim"); + }); + it("rejects tampered ciphertext and wrong keys", () => { + const token = encrypt("geheim", key); + const parts = token.split("."); + parts[3] = Buffer.from("anders").toString("base64url"); + expect(() => decrypt(parts.join("."), key)).toThrow(); + expect(() => decrypt(token, secretKey("another-key-with-length"))).toThrow(); + }); + it("refuses short keys", () => { + expect(() => secretKey("short")).toThrow(/CLAUDIO_SECRET_KEY/); + }); +}); + +describe("toRepoConfig", () => { + const role = (name: string, extra: Partial = {}): RepoRole => ({ + id: 1, + repoId: 1, + name, + description: null, + usernameEnc: encrypt(`${name}@example.test`, key), + passwordEnc: encrypt(`${name}-pw`, key), + loginPath: "/login", + successUrlContains: null, + recipe: null, + createdAt: new Date(), + ...extra, + }); + + it("maps roles to env var names and keeps credentials out of the config", () => { + const { config, roleEnv } = toRepoConfig( + { fullName: "o/r", notes: "Hinweis", extraAllowedHosts: ["auth.example.test"] }, + [role("dispatcher", { successUrlContains: "/dashboard" }), role("super-admin")], + (v) => decrypt(v, key), + ); + expect(config.repo).toBe("o/r"); + expect(config.notes).toBe("Hinweis"); + expect(config.extraAllowedHosts).toEqual(["auth.example.test"]); + expect(config.roles["dispatcher"]).toMatchObject({ usernameEnv: "CLAUDIO_ROLE_DISPATCHER_USER", successUrlContains: "/dashboard" }); + expect(roleEnvNames("super-admin").passwordEnv).toBe("CLAUDIO_ROLE_SUPER_ADMIN_PASSWORD"); + expect(roleEnv).toEqual({ + CLAUDIO_ROLE_DISPATCHER_USER: "dispatcher@example.test", + CLAUDIO_ROLE_DISPATCHER_PASSWORD: "dispatcher-pw", + CLAUDIO_ROLE_SUPER_ADMIN_USER: "super-admin@example.test", + CLAUDIO_ROLE_SUPER_ADMIN_PASSWORD: "super-admin-pw", + }); + expect(JSON.stringify(config)).not.toContain("-pw"); + }); +}); diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json new file mode 100644 index 0000000..404aab4 --- /dev/null +++ b/packages/db/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "noEmit": true, "rootDir": "src" }, + "include": ["src"] +} diff --git a/packages/github/src/client.ts b/packages/github/src/client.ts index c942a39..440ce41 100644 --- a/packages/github/src/client.ts +++ b/packages/github/src/client.ts @@ -103,3 +103,25 @@ export async function postPullRequestComment(octokit: Octokit, repoFullName: str function truncate(text: string, max: number): string { return text.length <= max ? text : text.slice(0, max) + `\n... [truncated ${text.length - max} chars]`; } + +export async function getDefaultBranch(octokit: Octokit, repoFullName: string): Promise { + const { owner, repo } = parseRepo(repoFullName); + const { data } = await octokit.rest.repos.get({ owner, repo }); + return data.default_branch; +} + +export async function getBranchHead(octokit: Octokit, repoFullName: string, branch: string): Promise { + const { owner, repo } = parseRepo(repoFullName); + const { data } = await octokit.rest.repos.getBranch({ owner, repo, branch }); + return data.commit.sha; +} + +export type ChangedSince = { files: { filename: string; status: string }[]; totalCommits: number; truncated: boolean }; + +/** Files changed between two commits (GitHub caps the list at 300 files). */ +export async function changedFilesBetween(octokit: Octokit, repoFullName: string, base: string, head: string): Promise { + const { owner, repo } = parseRepo(repoFullName); + const { data } = await octokit.rest.repos.compareCommitsWithBasehead({ owner, repo, basehead: `${base}...${head}`, per_page: 100 }); + const files = (data.files ?? []).map((f) => ({ filename: f.filename, status: f.status })); + return { files, totalCommits: data.total_commits, truncated: files.length >= 300 }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 888599d..aa7da3a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,6 +36,63 @@ importers: specifier: ^15.0.0 version: 15.0.0 + apps/demo-target: {} + + apps/web: + dependencies: + '@claudio/core': + specifier: workspace:* + version: link:../../packages/core + '@claudio/db': + specifier: workspace:* + version: link:../../packages/db + '@claudio/github': + specifier: workspace:* + version: link:../../packages/github + marked: + specifier: ^18.1.0 + version: 18.1.0 + octokit: + specifier: ^5.0.5 + version: 5.0.5 + zod: + specifier: ^4.6.5 + version: 4.6.5 + devDependencies: + '@sveltejs/adapter-node': + specifier: ^6.0.0 + version: 6.0.0(@sveltejs/kit@3.0.1(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15))) + '@sveltejs/kit': + specifier: ^3.0.0 + version: 3.0.1(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15)) + '@sveltejs/vite-plugin-svelte': + specifier: ^7.2.0 + version: 7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15)) + svelte: + specifier: ^5.57.1 + version: 5.57.2 + svelte-check: + specifier: ^4.6.0 + version: 4.7.6(picomatch@4.0.7)(svelte@5.57.2)(typescript@6.0.3) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vite: + specifier: ^8.3.0 + version: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15) + + apps/worker: + dependencies: + '@claudio/agent': + specifier: workspace:* + version: link:../../packages/agent + '@claudio/core': + specifier: workspace:* + version: link:../../packages/core + '@claudio/db': + specifier: workspace:* + version: link:../../packages/db + packages/agent: dependencies: '@anthropic-ai/claude-agent-sdk': @@ -78,6 +135,22 @@ importers: specifier: ^4.6.5 version: 4.6.5 + packages/db: + dependencies: + '@claudio/core': + specifier: workspace:* + version: link:../core + drizzle-orm: + specifier: ^0.45.4 + version: 0.45.4(mysql2@3.24.5(@types/node@22.20.5)) + mysql2: + specifier: ^3.24.5 + version: 3.24.5(@types/node@22.20.5) + devDependencies: + drizzle-kit: + specifier: ^0.31.11 + version: 0.31.11 + packages/github: dependencies: '@claudio/core': @@ -153,156 +226,455 @@ packages: resolution: {integrity: sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==} engines: {node: '>=6.9.0'} + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.hirok.io' + + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.hirok.io' + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.28.2': resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.28.2': resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.28.2': resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.28.2': resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.28.2': resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.28.2': resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.28.2': resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.28.2': resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.28.2': resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.28.2': resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.28.2': resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.28.2': resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.28.2': resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.28.2': resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.28.2': resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.28.2': resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.28.2': resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.28.2': resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.28.2': resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.28.2': resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.28.2': resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/openharmony-arm64@0.28.2': resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.28.2': resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.28.2': resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.28.2': resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.28.2': resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} engines: {node: '>=18'} @@ -315,6 +687,12 @@ packages: peerDependencies: hono: ^4 || ^5.0.0-0 + '@jridgewell/gen-mapping@0.3.13': + resolution: {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: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -468,6 +846,9 @@ packages: engines: {node: '>=18'} hasBin: true + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@rolldown/binding-android-arm-eabi@1.2.13': resolution: {integrity: sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -564,6 +945,46 @@ packages: '@stablelib/base64@1.0.1': resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@sveltejs/acorn-typescript@1.0.13': + resolution: {integrity: sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==} + peerDependencies: + acorn: ^8.9.0 + + '@sveltejs/adapter-node@6.0.0': + resolution: {integrity: sha512-s7muM4JmJ75OZ8o0G4VRhrEKdi8twuEm3t2o/qQsdlm12hKaX7S/CuUBuw3n983wB/IFBSpzG2ugX0xdGzbKwg==} + peerDependencies: + '@sveltejs/kit': ^3.0.0-next.0 + + '@sveltejs/kit@3.0.1': + resolution: {integrity: sha512-8JMCCq7ZUJELi3hbhb0+c9Rs8qnbHW90vhOfM+t1FSF4fhIpG9/FOsH4IYgcITMhbdkSrrn+Kvd9G+q8A+UO6Q==} + engines: {node: '>=22.17'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.0.0 + '@sveltejs/vite-plugin-svelte': ^7.0.0 + svelte: ^5.57.1 + typescript: ^6.0.0 + vite: ^8.0.12 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + typescript: + optional: true + + '@sveltejs/load-config@0.2.3': + resolution: {integrity: sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==} + engines: {node: '>= 18.0.0'} + + '@sveltejs/vite-plugin-svelte@7.3.1': + resolution: {integrity: sha512-ZPsLN8B1e/En+Ak5s4V7srFDT532oS0qieLsQwu63NGKsS+iAjoO2Js1BochlHlglcU+Pt7WAO3C5Ee+4f6gVA==} + engines: {node: ^20.19 || ^22.12 || >=24} + peerDependencies: + svelte: ^5.46.4 + vite: ^8.0.0-beta.7 || ^8.0.0 + '@types/aws-lambda@8.10.164': resolution: {integrity: sha512-XOnrazWcOd6yWPnR7DxqCBPPYciDjHq+NN8LPwwqBCJNXLyPJgKo47fZpJ113+oyAn9Zti0Cf6H4k6yPcE3WFg==} @@ -597,6 +1018,11 @@ packages: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} + acorn@8.19.0: + resolution: {integrity: sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==} + engines: {node: '>=0.4.0'} + hasBin: true + ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -608,10 +1034,22 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + aria-query@5.3.1: + resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} + engines: {node: '>= 0.4'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + aws-ssl-profiles@1.1.2: + resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} + engines: {node: '>= 6.0.0'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} @@ -622,6 +1060,9 @@ packages: bottleneck@2.19.5: resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==} + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -638,6 +1079,14 @@ packages: resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} engines: {node: '>=18'} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + commander@15.0.0: resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} engines: {node: '>=22.12.0'} @@ -666,6 +1115,10 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cookie@2.0.1: + resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} + engines: {node: '>=22'} + cors@2.8.6: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} @@ -683,6 +1136,10 @@ packages: supports-color: optional: true + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -691,6 +1148,108 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + devalue@5.9.4: + resolution: {integrity: sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==} + + drizzle-kit@0.31.11: + resolution: {integrity: sha512-YCYqxTLIB2OCqf6w9/Vef13baBVbwQIPcbT4Y56AfO6B9ajZhDhD8Jkveg/hJvT/iuMloKD/IYYkyMMNhr7Kqg==} + hasBin: true + + drizzle-orm@0.45.4: + resolution: {integrity: sha512-NiM1YZpJ2UxyeE67DpDDZ+Tsw4YLX3trSlkNT7Tm9eKoUNb1LvaAaQzXgYQsWs+AQ9TUmmwMmBOCXuqqxUWKKQ==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@netlify/db': '>=0.4.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@netlify/db': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -717,6 +1276,16 @@ packages: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.28.2: resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} @@ -725,6 +1294,17 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + + esrap@2.4.0: + resolution: {integrity: sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA==} + peerDependencies: + '@typescript-eslint/types': ^8.2.0 + peerDependenciesMeta: + '@typescript-eslint/types': + optional: true + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -792,6 +1372,9 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -800,6 +1383,9 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-tsconfig@4.14.3: + resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -838,6 +1424,12 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -927,9 +1519,27 @@ packages: resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + lru.min@1.1.5: + resolution: {integrity: sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==} + engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magic-string@1.4.3: resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} + marked@18.1.0: + resolution: {integrity: sha512-PamYXWWWg2nboG3oX5Ffzpy3EFZROqZJK9hSlMmUnekp7TxPs5DcT95vwd46m73/exfKCRVMtgXoPMI4FEJL6w==} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -950,9 +1560,27 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mysql2@3.24.5: + resolution: {integrity: sha512-X6Ujsr2QSkkLpkQGjxzpKRAPn9nu4axpR63ntBzquFVEvPOArgbUQ1sJjFKI7hnaYtiVZxa17Z7q18KSubW0IQ==} + engines: {node: '>= 8.0'} + peerDependencies: + '@types/node': '>= 8' + + named-placeholders@1.1.6: + resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} + engines: {node: '>=8.0.0'} + nanoid@3.3.20: resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -1047,10 +1675,17 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + rolldown@1.2.13: resolution: {integrity: sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1060,6 +1695,10 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} @@ -1098,10 +1737,25 @@ packages: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + source-map-js@1.2.2: resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + sql-escaper@1.5.2: + resolution: {integrity: sha512-6CKD38c31SENivxOADeMNLdukOnUxUcflKtzVWzace7Riv1v7cAEym5Cx9Q7gYZ3ezIJI7ZpqecQq8cqNeSSFg==} + engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} + standardwebhooks@1.1.1: resolution: {integrity: sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==} @@ -1112,6 +1766,18 @@ packages: std-env@4.3.0: resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + svelte-check@4.7.6: + resolution: {integrity: sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==} + engines: {node: '>= 18.0.0'} + hasBin: true + peerDependencies: + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: ^5.0.0 || ^6.0.0 + + svelte@5.57.2: + resolution: {integrity: sha512-1YiPCAXT0Ct6g1vOMmSLa0Nsc3QRMvtDF32ihgglGVrugIuIK38RyDiFNXVDhtQ81TJjSO57NdMw6DyYLqSGDQ==} + engines: {node: '>=18'} + tinybench@6.2.1: resolution: {integrity: sha512-IAbQaPJIIdhVxi0mqy9lez1wEwje0UQf9F1vwtbelEG5Nbgn0nPOxCvU+mNeNS9gyJTAhrHp74CbUaSynQpZcQ==} engines: {node: '>=20.0.0'} @@ -1132,6 +1798,10 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + ts-algebra@2.0.0: resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} @@ -1149,6 +1819,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -1209,6 +1884,14 @@ packages: yaml: optional: true + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + vitest@5.0.3: resolution: {integrity: sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==} engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} @@ -1263,6 +1946,9 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + zimmerframe@1.1.5: + resolution: {integrity: sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==} + zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: @@ -1321,81 +2007,237 @@ snapshots: '@babel/runtime@7.29.10': {} + '@drizzle-team/brocli@0.10.2': {} + + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.18.20 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.14.3 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + '@esbuild/aix-ppc64@0.28.2': optional: true + '@esbuild/android-arm64@0.18.20': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + '@esbuild/android-arm64@0.28.2': optional: true + '@esbuild/android-arm@0.18.20': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + '@esbuild/android-arm@0.28.2': optional: true + '@esbuild/android-x64@0.18.20': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + '@esbuild/android-x64@0.28.2': optional: true + '@esbuild/darwin-arm64@0.18.20': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + '@esbuild/darwin-arm64@0.28.2': optional: true + '@esbuild/darwin-x64@0.18.20': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + '@esbuild/darwin-x64@0.28.2': optional: true + '@esbuild/freebsd-arm64@0.18.20': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + '@esbuild/freebsd-arm64@0.28.2': optional: true + '@esbuild/freebsd-x64@0.18.20': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + '@esbuild/freebsd-x64@0.28.2': optional: true + '@esbuild/linux-arm64@0.18.20': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + '@esbuild/linux-arm64@0.28.2': optional: true + '@esbuild/linux-arm@0.18.20': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + '@esbuild/linux-arm@0.28.2': optional: true + '@esbuild/linux-ia32@0.18.20': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + '@esbuild/linux-ia32@0.28.2': optional: true + '@esbuild/linux-loong64@0.18.20': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + '@esbuild/linux-loong64@0.28.2': optional: true + '@esbuild/linux-mips64el@0.18.20': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + '@esbuild/linux-mips64el@0.28.2': optional: true + '@esbuild/linux-ppc64@0.18.20': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + '@esbuild/linux-ppc64@0.28.2': optional: true + '@esbuild/linux-riscv64@0.18.20': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + '@esbuild/linux-riscv64@0.28.2': optional: true + '@esbuild/linux-s390x@0.18.20': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + '@esbuild/linux-s390x@0.28.2': optional: true + '@esbuild/linux-x64@0.18.20': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + '@esbuild/linux-x64@0.28.2': optional: true + '@esbuild/netbsd-arm64@0.25.12': + optional: true + '@esbuild/netbsd-arm64@0.28.2': optional: true + '@esbuild/netbsd-x64@0.18.20': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + '@esbuild/netbsd-x64@0.28.2': optional: true + '@esbuild/openbsd-arm64@0.25.12': + optional: true + '@esbuild/openbsd-arm64@0.28.2': optional: true + '@esbuild/openbsd-x64@0.18.20': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + '@esbuild/openbsd-x64@0.28.2': optional: true + '@esbuild/openharmony-arm64@0.25.12': + optional: true + '@esbuild/openharmony-arm64@0.28.2': optional: true + '@esbuild/sunos-x64@0.18.20': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + '@esbuild/sunos-x64@0.28.2': optional: true + '@esbuild/win32-arm64@0.18.20': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + '@esbuild/win32-arm64@0.28.2': optional: true + '@esbuild/win32-ia32@0.18.20': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + '@esbuild/win32-ia32@0.28.2': optional: true + '@esbuild/win32-x64@0.18.20': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + '@esbuild/win32-x64@0.28.2': optional: true @@ -1403,6 +2245,16 @@ snapshots: dependencies: hono: 4.13.13 + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@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/sourcemap-codec@1.6.0': {} @@ -1606,6 +2458,8 @@ snapshots: playwright: 1.64.0-alpha-1790635538000 playwright-core: 1.64.0-alpha-1790635538000 + '@polka/url@1.0.0-next.29': {} + '@rolldown/binding-android-arm-eabi@1.2.13': optional: true @@ -1655,6 +2509,43 @@ snapshots: '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} + + '@sveltejs/acorn-typescript@1.0.13(acorn@8.19.0)': + dependencies: + acorn: 8.19.0 + + '@sveltejs/adapter-node@6.0.0(@sveltejs/kit@3.0.1(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15)))': + dependencies: + '@sveltejs/kit': 3.0.1(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15)) + + '@sveltejs/kit@3.0.1(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15)))(svelte@5.57.2)(typescript@6.0.3)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + '@standard-schema/spec': 1.1.0 + '@sveltejs/vite-plugin-svelte': 7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15)) + cookie: 2.0.1 + devalue: 5.9.4 + esm-env: 1.2.2 + magic-string: 1.4.3 + mrmime: 2.0.1 + sirv: 3.0.2 + svelte: 5.57.2 + vite: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15) + optionalDependencies: + typescript: 6.0.3 + + '@sveltejs/load-config@0.2.3': {} + + '@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15))': + dependencies: + deepmerge: 4.3.1 + magic-string: 1.4.3 + obug: 2.2.1 + svelte: 5.57.2 + vite: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15) + vitefu: 1.1.3(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15)) + '@types/aws-lambda@8.10.164': {} '@types/chai@5.2.3': @@ -1686,6 +2577,8 @@ snapshots: mime-types: 3.0.2 negotiator: 1.1.0 + acorn@8.19.0: {} + ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -1697,8 +2590,14 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + aria-query@5.3.1: {} + assertion-error@2.0.1: {} + aws-ssl-profiles@1.1.2: {} + + axobject-query@4.1.0: {} + before-after-hook@4.0.0: {} body-parser@2.3.0: @@ -1717,6 +2616,8 @@ snapshots: bottleneck@2.19.5: {} + buffer-from@1.1.2: {} + bytes@3.1.2: {} call-bind-apply-helpers@1.0.2: @@ -1731,6 +2632,12 @@ snapshots: chai@6.3.0: {} + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + clsx@2.1.1: {} + commander@15.0.0: {} content-disposition@2.0.1: {} @@ -1745,6 +2652,8 @@ snapshots: cookie@0.7.2: {} + cookie@2.0.1: {} + cors@2.8.6: dependencies: object-assign: 4.1.1 @@ -1760,10 +2669,25 @@ snapshots: dependencies: ms: 2.1.3 + deepmerge@4.3.1: {} + depd@2.0.0: {} detect-libc@2.1.2: {} + devalue@5.9.4: {} + + drizzle-kit@0.31.11: + dependencies: + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.12 + tsx: 4.23.15 + + drizzle-orm@0.45.4(mysql2@3.24.5(@types/node@22.20.5)): + optionalDependencies: + mysql2: 3.24.5(@types/node@22.20.5) + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -1784,6 +2708,60 @@ snapshots: dependencies: es-errors: 1.3.0 + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + esbuild@0.28.2: optionalDependencies: '@esbuild/aix-ppc64': 0.28.2 @@ -1815,6 +2793,12 @@ snapshots: escape-html@1.0.3: {} + esm-env@1.2.2: {} + + esrap@2.4.0: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -1900,6 +2884,10 @@ snapshots: function-bind@1.1.2: {} + generate-function@2.3.1: + dependencies: + is-property: 1.0.2 + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -1918,6 +2906,10 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.2 + get-tsconfig@4.14.3: + dependencies: + resolve-pkg-maps: 1.0.0 + gopd@1.2.0: {} has-symbols@1.1.0: {} @@ -1948,6 +2940,12 @@ snapshots: is-promise@4.0.0: {} + is-property@1.0.2: {} + + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.9 + isexe@2.0.0: {} jose@6.2.12: {} @@ -2012,10 +3010,22 @@ snapshots: lightningcss-win32-arm64-msvc: 1.33.0 lightningcss-win32-x64-msvc: 1.33.0 + locate-character@3.0.0: {} + + long@5.3.2: {} + + lru.min@1.1.5: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + magic-string@1.4.3: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 + marked@18.1.0: {} + math-intrinsics@1.1.0: {} media-typer@1.1.1: {} @@ -2028,8 +3038,27 @@ snapshots: dependencies: mime-db: 1.54.0 + mri@1.2.0: {} + + mrmime@2.0.1: {} + ms@2.1.3: {} + mysql2@3.24.5(@types/node@22.20.5): + dependencies: + '@types/node': 22.20.5 + aws-ssl-profiles: 1.1.2 + generate-function: 2.3.1 + iconv-lite: 0.7.3 + long: 5.3.2 + lru.min: 1.1.5 + named-placeholders: 1.1.6 + sql-escaper: 1.5.2 + + named-placeholders@1.1.6: + dependencies: + lru.min: 1.1.5 + nanoid@3.3.20: {} negotiator@1.1.0: @@ -2113,8 +3142,12 @@ snapshots: iconv-lite: 0.7.3 unpipe: 1.0.0 + readdirp@4.1.2: {} + require-from-string@2.0.2: {} + resolve-pkg-maps@1.0.0: {} + rolldown@1.2.13: dependencies: '@oxc-project/types': 0.153.0 @@ -2146,6 +3179,10 @@ snapshots: transitivePeerDependencies: - supports-color + sade@1.8.1: + dependencies: + mri: 1.2.0 + safer-buffer@2.1.2: {} send@1.2.1: @@ -2209,8 +3246,23 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + source-map-js@1.2.2: {} + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + sql-escaper@1.5.2: {} + standardwebhooks@1.1.1: dependencies: '@stablelib/base64': 1.0.1 @@ -2220,6 +3272,39 @@ snapshots: std-env@4.3.0: {} + svelte-check@4.7.6(picomatch@4.0.7)(svelte@5.57.2)(typescript@6.0.3): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@sveltejs/load-config': 0.2.3 + chokidar: 4.0.3 + fdir: 6.5.0(picomatch@4.0.7) + picocolors: 1.1.1 + sade: 1.8.1 + svelte: 5.57.2 + typescript: 6.0.3 + transitivePeerDependencies: + - picomatch + + svelte@5.57.2: + dependencies: + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.6.0 + '@sveltejs/acorn-typescript': 1.0.13(acorn@8.19.0) + '@types/estree': 1.0.9 + acorn: 8.19.0 + aria-query: 5.3.1 + axobject-query: 4.1.0 + clsx: 2.1.1 + devalue: 5.9.4 + esm-env: 1.2.2 + esrap: 2.4.0 + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.21 + zimmerframe: 1.1.5 + transitivePeerDependencies: + - '@typescript-eslint/types' + tinybench@6.2.1: {} tinyexec@1.3.1: {} @@ -2233,6 +3318,8 @@ snapshots: toidentifier@1.0.1: {} + totalist@3.0.1: {} + ts-algebra@2.0.0: {} tsx@4.23.15: @@ -2249,6 +3336,8 @@ snapshots: typescript@5.9.3: {} + typescript@6.0.3: {} + undici-types@6.21.0: {} universal-github-app-jwt@2.2.2: {} @@ -2272,6 +3361,10 @@ snapshots: fsevents: 2.3.3 tsx: 4.23.15 + vitefu@1.1.3(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15)): + optionalDependencies: + vite: 8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15) + vitest@5.0.3(@types/node@22.20.5)(vite@8.3.4(@types/node@22.20.5)(esbuild@0.28.2)(tsx@4.23.15)): dependencies: '@types/chai': 5.2.3 @@ -2301,6 +3394,8 @@ snapshots: wrappy@1.0.2: {} + zimmerframe@1.1.5: {} + zod-to-json-schema@3.25.2(zod@4.6.5): dependencies: zod: 4.6.5 From 4b906aa275de9cab04c091722871707f772072d3 Mon Sep 17 00:00:00 2001 From: Lars Lehmann Date: Tue, 13 Oct 2026 09:30:54 +0200 Subject: [PATCH 5/7] feat(github): implement webhook signature verification and trigger decision logic - Added `verifyWebhookSignature` function to validate GitHub webhook signatures. - Introduced `decideTrigger` function to determine actions based on GitHub events. - Created `renderInstanceUrl` function to generate PR instance URLs from templates. - Defined `AutomationSettings` type for repository-specific automation configurations. - Implemented tests for signature verification, instance URL rendering, and trigger decision logic. feat(tests): add unit tests for webhook functionality - Added tests for `verifyWebhookSignature`, `renderInstanceUrl`, and `decideTrigger` functions. - Covered various scenarios including comment triggers, label triggers, and push events. chore: add error message structure in markdown format - Created a markdown file to document error messages for internal errors. --- README.md | 4 + apps/demo-target/README.md | 2 + apps/demo-target/src/server.mjs | 15 + apps/web/src/hooks.server.ts | 3 +- .../src/lib/components/HumanTakeover.svelte | 198 +++ apps/web/src/lib/components/LiveLog.svelte | 9 +- apps/web/src/lib/components/LiveView.svelte | 20 +- apps/web/src/lib/live-frames.svelte.ts | 33 + apps/web/src/lib/server/automation.ts | 143 ++ apps/web/src/lib/server/live.ts | 62 +- apps/web/src/routes/+layout.server.ts | 17 +- apps/web/src/routes/+layout.svelte | 145 +- .../src/routes/analyses/[id]/+page.server.ts | 5 +- .../web/src/routes/analyses/[id]/+page.svelte | 9 +- .../src/routes/analyses/[id]/live/+server.ts | 6 +- .../src/routes/human/[id]/input/+server.ts | 23 + .../src/routes/human/[id]/resolve/+server.ts | 14 + apps/web/src/routes/repos/+page.server.ts | 14 +- apps/web/src/routes/repos/+page.svelte | 28 +- .../repos/[owner]/[name]/+page.server.ts | 107 +- .../routes/repos/[owner]/[name]/+page.svelte | 170 +- .../src/routes/reviews/[id]/+page.server.ts | 27 +- apps/web/src/routes/reviews/[id]/+page.svelte | 17 +- .../src/routes/reviews/[id]/live/+server.ts | 6 +- apps/web/src/routes/reviews/new/+page.svelte | 5 - .../src/routes/test-runs/[id]/+page.server.ts | 5 +- .../src/routes/test-runs/[id]/+page.svelte | 9 +- .../src/routes/test-runs/[id]/live/+server.ts | 6 +- .../web/src/routes/tests/[id]/+page.server.ts | 6 +- apps/web/src/routes/tests/[id]/+page.svelte | 15 +- .../routes/tests/[id]/compare/+page.svelte | 2 +- .../web/src/routes/webhooks/github/+server.ts | 37 + apps/worker/package.json | 4 +- apps/worker/src/finalize.ts | 76 + apps/worker/src/human.ts | 47 + apps/worker/src/main.ts | 20 +- docs/ARCHITECTURE.md | 2 +- packages/agent/src/analysis-prompt.ts | 1 + packages/agent/src/analysis-runner.ts | 11 +- packages/agent/src/hooks.ts | 21 + packages/agent/src/prompts.ts | 6 + packages/agent/src/runner.ts | 15 +- packages/agent/src/test-runner.ts | 74 +- packages/browser/src/human.ts | 84 + packages/browser/src/index.ts | 1 + packages/browser/src/live-view.ts | 23 + packages/browser/src/steps.ts | 41 +- packages/browser/src/tools/definitions.ts | 29 + packages/browser/src/tools/test-tools.ts | 18 +- packages/core/src/schemas.ts | 3 + packages/core/src/testing.ts | 22 + packages/core/test/testing.test.ts | 13 +- packages/db/migrations/0004_automation.sql | 8 + .../db/migrations/0005_lessons_default.sql | 2 + packages/db/migrations/0006_test_hosts.sql | 2 + .../db/migrations/0007_human_takeover.sql | 25 + .../db/migrations/meta/0004_snapshot.json | 1291 +++++++++++++++ .../db/migrations/meta/0005_snapshot.json | 1291 +++++++++++++++ .../db/migrations/meta/0006_snapshot.json | 1306 +++++++++++++++ .../db/migrations/meta/0007_snapshot.json | 1463 +++++++++++++++++ packages/db/migrations/meta/_journal.json | 28 + packages/db/package.json | 3 +- packages/db/src/queries.ts | 136 +- packages/db/src/repo-config.ts | 3 +- packages/db/src/schema.ts | 69 + packages/github/src/client.ts | 57 + packages/github/src/index.ts | 1 + packages/github/src/webhooks.ts | 94 ++ packages/github/test/webhooks.test.ts | 51 + pnpm-lock.yaml | 9 + s.md | 13 + 71 files changed, 7375 insertions(+), 150 deletions(-) create mode 100644 apps/web/src/lib/components/HumanTakeover.svelte create mode 100644 apps/web/src/lib/live-frames.svelte.ts create mode 100644 apps/web/src/lib/server/automation.ts create mode 100644 apps/web/src/routes/human/[id]/input/+server.ts create mode 100644 apps/web/src/routes/human/[id]/resolve/+server.ts create mode 100644 apps/web/src/routes/webhooks/github/+server.ts create mode 100644 apps/worker/src/finalize.ts create mode 100644 apps/worker/src/human.ts create mode 100644 packages/browser/src/human.ts create mode 100644 packages/db/migrations/0004_automation.sql create mode 100644 packages/db/migrations/0005_lessons_default.sql create mode 100644 packages/db/migrations/0006_test_hosts.sql create mode 100644 packages/db/migrations/0007_human_takeover.sql create mode 100644 packages/db/migrations/meta/0004_snapshot.json create mode 100644 packages/db/migrations/meta/0005_snapshot.json create mode 100644 packages/db/migrations/meta/0006_snapshot.json create mode 100644 packages/db/migrations/meta/0007_snapshot.json create mode 100644 packages/github/src/webhooks.ts create mode 100644 packages/github/test/webhooks.test.ts create mode 100644 s.md diff --git a/README.md b/README.md index 81fb348..0207b26 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ Built on the Claude Agent SDK, Playwright MCP and the Claude in Chrome extension - Web UI (first version): GitHub login, add repositories from your GitHub account, configure roles, start reviews, watch them live, read the report with evidence, and confirm or dismiss findings. Reviews run in a separate worker process; state lives in MySQL. - Phase 3, PR review: on the standard and thorough budgets the orchestrator delegates to separate subagents, one after the other (they share one browser): `ac-verifier` for the acceptance criteria, `regression-tester` for the affected areas against the app map, `ux-reviewer` for the screenshots. The quick budget keeps one agent. While a review or analysis runs, the web UI shows a live view of the agent's browser (Chromium screencast, no model tokens). After rating the findings, the TL;DR comment can be posted to the PR from the UI, and the report downloaded as Markdown, both honouring the chosen strictness and leaving out dismissed findings. PR comments link to the review in Claudio (`PUBLIC_BASE_URL`). - Phase 4, test workflows: per repository, tests described in plain language (flow, expectation, roles). The first run records the flow: an agent performs it exclusively through the `test_step` tool, which Claudio executes itself with Playwright and records, so the stored steps are exactly what ran (role/name locators, `{{run}}` and `{{date:+N:FORMAT}}` templates for unique data). Later runs replay the steps without a model; deterministic tests cost nothing, semi-deterministic ones are judged afterwards, explorative ones run with an agent each time. When a replayed step can no longer be executed, an agent heals the flow from that step, the healed steps become a new version, and a finding records the UI change. An independent judge (structured output) compares the final state with the expectation. Tests run on their own, as a suite against an instance, or with every PR review (a failed regression test turns an approval into "changes requested", also in the PR comment). Runs can be compared step by step. +- Phase 5, GitHub automation: per repository a webhook (set up from the settings with one click; needs admin rights and a public `PUBLIC_BASE_URL`) starts reviews on a `/claudio review [quick|standard|thorough]` comment, the `claudio-review` label, or automatically on every opened/updated PR, and can update the knowledge base on pushes to the default branch. The PR instance URL comes from a per-repo template (`https://pr-{pr}.preview.example.de`, also `{branch_slug}`, `{branch}`, `{sha}`). Results go back as the commit status "Claudio" (usable as a required check) and as the TL;DR comment, posted after the regression tests so it includes them. Automated runs act with the GitHub token of the person who set up the webhook. Findings dismissed with a reason become lessons that every later review of the repo is told about. +- Human takeover: Claudio never solves CAPTCHAs or bot checks itself. When one appears (reCAPTCHA, hCaptcha, Cloudflare Turnstile, Google's bot check, "Ich bin kein Roboter" pages), the run pauses: replays wait automatically, agents call `request_human_help`. Every page shows "Claudio wartet auf dich"; on the run page the live view becomes clickable, a person solves the challenge (click, type, keys, scroll are replayed in the run's browser) and presses "Erledigt, weitermachen". If the challenge is still there, Claudio asks again; without an answer within `CLAUDIO_HUMAN_TIMEOUT_MIN` (default 15) the run gives up cleanly. For your own apps, prefer the CAPTCHA providers' test keys on staging and PR instances. - Phase 2, repository analysis: Claudio reads the code and walks the running app, then writes a knowledge base per repository (overview, modules, routes, app map with the expected behaviour of every screen, notes). Every review receives the latest knowledge base. Updates only re-check what changed since the analysed commit. ## Setup @@ -46,6 +48,8 @@ The worker (`pnpm worker`) picks up queued reviews, one at a time by default (`C Repository analysis: open a repository, tab "Wissen", and start an analysis against a running instance of the main branch (e.g. staging). The `code-scanner` subagent maps the code; the orchestrator walks the app screen by screen and writes the documents with the `write_knowledge` tool. The tab shows when the analysed commit is behind the branch head; "Wissensbasis aktualisieren" then hands the agent the previous documents and the list of changed files. Reviews get the route index and app map inlined in the prompt when they are small (up to about 24,000 characters) and as files otherwise. +Webhook security: the endpoint `/webhooks/github` is public; every delivery must carry a valid `X-Hub-Signature-256` for the repository's own secret (generated on setup, stored encrypted), otherwise it is rejected with 401. For local development, expose the dev server with a tunnel (e.g. cloudflared) and set `PUBLIC_BASE_URL` to the tunnel address before setting up the webhook. + Offline demo: with `CLAUDIO_DEV_FIXTURES=1` the dev server offers a login without GitHub and a demo repository that reviews the fixture PR against `apps/demo-target` (start it with `pnpm demo-target`). Production: `pnpm --filter @claudio/web build`, then `node apps/web/build` (set `ORIGIN` and `PORT`) next to `pnpm worker`. diff --git a/apps/demo-target/README.md b/apps/demo-target/README.md index b2bd8cb..75b1d34 100644 --- a/apps/demo-target/README.md +++ b/apps/demo-target/README.md @@ -17,3 +17,5 @@ Bugs a good review must find (see `fixtures/pr-context.json` for the PR and issu 2. The new form lacks the required vehicle field, allows past dates, has unlabeled Von/Nach inputs and a button labelled "Senden" instead of "Speichern". 3. The server does not validate required fields. 4. The trip list logs a console error on every load. + +Bot check fixture: `/geschuetzt` redirects to `/pruefung` ("Ich bin kein Roboter") until a person confirms it. It is not a bug to find; it exercises Claudio's human takeover. diff --git a/apps/demo-target/src/server.mjs b/apps/demo-target/src/server.mjs index e1f496d..faf53ec 100644 --- a/apps/demo-target/src/server.mjs +++ b/apps/demo-target/src/server.mjs @@ -87,6 +87,21 @@ ${rows.map((t) => `${t.id}${t.passenger}${t.date} return redirect(res, `/trips?created=${trip.id}`); } if (p === "/api/trips") return json(res, trips); + // Bot check fixture: /geschuetzt is only reachable after a person ticks "Ich bin kein Roboter". + // Used to verify Claudio's human takeover; a real app would show a CAPTCHA here. + if (p === "/geschuetzt") { + if (parseCookies(req).human !== "1") return redirect(res, "/pruefung?next=/geschuetzt"); + return html(res, page("Geschützt", "

Geschützter Bereich

Die Bot-Prüfung wurde bestanden.

", user)); + } + if (p === "/pruefung" && req.method === "GET") { + return html(res, page("Sicherheitsprüfung", `

Sicherheitsprüfung

Bitte bestätigen Sie, dass Sie ein Mensch sind.

`, user)); + } + if (p === "/pruefung" && req.method === "POST") { + const b = await readBody(req); + if (b.get("human") !== "1") return redirect(res, "/pruefung"); + res.setHeader("set-cookie", "human=1; Path=/; HttpOnly"); + return redirect(res, url.searchParams.get("next") ?? "/dashboard"); + } return html(res, page("404", "

Seite nicht gefunden

", user), 404); }); diff --git a/apps/web/src/hooks.server.ts b/apps/web/src/hooks.server.ts index ee3f5d6..255ae7e 100644 --- a/apps/web/src/hooks.server.ts +++ b/apps/web/src/hooks.server.ts @@ -4,7 +4,8 @@ import { getSessionUser } from '@claudio/db'; import { SESSION_COOKIE } from '#lib/server/auth.js'; import { db } from '#lib/server/db.js'; -const PUBLIC_PATHS = ['/login', '/auth/']; +// Webhooks authenticate with their HMAC signature instead of a session. +const PUBLIC_PATHS = ['/login', '/auth/', '/webhooks/']; export const handle: Handle = async ({ event, resolve }) => { const token = event.cookies.get(SESSION_COOKIE); diff --git a/apps/web/src/lib/components/HumanTakeover.svelte b/apps/web/src/lib/components/HumanTakeover.svelte new file mode 100644 index 0000000..d91909f --- /dev/null +++ b/apps/web/src/lib/components/HumanTakeover.svelte @@ -0,0 +1,198 @@ + + +
+
+ +
+

Claudio braucht dich

+

{reason}. Löse es direkt im Bild unten, dann geht der Lauf weiter.

+
+
+ +
+ + {#if frames.url} + Browser von Claudio, zum Bedienen anklicken + {:else} +

Verbinde mit dem Browser …

+ {/if} + {#if lastClick}{/if} +
+ +
+
+ + + +
+
+ {#each ['Enter', 'Tab', 'Backspace', 'Escape'] as key (key)} + + {/each} + + +
+ {#if error}{/if} +
+ + + {url} +
+
+
+ + diff --git a/apps/web/src/lib/components/LiveLog.svelte b/apps/web/src/lib/components/LiveLog.svelte index 16ac956..16b3222 100644 --- a/apps/web/src/lib/components/LiveLog.svelte +++ b/apps/web/src/lib/components/LiveLog.svelte @@ -7,7 +7,8 @@ streamUrl, events, active, - onstatus + onstatus, + online }: { /** SSE endpoint of the run, e.g. /reviews/3/events */ streamUrl: string; @@ -17,6 +18,8 @@ active: boolean; /** Status changes from the stream (queued → running → finished). */ onstatus?: (status: string | null) => void; + /** Every new log line, e.g. to react to "Mensch gebraucht:". */ + online?: (line: string) => void; } = $props(); let live = $state([]); @@ -36,7 +39,9 @@ const seq = Number(ev.lastEventId); if (seq <= lastSeq) return; lastSeq = seq; - live.push({ seq, line: JSON.parse(ev.data) }); + const line = JSON.parse(ev.data); + live.push({ seq, line }); + online?.(line); }); const finish = (status: string) => { if (!streaming) return; diff --git a/apps/web/src/lib/components/LiveView.svelte b/apps/web/src/lib/components/LiveView.svelte index 214ca5c..c154d8f 100644 --- a/apps/web/src/lib/components/LiveView.svelte +++ b/apps/web/src/lib/components/LiveView.svelte @@ -1,7 +1,8 @@
@@ -11,17 +12,10 @@ {url}
- Aktueller Bildschirm des Browsers, den Claudio steuert (hasFrame = true)} - onerror={() => (failed = true)} - /> - {#if !hasFrame} -

- {failed ? 'Die Live-Ansicht ist gerade nicht erreichbar.' : 'Wartet auf das erste Bild, sobald der Browser startet …'} -

+ {#if frames.url} + Aktueller Bildschirm des Browsers, den Claudio steuert + {:else} +

Wartet auf das erste Bild, sobald der Browser startet …

{/if}
diff --git a/apps/web/src/lib/live-frames.svelte.ts b/apps/web/src/lib/live-frames.svelte.ts new file mode 100644 index 0000000..f282cd7 --- /dev/null +++ b/apps/web/src/lib/live-frames.svelte.ts @@ -0,0 +1,33 @@ +/** + * Polls a run's live frame endpoint and swaps the image only once a frame has + * fully loaded, so the view never flickers. Stops when the component unmounts. + */ +export function liveFrames(src: () => string, intervalMs = 300) { + const state = $state({ url: null as string | null, failed: false }); + $effect(() => { + const base = src(); + let stopped = false; + let n = 0; + let timer: ReturnType | undefined; + const next = (delay: number) => (timer = setTimeout(load, delay)); + function load() { + if (stopped) return; + const img = new Image(); + img.onload = () => { + if (stopped) return; + state.url = img.src; + state.failed = false; + next(intervalMs); + }; + // 204 (no frame yet) and network errors both land here; retry a little slower. + img.onerror = () => next(1000); + img.src = `${base}?t=${Date.now()}-${n++}`; + } + load(); + return () => { + stopped = true; + clearTimeout(timer); + }; + }); + return state; +} diff --git a/apps/web/src/lib/server/automation.ts b/apps/web/src/lib/server/automation.ts new file mode 100644 index 0000000..38182a5 --- /dev/null +++ b/apps/web/src/lib/server/automation.ts @@ -0,0 +1,143 @@ +import { AnalysisRequestSchema, ReviewRequestSchema } from '@claudio/core'; +import { + createAnalysis, + createReview, + createTestRuns, + findActiveReview, + getUser, + listAnalyses, + listRoles, + listTestScripts, + userAccessToken, + type AutoReviewDefaults, + type Repo +} from '@claudio/db'; +import { + createGithubClient, + decideTrigger, + getPullHead, + postPullRequestComment, + reactToComment, + renderInstanceUrl, + setCommitStatus, + type AutomationSettings +} from '@claudio/github'; +import { db } from './db.js'; +import { DEMO_FIXTURE, DEMO_REPO } from './demo.js'; +import { devFixtures } from './env.js'; + +export const DEFAULT_AUTOMATION: AutomationSettings = { + instanceUrlTemplate: '', + onComment: true, + onLabel: true, + onPush: false, + analyzeOnMerge: false, + commitStatus: true +}; + +export const DEFAULT_AUTO_REVIEW: AutoReviewDefaults = { + roles: [], + budget: 'standard', + strictness: 'normal', + postComment: true, + runTests: true +}; + +export type EventResult = { action: 'review' | 'analysis' | 'ignored'; reason: string; id?: number }; + +/** Token of the person who set up the automation; automated runs act on their behalf. */ +async function automationToken(repo: Repo): Promise<{ token: string | null; userId: number }> { + const userId = repo.automationUserId ?? repo.createdBy; + const user = await getUser(db(), userId); + return { token: user ? userAccessToken(user) || null : null, userId }; +} + +/** Turns a verified GitHub event into a review or analysis job. */ +export async function handleGithubEvent(repo: Repo, event: string, payload: Record, baseUrl: string): Promise { + const settings = repo.automation ?? DEFAULT_AUTOMATION; + const decision = decideTrigger(event, payload, settings, repo.defaultBranch); + if (decision.kind === 'ignore') return { action: 'ignored', reason: decision.reason }; + + const fixture = repo.fullName === DEMO_REPO && devFixtures() ? DEMO_FIXTURE : null; + const { token, userId } = await automationToken(repo); + const github = token && !fixture ? createGithubClient(token) : null; + + if (decision.kind === 'analysis') { + if (!repo.defaultInstanceUrl) return { action: 'ignored', reason: 'no default instance URL for the analysis' }; + if ((await listAnalyses(db(), { repoId: repo.id, activeOnly: true })).length > 0) return { action: 'ignored', reason: 'an analysis is already running' }; + const roles = (await listRoles(db(), repo.id)).map((r) => r.name); + const request = AnalysisRequestSchema.parse({ repo: repo.fullName, branch: decision.branch, instanceUrl: repo.defaultInstanceUrl, roles, budget: 'quick' }); + const id = await createAnalysis(db(), { repoId: repo.id, request, fixture, startedBy: userId }); + return { action: 'analysis', reason: `push to ${decision.branch}`, id }; + } + + // Review. Comment events carry no head commit; fetch it. + let { headSha, branch, title } = decision; + if ((!headSha || !branch) && github) { + const head = await getPullHead(github, repo.fullName, decision.prNumber); + if (!head.open) return { action: 'ignored', reason: 'pull request is closed' }; + ({ sha: headSha, branch, title } = head); + } + if (!repo.dataConfirmed) return { action: 'ignored', reason: 'the repository is not cleared for reviews (test data confirmation missing)' }; + + const template = settings.instanceUrlTemplate || (fixture ? repo.defaultInstanceUrl ?? '' : ''); + if (!template) { + if (github && decision.trigger === 'comment') { + await postPullRequestComment(github, repo.fullName, decision.prNumber, 'Claudio kann diesen PR nicht prüfen: In den Repo-Einstellungen fehlt die URL-Vorlage für die PR-Instanzen.').catch(() => undefined); + } + return { action: 'ignored', reason: 'no instance URL template' }; + } + const instanceUrl = renderInstanceUrl(template, { number: decision.prNumber, branch: branch ?? '', sha: headSha ?? '' }); + + if (await findActiveReview(db(), repo.id, decision.prNumber, decision.trigger === 'push' ? headSha : null)) { + return { action: 'ignored', reason: 'a review of this pull request is already queued or running' }; + } + + const defaults = repo.autoReview ?? DEFAULT_AUTO_REVIEW; + const roleNames = (await listRoles(db(), repo.id)).map((r) => r.name); + const parsed = ReviewRequestSchema.safeParse({ + repo: repo.fullName, + pr: decision.prNumber, + instanceUrl, + roles: defaults.roles.filter((r) => roleNames.includes(r)), + budget: decision.budget ?? defaults.budget, + strictness: defaults.strictness, + postComment: defaults.postComment && !fixture, + cloneRepo: !fixture + }); + if (!parsed.success) return { action: 'ignored', reason: `invalid review request: ${parsed.error.issues[0]?.message}` }; + + const id = await createReview(db(), { + repoId: repo.id, + prNumber: decision.prNumber, + prTitle: title, + request: parsed.data, + fixture, + startedBy: userId, + trigger: decision.trigger, + headSha + }); + + if (defaults.runTests) { + const tests = (await listTestScripts(db(), repo.id)).filter((t) => t.runInReviews); + await createTestRuns( + db(), + tests.map((t) => ({ + scriptId: t.id, + repoId: repo.id, + reviewId: id, + request: { repo: repo.fullName, instanceUrl, budget: 'quick' as const, language: parsed.data.language, headed: false, rerecord: false }, + startedBy: userId + })) + ); + } + + // Visible feedback on GitHub. Failures here must not lose the review. + if (github) { + if (decision.trigger === 'comment' && decision.commentId) await reactToComment(github, repo.fullName, decision.commentId).catch(() => undefined); + if (settings.commitStatus && headSha) { + await setCommitStatus(github, repo.fullName, headSha, 'pending', 'Claudio prüft den Pull Request …', `${baseUrl}/reviews/${id}`).catch(() => undefined); + } + } + return { action: 'review', reason: `${decision.trigger} on #${decision.prNumber}`, id }; +} diff --git a/apps/web/src/lib/server/live.ts b/apps/web/src/lib/server/live.ts index 82955b2..6badf9c 100644 --- a/apps/web/src/lib/server/live.ts +++ b/apps/web/src/lib/server/live.ts @@ -1,59 +1,17 @@ -import { readFile, stat } from 'node:fs/promises'; +import { readFile } from 'node:fs/promises'; import { runsRoot } from './env.js'; import { resolveRunFile } from './files.js'; -const BOUNDARY = 'claudio-frame'; -const ACTIVE = new Set(['queued', 'running']); - /** - * MJPEG stream of a run's browser (runDir/live.jpg, written by the worker's - * screencast). An plays it like a video. Ends when the run is over. + * The newest frame of a run's browser (runDir/live.jpg, written by the worker's + * screencast). The page polls it a few times per second; 204 while there is none. */ -export function liveViewStream(request: Request, lookup: () => Promise<{ status: string; runId: string | null } | null>): Response { - const encoder = new TextEncoder(); - const stream = new ReadableStream({ - async start(controller) { - let closed = false; - request.signal.addEventListener('abort', () => (closed = true)); - let lastMtime = 0; - let lastCheck = 0; - let run = await lookup(); - try { - while (!closed && run && ACTIVE.has(run.status)) { - const file = run.runId ? resolveRunFile(runsRoot, run.runId, 'live.jpg') : null; - if (file) { - const info = await stat(file).catch(() => null); - if (info && info.mtimeMs !== lastMtime) { - const jpeg = await readFile(file).catch(() => null); - if (jpeg && jpeg.length > 0) { - lastMtime = info.mtimeMs; - controller.enqueue(encoder.encode(`--${BOUNDARY}\r\nContent-Type: image/jpeg\r\nContent-Length: ${jpeg.length}\r\n\r\n`)); - controller.enqueue(new Uint8Array(jpeg)); - controller.enqueue(encoder.encode('\r\n')); - } - } - } - await new Promise((r) => setTimeout(r, 250)); - if (Date.now() - lastCheck > 2000) { - lastCheck = Date.now(); - run = await lookup(); - } - } - } catch { - // client went away mid-write - } - try { - controller.close(); - } catch { - // already closed - } - } - }); - return new Response(stream, { - headers: { - 'content-type': `multipart/x-mixed-replace; boundary=${BOUNDARY}`, - 'cache-control': 'no-store', - 'x-content-type-options': 'nosniff' - } +export async function liveFrame(lookup: () => Promise<{ status: string; runId: string | null } | null>): Promise { + const run = await lookup(); + const file = run?.runId ? resolveRunFile(runsRoot, run.runId, 'live.jpg') : null; + const jpeg = file ? await readFile(file).catch(() => null) : null; + if (!jpeg || jpeg.length === 0) return new Response(null, { status: 204, headers: { 'cache-control': 'no-store' } }); + return new Response(new Uint8Array(jpeg), { + headers: { 'content-type': 'image/jpeg', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' } }); } diff --git a/apps/web/src/routes/+layout.server.ts b/apps/web/src/routes/+layout.server.ts index d8083fd..da3765f 100644 --- a/apps/web/src/routes/+layout.server.ts +++ b/apps/web/src/routes/+layout.server.ts @@ -1,3 +1,18 @@ +import { listOpenHumanRequests, listRepos } from '@claudio/db'; +import { db } from '#lib/server/db.js'; +import { DEMO_REPO } from '#lib/server/demo.js'; +import { devFixtures } from '#lib/server/env.js'; import type { LayoutServerLoad } from './$types'; -export const load: LayoutServerLoad = ({ locals }) => ({ user: locals.user }); +export const load: LayoutServerLoad = async ({ locals }) => { + if (!locals.user) return { user: null, repos: [], waiting: [] }; + const [repos, waiting] = await Promise.all([listRepos(db()), listOpenHumanRequests(db())]); + const runPath = { review: 'reviews', analysis: 'analyses', test: 'test-runs' } as const; + return { + user: locals.user, + // onGithub: false for the offline demo repo, which does not exist on GitHub. + repos: repos.map((r) => ({ fullName: r.fullName, onGithub: !(devFixtures() && r.fullName === DEMO_REPO) })), + // Runs paused for a person (CAPTCHA); shown on every page so nobody misses them. + waiting: waiting.map((w) => ({ id: w.id, reason: w.reason, repo: w.repoFullName, href: `/${runPath[w.runKind]}/${w.runRefId}` })) + }; +}; diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte index 34f7235..939e9e1 100644 --- a/apps/web/src/routes/+layout.svelte +++ b/apps/web/src/routes/+layout.svelte @@ -5,10 +5,19 @@ import type { LayoutProps } from './$types'; let { children, data }: LayoutProps = $props(); + const repos = $derived(data.repos ?? []); + + // The repository the current page belongs to, whatever kind of page it is. + const currentRepo = $derived.by((): string | null => { + if (page.params.owner && page.params.name) return `${page.params.owner}/${page.params.name}`; + const d = page.data as Record; + const candidate = d.review?.repo ?? d.analysis?.repo ?? d.script?.repo ?? d.run?.repo ?? (typeof d.repo === 'string' ? d.repo : null); + return typeof candidate === 'string' ? candidate : null; + }); const nav = [ { href: '/', label: 'Übersicht', match: (p: string) => p === '/' || p.startsWith('/reviews') }, - { href: '/repos', label: 'Repositories', match: (p: string) => p.startsWith('/repos') } + { href: '/repos', label: 'Repositories', match: (p: string) => p === '/repos' } ]; @@ -29,6 +38,19 @@ {item.label} {/each} + {#if repos.length} +
    + {#each repos as repo (repo.fullName)} + {@const [owner, name] = repo.fullName.split('/')} +
  • + + {owner}/{name} + +
  • + {/each} +
+ {/if} + Repository hinzufügen
{#if data.user.avatarUrl} @@ -40,6 +62,26 @@
+ {#each (data.waiting ?? []).filter((w) => w.href !== page.url.pathname) as w (w.id)} + + + Claudio wartet auf dich: {w.reason} ({w.repo}) + Öffnen + + {/each} + {#if currentRepo} + {@const info = repos.find((r) => r.fullName === currentRepo)} + {@const onRepoPage = Boolean(page.params.owner)} + + {#if !onRepoPage || info?.onGithub} +
+ {#if !onRepoPage}{currentRepo}{/if} + {#if info?.onGithub} + Auf GitHub + {/if} +
+ {/if} + {/if} {@render children()}
@@ -93,6 +135,93 @@ background: var(--petrol-soft); color: var(--petrol); } + .add { + justify-content: center; + font-size: 0.875rem; + } + .repo-list { + list-style: none; + margin: -1.25rem 0 0; + padding: 0 0 0 0.6rem; + display: grid; + gap: 0.1rem; + max-height: 40vh; + overflow-y: auto; + border-left: 1px solid var(--rule); + margin-left: 0.6rem; + } + .repo-list a { + display: block; + padding: 0.3rem 0.5rem; + border-radius: var(--radius-s); + color: var(--ink-2); + text-decoration: none; + font-size: 0.875rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .repo-list a:hover { + background: var(--sunken); + color: var(--ink); + } + .repo-list a[aria-current='page'] { + background: var(--petrol-soft); + color: var(--petrol); + font-weight: 600; + } + .owner { + color: var(--ink-3); + font-weight: 400; + } + .waiting { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.7rem 1rem; + margin-bottom: 1rem; + border: 1px solid var(--major); + border-left-width: 6px; + border-radius: var(--radius-s); + background: var(--major-soft); + color: var(--ink); + text-decoration: none; + } + .waiting .go { + margin-left: auto; + font-weight: 600; + color: var(--major); + } + .waiting .pulse { + width: 0.6rem; + height: 0.6rem; + border-radius: 50%; + background: var(--major); + flex: none; + animation: pulse 1.2s ease-in-out infinite; + } + @keyframes pulse { + 50% { + opacity: 0.3; + } + } + .repobar { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + margin-bottom: 0.75rem; + } + .repo-name { + font-size: 0.9375rem; + font-weight: 600; + color: var(--ink-2); + text-decoration: none; + } + .repo-name:hover { + color: var(--petrol); + text-decoration: underline; + } .me { margin-top: auto; display: flex; @@ -150,6 +279,20 @@ order: 3; width: 100%; } + .add { + order: 5; + } + .repo-list { + order: 4; + width: 100%; + max-height: none; + margin: 0; + border-left: 0; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + } .me { margin: 0 0 0 auto; } diff --git a/apps/web/src/routes/analyses/[id]/+page.server.ts b/apps/web/src/routes/analyses/[id]/+page.server.ts index 36a8a53..5b8121f 100644 --- a/apps/web/src/routes/analyses/[id]/+page.server.ts +++ b/apps/web/src/routes/analyses/[id]/+page.server.ts @@ -1,6 +1,6 @@ import { error } from '@sveltejs/kit'; import { KNOWLEDGE_DOCS, sortBySeverity } from '@claudio/core'; -import { eventsSince, getAnalysis, knowledgeDocsOf, requestAnalysisAbort } from '@claudio/db'; +import { eventsSince, getAnalysis, knowledgeDocsOf, openHumanRequest, requestAnalysisAbort } from '@claudio/db'; import { db } from '#lib/server/db.js'; import { runEvidence, runFindings } from '#lib/server/run-files.js'; import { renderMarkdown } from '#lib/markdown.js'; @@ -38,7 +38,8 @@ export const load: PageServerLoad = async ({ params }) => { docs: docs.map((d) => ({ name: d.name, title: KNOWLEDGE_DOCS[d.name].title, html: renderMarkdown(d.content), chars: d.content.length })), evidence: a.runId ? runEvidence(a.runId).filter((e) => e.url && e.kind === 'screenshot') : [], findings: a.runId ? sortBySeverity(runFindings(a.runId)) : [], - events: events.map((e) => ({ seq: e.seq, line: e.line })) + events: events.map((e) => ({ seq: e.seq, line: e.line })), + human: await openHumanRequest(db(), 'analysis', a.id).then((h) => (h ? { id: h.id, reason: h.reason } : null)) }; }; diff --git a/apps/web/src/routes/analyses/[id]/+page.svelte b/apps/web/src/routes/analyses/[id]/+page.svelte index 5df42ca..81642cd 100644 --- a/apps/web/src/routes/analyses/[id]/+page.svelte +++ b/apps/web/src/routes/analyses/[id]/+page.svelte @@ -1,7 +1,9 @@ @@ -62,6 +63,12 @@
+ {#if data.authExpired} + + {/if} {#each (data.waiting ?? []).filter((w) => w.href !== page.url.pathname) as w (w.id)} @@ -174,6 +181,13 @@ color: var(--ink-3); font-weight: 400; } + .expired { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 1rem; + } .waiting { display: flex; align-items: center; diff --git a/apps/web/src/routes/+page.server.ts b/apps/web/src/routes/+page.server.ts index efa19fa..a3eb51e 100644 --- a/apps/web/src/routes/+page.server.ts +++ b/apps/web/src/routes/+page.server.ts @@ -1,13 +1,15 @@ -import { dismissedFindingIds, listAnalyses, listRepos, listReviews, testResultsByReview } from '@claudio/db'; +import { dismissedFindingIds, listAnalyses, listReviews, testResultsByReview } from '@claudio/db'; +import { visibleRepos } from '#lib/server/access.js'; import { db } from '#lib/server/db.js'; import { summarize } from '#lib/server/summary.js'; import type { PageServerLoad } from './$types'; -export const load: PageServerLoad = async () => { - const [reviews, repos, analyses] = await Promise.all([ - listReviews(db(), { limit: 40 }), - listRepos(db()), - listAnalyses(db(), { activeOnly: true }) +export const load: PageServerLoad = async ({ locals }) => { + const { repos } = await visibleRepos(locals.user!); + const repoIds = repos.map((r) => r.id); + const [reviews, analyses] = await Promise.all([ + listReviews(db(), { limit: 40, repoIds }), + listAnalyses(db(), { activeOnly: true, repoIds }) ]); const [dismissed, tests] = await Promise.all([ dismissedFindingIds(db(), reviews.map((r) => r.id)), diff --git a/apps/web/src/routes/analyses/[id]/+page.server.ts b/apps/web/src/routes/analyses/[id]/+page.server.ts index 5b8121f..2e5b352 100644 --- a/apps/web/src/routes/analyses/[id]/+page.server.ts +++ b/apps/web/src/routes/analyses/[id]/+page.server.ts @@ -1,20 +1,24 @@ import { error } from '@sveltejs/kit'; import { KNOWLEDGE_DOCS, sortBySeverity } from '@claudio/core'; import { eventsSince, getAnalysis, knowledgeDocsOf, openHumanRequest, requestAnalysisAbort } from '@claudio/db'; +import type { AccessLevel } from '@claudio/db'; +import { requireRepo } from '#lib/server/access.js'; import { db } from '#lib/server/db.js'; import { runEvidence, runFindings } from '#lib/server/run-files.js'; import { renderMarkdown } from '#lib/markdown.js'; import type { Actions, PageServerLoad } from './$types'; -async function loadAnalysis(idParam: string) { +async function loadAnalysis(idParam: string, user: App.Locals['user'], min: AccessLevel = 'read') { const id = Number(idParam); const analysis = Number.isInteger(id) ? await getAnalysis(db(), id) : undefined; if (!analysis) error(404, 'Diese Analyse gibt es nicht.'); + // Visible only with access to its repository on GitHub. + await requireRepo(user, analysis.repoId, min); return analysis; } -export const load: PageServerLoad = async ({ params }) => { - const a = await loadAnalysis(params.id); +export const load: PageServerLoad = async ({ params, locals }) => { + const a = await loadAnalysis(params.id, locals.user, 'read'); const [events, docs] = await Promise.all([eventsSince(db(), { analysisId: a.id }, 0, 5000), knowledgeDocsOf(db(), a.id)]); return { analysis: { @@ -44,8 +48,8 @@ export const load: PageServerLoad = async ({ params }) => { }; export const actions: Actions = { - abort: async ({ params }) => { - const a = await loadAnalysis(params.id); + abort: async ({ params, locals }) => { + const a = await loadAnalysis(params.id, locals.user, 'write'); await requestAnalysisAbort(db(), a.id); return { aborted: true }; } diff --git a/apps/web/src/routes/analyses/[id]/events/+server.ts b/apps/web/src/routes/analyses/[id]/events/+server.ts index 47af09f..0aa91c1 100644 --- a/apps/web/src/routes/analyses/[id]/events/+server.ts +++ b/apps/web/src/routes/analyses/[id]/events/+server.ts @@ -1,11 +1,14 @@ import { error } from '@sveltejs/kit'; import { getAnalysis } from '@claudio/db'; +import { requireRepo } from '#lib/server/access.js'; import { db } from '#lib/server/db.js'; import { runEventStream } from '#lib/server/sse.js'; import type { RequestHandler } from './$types'; -export const GET: RequestHandler = async ({ params, url, request }) => { +export const GET: RequestHandler = async ({ params, url, request, locals }) => { const id = Number(params.id); - if (!Number.isInteger(id) || !(await getAnalysis(db(), id))) error(404, 'Diese Analyse gibt es nicht.'); + const item = Number.isInteger(id) ? await getAnalysis(db(), id) : undefined; + if (!item) error(404, 'Diese Analyse gibt es nicht.'); + await requireRepo(locals.user, item.repoId, 'read'); return runEventStream(request, url, { analysisId: id }, async () => (await getAnalysis(db(), id))?.status ?? null); }; diff --git a/apps/web/src/routes/analyses/[id]/live/+server.ts b/apps/web/src/routes/analyses/[id]/live/+server.ts index 5a8ae2e..9784bdb 100644 --- a/apps/web/src/routes/analyses/[id]/live/+server.ts +++ b/apps/web/src/routes/analyses/[id]/live/+server.ts @@ -1,12 +1,15 @@ import { error } from '@sveltejs/kit'; import { getAnalysis } from '@claudio/db'; +import { requireRepo } from '#lib/server/access.js'; import { db } from '#lib/server/db.js'; import { liveFrame } from '#lib/server/live.js'; import type { RequestHandler } from './$types'; -export const GET: RequestHandler = async ({ params }) => { +export const GET: RequestHandler = async ({ params, locals }) => { const id = Number(params.id); - if (!Number.isInteger(id) || !(await getAnalysis(db(), id))) error(404, 'Diese Analyse gibt es nicht.'); + const item = Number.isInteger(id) ? await getAnalysis(db(), id) : undefined; + if (!item) error(404, 'Diese Analyse gibt es nicht.'); + await requireRepo(locals.user, item.repoId, 'read'); return liveFrame(async () => { const a = await getAnalysis(db(), id); return a ? { status: a.status, runId: a.runId } : null; diff --git a/apps/web/src/routes/api/health/+server.ts b/apps/web/src/routes/api/health/+server.ts new file mode 100644 index 0000000..546f07d --- /dev/null +++ b/apps/web/src/routes/api/health/+server.ts @@ -0,0 +1,14 @@ +import { json } from '@sveltejs/kit'; +import { sql } from 'drizzle-orm'; +import { db } from '#lib/server/db.js'; +import type { RequestHandler } from './$types'; + +/** Liveness/readiness for Kubernetes: the app answers and reaches the database. Public, no data. */ +export const GET: RequestHandler = async () => { + try { + await db().execute(sql`SELECT 1`); + return json({ ok: true }); + } catch (err) { + return json({ ok: false, error: err instanceof Error ? err.message : String(err) }, { status: 503 }); + } +}; diff --git a/apps/web/src/routes/auth/github/callback/+server.ts b/apps/web/src/routes/auth/github/callback/+server.ts index bb4cbcb..1b38b39 100644 --- a/apps/web/src/routes/auth/github/callback/+server.ts +++ b/apps/web/src/routes/auth/github/callback/+server.ts @@ -1,9 +1,10 @@ import { redirect } from '@sveltejs/kit'; -import { upsertUser } from '@claudio/db'; +import { clearAccess, upsertUser } from '@claudio/db'; import { STATE_COOKIE, isAllowed, safeNext, startSession } from '#lib/server/auth.js'; import { db } from '#lib/server/db.js'; import { githubOAuthConfig, publicBaseUrl } from '#lib/server/env.js'; -import { exchangeCode, fetchGithubUser } from '#lib/server/github.js'; +import { exchangeCode } from '@claudio/github'; +import { fetchGithubUser } from '#lib/server/github.js'; import type { RequestHandler } from './$types'; export const GET: RequestHandler = async ({ url, cookies }) => { @@ -18,12 +19,15 @@ export const GET: RequestHandler = async ({ url, cookies }) => { let target = '/login?error=github'; try { - const token = await exchangeCode(config.clientId, config.clientSecret, code, `${publicBaseUrl(url)}/auth/github/callback`); + const tokens = await exchangeCode(config.clientId, config.clientSecret, code, `${publicBaseUrl(url)}/auth/github/callback`); + const token = tokens.accessToken; const profile = await fetchGithubUser(token); if (!(await isAllowed(profile.login, token))) { target = '/login?error=forbidden'; } else { - const user = await upsertUser(db(), { ...profile, accessToken: token }); + const user = await upsertUser(db(), { ...profile, accessToken: token, refreshToken: tokens.refreshToken, expiresAt: tokens.expiresAt }); + // Permissions are fetched fresh after every login, so changes on GitHub apply right away. + await clearAccess(db(), user.id); await startSession(cookies, user.id, url.protocol === 'https:'); target = safeNext(next ?? null); } diff --git a/apps/web/src/routes/human/[id]/input/+server.ts b/apps/web/src/routes/human/[id]/input/+server.ts index cf5a307..885beb5 100644 --- a/apps/web/src/routes/human/[id]/input/+server.ts +++ b/apps/web/src/routes/human/[id]/input/+server.ts @@ -1,6 +1,7 @@ import { error, json } from '@sveltejs/kit'; import { z } from 'zod'; import { addHumanInput, getHumanRequest } from '@claudio/db'; +import { requireRepo } from '#lib/server/access.js'; import { db } from '#lib/server/db.js'; import type { RequestHandler } from './$types'; @@ -15,6 +16,7 @@ const InputSchema = z.discriminatedUnion('type', [ export const POST: RequestHandler = async ({ params, request, locals }) => { const req = await getHumanRequest(db(), Number(params.id)); if (!req) error(404, 'Unbekannte Anfrage.'); + await requireRepo(locals.user, req.repoId, 'write'); if (req.status !== 'open') error(409, 'Die Anfrage ist bereits erledigt.'); const parsed = InputSchema.safeParse(await request.json().catch(() => null)); if (!parsed.success) error(400, 'Ungültige Eingabe.'); diff --git a/apps/web/src/routes/human/[id]/resolve/+server.ts b/apps/web/src/routes/human/[id]/resolve/+server.ts index c5abc45..a6b6118 100644 --- a/apps/web/src/routes/human/[id]/resolve/+server.ts +++ b/apps/web/src/routes/human/[id]/resolve/+server.ts @@ -1,5 +1,6 @@ import { error, json } from '@sveltejs/kit'; import { closeHumanRequest, getHumanRequest } from '@claudio/db'; +import { requireRepo } from '#lib/server/access.js'; import { db } from '#lib/server/db.js'; import type { RequestHandler } from './$types'; @@ -7,6 +8,7 @@ import type { RequestHandler } from './$types'; export const POST: RequestHandler = async ({ params, request, locals }) => { const req = await getHumanRequest(db(), Number(params.id)); if (!req) error(404, 'Unbekannte Anfrage.'); + await requireRepo(locals.user, req.repoId, 'write'); const body = (await request.json().catch(() => ({}))) as { status?: string }; const status = body.status === 'cancelled' ? 'cancelled' : 'resolved'; await closeHumanRequest(db(), req.id, status, locals.user!.id); diff --git a/apps/web/src/routes/kosten/+page.server.ts b/apps/web/src/routes/kosten/+page.server.ts new file mode 100644 index 0000000..e128b2d --- /dev/null +++ b/apps/web/src/routes/kosten/+page.server.ts @@ -0,0 +1,33 @@ +import { costSummary } from '@claudio/db'; +import { visibleRepos } from '#lib/server/access.js'; +import { db } from '#lib/server/db.js'; +import type { PageServerLoad } from './$types'; + +type Cell = { runs: number; usd: number }; +type RepoRow = { repo: string; review: Cell; analysis: Cell; test: Cell; total: number }; + +export const load: PageServerLoad = async ({ locals }) => { + const visible = new Set((await visibleRepos(locals.user!)).repos.map((r) => r.fullName)); + const since = new Date(); + since.setMonth(since.getMonth() - 11, 1); + since.setHours(0, 0, 0, 0); + const rows = (await costSummary(db(), since)).filter((r) => visible.has(r.repo)); + + const months = new Map>(); + for (const r of rows) { + const repos = months.get(r.month) ?? new Map(); + const empty = (): Cell => ({ runs: 0, usd: 0 }); + const row = repos.get(r.repo) ?? { repo: r.repo, review: empty(), analysis: empty(), test: empty(), total: 0 }; + row[r.kind] = { runs: r.runs, usd: r.usd }; + row.total += r.usd; + repos.set(r.repo, row); + months.set(r.month, repos); + } + const result = [...months.entries()] + .sort(([a], [b]) => b.localeCompare(a)) + .map(([month, repos]) => { + const list = [...repos.values()].sort((a, b) => b.total - a.total); + return { month, repos: list, total: list.reduce((n, r) => n + r.total, 0), runs: list.reduce((n, r) => n + r.review.runs + r.analysis.runs + r.test.runs, 0) }; + }); + return { months: result, yearTotal: result.reduce((n, m) => n + m.total, 0) }; +}; diff --git a/apps/web/src/routes/kosten/+page.svelte b/apps/web/src/routes/kosten/+page.svelte new file mode 100644 index 0000000..d2f658e --- /dev/null +++ b/apps/web/src/routes/kosten/+page.svelte @@ -0,0 +1,82 @@ + + +Kosten · Claudio + +

Kosten

+

+ Modellkosten der letzten zwölf Monate, wie das Agent SDK sie meldet: Schätzungen zum Listenpreis, keine Rechnung. Reine Testwiedergaben ohne Modell kosten nichts. +

+ +{#if data.months.length === 0} +

Noch keine Läufe.

+{:else} +

Zwölf Monate gesamt: {formatUsd(data.yearTotal)}

+ {#each data.months as m (m.month)} +
+

{monthName(m.month)} {formatUsd(m.total)} {m.runs} Läufe

+
+ + + + + + {#each m.repos as r (r.repo)} + + + + + + + + {/each} + +
RepositoryReviewsAnalysenTestsSumme
{r.repo}{cell(r.review)}{r.review.runs || ''}{cell(r.analysis)}{r.analysis.runs || ''}{cell(r.test)}{r.test.runs || ''}{formatUsd(r.total)}
+
+
+ {/each} +

Die kleine Zahl neben einem Betrag ist die Anzahl der Läufe.

+{/if} + + diff --git a/apps/web/src/routes/repos/+page.server.ts b/apps/web/src/routes/repos/+page.server.ts index 77938ab..5fe28c7 100644 --- a/apps/web/src/routes/repos/+page.server.ts +++ b/apps/web/src/routes/repos/+page.server.ts @@ -1,5 +1,6 @@ import { fail, redirect } from '@sveltejs/kit'; -import { addRepo, listRepos, listReviews } from '@claudio/db'; +import { addRepo, listReviews } from '@claudio/db'; +import { visibleRepos } from '#lib/server/access.js'; import { userToken } from '#lib/server/auth.js'; import { db } from '#lib/server/db.js'; import { createDemoRepo, DEMO_REPO } from '#lib/server/demo.js'; @@ -8,7 +9,8 @@ import { getRepoInfo, githubErrorMessage, listUserRepos, type GithubRepo } from import type { Actions, PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ locals }) => { - const [repos, reviews] = await Promise.all([listRepos(db()), listReviews(db(), { limit: 500 })]); + const { repos } = await visibleRepos(locals.user!); + const reviews = await listReviews(db(), { limit: 500, repoIds: repos.map((r) => r.id) }); const lastReview = new Map(); for (const r of reviews) if (!lastReview.has(r.repoId)) lastReview.set(r.repoId, r.createdAt); @@ -59,7 +61,8 @@ export const actions: Actions = { } catch (e) { return fail(400, { error: githubErrorMessage(e) }); } - await addRepo(db(), { ...info, createdBy: locals.user!.id }); + if (!info.canWrite) return fail(403, { error: 'Zum Einrichten in Claudio brauchst du Schreibrechte an diesem Repository auf GitHub.' }); + await addRepo(db(), { fullName: info.fullName, isPrivate: info.isPrivate, defaultBranch: info.defaultBranch, createdBy: locals.user!.id }); redirect(303, `/repos/${info.fullName}?tab=settings&new=1`); }, demo: async ({ locals }) => { diff --git a/apps/web/src/routes/repos/[owner]/[name]/+page.server.ts b/apps/web/src/routes/repos/[owner]/[name]/+page.server.ts index 713394e..0e5416d 100644 --- a/apps/web/src/routes/repos/[owner]/[name]/+page.server.ts +++ b/apps/web/src/routes/repos/[owner]/[name]/+page.server.ts @@ -3,27 +3,32 @@ import { z } from 'zod'; import { randomUUID } from 'node:crypto'; import { AnalysisRequestSchema, KNOWLEDGE_DOCS, LoginStepSchema, TestRunRequestSchema, parseHostList } from '@claudio/core'; import { + type AccessLevel, createAnalysis, createTestRuns, createTestScript, deleteRole, dismissedFindingIds, - getRepo, + getUser, latestKnowledge, listAnalyses, listReviews, listRoles, listTestRuns, listTestScripts, - testResultsByReview, removeLesson, removeRepo, - setWebhook, - updateAutomation, + repoAnthropicKey, + setAnthropicKey, roleUsername, saveRole, + setWebhook, + testResultsByReview, + updateAutomation, updateRepoSettings } from '@claudio/db'; +import { atLeast, requireRepo, visibleRepos } from '#lib/server/access.js'; +import { NO_KEY_MESSAGE, checkAnthropicKey, keyOptional } from '#lib/server/anthropic.js'; import { userToken } from '#lib/server/auth.js'; import { db } from '#lib/server/db.js'; import { DEMO_FIXTURE, DEMO_REPO, demoPullRequest } from '#lib/server/demo.js'; @@ -36,14 +41,13 @@ import { githubErrorMessage, listOpenPulls, type GithubPull } from '#lib/server/ import { summarize } from '#lib/server/summary.js'; import type { Actions, PageServerLoad } from './$types'; -async function loadRepo(params: { owner: string; name: string }) { - const repo = await getRepo(db(), `${params.owner}/${params.name}`); - if (!repo) error(404, 'Dieses Repository ist in Claudio nicht eingerichtet.'); - return repo; +/** The repo of this page, if the user has at least `min` access on GitHub (404 / 403 otherwise). */ +async function loadRepo(params: { owner: string; name: string }, user: App.Locals['user'], min: AccessLevel = 'read') { + return (await requireRepo(user, `${params.owner}/${params.name}`, min)).repo; } export const load: PageServerLoad = async ({ params, locals, url }) => { - const repo = await loadRepo(params); + const { repo, level } = await requireRepo(locals.user, `${params.owner}/${params.name}`, 'read'); const [roles, reviews] = await Promise.all([listRoles(db(), repo.id), listReviews(db(), { repoId: repo.id, limit: 100 })]); const [dismissed, reviewTests] = await Promise.all([ dismissedFindingIds(db(), reviews.map((r) => r.id)), @@ -86,19 +90,31 @@ export const load: PageServerLoad = async ({ params, locals, url }) => { ]); // Webhook health, shown in the settings. - let webhook: { id: number; ok: boolean | null; status: string } | null = null; + let webhook: { id: number; ok: boolean | null; status: string; by: string | null } | null = null; if (repo.webhookId) { + const owner = repo.automationUserId ? await getUser(db(), repo.automationUserId) : undefined; const token = await userToken(locals.user!); const info = token && !isDemo ? await getRepoWebhook(createGithubClient(token), repo.fullName, repo.webhookId).catch(() => null) : null; const code = info?.lastResponse?.code ?? null; webhook = { id: repo.webhookId, ok: code === null ? null : code >= 200 && code < 300, - status: info ? (code ? `Letzte Zustellung: HTTP ${code}${info.lastResponse?.message ? ` (${info.lastResponse.message})` : ''}` : 'Noch keine Zustellung') : 'Status nicht abrufbar' + status: info ? (code ? `Letzte Zustellung: HTTP ${code}${info.lastResponse?.message ? ` (${info.lastResponse.message})` : ''}` : 'Noch keine Zustellung') : 'Status nicht abrufbar', + by: owner?.login ?? null }; } const base = publicBaseUrl(url); + // Anthropic key: status, and other repos (where this user is admin) whose key can be copied over. + const keySetBy = repo.anthropicKeySetBy ? await getUser(db(), repo.anthropicKeySetBy) : undefined; + let keySources: { fullName: string; hint: string }[] = []; + if (level === 'admin') { + const { repos: visible, levels } = await visibleRepos(locals.user!); + keySources = visible + .filter((r) => r.id !== repo.id && r.anthropicKeyEnc && atLeast(levels.get(r.id) ?? 'none', 'admin')) + .map((r) => ({ fullName: r.fullName, hint: r.anthropicKeyHint ?? '' })); + } + const tab = url.searchParams.get('tab'); return { repo: { @@ -143,6 +159,11 @@ export const load: PageServerLoad = async ({ params, locals, url }) => { startedBy: a.startedByLogin, createdAt: a.createdAt })), + anthropicKey: repo.anthropicKeyEnc + ? { hint: repo.anthropicKeyHint ?? '', by: keySetBy?.login ?? null, at: repo.anthropicKeySetAt } + : null, + keyOptional: keyOptional(repo), + keySources, automation: repo.automation ?? DEFAULT_AUTOMATION, autoReview: repo.autoReview ?? DEFAULT_AUTO_REVIEW, webhook, @@ -164,6 +185,7 @@ export const load: PageServerLoad = async ({ params, locals, url }) => { batch: batchId ? batchRuns.map((r) => ({ id: r.id, scriptName: r.scriptName, status: r.status, outcome: r.outcome, healed: r.healed, mode: r.mode, costUsd: r.costUsd })) : null, + level, tab: tab === 'settings' || tab === 'reviews' || tab === 'knowledge' || tab === 'tests' ? tab : 'pulls', isNew: url.searchParams.has('new') }; @@ -182,8 +204,8 @@ const RoleForm = z.object({ }); export const actions: Actions = { - settings: async ({ params, request }) => { - const repo = await loadRepo(params); + settings: async ({ params, request, locals }) => { + const repo = await loadRepo(params, locals.user, 'admin'); const f = await request.formData(); const defaultInstanceUrl = text(f.get('defaultInstanceUrl')); if (defaultInstanceUrl && !URL.canParse(defaultInstanceUrl)) return fail(400, { error: 'Die Standard-URL ist keine gültige Adresse.', form: 'settings' }); @@ -197,8 +219,8 @@ export const actions: Actions = { return { saved: 'settings' }; }, - role: async ({ params, request }) => { - const repo = await loadRepo(params); + role: async ({ params, request, locals }) => { + const repo = await loadRepo(params, locals.user, 'admin'); const f = await request.formData(); const parsed = RoleForm.safeParse(Object.fromEntries(['name', 'description', 'username', 'password', 'loginPath', 'successUrlContains', 'recipe'].map((k) => [k, text(f.get(k))]))); if (!parsed.success) return fail(400, { error: parsed.error.issues[0]?.message ?? 'Eingaben prüfen.', form: 'role' }); @@ -227,16 +249,17 @@ export const actions: Actions = { return { saved: `role:${v.name}` }; }, - deleteRole: async ({ params, request }) => { - const repo = await loadRepo(params); + deleteRole: async ({ params, request, locals }) => { + const repo = await loadRepo(params, locals.user, 'admin'); await deleteRole(db(), repo.id, text((await request.formData()).get('name'))); return { saved: 'deleted' }; }, analyze: async ({ params, request, locals }) => { - const repo = await loadRepo(params); + const repo = await loadRepo(params, locals.user, 'write'); const roles = await listRoles(db(), repo.id); if (!repo.dataConfirmed || roles.length === 0) return fail(400, { error: 'Richte zuerst Rollen und die Datenschutz-Bestätigung ein.', form: 'analyze' }); + if (!repo.anthropicKeyEnc && !keyOptional(repo)) return fail(400, { error: NO_KEY_MESSAGE, form: 'analyze' }); if ((await listAnalyses(db(), { repoId: repo.id, activeOnly: true })).length > 0) { return fail(400, { error: 'Für dieses Repository läuft schon eine Analyse.', form: 'analyze' }); } @@ -260,7 +283,7 @@ export const actions: Actions = { }, createTest: async ({ params, request, locals }) => { - const repo = await loadRepo(params); + const repo = await loadRepo(params, locals.user, 'write'); const f = await request.formData(); const input = { name: text(f.get('name')), @@ -279,7 +302,7 @@ export const actions: Actions = { }, runTests: async ({ params, request, locals }) => { - const repo = await loadRepo(params); + const repo = await loadRepo(params, locals.user, 'write'); if (!repo.dataConfirmed) return fail(400, { error: 'Bestätige zuerst in den Einstellungen, dass die Instanz nur Testdaten enthält.', form: 'runTests' }); const f = await request.formData(); const parsed = TestRunRequestSchema.safeParse({ repo: repo.fullName, instanceUrl: text(f.get('instanceUrl')), budget: f.get('budget') }); @@ -294,8 +317,8 @@ export const actions: Actions = { redirect(303, `/repos/${repo.fullName}?tab=tests&batch=${batchId}`); }, - automation: async ({ params, request }) => { - const repo = await loadRepo(params); + automation: async ({ params, request, locals }) => { + const repo = await loadRepo(params, locals.user, 'admin'); const f = await request.formData(); const template = text(f.get('instanceUrlTemplate')); if (template && !URL.canParse(template.replace(/\{[a-z_]+\}/g, 'x'))) { @@ -326,7 +349,7 @@ export const actions: Actions = { }, installWebhook: async ({ params, locals, url }) => { - const repo = await loadRepo(params); + const repo = await loadRepo(params, locals.user, 'admin'); const token = await userToken(locals.user!); if (!token) return fail(400, { error: 'Zum Einrichten brauchst du eine Anmeldung mit GitHub.', form: 'webhook' }); const secret = randomUUID() + randomUUID(); @@ -346,22 +369,48 @@ export const actions: Actions = { }, removeWebhook: async ({ params, locals }) => { - const repo = await loadRepo(params); + const repo = await loadRepo(params, locals.user, 'admin'); const token = await userToken(locals.user!); if (repo.webhookId && token) await deleteRepoWebhook(createGithubClient(token), repo.fullName, repo.webhookId).catch(() => undefined); await setWebhook(db(), repo.id, null); return { saved: 'webhook-removed' }; }, - removeLesson: async ({ params, request }) => { - const repo = await loadRepo(params); + setKey: async ({ params, request, locals }) => { + const repo = await loadRepo(params, locals.user, 'admin'); + const f = await request.formData(); + const source = text(f.get('source')); + let key = text(f.get('key')); + if (source) { + // Copy from another repository, only if the user is admin there too. + const { repo: from } = await requireRepo(locals.user, source, 'admin'); + key = repoAnthropicKey(from) ?? ''; + if (!key) return fail(400, { error: `In ${from.fullName} ist kein Key hinterlegt.`, form: 'key' }); + } + if (!key) return fail(400, { error: 'Trage einen Key ein oder wähle ein Repository, aus dem er übernommen wird.', form: 'key' }); + const check = await checkAnthropicKey(key); + if (!check.ok && !(check.unverified && f.get('force') === 'on')) { + return fail(400, { error: check.reason, unverified: check.unverified ?? false, form: 'key' }); + } + await setAnthropicKey(db(), repo.id, key, locals.user!.id); + return { saved: 'key' }; + }, + + removeKey: async ({ params, locals }) => { + const repo = await loadRepo(params, locals.user, 'admin'); + await setAnthropicKey(db(), repo.id, null, locals.user!.id); + return { saved: 'key-removed' }; + }, + + removeLesson: async ({ params, request, locals }) => { + const repo = await loadRepo(params, locals.user, 'admin'); const f = await request.formData(); await removeLesson(db(), repo.id, { reviewId: Number(f.get('reviewId')), findingId: text(f.get('findingId')) }); return { saved: 'lesson' }; }, - remove: async ({ params }) => { - const repo = await loadRepo(params); + remove: async ({ params, locals }) => { + const repo = await loadRepo(params, locals.user, 'admin'); const [reviews, analyses] = await Promise.all([listReviews(db(), { repoId: repo.id, limit: 1 }), listAnalyses(db(), { repoId: repo.id, limit: 1 })]); if (reviews.length > 0 || analyses.length > 0) { return fail(400, { error: 'Repositories mit Reviews oder Analysen bleiben erhalten, damit Prüfprotokolle und Wissensbasis nicht verloren gehen.', form: 'remove' }); diff --git a/apps/web/src/routes/repos/[owner]/[name]/+page.svelte b/apps/web/src/routes/repos/[owner]/[name]/+page.svelte index 553b456..9a26b13 100644 --- a/apps/web/src/routes/repos/[owner]/[name]/+page.svelte +++ b/apps/web/src/routes/repos/[owner]/[name]/+page.svelte @@ -28,7 +28,11 @@ { id: 'settings', label: 'Einstellungen' } ] as const; - const ready = $derived(data.repo.dataConfirmed && data.roles.length > 0); + const hasKey = $derived(Boolean(data.anthropicKey) || data.keyOptional); + const ready = $derived(data.repo.dataConfirmed && data.roles.length > 0 && hasKey); + // What the user may do here follows their permission on GitHub. + const canWrite = $derived(data.level === 'write' || data.level === 'admin'); + const isAdmin = $derived(data.level === 'admin'); const saved = $derived(form && 'saved' in form ? form.saved : null); const runningAnalysis = $derived(data.analyses.find((a) => isActive(a.status))); let analyzing = $state(false); @@ -49,7 +53,7 @@

{data.repo.fullName}

{#if data.tab === 'pulls'} - {#if !ready} + {#if !canWrite} +

Du hast an diesem Repository auf GitHub nur Lesezugriff. Berichte, Tests und Wissen kannst du ansehen, aber nichts starten.

+ {:else if !ready}

- Bevor Claudio Pull Requests prüfen kann, fehlen noch - {#if !data.repo.dataConfirmed && data.roles.length === 0}die Datenschutz-Bestätigung und mindestens eine Rolle{:else if !data.repo.dataConfirmed}die Datenschutz-Bestätigung{:else}Zugangsdaten für mindestens eine Rolle{/if}. - Einstellungen öffnen + Bevor Claudio Pull Requests prüfen kann, fehlt noch: + {[!hasKey && 'ein Anthropic-API-Key', !data.repo.dataConfirmed && 'die Bestätigung, dass die Instanzen nur Testdaten enthalten', data.roles.length === 0 && 'mindestens eine Rolle mit Zugangsdaten'].filter(Boolean).join(', ')}. + {#if isAdmin}Einstellungen öffnen{:else}Das kann ein Admin des Repositorys in den Einstellungen eintragen.{/if}

{/if} {#if data.pullsError} @@ -84,7 +90,7 @@ {#if pr.fixture}Demo-Fixture{/if} - {#if ready} + {#if ready && canWrite} Review starten {/if} @@ -153,10 +159,10 @@ {/if} - {#if data.tests.length && !data.activeTestRuns} + {#if data.tests.length && !data.activeTestRuns && canWrite}
Alle Tests ausführen -
(runningTests = true)}> + (runningTests = true)}> {#if formError('runTests')}{/if}