From 1907b906ab7d8596f14f9ceab1383738fdb06b2d Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:38:52 +0200 Subject: [PATCH 1/2] feat(lab): CL-04 CLI and management read surfaces --- .../001_pr_stack_status.md | 23 +- .../004_cl04_read_surfaces.md | 56 +++ src/cli/codex-shim-autorestore.ts | 1 + src/cli/help.ts | 15 + src/cli/index.ts | 5 + src/cli/lab.ts | 268 +++++++++++ src/lab/index.ts | 1 + src/lab/query/catalog.ts | 72 +++ src/lab/query/connection.ts | 77 +++ src/lab/query/constants.ts | 4 + src/lab/query/cursor.ts | 132 +++++ src/lab/query/dto-map.ts | 277 +++++++++++ src/lab/query/errors.ts | 22 + src/lab/query/index.ts | 34 ++ src/lab/query/queries.ts | 454 ++++++++++++++++++ src/lab/query/types.ts | 266 ++++++++++ src/server/management-api.ts | 2 + src/server/management/lab-routes.ts | 377 +++++++++++++++ tests/lab-read-surfaces.test.ts | 361 ++++++++++++++ 19 files changed, 2443 insertions(+), 4 deletions(-) create mode 100644 devlog/_plan/260807_compatibility_lab/004_cl04_read_surfaces.md create mode 100644 src/cli/lab.ts create mode 100644 src/lab/query/catalog.ts create mode 100644 src/lab/query/connection.ts create mode 100644 src/lab/query/constants.ts create mode 100644 src/lab/query/cursor.ts create mode 100644 src/lab/query/dto-map.ts create mode 100644 src/lab/query/errors.ts create mode 100644 src/lab/query/index.ts create mode 100644 src/lab/query/queries.ts create mode 100644 src/lab/query/types.ts create mode 100644 src/server/management/lab-routes.ts create mode 100644 tests/lab-read-surfaces.test.ts diff --git a/devlog/_plan/260807_compatibility_lab/001_pr_stack_status.md b/devlog/_plan/260807_compatibility_lab/001_pr_stack_status.md index 1ce0942d00..50b6f1f2e2 100644 --- a/devlog/_plan/260807_compatibility_lab/001_pr_stack_status.md +++ b/devlog/_plan/260807_compatibility_lab/001_pr_stack_status.md @@ -22,7 +22,8 @@ independent review, blockers, and whether a later phase is authorized. | CL-00 | `feat/cl-00-compatibility-contracts` | `3ad5bb6bd3f76f6879d84b78ea39edd3e01ec296` | `c014464237fd3c95bda08bc18bfab8ba8f532308` | [#1286](https://github.com/lidge-jun/opencodex/pull/1286) | ACCEPTED AFTER CODERABBIT REMEDIATION (merged to `dev` at `243c3f4905797aa11c62ba933bb03d6d721266fd`) | | CL-01 | `feat/cl-01-conformance-harness` | `c2113ca47b8a05c5a5f90679e4eaa640ca2c6a66` | `22d608c82d82e2746c0cef9cd761db19a8e465ee` | [#1320](https://github.com/lidge-jun/opencodex/pull/1320) | MERGED TO `dev` at `4bb249b756abd468c675d2d92fffe4da95ad3e2a` | | CL-02 | `feat/cl-02-evidence-ledger` | `4bb249b756abd468c675d2d92fffe4da95ad3e2a` | NOT RECORDED | [#1333](https://github.com/lidge-jun/opencodex/pull/1333) | MERGED TO `dev` at `025c37916225dd685d9217e5b40190600f06d278`; POST-MERGE HARDENING [#1343](https://github.com/lidge-jun/opencodex/pull/1343) MERGED at `eee2dab4d1bbacefce56057adad51d734f346702`; FINAL CLOSURE GATE [#1348](https://github.com/lidge-jun/opencodex/pull/1348) | -| CL-03 | `feat/cl-03-live-route-probes` | `4f746d13799888ea0a8c7a111aa2ad61c2126ea0` | `003f7402f49bfe8dd710a7beba52f717051bfadf` | [#1352](https://github.com/lidge-jun/opencodex/pull/1352) | DRAFT PR OPEN (implementation; not accepted) | +| CL-03 | `feat/cl-03-live-route-probes` | `4f746d13799888ea0a8c7a111aa2ad61c2126ea0` | `003f7402f49bfe8dd710a7beba52f717051bfadf` | [#1352](https://github.com/lidge-jun/opencodex/pull/1352) | MERGED TO `dev` at `68c71a4e9cdf882d812f09fd94783a28749db629`; ACCEPTED/CLOSED | +| CL-04 | `feat/cl-04-lab-read-surfaces` | `68c71a4e9cdf882d812f09fd94783a28749db629` | NOT RECORDED | DRAFT PENDING | IMPLEMENTATION IN PROGRESS (not accepted) | The CL-01 starting SHA is the exact CL-00 tip recorded when CL-01 began. Its moving base-ref name is not a substitute for that historical SHA. @@ -187,6 +188,20 @@ Claims cannot produce `PROBED`/`VERIFIED`. ### CL-03 blockers -- Independent acceptance review not performed -- Draft PR review findings not yet reconciled -- Full local ledger suite not green on Windows host (pre-existing SQLite EPERM) +- ~~Independent acceptance review not performed~~ — reconciled at merge #1352 +- ~~Draft PR review findings not yet reconciled~~ — CodeRabbit/review findings addressed pre-merge +- Full local ledger suite may show pre-existing Windows SQLite `EPERM` flakes (`rebuild.ts` unchanged vs base) + +## CL-03 merge log (2026-08-09) + +- **Merged to `dev`:** `68c71a4e9cdf882d812f09fd94783a28749db629` via upstream [#1352](https://github.com/lidge-jun/opencodex/pull/1352) +- **Final required CI:** green at merge (cross-platform) +- **CodeRabbit/review:** findings reconciled pre-merge +- **CL-03 state:** accepted/closed; CL-04 authorized from current `dev` + +## CL-04 start log (2026-08-09) + +- **Starting `upstream/dev` SHA:** `68c71a4e9cdf882d812f09fd94783a28749db629` +- **Branch:** `feat/cl-04-lab-read-surfaces` +- **Scope:** read-only CLI (`ocx lab`), authenticated `GET /api/lab/*`, shared `src/lab/query/` layer +- **CL-05:** not started diff --git a/devlog/_plan/260807_compatibility_lab/004_cl04_read_surfaces.md b/devlog/_plan/260807_compatibility_lab/004_cl04_read_surfaces.md new file mode 100644 index 0000000000..e527bbbe3c --- /dev/null +++ b/devlog/_plan/260807_compatibility_lab/004_cl04_read_surfaces.md @@ -0,0 +1,56 @@ +# CL-04 implementation record — Lab CLI and management read surfaces + +## Programme position + +- **Phase:** CL-04 (read-surface only) +- **Starting `upstream/dev` SHA:** `68c71a4e9cdf882d812f09fd94783a28749db629` (merge #1352) +- **Branch:** `feat/cl-04-lab-read-surfaces` +- **PR target:** `lidge-jun/opencodex:dev` (draft from `Wibias/opencodex`) +- **CL-05:** not started + +## Scope delivered + +### Shared query layer (`src/lab/query/`) + +- Read-only SQLite open via `openLabReadConnection()`; never creates DB or rebuilds projection +- Schema/spec validation (`LAB_SQLITE_SCHEMA_VERSION`, `LAB_PROJECTION_SPEC_VERSION`) +- Explicit `lab_projection_unavailable` / `lab_projection_incompatible` errors +- Parameterized SQL, deterministic keyset cursors, bounded pagination (default 50, max 100) +- Privacy-safe DTO mapping (`dto-map.ts`) — no raw `payload_json` or artifact bytes +- Catalogue from packaged CL-01/CL-03 authorities (`catalog.ts`) + +### Management API (`src/server/management/lab-routes.ts`) + +- `GET /api/lab/status` +- `GET /api/lab/verdicts` +- `GET /api/lab/subjects`, `GET /api/lab/subjects/:subjectId` +- `GET /api/lab/observations` +- `GET /api/lab/events`, `GET /api/lab/events/:eventId` +- `GET /api/lab/artifacts`, `GET /api/lab/artifacts/:digest` +- `GET /api/lab/catalog` + +### CLI (`src/cli/lab.ts`) + +- `ocx lab status|verdicts|subjects|subject|observations|events|event|artifacts|artifact|catalog` +- `--json` machine output; skips Codex shim autorestore; no daemon/network + +## Validation (local) + +- `bun x tsc --noEmit` +- `bun test tests/lab-read-surfaces.test.ts` +- `bun test tests/lab-conformance-harness.test.ts` +- `bun test tests/lab-evidence-ledger.test.ts` +- `bun test tests/lab-live-probe.test.ts` +- `bun test tests/lab-live-sandbox.test.ts` +- `bun run privacy:scan` + +## Acceptance blockers + +- Draft PR not yet opened / CI not yet green on branch +- Independent acceptance review not performed +- CodeRabbit/reviewer findings on CL-04 PR not yet reconciled + +## Out of scope (confirmed) + +- CL-05 GUI, CL-06 routing profile fields, CL-07 fabric, CL-08 probes/public publish +- Automatic projection rebuilds, probe execution, raw artifact download APIs diff --git a/src/cli/codex-shim-autorestore.ts b/src/cli/codex-shim-autorestore.ts index e6cf3ffd63..819fa61d9b 100644 --- a/src/cli/codex-shim-autorestore.ts +++ b/src/cli/codex-shim-autorestore.ts @@ -17,6 +17,7 @@ const DEFAULT_DEPS: CodexShimAutoRestoreCliDeps = { export function skipsCodexShimAutoRestore(command: string | undefined, args: string[]): boolean { if (command === "uninstall" || command === "remove") return true; + if (command === "lab") return true; return command === "codex-shim" && ["install", "uninstall", "remove"].includes(args[1] ?? ""); } diff --git a/src/cli/help.ts b/src/cli/help.ts index 4737f9515a..c44e0b2b1f 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -264,6 +264,20 @@ const helpEntries: Record = { "Invalid or unknown arguments exit 64. Not-ready, pending, failed, timeout, and unreachable exit 1.", ], }, + lab: { + usage: "ocx lab [options] [--json]", + summary: "Read-only Compatibility Lab projection inspection (local SQLite; no daemon).", + details: [ + "status Projection availability, schema versions, and row counts.", + "verdicts Paginated derived compatibility verdicts with filters.", + "subjects List subjects; subject returns one typed subject.", + "observations Paginated observation rows from the projection.", + "events Event history; event returns one safe typed event.", + "artifacts Artifact metadata only (no content download).", + "catalog Packaged protocol/live scenario catalogue metadata.", + "Reads never rebuild the projection, trigger probes, or require the proxy.", + ], + }, }; function packageVersion(): string { @@ -318,6 +332,7 @@ Usage: ocx grok Grok Build model selection and apply ocx system Runtime settings, startup, sync, and updates ocx config Validated configuration show/get/set/import/export + ocx lab Read-only Compatibility Lab projection inspection ocx claude [args...] Launch Claude Code wired to the proxy (model discovery on) ocx claude desktop [sub] Manage and apply Claude Desktop's four-family profile ocx opencode [args...] Launch opencode wired to the proxy (runtime provider config) diff --git a/src/cli/index.ts b/src/cli/index.ts index f86c5eb7e9..35da313d77 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1367,6 +1367,11 @@ switch (command) { process.exitCode = await handleConfigCommand(args.slice(1)); break; } + case "lab": { + const { handleLabCommand } = await import("./lab"); + process.exitCode = await handleLabCommand(args.slice(1)); + break; + } case "claude": { const { cmdClaude } = await import("./claude"); // "ocx claude desktop" → write Desktop 3P config diff --git a/src/cli/lab.ts b/src/cli/lab.ts new file mode 100644 index 0000000000..ef04e27410 --- /dev/null +++ b/src/cli/lab.ts @@ -0,0 +1,268 @@ +/** + * `ocx lab` — read-only Compatibility Lab inspection (CL-04). + * + * Local SQLite projection reads; no daemon, network, probes, or rebuilds. + */ +import { getConfigDir } from "../config"; +import type { + ArtifactClass, + CompatibilityVerdict, + EvidenceLayer, + ExecutionMode, + LabEventKind, + ObservationOutcome, +} from "../lab/constants"; +import { + InvalidCursorError, + LabProjectionIncompatibleError, + LabProjectionUnavailableError, + queryLabArtifactByDigest, + queryLabArtifacts, + queryLabCatalogEntries, + queryLabEventById, + queryLabEvents, + queryLabObservations, + queryLabStatus, + queryLabSubjectById, + queryLabSubjects, + queryLabVerdicts, +} from "../lab/query"; +import { + CliUsageError, + printData, + rejectArgs, + runCliAction, + takeFlag, + takeIntegerOption, + takeOption, +} from "./runtime-api"; + +const USAGE = `Usage: + ocx lab status [--json] + ocx lab verdicts [--subject ] [--layer ] [--suite ] [--verdict ] [--from ] [--to ] [--limit ] [--cursor ] [--json] + ocx lab subjects [--kind ] [--limit ] [--cursor ] [--json] + ocx lab subject [--json] + ocx lab observations [--subject ] [--layer ] [--suite ] [--scenario ] [--outcome ] [--execution-mode ] [--from ] [--to ] [--limit ] [--cursor ] [--json] + ocx lab events [--event-kind ] [--subject ] [--from ] [--to ] [--excluded ] [--limit ] [--cursor ] [--json] + ocx lab event [--json] + ocx lab artifacts [--status ] [--artifact-class ] [--limit ] [--cursor ] [--json] + ocx lab artifact [--json] + ocx lab catalog [--layer ] [--suite ] [--json]`; + +export interface LabCliDeps { + configDir?: string; +} + +function labErrorMessage(err: unknown): string { + if (err instanceof LabProjectionUnavailableError) return "lab projection is not available"; + if (err instanceof LabProjectionIncompatibleError) return "lab projection schema or spec version is incompatible"; + if (err instanceof InvalidCursorError) return "invalid cursor"; + return "lab read failed"; +} + +function statusSummary(status: ReturnType): string[] { + if (!status.projectionAvailable) { + if (status.projectionIncompatible) return ["Lab projection: incompatible"]; + return ["Lab projection: unavailable"]; + } + return [ + "Lab projection: available", + `SQLite schema: ${status.sqliteSchemaVersion}`, + `Projection spec: ${status.projectionSpecVersion}`, + `Built at: ${status.builtAtMs}`, + `Events: ${status.eventCount} | Subjects: ${status.subjectCount} | Observations: ${status.observationCount}`, + `Claims: ${status.claimCount} | Verdicts: ${status.verdictCount} | Artifacts: ${status.artifactCount}`, + `Corruption rows: ${status.corruptionCount}`, + ]; +} + +function verdictLines(page: Awaited>): string[] { + const lines = page.items.map((v) => + `${v.verdict} ${v.evidenceLayer} ${v.suiteId} subject=${v.subjectId} asOf=${v.asOf}`, + ); + if (page.hasMore) lines.push(`(more available; pass --cursor ${page.nextCursor ?? ""})`); + return lines.length > 0 ? lines : ["No verdicts"]; +} + +function subjectListLines(page: Awaited>): string[] { + const lines = page.items.map((s) => `${s.subjectId} (${s.subjectKind})`); + if (page.hasMore) lines.push(`(more available; pass --cursor ${page.nextCursor ?? ""})`); + return lines.length > 0 ? lines : ["No subjects"]; +} + +function observationLines(page: Awaited>): string[] { + const lines = page.items.map((o) => + `${o.outcome} ${o.evidenceLayer} ${o.scenarioId} event=${o.eventId} completed=${o.completedAt}`, + ); + if (page.hasMore) lines.push(`(more available; pass --cursor ${page.nextCursor ?? ""})`); + return lines.length > 0 ? lines : ["No observations"]; +} + +function eventListLines(page: Awaited>): string[] { + const lines = page.items.map((e) => + `${e.eventKind} ${e.eventId} recorded=${e.recordedAt}${e.excluded ? " excluded" : ""}`, + ); + if (page.hasMore) lines.push(`(more available; pass --cursor ${page.nextCursor ?? ""})`); + return lines.length > 0 ? lines : ["No events"]; +} + +function artifactLines(page: Awaited>): string[] { + const lines = page.items.map((a) => `${a.status} ${a.digest} class=${a.artifactClass ?? "unknown"}`); + if (page.hasMore) lines.push(`(more available; pass --cursor ${page.nextCursor ?? ""})`); + return lines.length > 0 ? lines : ["No artifacts"]; +} + +function catalogLines(scenarios: ReturnType): string[] { + const lines = scenarios.map((s) => + `${s.evidenceLayer} ${s.suiteId} ${s.scenarioId} digest=${s.scenarioManifestDigest.slice(0, 12)}…`, + ); + return lines.length > 0 ? lines : ["No catalogue scenarios"]; +} + +export async function handleLabCommand(argv: string[], deps: LabCliDeps = {}): Promise { + return runCliAction(async () => { + const configDir = deps.configDir ?? getConfigDir(); + const [sub = "status", ...rest] = argv; + const wantsJson = takeFlag(rest, "--json"); + + try { + switch (sub) { + case "status": { + rejectArgs(rest, USAGE); + const status = queryLabStatus(configDir); + printData(status, wantsJson, statusSummary(status)); + return; + } + case "verdicts": { + const subjectId = takeOption(rest, "--subject"); + const layer = takeOption(rest, "--layer") as EvidenceLayer | undefined; + const suiteId = takeOption(rest, "--suite"); + const verdict = takeOption(rest, "--verdict") as CompatibilityVerdict | undefined; + const from = takeIntegerOption(rest, "--from", { min: 0 }); + const to = takeIntegerOption(rest, "--to", { min: 0 }); + const limit = takeIntegerOption(rest, "--limit", { min: 1 }); + const cursor = takeOption(rest, "--cursor"); + rejectArgs(rest, USAGE); + const page = queryLabVerdicts({ + subjectId, + layer, + suiteId, + verdict, + from, + to, + }, cursor, limit, configDir); + printData(page, wantsJson, verdictLines(page)); + return; + } + case "subjects": { + const kind = takeOption(rest, "--kind"); + const limit = takeIntegerOption(rest, "--limit", { min: 1 }); + const cursor = takeOption(rest, "--cursor"); + rejectArgs(rest, USAGE); + const page = queryLabSubjects(kind, cursor, limit, configDir); + printData(page, wantsJson, subjectListLines(page)); + return; + } + case "subject": { + const subjectId = rest[0]; + if (!subjectId) throw new CliUsageError("subject id required", USAGE); + rest.splice(0, 1); + rejectArgs(rest, USAGE); + const subject = queryLabSubjectById(subjectId, configDir); + if (!subject) throw new CliUsageError("unknown subject", USAGE); + printData({ subjectId, subject }, wantsJson, [`Subject ${subjectId}`]); + return; + } + case "observations": { + const subjectId = takeOption(rest, "--subject"); + const layer = takeOption(rest, "--layer") as EvidenceLayer | undefined; + const suiteId = takeOption(rest, "--suite"); + const scenarioId = takeOption(rest, "--scenario"); + const outcome = takeOption(rest, "--outcome") as ObservationOutcome | undefined; + const executionMode = takeOption(rest, "--execution-mode") as ExecutionMode | undefined; + const from = takeIntegerOption(rest, "--from", { min: 0 }); + const to = takeIntegerOption(rest, "--to", { min: 0 }); + const limit = takeIntegerOption(rest, "--limit", { min: 1 }); + const cursor = takeOption(rest, "--cursor"); + rejectArgs(rest, USAGE); + const page = queryLabObservations({ + subjectId, + layer, + suiteId, + scenarioId, + outcome, + executionMode, + from, + to, + }, cursor, limit, configDir); + printData(page, wantsJson, observationLines(page)); + return; + } + case "events": { + const eventKind = takeOption(rest, "--event-kind") as LabEventKind | undefined; + const subjectId = takeOption(rest, "--subject"); + const from = takeIntegerOption(rest, "--from", { min: 0 }); + const to = takeIntegerOption(rest, "--to", { min: 0 }); + const excludedRaw = takeOption(rest, "--excluded"); + const limit = takeIntegerOption(rest, "--limit", { min: 1 }); + const cursor = takeOption(rest, "--cursor"); + rejectArgs(rest, USAGE); + const page = queryLabEvents({ + eventKind, + subjectId, + from, + to, + excluded: excludedRaw === "true" ? true : excludedRaw === "false" ? false : undefined, + }, cursor, limit, configDir); + printData(page, wantsJson, eventListLines(page)); + return; + } + case "event": { + const eventId = rest[0]; + if (!eventId) throw new CliUsageError("event id required", USAGE); + rest.splice(0, 1); + rejectArgs(rest, USAGE); + const event = queryLabEventById(eventId, configDir); + if (!event) throw new CliUsageError("unknown event", USAGE); + printData({ event }, wantsJson, [`Event ${eventId}`]); + return; + } + case "artifacts": { + const status = takeOption(rest, "--status") as "present" | "corrupt" | "purged_unavailable" | undefined; + const artifactClass = takeOption(rest, "--artifact-class") as ArtifactClass | undefined; + const limit = takeIntegerOption(rest, "--limit", { min: 1 }); + const cursor = takeOption(rest, "--cursor"); + rejectArgs(rest, USAGE); + const page = queryLabArtifacts({ status, artifactClass }, cursor, limit, configDir); + printData(page, wantsJson, artifactLines(page)); + return; + } + case "artifact": { + const digest = rest[0]; + if (!digest) throw new CliUsageError("artifact digest required", USAGE); + rest.splice(0, 1); + rejectArgs(rest, USAGE); + const artifact = queryLabArtifactByDigest(digest, configDir); + if (!artifact) throw new CliUsageError("unknown artifact", USAGE); + printData({ artifact }, wantsJson, [`Artifact ${digest}`]); + return; + } + case "catalog": { + const layer = takeOption(rest, "--layer") as EvidenceLayer | undefined; + const suiteId = takeOption(rest, "--suite"); + rejectArgs(rest, USAGE); + const scenarios = queryLabCatalogEntries({ layer, suiteId }); + printData({ scenarios }, wantsJson, catalogLines(scenarios)); + return; + } + default: + throw new CliUsageError(`unknown lab subcommand: ${sub}`, USAGE); + } + } catch (err) { + if (err instanceof CliUsageError) throw err; + throw new CliUsageError(labErrorMessage(err), USAGE); + } + }); +} + +export const LAB_USAGE = USAGE; diff --git a/src/lab/index.ts b/src/lab/index.ts index bbce80e516..b410b147ff 100644 --- a/src/lab/index.ts +++ b/src/lab/index.ts @@ -33,3 +33,4 @@ export * from "./subject/route-subject"; export * from "./subject/behavior-fingerprint"; export * from "./subject/installation-salt"; export { CL03_LIVE_SUITES } from "./conformance/types"; +export * from "./query"; diff --git a/src/lab/query/catalog.ts b/src/lab/query/catalog.ts new file mode 100644 index 0000000000..876f9c4199 --- /dev/null +++ b/src/lab/query/catalog.ts @@ -0,0 +1,72 @@ +import { + discoverLiveScenarios, + expandLiveScenario, + loadLiveCaseAuthority, +} from "../live/manifest"; +import { + liveSuiteManifestDigestForCase, +} from "../live/suite-manifest"; +import { + discoverScenarios, + expandScenario, + loadCaseAuthority, +} from "../conformance/manifest"; +import { + suiteManifestDigestForCase, +} from "../conformance/suite-manifest"; +import { scenarioManifestDigest } from "../digest"; +import type { CaseRecord } from "../conformance/types"; +import type { CatalogFilters, CatalogScenarioDto } from "./types"; + +function mapCaseToCatalogScenario( + caseRecord: CaseRecord, + authority: ReturnType, + suiteDigestFn: (caseRecord: CaseRecord, authority: ReturnType) => string, + expandFn: (caseRecord: CaseRecord, authority: ReturnType) => Record, +): CatalogScenarioDto { + const expanded = expandFn(caseRecord, authority); + return { + scenarioId: caseRecord.id, + scenarioVersion: authority.manifestDefaults.version, + evidenceLayer: authority.manifestDefaults.evidenceLayer, + suiteId: caseRecord.suite, + suiteVersion: authority.manifestDefaults.suiteVersion, + capability: caseRecord.capability, + verificationRole: caseRecord.verificationRole ?? authority.manifestDefaults.verificationRole, + requirements: { + inboundProtocols: [...caseRecord.requirements.inboundProtocols], + upstreamProtocols: [...caseRecord.requirements.upstreamProtocols], + surfaces: [...caseRecord.requirements.surfaces], + }, + freshness: { ...authority.manifestDefaults.freshness }, + scenarioManifestDigest: scenarioManifestDigest(expanded), + suiteManifestDigest: suiteDigestFn(caseRecord, authority), + }; +} + +export function queryLabCatalog(filters: CatalogFilters = {}): CatalogScenarioDto[] { + const items: CatalogScenarioDto[] = []; + if (!filters.layer || filters.layer === "protocol_conformance") { + const authority = loadCaseAuthority(); + const suites = filters.suiteId ? [filters.suiteId] : undefined; + const scenarios = discoverScenarios(authority, suites ?? undefined); + for (const caseRecord of scenarios) { + items.push(mapCaseToCatalogScenario(caseRecord, authority, suiteManifestDigestForCase, expandScenario)); + } + } + if (!filters.layer || filters.layer === "live_route_compatibility") { + const authority = loadLiveCaseAuthority(); + const suites = filters.suiteId ? [filters.suiteId] : undefined; + const scenarios = discoverLiveScenarios(authority, suites ?? undefined); + for (const caseRecord of scenarios) { + items.push(mapCaseToCatalogScenario(caseRecord, authority, liveSuiteManifestDigestForCase, expandLiveScenario)); + } + } + return items.sort((a, b) => { + const layerCmp = a.evidenceLayer.localeCompare(b.evidenceLayer); + if (layerCmp !== 0) return layerCmp; + const suiteCmp = a.suiteId.localeCompare(b.suiteId); + if (suiteCmp !== 0) return suiteCmp; + return a.scenarioId.localeCompare(b.scenarioId); + }); +} diff --git a/src/lab/query/connection.ts b/src/lab/query/connection.ts new file mode 100644 index 0000000000..4f9465d28e --- /dev/null +++ b/src/lab/query/connection.ts @@ -0,0 +1,77 @@ +import { Database } from "bun:sqlite"; +import { existsSync } from "node:fs"; +import { LAB_PROJECTION_SPEC_VERSION } from "../constants"; +import { labSqlitePath } from "../paths"; +import { LAB_SQLITE_SCHEMA_VERSION } from "../projection/schema"; +import { LabProjectionIncompatibleError, LabProjectionUnavailableError } from "./errors"; + +export interface LabReadConnection { + db: Database; + sqlitePath: string; + schemaVersion: number; + projectionSpecVersion: string; + builtAtMs: number; +} + +export function resolveLabSqlitePath(configDir?: string): string { + return labSqlitePath(configDir); +} + +export function openLabReadConnection(configDir?: string): LabReadConnection { + const sqlitePath = resolveLabSqlitePath(configDir); + if (!existsSync(sqlitePath)) { + throw new LabProjectionUnavailableError(); + } + const db = new Database(sqlitePath, { readonly: true }); + try { + const metaRows = db + .query("SELECT key, value FROM schema_meta") + .all() as Array<{ key: string; value: string }>; + const meta = new Map(metaRows.map((r) => [r.key, r.value])); + const schemaRaw = meta.get("schema_version"); + const specRaw = meta.get("projection_spec_version"); + const builtRaw = meta.get("built_at_ms"); + if (!schemaRaw || !specRaw || !builtRaw) { + db.close(); + throw new LabProjectionUnavailableError(); + } + const schemaVersion = Number(schemaRaw); + const projectionSpecVersion = specRaw; + const builtAtMs = Number(builtRaw); + if ( + !Number.isInteger(schemaVersion) || + schemaVersion !== LAB_SQLITE_SCHEMA_VERSION || + projectionSpecVersion !== LAB_PROJECTION_SPEC_VERSION || + !Number.isFinite(builtAtMs) + ) { + db.close(); + throw new LabProjectionIncompatibleError(); + } + return { + db, + sqlitePath, + schemaVersion, + projectionSpecVersion, + builtAtMs, + }; + } catch (err) { + try { + db.close(); + } catch { + /* ignore */ + } + if (err instanceof LabProjectionUnavailableError || err instanceof LabProjectionIncompatibleError) { + throw err; + } + throw new LabProjectionUnavailableError(); + } +} + +export function closeLabReadConnection(conn: LabReadConnection): void { + conn.db.close(); +} + +export function countTable(conn: LabReadConnection, table: string): number { + const row = conn.db.query(`SELECT COUNT(*) AS c FROM ${table}`).get() as { c: number }; + return Number(row.c); +} diff --git a/src/lab/query/constants.ts b/src/lab/query/constants.ts new file mode 100644 index 0000000000..88b439b515 --- /dev/null +++ b/src/lab/query/constants.ts @@ -0,0 +1,4 @@ +export const LAB_QUERY_DEFAULT_PAGE_SIZE = 50; +export const LAB_QUERY_MAX_PAGE_SIZE = 100; +export const LAB_QUERY_CURSOR_VERSION = 1; +export const LAB_QUERY_MAX_CURSOR_BYTES = 4096; diff --git a/src/lab/query/cursor.ts b/src/lab/query/cursor.ts new file mode 100644 index 0000000000..cbcdde0362 --- /dev/null +++ b/src/lab/query/cursor.ts @@ -0,0 +1,132 @@ +import { LAB_QUERY_CURSOR_VERSION, LAB_QUERY_MAX_CURSOR_BYTES } from "./constants"; +import { InvalidCursorError } from "./errors"; + +export type VerdictCursorPayload = { + v: typeof LAB_QUERY_CURSOR_VERSION; + k: "verdicts"; + a: number; + p: string; + f: string; +}; + +export type ObservationCursorPayload = { + v: typeof LAB_QUERY_CURSOR_VERSION; + k: "observations"; + c: number; + e: string; + f: string; +}; + +export type EventCursorPayload = { + v: typeof LAB_QUERY_CURSOR_VERSION; + k: "events"; + r: number; + e: string; + f: string; +}; + +export type SubjectCursorPayload = { + v: typeof LAB_QUERY_CURSOR_VERSION; + k: "subjects"; + s: string; + f: string; +}; + +export type ArtifactCursorPayload = { + v: typeof LAB_QUERY_CURSOR_VERSION; + k: "artifacts"; + d: string; + f: string; +}; + +export type LabCursorPayload = + | VerdictCursorPayload + | ObservationCursorPayload + | EventCursorPayload + | SubjectCursorPayload + | ArtifactCursorPayload; + +function stableFilterKey(filters: Record): string { + const entries = Object.entries(filters) + .filter(([, v]) => v !== undefined && v !== null) + .sort(([a], [b]) => a.localeCompare(b)); + return JSON.stringify(entries); +} + +export function encodeLabCursor(payload: LabCursorPayload): string { + return Buffer.from(JSON.stringify(payload)).toString("base64url"); +} + +export function decodeLabCursor(raw: string | null | undefined): LabCursorPayload | null { + if (typeof raw !== "string" || raw.length === 0 || raw.length > LAB_QUERY_MAX_CURSOR_BYTES) return null; + try { + const parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8")) as Record; + if (parsed.v !== LAB_QUERY_CURSOR_VERSION || typeof parsed.k !== "string") return null; + if (parsed.k === "verdicts") { + if (typeof parsed.a !== "number" || !Number.isFinite(parsed.a)) return null; + if (typeof parsed.p !== "string" || parsed.p.length === 0 || parsed.p.length > 256) return null; + if (typeof parsed.f !== "string") return null; + return parsed as VerdictCursorPayload; + } + if (parsed.k === "observations") { + if (typeof parsed.c !== "number" || !Number.isFinite(parsed.c)) return null; + if (typeof parsed.e !== "string" || parsed.e.length === 0 || parsed.e.length > 256) return null; + if (typeof parsed.f !== "string") return null; + return parsed as ObservationCursorPayload; + } + if (parsed.k === "events") { + if (typeof parsed.r !== "number" || !Number.isFinite(parsed.r)) return null; + if (typeof parsed.e !== "string" || parsed.e.length === 0 || parsed.e.length > 256) return null; + if (typeof parsed.f !== "string") return null; + return parsed as EventCursorPayload; + } + if (parsed.k === "subjects") { + if (typeof parsed.s !== "string" || parsed.s.length === 0 || parsed.s.length > 256) return null; + if (typeof parsed.f !== "string") return null; + return parsed as SubjectCursorPayload; + } + if (parsed.k === "artifacts") { + if (typeof parsed.d !== "string" || parsed.d.length === 0 || parsed.d.length > 128) return null; + if (typeof parsed.f !== "string") return null; + return parsed as ArtifactCursorPayload; + } + return null; + } catch { + return null; + } +} + +export function assertCursorFilters( + cursor: LabCursorPayload, + filters: Record, + kind: LabCursorPayload["k"], +): void { + if (cursor.k !== kind) throw new InvalidCursorError(); + const expected = stableFilterKey(filters); + const actual = (cursor as { f: string }).f; + if (actual !== expected) throw new InvalidCursorError(); +} + +export function filterKeyFor(filters: Record): string { + return stableFilterKey(filters); +} + +export function verdictCursor(asOf: number, projectionKey: string, filters: Record): VerdictCursorPayload { + return { v: LAB_QUERY_CURSOR_VERSION, k: "verdicts", a: asOf, p: projectionKey, f: stableFilterKey(filters) }; +} + +export function observationCursor(completedAt: number, eventId: string, filters: Record): ObservationCursorPayload { + return { v: LAB_QUERY_CURSOR_VERSION, k: "observations", c: completedAt, e: eventId, f: stableFilterKey(filters) }; +} + +export function eventCursor(recordedAt: number, eventId: string, filters: Record): EventCursorPayload { + return { v: LAB_QUERY_CURSOR_VERSION, k: "events", r: recordedAt, e: eventId, f: stableFilterKey(filters) }; +} + +export function subjectCursor(subjectId: string, filters: Record): SubjectCursorPayload { + return { v: LAB_QUERY_CURSOR_VERSION, k: "subjects", s: subjectId, f: stableFilterKey(filters) }; +} + +export function artifactCursor(digest: string, filters: Record): ArtifactCursorPayload { + return { v: LAB_QUERY_CURSOR_VERSION, k: "artifacts", d: digest, f: stableFilterKey(filters) }; +} diff --git a/src/lab/query/dto-map.ts b/src/lab/query/dto-map.ts new file mode 100644 index 0000000000..a35e5b57fa --- /dev/null +++ b/src/lab/query/dto-map.ts @@ -0,0 +1,277 @@ +import { sanitizeDiagnostic } from "../artifacts/sanitize"; +import type { + ClaimSnapshotEvent, + InvalidationEvent, + LabEvent, + ObservationEvent, + PurgeTombstoneEvent, + RouteDependencyV1, + RouteSubjectV1, + TaskSubjectV1, +} from "../events/types"; +import { validateLabEvent } from "../events/validate"; +import type { + ArtifactMetadataDto, + ClaimSnapshotEventDto, + EventListItemDto, + InvalidationEventDto, + LabEventDto, + ObservationDto, + ObservationEventDto, + ProtocolSubjectDto, + PurgeTombstoneEventDto, + RouteDependencyDto, + RouteSubjectDto, + SubjectDto, + SubjectListItemDto, + TaskSubjectDto, + VerdictDto, +} from "./types"; + +const SECRETISH = /sk-[a-z0-9]{10,}|Bearer\s+[A-Za-z0-9._\-]+|ghp_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}/gi; +const URL_USERINFO = /https?:\/\/[^/\s:@]+:[^/\s@]+@/gi; +const WINDOWS_PATH = /[A-Za-z]:\\(?:[^\\:*?"<>|\r\n]+\\)*[^\\:*?"<>|\r\n]*/g; +const POSIX_HOME = /(?:^|[^A-Za-z0-9_])(?:\/home\/|\/Users\/|~\/)[^\s"'<>|]+/g; + +export function sanitizePublicText(value: string | null | undefined, max = 256): string | null { + if (value === null || value === undefined) return null; + let out = sanitizeDiagnostic(value); + out = out.replace(SECRETISH, "[redacted]"); + out = out.replace(URL_USERINFO, "[redacted-url]@"); + out = out.replace(WINDOWS_PATH, "[redacted-path]"); + out = out.replace(POSIX_HOME, "[redacted-path]"); + if (out.length > max) out = out.slice(0, max); + return out; +} + +function parseJsonArray(raw: string): string[] { + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === "string") : []; + } catch { + return []; + } +} + +export function mapVerdictRow(row: Record): VerdictDto { + return { + projectionKey: String(row.projection_key), + subjectId: String(row.subject_id), + evidenceLayer: row.evidence_layer as VerdictDto["evidenceLayer"], + suiteId: String(row.suite_id), + suiteVersion: String(row.suite_version), + suiteManifestDigest: String(row.suite_manifest_digest), + projectionSpecVersion: String(row.projection_spec_version), + verdict: row.verdict as VerdictDto["verdict"], + asOf: Number(row.as_of), + scenarioManifestDigests: parseJsonArray(String(row.scenario_manifest_digests_json)), + claimSourceDigest: row.claim_source_digest ? String(row.claim_source_digest) : null, + contributingEventIds: parseJsonArray(String(row.contributing_event_ids_json)), + contradictingEventIds: parseJsonArray(String(row.contradicting_event_ids_json)), + notes: parseJsonArray(String(row.notes_json)).map((n) => sanitizePublicText(n, 512) ?? ""), + }; +} + +function mapRouteDependency(dep: RouteDependencyV1): RouteDependencyDto { + return { + role: dep.role, + providerId: dep.providerId, + providerInstanceFingerprint: dep.providerInstanceFingerprint, + clientModelId: dep.clientModelId, + upstreamModelId: dep.upstreamModelId, + effectiveAdapter: dep.effectiveAdapter, + upstreamProtocol: dep.upstreamProtocol, + endpointFingerprint: dep.endpointFingerprint, + behaviorFingerprint: dep.behaviorFingerprint, + }; +} + +function mapRouteSubject(subject: RouteSubjectV1): RouteSubjectDto { + return { + subjectKind: "route", + subjectSchemaVersion: 1, + providerId: subject.providerId, + providerInstanceFingerprint: subject.providerInstanceFingerprint, + clientModelId: subject.clientModelId, + upstreamModelId: subject.upstreamModelId, + effectiveAdapter: subject.effectiveAdapter, + inboundProtocol: subject.inboundProtocol, + upstreamProtocol: subject.upstreamProtocol, + surface: subject.surface, + opencodexCompatibilityVersion: subject.opencodexCompatibilityVersion, + behaviorFingerprint: subject.behaviorFingerprint, + endpointFingerprint: subject.endpointFingerprint, + dependencies: subject.dependencies.map(mapRouteDependency), + }; +} + +export function mapSubjectJson(subjectJson: string): SubjectDto | null { + try { + const parsed = JSON.parse(subjectJson) as Record; + const kind = parsed.subjectKind; + if (kind === "protocol") { + return { + subjectKind: "protocol", + subjectSchemaVersion: 1, + opencodexCompatibilityVersion: String(parsed.opencodexCompatibilityVersion), + effectiveAdapter: String(parsed.effectiveAdapter), + inboundProtocol: String(parsed.inboundProtocol), + upstreamProtocol: String(parsed.upstreamProtocol), + surface: String(parsed.surface), + behaviorFingerprint: String(parsed.behaviorFingerprint), + }; + } + if (kind === "route") { + return mapRouteSubject(parsed as unknown as RouteSubjectV1); + } + if (kind === "task") { + const task = parsed as unknown as TaskSubjectV1; + return { + subjectKind: "task", + subjectSchemaVersion: 1, + routeSubject: mapRouteSubject(task.routeSubject), + taskClassId: task.taskClassId, + taskClassVersion: task.taskClassVersion, + taskFixtureDigest: task.taskFixtureDigest, + verifierManifestDigest: task.verifierManifestDigest, + fabricCompatibilityVersion: task.fabricCompatibilityVersion, + sandboxProfileDigest: task.sandboxProfileDigest, + }; + } + return null; + } catch { + return null; + } +} + +export function mapSubjectListRow(row: Record): SubjectListItemDto { + return { + subjectId: String(row.subject_id), + subjectKind: String(row.subject_kind), + }; +} + +export function mapObservationRow( + row: Record, + excluded: boolean, + exclusionReason: string | null, +): ObservationDto { + return { + eventId: String(row.event_id), + subjectId: String(row.subject_id), + evidenceLayer: row.evidence_layer as ObservationDto["evidenceLayer"], + suiteId: String(row.suite_id), + suiteVersion: String(row.suite_version), + suiteManifestDigest: String(row.suite_manifest_digest), + scenarioId: String(row.scenario_id), + scenarioVersion: String(row.scenario_version), + scenarioManifestDigest: String(row.scenario_manifest_digest), + outcome: row.outcome as ObservationDto["outcome"], + completedAt: Number(row.completed_at), + executionMode: row.execution_mode as ObservationDto["executionMode"], + excluded, + exclusionReason: sanitizePublicText(exclusionReason), + }; +} + +export function mapEventListRow(row: Record): EventListItemDto { + return { + eventId: String(row.event_id), + eventKind: row.event_kind as EventListItemDto["eventKind"], + recordedAt: Number(row.recorded_at), + excluded: Number(row.excluded) === 1, + exclusionReason: sanitizePublicText(row.exclusion_reason ? String(row.exclusion_reason) : null), + }; +} + +export function mapArtifactRow(row: Record): ArtifactMetadataDto { + return { + digest: String(row.digest), + artifactClass: row.artifact_class ? row.artifact_class as ArtifactMetadataDto["artifactClass"] : null, + mediaType: row.media_type ? String(row.media_type) : null, + byteCount: row.byte_count === null || row.byte_count === undefined ? null : Number(row.byte_count), + status: row.status as ArtifactMetadataDto["status"], + lastError: sanitizePublicText(row.last_error ? String(row.last_error) : null), + }; +} + +function exclusionFromRow(row: Record): { excluded: boolean; exclusionReason: string | null } { + return { + excluded: Number(row.excluded) === 1, + exclusionReason: sanitizePublicText(row.exclusion_reason ? String(row.exclusion_reason) : null), + }; +} + +export function mapValidatedEventToDto(event: LabEvent, excluded: boolean, exclusionReason: string | null): LabEventDto { + const base = { + eventId: event.eventId, + recordedAt: event.recordedAt, + producer: event.producer, + producerVersion: event.producerVersion, + excluded, + exclusionReason, + }; + if (event.eventKind === "observation") { + const obs = event as ObservationEvent; + return { + eventKind: "observation", + ...base, + evidenceLayer: obs.evidenceLayer, + scenarioId: obs.scenarioId, + scenarioVersion: obs.scenarioVersion, + scenarioManifestDigest: obs.scenarioManifestDigest, + suiteId: obs.suiteId, + suiteVersion: obs.suiteVersion, + suiteManifestDigest: obs.suiteManifestDigest, + subjectId: obs.subjectId, + startedAt: obs.startedAt, + completedAt: obs.completedAt, + executionMode: obs.executionMode, + attempt: obs.attempt, + outcome: obs.outcome, + } satisfies ObservationEventDto; + } + if (event.eventKind === "claim_snapshot") { + const claim = event as ClaimSnapshotEvent; + return { + eventKind: "claim_snapshot", + ...base, + subjectId: claim.subjectId, + capability: claim.capability, + polarity: claim.polarity, + sourceManifestDigest: claim.sourceManifestDigest, + effectiveAt: claim.effectiveAt, + } satisfies ClaimSnapshotEventDto; + } + if (event.eventKind === "invalidation") { + const inv = event as InvalidationEvent; + return { + eventKind: "invalidation", + ...base, + reason: inv.reason, + targetEventIds: [...inv.targetEventIds], + } satisfies InvalidationEventDto; + } + const purge = event as PurgeTombstoneEvent; + return { + eventKind: "purge_tombstone", + ...base, + targetEventIds: [...purge.targetEventIds], + targetArtifactDigests: [...purge.targetArtifactDigests], + purgeActions: [...purge.purgeActions], + } satisfies PurgeTombstoneEventDto; +} + +export function parseEventPayloadToDto( + payloadJson: string, + excluded: boolean, + exclusionReason: string | null, +): LabEventDto | null { + try { + const parsed = JSON.parse(payloadJson); + const event = validateLabEvent(parsed); + return mapValidatedEventToDto(event, excluded, exclusionReason); + } catch { + return null; + } +} diff --git a/src/lab/query/errors.ts b/src/lab/query/errors.ts new file mode 100644 index 0000000000..c8363001e2 --- /dev/null +++ b/src/lab/query/errors.ts @@ -0,0 +1,22 @@ +/** CL-04 read-surface errors — safe codes only, no SQLite/path leakage. */ + +export class LabProjectionUnavailableError extends Error { + readonly code = "lab_projection_unavailable" as const; + constructor() { + super("lab projection is not available"); + } +} + +export class LabProjectionIncompatibleError extends Error { + readonly code = "lab_projection_incompatible" as const; + constructor() { + super("lab projection schema or spec version is incompatible"); + } +} + +export class InvalidCursorError extends Error { + readonly code = "invalid_cursor" as const; + constructor() { + super("invalid cursor"); + } +} diff --git a/src/lab/query/index.ts b/src/lab/query/index.ts new file mode 100644 index 0000000000..6582bacb41 --- /dev/null +++ b/src/lab/query/index.ts @@ -0,0 +1,34 @@ +export { + LAB_QUERY_DEFAULT_PAGE_SIZE, + LAB_QUERY_MAX_PAGE_SIZE, +} from "./constants"; +export { + LabProjectionUnavailableError, + LabProjectionIncompatibleError, + InvalidCursorError, +} from "./errors"; +export { + openLabReadConnection, + closeLabReadConnection, + resolveLabSqlitePath, + type LabReadConnection, +} from "./connection"; +export { + encodeLabCursor, + decodeLabCursor, + filterKeyFor, +} from "./cursor"; +export { queryLabCatalog } from "./catalog"; +export { + queryLabStatus, + queryLabVerdicts, + queryLabSubjects, + queryLabSubjectById, + queryLabObservations, + queryLabEvents, + queryLabEventById, + queryLabArtifacts, + queryLabArtifactByDigest, + queryLabCatalogEntries, +} from "./queries"; +export { sanitizePublicText } from "./dto-map"; diff --git a/src/lab/query/queries.ts b/src/lab/query/queries.ts new file mode 100644 index 0000000000..be3b09d28d --- /dev/null +++ b/src/lab/query/queries.ts @@ -0,0 +1,454 @@ +import { + LAB_QUERY_DEFAULT_PAGE_SIZE, + LAB_QUERY_MAX_PAGE_SIZE, +} from "./constants"; +import { + closeLabReadConnection, + countTable, + openLabReadConnection, + type LabReadConnection, +} from "./connection"; +import { + assertCursorFilters, + artifactCursor, + decodeLabCursor, + encodeLabCursor, + eventCursor, + filterKeyFor, + observationCursor, + subjectCursor, + verdictCursor, +} from "./cursor"; +import { + mapArtifactRow, + mapEventListRow, + mapObservationRow, + mapSubjectJson, + mapSubjectListRow, + mapValidatedEventToDto, + mapVerdictRow, + parseEventPayloadToDto, +} from "./dto-map"; +import { InvalidCursorError } from "./errors"; +import { queryLabCatalog } from "./catalog"; +import type { + ArtifactFilters, + ArtifactMetadataDto, + EventFilters, + EventListItemDto, + LabEventDto, + LabStatusDto, + ObservationDto, + ObservationFilters, + PaginatedResult, + SubjectDto, + SubjectListItemDto, + VerdictDto, + VerdictFilters, +} from "./types"; +import { validateLabEvent } from "../events/validate"; + +function clampLimit(limit: number | undefined): number { + if (limit === undefined) return LAB_QUERY_DEFAULT_PAGE_SIZE; + return Math.min(Math.max(limit, 1), LAB_QUERY_MAX_PAGE_SIZE); +} + +export function queryLabStatus(configDir?: string): LabStatusDto { + try { + const conn = openLabReadConnection(configDir); + try { + return { + projectionAvailable: true, + sqliteSchemaVersion: conn.schemaVersion, + projectionSpecVersion: conn.projectionSpecVersion, + builtAtMs: conn.builtAtMs, + eventCount: countTable(conn, "events"), + subjectCount: countTable(conn, "subjects"), + observationCount: countTable(conn, "observations"), + claimCount: countTable(conn, "claims"), + verdictCount: countTable(conn, "verdicts"), + artifactCount: countTable(conn, "artifacts"), + corruptionCount: countTable(conn, "corruption"), + }; + } finally { + closeLabReadConnection(conn); + } + } catch (err) { + if (err && typeof err === "object" && "code" in err) { + const code = String((err as { code: unknown }).code); + if (code === "lab_projection_incompatible") { + return { projectionAvailable: false, projectionIncompatible: true }; + } + } + return { projectionAvailable: false }; + } +} + +function withConnection(configDir: string | undefined, fn: (conn: LabReadConnection) => T): T { + const conn = openLabReadConnection(configDir); + try { + return fn(conn); + } finally { + closeLabReadConnection(conn); + } +} + +export function queryLabVerdicts( + filters: VerdictFilters = {}, + cursorRaw?: string | null, + limit?: number, + configDir?: string, +): PaginatedResult { + const pageSize = clampLimit(limit); + const filterRecord: Record = { ...filters }; + const decoded = cursorRaw ? decodeLabCursor(cursorRaw) : null; + if (cursorRaw && !decoded) throw new InvalidCursorError(); + if (decoded) assertCursorFilters(decoded, filterRecord, "verdicts"); + + return withConnection(configDir, (conn) => { + const where: string[] = []; + const params: Array = []; + if (filters.subjectId) { + where.push("subject_id = ?"); + params.push(filters.subjectId); + } + if (filters.layer) { + where.push("evidence_layer = ?"); + params.push(filters.layer); + } + if (filters.suiteId) { + where.push("suite_id = ?"); + params.push(filters.suiteId); + } + if (filters.verdict) { + where.push("verdict = ?"); + params.push(filters.verdict); + } + if (filters.from !== undefined) { + where.push("as_of >= ?"); + params.push(filters.from); + } + if (filters.to !== undefined) { + where.push("as_of <= ?"); + params.push(filters.to); + } + if (decoded && decoded.k === "verdicts") { + where.push("(as_of < ? OR (as_of = ? AND projection_key > ?))"); + params.push(decoded.a, decoded.a, decoded.p); + } + const whereSql = where.length > 0 ? `WHERE ${where.join(" AND ")}` : ""; + const rows = conn.db + .query( + `SELECT projection_key, subject_id, evidence_layer, suite_id, suite_version, + suite_manifest_digest, projection_spec_version, verdict, as_of, + scenario_manifest_digests_json, claim_source_digest, + contributing_event_ids_json, contradicting_event_ids_json, notes_json + FROM verdicts ${whereSql} + ORDER BY as_of DESC, projection_key ASC + LIMIT ?`, + ) + .all(...params, pageSize + 1) as Array>; + + const hasMore = rows.length > pageSize; + const pageRows = hasMore ? rows.slice(0, pageSize) : rows; + const items = pageRows.map(mapVerdictRow); + let nextCursor: string | undefined; + if (hasMore && pageRows.length > 0) { + const last = pageRows[pageRows.length - 1]!; + nextCursor = encodeLabCursor( + verdictCursor(Number(last.as_of), String(last.projection_key), filterRecord), + ); + } + return { items, hasMore, ...(nextCursor ? { nextCursor } : {}) }; + }); +} + +export function queryLabSubjects( + kind?: string, + cursorRaw?: string | null, + limit?: number, + configDir?: string, +): PaginatedResult { + const pageSize = clampLimit(limit); + const filterRecord: Record = { kind }; + const decoded = cursorRaw ? decodeLabCursor(cursorRaw) : null; + if (cursorRaw && !decoded) throw new InvalidCursorError(); + if (decoded) assertCursorFilters(decoded, filterRecord, "subjects"); + + return withConnection(configDir, (conn) => { + const where: string[] = []; + const params: Array = []; + if (kind) { + where.push("subject_kind = ?"); + params.push(kind); + } + if (decoded && decoded.k === "subjects") { + where.push("subject_id > ?"); + params.push(decoded.s); + } + const whereSql = where.length > 0 ? `WHERE ${where.join(" AND ")}` : ""; + const rows = conn.db + .query(`SELECT subject_id, subject_kind FROM subjects ${whereSql} ORDER BY subject_id ASC LIMIT ?`) + .all(...params, pageSize + 1) as Array>; + + const hasMore = rows.length > pageSize; + const pageRows = hasMore ? rows.slice(0, pageSize) : rows; + const items = pageRows.map(mapSubjectListRow); + let nextCursor: string | undefined; + if (hasMore && pageRows.length > 0) { + const last = pageRows[pageRows.length - 1]!; + nextCursor = encodeLabCursor(subjectCursor(String(last.subject_id), filterRecord)); + } + return { items, hasMore, ...(nextCursor ? { nextCursor } : {}) }; + }); +} + +export function queryLabSubjectById(subjectId: string, configDir?: string): SubjectDto | null { + return withConnection(configDir, (conn) => { + const row = conn.db + .query("SELECT subject_json FROM subjects WHERE subject_id = ?") + .get(subjectId) as { subject_json: string } | null; + if (!row) return null; + return mapSubjectJson(row.subject_json); + }); +} + +export function queryLabObservations( + filters: ObservationFilters = {}, + cursorRaw?: string | null, + limit?: number, + configDir?: string, +): PaginatedResult { + const pageSize = clampLimit(limit); + const filterRecord: Record = { ...filters }; + const decoded = cursorRaw ? decodeLabCursor(cursorRaw) : null; + if (cursorRaw && !decoded) throw new InvalidCursorError(); + if (decoded) assertCursorFilters(decoded, filterRecord, "observations"); + + return withConnection(configDir, (conn) => { + const where: string[] = []; + const params: Array = []; + if (filters.subjectId) { + where.push("o.subject_id = ?"); + params.push(filters.subjectId); + } + if (filters.layer) { + where.push("o.evidence_layer = ?"); + params.push(filters.layer); + } + if (filters.suiteId) { + where.push("o.suite_id = ?"); + params.push(filters.suiteId); + } + if (filters.scenarioId) { + where.push("o.scenario_id = ?"); + params.push(filters.scenarioId); + } + if (filters.outcome) { + where.push("o.outcome = ?"); + params.push(filters.outcome); + } + if (filters.executionMode) { + where.push("o.execution_mode = ?"); + params.push(filters.executionMode); + } + if (filters.from !== undefined) { + where.push("o.completed_at >= ?"); + params.push(filters.from); + } + if (filters.to !== undefined) { + where.push("o.completed_at <= ?"); + params.push(filters.to); + } + if (decoded && decoded.k === "observations") { + where.push("(o.completed_at < ? OR (o.completed_at = ? AND o.event_id < ?))"); + params.push(decoded.c, decoded.c, decoded.e); + } + const whereSql = where.length > 0 ? `WHERE ${where.join(" AND ")}` : ""; + const rows = conn.db + .query( + `SELECT o.event_id, o.subject_id, o.evidence_layer, o.suite_id, o.suite_version, + o.suite_manifest_digest, o.scenario_id, o.scenario_version, o.scenario_manifest_digest, + o.outcome, o.completed_at, o.execution_mode, + e.excluded, e.exclusion_reason + FROM observations o + JOIN events e ON e.event_id = o.event_id + ${whereSql} + ORDER BY o.completed_at DESC, o.event_id DESC + LIMIT ?`, + ) + .all(...params, pageSize + 1) as Array>; + + const hasMore = rows.length > pageSize; + const pageRows = hasMore ? rows.slice(0, pageSize) : rows; + const items = pageRows.map((row) => + mapObservationRow(row, Number(row.excluded) === 1, row.exclusion_reason ? String(row.exclusion_reason) : null), + ); + let nextCursor: string | undefined; + if (hasMore && pageRows.length > 0) { + const last = pageRows[pageRows.length - 1]!; + nextCursor = encodeLabCursor( + observationCursor(Number(last.completed_at), String(last.event_id), filterRecord), + ); + } + return { items, hasMore, ...(nextCursor ? { nextCursor } : {}) }; + }); +} + +export function queryLabEvents( + filters: EventFilters = {}, + cursorRaw?: string | null, + limit?: number, + configDir?: string, +): PaginatedResult { + const pageSize = clampLimit(limit); + const filterRecord: Record = { ...filters }; + const decoded = cursorRaw ? decodeLabCursor(cursorRaw) : null; + if (cursorRaw && !decoded) throw new InvalidCursorError(); + if (decoded) assertCursorFilters(decoded, filterRecord, "events"); + + return withConnection(configDir, (conn) => { + const where: string[] = []; + const params: Array = []; + if (filters.eventKind) { + where.push("e.event_kind = ?"); + params.push(filters.eventKind); + } + if (filters.from !== undefined) { + where.push("e.recorded_at >= ?"); + params.push(filters.from); + } + if (filters.to !== undefined) { + where.push("e.recorded_at <= ?"); + params.push(filters.to); + } + if (filters.excluded !== undefined) { + where.push("e.excluded = ?"); + params.push(filters.excluded ? 1 : 0); + } + if (filters.subjectId) { + where.push( + "(EXISTS (SELECT 1 FROM observations ox WHERE ox.event_id = e.event_id AND ox.subject_id = ?) OR EXISTS (SELECT 1 FROM claims cx WHERE cx.event_id = e.event_id AND cx.subject_id = ?))", + ); + params.push(filters.subjectId, filters.subjectId); + } + if (decoded && decoded.k === "events") { + where.push("(e.recorded_at < ? OR (e.recorded_at = ? AND e.event_id < ?))"); + params.push(decoded.r, decoded.r, decoded.e); + } + const joinSql = ""; + const whereSql = where.length > 0 ? `WHERE ${where.join(" AND ")}` : ""; + const rows = conn.db + .query( + `SELECT DISTINCT e.event_id, e.event_kind, e.recorded_at, e.excluded, e.exclusion_reason + FROM events e ${joinSql} + ${whereSql} + ORDER BY e.recorded_at DESC, e.event_id DESC + LIMIT ?`, + ) + .all(...params, pageSize + 1) as Array>; + + const hasMore = rows.length > pageSize; + const pageRows = hasMore ? rows.slice(0, pageSize) : rows; + const items = pageRows.map(mapEventListRow); + let nextCursor: string | undefined; + if (hasMore && pageRows.length > 0) { + const last = pageRows[pageRows.length - 1]!; + nextCursor = encodeLabCursor( + eventCursor(Number(last.recorded_at), String(last.event_id), filterRecord), + ); + } + return { items, hasMore, ...(nextCursor ? { nextCursor } : {}) }; + }); +} + +export function queryLabEventById(eventId: string, configDir?: string): LabEventDto | null { + return withConnection(configDir, (conn) => { + const row = conn.db + .query( + "SELECT event_id, payload_json, excluded, exclusion_reason FROM events WHERE event_id = ?", + ) + .get(eventId) as { payload_json: string; excluded: number; exclusion_reason: string | null } | null; + if (!row) return null; + const excluded = Number(row.excluded) === 1; + const exclusionReason = row.exclusion_reason ? String(row.exclusion_reason) : null; + const dto = parseEventPayloadToDto(row.payload_json, excluded, exclusionReason); + if (dto) return dto; + try { + const event = validateLabEvent(JSON.parse(row.payload_json)); + return mapValidatedEventToDto(event, excluded, exclusionReason); + } catch { + return null; + } + }); +} + +export function queryLabArtifacts( + filters: ArtifactFilters = {}, + cursorRaw?: string | null, + limit?: number, + configDir?: string, +): PaginatedResult { + const pageSize = clampLimit(limit); + const filterRecord: Record = { ...filters }; + const decoded = cursorRaw ? decodeLabCursor(cursorRaw) : null; + if (cursorRaw && !decoded) throw new InvalidCursorError(); + if (decoded) assertCursorFilters(decoded, filterRecord, "artifacts"); + + return withConnection(configDir, (conn) => { + const where: string[] = []; + const params: Array = []; + if (filters.status) { + where.push("status = ?"); + params.push(filters.status); + } + if (filters.artifactClass) { + where.push("artifact_class = ?"); + params.push(filters.artifactClass); + } + if (decoded && decoded.k === "artifacts") { + where.push("digest > ?"); + params.push(decoded.d); + } + const whereSql = where.length > 0 ? `WHERE ${where.join(" AND ")}` : ""; + const rows = conn.db + .query( + `SELECT digest, artifact_class, media_type, byte_count, status, last_error + FROM artifacts ${whereSql} ORDER BY digest ASC LIMIT ?`, + ) + .all(...params, pageSize + 1) as Array>; + + const hasMore = rows.length > pageSize; + const pageRows = hasMore ? rows.slice(0, pageSize) : rows; + const items = pageRows.map(mapArtifactRow); + let nextCursor: string | undefined; + if (hasMore && pageRows.length > 0) { + const last = pageRows[pageRows.length - 1]!; + nextCursor = encodeLabCursor(artifactCursor(String(last.digest), filterRecord)); + } + return { items, hasMore, ...(nextCursor ? { nextCursor } : {}) }; + }); +} + +export function queryLabArtifactByDigest(digest: string, configDir?: string): ArtifactMetadataDto | null { + return withConnection(configDir, (conn) => { + const row = conn.db + .query( + "SELECT digest, artifact_class, media_type, byte_count, status, last_error FROM artifacts WHERE digest = ?", + ) + .get(digest) as Record | null; + if (!row) return null; + return mapArtifactRow(row); + }); +} + +export function queryLabCatalogEntries( + filters: { layer?: string; suiteId?: string } = {}, +): ReturnType { + return queryLabCatalog({ + layer: filters.layer as import("../constants").EvidenceLayer | undefined, + suiteId: filters.suiteId, + }); +} + +export { filterKeyFor }; diff --git a/src/lab/query/types.ts b/src/lab/query/types.ts new file mode 100644 index 0000000000..fbb5f1bb33 --- /dev/null +++ b/src/lab/query/types.ts @@ -0,0 +1,266 @@ +import type { + ArtifactClass, + CompatibilityVerdict, + EvidenceLayer, + ExecutionMode, + InvalidationReason, + LabEventKind, + ObservationOutcome, + PurgeAction, +} from "../constants"; + +export interface LabStatusDto { + projectionAvailable: boolean; + projectionIncompatible?: boolean; + sqliteSchemaVersion?: number; + projectionSpecVersion?: string; + builtAtMs?: number; + eventCount?: number; + subjectCount?: number; + observationCount?: number; + claimCount?: number; + verdictCount?: number; + artifactCount?: number; + corruptionCount?: number; +} + +export interface VerdictDto { + projectionKey: string; + subjectId: string; + evidenceLayer: EvidenceLayer; + suiteId: string; + suiteVersion: string; + suiteManifestDigest: string; + projectionSpecVersion: string; + verdict: CompatibilityVerdict; + asOf: number; + scenarioManifestDigests: string[]; + claimSourceDigest: string | null; + contributingEventIds: string[]; + contradictingEventIds: string[]; + notes: string[]; +} + +export interface ProtocolSubjectDto { + subjectKind: "protocol"; + subjectSchemaVersion: 1; + opencodexCompatibilityVersion: string; + effectiveAdapter: string; + inboundProtocol: string; + upstreamProtocol: string; + surface: string; + behaviorFingerprint: string; +} + +export interface RouteDependencyDto { + role: string; + providerId: string; + providerInstanceFingerprint: string; + clientModelId: string; + upstreamModelId: string; + effectiveAdapter: string; + upstreamProtocol: string; + endpointFingerprint: string; + behaviorFingerprint: string; +} + +export interface RouteSubjectDto { + subjectKind: "route"; + subjectSchemaVersion: 1; + providerId: string; + providerInstanceFingerprint: string; + clientModelId: string; + upstreamModelId: string; + effectiveAdapter: string; + inboundProtocol: string; + upstreamProtocol: string; + surface: string; + opencodexCompatibilityVersion: string; + behaviorFingerprint: string; + endpointFingerprint: string; + dependencies: RouteDependencyDto[]; +} + +export interface TaskSubjectDto { + subjectKind: "task"; + subjectSchemaVersion: 1; + routeSubject: RouteSubjectDto; + taskClassId: string; + taskClassVersion: string; + taskFixtureDigest: string; + verifierManifestDigest: string; + fabricCompatibilityVersion: string; + sandboxProfileDigest: string; +} + +export type SubjectDto = ProtocolSubjectDto | RouteSubjectDto | TaskSubjectDto; + +export interface SubjectListItemDto { + subjectId: string; + subjectKind: string; +} + +export interface ObservationDto { + eventId: string; + subjectId: string; + evidenceLayer: EvidenceLayer; + suiteId: string; + suiteVersion: string; + suiteManifestDigest: string; + scenarioId: string; + scenarioVersion: string; + scenarioManifestDigest: string; + outcome: ObservationOutcome; + completedAt: number; + executionMode: ExecutionMode; + excluded: boolean; + exclusionReason: string | null; +} + +export interface ObservationEventDto { + eventKind: "observation"; + eventId: string; + recordedAt: number; + producer: string; + producerVersion: string; + evidenceLayer: EvidenceLayer; + scenarioId: string; + scenarioVersion: string; + scenarioManifestDigest: string; + suiteId: string; + suiteVersion: string; + suiteManifestDigest: string; + subjectId: string; + startedAt: number; + completedAt: number; + executionMode: ExecutionMode; + attempt: number; + outcome: ObservationOutcome; + excluded: boolean; + exclusionReason: string | null; +} + +export interface ClaimSnapshotEventDto { + eventKind: "claim_snapshot"; + eventId: string; + recordedAt: number; + producer: string; + producerVersion: string; + subjectId: string; + capability: string; + polarity: string; + sourceManifestDigest: string; + effectiveAt: number; + excluded: boolean; + exclusionReason: string | null; +} + +export interface InvalidationEventDto { + eventKind: "invalidation"; + eventId: string; + recordedAt: number; + producer: string; + producerVersion: string; + reason: InvalidationReason; + targetEventIds: string[]; + excluded: boolean; + exclusionReason: string | null; +} + +export interface PurgeTombstoneEventDto { + eventKind: "purge_tombstone"; + eventId: string; + recordedAt: number; + producer: string; + producerVersion: string; + targetEventIds: string[]; + targetArtifactDigests: string[]; + purgeActions: PurgeAction[]; + excluded: boolean; + exclusionReason: string | null; +} + +export type LabEventDto = + | ObservationEventDto + | ClaimSnapshotEventDto + | InvalidationEventDto + | PurgeTombstoneEventDto; + +export interface EventListItemDto { + eventId: string; + eventKind: LabEventKind; + recordedAt: number; + excluded: boolean; + exclusionReason: string | null; +} + +export interface ArtifactMetadataDto { + digest: string; + artifactClass: ArtifactClass | null; + mediaType: string | null; + byteCount: number | null; + status: "present" | "corrupt" | "purged_unavailable"; + lastError: string | null; +} + +export interface CatalogScenarioDto { + scenarioId: string; + scenarioVersion: string; + evidenceLayer: EvidenceLayer; + suiteId: string; + suiteVersion: string; + capability: string; + verificationRole: string; + requirements: { + inboundProtocols: string[]; + upstreamProtocols: string[]; + surfaces: string[]; + }; + freshness: Record; + scenarioManifestDigest: string; + suiteManifestDigest: string; +} + +export interface PaginatedResult { + items: T[]; + nextCursor?: string; + hasMore: boolean; +} + +export interface VerdictFilters { + subjectId?: string; + layer?: EvidenceLayer; + suiteId?: string; + verdict?: CompatibilityVerdict; + from?: number; + to?: number; +} + +export interface ObservationFilters { + subjectId?: string; + layer?: EvidenceLayer; + suiteId?: string; + scenarioId?: string; + outcome?: ObservationOutcome; + executionMode?: ExecutionMode; + from?: number; + to?: number; +} + +export interface EventFilters { + eventKind?: LabEventKind; + subjectId?: string; + from?: number; + to?: number; + excluded?: boolean; +} + +export interface ArtifactFilters { + status?: "present" | "corrupt" | "purged_unavailable"; + artifactClass?: ArtifactClass; +} + +export interface CatalogFilters { + layer?: EvidenceLayer; + suiteId?: string; +} diff --git a/src/server/management-api.ts b/src/server/management-api.ts index f5bc4c32c2..372e738e76 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -68,6 +68,7 @@ import { handleAgentSettingsRoutes } from "./management/agent-settings-routes"; import { handleOauthAccountRoutes } from "./management/oauth-account-routes"; import { handleComboRoutes } from "./management/combo-routes"; import { handleSystemRoutes } from "./management/system-routes"; +import { handleLabRoutes } from "./management/lab-routes"; import { handleSidebarRoutes } from "./management/sidebar-routes"; import { handleIntegrationRoutes } from "./management/integration-routes"; import { handleNativeIntegrationRoutes } from "./management/native-integration-routes"; @@ -187,6 +188,7 @@ export async function handleManagementAPI( ?? (await handleOauthAccountRoutes(ctx)) ?? (await handleComboRoutes(ctx)) ?? (await handleSystemRoutes(ctx)) + ?? (await handleLabRoutes(ctx)) ?? (await handleSidebarRoutes(ctx)); } catch (error) { const tooLarge = managementBodyTooLargeResponse(error, req, config); diff --git a/src/server/management/lab-routes.ts b/src/server/management/lab-routes.ts new file mode 100644 index 0000000000..2a1243434b --- /dev/null +++ b/src/server/management/lab-routes.ts @@ -0,0 +1,377 @@ +/** + * CL-04 read-only Compatibility Lab management API. + * + * - GET /api/lab/status + * - GET /api/lab/verdicts + * - GET /api/lab/subjects + * - GET /api/lab/subjects/:subjectId + * - GET /api/lab/observations + * - GET /api/lab/events + * - GET /api/lab/events/:eventId + * - GET /api/lab/artifacts + * - GET /api/lab/artifacts/:digest + * - GET /api/lab/catalog + */ + +import { + EVIDENCE_LAYERS, + EXECUTION_MODES, + EVENT_KINDS, + OUTCOMES, + VERDICTS, + type EvidenceLayer, + type ExecutionMode, + type LabEventKind, + type ObservationOutcome, + type CompatibilityVerdict, +} from "../../lab/constants"; +import { + InvalidCursorError, + LabProjectionIncompatibleError, + LabProjectionUnavailableError, + LAB_QUERY_MAX_PAGE_SIZE, + queryLabArtifactByDigest, + queryLabArtifacts, + queryLabCatalogEntries, + queryLabEventById, + queryLabEvents, + queryLabObservations, + queryLabStatus, + queryLabSubjectById, + queryLabSubjects, + queryLabVerdicts, +} from "../../lab/query"; +import { jsonResponse } from "../auth-cors"; +import type { ManagementContext } from "./context"; + +function parseQueryInt(raw: string | null): number | undefined | "invalid" { + if (raw === null) return undefined; + const trimmed = raw.trim(); + if (trimmed.length === 0) return "invalid"; + const value = Number(trimmed); + return Number.isInteger(value) ? value : "invalid"; +} + +function errorResponse( + code: string, + message: string, + status: number, + ctx: ManagementContext, +): Response { + return jsonResponse({ error: { code, message } }, status, ctx.req, ctx.config); +} + +function projectionErrorResponse(err: unknown, ctx: ManagementContext): Response | null { + if (err instanceof LabProjectionUnavailableError) { + return errorResponse("lab_projection_unavailable", "lab projection is not available", 503, ctx); + } + if (err instanceof LabProjectionIncompatibleError) { + return errorResponse("lab_projection_incompatible", "lab projection schema or spec version is incompatible", 503, ctx); + } + if (err instanceof InvalidCursorError) { + return errorResponse("invalid_cursor", "invalid cursor", 400, ctx); + } + return null; +} + +function parseLimit(raw: string | null, ctx: ManagementContext): number | undefined | Response { + const parsed = raw === null ? undefined : parseQueryInt(raw); + if (parsed === "invalid") { + return errorResponse( + "invalid_limit", + `limit must be an integer from 1 to ${LAB_QUERY_MAX_PAGE_SIZE}`, + 400, + ctx, + ); + } + if (parsed !== undefined && (parsed < 1 || parsed > LAB_QUERY_MAX_PAGE_SIZE)) { + return errorResponse( + "invalid_limit", + `limit must be an integer from 1 to ${LAB_QUERY_MAX_PAGE_SIZE}`, + 400, + ctx, + ); + } + return parsed; +} + +function parseRange( + fromRaw: string | null, + toRaw: string | null, + ctx: ManagementContext, +): { from?: number; to?: number } | Response { + const fromParsed = parseQueryInt(fromRaw); + if (fromParsed === "invalid") { + return errorResponse("invalid_range", "from must be an integer timestamp", 400, ctx); + } + const toParsed = parseQueryInt(toRaw); + if (toParsed === "invalid") { + return errorResponse("invalid_range", "to must be an integer timestamp", 400, ctx); + } + if (fromParsed !== undefined && toParsed !== undefined && fromParsed > toParsed) { + return errorResponse("invalid_range", "from must not be after to", 400, ctx); + } + return { from: fromParsed, to: toParsed }; +} + +function parseLayer(raw: string | null, ctx: ManagementContext): EvidenceLayer | undefined | Response { + if (!raw) return undefined; + const trimmed = raw.trim(); + if (!EVIDENCE_LAYERS.includes(trimmed as EvidenceLayer)) { + return errorResponse("invalid_layer", "layer must be a supported evidence layer", 400, ctx); + } + return trimmed as EvidenceLayer; +} + +function parseVerdict(raw: string | null, ctx: ManagementContext): CompatibilityVerdict | undefined | Response { + if (!raw) return undefined; + const trimmed = raw.trim(); + if (!VERDICTS.includes(trimmed as CompatibilityVerdict)) { + return errorResponse("invalid_verdict", "verdict must be a supported compatibility verdict", 400, ctx); + } + return trimmed as CompatibilityVerdict; +} + +function parseEventKind(raw: string | null, ctx: ManagementContext): LabEventKind | undefined | Response { + if (!raw) return undefined; + const trimmed = raw.trim(); + if (!EVENT_KINDS.includes(trimmed as LabEventKind)) { + return errorResponse("invalid_event_kind", "eventKind must be a supported lab event kind", 400, ctx); + } + return trimmed as LabEventKind; +} + +function parseOutcome(raw: string | null, ctx: ManagementContext): ObservationOutcome | undefined | Response { + if (!raw) return undefined; + const trimmed = raw.trim(); + if (!OUTCOMES.includes(trimmed as ObservationOutcome)) { + return errorResponse("invalid_outcome", "outcome must be a supported observation outcome", 400, ctx); + } + return trimmed as ObservationOutcome; +} + +function parseExecutionMode(raw: string | null, ctx: ManagementContext): ExecutionMode | undefined | Response { + if (!raw) return undefined; + const trimmed = raw.trim(); + if (!EXECUTION_MODES.includes(trimmed as ExecutionMode)) { + return errorResponse("invalid_execution_mode", "executionMode must be a supported execution mode", 400, ctx); + } + return trimmed as ExecutionMode; +} + +function rejectUnsafeId(id: string, ctx: ManagementContext): Response | null { + if (id.includes("/") || id.includes("\\") || id.includes("%2f") || id.includes("%5c")) { + return errorResponse("not_found", "unknown resource", 404, ctx); + } + if (id.length === 0 || id.length > 256) { + return errorResponse("not_found", "unknown resource", 404, ctx); + } + return null; +} + +function paginatedEnvelope(page: { items: T[]; nextCursor?: string; hasMore: boolean }, key: string) { + return { + [key]: page.items, + hasMore: page.hasMore, + ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}), + }; +} + +export async function handleLabRoutes(ctx: ManagementContext): Promise { + const { url, req, config } = ctx; + if (!url.pathname.startsWith("/api/lab")) return null; + if (req.method !== "GET") return null; + + if (url.pathname === "/api/lab/status") { + return jsonResponse(queryLabStatus(), 200, req, config); + } + + if (url.pathname === "/api/lab/catalog") { + const layerRaw = url.searchParams.get("layer"); + const layerParsed = layerRaw ? parseLayer(layerRaw, ctx) : undefined; + if (layerParsed instanceof Response) return layerParsed; + const suiteId = url.searchParams.get("suiteId")?.trim() || url.searchParams.get("suite")?.trim() || undefined; + try { + const scenarios = queryLabCatalogEntries({ + layer: layerParsed, + suiteId, + }); + return jsonResponse({ scenarios }, 200, req, config); + } catch (err) { + const mapped = projectionErrorResponse(err, ctx); + if (mapped) return mapped; + return errorResponse("server_error", "internal lab read failure", 500, ctx); + } + } + + if (url.pathname === "/api/lab/verdicts") { + const limit = parseLimit(url.searchParams.get("limit"), ctx); + if (limit instanceof Response) return limit; + const range = parseRange(url.searchParams.get("from"), url.searchParams.get("to"), ctx); + if (range instanceof Response) return range; + const layer = parseLayer(url.searchParams.get("layer"), ctx); + if (layer instanceof Response) return layer; + const verdict = parseVerdict(url.searchParams.get("verdict"), ctx); + if (verdict instanceof Response) return verdict; + try { + const page = queryLabVerdicts({ + subjectId: url.searchParams.get("subjectId")?.trim() || undefined, + layer, + suiteId: url.searchParams.get("suiteId")?.trim() || undefined, + verdict, + from: range.from, + to: range.to, + }, url.searchParams.get("cursor"), limit); + return jsonResponse(paginatedEnvelope(page, "verdicts"), 200, req, config); + } catch (err) { + const mapped = projectionErrorResponse(err, ctx); + if (mapped) return mapped; + return errorResponse("server_error", "internal lab read failure", 500, ctx); + } + } + + if (url.pathname === "/api/lab/subjects") { + const limit = parseLimit(url.searchParams.get("limit"), ctx); + if (limit instanceof Response) return limit; + try { + const page = queryLabSubjects( + url.searchParams.get("kind")?.trim() || undefined, + url.searchParams.get("cursor"), + limit, + ); + return jsonResponse(paginatedEnvelope(page, "subjects"), 200, req, config); + } catch (err) { + const mapped = projectionErrorResponse(err, ctx); + if (mapped) return mapped; + return errorResponse("server_error", "internal lab read failure", 500, ctx); + } + } + + const subjectMatch = url.pathname.match(/^\/api\/lab\/subjects\/([^/]+)$/); + if (subjectMatch) { + const subjectId = decodeURIComponent(subjectMatch[1]!); + const unsafe = rejectUnsafeId(subjectId, ctx); + if (unsafe) return unsafe; + try { + const subject = queryLabSubjectById(subjectId); + if (!subject) return errorResponse("not_found", "unknown subject", 404, ctx); + return jsonResponse({ subjectId, subject }, 200, req, config); + } catch (err) { + const mapped = projectionErrorResponse(err, ctx); + if (mapped) return mapped; + return errorResponse("server_error", "internal lab read failure", 500, ctx); + } + } + + if (url.pathname === "/api/lab/observations") { + const limit = parseLimit(url.searchParams.get("limit"), ctx); + if (limit instanceof Response) return limit; + const range = parseRange(url.searchParams.get("from"), url.searchParams.get("to"), ctx); + if (range instanceof Response) return range; + const layer = parseLayer(url.searchParams.get("layer"), ctx); + if (layer instanceof Response) return layer; + const outcome = parseOutcome(url.searchParams.get("outcome"), ctx); + if (outcome instanceof Response) return outcome; + const executionMode = parseExecutionMode(url.searchParams.get("executionMode"), ctx); + if (executionMode instanceof Response) return executionMode; + try { + const page = queryLabObservations({ + subjectId: url.searchParams.get("subjectId")?.trim() || undefined, + layer, + suiteId: url.searchParams.get("suiteId")?.trim() || undefined, + scenarioId: url.searchParams.get("scenarioId")?.trim() || undefined, + outcome, + executionMode, + from: range.from, + to: range.to, + }, url.searchParams.get("cursor"), limit); + return jsonResponse(paginatedEnvelope(page, "observations"), 200, req, config); + } catch (err) { + const mapped = projectionErrorResponse(err, ctx); + if (mapped) return mapped; + return errorResponse("server_error", "internal lab read failure", 500, ctx); + } + } + + if (url.pathname === "/api/lab/events") { + const limit = parseLimit(url.searchParams.get("limit"), ctx); + if (limit instanceof Response) return limit; + const range = parseRange(url.searchParams.get("from"), url.searchParams.get("to"), ctx); + if (range instanceof Response) return range; + const eventKind = parseEventKind(url.searchParams.get("eventKind"), ctx); + if (eventKind instanceof Response) return eventKind; + const excludedRaw = url.searchParams.get("excluded"); + let excluded: boolean | undefined; + if (excludedRaw === "true") excluded = true; + else if (excludedRaw === "false") excluded = false; + try { + const page = queryLabEvents({ + eventKind, + subjectId: url.searchParams.get("subjectId")?.trim() || undefined, + from: range.from, + to: range.to, + excluded, + }, url.searchParams.get("cursor"), limit); + return jsonResponse(paginatedEnvelope(page, "events"), 200, req, config); + } catch (err) { + const mapped = projectionErrorResponse(err, ctx); + if (mapped) return mapped; + return errorResponse("server_error", "internal lab read failure", 500, ctx); + } + } + + const eventMatch = url.pathname.match(/^\/api\/lab\/events\/([^/]+)$/); + if (eventMatch) { + const eventId = decodeURIComponent(eventMatch[1]!); + const unsafe = rejectUnsafeId(eventId, ctx); + if (unsafe) return unsafe; + try { + const event = queryLabEventById(eventId); + if (!event) return errorResponse("not_found", "unknown event", 404, ctx); + return jsonResponse({ event }, 200, req, config); + } catch (err) { + const mapped = projectionErrorResponse(err, ctx); + if (mapped) return mapped; + return errorResponse("server_error", "internal lab read failure", 500, ctx); + } + } + + if (url.pathname === "/api/lab/artifacts") { + const limit = parseLimit(url.searchParams.get("limit"), ctx); + if (limit instanceof Response) return limit; + const statusRaw = url.searchParams.get("status")?.trim(); + const artifactClass = url.searchParams.get("artifactClass")?.trim() || undefined; + if (statusRaw && !["present", "corrupt", "purged_unavailable"].includes(statusRaw)) { + return errorResponse("invalid_status", "status must be present, corrupt, or purged_unavailable", 400, ctx); + } + try { + const page = queryLabArtifacts({ + status: statusRaw as "present" | "corrupt" | "purged_unavailable" | undefined, + artifactClass: artifactClass as import("../../lab/constants").ArtifactClass | undefined, + }, url.searchParams.get("cursor"), limit); + return jsonResponse(paginatedEnvelope(page, "artifacts"), 200, req, config); + } catch (err) { + const mapped = projectionErrorResponse(err, ctx); + if (mapped) return mapped; + return errorResponse("server_error", "internal lab read failure", 500, ctx); + } + } + + const artifactMatch = url.pathname.match(/^\/api\/lab\/artifacts\/([^/]+)$/); + if (artifactMatch) { + const digest = decodeURIComponent(artifactMatch[1]!); + const unsafe = rejectUnsafeId(digest, ctx); + if (unsafe) return unsafe; + try { + const artifact = queryLabArtifactByDigest(digest); + if (!artifact) return errorResponse("not_found", "unknown artifact", 404, ctx); + return jsonResponse({ artifact }, 200, req, config); + } catch (err) { + const mapped = projectionErrorResponse(err, ctx); + if (mapped) return mapped; + return errorResponse("server_error", "internal lab read failure", 500, ctx); + } + } + + return null; +} diff --git a/tests/lab-read-surfaces.test.ts b/tests/lab-read-surfaces.test.ts new file mode 100644 index 0000000000..0112002bd9 --- /dev/null +++ b/tests/lab-read-surfaces.test.ts @@ -0,0 +1,361 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, readFileSync, rmSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { Database } from "bun:sqlite"; +import { handleLabCommand } from "../src/cli/lab"; +import { skipsCodexShimAutoRestore } from "../src/cli/codex-shim-autorestore"; +import { + appendLabEvent, + assignEventId, + observationFromConformanceResult, + persistConformanceResult, + rebuildLabProjection, + subjectIdForSubject, + LAB_EVENT_SCHEMA_VERSION, + LAB_PRODUCER, + LAB_PROJECTION_SPEC_VERSION, +} from "../src/lab"; +import { createArtifactStore } from "../src/lab/artifacts/store"; +import { resolveProtocolExecutionContext } from "../src/lab/conformance/executor"; +import type { CaseRecord } from "../src/lab/conformance/types"; +import { discoverScenarios, loadCaseAuthority } from "../src/lab/conformance/manifest"; +import { + InvalidCursorError, + LabProjectionIncompatibleError, + LabProjectionUnavailableError, + LAB_QUERY_MAX_PAGE_SIZE, + openLabReadConnection, + queryLabArtifactByDigest, + queryLabArtifacts, + queryLabCatalogEntries, + queryLabEventById, + queryLabEvents, + queryLabObservations, + queryLabStatus, + queryLabSubjectById, + queryLabSubjects, + queryLabVerdicts, + sanitizePublicText, +} from "../src/lab/query"; +import { encodeLabCursor, verdictCursor } from "../src/lab/query/cursor"; +import { handleManagementAPI } from "../src/server/management-api"; +import { ManagementRequest } from "./helpers/management-auth"; +import type { OcxConfig } from "../src/types"; + +const HOMES: string[] = []; + +function tempHome(): string { + const dir = join(tmpdir(), `ocx-lab-cl04-${process.pid}-${Math.random().toString(16).slice(2)}`); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + HOMES.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of HOMES.splice(0)) { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + /* ignore */ + } + } + delete process.env.OPENCODEX_HOME; +}); + +function withHome(fn: (home: string) => T): T { + const home = tempHome(); + process.env.OPENCODEX_HOME = home; + return fn(home); +} + +function syntheticPassResult(caseRecord: CaseRecord) { + return { + scenarioId: caseRecord.id, + suite: caseRecord.suite, + passed: true, + classification: "inconclusive" as const, + assertionResults: caseRecord.assertions.map((a) => ({ + id: a.id, + operator: a.operator, + required: a.required, + passed: true, + observedSummary: "ok", + })), + diagnostics: [], + executionContext: resolveProtocolExecutionContext(caseRecord), + startedAt: 999, + completedAt: 1000, + }; +} + +function seedProjection(home: string, suiteId = "responses-core") { + const authority = loadCaseAuthority(); + const scenarios = discoverScenarios(authority, [suiteId]); + let recordedAt = 1_700_000_000_000; + for (const caseRecord of scenarios.slice(0, 2)) { + const store = createArtifactStore(join(home, "lab", "artifacts")); + persistConformanceResult(syntheticPassResult(caseRecord), caseRecord, authority, { + configDir: home, + recordedAt: recordedAt++, + artifactStore: store, + }); + store.close(); + } + return rebuildLabProjection(home); +} + +function config(home: string): OcxConfig { + return { providers: {} } as OcxConfig; +} + +async function apiGet(home: string, path: string): Promise { + const req = new ManagementRequest(`http://127.0.0.1${path}`, { method: "GET" }); + const response = await handleManagementAPI(req, new URL(req.url), config(home), { + refreshCodexCatalog: async () => {}, + }); + expect(response).not.toBeNull(); + return response!; +} + +describe("CL-04 query layer", () => { + test("missing projection reports unavailable", () => { + withHome((home) => { + const status = queryLabStatus(home); + expect(status.projectionAvailable).toBe(false); + expect(() => queryLabVerdicts({}, undefined, undefined, home)).toThrow(LabProjectionUnavailableError); + }); + }); + + test("incompatible schema version fails explicitly", () => { + withHome((home) => { + seedProjection(home); + const sqlitePath = join(home, "lab", "compatibility.sqlite"); + const db = new Database(sqlitePath); + db.prepare("UPDATE schema_meta SET value = ? WHERE key = ?").run("999", "schema_version"); + db.close(); + expect(() => openLabReadConnection(home)).toThrow(LabProjectionIncompatibleError); + const status = queryLabStatus(home); + expect(status.projectionIncompatible).toBe(true); + }); + }); + + test("valid status and verdict filtering", () => { + withHome((home) => { + seedProjection(home); + const status = queryLabStatus(home); + expect(status.projectionAvailable).toBe(true); + expect(status.sqliteSchemaVersion).toBe(2); + expect(status.projectionSpecVersion).toBe(LAB_PROJECTION_SPEC_VERSION); + expect(status.verdictCount).toBeGreaterThan(0); + + const page = queryLabVerdicts({ layer: "protocol_conformance" }, undefined, 10, home); + expect(page.items.length).toBeGreaterThan(0); + for (const item of page.items) { + expect(item.evidenceLayer).toBe("protocol_conformance"); + } + }); + }); + + test("deterministic pagination and malformed cursor", () => { + withHome((home) => { + seedProjection(home); + const first = queryLabVerdicts({}, undefined, 1, home); + expect(first.items.length).toBe(1); + if (!first.nextCursor) return; + const second = queryLabVerdicts({}, first.nextCursor, 1, home); + expect(second.items[0]?.projectionKey).not.toBe(first.items[0]?.projectionKey); + expect(() => queryLabVerdicts({}, "not-a-cursor", undefined, home)).toThrow(InvalidCursorError); + const tampered = encodeLabCursor({ + v: 1, + k: "verdicts", + a: 1, + p: "x", + f: "tampered", + }); + expect(() => queryLabVerdicts({}, tampered, undefined, home)).toThrow(InvalidCursorError); + }); + }); + + test("max limit enforcement", () => { + withHome((home) => { + seedProjection(home); + const page = queryLabVerdicts({}, undefined, LAB_QUERY_MAX_PAGE_SIZE + 50, home); + expect(page.items.length).toBeLessThanOrEqual(LAB_QUERY_MAX_PAGE_SIZE); + }); + }); + + test("subjects observations events artifacts catalogue", () => { + withHome((home) => { + seedProjection(home); + const subjects = queryLabSubjects(undefined, undefined, undefined, home); + expect(subjects.items.length).toBeGreaterThan(0); + const subject = queryLabSubjectById(subjects.items[0]!.subjectId, home); + expect(subject).not.toBeNull(); + + const observations = queryLabObservations({}, undefined, undefined, home); + expect(observations.items.length).toBeGreaterThan(0); + + const events = queryLabEvents({}, undefined, undefined, home); + expect(events.items.length).toBeGreaterThan(0); + const event = queryLabEventById(events.items[0]!.eventId, home); + expect(event).not.toBeNull(); + expect(event).not.toHaveProperty("payload_json"); + + const artifacts = queryLabArtifacts({}, undefined, undefined, home); + expect(artifacts.items.length).toBeGreaterThan(0); + const artifact = queryLabArtifactByDigest(artifacts.items[0]!.digest, home); + expect(artifact).not.toBeNull(); + + const catalog = queryLabCatalogEntries({ layer: "protocol_conformance" }); + expect(catalog.length).toBeGreaterThan(0); + }); + }); + + test("read calls do not mutate ledger sqlite artifacts", () => { + withHome((home) => { + seedProjection(home); + const ledger = join(home, "lab", "compatibility.jsonl"); + const sqlite = join(home, "lab", "compatibility.sqlite"); + const ledgerBefore = readFileSync(ledger); + const sqliteBefore = readFileSync(sqlite); + const ledgerMtime = statSync(ledger).mtimeMs; + const sqliteMtime = statSync(sqlite).mtimeMs; + + queryLabStatus(home); + queryLabVerdicts({}, undefined, undefined, home); + queryLabSubjects(undefined, undefined, undefined, home); + queryLabObservations({}, undefined, undefined, home); + queryLabEvents({}, undefined, undefined, home); + queryLabArtifacts({}, undefined, undefined, home); + + expect(readFileSync(ledger).equals(ledgerBefore)).toBe(true); + expect(readFileSync(sqlite).equals(sqliteBefore)).toBe(true); + expect(statSync(ledger).mtimeMs).toBe(ledgerMtime); + expect(statSync(sqlite).mtimeMs).toBe(sqliteMtime); + }); + }); +}); + +describe("CL-04 management API", () => { + test("lab status and verdict routes", async () => { + await withHome(async (home) => { + seedProjection(home); + const statusRes = await apiGet(home, "/api/lab/status"); + expect(statusRes.status).toBe(200); + const statusBody = await statusRes.json() as { projectionAvailable: boolean }; + expect(statusBody.projectionAvailable).toBe(true); + + const verdictsRes = await apiGet(home, "/api/lab/verdicts?limit=5&layer=protocol_conformance"); + expect(verdictsRes.status).toBe(200); + const verdictsBody = await verdictsRes.json() as { verdicts: unknown[]; hasMore: boolean }; + expect(verdictsBody.verdicts.length).toBeGreaterThan(0); + expect(verdictsBody).not.toHaveProperty("payload_json"); + }); + }); + + test("invalid filters and not found", async () => { + await withHome(async (home) => { + seedProjection(home); + const badLayer = await apiGet(home, "/api/lab/verdicts?layer=bad"); + expect(badLayer.status).toBe(400); + const body = await badLayer.json() as { error: { code: string } }; + expect(body.error.code).toBe("invalid_layer"); + + const missing = await apiGet(home, "/api/lab/subjects/unknown-subject-id"); + expect(missing.status).toBe(404); + + const slash = await apiGet(home, "/api/lab/events/ev%2Fent"); + expect(slash.status).toBe(404); + }); + }); + + test("missing projection returns unavailable on data routes", async () => { + await withHome(async (home) => { + const res = await apiGet(home, "/api/lab/verdicts"); + expect(res.status).toBe(503); + const body = await res.json() as { error: { code: string } }; + expect(body.error.code).toBe("lab_projection_unavailable"); + }); + }); + + test("catalog route returns digest-backed metadata", async () => { + await withHome(async (home) => { + const res = await apiGet(home, "/api/lab/catalog?layer=protocol_conformance"); + expect(res.status).toBe(200); + const body = await res.json() as { scenarios: Array<{ scenarioManifestDigest: string }> }; + expect(body.scenarios.length).toBeGreaterThan(0); + expect(body.scenarios[0]?.scenarioManifestDigest).toMatch(/^[a-f0-9]{64}$/); + }); + }); +}); + +describe("CL-04 CLI", () => { + test("lab skips codex shim autorestore", () => { + expect(skipsCodexShimAutoRestore("lab", [])).toBe(true); + }); + + test("lab status human and json without daemon", async () => { + await withHome(async (home) => { + seedProjection(home); + const code = await handleLabCommand(["status"], { configDir: home }); + expect(code).toBe(0); + const jsonCode = await handleLabCommand(["status", "--json"], { configDir: home }); + expect(jsonCode).toBe(0); + }); + }); + + test("lab verdicts and invalid args", async () => { + await withHome(async (home) => { + seedProjection(home); + expect(await handleLabCommand(["verdicts", "--json", "--limit", "1"], { configDir: home })).toBe(0); + expect(await handleLabCommand(["unknown-sub"], { configDir: home })).toBe(2); + }); + }); + + test("lab unavailable projection", async () => { + await withHome(async (home) => { + expect(await handleLabCommand(["status", "--json"], { configDir: home })).toBe(0); + expect(await handleLabCommand(["verdicts"], { configDir: home })).toBe(2); + }); + }); +}); + +describe("CL-04 privacy boundary", () => { + test("sanitizePublicText redacts secrets paths and urls", () => { + const raw = "Bearer secret-token sk-abcdef1234567890 https://user:pass@example.com/path C:\\Users\\secret\\file.txt /home/user/secret"; + const sanitized = sanitizePublicText(raw, 512) ?? ""; + expect(sanitized).not.toContain("secret-token"); + expect(sanitized).not.toContain("sk-abcdef"); + expect(sanitized).not.toContain("user:pass"); + expect(sanitized).not.toContain("C:\\Users"); + expect(sanitized).not.toContain("/home/user"); + }); + + test("corruption and artifact errors do not leak raw detail", () => { + withHome((home) => { + seedProjection(home); + const sqlitePath = join(home, "lab", "compatibility.sqlite"); + const db = new Database(sqlitePath); + db.prepare( + "INSERT INTO corruption(kind, line_number, event_id, detail) VALUES (?, ?, ?, ?)", + ).run( + "malformed_line", + 1, + null, + "Bearer access-token-value-REDTEST https://user:pw@chatgpt.com C:\\Users\\example\\secret\\path", + ); + db.prepare("UPDATE artifacts SET last_error = ? WHERE rowid = 1").run( + "sk-test-1234567890abcdef", + ); + db.close(); + + const artifacts = queryLabArtifacts({}, undefined, undefined, home); + for (const a of artifacts.items) { + const serialized = JSON.stringify(a); + expect(serialized).not.toContain("sk-test"); + expect(serialized).not.toContain("Bearer"); + } + }); + }); +}); From 1450238ee5d186d74e3477cbefdbe0aad6403d35 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:57:36 +0200 Subject: [PATCH 2/2] fix(lab): address PR review findings --- .../001_pr_stack_status.md | 6 +- .../004_cl04_read_surfaces.md | 20 +-- src/cli/codex-shim-autorestore.ts | 1 + src/cli/help.ts | 2 +- src/cli/lab.ts | 159 ++++++++++++++---- src/lab/query/connection.ts | 36 +++- src/lab/query/dto-map.ts | 2 +- src/lab/query/queries.ts | 16 +- src/server/management/lab-routes.ts | 17 +- tests/lab-read-surfaces.test.ts | 148 ++++++++++++++-- 10 files changed, 325 insertions(+), 82 deletions(-) diff --git a/devlog/_plan/260807_compatibility_lab/001_pr_stack_status.md b/devlog/_plan/260807_compatibility_lab/001_pr_stack_status.md index 50b6f1f2e2..2fde00f849 100644 --- a/devlog/_plan/260807_compatibility_lab/001_pr_stack_status.md +++ b/devlog/_plan/260807_compatibility_lab/001_pr_stack_status.md @@ -157,10 +157,8 @@ Claims cannot produce `PROBED`/`VERIFIED`. - CL-00: **ACCEPTED** (merged #1286). - CL-01: **MERGED** via #1320 at `4bb249b756abd468c675d2d92fffe4da95ad3e2a`. - CL-02: **MERGED** via #1333 at `025c37916225dd685d9217e5b40190600f06d278`; post-merge hardening #1343 is also **MERGED** at `eee2dab4d1bbacefce56057adad51d734f346702`; final closure is tracked in #1348. -- CL-03: **DRAFT PR OPEN** ([#1352](https://github.com/lidge-jun/opencodex/pull/1352)) on - `feat/cl-03-live-route-probes` from `4f746d13799888ea0a8c7a111aa2ad61c2126ea0`; - implementation head `003f7402f49bfe8dd710a7beba52f717051bfadf`. Not accepted. -- CL-04: **NOT STARTED** (blocked until CL-03 independent acceptance and review reconciliation). +- CL-03: **ACCEPTED/CLOSED** via [#1352](https://github.com/lidge-jun/opencodex/pull/1352), merged to `dev` at `68c71a4e9cdf882d812f09fd94783a28749db629`. +- CL-04: **IMPLEMENTATION IN PROGRESS**, authorized from accepted CL-03 merge `68c71a4e9cdf882d812f09fd94783a28749db629`. ## CL-03 implementation log (2026-08-09) diff --git a/devlog/_plan/260807_compatibility_lab/004_cl04_read_surfaces.md b/devlog/_plan/260807_compatibility_lab/004_cl04_read_surfaces.md index e527bbbe3c..9f908ec516 100644 --- a/devlog/_plan/260807_compatibility_lab/004_cl04_read_surfaces.md +++ b/devlog/_plan/260807_compatibility_lab/004_cl04_read_surfaces.md @@ -5,7 +5,7 @@ - **Phase:** CL-04 (read-surface only) - **Starting `upstream/dev` SHA:** `68c71a4e9cdf882d812f09fd94783a28749db629` (merge #1352) - **Branch:** `feat/cl-04-lab-read-surfaces` -- **PR target:** `lidge-jun/opencodex:dev` (draft from `Wibias/opencodex`) +- **PR:** [#1378](https://github.com/lidge-jun/opencodex/pull/1378) → `lidge-jun/opencodex:dev` - **CL-05:** not started ## Scope delivered @@ -36,19 +36,19 @@ ## Validation (local) -- `bun x tsc --noEmit` -- `bun test tests/lab-read-surfaces.test.ts` -- `bun test tests/lab-conformance-harness.test.ts` -- `bun test tests/lab-evidence-ledger.test.ts` -- `bun test tests/lab-live-probe.test.ts` -- `bun test tests/lab-live-sandbox.test.ts` -- `bun run privacy:scan` +- `bun x tsc --noEmit` — passed +- `bun test tests/lab-read-surfaces.test.ts` — 17/17 passed +- `bun test tests/lab-conformance-harness.test.ts` — 17/17 passed +- `bun test tests/lab-evidence-ledger.test.ts` — 37/41 passed; 4 pre-existing Windows SQLite `EPERM`/file-lock flakes in `wipeSqlite` during repeated `rebuildLabProjection` (same failures on base `68c71a4` without CL-04) +- `bun test tests/lab-live-probe.test.ts` — 19/19 passed +- `bun test tests/lab-live-sandbox.test.ts` — 17/17 passed +- `bun run privacy:scan` — passed ## Acceptance blockers -- Draft PR not yet opened / CI not yet green on branch +- PR #1378 requires final CI after review remediation - Independent acceptance review not performed -- CodeRabbit/reviewer findings on CL-04 PR not yet reconciled +- Reviewer findings must be reconciled before CL-04 acceptance ## Out of scope (confirmed) diff --git a/src/cli/codex-shim-autorestore.ts b/src/cli/codex-shim-autorestore.ts index 819fa61d9b..b41e509e86 100644 --- a/src/cli/codex-shim-autorestore.ts +++ b/src/cli/codex-shim-autorestore.ts @@ -17,6 +17,7 @@ const DEFAULT_DEPS: CodexShimAutoRestoreCliDeps = { export function skipsCodexShimAutoRestore(command: string | undefined, args: string[]): boolean { if (command === "uninstall" || command === "remove") return true; + // `lab` is read-only inspection; it must not trigger shim side effects. if (command === "lab") return true; return command === "codex-shim" && ["install", "uninstall", "remove"].includes(args[1] ?? ""); } diff --git a/src/cli/help.ts b/src/cli/help.ts index c44e0b2b1f..2a8f919bb8 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -274,7 +274,7 @@ const helpEntries: Record = { "observations Paginated observation rows from the projection.", "events Event history; event returns one safe typed event.", "artifacts Artifact metadata only (no content download).", - "catalog Packaged protocol/live scenario catalogue metadata.", + "catalog Packaged protocol/live scenario catalog metadata.", "Reads never rebuild the projection, trigger probes, or require the proxy.", ], }, diff --git a/src/cli/lab.ts b/src/cli/lab.ts index ef04e27410..1fdfaf8647 100644 --- a/src/cli/lab.ts +++ b/src/cli/lab.ts @@ -4,13 +4,19 @@ * Local SQLite projection reads; no daemon, network, probes, or rebuilds. */ import { getConfigDir } from "../config"; -import type { - ArtifactClass, - CompatibilityVerdict, - EvidenceLayer, - ExecutionMode, - LabEventKind, - ObservationOutcome, +import { + ARTIFACT_CLASSES, + EVIDENCE_LAYERS, + EVENT_KINDS, + EXECUTION_MODES, + OUTCOMES, + VERDICTS, + type ArtifactClass, + type CompatibilityVerdict, + type EvidenceLayer, + type ExecutionMode, + type LabEventKind, + type ObservationOutcome, } from "../lab/constants"; import { InvalidCursorError, @@ -29,6 +35,7 @@ import { } from "../lab/query"; import { CliUsageError, + RuntimeApiError, printData, rejectArgs, runCliAction, @@ -49,10 +56,20 @@ const USAGE = `Usage: ocx lab artifact [--json] ocx lab catalog [--layer ] [--suite ] [--json]`; +const ARTIFACT_STATUSES = ["present", "corrupt", "purged_unavailable"] as const; +type ArtifactStatus = (typeof ARTIFACT_STATUSES)[number]; + export interface LabCliDeps { configDir?: string; } +class LabStateError extends RuntimeApiError { + constructor(message: string) { + super(message, 503, null); + this.name = "LabStateError"; + } +} + function labErrorMessage(err: unknown): string { if (err instanceof LabProjectionUnavailableError) return "lab projection is not available"; if (err instanceof LabProjectionIncompatibleError) return "lab projection schema or spec version is incompatible"; @@ -60,6 +77,33 @@ function labErrorMessage(err: unknown): string { return "lab read failed"; } +function takeEnumOption( + args: string[], + flag: string, + values: readonly T[], + message: string, +): T | undefined { + const raw = takeOption(args, flag); + if (raw === undefined) return undefined; + if (!(values as readonly string[]).includes(raw)) { + throw new CliUsageError(message, USAGE); + } + return raw as T; +} + +function assertRange(from: number | undefined, to: number | undefined): void { + if (from !== undefined && to !== undefined && from > to) { + throw new CliUsageError("--from must not be greater than --to", USAGE); + } +} + +function appendPaginationHint( + lines: string[], + page: { hasMore: boolean; nextCursor?: string | null }, +): void { + if (page.hasMore) lines.push(`(more available; pass --cursor ${page.nextCursor ?? ""})`); +} + function statusSummary(status: ReturnType): string[] { if (!status.projectionAvailable) { if (status.projectionIncompatible) return ["Lab projection: incompatible"]; @@ -76,39 +120,39 @@ function statusSummary(status: ReturnType): string[] { ]; } -function verdictLines(page: Awaited>): string[] { +function verdictLines(page: ReturnType): string[] { const lines = page.items.map((v) => `${v.verdict} ${v.evidenceLayer} ${v.suiteId} subject=${v.subjectId} asOf=${v.asOf}`, ); - if (page.hasMore) lines.push(`(more available; pass --cursor ${page.nextCursor ?? ""})`); + appendPaginationHint(lines, page); return lines.length > 0 ? lines : ["No verdicts"]; } -function subjectListLines(page: Awaited>): string[] { +function subjectListLines(page: ReturnType): string[] { const lines = page.items.map((s) => `${s.subjectId} (${s.subjectKind})`); - if (page.hasMore) lines.push(`(more available; pass --cursor ${page.nextCursor ?? ""})`); + appendPaginationHint(lines, page); return lines.length > 0 ? lines : ["No subjects"]; } -function observationLines(page: Awaited>): string[] { +function observationLines(page: ReturnType): string[] { const lines = page.items.map((o) => `${o.outcome} ${o.evidenceLayer} ${o.scenarioId} event=${o.eventId} completed=${o.completedAt}`, ); - if (page.hasMore) lines.push(`(more available; pass --cursor ${page.nextCursor ?? ""})`); + appendPaginationHint(lines, page); return lines.length > 0 ? lines : ["No observations"]; } -function eventListLines(page: Awaited>): string[] { +function eventListLines(page: ReturnType): string[] { const lines = page.items.map((e) => `${e.eventKind} ${e.eventId} recorded=${e.recordedAt}${e.excluded ? " excluded" : ""}`, ); - if (page.hasMore) lines.push(`(more available; pass --cursor ${page.nextCursor ?? ""})`); + appendPaginationHint(lines, page); return lines.length > 0 ? lines : ["No events"]; } -function artifactLines(page: Awaited>): string[] { +function artifactLines(page: ReturnType): string[] { const lines = page.items.map((a) => `${a.status} ${a.digest} class=${a.artifactClass ?? "unknown"}`); - if (page.hasMore) lines.push(`(more available; pass --cursor ${page.nextCursor ?? ""})`); + appendPaginationHint(lines, page); return lines.length > 0 ? lines : ["No artifacts"]; } @@ -116,14 +160,15 @@ function catalogLines(scenarios: ReturnType): str const lines = scenarios.map((s) => `${s.evidenceLayer} ${s.suiteId} ${s.scenarioId} digest=${s.scenarioManifestDigest.slice(0, 12)}…`, ); - return lines.length > 0 ? lines : ["No catalogue scenarios"]; + return lines.length > 0 ? lines : ["No catalog scenarios"]; } export async function handleLabCommand(argv: string[], deps: LabCliDeps = {}): Promise { return runCliAction(async () => { const configDir = deps.configDir ?? getConfigDir(); - const [sub = "status", ...rest] = argv; - const wantsJson = takeFlag(rest, "--json"); + const argvCopy = [...argv]; + const wantsJson = takeFlag(argvCopy, "--json"); + const [sub = "status", ...rest] = argvCopy; try { switch (sub) { @@ -135,11 +180,22 @@ export async function handleLabCommand(argv: string[], deps: LabCliDeps = {}): P } case "verdicts": { const subjectId = takeOption(rest, "--subject"); - const layer = takeOption(rest, "--layer") as EvidenceLayer | undefined; + const layer = takeEnumOption( + rest, + "--layer", + EVIDENCE_LAYERS, + "--layer must be a supported evidence layer", + ); const suiteId = takeOption(rest, "--suite"); - const verdict = takeOption(rest, "--verdict") as CompatibilityVerdict | undefined; + const verdict = takeEnumOption( + rest, + "--verdict", + VERDICTS, + "--verdict must be a supported compatibility verdict", + ); const from = takeIntegerOption(rest, "--from", { min: 0 }); const to = takeIntegerOption(rest, "--to", { min: 0 }); + assertRange(from, to); const limit = takeIntegerOption(rest, "--limit", { min: 1 }); const cursor = takeOption(rest, "--cursor"); rejectArgs(rest, USAGE); @@ -170,18 +226,34 @@ export async function handleLabCommand(argv: string[], deps: LabCliDeps = {}): P rejectArgs(rest, USAGE); const subject = queryLabSubjectById(subjectId, configDir); if (!subject) throw new CliUsageError("unknown subject", USAGE); - printData({ subjectId, subject }, wantsJson, [`Subject ${subjectId}`]); + printData({ subject }, wantsJson, [`Subject ${subjectId}`]); return; } case "observations": { const subjectId = takeOption(rest, "--subject"); - const layer = takeOption(rest, "--layer") as EvidenceLayer | undefined; + const layer = takeEnumOption( + rest, + "--layer", + EVIDENCE_LAYERS, + "--layer must be a supported evidence layer", + ); const suiteId = takeOption(rest, "--suite"); const scenarioId = takeOption(rest, "--scenario"); - const outcome = takeOption(rest, "--outcome") as ObservationOutcome | undefined; - const executionMode = takeOption(rest, "--execution-mode") as ExecutionMode | undefined; + const outcome = takeEnumOption( + rest, + "--outcome", + OUTCOMES, + "--outcome must be a supported observation outcome", + ); + const executionMode = takeEnumOption( + rest, + "--execution-mode", + EXECUTION_MODES, + "--execution-mode must be a supported execution mode", + ); const from = takeIntegerOption(rest, "--from", { min: 0 }); const to = takeIntegerOption(rest, "--to", { min: 0 }); + assertRange(from, to); const limit = takeIntegerOption(rest, "--limit", { min: 1 }); const cursor = takeOption(rest, "--cursor"); rejectArgs(rest, USAGE); @@ -199,11 +271,20 @@ export async function handleLabCommand(argv: string[], deps: LabCliDeps = {}): P return; } case "events": { - const eventKind = takeOption(rest, "--event-kind") as LabEventKind | undefined; + const eventKind = takeEnumOption( + rest, + "--event-kind", + EVENT_KINDS, + "--event-kind must be a supported lab event kind", + ); const subjectId = takeOption(rest, "--subject"); const from = takeIntegerOption(rest, "--from", { min: 0 }); const to = takeIntegerOption(rest, "--to", { min: 0 }); + assertRange(from, to); const excludedRaw = takeOption(rest, "--excluded"); + if (excludedRaw !== undefined && excludedRaw !== "true" && excludedRaw !== "false") { + throw new CliUsageError("--excluded must be true or false", USAGE); + } const limit = takeIntegerOption(rest, "--limit", { min: 1 }); const cursor = takeOption(rest, "--cursor"); rejectArgs(rest, USAGE); @@ -228,8 +309,18 @@ export async function handleLabCommand(argv: string[], deps: LabCliDeps = {}): P return; } case "artifacts": { - const status = takeOption(rest, "--status") as "present" | "corrupt" | "purged_unavailable" | undefined; - const artifactClass = takeOption(rest, "--artifact-class") as ArtifactClass | undefined; + const status = takeEnumOption( + rest, + "--status", + ARTIFACT_STATUSES, + "--status must be present, corrupt, or purged_unavailable", + ); + const artifactClass = takeEnumOption( + rest, + "--artifact-class", + ARTIFACT_CLASSES, + "--artifact-class must be a supported artifact class", + ); const limit = takeIntegerOption(rest, "--limit", { min: 1 }); const cursor = takeOption(rest, "--cursor"); rejectArgs(rest, USAGE); @@ -248,7 +339,12 @@ export async function handleLabCommand(argv: string[], deps: LabCliDeps = {}): P return; } case "catalog": { - const layer = takeOption(rest, "--layer") as EvidenceLayer | undefined; + const layer = takeEnumOption( + rest, + "--layer", + EVIDENCE_LAYERS, + "--layer must be a supported evidence layer", + ); const suiteId = takeOption(rest, "--suite"); rejectArgs(rest, USAGE); const scenarios = queryLabCatalogEntries({ layer, suiteId }); @@ -260,6 +356,9 @@ export async function handleLabCommand(argv: string[], deps: LabCliDeps = {}): P } } catch (err) { if (err instanceof CliUsageError) throw err; + if (err instanceof LabProjectionUnavailableError || err instanceof LabProjectionIncompatibleError) { + throw new LabStateError(labErrorMessage(err)); + } throw new CliUsageError(labErrorMessage(err), USAGE); } }); diff --git a/src/lab/query/connection.ts b/src/lab/query/connection.ts index 4f9465d28e..d58b61e995 100644 --- a/src/lab/query/connection.ts +++ b/src/lab/query/connection.ts @@ -13,6 +13,28 @@ export interface LabReadConnection { builtAtMs: number; } +const COUNTABLE_TABLES = [ + "events", + "subjects", + "observations", + "claims", + "verdicts", + "artifacts", + "corruption", +] as const; + +export type CountableTable = (typeof COUNTABLE_TABLES)[number]; + +const COUNT_TABLE_SQL: Record = { + events: "SELECT COUNT(*) AS c FROM events", + subjects: "SELECT COUNT(*) AS c FROM subjects", + observations: "SELECT COUNT(*) AS c FROM observations", + claims: "SELECT COUNT(*) AS c FROM claims", + verdicts: "SELECT COUNT(*) AS c FROM verdicts", + artifacts: "SELECT COUNT(*) AS c FROM artifacts", + corruption: "SELECT COUNT(*) AS c FROM corruption", +}; + export function resolveLabSqlitePath(configDir?: string): string { return labSqlitePath(configDir); } @@ -22,7 +44,12 @@ export function openLabReadConnection(configDir?: string): LabReadConnection { if (!existsSync(sqlitePath)) { throw new LabProjectionUnavailableError(); } - const db = new Database(sqlitePath, { readonly: true }); + let db: Database; + try { + db = new Database(sqlitePath, { readonly: true }); + } catch { + throw new LabProjectionUnavailableError(); + } try { const metaRows = db .query("SELECT key, value FROM schema_meta") @@ -71,7 +98,10 @@ export function closeLabReadConnection(conn: LabReadConnection): void { conn.db.close(); } -export function countTable(conn: LabReadConnection, table: string): number { - const row = conn.db.query(`SELECT COUNT(*) AS c FROM ${table}`).get() as { c: number }; +export function countTable(conn: LabReadConnection, table: CountableTable): number { + if (!(COUNTABLE_TABLES as readonly string[]).includes(table)) { + throw new LabProjectionUnavailableError(); + } + const row = conn.db.query(COUNT_TABLE_SQL[table]).get() as { c: number }; return Number(row.c); } diff --git a/src/lab/query/dto-map.ts b/src/lab/query/dto-map.ts index a35e5b57fa..cc7bcc97c5 100644 --- a/src/lab/query/dto-map.ts +++ b/src/lab/query/dto-map.ts @@ -209,7 +209,7 @@ export function mapValidatedEventToDto(event: LabEvent, excluded: boolean, exclu producer: event.producer, producerVersion: event.producerVersion, excluded, - exclusionReason, + exclusionReason: sanitizePublicText(exclusionReason), }; if (event.eventKind === "observation") { const obs = event as ObservationEvent; diff --git a/src/lab/query/queries.ts b/src/lab/query/queries.ts index be3b09d28d..1c874ebd0f 100644 --- a/src/lab/query/queries.ts +++ b/src/lab/query/queries.ts @@ -25,7 +25,6 @@ import { mapObservationRow, mapSubjectJson, mapSubjectListRow, - mapValidatedEventToDto, mapVerdictRow, parseEventPayloadToDto, } from "./dto-map"; @@ -46,7 +45,6 @@ import type { VerdictDto, VerdictFilters, } from "./types"; -import { validateLabEvent } from "../events/validate"; function clampLimit(limit: number | undefined): number { if (limit === undefined) return LAB_QUERY_DEFAULT_PAGE_SIZE; @@ -336,12 +334,11 @@ export function queryLabEvents( where.push("(e.recorded_at < ? OR (e.recorded_at = ? AND e.event_id < ?))"); params.push(decoded.r, decoded.r, decoded.e); } - const joinSql = ""; const whereSql = where.length > 0 ? `WHERE ${where.join(" AND ")}` : ""; const rows = conn.db .query( - `SELECT DISTINCT e.event_id, e.event_kind, e.recorded_at, e.excluded, e.exclusion_reason - FROM events e ${joinSql} + `SELECT e.event_id, e.event_kind, e.recorded_at, e.excluded, e.exclusion_reason + FROM events e ${whereSql} ORDER BY e.recorded_at DESC, e.event_id DESC LIMIT ?`, @@ -372,14 +369,7 @@ export function queryLabEventById(eventId: string, configDir?: string): LabEvent if (!row) return null; const excluded = Number(row.excluded) === 1; const exclusionReason = row.exclusion_reason ? String(row.exclusion_reason) : null; - const dto = parseEventPayloadToDto(row.payload_json, excluded, exclusionReason); - if (dto) return dto; - try { - const event = validateLabEvent(JSON.parse(row.payload_json)); - return mapValidatedEventToDto(event, excluded, exclusionReason); - } catch { - return null; - } + return parseEventPayloadToDto(row.payload_json, excluded, exclusionReason); }); } diff --git a/src/server/management/lab-routes.ts b/src/server/management/lab-routes.ts index 2a1243434b..3453d27855 100644 --- a/src/server/management/lab-routes.ts +++ b/src/server/management/lab-routes.ts @@ -169,6 +169,14 @@ function rejectUnsafeId(id: string, ctx: ManagementContext): Response | null { return null; } +function decodePathSegment(raw: string): string | null { + try { + return decodeURIComponent(raw); + } catch { + return null; + } +} + function paginatedEnvelope(page: { items: T[]; nextCursor?: string; hasMore: boolean }, key: string) { return { [key]: page.items, @@ -249,7 +257,8 @@ export async function handleLabRoutes(ctx: ManagementContext): Promise { seedProjection(home); const first = queryLabVerdicts({}, undefined, 1, home); expect(first.items.length).toBe(1); - if (!first.nextCursor) return; - const second = queryLabVerdicts({}, first.nextCursor, 1, home); + expect(first.hasMore).toBe(true); + expect(first.nextCursor).toBeTruthy(); + const second = queryLabVerdicts({}, first.nextCursor!, 1, home); expect(second.items[0]?.projectionKey).not.toBe(first.items[0]?.projectionKey); expect(() => queryLabVerdicts({}, "not-a-cursor", undefined, home)).toThrow(InvalidCursorError); const tampered = encodeLabCursor({ @@ -224,10 +228,17 @@ describe("CL-04 query layer", () => { queryLabStatus(home); queryLabVerdicts({}, undefined, undefined, home); - queryLabSubjects(undefined, undefined, undefined, home); + const subjectsForRead = queryLabSubjects(undefined, undefined, undefined, home); queryLabObservations({}, undefined, undefined, home); - queryLabEvents({}, undefined, undefined, home); - queryLabArtifacts({}, undefined, undefined, home); + const eventsForRead = queryLabEvents({}, undefined, undefined, home); + const artifactsForRead = queryLabArtifacts({}, undefined, undefined, home); + expect(subjectsForRead.items.length).toBeGreaterThan(0); + expect(eventsForRead.items.length).toBeGreaterThan(0); + expect(artifactsForRead.items.length).toBeGreaterThan(0); + queryLabSubjectById(subjectsForRead.items[0]!.subjectId, home); + queryLabEventById(eventsForRead.items[0]!.eventId, home); + queryLabArtifactByDigest(artifactsForRead.items[0]!.digest, home); + queryLabCatalogEntries({ layer: "protocol_conformance" }); expect(readFileSync(ledger).equals(ledgerBefore)).toBe(true); expect(readFileSync(sqlite).equals(sqliteBefore)).toBe(true); @@ -250,7 +261,9 @@ describe("CL-04 management API", () => { expect(verdictsRes.status).toBe(200); const verdictsBody = await verdictsRes.json() as { verdicts: unknown[]; hasMore: boolean }; expect(verdictsBody.verdicts.length).toBeGreaterThan(0); - expect(verdictsBody).not.toHaveProperty("payload_json"); + for (const verdict of verdictsBody.verdicts) { + expect(verdict).not.toHaveProperty("payload_json"); + } }); }); @@ -267,6 +280,11 @@ describe("CL-04 management API", () => { const slash = await apiGet(home, "/api/lab/events/ev%2Fent"); expect(slash.status).toBe(404); + + for (const path of ["/api/lab/subjects/%", "/api/lab/events/%", "/api/lab/artifacts/%"]) { + const malformed = await apiGet(home, path); + expect(malformed.status).toBe(404); + } }); }); @@ -298,25 +316,103 @@ describe("CL-04 CLI", () => { test("lab status human and json without daemon", async () => { await withHome(async (home) => { seedProjection(home); - const code = await handleLabCommand(["status"], { configDir: home }); - expect(code).toBe(0); - const jsonCode = await handleLabCommand(["status", "--json"], { configDir: home }); - expect(jsonCode).toBe(0); + const lines: string[] = []; + const originalLog = console.log; + console.log = (...args: unknown[]) => { lines.push(args.join(" ")); }; + try { + const code = await handleLabCommand(["status"], { configDir: home }); + expect(code).toBe(0); + expect(lines.join("\n")).toContain("Lab projection: available"); + + lines.length = 0; + const jsonCode = await handleLabCommand(["status", "--json"], { configDir: home }); + expect(jsonCode).toBe(0); + expect(JSON.parse(lines.join("\n")).projectionAvailable).toBe(true); + + lines.length = 0; + expect(await handleLabCommand([], { configDir: home })).toBe(0); + expect(lines.join("\n")).toContain("Lab projection: available"); + + lines.length = 0; + expect(await handleLabCommand(["--json"], { configDir: home })).toBe(0); + expect(JSON.parse(lines.join("\n")).projectionAvailable).toBe(true); + } finally { + console.log = originalLog; + } }); }); test("lab verdicts and invalid args", async () => { await withHome(async (home) => { seedProjection(home); - expect(await handleLabCommand(["verdicts", "--json", "--limit", "1"], { configDir: home })).toBe(0); - expect(await handleLabCommand(["unknown-sub"], { configDir: home })).toBe(2); + const lines: string[] = []; + const errors: string[] = []; + const originalLog = console.log; + const originalError = console.error; + console.log = (...args: unknown[]) => { lines.push(args.join(" ")); }; + console.error = (...args: unknown[]) => { errors.push(args.join(" ")); }; + try { + expect(await handleLabCommand(["verdicts", "--limit", "1"], { configDir: home })).toBe(0); + expect(lines.join("\n")).toContain("(more available; pass --cursor "); + + lines.length = 0; + expect(await handleLabCommand(["verdicts", "--json", "--limit", "1"], { configDir: home })).toBe(0); + expect(JSON.parse(lines.join("\n")).items.length).toBe(1); + + for (const command of ["subjects", "observations", "events", "artifacts", "catalog"]) { + lines.length = 0; + expect(await handleLabCommand([command], { configDir: home })).toBe(0); + expect(lines.length).toBeGreaterThan(0); + } + + const subjectId = queryLabSubjects(undefined, undefined, 1, home).items[0]!.subjectId; + lines.length = 0; + expect(await handleLabCommand(["subject", subjectId, "--json"], { configDir: home })).toBe(0); + const subjectEnvelope = JSON.parse(lines.join("\n")); + expect(subjectEnvelope.subject).toBeDefined(); + expect(subjectEnvelope).not.toHaveProperty("subjectId"); + + errors.length = 0; + expect(await handleLabCommand(["unknown-sub"], { configDir: home })).toBe(2); + expect(errors.join("\n")).toContain("unknown lab subcommand"); + + errors.length = 0; + expect(await handleLabCommand(["verdicts", "--layer", "bogus"], { configDir: home })).toBe(2); + expect(errors.join("\n")).toContain("supported evidence layer"); + + errors.length = 0; + expect(await handleLabCommand(["verdicts", "--from", "2000", "--to", "1000"], { configDir: home })).toBe(2); + expect(errors.join("\n")).toContain("--from must not be greater than --to"); + + errors.length = 0; + expect(await handleLabCommand(["events", "--excluded", "yes"], { configDir: home })).toBe(2); + expect(errors.join("\n")).toContain("--excluded must be true or false"); + } finally { + console.log = originalLog; + console.error = originalError; + } }); }); test("lab unavailable projection", async () => { await withHome(async (home) => { - expect(await handleLabCommand(["status", "--json"], { configDir: home })).toBe(0); - expect(await handleLabCommand(["verdicts"], { configDir: home })).toBe(2); + const lines: string[] = []; + const errors: string[] = []; + const originalLog = console.log; + const originalError = console.error; + console.log = (...args: unknown[]) => { lines.push(args.join(" ")); }; + console.error = (...args: unknown[]) => { errors.push(args.join(" ")); }; + try { + expect(await handleLabCommand(["status", "--json"], { configDir: home })).toBe(0); + expect(JSON.parse(lines.join("\n")).projectionAvailable).toBe(false); + errors.length = 0; + expect(await handleLabCommand(["verdicts"], { configDir: home })).toBe(1); + expect(errors.join("\n")).toContain("lab projection is not available"); + expect(errors.join("\n")).not.toContain("Usage:"); + } finally { + console.log = originalLog; + console.error = originalError; + } }); }); }); @@ -335,9 +431,10 @@ describe("CL-04 privacy boundary", () => { test("corruption and artifact errors do not leak raw detail", () => { withHome((home) => { seedProjection(home); + const eventId = queryLabEvents({}, undefined, 1, home).items[0]!.eventId; const sqlitePath = join(home, "lab", "compatibility.sqlite"); const db = new Database(sqlitePath); - db.prepare( + const corruptionInsert = db.prepare( "INSERT INTO corruption(kind, line_number, event_id, detail) VALUES (?, ?, ?, ?)", ).run( "malformed_line", @@ -345,17 +442,34 @@ describe("CL-04 privacy boundary", () => { null, "Bearer access-token-value-REDTEST https://user:pw@chatgpt.com C:\\Users\\example\\secret\\path", ); - db.prepare("UPDATE artifacts SET last_error = ? WHERE rowid = 1").run( + expect(corruptionInsert.changes).toBe(1); + const artifactUpdate = db.prepare("UPDATE artifacts SET last_error = ? WHERE rowid = 1").run( "sk-test-1234567890abcdef", ); + expect(artifactUpdate.changes).toBe(1); + const eventUpdate = db.prepare("UPDATE events SET exclusion_reason = ? WHERE event_id = ?").run( + "Bearer detail-event-token C:\\Users\\detail\\secret", + eventId, + ); + expect(eventUpdate.changes).toBe(1); db.close(); + const status = queryLabStatus(home); + expect(status.corruptionCount).toBeGreaterThan(0); + expect(JSON.stringify(status)).not.toContain("access-token-value-REDTEST"); + const artifacts = queryLabArtifacts({}, undefined, undefined, home); + expect(artifacts.items.length).toBeGreaterThan(0); for (const a of artifacts.items) { const serialized = JSON.stringify(a); expect(serialized).not.toContain("sk-test"); - expect(serialized).not.toContain("Bearer"); } + + const event = queryLabEventById(eventId, home); + expect(event).not.toBeNull(); + const serializedEvent = JSON.stringify(event); + expect(serializedEvent).not.toContain("detail-event-token"); + expect(serializedEvent).not.toContain("C:\\Users"); }); }); });