From 42288db1cfe07811415dd3d0d54b280a85f08089 Mon Sep 17 00:00:00 2001 From: Nate Summers Date: Tue, 14 Jul 2026 10:42:53 -0700 Subject: [PATCH 1/2] feat(validation): persist review evidence and routing Add typed validation-review results, responsive screenshot evidence, root-controlled cyclical transitions, migration backfill, and deterministic regression coverage. Co-Authored-By: OpenAI Codex GPT-5 --- agent/lib/screenshot-artifact-uri.ts | 19 + agent/validation-review-agent.ts | 205 ++ agent/validation-review-fixture.ts | 219 ++ drizzle/0004_mushy_corsair.sql | 1 + ...ckfill_validation_screenshot_artifacts.sql | 20 + drizzle/meta/0004_snapshot.json | 2259 +++++++++++++++++ drizzle/meta/0005_snapshot.json | 2259 +++++++++++++++++ drizzle/meta/_journal.json | 14 + package.json | 1 + playwright.validation-evidence.config.ts | 21 + schemas/loop-manifest.schema.json | 3 +- schemas/loop-manifest.ts | 1 + src/db/schema.ts | 1 + src/lib/loops/development-run-transitions.ts | 641 ++++- src/lib/loops/development-run.ts | 18 +- src/lib/loops/manifest.ts | 7 + src/lib/loops/screenshot-evidence.ts | 291 +++ src/lib/loops/validation-report.ts | 21 + src/lib/loops/validation-runner.ts | 51 +- src/lib/seed/demo-data.ts | 14 + .../agent/validation-review-agent.test.ts | 133 + tests/unit/db/migrations.test.ts | 52 +- .../github/webhook-store.integration.test.ts | 6 +- tests/unit/github/webhooks.test.ts | 4 +- .../loops/development-run-transitions.test.ts | 25 + tests/unit/loops/development-run.test.ts | 11 +- .../validation-review-transition.test.ts | 694 +++++ tests/unit/loops/validation-runner.test.ts | 36 +- .../unit/loops/validation-screenshots.test.ts | 241 ++ 29 files changed, 7217 insertions(+), 51 deletions(-) create mode 100644 agent/lib/screenshot-artifact-uri.ts create mode 100644 agent/validation-review-agent.ts create mode 100644 agent/validation-review-fixture.ts create mode 100644 drizzle/0004_mushy_corsair.sql create mode 100644 drizzle/0005_backfill_validation_screenshot_artifacts.sql create mode 100644 drizzle/meta/0004_snapshot.json create mode 100644 drizzle/meta/0005_snapshot.json create mode 100644 playwright.validation-evidence.config.ts create mode 100644 src/lib/loops/screenshot-evidence.ts create mode 100644 tests/unit/agent/validation-review-agent.test.ts create mode 100644 tests/unit/loops/validation-review-transition.test.ts create mode 100644 tests/unit/loops/validation-screenshots.test.ts diff --git a/agent/lib/screenshot-artifact-uri.ts b/agent/lib/screenshot-artifact-uri.ts new file mode 100644 index 0000000..849c915 --- /dev/null +++ b/agent/lib/screenshot-artifact-uri.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +export const screenshotArtifactUriSchema = z + .string() + .max(2_048) + .refine((value) => { + if (!value.startsWith("artifact://") || value.includes("\\")) return false; + const segments = value.slice("artifact://".length).split("/"); + return ( + segments.length >= 2 && + segments.every( + (segment) => + segment.length > 0 && + segment !== "." && + segment !== ".." && + /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(segment), + ) + ); + }, "Unsafe screenshot artifact URI."); diff --git a/agent/validation-review-agent.ts b/agent/validation-review-agent.ts new file mode 100644 index 0000000..599c3f3 --- /dev/null +++ b/agent/validation-review-agent.ts @@ -0,0 +1,205 @@ +import { createHash } from "node:crypto"; + +import { z } from "zod"; + +import { canonicalJsonStringify } from "./lib/canonical-json"; +import { screenshotArtifactUriSchema } from "./lib/screenshot-artifact-uri"; + +export const validationReviewAgentModelLabel = "openai/gpt-5.6-terra-xhigh"; +export const validationReviewResultSchemaId = "loopworks.validation_review_result.v1"; + +const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/); +const identifierSchema = z.string().regex(/^[a-z0-9][a-z0-9-]*$/); +const safeTextSchema = z + .string() + .min(1) + .max(2_000) + .refine( + (value) => + !/(?:authorization\s*:|bearer\s+|password\s*[:=]|token\s*[:=]|secret\s*[:=]|gh[pousr]_|sk-[a-z0-9]|-----BEGIN|data:image\/|raw (?:stdout|stderr|prompt))/i.test( + value, + ), + "Review narrative contains forbidden secret-like or raw evidence content.", + ); +const safePathSchema = z.string().refine((value) => { + const path = value.replaceAll("\\", "/"); + return ( + path.length > 0 && + !path.startsWith("/") && + !/^[A-Za-z]:\//.test(path) && + !path.includes("//") && + !path.split("/").some((segment) => segment === "." || segment === "..") && + !/[;&|`$<>\n\r\0]/.test(path) + ); +}, "Unsafe repository path."); + +const validationEvidenceSchema = z + .object({ + key: z.string().min(1).max(160), + command: z.string().min(1).max(500), + outcome: z.literal("pass"), + outputSha256: sha256Schema.optional(), + }) + .strict(); +const screenshotCitationSchema = z + .object({ + id: identifierSchema, + testId: identifierSchema, + viewport: z.enum(["mobile", "laptop", "desktop"]), + width: z.number().int().positive(), + height: z.number().int().positive(), + uri: screenshotArtifactUriSchema, + sha256: sha256Schema, + }) + .strict(); + +const citationSetSchema = z.object({ + validationCitationKeys: z.array(z.string().min(1)).min(1), + screenshotCitationIds: z.array(identifierSchema), +}); + +export const validationReviewResultSchema = z + .object({ + version: z.literal(1), + schemaId: z.literal(validationReviewResultSchemaId), + model: z.literal(validationReviewAgentModelLabel), + binding: z + .object({ + runId: z.string().uuid(), + reviewAttempt: z.number().int().positive(), + planId: z.string().min(1), + planSha256: sha256Schema, + testPlanSha256: sha256Schema, + implementationResultSha256: sha256Schema, + productionPatchSha256: sha256Schema, + validationReportSha256: sha256Schema, + screenshotEvidenceSha256: sha256Schema, + repositoryFullName: z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/), + commitSha: z.string().regex(/^[a-f0-9]{40}$/), + }) + .strict(), + evidence: z + .object({ + validationResults: z.array(validationEvidenceSchema).min(1), + screenshots: z.array(screenshotCitationSchema), + }) + .strict(), + findings: z.array( + citationSetSchema + .extend({ + id: identifierSchema, + severity: z.enum(["blocker", "high", "medium", "low"]), + category: z.enum([ + "implementation", + "test-plan", + "coverage", + "fixture", + "security", + "accessibility", + "responsive", + "observability", + "architecture", + ]), + summary: safeTextSchema, + path: safePathSchema.optional(), + line: z.number().int().positive().optional(), + }) + .strict(), + ), + recommendation: citationSetSchema + .extend({ + route: z.enum(["commit", "development", "test-writing"]), + reason: safeTextSchema, + findingIds: z.array(identifierSchema), + }) + .strict(), + }) + .strict() + .superRefine((result, context) => { + const validationKeys = new Set(result.evidence.validationResults.map(({ key }) => key)); + const screenshotIds = new Set(result.evidence.screenshots.map(({ id }) => id)); + const findingIds = new Set(result.findings.map(({ id }) => id)); + if (validationKeys.size !== result.evidence.validationResults.length) { + context.addIssue({ code: "custom", message: "Validation evidence keys must be unique." }); + } + if (screenshotIds.size !== result.evidence.screenshots.length) { + context.addIssue({ code: "custom", message: "Screenshot evidence ids must be unique." }); + } + if (findingIds.size !== result.findings.length) { + context.addIssue({ code: "custom", message: "Finding ids must be unique." }); + } + + const validateCitations = (input: { + validationCitationKeys: string[]; + screenshotCitationIds: string[]; + }) => { + if (new Set(input.validationCitationKeys).size !== input.validationCitationKeys.length) { + context.addIssue({ code: "custom", message: "Validation citations must be unique." }); + } + if (new Set(input.screenshotCitationIds).size !== input.screenshotCitationIds.length) { + context.addIssue({ code: "custom", message: "Screenshot citations must be unique." }); + } + for (const key of input.validationCitationKeys) { + if (!validationKeys.has(key)) { + context.addIssue({ code: "custom", message: `Unknown validation citation ${key}.` }); + } + } + for (const id of input.screenshotCitationIds) { + if (!screenshotIds.has(id)) { + context.addIssue({ code: "custom", message: `Unknown screenshot citation ${id}.` }); + } + } + }; + for (const finding of result.findings) { + validateCitations(finding); + if ( + ["accessibility", "responsive"].includes(finding.category) && + finding.screenshotCitationIds.length === 0 + ) { + context.addIssue({ + code: "custom", + message: `${finding.category} findings require a screenshot citation.`, + }); + } + if (finding.line && !finding.path) { + context.addIssue({ code: "custom", message: "Finding line requires a repository path." }); + } + } + validateCitations(result.recommendation); + if ( + new Set(result.recommendation.findingIds).size !== result.recommendation.findingIds.length + ) { + context.addIssue({ code: "custom", message: "Recommendation finding ids must be unique." }); + } + for (const id of result.recommendation.findingIds) { + if (!findingIds.has(id)) { + context.addIssue({ code: "custom", message: `Unknown recommendation finding ${id}.` }); + } + } + if (result.recommendation.route !== "commit" && result.recommendation.findingIds.length === 0) { + context.addIssue({ code: "custom", message: "Backward routing requires a cited finding." }); + } + if ( + result.recommendation.route === "commit" && + result.findings.some(({ severity }) => severity === "blocker" || severity === "high") + ) { + context.addIssue({ + code: "custom", + message: "Blocking or high findings cannot route to commit.", + }); + } + }); + +export type ValidationReviewResult = z.infer; + +export function computeValidationReviewDigest(value: unknown): string { + return createHash("sha256").update(canonicalJsonStringify(value)).digest("hex"); +} + +export function createValidationReviewArtifactContractMetadata() { + return { + expectedValidationReviewResultSchemaId: validationReviewResultSchemaId, + validationReviewMetadataKind: "validation_review_contract" as const, + validationReviewVersion: 1 as const, + }; +} diff --git a/agent/validation-review-fixture.ts b/agent/validation-review-fixture.ts new file mode 100644 index 0000000..3d3e90c --- /dev/null +++ b/agent/validation-review-fixture.ts @@ -0,0 +1,219 @@ +import { createHash } from "node:crypto"; +import { + computeScreenshotEvidenceDigest, + type ScreenshotEvidence, + screenshotEvidenceSchemaId, +} from "@/lib/loops/screenshot-evidence"; +import type { ValidationReportV1 } from "@/lib/loops/validation-report"; +import { + computeImplementationDigest, + type ImplementationResult, + implementationAgentModelLabel, + implementationResultSchemaId, +} from "./implementation-agent"; +import { createPlanningAgentSeedPlan } from "./planning-agent"; +import type { ValidationReviewContext } from "./subagents/validation-reviewer/lib/context"; +import { computeTestPlanDigest, testPlanSchemaId } from "./test-writing-agent"; +import { computeValidationReviewDigest } from "./validation-review-agent"; + +const sha256 = (value: string) => createHash("sha256").update(value).digest("hex"); + +export function createValidationReviewFixtureContext(): ValidationReviewContext { + const plan = createPlanningAgentSeedPlan({ + body: "## Acceptance Criteria\n- Review cites deterministic results and relevant screenshots.", + issueNumber: 49, + labels: ["area:agents", "area:validation"], + milestone: "M4", + repositoryFullName: "ncolesummers/loopworks", + repositoryRevision: { ref: "main", commitSha: "a".repeat(40) }, + title: "Validation review subagent for the code review stage", + }); + const revision = plan.repositoryRevision; + if (!revision) throw new Error("Validation-review fixture requires a pinned revision."); + const testPatch = [ + "diff --git a/tests/e2e/review.spec.ts b/tests/e2e/review.spec.ts", + "new file mode 100644", + "--- /dev/null", + "+++ b/tests/e2e/review.spec.ts", + "@@ -0,0 +1 @@", + "+test('review evidence', async () => {});", + ].join("\n"); + const testPlan = { + version: 1 as const, + schemaId: testPlanSchemaId as typeof testPlanSchemaId, + plan: { + id: plan.identity.id, + sha256: plan.identity.sha256, + repositoryFullName: plan.issue.repositoryFullName, + commitSha: revision.commitSha, + }, + acceptanceCriteria: [ + { id: "ac-1", text: "Review cites deterministic results and relevant screenshots." }, + ], + tests: [ + { + id: "browser-ac-1", + acceptanceCriterionIds: ["ac-1"], + type: "browser" as const, + path: "tests/e2e/review.spec.ts", + command: "bunx playwright test tests/e2e/review.spec.ts", + steps: ["Open the run detail and inspect the reviewed state."], + expectedFailure: { kind: "assertion" as const, message: "review evidence is missing" }, + fixtureIds: [], + }, + ], + fixtures: [], + patch: { + format: "unified-diff" as const, + content: testPatch, + sha256: sha256(testPatch), + byteCount: Buffer.byteLength(testPatch), + paths: ["tests/e2e/review.spec.ts"], + }, + }; + const productionPatch = [ + "diff --git a/src/components/review-card.tsx b/src/components/review-card.tsx", + "new file mode 100644", + "--- /dev/null", + "+++ b/src/components/review-card.tsx", + "@@ -0,0 +1 @@", + "+export const ReviewCard = () => null;", + ].join("\n"); + const implementationResult: ImplementationResult = { + version: 1, + schemaId: implementationResultSchemaId, + model: implementationAgentModelLabel, + binding: { + planId: plan.identity.id, + planSha256: plan.identity.sha256, + testPlanSha256: computeTestPlanDigest(testPlan), + testPatchSha256: testPlan.patch.sha256, + fixturesSha256: computeImplementationDigest(testPlan.fixtures), + repositoryFullName: plan.issue.repositoryFullName, + commitSha: revision.commitSha, + }, + patch: { + format: "unified-diff", + content: productionPatch, + sha256: sha256(productionPatch), + byteCount: Buffer.byteLength(productionPatch), + paths: ["src/components/review-card.tsx"], + }, + greenEvidence: [ + { + id: "green-ac-1", + testId: "browser-ac-1", + acceptanceCriterionIds: ["ac-1"], + command: "bunx playwright test tests/e2e/review.spec.ts", + testPath: "tests/e2e/review.spec.ts", + outcome: "pass", + exitCode: 0, + durationMs: 10, + executionReceipt: "b".repeat(64), + outputReference: { + uri: "artifact://green.log", + sha256: "c".repeat(64), + byteCount: 10, + redacted: true, + }, + }, + ], + validationEvidence: { + command: "bun run validate", + outcome: "pass", + exitCode: 0, + durationMs: 20, + executionReceipt: "d".repeat(64), + outputReference: { + uri: "artifact://validate.log", + sha256: "e".repeat(64), + byteCount: 10, + redacted: true, + }, + }, + }; + const validationReport: ValidationReportV1 = { + version: 1, + schemaId: "loopworks.validation_report.v1", + generatedAt: "2026-07-13T20:00:00.000Z", + overallOutcome: "pass", + counts: { failed: 0, passed: 1, skipped: 0, total: 1 }, + results: [ + { + key: "aggregate-validation", + name: "Aggregate validation", + command: "bun run validate", + durationMs: 20, + exitCode: 0, + outcome: "pass", + phase: "before_review", + produces: "validation_report", + required: true, + output: { + uri: "artifact://validation.log", + sha256: "f".repeat(64), + stdoutBytes: 10, + stderrBytes: 0, + truncated: false, + }, + }, + ], + }; + const screenshotEvidence: ScreenshotEvidence = { + version: 1, + schemaId: screenshotEvidenceSchemaId, + binding: { + repositoryFullName: plan.issue.repositoryFullName, + commitSha: revision.commitSha, + testPlanSha256: computeTestPlanDigest(testPlan), + productionPatchSha256: implementationResult.patch.sha256, + }, + uiAffecting: true, + browserTestIds: ["browser-ac-1"], + captures: ( + [ + ["mobile", 390, 844], + ["laptop", 1280, 832], + ["desktop", 1440, 960], + ] as const + ).map(([viewport, width, height], index) => ({ + id: `browser-ac-1-${viewport}`, + testId: "browser-ac-1", + viewport, + width, + height, + mimeType: "image/png" as const, + uri: `artifact://screenshots/${viewport}.png`, + sha256: String(index + 1).repeat(64), + byteCount: 100, + })), + }; + + return { + run: { + id: "00000000-0000-4000-8000-000000000049", + currentStage: "code-review", + status: "running", + }, + validationStep: { + id: "00000000-0000-4000-8000-000000000149", + status: "succeeded", + }, + reviewStep: { + id: "00000000-0000-4000-8000-000000000249", + status: "running", + attempt: 1, + }, + planStatus: "approved", + approvalStatus: "approved", + plan, + testPlan, + implementationResult, + validationReport, + screenshotEvidence, + testPlanArtifactSha256: computeTestPlanDigest(testPlan), + implementationArtifactSha256: computeImplementationDigest(implementationResult), + validationArtifactSha256: computeValidationReviewDigest(validationReport), + screenshotArtifactSha256: computeScreenshotEvidenceDigest(screenshotEvidence), + }; +} diff --git a/drizzle/0004_mushy_corsair.sql b/drizzle/0004_mushy_corsair.sql new file mode 100644 index 0000000..0bcde00 --- /dev/null +++ b/drizzle/0004_mushy_corsair.sql @@ -0,0 +1 @@ +ALTER TYPE "public"."artifact_type" ADD VALUE 'screenshot' BEFORE 'other'; \ No newline at end of file diff --git a/drizzle/0005_backfill_validation_screenshot_artifacts.sql b/drizzle/0005_backfill_validation_screenshot_artifacts.sql new file mode 100644 index 0000000..d05f89f --- /dev/null +++ b/drizzle/0005_backfill_validation_screenshot_artifacts.sql @@ -0,0 +1,20 @@ +INSERT INTO "artifacts" ("run_id", "step_id", "type", "title", "uri", "metadata") +SELECT + "run_steps"."run_id", + "run_steps"."id", + 'screenshot', + 'Validation screenshots', + 'artifact://validation/' || "run_steps"."run_id"::text || '/screenshots', + jsonb_build_object( + 'expectedScreenshotEvidenceSchemaId', 'loopworks.screenshot_evidence.v1', + 'screenshotEvidenceMetadataKind', 'screenshot_evidence_contract', + 'screenshotEvidenceVersion', 1 + ) +FROM "run_steps" +WHERE "run_steps"."stage" = 'validation' + AND NOT EXISTS ( + SELECT 1 + FROM "artifacts" + WHERE "artifacts"."step_id" = "run_steps"."id" + AND "artifacts"."type" = 'screenshot' + ); diff --git a/drizzle/meta/0004_snapshot.json b/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..ea8bbbe --- /dev/null +++ b/drizzle/meta/0004_snapshot.json @@ -0,0 +1,2259 @@ +{ + "id": "9ed14d14-2365-44f6-b397-7d4da1737858", + "prevId": "1ab74bee-a441-4310-bb27-05726691115c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "accounts_provider_provider_account_id_pk": { + "name": "accounts_provider_provider_account_id_pk", + "columns": ["provider", "provider_account_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_plans": { + "name": "agent_plans", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "loop_id": { + "name": "loop_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_number": { + "name": "issue_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'planner'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_plans_loop_id_created_at_idx": { + "name": "agent_plans_loop_id_created_at_idx", + "columns": [ + { + "expression": "loop_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_plans_run_id_created_at_idx": { + "name": "agent_plans_run_id_created_at_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_plans_loop_id_loops_id_fk": { + "name": "agent_plans_loop_id_loops_id_fk", + "tableFrom": "agent_plans", + "tableTo": "loops", + "columnsFrom": ["loop_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "agent_plans_run_id_loop_runs_id_fk": { + "name": "agent_plans_run_id_loop_runs_id_fk", + "tableFrom": "agent_plans", + "tableTo": "loop_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approval_transition_events": { + "name": "approval_transition_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "approval_id": { + "name": "approval_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "from_status": { + "name": "from_status", + "type": "approval_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "to_status": { + "name": "to_status", + "type": "approval_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "approval_transition_events_approval_created_at_idx": { + "name": "approval_transition_events_approval_created_at_idx", + "columns": [ + { + "expression": "approval_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approval_transition_events_run_created_at_idx": { + "name": "approval_transition_events_run_created_at_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approval_transition_events_approval_id_approvals_id_fk": { + "name": "approval_transition_events_approval_id_approvals_id_fk", + "tableFrom": "approval_transition_events", + "tableTo": "approvals", + "columnsFrom": ["approval_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "approval_transition_events_run_id_loop_runs_id_fk": { + "name": "approval_transition_events_run_id_loop_runs_id_fk", + "tableFrom": "approval_transition_events", + "tableTo": "loop_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approvals": { + "name": "approvals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "loop_id": { + "name": "loop_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "approval_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "approvals_loop_id_status_idx": { + "name": "approvals_loop_id_status_idx", + "columns": [ + { + "expression": "loop_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approvals_run_id_status_idx": { + "name": "approvals_run_id_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "approvals_loop_id_loops_id_fk": { + "name": "approvals_loop_id_loops_id_fk", + "tableFrom": "approvals", + "tableTo": "loops", + "columnsFrom": ["loop_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "approvals_run_id_loop_runs_id_fk": { + "name": "approvals_run_id_loop_runs_id_fk", + "tableFrom": "approvals", + "tableTo": "loop_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artifacts": { + "name": "artifacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "step_id": { + "name": "step_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "artifact_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'other'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "artifacts_run_type_idx": { + "name": "artifacts_run_type_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "artifacts_step_id_idx": { + "name": "artifacts_step_id_idx", + "columns": [ + { + "expression": "step_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "artifacts_run_id_loop_runs_id_fk": { + "name": "artifacts_run_id_loop_runs_id_fk", + "tableFrom": "artifacts", + "tableTo": "loop_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "artifacts_step_id_run_steps_id_fk": { + "name": "artifacts_step_id_run_steps_id_fk", + "tableFrom": "artifacts", + "tableTo": "run_steps", + "columnsFrom": ["step_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployments": { + "name": "deployments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "loop_id": { + "name": "loop_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'vercel'" + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_name": { + "name": "project_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "deployment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit_sha": { + "name": "commit_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inspector_url": { + "name": "inspector_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "alias_urls": { + "name": "alias_urls", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ready_at": { + "name": "ready_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "deployments_project_id_created_at_idx": { + "name": "deployments_project_id_created_at_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "deployments_run_id_created_at_idx": { + "name": "deployments_run_id_created_at_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployments_repository_id_repositories_id_fk": { + "name": "deployments_repository_id_repositories_id_fk", + "tableFrom": "deployments", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "deployments_loop_id_loops_id_fk": { + "name": "deployments_loop_id_loops_id_fk", + "tableFrom": "deployments", + "tableTo": "loops", + "columnsFrom": ["loop_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "deployments_run_id_loop_runs_id_fk": { + "name": "deployments_run_id_loop_runs_id_fk", + "tableFrom": "deployments", + "tableTo": "loop_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployments_external_id_unique": { + "name": "deployments_external_id_unique", + "nullsNotDistinct": false, + "columns": ["external_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_locks": { + "name": "idempotency_locks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "idempotency_lock_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'acquired'" + }, + "acquired_at": { + "name": "acquired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idempotency_locks_scope_status_idx": { + "name": "idempotency_locks_scope_status_idx", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idempotency_locks_expires_at_idx": { + "name": "idempotency_locks_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "idempotency_locks_key_unique": { + "name": "idempotency_locks_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.loop_events": { + "name": "loop_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "loop_id": { + "name": "loop_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_state": { + "name": "from_state", + "type": "loop_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "to_state": { + "name": "to_state", + "type": "loop_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "loop_events_loop_id_created_at_idx": { + "name": "loop_events_loop_id_created_at_idx", + "columns": [ + { + "expression": "loop_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "loop_events_loop_id_loops_id_fk": { + "name": "loop_events_loop_id_loops_id_fk", + "tableFrom": "loop_events", + "tableTo": "loops", + "columnsFrom": ["loop_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.loop_runs": { + "name": "loop_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "loop_key": { + "name": "loop_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_issue_number": { + "name": "github_issue_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "github_issue_url": { + "name": "github_issue_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "current_stage": { + "name": "current_stage", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'planning'" + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_cents": { + "name": "cost_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "loop_runs_repository_status_idx": { + "name": "loop_runs_repository_status_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "loop_runs_repository_issue_idx": { + "name": "loop_runs_repository_issue_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "github_issue_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "loop_runs_repository_id_repositories_id_fk": { + "name": "loop_runs_repository_id_repositories_id_fk", + "tableFrom": "loop_runs", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.loops": { + "name": "loops", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "github_issue_number": { + "name": "github_issue_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "loop_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'intake'" + }, + "milestone": { + "name": "milestone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "area_label": { + "name": "area_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority_label": { + "name": "priority_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_github_login": { + "name": "owner_github_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "loops_repository_issue_number_idx": { + "name": "loops_repository_issue_number_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "github_issue_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "loops_repository_id_repositories_id_fk": { + "name": "loops_repository_id_repositories_id_fk", + "tableFrom": "loops", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.observability_events": { + "name": "observability_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "step_id": { + "name": "step_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "observability_severity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metric_name": { + "name": "metric_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metric_value": { + "name": "metric_value", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "observability_events_type_created_at_idx": { + "name": "observability_events_type_created_at_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "observability_events_run_created_at_idx": { + "name": "observability_events_run_created_at_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "observability_events_trace_id_idx": { + "name": "observability_events_trace_id_idx", + "columns": [ + { + "expression": "trace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "observability_events_repository_id_repositories_id_fk": { + "name": "observability_events_repository_id_repositories_id_fk", + "tableFrom": "observability_events", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "observability_events_run_id_loop_runs_id_fk": { + "name": "observability_events_run_id_loop_runs_id_fk", + "tableFrom": "observability_events", + "tableTo": "loop_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "observability_events_step_id_run_steps_id_fk": { + "name": "observability_events_step_id_run_steps_id_fk", + "tableFrom": "observability_events", + "tableTo": "run_steps", + "columnsFrom": ["step_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.repositories": { + "name": "repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "github_repo_id": { + "name": "github_repo_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "health": { + "name": "health", + "type": "repo_health", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'healthy'" + }, + "framework": { + "name": "framework", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Unknown'" + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "ci_commands": { + "name": "ci_commands", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "docs_href": { + "name": "docs_href", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "observability_href": { + "name": "observability_href", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "design_system_href": { + "name": "design_system_href", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled_loops": { + "name": "enabled_loops", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "validation_gates": { + "name": "validation_gates", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "repositories_github_repo_id_unique": { + "name": "repositories_github_repo_id_unique", + "nullsNotDistinct": false, + "columns": ["github_repo_id"] + }, + "repositories_full_name_unique": { + "name": "repositories_full_name_unique", + "nullsNotDistinct": false, + "columns": ["full_name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.run_steps": { + "name": "run_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stage": { + "name": "stage", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "run_step_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "validation_command": { + "name": "validation_command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "validation_status": { + "name": "validation_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "run_steps_run_stage_idx": { + "name": "run_steps_run_stage_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stage", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "run_steps_run_status_idx": { + "name": "run_steps_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "run_steps_run_id_loop_runs_id_fk": { + "name": "run_steps_run_id_loop_runs_id_fk", + "tableFrom": "run_steps", + "tableTo": "loop_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_login": { + "name": "github_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "users_github_login_unique": { + "name": "users_github_login_unique", + "nullsNotDistinct": false, + "columns": ["github_login"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vercel_projects": { + "name": "vercel_projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_name": { + "name": "project_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_slug": { + "name": "team_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "production_url": { + "name": "production_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dashboard_url": { + "name": "dashboard_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "vercel_projects_repository_id_idx": { + "name": "vercel_projects_repository_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "vercel_projects_repository_id_repositories_id_fk": { + "name": "vercel_projects_repository_id_repositories_id_fk", + "tableFrom": "vercel_projects", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "vercel_projects_project_id_unique": { + "name": "vercel_projects_project_id_unique", + "nullsNotDistinct": false, + "columns": ["project_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification_tokens": { + "name": "verification_tokens", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_tokens_identifier_token_pk": { + "name": "verification_tokens_identifier_token_pk", + "columns": ["identifier", "token"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_deliveries": { + "name": "webhook_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "delivery_id": { + "name": "delivery_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_full_name": { + "name": "repository_full_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "webhook_delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "webhook_deliveries_delivery_id_unique": { + "name": "webhook_deliveries_delivery_id_unique", + "nullsNotDistinct": false, + "columns": ["delivery_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.approval_status": { + "name": "approval_status", + "schema": "public", + "values": ["requested", "approved", "rejected", "cancelled", "expired", "applied", "bypassed"] + }, + "public.artifact_type": { + "name": "artifact_type", + "schema": "public", + "values": [ + "plan", + "validation_report", + "test_plan", + "patch", + "pr_intent", + "deployment_summary", + "log_summary", + "trace", + "screenshot", + "other" + ] + }, + "public.deployment_status": { + "name": "deployment_status", + "schema": "public", + "values": ["queued", "building", "ready", "error", "canceled"] + }, + "public.idempotency_lock_status": { + "name": "idempotency_lock_status", + "schema": "public", + "values": ["acquired", "released", "expired"] + }, + "public.loop_state": { + "name": "loop_state", + "schema": "public", + "values": [ + "intake", + "triage", + "planned", + "in_progress", + "waiting_on_review", + "validating", + "blocked", + "done" + ] + }, + "public.observability_severity": { + "name": "observability_severity", + "schema": "public", + "values": ["debug", "info", "warn", "error"] + }, + "public.repo_health": { + "name": "repo_health", + "schema": "public", + "values": ["healthy", "watch", "blocked", "disconnected"] + }, + "public.run_status": { + "name": "run_status", + "schema": "public", + "values": [ + "queued", + "running", + "waiting_for_approval", + "blocked", + "failed", + "succeeded", + "canceled" + ] + }, + "public.run_step_status": { + "name": "run_step_status", + "schema": "public", + "values": ["queued", "running", "skipped", "failed", "succeeded"] + }, + "public.webhook_delivery_status": { + "name": "webhook_delivery_status", + "schema": "public", + "values": ["received", "processed", "ignored", "failed"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..e054356 --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,2259 @@ +{ + "id": "c7100bf4-4ad0-484f-8b52-ca42b99de518", + "prevId": "9ed14d14-2365-44f6-b397-7d4da1737858", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "columnsFrom": ["user_id"], + "tableTo": "users", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "accounts_provider_provider_account_id_pk": { + "name": "accounts_provider_provider_account_id_pk", + "columns": ["provider", "provider_account_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_plans": { + "name": "agent_plans", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "loop_id": { + "name": "loop_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_number": { + "name": "issue_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'planner'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "input": { + "name": "input", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "agent_plans_loop_id_created_at_idx": { + "name": "agent_plans_loop_id_created_at_idx", + "columns": [ + { + "expression": "loop_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "agent_plans_run_id_created_at_idx": { + "name": "agent_plans_run_id_created_at_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "agent_plans_loop_id_loops_id_fk": { + "name": "agent_plans_loop_id_loops_id_fk", + "tableFrom": "agent_plans", + "columnsFrom": ["loop_id"], + "tableTo": "loops", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + }, + "agent_plans_run_id_loop_runs_id_fk": { + "name": "agent_plans_run_id_loop_runs_id_fk", + "tableFrom": "agent_plans", + "columnsFrom": ["run_id"], + "tableTo": "loop_runs", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approval_transition_events": { + "name": "approval_transition_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "approval_id": { + "name": "approval_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "from_status": { + "name": "from_status", + "type": "approval_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "to_status": { + "name": "to_status", + "type": "approval_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "approval_transition_events_approval_created_at_idx": { + "name": "approval_transition_events_approval_created_at_idx", + "columns": [ + { + "expression": "approval_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "approval_transition_events_run_created_at_idx": { + "name": "approval_transition_events_run_created_at_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "approval_transition_events_approval_id_approvals_id_fk": { + "name": "approval_transition_events_approval_id_approvals_id_fk", + "tableFrom": "approval_transition_events", + "columnsFrom": ["approval_id"], + "tableTo": "approvals", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "approval_transition_events_run_id_loop_runs_id_fk": { + "name": "approval_transition_events_run_id_loop_runs_id_fk", + "tableFrom": "approval_transition_events", + "columnsFrom": ["run_id"], + "tableTo": "loop_runs", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.approvals": { + "name": "approvals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "loop_id": { + "name": "loop_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "approval_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "approvals_loop_id_status_idx": { + "name": "approvals_loop_id_status_idx", + "columns": [ + { + "expression": "loop_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "approvals_run_id_status_idx": { + "name": "approvals_run_id_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "approvals_loop_id_loops_id_fk": { + "name": "approvals_loop_id_loops_id_fk", + "tableFrom": "approvals", + "columnsFrom": ["loop_id"], + "tableTo": "loops", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + }, + "approvals_run_id_loop_runs_id_fk": { + "name": "approvals_run_id_loop_runs_id_fk", + "tableFrom": "approvals", + "columnsFrom": ["run_id"], + "tableTo": "loop_runs", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artifacts": { + "name": "artifacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "step_id": { + "name": "step_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "artifact_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'other'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "artifacts_run_type_idx": { + "name": "artifacts_run_type_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "artifacts_step_id_idx": { + "name": "artifacts_step_id_idx", + "columns": [ + { + "expression": "step_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "artifacts_run_id_loop_runs_id_fk": { + "name": "artifacts_run_id_loop_runs_id_fk", + "tableFrom": "artifacts", + "columnsFrom": ["run_id"], + "tableTo": "loop_runs", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "artifacts_step_id_run_steps_id_fk": { + "name": "artifacts_step_id_run_steps_id_fk", + "tableFrom": "artifacts", + "columnsFrom": ["step_id"], + "tableTo": "run_steps", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployments": { + "name": "deployments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "loop_id": { + "name": "loop_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'vercel'" + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_name": { + "name": "project_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "deployment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commit_sha": { + "name": "commit_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inspector_url": { + "name": "inspector_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "alias_urls": { + "name": "alias_urls", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ready_at": { + "name": "ready_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "deployments_project_id_created_at_idx": { + "name": "deployments_project_id_created_at_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "deployments_run_id_created_at_idx": { + "name": "deployments_run_id_created_at_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "deployments_repository_id_repositories_id_fk": { + "name": "deployments_repository_id_repositories_id_fk", + "tableFrom": "deployments", + "columnsFrom": ["repository_id"], + "tableTo": "repositories", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + }, + "deployments_loop_id_loops_id_fk": { + "name": "deployments_loop_id_loops_id_fk", + "tableFrom": "deployments", + "columnsFrom": ["loop_id"], + "tableTo": "loops", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + }, + "deployments_run_id_loop_runs_id_fk": { + "name": "deployments_run_id_loop_runs_id_fk", + "tableFrom": "deployments", + "columnsFrom": ["run_id"], + "tableTo": "loop_runs", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "deployments_external_id_unique": { + "name": "deployments_external_id_unique", + "columns": ["external_id"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_locks": { + "name": "idempotency_locks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "idempotency_lock_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'acquired'" + }, + "acquired_at": { + "name": "acquired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "released_at": { + "name": "released_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idempotency_locks_scope_status_idx": { + "name": "idempotency_locks_scope_status_idx", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "idempotency_locks_expires_at_idx": { + "name": "idempotency_locks_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "idempotency_locks_key_unique": { + "name": "idempotency_locks_key_unique", + "columns": ["key"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.loop_events": { + "name": "loop_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "loop_id": { + "name": "loop_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_state": { + "name": "from_state", + "type": "loop_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "to_state": { + "name": "to_state", + "type": "loop_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "loop_events_loop_id_created_at_idx": { + "name": "loop_events_loop_id_created_at_idx", + "columns": [ + { + "expression": "loop_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "loop_events_loop_id_loops_id_fk": { + "name": "loop_events_loop_id_loops_id_fk", + "tableFrom": "loop_events", + "columnsFrom": ["loop_id"], + "tableTo": "loops", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.loop_runs": { + "name": "loop_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "loop_key": { + "name": "loop_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_issue_number": { + "name": "github_issue_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "github_issue_url": { + "name": "github_issue_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "current_stage": { + "name": "current_stage", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'planning'" + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_cents": { + "name": "cost_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "loop_runs_repository_status_idx": { + "name": "loop_runs_repository_status_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "loop_runs_repository_issue_idx": { + "name": "loop_runs_repository_issue_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "github_issue_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "loop_runs_repository_id_repositories_id_fk": { + "name": "loop_runs_repository_id_repositories_id_fk", + "tableFrom": "loop_runs", + "columnsFrom": ["repository_id"], + "tableTo": "repositories", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.loops": { + "name": "loops", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "github_issue_number": { + "name": "github_issue_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "loop_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'intake'" + }, + "milestone": { + "name": "milestone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "area_label": { + "name": "area_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority_label": { + "name": "priority_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_github_login": { + "name": "owner_github_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "loops_repository_issue_number_idx": { + "name": "loops_repository_issue_number_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "github_issue_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "loops_repository_id_repositories_id_fk": { + "name": "loops_repository_id_repositories_id_fk", + "tableFrom": "loops", + "columnsFrom": ["repository_id"], + "tableTo": "repositories", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.observability_events": { + "name": "observability_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "step_id": { + "name": "step_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "observability_severity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metric_name": { + "name": "metric_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metric_value": { + "name": "metric_value", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "observability_events_type_created_at_idx": { + "name": "observability_events_type_created_at_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "observability_events_run_created_at_idx": { + "name": "observability_events_run_created_at_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "observability_events_trace_id_idx": { + "name": "observability_events_trace_id_idx", + "columns": [ + { + "expression": "trace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "observability_events_repository_id_repositories_id_fk": { + "name": "observability_events_repository_id_repositories_id_fk", + "tableFrom": "observability_events", + "columnsFrom": ["repository_id"], + "tableTo": "repositories", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + }, + "observability_events_run_id_loop_runs_id_fk": { + "name": "observability_events_run_id_loop_runs_id_fk", + "tableFrom": "observability_events", + "columnsFrom": ["run_id"], + "tableTo": "loop_runs", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + }, + "observability_events_step_id_run_steps_id_fk": { + "name": "observability_events_step_id_run_steps_id_fk", + "tableFrom": "observability_events", + "columnsFrom": ["step_id"], + "tableTo": "run_steps", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.repositories": { + "name": "repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "github_repo_id": { + "name": "github_repo_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "health": { + "name": "health", + "type": "repo_health", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'healthy'" + }, + "framework": { + "name": "framework", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Unknown'" + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "ci_commands": { + "name": "ci_commands", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "docs_href": { + "name": "docs_href", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "observability_href": { + "name": "observability_href", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "design_system_href": { + "name": "design_system_href", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled_loops": { + "name": "enabled_loops", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "validation_gates": { + "name": "validation_gates", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "repositories_github_repo_id_unique": { + "name": "repositories_github_repo_id_unique", + "columns": ["github_repo_id"], + "nullsNotDistinct": false + }, + "repositories_full_name_unique": { + "name": "repositories_full_name_unique", + "columns": ["full_name"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.run_steps": { + "name": "run_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stage": { + "name": "stage", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "run_step_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "validation_command": { + "name": "validation_command", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "validation_status": { + "name": "validation_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trace_id": { + "name": "trace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "run_steps_run_stage_idx": { + "name": "run_steps_run_stage_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stage", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "run_steps_run_status_idx": { + "name": "run_steps_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "run_steps_run_id_loop_runs_id_fk": { + "name": "run_steps_run_id_loop_runs_id_fk", + "tableFrom": "run_steps", + "columnsFrom": ["run_id"], + "tableTo": "loop_runs", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "columnsFrom": ["user_id"], + "tableTo": "users", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_login": { + "name": "github_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "columns": ["email"], + "nullsNotDistinct": false + }, + "users_github_login_unique": { + "name": "users_github_login_unique", + "columns": ["github_login"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vercel_projects": { + "name": "vercel_projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_name": { + "name": "project_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_slug": { + "name": "team_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "production_url": { + "name": "production_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dashboard_url": { + "name": "dashboard_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "vercel_projects_repository_id_idx": { + "name": "vercel_projects_repository_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "vercel_projects_repository_id_repositories_id_fk": { + "name": "vercel_projects_repository_id_repositories_id_fk", + "tableFrom": "vercel_projects", + "columnsFrom": ["repository_id"], + "tableTo": "repositories", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "vercel_projects_project_id_unique": { + "name": "vercel_projects_project_id_unique", + "columns": ["project_id"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification_tokens": { + "name": "verification_tokens", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_tokens_identifier_token_pk": { + "name": "verification_tokens_identifier_token_pk", + "columns": ["identifier", "token"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_deliveries": { + "name": "webhook_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "delivery_id": { + "name": "delivery_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_full_name": { + "name": "repository_full_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "webhook_delivery_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "webhook_deliveries_delivery_id_unique": { + "name": "webhook_deliveries_delivery_id_unique", + "columns": ["delivery_id"], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.approval_status": { + "name": "approval_status", + "schema": "public", + "values": ["requested", "approved", "rejected", "cancelled", "expired", "applied", "bypassed"] + }, + "public.artifact_type": { + "name": "artifact_type", + "schema": "public", + "values": [ + "plan", + "validation_report", + "test_plan", + "patch", + "pr_intent", + "deployment_summary", + "log_summary", + "trace", + "screenshot", + "other" + ] + }, + "public.deployment_status": { + "name": "deployment_status", + "schema": "public", + "values": ["queued", "building", "ready", "error", "canceled"] + }, + "public.idempotency_lock_status": { + "name": "idempotency_lock_status", + "schema": "public", + "values": ["acquired", "released", "expired"] + }, + "public.loop_state": { + "name": "loop_state", + "schema": "public", + "values": [ + "intake", + "triage", + "planned", + "in_progress", + "waiting_on_review", + "validating", + "blocked", + "done" + ] + }, + "public.observability_severity": { + "name": "observability_severity", + "schema": "public", + "values": ["debug", "info", "warn", "error"] + }, + "public.repo_health": { + "name": "repo_health", + "schema": "public", + "values": ["healthy", "watch", "blocked", "disconnected"] + }, + "public.run_status": { + "name": "run_status", + "schema": "public", + "values": [ + "queued", + "running", + "waiting_for_approval", + "blocked", + "failed", + "succeeded", + "canceled" + ] + }, + "public.run_step_status": { + "name": "run_step_status", + "schema": "public", + "values": ["queued", "running", "skipped", "failed", "succeeded"] + }, + "public.webhook_delivery_status": { + "name": "webhook_delivery_status", + "schema": "public", + "values": ["received", "processed", "ignored", "failed"] + } + }, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 73c753b..aa0fb1e 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,20 @@ "when": 1783795849940, "tag": "0003_dusty_nico_minoru", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1784008795731, + "tag": "0004_mushy_corsair", + "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1784010988181, + "tag": "0005_backfill_validation_screenshot_artifacts", + "breakpoints": true } ] } diff --git a/package.json b/package.json index 6c07467..1502c23 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "test": "vitest run", "test:watch": "vitest", "test:e2e": "playwright test", + "test:e2e:validation-evidence": "playwright test --config=playwright.validation-evidence.config.ts", "test:e2e:seeded": "bun run scripts/test-seeded-postgres.ts", "storybook": "storybook dev -p 6006", "storybook:build": "storybook build", diff --git a/playwright.validation-evidence.config.ts b/playwright.validation-evidence.config.ts new file mode 100644 index 0000000..71d900f --- /dev/null +++ b/playwright.validation-evidence.config.ts @@ -0,0 +1,21 @@ +import { defineConfig, devices } from "@playwright/test"; + +import baseConfig from "./playwright.config"; +import { validationScreenshotViewports } from "./src/lib/loops/screenshot-evidence"; + +export default defineConfig({ + ...baseConfig, + outputDir: "test-results/validation-evidence", + projects: validationScreenshotViewports.map(({ name, width, height }) => ({ + name: `validation-${name}`, + testMatch: /(?:auth-guard|portal)\.spec\.ts/, + use: { + ...devices["Desktop Chrome"], + viewport: { width, height }, + }, + })), + use: { + ...baseConfig.use, + screenshot: "on", + }, +}); diff --git a/schemas/loop-manifest.schema.json b/schemas/loop-manifest.schema.json index bfef426..260694d 100644 --- a/schemas/loop-manifest.schema.json +++ b/schemas/loop-manifest.schema.json @@ -429,7 +429,8 @@ "pr_intent", "summary", "diff_summary", - "trace" + "trace", + "screenshot" ] }, "validationGate": { diff --git a/schemas/loop-manifest.ts b/schemas/loop-manifest.ts index bd6501d..827f1fb 100644 --- a/schemas/loop-manifest.ts +++ b/schemas/loop-manifest.ts @@ -81,6 +81,7 @@ export const artifactContractTypeValues = [ "summary", "diff_summary", "trace", + "screenshot", ] as const; export const artifactRetentionValues = ["run", "pr", "audit"] as const; diff --git a/src/db/schema.ts b/src/db/schema.ts index f2ed60d..cc8527f 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -62,6 +62,7 @@ export const artifactTypeEnum = pgEnum("artifact_type", [ "deployment_summary", "log_summary", "trace", + "screenshot", "other", ]); export const idempotencyLockStatusEnum = pgEnum("idempotency_lock_status", [ diff --git a/src/lib/loops/development-run-transitions.ts b/src/lib/loops/development-run-transitions.ts index c72d77c..afbe6cc 100644 --- a/src/lib/loops/development-run-transitions.ts +++ b/src/lib/loops/development-run-transitions.ts @@ -1,25 +1,33 @@ import { randomUUID } from "node:crypto"; - -import { - computePlanningArtifactDigest, - pinnedPlanningAgentOutputSchema, - planningAgentOutputSchema, -} from "@agent/planning-agent"; import { computeImplementationDigest, + createImplementationArtifactContractMetadata, type ImplementationResult, implementationResultSchema, } from "@agent/implementation-agent"; +import { + computePlanningArtifactDigest, + pinnedPlanningAgentOutputSchema, + planningAgentOutputSchema, +} from "@agent/planning-agent"; import { verifyImplementationExecutionReceipt } from "@agent/subagents/implementer/lib/tool-policy"; import { verifyTestExecutionReceipt } from "@agent/subagents/test-writer/lib/tool-policy"; import { computeTestPlanDigest, + createRedTestEvidenceArtifactContractMetadata, + createTestPlanArtifactContractMetadata, redTestEvidenceSchema, + type TestWritingAgentOutput, testPlanArtifactSchema, testWriterModelLabel, - type TestWritingAgentOutput, testWritingAgentOutputSchema, } from "@agent/test-writing-agent"; +import { + computeValidationReviewDigest, + createValidationReviewArtifactContractMetadata, + type ValidationReviewResult, + validationReviewResultSchema, +} from "@agent/validation-review-agent"; import { and, desc, eq, sql } from "drizzle-orm"; import type { db } from "@/db/client"; import { @@ -38,11 +46,25 @@ import type { GitHubPullRequestWriter, } from "@/lib/github/pull-request"; import { createGitHubPullRequest, createPullRequestChangeDigest } from "@/lib/github/pull-request"; +import { defaultLoopManifest } from "@/lib/loops/manifest"; import { composePrIntent, createPrIntentArtifactMetadata } from "@/lib/loops/pr-intent"; +import { + assertScreenshotEvidenceBinding, + assertScreenshotEvidenceCoverage, + classifyUiAffectingChange, + computeScreenshotEvidenceDigest, + createScreenshotEvidenceArtifactContractMetadata, + createScreenshotEvidenceArtifactMetadata, + type ScreenshotEvidence, + screenshotBrowserTests, + screenshotEvidenceSchema, +} from "@/lib/loops/screenshot-evidence"; import type { ValidationGateResultV1, ValidationReportV1 } from "@/lib/loops/validation-report"; import { + createValidationReportArtifactContractMetadata, createValidationReportArtifactMetadata, validationReportArtifactMetadataSchema, + validationReportV1Schema, } from "@/lib/loops/validation-report"; import type { LoopworksLogger } from "@/lib/observability/logger"; import { @@ -268,6 +290,9 @@ type ValidationTransitionResult = { }; const prApprovalScope = "external-write-review"; +const developmentLoopMaxAttempts = + defaultLoopManifest.loops.find(({ key }) => key === "development-loop")?.retryPolicy + .maxAttempts ?? 1; type PrStageTransitionResult = { artifactId: string; @@ -1059,8 +1084,10 @@ export async function applyDevelopmentLoopValidationReport(input: { occurredAt?: Date; report: ValidationReportV1; runId: string; + screenshotEvidence?: ScreenshotEvidence; }): Promise { const occurredAt = input.occurredAt ?? new Date(); + const report = validationReportV1Schema.parse(input.report); let metricInputs: ValidationTransitionMetricInputs | undefined; const result = await input.database.transaction(async (tx) => { @@ -1095,18 +1122,19 @@ export async function applyDevelopmentLoopValidationReport(input: { `Run ${input.runId} does not have a validation step.`, ); } + const generatedAt = new Date(report.generatedAt); + if (generatedAt < step.queuedAt || generatedAt > occurredAt) { + throw new DevelopmentLoopTransitionError( + "Validation report timestamp is stale or later than the transition time.", + ); + } - const [artifact] = await tx + const validationArtifacts = await tx .select() .from(artifacts) - .where( - and( - eq(artifacts.runId, input.runId), - eq(artifacts.stepId, step.id), - eq(artifacts.type, "validation_report"), - ), - ) - .limit(1); + .where(and(eq(artifacts.runId, input.runId), eq(artifacts.stepId, step.id))); + const artifact = validationArtifacts.find(({ type }) => type === "validation_report"); + const screenshotArtifact = validationArtifacts.find(({ type }) => type === "screenshot"); if (!artifact) { throw new DevelopmentLoopTransitionError( @@ -1130,9 +1158,62 @@ export async function applyDevelopmentLoopValidationReport(input: { } satisfies ValidationTransitionResult; } - const blockedReason = getBlockedReason(input.report, input.expectedValidationGates); + let screenshotEvidence = input.screenshotEvidence + ? screenshotEvidenceSchema.parse(input.screenshotEvidence) + : undefined; + let uiAffecting: boolean | undefined; + const runArtifacts = await tx.select().from(artifacts).where(eq(artifacts.runId, input.runId)); + const testPlanArtifact = runArtifacts.find(({ type }) => type === "test_plan"); + const implementationArtifact = runArtifacts.find( + ({ type, metadata }) => + type === "patch" && metadata?.implementationMetadataKind === "implementation_result", + ); + const testPlanParsed = testPlanArtifactSchema.safeParse(testPlanArtifact?.metadata?.testPlan); + const implementationParsed = implementationResultSchema.safeParse( + implementationArtifact?.metadata?.implementationResult, + ); + if (testPlanParsed.success && implementationParsed.success) { + const expectedScreenshotBinding = { + repositoryFullName: implementationParsed.data.binding.repositoryFullName, + commitSha: implementationParsed.data.binding.commitSha, + testPlanSha256: computeTestPlanDigest(testPlanParsed.data), + productionPatchSha256: implementationParsed.data.patch.sha256, + }; + uiAffecting = classifyUiAffectingChange({ + productionPaths: implementationParsed.data.patch.paths, + tests: testPlanParsed.data.tests, + }); + if (screenshotEvidence) { + assertScreenshotEvidenceBinding(screenshotEvidence, expectedScreenshotBinding); + assertScreenshotEvidenceCoverage(screenshotEvidence, { + uiAffecting, + browserTestIds: screenshotBrowserTests(testPlanParsed.data.tests).map(({ id }) => id), + }); + } else if (!uiAffecting) { + screenshotEvidence = screenshotEvidenceSchema.parse({ + version: 1, + schemaId: "loopworks.screenshot_evidence.v1", + binding: expectedScreenshotBinding, + uiAffecting: false, + browserTestIds: [], + captures: [], + }); + } + } else if (screenshotEvidence) { + throw new DevelopmentLoopTransitionError( + "Screenshot evidence requires persisted test-plan and implementation bindings.", + ); + } + const screenshotBlockedReason = + uiAffecting === true && !screenshotEvidence + ? "UI-affecting validation requires complete screenshot evidence." + : uiAffecting !== undefined && !screenshotArtifact + ? "Validation requires a screenshot evidence artifact." + : undefined; + const blockedReason = + getBlockedReason(report, input.expectedValidationGates) ?? screenshotBlockedReason; const stepStatus = blockedReason ? "failed" : "succeeded"; - const stepDurationMs = sumValidationDurationMs(input.report); + const stepDurationMs = sumValidationDurationMs(report); const stepStartedAt = getStartedAtForDuration({ completedAt: occurredAt, durationMs: stepDurationMs, @@ -1140,14 +1221,24 @@ export async function applyDevelopmentLoopValidationReport(input: { }); const stepDurationSeconds = durationSecondsBetween(stepStartedAt, occurredAt); const traceId = step.traceId ?? run.traceId; - const requiredSkippedCount = requiredSkippedResults(input.report).length; + const requiredSkippedCount = requiredSkippedResults(report).length; await tx .update(artifacts) .set({ - metadata: createValidationReportArtifactMetadata(input.report), + metadata: createValidationReportArtifactMetadata(report), + sha256: computeValidationReviewDigest(report), }) .where(eq(artifacts.id, artifact.id)); + if (screenshotEvidence && screenshotArtifact) { + await tx + .update(artifacts) + .set({ + metadata: createScreenshotEvidenceArtifactMetadata(screenshotEvidence), + sha256: computeScreenshotEvidenceDigest(screenshotEvidence), + }) + .where(eq(artifacts.id, screenshotArtifact.id)); + } await tx .update(runSteps) @@ -1155,7 +1246,7 @@ export async function applyDevelopmentLoopValidationReport(input: { completedAt: occurredAt, metadata: createStepValidationMetadata({ metadata: step.metadata, - report: input.report, + report, requiredSkippedCount, }), startedAt: stepStartedAt, @@ -1172,7 +1263,7 @@ export async function applyDevelopmentLoopValidationReport(input: { metadata: createValidationTransitionMetadata({ blockedReason, metadata: run.metadata, - report: input.report, + report, }), startedAt: run.startedAt ?? run.queuedAt, status: blockedReason ? "blocked" : "running", @@ -1181,7 +1272,7 @@ export async function applyDevelopmentLoopValidationReport(input: { metricInputs = createValidationMetricInputs({ loopKey: run.loopKey, - report: input.report, + report, stage: step.stage, stepDurationSeconds, stepStatus, @@ -1217,6 +1308,510 @@ export async function applyDevelopmentLoopValidationReport(input: { return result; } +export type ValidationReviewTransitionResult = { + idempotent?: boolean; + route: "commit" | "development" | "test-writing"; + runId: string; + stage: "code-review"; + status: "advanced" | "requeued"; + stepId: string; + traceId?: string; +}; + +type ValidationReviewHistoryEntry = { + attempt: number; + digest: string; + findingCount: number; + occurredAt: string; + reasonSha256: string; + route: ValidationReviewTransitionResult["route"]; +}; + +function validationReviewHistory(metadata: RunMetadata | null): ValidationReviewHistoryEntry[] { + const value = metadata?.validationReviewHistory; + if (!Array.isArray(value)) return []; + return value.flatMap((entry) => { + if (!entry || typeof entry !== "object") return []; + const candidate = entry as Partial; + return typeof candidate.attempt === "number" && + typeof candidate.digest === "string" && + typeof candidate.findingCount === "number" && + typeof candidate.occurredAt === "string" && + typeof candidate.reasonSha256 === "string" && + ["commit", "development", "test-writing"].includes(candidate.route ?? "") + ? [candidate as ValidationReviewHistoryEntry] + : []; + }); +} + +function metadataWithoutExecutionClaims(metadata: RunMetadata | null): RunMetadata { + const { + implementationClaim: _implementationClaim, + testWritingClaim: _testWritingClaim, + validationReviewClaim: _validationReviewClaim, + ...rest + } = metadata ?? {}; + return rest; +} + +function sameCanonicalValue(left: unknown, right: unknown): boolean { + return computeValidationReviewDigest(left) === computeValidationReviewDigest(right); +} + +export async function applyDevelopmentLoopValidationReviewResult(input: { + database: DevelopmentLoopTransitionDatabase; + logger?: LoopworksLogger; + metrics?: DevelopmentLoopTransitionMetrics; + occurredAt?: Date; + output: ValidationReviewResult; + runId: string; +}): Promise { + const occurredAt = input.occurredAt ?? new Date(); + const transitionStartedAt = Date.now(); + const output = validationReviewResultSchema.parse(input.output); + const digest = computeValidationReviewDigest(output); + const span = startLoopworksSpan("loopworks.validation_review.transition", { + attributes: { + "loopworks.attempt": output.binding.reviewAttempt, + "loopworks.finding_count": output.findings.length, + "loopworks.route": output.recommendation.route, + "loopworks.run_id": input.runId, + "loopworks.screenshot_count": output.evidence.screenshots.length, + "loopworks.stage": "code-review", + "loopworks.validation_evidence_count": output.evidence.validationResults.length, + }, + }); + let retryMetric: DevelopmentLoopStepRetryMetricInput | undefined; + + try { + const result = await input.database.transaction( + async (tx) => { + const [run] = await tx + .select({ + currentStage: loopRuns.currentStage, + id: loopRuns.id, + loopKey: loopRuns.loopKey, + metadata: loopRuns.metadata, + repositoryFullName: repositories.fullName, + status: loopRuns.status, + traceId: loopRuns.traceId, + }) + .from(loopRuns) + .innerJoin(repositories, eq(loopRuns.repositoryId, repositories.id)) + .where(eq(loopRuns.id, input.runId)) + .limit(1); + if (!run) throw new DevelopmentLoopTransitionError(`Run ${input.runId} was not found.`); + + const priorHistory = validationReviewHistory(run.metadata); + const priorAttempt = priorHistory.find( + ({ attempt }) => attempt === output.binding.reviewAttempt, + ); + if (priorAttempt) { + if ( + priorAttempt.digest !== digest || + priorAttempt.route !== output.recommendation.route + ) { + throw new DevelopmentLoopTransitionError( + "Validation review replay does not match the previously applied result.", + ); + } + const reviewStep = ( + await tx + .select() + .from(runSteps) + .where(and(eq(runSteps.runId, input.runId), eq(runSteps.stage, "code-review"))) + )[0]; + if (!reviewStep) throw new DevelopmentLoopTransitionError("Code-review step is missing."); + return { + idempotent: true, + route: priorAttempt.route, + runId: input.runId, + stage: "code-review", + status: priorAttempt.route === "commit" ? "advanced" : "requeued", + stepId: reviewStep.id, + ...((reviewStep.traceId ?? run.traceId) + ? { traceId: reviewStep.traceId ?? run.traceId ?? undefined } + : {}), + }; + } + + const steps = await tx.select().from(runSteps).where(eq(runSteps.runId, input.runId)); + const stepByStage = new Map(steps.map((step) => [step.stage, step])); + const validationStep = stepByStage.get("validation"); + const reviewStep = stepByStage.get("code-review"); + if (!validationStep || !reviewStep) { + throw new DevelopmentLoopTransitionError( + "Validation review requires validation and code-review steps.", + ); + } + if ( + run.currentStage !== "code-review" || + run.status !== "running" || + validationStep.status !== "succeeded" || + !["queued", "running"].includes(reviewStep.status) + ) { + throw new DevelopmentLoopTransitionError( + "Validation review cannot run before completed passing validation.", + ); + } + if ( + output.binding.runId !== input.runId || + output.binding.reviewAttempt !== reviewStep.attempt + ) { + throw new DevelopmentLoopTransitionError( + "Validation review result is not bound to the active run attempt.", + ); + } + + const planRows = await tx + .select() + .from(agentPlans) + .where(eq(agentPlans.runId, input.runId)); + const planApprovals = await tx + .select() + .from(approvals) + .where(and(eq(approvals.runId, input.runId), eq(approvals.scope, "plan-review"))); + if (planRows.length !== 1 || planApprovals.length !== 1) { + throw new DevelopmentLoopTransitionError( + "Validation review requires exactly one approved plan.", + ); + } + const [planRow] = planRows; + const [approval] = planApprovals; + if (!planRow || !approval) { + throw new DevelopmentLoopTransitionError( + "Validation review plan context changed while it was being loaded.", + ); + } + const plan = pinnedPlanningAgentOutputSchema.parse(planRow.plan); + if ( + planRow.status !== "approved" || + approval.status !== "approved" || + approval.metadata?.planId !== planRow.id || + approval.metadata?.planSha256 !== plan.identity.sha256 || + computePlanningArtifactDigest(plan) !== plan.identity.sha256 + ) { + throw new DevelopmentLoopTransitionError("Validation review plan identity is invalid."); + } + + const rows = await tx.select().from(artifacts).where(eq(artifacts.runId, input.runId)); + const exactArtifact = ( + stage: string, + type: string, + predicate?: (row: (typeof rows)[number]) => boolean, + ) => { + const stageStep = stepByStage.get(stage); + const matches = rows.filter( + (row) => + row.stepId === stageStep?.id && row.type === type && (!predicate || predicate(row)), + ); + if (matches.length !== 1) { + throw new DevelopmentLoopTransitionError( + `Validation review requires exactly one ${stage} ${type} artifact.`, + ); + } + const [match] = matches; + if (!match) { + throw new DevelopmentLoopTransitionError( + `Validation review ${stage} ${type} artifact disappeared.`, + ); + } + return match; + }; + const testPlanArtifact = exactArtifact("test-writing", "test_plan"); + const implementationArtifact = exactArtifact("development", "patch"); + const validationArtifact = exactArtifact("validation", "validation_report"); + const screenshotArtifact = exactArtifact("validation", "screenshot"); + const reviewArtifact = exactArtifact("code-review", "log_summary"); + const testPlan = testPlanArtifactSchema.parse(testPlanArtifact.metadata?.testPlan); + const implementation = implementationResultSchema.parse( + implementationArtifact.metadata?.implementationResult, + ); + const report = validationReportArtifactMetadataSchema.parse( + validationArtifact.metadata, + ).validationReport; + const screenshots = screenshotEvidenceSchema.parse( + screenshotArtifact.metadata?.screenshotEvidence, + ); + if ( + report.overallOutcome !== "pass" || + report.results.length === 0 || + report.results.some( + (entry) => entry.outcome !== "pass" || (entry.required && entry.outcome !== "pass"), + ) + ) { + throw new DevelopmentLoopTransitionError( + "Validation review cannot run before completed passing validation.", + ); + } + + const expectedCriteria = plan.issue.acceptanceCriteria.map((text, index) => ({ + id: `ac-${index + 1}`, + text, + })); + const expectedImplementationBinding = { + planId: plan.identity.id, + planSha256: plan.identity.sha256, + testPlanSha256: computeTestPlanDigest(testPlan), + testPatchSha256: testPlan.patch.sha256, + fixturesSha256: computeImplementationDigest(testPlan.fixtures), + repositoryFullName: plan.issue.repositoryFullName, + commitSha: plan.repositoryRevision.commitSha, + }; + if ( + testPlan.plan.id !== plan.identity.id || + testPlan.plan.sha256 !== plan.identity.sha256 || + testPlan.plan.repositoryFullName !== plan.issue.repositoryFullName || + testPlan.plan.commitSha !== plan.repositoryRevision.commitSha || + JSON.stringify(testPlan.acceptanceCriteria) !== JSON.stringify(expectedCriteria) || + !sameCanonicalValue(implementation.binding, expectedImplementationBinding) + ) { + throw new DevelopmentLoopTransitionError( + "Validation review persisted artifacts do not share the approved handoff binding.", + ); + } + + const expectedBinding = { + runId: input.runId, + reviewAttempt: reviewStep.attempt, + planId: plan.identity.id, + planSha256: plan.identity.sha256, + testPlanSha256: computeTestPlanDigest(testPlan), + implementationResultSha256: computeImplementationDigest(implementation), + productionPatchSha256: implementation.patch.sha256, + validationReportSha256: computeValidationReviewDigest(report), + screenshotEvidenceSha256: computeScreenshotEvidenceDigest(screenshots), + repositoryFullName: run.repositoryFullName, + commitSha: plan.repositoryRevision.commitSha, + }; + if ( + !sameCanonicalValue(output.binding, expectedBinding) || + testPlanArtifact.sha256 !== expectedBinding.testPlanSha256 || + implementationArtifact.sha256 !== expectedBinding.implementationResultSha256 || + validationArtifact.sha256 !== expectedBinding.validationReportSha256 || + screenshotArtifact.sha256 !== expectedBinding.screenshotEvidenceSha256 + ) { + throw new DevelopmentLoopTransitionError( + "Validation review result is not bound to the persisted evidence.", + ); + } + assertScreenshotEvidenceBinding(screenshots, { + repositoryFullName: plan.issue.repositoryFullName, + commitSha: plan.repositoryRevision.commitSha, + testPlanSha256: computeTestPlanDigest(testPlan), + productionPatchSha256: implementation.patch.sha256, + }); + assertScreenshotEvidenceCoverage(screenshots, { + uiAffecting: classifyUiAffectingChange({ + productionPaths: implementation.patch.paths, + tests: testPlan.tests, + }), + browserTestIds: screenshotBrowserTests(testPlan.tests).map(({ id }) => id), + }); + const expectedValidationEvidence = report.results.map( + ({ key, command, outcome, output }) => ({ + key, + command, + outcome: outcome as "pass", + ...(output?.sha256 ? { outputSha256: output.sha256 } : {}), + }), + ); + const expectedScreenshotEvidence = screenshots.captures.map( + ({ id, testId, viewport, width, height, uri, sha256 }) => ({ + id, + testId, + viewport, + width, + height, + uri, + sha256, + }), + ); + if ( + !sameCanonicalValue(output.evidence.validationResults, expectedValidationEvidence) || + !sameCanonicalValue(output.evidence.screenshots, expectedScreenshotEvidence) + ) { + throw new DevelopmentLoopTransitionError( + "Validation review citations do not exactly match persisted evidence.", + ); + } + if ( + output.recommendation.route !== "commit" && + reviewStep.attempt >= developmentLoopMaxAttempts + ) { + throw new DevelopmentLoopTransitionError( + "Validation review retry budget is exhausted for this run.", + ); + } + + const claimId = randomUUID(); + const [claimedStep] = await tx + .update(runSteps) + .set({ metadata: { ...(reviewStep.metadata ?? {}), validationReviewClaim: claimId } }) + .where( + and( + eq(runSteps.id, reviewStep.id), + sql`not coalesce(${runSteps.metadata} ? 'validationReviewClaim', false)`, + ), + ) + .returning({ id: runSteps.id }); + if (!claimedStep) { + throw new DevelopmentLoopTransitionError( + `Validation review transition is already in progress for run ${input.runId}.`, + ); + } + + await tx + .update(artifacts) + .set({ + metadata: { + validationReviewMetadataKind: "validation_review_result", + validationReviewResult: output, + validationReviewResultSchemaId: output.schemaId, + validationReviewVersion: output.version, + }, + sha256: digest, + }) + .where(eq(artifacts.id, reviewArtifact.id)); + + const route = output.recommendation.route; + const traceId = reviewStep.traceId ?? run.traceId; + const historyEntry: ValidationReviewHistoryEntry = { + attempt: reviewStep.attempt, + digest, + findingCount: output.findings.length, + occurredAt: occurredAt.toISOString(), + reasonSha256: computeValidationReviewDigest(output.recommendation.reason), + route, + }; + const runMetadata = { + ...metadataWithoutBlockedReason(run.metadata), + validationReviewHistory: [...priorHistory, historyEntry], + }; + + if (route === "commit") { + await tx + .update(runSteps) + .set({ + completedAt: occurredAt, + metadata: metadataWithoutExecutionClaims(reviewStep.metadata), + startedAt: reviewStep.startedAt ?? occurredAt, + status: "succeeded", + traceId, + }) + .where(eq(runSteps.id, reviewStep.id)); + await tx + .update(loopRuns) + .set({ currentStage: "commit", metadata: runMetadata, status: "running" }) + .where(eq(loopRuns.id, input.runId)); + } else { + const resetStages = + route === "development" + ? ["development", "validation", "code-review"] + : ["test-writing", "development", "validation", "code-review"]; + for (const stage of resetStages) { + const step = stepByStage.get(stage); + if (!step) throw new DevelopmentLoopTransitionError(`Run is missing ${stage} step.`); + await tx + .update(runSteps) + .set({ + attempt: step.attempt + 1, + completedAt: null, + metadata: metadataWithoutExecutionClaims(step.metadata), + queuedAt: occurredAt, + startedAt: null, + status: "queued", + traceId: step.traceId ?? run.traceId, + validationStatus: + stage === "test-writing" ? "red" : stage === "validation" ? "required" : null, + }) + .where(eq(runSteps.id, step.id)); + } + + const resetArtifact = async (artifactId: string, metadata: RunMetadata) => { + await tx + .update(artifacts) + .set({ metadata, sha256: null }) + .where(eq(artifacts.id, artifactId)); + }; + if (route === "test-writing") { + const redArtifact = exactArtifact("test-writing", "validation_report"); + await resetArtifact(testPlanArtifact.id, createTestPlanArtifactContractMetadata()); + await resetArtifact(redArtifact.id, createRedTestEvidenceArtifactContractMetadata()); + } + await resetArtifact( + implementationArtifact.id, + createImplementationArtifactContractMetadata(), + ); + await resetArtifact( + validationArtifact.id, + createValidationReportArtifactContractMetadata(), + ); + await resetArtifact( + screenshotArtifact.id, + createScreenshotEvidenceArtifactContractMetadata(), + ); + await resetArtifact(reviewArtifact.id, createValidationReviewArtifactContractMetadata()); + await tx + .update(loopRuns) + .set({ currentStage: route, metadata: runMetadata, status: "queued" }) + .where(eq(loopRuns.id, input.runId)); + retryMetric = { + loopKey: run.loopKey, + reason: "validation-review", + stage: route, + }; + } + + return { + route, + runId: input.runId, + stage: "code-review", + status: route === "commit" ? "advanced" : "requeued", + stepId: reviewStep.id, + ...(traceId ? { traceId } : {}), + }; + }, + ); + + emitSafely(input.metrics?.stepDuration ?? recordDevelopmentLoopStepDurationMetric, { + durationSeconds: Math.max(0, Date.now() - transitionStartedAt) / 1000, + loopKey: "development-loop", + stage: "code-review", + status: "succeeded", + }); + if (retryMetric) { + emitSafely(input.metrics?.stepRetry ?? recordDevelopmentLoopStepRetryMetric, retryMetric); + } + input.logger?.info( + { + attempt: output.binding.reviewAttempt, + durationMs: Math.max(0, Date.now() - transitionStartedAt), + findingCount: output.findings.length, + idempotent: result.idempotent, + route: result.route, + runId: result.runId, + screenshotCount: output.evidence.screenshots.length, + stepId: result.stepId, + validationEvidenceCount: output.evidence.validationResults.length, + }, + "validation_review_stage_routed", + ); + span.setAttributes({ + "loopworks.duration_ms": Math.max(0, Date.now() - transitionStartedAt), + "loopworks.idempotent": result.idempotent ?? false, + "loopworks.outcome": result.status, + }); + markLoopworksSpanOk(span); + return result; + } catch (error) { + markLoopworksSpanError(span, error); + throw error; + } finally { + span.end(); + } +} + function issueTitleFromMetadata( metadata: RunMetadata | null | undefined, issueNumber: number, diff --git a/src/lib/loops/development-run.ts b/src/lib/loops/development-run.ts index 2f47b43..caea0fb 100644 --- a/src/lib/loops/development-run.ts +++ b/src/lib/loops/development-run.ts @@ -5,6 +5,7 @@ import { createRedTestEvidenceArtifactContractMetadata, createTestPlanArtifactContractMetadata, } from "@agent/test-writing-agent"; +import { createValidationReviewArtifactContractMetadata } from "@agent/validation-review-agent"; import { and, eq, sql } from "drizzle-orm"; import type { db } from "@/db/client"; import { @@ -17,6 +18,7 @@ import { runSteps, } from "@/db/schema"; import { createPrIntentArtifactContractMetadata } from "@/lib/loops/pr-intent"; +import { createScreenshotEvidenceArtifactContractMetadata } from "@/lib/loops/screenshot-evidence"; import { createValidationReportArtifactContractMetadata } from "@/lib/loops/validation-report"; import { recordDevelopmentLoopRunCreatedObservability } from "@/lib/observability/metrics"; import { getActiveTraceId, isValidW3cTraceId } from "@/lib/observability/trace-context"; @@ -42,6 +44,7 @@ export type DevelopmentLoopArtifactType = | "patch" | "pr_intent" | "log_summary" + | "screenshot" | "other"; type DevelopmentLoopArtifactContract = { @@ -99,7 +102,10 @@ export const developmentLoopStages = [ { actorId: "ci-runner", actorType: "ci", - artifacts: [{ label: "Validation report", required: true, type: "validation_report" }], + artifacts: [ + { label: "Validation report", required: true, type: "validation_report" }, + { label: "Validation screenshots", required: true, type: "screenshot" }, + ], key: "validation", summary: "Run deterministic checks before review, LLM judgment, commit, or PR stages.", timelineKind: "validation", @@ -108,8 +114,8 @@ export const developmentLoopStages = [ validationStatus: "required", }, { - actorId: "reviewer", - actorType: "human", + actorId: "validation-reviewer", + actorType: "agent", artifacts: [{ label: "Code review notes", required: true, type: "log_summary" }], key: "code-review", summary: "Review assumptions, security/a11y risks, and validation evidence.", @@ -470,6 +476,12 @@ export async function createDevelopmentLoopRun(input: { : {}), ...(artifact.type === "test_plan" ? createTestPlanArtifactContractMetadata() : {}), ...(artifact.type === "patch" ? createImplementationArtifactContractMetadata() : {}), + ...(artifact.type === "screenshot" + ? createScreenshotEvidenceArtifactContractMetadata() + : {}), + ...(artifact.type === "log_summary" && artifact.stageKey === "code-review" + ? createValidationReviewArtifactContractMetadata() + : {}), ...(artifact.type === "pr_intent" ? createPrIntentArtifactContractMetadata() : {}), }, runId, diff --git a/src/lib/loops/manifest.ts b/src/lib/loops/manifest.ts index 1152365..ce41152 100644 --- a/src/lib/loops/manifest.ts +++ b/src/lib/loops/manifest.ts @@ -94,6 +94,13 @@ export const defaultLoopManifest: LoopManifest = loopManifestSchema.parse({ description: "Deterministic command evidence collected before review or rollout.", retention: "audit", }, + { + type: "screenshot", + required: true, + description: + "Validation-owned responsive screenshot manifest, explicit and empty for non-UI runs.", + retention: "audit", + }, { type: "test_plan", required: true, diff --git a/src/lib/loops/screenshot-evidence.ts b/src/lib/loops/screenshot-evidence.ts new file mode 100644 index 0000000..c8f5a2a --- /dev/null +++ b/src/lib/loops/screenshot-evidence.ts @@ -0,0 +1,291 @@ +import { createHash } from "node:crypto"; + +import { canonicalJsonStringify } from "@agent/lib/canonical-json"; +import { screenshotArtifactUriSchema } from "@agent/lib/screenshot-artifact-uri"; +import { z } from "zod"; + +export const screenshotEvidenceSchemaId = "loopworks.screenshot_evidence.v1"; +export const validationScreenshotViewports = [ + { name: "mobile", width: 390, height: 844 }, + { name: "laptop", width: 1280, height: 832 }, + { name: "desktop", width: 1440, height: 960 }, +] as const; + +const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/); +const identifierSchema = z.string().regex(/^[a-z0-9][a-z0-9-]*$/); +const repositorySchema = z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/); +const viewportNameSchema = z.enum(validationScreenshotViewports.map(({ name }) => name)); + +const screenshotCaptureSchema = z + .object({ + id: identifierSchema, + testId: identifierSchema, + viewport: viewportNameSchema, + width: z.number().int().positive(), + height: z.number().int().positive(), + mimeType: z.literal("image/png"), + uri: screenshotArtifactUriSchema, + sha256: sha256Schema, + byteCount: z.number().int().positive(), + }) + .strict(); + +export const screenshotEvidenceSchema = z + .object({ + version: z.literal(1), + schemaId: z.literal(screenshotEvidenceSchemaId), + binding: z + .object({ + repositoryFullName: repositorySchema, + commitSha: z.string().regex(/^[a-f0-9]{40}$/), + testPlanSha256: sha256Schema, + productionPatchSha256: sha256Schema, + }) + .strict(), + uiAffecting: z.boolean(), + browserTestIds: z.array(identifierSchema), + captures: z.array(screenshotCaptureSchema), + }) + .strict() + .superRefine((evidence, context) => { + const browserTestIds = new Set(evidence.browserTestIds); + if (browserTestIds.size !== evidence.browserTestIds.length) { + context.addIssue({ code: "custom", message: "Browser test ids must be unique." }); + } + const captureIds = new Set(); + const captureKeys = new Set(); + const captureUris = new Set(); + for (const [index, capture] of evidence.captures.entries()) { + if (captureIds.has(capture.id)) { + context.addIssue({ + code: "custom", + message: `Duplicate screenshot capture ${capture.id}.`, + path: ["captures", index, "id"], + }); + } + captureIds.add(capture.id); + if (captureUris.has(capture.uri)) { + context.addIssue({ code: "custom", message: `Duplicate screenshot URI ${capture.uri}.` }); + } + captureUris.add(capture.uri); + if (!browserTestIds.has(capture.testId)) { + context.addIssue({ + code: "custom", + message: `Screenshot references unknown browser test ${capture.testId}.`, + path: ["captures", index, "testId"], + }); + } + const viewport = validationScreenshotViewports.find(({ name }) => name === capture.viewport); + if (!viewport || viewport.width !== capture.width || viewport.height !== capture.height) { + context.addIssue({ + code: "custom", + message: `Screenshot dimensions do not match ${capture.viewport}.`, + path: ["captures", index], + }); + } + const key = `${capture.testId}:${capture.viewport}`; + if (captureKeys.has(key)) { + context.addIssue({ code: "custom", message: `Duplicate screenshot target ${key}.` }); + } + captureKeys.add(key); + } + if (!evidence.uiAffecting) { + if (evidence.browserTestIds.length > 0 || evidence.captures.length > 0) { + context.addIssue({ code: "custom", message: "Non-UI evidence cannot contain captures." }); + } + return; + } + if (evidence.browserTestIds.length === 0) { + context.addIssue({ code: "custom", message: "UI evidence requires a browser test." }); + } + for (const testId of evidence.browserTestIds) { + for (const viewport of validationScreenshotViewports) { + if (!captureKeys.has(`${testId}:${viewport.name}`)) { + context.addIssue({ + code: "custom", + message: `Missing ${viewport.name} screenshot for ${testId}.`, + }); + } + } + } + }); + +export type ScreenshotEvidence = z.infer; +export type ValidationScreenshotViewport = (typeof validationScreenshotViewports)[number]; +export type ScreenshotTest = { id: string; path: string; type: "unit" | "integration" | "browser" }; +export type CaptureValidationScreenshotsInput = { + binding: ScreenshotEvidence["binding"]; + productionPaths: readonly string[]; + tests: readonly ScreenshotTest[]; + capture: (input: { + test: ScreenshotTest; + viewport: ValidationScreenshotViewport; + }) => Promise; + write: (input: { + bytes: Uint8Array; + id: string; + test: ScreenshotTest; + viewport: ValidationScreenshotViewport; + }) => Promise<{ uri: string; sha256?: string; byteCount?: number }>; +}; + +export function computeScreenshotEvidenceDigest(value: unknown): string { + return createHash("sha256").update(canonicalJsonStringify(value)).digest("hex"); +} + +export function assertScreenshotEvidenceBinding( + evidence: ScreenshotEvidence, + expected: ScreenshotEvidence["binding"], +): ScreenshotEvidence { + if ( + evidence.binding.repositoryFullName !== expected.repositoryFullName || + evidence.binding.commitSha !== expected.commitSha || + evidence.binding.testPlanSha256 !== expected.testPlanSha256 || + evidence.binding.productionPatchSha256 !== expected.productionPatchSha256 + ) { + throw new Error("Screenshot evidence is not bound to the persisted validation handoff."); + } + return evidence; +} + +const uiProductionPathPattern = + /^(?:src\/(?:app|components)\/|src\/.*\.(?:css|scss|sass|less)$|public\/.*\.(?:svg|png|jpe?g|gif|webp|avif|ico)$)/i; +const storyPathPattern = /(?:^|\/)(?:stories)(?:\/|$)|\.stories\.[^/]+$/i; + +export function screenshotBrowserTests(tests: readonly ScreenshotTest[]): ScreenshotTest[] { + return tests.filter(({ type, path }) => type === "browser" || storyPathPattern.test(path)); +} + +export function classifyUiAffectingChange(input: { + productionPaths: readonly string[]; + tests: readonly ScreenshotTest[]; +}): boolean { + return ( + input.productionPaths.some((path) => uiProductionPathPattern.test(path)) || + input.tests.some((test) => test.type === "browser" || storyPathPattern.test(test.path)) + ); +} + +function pngDimensions(bytes: Uint8Array): { width: number; height: number } | undefined { + const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + if (bytes.length < 58 || !signature.every((value, index) => bytes[index] === value)) { + return undefined; + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let offset = 8; + let dimensions: { width: number; height: number } | undefined; + let hasImageData = false; + let hasEnd = false; + while (offset + 12 <= bytes.length) { + const length = view.getUint32(offset); + const end = offset + 12 + length; + if (end > bytes.length) return undefined; + const type = String.fromCharCode(...bytes.slice(offset + 4, offset + 8)); + if (offset === 8) { + if (type !== "IHDR" || length !== 13) return undefined; + const width = view.getUint32(offset + 8); + const height = view.getUint32(offset + 12); + if (width === 0 || height === 0) return undefined; + dimensions = { width, height }; + } else if (type === "IDAT" && length > 0) { + hasImageData = true; + } else if (type === "IEND" && length === 0) { + hasEnd = end === bytes.length; + break; + } + offset = end; + } + return dimensions && hasImageData && hasEnd ? dimensions : undefined; +} + +export function assertScreenshotEvidenceCoverage( + evidence: ScreenshotEvidence, + expected: { browserTestIds: readonly string[]; uiAffecting: boolean }, +): ScreenshotEvidence { + if ( + evidence.uiAffecting !== expected.uiAffecting || + JSON.stringify([...evidence.browserTestIds].sort()) !== + JSON.stringify([...expected.browserTestIds].sort()) + ) { + throw new Error("Screenshot evidence does not cover the persisted browser tests."); + } + return evidence; +} + +export async function captureValidationScreenshots( + input: CaptureValidationScreenshotsInput, +): Promise { + const uiAffecting = classifyUiAffectingChange(input); + const browserTests = screenshotBrowserTests(input.tests); + if (!uiAffecting) { + return screenshotEvidenceSchema.parse({ + version: 1, + schemaId: screenshotEvidenceSchemaId, + binding: input.binding, + uiAffecting: false, + browserTestIds: [], + captures: [], + }); + } + if (browserTests.length === 0) { + throw new Error("UI-affecting validation requires a browser test journey."); + } + + const captures: ScreenshotEvidence["captures"] = []; + for (const test of browserTests) { + for (const viewport of validationScreenshotViewports) { + const id = `${test.id}-${viewport.name}`; + const bytes = await input.capture({ test, viewport }); + const dimensions = pngDimensions(bytes); + if (!dimensions) throw new Error(`Screenshot ${id} is not a structurally valid PNG.`); + if (dimensions.width !== viewport.width || dimensions.height !== viewport.height) { + throw new Error(`Screenshot ${id} dimensions do not match the requested viewport.`); + } + const written = await input.write({ bytes, id, test, viewport }); + const digest = createHash("sha256").update(bytes).digest("hex"); + if (written.sha256 && written.sha256 !== digest) { + throw new Error(`Screenshot ${id} writer returned a forged digest.`); + } + if (written.byteCount !== undefined && written.byteCount !== bytes.byteLength) { + throw new Error(`Screenshot ${id} writer returned a forged byte count.`); + } + captures.push({ + id, + testId: test.id, + viewport: viewport.name, + width: viewport.width, + height: viewport.height, + mimeType: "image/png", + uri: written.uri, + sha256: digest, + byteCount: written.byteCount ?? bytes.byteLength, + }); + } + } + + return screenshotEvidenceSchema.parse({ + version: 1, + schemaId: screenshotEvidenceSchemaId, + binding: input.binding, + uiAffecting: true, + browserTestIds: browserTests.map(({ id }) => id), + captures, + }); +} + +export function createScreenshotEvidenceArtifactContractMetadata() { + return { + expectedScreenshotEvidenceSchemaId: screenshotEvidenceSchemaId, + screenshotEvidenceMetadataKind: "screenshot_evidence_contract" as const, + screenshotEvidenceVersion: 1 as const, + }; +} + +export function createScreenshotEvidenceArtifactMetadata(evidence: ScreenshotEvidence) { + return { + screenshotEvidence: screenshotEvidenceSchema.parse(evidence), + screenshotEvidenceMetadataKind: "screenshot_evidence_result" as const, + screenshotEvidenceSchemaId, + screenshotEvidenceVersion: 1 as const, + }; +} diff --git a/src/lib/loops/validation-report.ts b/src/lib/loops/validation-report.ts index 8b7d856..b063c2d 100644 --- a/src/lib/loops/validation-report.ts +++ b/src/lib/loops/validation-report.ts @@ -70,6 +70,27 @@ export const validationReportV1Schema = z }); } seenKeys.add(result.key); + if (result.outcome === "pass" && result.exitCode !== 0) { + context.addIssue({ + code: "custom", + message: "passing validation requires exitCode 0.", + path: ["results", index, "exitCode"], + }); + } + if (result.outcome === "fail" && (result.exitCode === null || result.exitCode === 0)) { + context.addIssue({ + code: "custom", + message: "failed validation requires a non-zero exitCode.", + path: ["results", index, "exitCode"], + }); + } + if (result.outcome === "skipped" && (result.exitCode !== null || !result.skipReason)) { + context.addIssue({ + code: "custom", + message: "skipped validation requires null exitCode and skipReason.", + path: ["results", index], + }); + } } for (const key of ["failed", "passed", "skipped", "total"] as const) { diff --git a/src/lib/loops/validation-runner.ts b/src/lib/loops/validation-runner.ts index 7fad23f..1299e2a 100644 --- a/src/lib/loops/validation-runner.ts +++ b/src/lib/loops/validation-runner.ts @@ -1,20 +1,31 @@ import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; - import { - validationReportSchemaId, - validationReportV1Schema, - validationReportVersion, + type CaptureValidationScreenshotsInput, + captureValidationScreenshots, + type ScreenshotEvidence, +} from "./screenshot-evidence"; +import { type ValidationGate, type ValidationGateOutcome, type ValidationGateResultV1, type ValidationOutputReference, type ValidationReportV1, + validationReportSchemaId, + validationReportV1Schema, + validationReportVersion, } from "./validation-report"; + export { createValidationReportArtifactContractMetadata, createValidationReportArtifactMetadata, summarizeValidationReport, + type ValidationGate, + type ValidationGateOutcome, + type ValidationGateResultV1, + type ValidationOutputReference, + type ValidationReportArtifactMetadata, + type ValidationReportV1, validationGateOutcomeValues, validationGateResultV1Schema, validationOutputReferenceSchema, @@ -22,12 +33,6 @@ export { validationReportSchemaId, validationReportV1Schema, validationReportVersion, - type ValidationGate, - type ValidationGateOutcome, - type ValidationGateResultV1, - type ValidationOutputReference, - type ValidationReportArtifactMetadata, - type ValidationReportV1, } from "./validation-report"; const allowedBunRunScripts = new Set([ @@ -39,10 +44,11 @@ const allowedBunRunScripts = new Set([ "storybook:build", "test", "test:e2e", + "test:e2e:validation-evidence", "typecheck", "validate", ]); -const allowedBunRunScriptsWithArgs = new Set(["test", "test:e2e"]); +const allowedBunRunScriptsWithArgs = new Set(["test", "test:e2e", "test:e2e:validation-evidence"]); const defaultTimeoutMs = 10 * 60_000; const defaultMaxOutputBytes = 128_000; const shellConstructPattern = /&&|\|\||[;|<>`$\\\n\r]/; @@ -127,6 +133,10 @@ export type RunValidationGatesInput = { timeoutMs?: number; }; +export type RunValidationWithScreenshotEvidenceInput = RunValidationGatesInput & { + screenshot: CaptureValidationScreenshotsInput; +}; + function parseValidationCommand(command: string): string[] { const argv: string[] = []; let current = ""; @@ -500,3 +510,22 @@ export async function runValidationGates( version: validationReportVersion, }); } + +export async function runValidationWithScreenshotEvidence( + input: RunValidationWithScreenshotEvidenceInput, +): Promise<{ report: ValidationReportV1; screenshotEvidence?: ScreenshotEvidence }> { + const { screenshot, ...validation } = input; + const report = await runValidationGates(validation); + if ( + report.overallOutcome !== "pass" || + report.results.some( + ({ outcome, required }) => outcome === "fail" || (required && outcome !== "pass"), + ) + ) { + return { report }; + } + return { + report, + screenshotEvidence: await captureValidationScreenshots(screenshot), + }; +} diff --git a/src/lib/seed/demo-data.ts b/src/lib/seed/demo-data.ts index 9066126..3275ede 100644 --- a/src/lib/seed/demo-data.ts +++ b/src/lib/seed/demo-data.ts @@ -159,6 +159,7 @@ export const demoSeedIds = { other: seedId("artifacts", 7), validationReportSucceeded: seedId("artifacts", 8), testPlan: seedId("artifacts", 9), + screenshot: seedId("artifacts", 10), }, approvals: { requested: seedId("approvals", 0), @@ -603,6 +604,19 @@ export function buildDemoSeedData(): DemoSeedData { uri: "https://github.com/ncolesummers/loopworks-web/actions/runs/demo-validation-report", metadata: demoValidationReportMetadata, }, + { + id: ids.artifacts.screenshot, + runId: ids.loopRuns.failed, + stepId: ids.runSteps.failed, + type: "screenshot", + title: "Validation screenshots", + uri: "artifact://demo/validation-screenshots", + metadata: { + expectedScreenshotEvidenceSchemaId: "loopworks.screenshot_evidence.v1", + screenshotEvidenceMetadataKind: "screenshot_evidence_contract", + screenshotEvidenceVersion: 1, + }, + }, { id: ids.artifacts.patch, runId: ids.loopRuns.succeeded, diff --git a/tests/unit/agent/validation-review-agent.test.ts b/tests/unit/agent/validation-review-agent.test.ts new file mode 100644 index 0000000..4fd01c6 --- /dev/null +++ b/tests/unit/agent/validation-review-agent.test.ts @@ -0,0 +1,133 @@ +/** @vitest-environment node */ +import { + computeValidationReviewDigest, + type ValidationReviewResult, + validationReviewAgentModelLabel, + validationReviewResultSchema, + validationReviewResultSchemaId, +} from "@agent/validation-review-agent"; + +function validResult(): ValidationReviewResult { + return { + version: 1 as const, + schemaId: validationReviewResultSchemaId, + model: validationReviewAgentModelLabel, + binding: { + runId: "00000000-0000-4000-8000-000000000049", + reviewAttempt: 1, + planId: "plan-49", + planSha256: "a".repeat(64), + testPlanSha256: "b".repeat(64), + implementationResultSha256: "c".repeat(64), + productionPatchSha256: "d".repeat(64), + validationReportSha256: "e".repeat(64), + screenshotEvidenceSha256: "f".repeat(64), + repositoryFullName: "ncolesummers/loopworks", + commitSha: "1".repeat(40), + }, + evidence: { + validationResults: [ + { + key: "aggregate-validation", + command: "bun run validate", + outcome: "pass" as const, + outputSha256: "2".repeat(64), + }, + ], + screenshots: [ + { + id: "browser-ac-1-mobile", + testId: "browser-ac-1", + viewport: "mobile" as const, + width: 390, + height: 844, + uri: "artifact://screenshots/browser-ac-1-mobile.png", + sha256: "3".repeat(64), + }, + ], + }, + findings: [ + { + id: "finding-1", + severity: "medium" as const, + category: "responsive" as const, + summary: "The narrow layout needs another implementation pass.", + path: "src/components/example.tsx", + line: 42, + validationCitationKeys: ["aggregate-validation"], + screenshotCitationIds: ["browser-ac-1-mobile"], + }, + ], + recommendation: { + route: "development" as const, + reason: "Responsive evidence shows an implementation defect.", + findingIds: ["finding-1"], + validationCitationKeys: ["aggregate-validation"], + screenshotCitationIds: ["browser-ac-1-mobile"], + }, + }; +} + +function firstFinding(result: ValidationReviewResult) { + const [finding] = result.findings; + if (!finding) throw new Error("Expected fixture finding."); + return finding; +} + +function firstScreenshot(result: ValidationReviewResult) { + const [screenshot] = result.evidence.screenshots; + if (!screenshot) throw new Error("Expected fixture screenshot."); + return screenshot; +} + +describe("validation review result contract", () => { + it("accepts digest-bound findings with exact validation and screenshot citations", () => { + const result = validResult(); + + expect(validationReviewResultSchema.parse(result)).toEqual(result); + expect(computeValidationReviewDigest(result)).toMatch(/^[a-f0-9]{64}$/); + expect(result.model).toBe("openai/gpt-5.6-terra-xhigh"); + }); + + it("rejects unknown, missing, or duplicate evidence citations", () => { + const unknown = validResult(); + firstFinding(unknown).validationCitationKeys = ["missing-gate"]; + expect(validationReviewResultSchema.safeParse(unknown).success).toBe(false); + + const missingScreenshot = validResult(); + firstFinding(missingScreenshot).screenshotCitationIds = []; + expect(validationReviewResultSchema.safeParse(missingScreenshot).success).toBe(false); + + const duplicate = validResult(); + duplicate.evidence.screenshots.push({ ...firstScreenshot(duplicate) }); + expect(validationReviewResultSchema.safeParse(duplicate).success).toBe(false); + + const duplicateCitation = validResult(); + firstFinding(duplicateCitation).validationCitationKeys.push("aggregate-validation"); + expect(validationReviewResultSchema.safeParse(duplicateCitation).success).toBe(false); + }); + + it("rejects unsafe routing, paths, and secret-like narrative", () => { + const blocker = validResult(); + firstFinding(blocker).severity = "blocker"; + blocker.recommendation.route = "commit"; + expect(validationReviewResultSchema.safeParse(blocker).success).toBe(false); + + const noFinding = validResult(); + noFinding.findings = []; + noFinding.recommendation.findingIds = []; + expect(validationReviewResultSchema.safeParse(noFinding).success).toBe(false); + + const traversal = validResult(); + firstFinding(traversal).path = "../private.env"; + expect(validationReviewResultSchema.safeParse(traversal).success).toBe(false); + + const unsafeScreenshotUri = validResult(); + firstScreenshot(unsafeScreenshotUri).uri = "https://user:password@example.com/capture.png"; + expect(validationReviewResultSchema.safeParse(unsafeScreenshotUri).success).toBe(false); + + const secret = validResult(); + secret.recommendation.reason = "authorization: Bearer top-secret"; + expect(validationReviewResultSchema.safeParse(secret).success).toBe(false); + }); +}); diff --git a/tests/unit/db/migrations.test.ts b/tests/unit/db/migrations.test.ts index 1ff3601..5e39d2b 100644 --- a/tests/unit/db/migrations.test.ts +++ b/tests/unit/db/migrations.test.ts @@ -1,7 +1,13 @@ /** @vitest-environment node */ import { existsSync, readdirSync, readFileSync } from "node:fs"; -import { approvalTransitionEvents, repositories } from "@/db/schema"; +import { + approvalTransitionEvents, + artifacts, + artifactTypeEnum, + loopRuns, + repositories, +} from "@/db/schema"; import { createPgliteTestDatabase } from "../../helpers/pglite"; const migrationReplayTimeoutMs = 15_000; @@ -77,6 +83,50 @@ describe("Drizzle migrations", () => { expect(migrationSql).toContain("'bypassed'"); }); + it( + "tracks screenshot artifacts in the schema and generated migrations", + async () => { + expect(artifactTypeEnum.enumValues).toContain("screenshot"); + const migrationSql = readMigrationSql(); + expect(migrationSql).toContain("ADD VALUE 'screenshot'"); + expect(migrationSql).toContain("screenshot_evidence_contract"); + expect(migrationSql).toContain('WHERE "run_steps"."stage" = \'validation\''); + + const context = await createPgliteTestDatabase(); + try { + const [repository] = await context.db + .insert(repositories) + .values({ + githubRepoId: 49_000_001, + owner: "ncolesummers", + name: "loopworks", + fullName: "ncolesummers/loopworks", + }) + .returning(); + if (!repository) throw new Error("Expected repository fixture."); + const runId = "00000000-0000-4000-8000-000000000049"; + await context.db.insert(loopRuns).values({ + id: runId, + loopKey: "development-loop", + repositoryId: repository.id, + }); + const [artifact] = await context.db + .insert(artifacts) + .values({ + runId: "00000000-0000-4000-8000-000000000049", + title: "Validation screenshots", + type: "screenshot", + uri: "artifact://screenshots/manifest", + }) + .returning(); + expect(artifact?.type).toBe("screenshot"); + } finally { + await context.close(); + } + }, + migrationReplayTimeoutMs, + ); + it( "replays generated migrations against a clean Postgres-compatible database", async () => { diff --git a/tests/unit/github/webhook-store.integration.test.ts b/tests/unit/github/webhook-store.integration.test.ts index 14eeef8..62dd3cd 100644 --- a/tests/unit/github/webhook-store.integration.test.ts +++ b/tests/unit/github/webhook-store.integration.test.ts @@ -307,7 +307,7 @@ describe("GitHub webhook delivery store (pglite integration)", () => { duplicate: false, agentReadyTrigger: { shouldTrigger: true, workflow: "development" }, developmentRun: { - artifactCount: 9, + artifactCount: 10, mode: "created", stageCount: 8, }, @@ -351,7 +351,7 @@ describe("GitHub webhook delivery store (pglite integration)", () => { .from(observabilityEvents) .where(eq(observabilityEvents.eventType, "development_loop_run_created")); expect(event.traceId).toBe(runRows[0]?.traceId); - expect(artifactRows).toHaveLength(9); + expect(artifactRows).toHaveLength(10); expect(planRows).toHaveLength(1); // A replayed delivery is rejected at the route boundary without creating new rows. @@ -369,7 +369,7 @@ describe("GitHub webhook delivery store (pglite integration)", () => { expect(await context.db.select().from(idempotencyLocks)).toHaveLength(1); expect(await context.db.select().from(loopRuns)).toHaveLength(1); expect(await context.db.select().from(runSteps)).toHaveLength(8); - expect(await context.db.select().from(artifacts)).toHaveLength(9); + expect(await context.db.select().from(artifacts)).toHaveLength(10); expect(await context.db.select().from(agentPlans)).toHaveLength(1); }); diff --git a/tests/unit/github/webhooks.test.ts b/tests/unit/github/webhooks.test.ts index 17cfb73..6dcf4ce 100644 --- a/tests/unit/github/webhooks.test.ts +++ b/tests/unit/github/webhooks.test.ts @@ -484,7 +484,7 @@ describe("GitHub webhook helpers", () => { workflow: "development", }, developmentRun: { - artifactCount: 9, + artifactCount: 10, mode: "simulated", stageCount: 8, }, @@ -605,7 +605,7 @@ describe("GitHub webhook helpers", () => { deliveryId: "successful-processing-route-delivery", metadata: { developmentRun: { - artifactCount: 9, + artifactCount: 10, mode: "simulated", stageCount: 8, }, diff --git a/tests/unit/loops/development-run-transitions.test.ts b/tests/unit/loops/development-run-transitions.test.ts index b08d296..4aa9519 100644 --- a/tests/unit/loops/development-run-transitions.test.ts +++ b/tests/unit/loops/development-run-transitions.test.ts @@ -311,6 +311,31 @@ describe("development-loop run transitions", () => { }); }); + it("rejects a report generated before the current validation attempt", async () => { + const runId = await createRun(context); + const report = validationReportV1Schema.parse({ + ...validationReport([ + gateResult({ + durationMs: 1000, + exitCode: 0, + key: "focused-tests", + outcome: "pass", + required: true, + }), + ]), + generatedAt: "2026-07-08T15:59:59.000Z", + }); + + await expect( + applyDevelopmentLoopValidationReport({ + database: transitionDatabase(context), + occurredAt: new Date("2026-07-08T16:05:00.000Z"), + report, + runId, + }), + ).rejects.toThrow("Validation report timestamp is stale"); + }); + it("returns the persisted blocked reason when a failed validation transition is replayed", async () => { const runId = await createRun(context); const metrics = createMetricRecorder(); diff --git a/tests/unit/loops/development-run.test.ts b/tests/unit/loops/development-run.test.ts index b6219d3..b9e220b 100644 --- a/tests/unit/loops/development-run.test.ts +++ b/tests/unit/loops/development-run.test.ts @@ -116,7 +116,7 @@ describe("agent-ready development loop run skeleton", () => { const artifactRecords = projectDevelopmentLoopArtifacts(skeleton); expect(skeleton.stages).toHaveLength(8); - expect(skeleton.artifacts).toHaveLength(9); + expect(skeleton.artifacts).toHaveLength(10); expect(timeline.map((event) => event.title)).toEqual([ "Planning", "Test writing", @@ -134,6 +134,7 @@ describe("agent-ready development loop run skeleton", () => { "Automated test plan", "Patch artifact", "Validation report", + "Validation screenshots", "Code review notes", "Commit intent", "PR intent", @@ -141,7 +142,7 @@ describe("agent-ready development loop run skeleton", () => { ]); }); - it("creates one durable run, eight stage rows, nine artifacts, and an agent plan", async () => { + it("creates one durable run, eight stage rows, ten artifacts, and an agent plan", async () => { await insertRepository(context); const result = await withTestTrace(() => @@ -154,7 +155,7 @@ describe("agent-ready development loop run skeleton", () => { ); expect(result).toMatchObject({ - artifactCount: 9, + artifactCount: 10, mode: "created", stageCount: 8, }); @@ -186,7 +187,7 @@ describe("agent-ready development loop run skeleton", () => { expect(stepRows.map((step) => step.stage)).toEqual( developmentLoopStages.map((stage) => stage.key), ); - expect(artifactRows).toHaveLength(9); + expect(artifactRows).toHaveLength(10); expect(artifactRows.every((artifact) => artifact.runId === runRows[0]?.id)).toBe(true); expect( artifactRows.find( @@ -273,7 +274,7 @@ describe("agent-ready development loop run skeleton", () => { expect(second).toEqual(first); expect(await context.db.select().from(loopRuns)).toHaveLength(1); expect(await context.db.select().from(runSteps)).toHaveLength(8); - expect(await context.db.select().from(artifacts)).toHaveLength(9); + expect(await context.db.select().from(artifacts)).toHaveLength(10); expect(await context.db.select().from(agentPlans)).toHaveLength(1); }); diff --git a/tests/unit/loops/validation-review-transition.test.ts b/tests/unit/loops/validation-review-transition.test.ts new file mode 100644 index 0000000..7dcac80 --- /dev/null +++ b/tests/unit/loops/validation-review-transition.test.ts @@ -0,0 +1,694 @@ +/** @vitest-environment node */ +import { createHash } from "node:crypto"; + +import { + computeImplementationDigest, + type ImplementationResult, + implementationAgentModelLabel, + implementationResultSchemaId, +} from "@agent/implementation-agent"; +import { + computeTestPlanDigest, + redTestEvidenceSchemaId, + testPlanSchemaId, +} from "@agent/test-writing-agent"; +import { + computeValidationReviewDigest, + type ValidationReviewResult, + validationReviewAgentModelLabel, + validationReviewResultSchemaId, +} from "@agent/validation-review-agent"; +import { and, eq, inArray } from "drizzle-orm"; + +import { agentPlans, approvals, artifacts, loopRuns, repositories, runSteps } from "@/db/schema"; +import { applyApprovalTransition } from "@/lib/approval-transitions"; +import type { ApprovalTransitionDatabase } from "@/lib/approvals"; +import { + createDevelopmentLoopRun, + type DevelopmentLoopRunDatabase, +} from "@/lib/loops/development-run"; +import { + applyDevelopmentLoopValidationReport, + applyDevelopmentLoopValidationReviewResult, + type DevelopmentLoopTransitionDatabase, +} from "@/lib/loops/development-run-transitions"; +import { + computeScreenshotEvidenceDigest, + type ScreenshotEvidence, + screenshotEvidenceSchemaId, +} from "@/lib/loops/screenshot-evidence"; +import type { ValidationReportV1 } from "@/lib/loops/validation-report"; +import { createPgliteTestDatabase, type PgliteTestDatabase } from "../../helpers/pglite"; + +const sha256 = (value: string) => createHash("sha256").update(value).digest("hex"); + +describe("validation review transition", () => { + let context: PgliteTestDatabase; + + beforeEach(async () => { + context = await createPgliteTestDatabase(); + await context.db.insert(repositories).values({ + githubRepoId: 49_000_002, + owner: "ncolesummers", + name: "loopworks", + fullName: "ncolesummers/loopworks", + enabledLoops: ["Agent-ready development loop"], + validationGates: ["Aggregate validation"], + }); + }); + + afterEach(async () => context.close()); + + async function prepare() { + const created = await createDevelopmentLoopRun({ + database: context.db as unknown as DevelopmentLoopRunDatabase, + now: () => new Date("2026-07-13T19:00:00.000Z"), + trigger: { + body: "## Acceptance Criteria\n- Review cites validation and responsive evidence.", + issueNumber: 49, + repositoryFullName: "ncolesummers/loopworks", + repositoryRevision: { ref: "main", commitSha: "a".repeat(40) }, + title: "Validation review subagent", + }, + }); + if (created.mode !== "created") throw new Error("Expected created run."); + const [approval] = await context.db + .select() + .from(approvals) + .where(eq(approvals.runId, created.runId)); + if (!approval) throw new Error("Expected plan approval."); + await applyApprovalTransition({ + action: "approve", + actorId: "maintainer", + approvalId: approval.id, + database: context.db as unknown as ApprovalTransitionDatabase, + expectedStatus: "requested", + }); + const [planRow] = await context.db + .select() + .from(agentPlans) + .where(eq(agentPlans.runId, created.runId)); + const plan = planRow?.plan as { identity?: { id?: string; sha256?: string } }; + if (!planRow || !plan.identity?.id || !plan.identity.sha256) { + throw new Error("Expected approved plan identity."); + } + + const testPatch = [ + "diff --git a/tests/e2e/review.spec.ts b/tests/e2e/review.spec.ts", + "new file mode 100644", + "--- /dev/null", + "+++ b/tests/e2e/review.spec.ts", + "@@ -0,0 +1 @@", + "+test('responsive review', async () => {});", + ].join("\n"); + const testPlan = { + version: 1 as const, + schemaId: testPlanSchemaId, + plan: { + id: plan.identity.id, + sha256: plan.identity.sha256, + repositoryFullName: "ncolesummers/loopworks", + commitSha: "a".repeat(40), + }, + acceptanceCriteria: [ + { id: "ac-1", text: "Review cites validation and responsive evidence." }, + ], + tests: [ + { + id: "browser-ac-1", + acceptanceCriterionIds: ["ac-1"], + type: "browser" as const, + path: "tests/e2e/review.spec.ts", + command: "bunx playwright test tests/e2e/review.spec.ts", + steps: ["Open the run detail and inspect the responsive state."], + expectedFailure: { kind: "assertion" as const, message: "responsive state is missing" }, + fixtureIds: [], + }, + ], + fixtures: [], + patch: { + format: "unified-diff" as const, + content: testPatch, + sha256: sha256(testPatch), + byteCount: Buffer.byteLength(testPatch), + paths: ["tests/e2e/review.spec.ts"], + }, + }; + const redEvidence = { + version: 1 as const, + schemaId: redTestEvidenceSchemaId, + planId: plan.identity.id, + planSha256: plan.identity.sha256, + testPlanSha256: computeTestPlanDigest(testPlan), + results: [ + { + id: "red-ac-1", + testId: "browser-ac-1", + acceptanceCriterionIds: ["ac-1"], + command: "bunx playwright test tests/e2e/review.spec.ts", + outcome: "expected_failure" as const, + exitCode: 1, + durationMs: 10, + expectedAssertion: "responsive state is missing", + executionReceipt: "b".repeat(64), + outputReference: { + uri: "artifact://red.log", + sha256: "c".repeat(64), + byteCount: 10, + redacted: true as const, + }, + }, + ], + }; + const productionPatch = [ + "diff --git a/src/components/review-card.tsx b/src/components/review-card.tsx", + "new file mode 100644", + "--- /dev/null", + "+++ b/src/components/review-card.tsx", + "@@ -0,0 +1 @@", + "+export const ReviewCard = () => null;", + ].join("\n"); + const implementation: ImplementationResult = { + version: 1, + schemaId: implementationResultSchemaId, + model: implementationAgentModelLabel, + binding: { + planId: plan.identity.id, + planSha256: plan.identity.sha256, + testPlanSha256: computeTestPlanDigest(testPlan), + testPatchSha256: testPlan.patch.sha256, + fixturesSha256: computeImplementationDigest(testPlan.fixtures), + repositoryFullName: "ncolesummers/loopworks", + commitSha: "a".repeat(40), + }, + patch: { + format: "unified-diff", + content: productionPatch, + sha256: sha256(productionPatch), + byteCount: Buffer.byteLength(productionPatch), + paths: ["src/components/review-card.tsx"], + }, + greenEvidence: [ + { + id: "green-ac-1", + testId: "browser-ac-1", + acceptanceCriterionIds: ["ac-1"], + command: "bunx playwright test tests/e2e/review.spec.ts", + testPath: "tests/e2e/review.spec.ts", + outcome: "pass", + exitCode: 0, + durationMs: 10, + executionReceipt: "d".repeat(64), + outputReference: { + uri: "artifact://green.log", + sha256: "e".repeat(64), + byteCount: 10, + redacted: true, + }, + }, + ], + validationEvidence: { + command: "bun run validate", + outcome: "pass", + exitCode: 0, + durationMs: 20, + executionReceipt: "f".repeat(64), + outputReference: { + uri: "artifact://validate.log", + sha256: "1".repeat(64), + byteCount: 10, + redacted: true, + }, + }, + }; + const report: ValidationReportV1 = { + version: 1, + schemaId: "loopworks.validation_report.v1", + generatedAt: "2026-07-13T20:00:00.000Z", + overallOutcome: "pass", + counts: { failed: 0, passed: 1, skipped: 0, total: 1 }, + results: [ + { + key: "aggregate-validation", + name: "Aggregate validation", + command: "bun run validate", + durationMs: 20, + exitCode: 0, + outcome: "pass", + phase: "before_review", + produces: "validation_report", + required: true, + output: { + uri: "artifact://validation.log", + sha256: "2".repeat(64), + stdoutBytes: 10, + stderrBytes: 0, + truncated: false, + }, + }, + ], + }; + const screenshotEvidence: ScreenshotEvidence = { + version: 1, + schemaId: screenshotEvidenceSchemaId, + binding: { + repositoryFullName: "ncolesummers/loopworks", + commitSha: "a".repeat(40), + testPlanSha256: computeTestPlanDigest(testPlan), + productionPatchSha256: implementation.patch.sha256, + }, + uiAffecting: true, + browserTestIds: ["browser-ac-1"], + captures: ( + [ + ["mobile", 390, 844], + ["laptop", 1280, 832], + ["desktop", 1440, 960], + ] as const + ).map(([viewport, width, height], index) => ({ + id: `browser-ac-1-${viewport}`, + testId: "browser-ac-1", + viewport, + width, + height, + mimeType: "image/png" as const, + uri: `artifact://screenshots/${viewport}.png`, + sha256: String(index + 3).repeat(64), + byteCount: 100, + })), + }; + + const steps = await context.db.select().from(runSteps).where(eq(runSteps.runId, created.runId)); + const step = (stage: string) => { + const value = steps.find((candidate) => candidate.stage === stage); + if (!value) throw new Error(`Expected ${stage} step.`); + return value; + }; + const testArtifacts = await context.db + .select() + .from(artifacts) + .where(eq(artifacts.stepId, step("test-writing").id)); + const patchArtifact = ( + await context.db + .select() + .from(artifacts) + .where(eq(artifacts.stepId, step("development").id)) + ).find(({ type }) => type === "patch"); + const validationArtifacts = await context.db + .select() + .from(artifacts) + .where(eq(artifacts.stepId, step("validation").id)); + const reviewArtifact = ( + await context.db + .select() + .from(artifacts) + .where(eq(artifacts.stepId, step("code-review").id)) + ).find(({ type }) => type === "log_summary"); + const testPlanArtifact = testArtifacts.find(({ type }) => type === "test_plan"); + const redArtifact = testArtifacts.find(({ type }) => type === "validation_report"); + const validationArtifact = validationArtifacts.find(({ type }) => type === "validation_report"); + let screenshotArtifact = validationArtifacts.find(({ type }) => type === "screenshot"); + if (!screenshotArtifact) { + [screenshotArtifact] = await context.db + .insert(artifacts) + .values({ + runId: created.runId, + stepId: step("validation").id, + type: "screenshot", + title: "Validation screenshots", + uri: "artifact://validation/screenshots", + }) + .returning(); + } + if ( + !testPlanArtifact || + !redArtifact || + !patchArtifact || + !validationArtifact || + !screenshotArtifact || + !reviewArtifact + ) { + throw new Error("Expected stage artifacts."); + } + await context.db + .update(artifacts) + .set({ + metadata: { testPlan, testPlanMetadataKind: "test_plan_result" }, + sha256: computeTestPlanDigest(testPlan), + }) + .where(eq(artifacts.id, testPlanArtifact.id)); + await context.db + .update(artifacts) + .set({ + metadata: { + redTestEvidence: redEvidence, + redTestEvidenceMetadataKind: "red_test_evidence_result", + }, + sha256: computeTestPlanDigest(redEvidence), + }) + .where(eq(artifacts.id, redArtifact.id)); + await context.db + .update(artifacts) + .set({ + metadata: { + implementationMetadataKind: "implementation_result", + implementationResult: implementation, + implementationResultSchemaId, + implementationVersion: 1, + }, + sha256: computeImplementationDigest(implementation), + }) + .where(eq(artifacts.id, patchArtifact.id)); + const validationReportSha256 = computeValidationReviewDigest(report); + await context.db + .update(artifacts) + .set({ + metadata: { + detail: "Validation report: 1 passed, 0 failed, 0 skipped.", + validationReport: report, + validationReportMetadataKind: "validation_report_result", + validationReportSchemaId: report.schemaId, + validationReportVersion: 1, + }, + sha256: validationReportSha256, + }) + .where(eq(artifacts.id, validationArtifact.id)); + await context.db + .update(artifacts) + .set({ + metadata: { + screenshotEvidence, + screenshotEvidenceMetadataKind: "screenshot_evidence_result", + screenshotEvidenceSchemaId, + screenshotEvidenceVersion: 1, + }, + sha256: computeScreenshotEvidenceDigest(screenshotEvidence), + }) + .where(eq(artifacts.id, screenshotArtifact.id)); + await context.db + .update(runSteps) + .set({ status: "succeeded", completedAt: new Date(), validationStatus: "red" }) + .where(eq(runSteps.id, step("test-writing").id)); + await context.db + .update(runSteps) + .set({ + status: "succeeded", + completedAt: new Date(), + validationStatus: "green", + metadata: { implementationClaim: "claimed" }, + }) + .where(eq(runSteps.id, step("development").id)); + await context.db + .update(runSteps) + .set({ status: "succeeded", completedAt: new Date(), validationStatus: "passed" }) + .where(eq(runSteps.id, step("validation").id)); + await context.db + .update(runSteps) + .set({ status: "running", startedAt: new Date() }) + .where(eq(runSteps.id, step("code-review").id)); + await context.db + .update(loopRuns) + .set({ currentStage: "code-review", status: "running" }) + .where(eq(loopRuns.id, created.runId)); + + const binding = { + runId: created.runId, + reviewAttempt: 1, + planId: plan.identity.id, + planSha256: plan.identity.sha256, + testPlanSha256: computeTestPlanDigest(testPlan), + implementationResultSha256: computeImplementationDigest(implementation), + productionPatchSha256: implementation.patch.sha256, + validationReportSha256, + screenshotEvidenceSha256: computeScreenshotEvidenceDigest(screenshotEvidence), + repositoryFullName: "ncolesummers/loopworks", + commitSha: "a".repeat(40), + }; + const evidence = { + validationResults: [ + { + key: "aggregate-validation", + command: "bun run validate", + outcome: "pass" as const, + outputSha256: "2".repeat(64), + }, + ], + screenshots: screenshotEvidence.captures.map( + ({ id, testId, viewport, width, height, uri, sha256 }) => ({ + id, + testId, + viewport, + width, + height, + uri, + sha256, + }), + ), + }; + const result = (route: "commit" | "development" | "test-writing"): ValidationReviewResult => ({ + version: 1, + schemaId: validationReviewResultSchemaId, + model: validationReviewAgentModelLabel, + binding, + evidence, + findings: + route === "commit" + ? [] + : [ + { + id: "finding-1", + severity: route === "test-writing" ? "high" : "medium", + category: route === "test-writing" ? "test-plan" : "implementation", + summary: + route === "test-writing" + ? "The browser journey does not cover the required state." + : "The implementation does not satisfy the reviewed state.", + path: + route === "test-writing" + ? "tests/e2e/review.spec.ts" + : "src/components/review-card.tsx", + validationCitationKeys: ["aggregate-validation"], + screenshotCitationIds: ["browser-ac-1-mobile"], + }, + ], + recommendation: { + route, + reason: + route === "commit" + ? "All bound validation and screenshot evidence supports forward routing." + : "The cited finding requires another bounded stage attempt.", + findingIds: route === "commit" ? [] : ["finding-1"], + validationCitationKeys: ["aggregate-validation"], + screenshotCitationIds: screenshotEvidence.captures.map(({ id }) => id), + }, + }); + + return { + report, + runId: created.runId, + result, + reviewArtifactId: reviewArtifact.id, + screenshotArtifactId: screenshotArtifact.id, + }; + } + + it("blocks UI-affecting validation before review when screenshots are absent", async () => { + const prepared = await prepare(); + await context.db + .update(artifacts) + .set({ sha256: null }) + .where(eq(artifacts.id, prepared.screenshotArtifactId)); + await context.db + .update(runSteps) + .set({ completedAt: null, status: "queued", validationStatus: "required" }) + .where(and(eq(runSteps.runId, prepared.runId), eq(runSteps.stage, "validation"))); + await context.db + .update(loopRuns) + .set({ currentStage: "validation", status: "running" }) + .where(eq(loopRuns.id, prepared.runId)); + + await expect( + applyDevelopmentLoopValidationReport({ + database: context.db as unknown as DevelopmentLoopTransitionDatabase, + report: prepared.report, + runId: prepared.runId, + }), + ).resolves.toMatchObject({ + blockedReason: "UI-affecting validation requires complete screenshot evidence.", + status: "blocked", + }); + }); + + it("persists forward review evidence and advances to commit", async () => { + const prepared = await prepare(); + const logger = { info: vi.fn() }; + + const result = await applyDevelopmentLoopValidationReviewResult({ + database: context.db as unknown as DevelopmentLoopTransitionDatabase, + logger: logger as never, + output: prepared.result("commit"), + runId: prepared.runId, + }); + + const [run] = await context.db.select().from(loopRuns).where(eq(loopRuns.id, prepared.runId)); + const [reviewStep] = await context.db + .select() + .from(runSteps) + .where(and(eq(runSteps.runId, prepared.runId), eq(runSteps.stage, "code-review"))); + const [reviewArtifact] = await context.db + .select() + .from(artifacts) + .where(eq(artifacts.id, prepared.reviewArtifactId)); + expect(result).toMatchObject({ stage: "code-review", status: "advanced", route: "commit" }); + expect(run).toMatchObject({ currentStage: "commit", status: "running" }); + expect(reviewStep).toMatchObject({ status: "succeeded" }); + expect(reviewArtifact?.metadata).toMatchObject({ + validationReviewMetadataKind: "validation_review_result", + validationReviewResult: prepared.result("commit"), + }); + expect(logger.info).toHaveBeenCalledWith( + expect.objectContaining({ route: "commit", findingCount: 0, screenshotCount: 3 }), + "validation_review_stage_routed", + ); + }); + + it.each([ + ["development" as const, ["development", "validation", "code-review"]], + ["test-writing" as const, ["test-writing", "development", "validation", "code-review"]], + ])("atomically rewinds to %s and clears invalidated claims and artifacts", async (route, resetStages) => { + const prepared = await prepare(); + await applyDevelopmentLoopValidationReviewResult({ + database: context.db as unknown as DevelopmentLoopTransitionDatabase, + output: prepared.result(route), + runId: prepared.runId, + }); + + const [run] = await context.db.select().from(loopRuns).where(eq(loopRuns.id, prepared.runId)); + const steps = await context.db + .select() + .from(runSteps) + .where(and(eq(runSteps.runId, prepared.runId), inArray(runSteps.stage, resetStages))); + expect(run).toMatchObject({ currentStage: route, status: "queued" }); + expect(run?.metadata).toMatchObject({ + validationReviewHistory: [ + expect.objectContaining({ + attempt: 1, + route, + digest: expect.stringMatching(/^[a-f0-9]{64}$/), + }), + ], + }); + expect(JSON.stringify(run?.metadata)).not.toContain("bounded stage attempt"); + for (const step of steps) { + expect(step).toMatchObject({ + attempt: 2, + status: "queued", + startedAt: null, + completedAt: null, + }); + expect(step.metadata).not.toHaveProperty("implementationClaim"); + expect(step.metadata).not.toHaveProperty("testWritingClaim"); + expect(step.metadata).not.toHaveProperty("validationReviewClaim"); + } + const resetArtifacts = await context.db + .select() + .from(artifacts) + .where( + inArray( + artifacts.stepId, + steps.map(({ id }) => id), + ), + ); + expect(resetArtifacts.every(({ sha256 }) => sha256 === null)).toBe(true); + if (route === "development") { + const [testStep] = await context.db + .select() + .from(runSteps) + .where(and(eq(runSteps.runId, prepared.runId), eq(runSteps.stage, "test-writing"))); + expect(testStep).toMatchObject({ attempt: 1, status: "succeeded" }); + } + }); + + it("accepts exact replay idempotently and rejects conflicting replay", async () => { + const prepared = await prepare(); + const output = prepared.result("development"); + await applyDevelopmentLoopValidationReviewResult({ + database: context.db as unknown as DevelopmentLoopTransitionDatabase, + output, + runId: prepared.runId, + }); + await expect( + applyDevelopmentLoopValidationReviewResult({ + database: context.db as unknown as DevelopmentLoopTransitionDatabase, + output, + runId: prepared.runId, + }), + ).resolves.toMatchObject({ idempotent: true, route: "development" }); + + const conflicting = structuredClone(output); + conflicting.recommendation.reason = "A different reason must not replace the applied review."; + await expect( + applyDevelopmentLoopValidationReviewResult({ + database: context.db as unknown as DevelopmentLoopTransitionDatabase, + output: conflicting, + runId: prepared.runId, + }), + ).rejects.toThrow("does not match"); + }); + + it("serializes concurrent application and leaves one auditable result", async () => { + const prepared = await prepare(); + const output = prepared.result("commit"); + const applications = await Promise.allSettled( + [1, 2].map(() => + applyDevelopmentLoopValidationReviewResult({ + database: context.db as unknown as DevelopmentLoopTransitionDatabase, + output, + runId: prepared.runId, + }), + ), + ); + expect(applications.some(({ status }) => status === "fulfilled")).toBe(true); + await expect( + applyDevelopmentLoopValidationReviewResult({ + database: context.db as unknown as DevelopmentLoopTransitionDatabase, + output, + runId: prepared.runId, + }), + ).resolves.toMatchObject({ idempotent: true, route: "commit" }); + const [run] = await context.db.select().from(loopRuns).where(eq(loopRuns.id, prepared.runId)); + expect((run?.metadata?.validationReviewHistory as unknown[] | undefined) ?? []).toHaveLength(1); + }); + + it("rejects stale validation bindings before persisting review notes", async () => { + const prepared = await prepare(); + const output = prepared.result("commit"); + output.binding.validationReportSha256 = "0".repeat(64); + + await expect( + applyDevelopmentLoopValidationReviewResult({ + database: context.db as unknown as DevelopmentLoopTransitionDatabase, + output, + runId: prepared.runId, + }), + ).rejects.toThrow("bound"); + }); + + it("refuses a backward route after the manifest retry budget is exhausted", async () => { + const prepared = await prepare(); + await context.db + .update(runSteps) + .set({ attempt: 2 }) + .where(and(eq(runSteps.runId, prepared.runId), eq(runSteps.stage, "code-review"))); + const output = prepared.result("development"); + output.binding.reviewAttempt = 2; + + await expect( + applyDevelopmentLoopValidationReviewResult({ + database: context.db as unknown as DevelopmentLoopTransitionDatabase, + output, + runId: prepared.runId, + }), + ).rejects.toThrow("retry budget"); + }); +}); diff --git a/tests/unit/loops/validation-runner.test.ts b/tests/unit/loops/validation-runner.test.ts index bed844f..696302c 100644 --- a/tests/unit/loops/validation-runner.test.ts +++ b/tests/unit/loops/validation-runner.test.ts @@ -1,15 +1,15 @@ /** @vitest-environment node */ import { readFile } from "node:fs/promises"; - -import type { LoopDefinition } from "../../../schemas/loop-manifest"; import { createValidationReportArtifactMetadata, runValidationGates, + runValidationWithScreenshotEvidence, type ValidationCommandExecutionInput, type ValidationOutputWriterInput, validationReportSchemaId, validationReportV1Schema, } from "@/lib/loops/validation-runner"; +import type { LoopDefinition } from "../../../schemas/loop-manifest"; const fixtureGates = [ { @@ -223,6 +223,13 @@ describe("deterministic validation runner", () => { results: [report.results[0], report.results[0]], }), ).toThrow(/unique/); + + expect(() => + validationReportV1Schema.parse({ + ...report, + results: [{ ...report.results[0], command: "false", exitCode: 1, outcome: "pass" }], + }), + ).toThrow(/exitCode/); }); it("keeps the runner independent from persistence and lifecycle telemetry", async () => { @@ -233,4 +240,29 @@ describe("deterministic validation runner", () => { expect(source).not.toContain("loopRuns"); expect(source).not.toContain("runSteps"); }); + + it("runs validation-owned screenshot capture only after deterministic gates pass", async () => { + const capture = vi.fn(); + const result = await runValidationWithScreenshotEvidence({ + executor: async () => ({ exitCode: 0, stderr: "", stdout: "" }), + gates: [fixtureGates[0]], + now: createSteppedClock(), + screenshot: { + binding: { + repositoryFullName: "ncolesummers/loopworks", + commitSha: "a".repeat(40), + testPlanSha256: "b".repeat(64), + productionPatchSha256: "c".repeat(64), + }, + productionPaths: ["src/lib/parser.ts"], + tests: [], + capture, + write: vi.fn(), + }, + }); + + expect(result.report.overallOutcome).toBe("pass"); + expect(result.screenshotEvidence).toMatchObject({ uiAffecting: false, captures: [] }); + expect(capture).not.toHaveBeenCalled(); + }); }); diff --git a/tests/unit/loops/validation-screenshots.test.ts b/tests/unit/loops/validation-screenshots.test.ts new file mode 100644 index 0000000..1a10b61 --- /dev/null +++ b/tests/unit/loops/validation-screenshots.test.ts @@ -0,0 +1,241 @@ +/** @vitest-environment node */ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { + assertScreenshotEvidenceBinding, + assertScreenshotEvidenceCoverage, + captureValidationScreenshots, + classifyUiAffectingChange, + computeScreenshotEvidenceDigest, + screenshotEvidenceSchema, + screenshotEvidenceSchemaId, + validationScreenshotViewports, +} from "@/lib/loops/screenshot-evidence"; +import validationEvidencePlaywrightConfig from "../../../playwright.validation-evidence.config"; + +function png(width: number, height: number): Uint8Array { + const bytes = new Uint8Array(58); + bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + bytes.set([0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52], 8); + new DataView(bytes.buffer).setUint32(16, width); + new DataView(bytes.buffer).setUint32(20, height); + bytes.set([0, 0, 0, 1, 0x49, 0x44, 0x41, 0x54, 0, 0, 0, 0, 0], 33); + bytes.set([0, 0, 0, 0, 0x49, 0x45, 0x4e, 0x44, 0, 0, 0, 0], 46); + return bytes; +} + +const browserTest = { + id: "browser-ac-1", + type: "browser" as const, + path: "tests/e2e/review.spec.ts", +}; + +describe("validation screenshot evidence", () => { + it("configures Playwright to retain final states at every evidence viewport", () => { + expect(validationEvidencePlaywrightConfig.use?.screenshot).toBe("on"); + expect(validationEvidencePlaywrightConfig.outputDir).toBe("test-results/validation-evidence"); + expect( + validationEvidencePlaywrightConfig.projects?.map(({ name, use }) => ({ + name, + viewport: use?.viewport, + })), + ).toEqual([ + { name: "validation-mobile", viewport: { width: 390, height: 844 } }, + { name: "validation-laptop", viewport: { width: 1280, height: 832 } }, + { name: "validation-desktop", viewport: { width: 1440, height: 960 } }, + ]); + }); + + it("exposes the validation evidence Playwright configuration as a runnable script", async () => { + const packageJson = JSON.parse(await readFile("package.json", "utf8")); + expect(packageJson.scripts?.["test:e2e:validation-evidence"]).toBe( + "playwright test --config=playwright.validation-evidence.config.ts", + ); + }); + + it("classifies UI-affecting production and test paths deterministically", () => { + expect( + classifyUiAffectingChange({ productionPaths: ["src/components/card.tsx"], tests: [] }), + ).toBe(true); + expect( + classifyUiAffectingChange({ productionPaths: ["src/lib/parser.ts"], tests: [browserTest] }), + ).toBe(true); + expect(classifyUiAffectingChange({ productionPaths: ["src/lib/parser.ts"], tests: [] })).toBe( + false, + ); + expect(classifyUiAffectingChange({ productionPaths: ["public/logo.svg"], tests: [] })).toBe( + true, + ); + }); + + it("captures every browser test at mobile, laptop, and desktop viewports", async () => { + const capture = vi.fn(async ({ viewport }) => png(viewport.width, viewport.height)); + const write = vi.fn(async ({ id, bytes }: { id: string; bytes: Uint8Array }) => ({ + uri: `artifact://screenshots/${id}.png`, + sha256: createHash("sha256").update(bytes).digest("hex"), + byteCount: bytes.byteLength, + })); + + const evidence = await captureValidationScreenshots({ + binding: { + repositoryFullName: "ncolesummers/loopworks", + commitSha: "a".repeat(40), + testPlanSha256: "b".repeat(64), + productionPatchSha256: "c".repeat(64), + }, + productionPaths: ["src/components/card.tsx"], + tests: [browserTest], + capture, + write, + }); + + expect(validationScreenshotViewports).toEqual([ + { name: "mobile", width: 390, height: 844 }, + { name: "laptop", width: 1280, height: 832 }, + { name: "desktop", width: 1440, height: 960 }, + ]); + expect(capture).toHaveBeenCalledTimes(3); + expect(write).toHaveBeenCalledTimes(3); + expect(evidence.captures.map(({ viewport }) => viewport)).toEqual([ + "mobile", + "laptop", + "desktop", + ]); + expect(screenshotEvidenceSchema.parse(evidence)).toEqual(evidence); + expect(computeScreenshotEvidenceDigest(evidence)).toMatch(/^[a-f0-9]{64}$/); + }); + + it("records an empty non-UI manifest and fails closed for incomplete UI evidence", async () => { + const nonUi = await captureValidationScreenshots({ + binding: { + repositoryFullName: "ncolesummers/loopworks", + commitSha: "a".repeat(40), + testPlanSha256: "b".repeat(64), + productionPatchSha256: "c".repeat(64), + }, + productionPaths: ["src/lib/parser.ts"], + tests: [], + capture: vi.fn(), + write: vi.fn(), + }); + expect(nonUi).toMatchObject({ + schemaId: screenshotEvidenceSchemaId, + uiAffecting: false, + browserTestIds: [], + captures: [], + }); + + const incomplete = { + ...nonUi, + uiAffecting: true, + browserTestIds: ["browser-ac-1"], + captures: [], + }; + expect(screenshotEvidenceSchema.safeParse(incomplete).success).toBe(false); + }); + + it("rejects non-PNG capture bytes", async () => { + await expect( + captureValidationScreenshots({ + binding: { + repositoryFullName: "ncolesummers/loopworks", + commitSha: "a".repeat(40), + testPlanSha256: "b".repeat(64), + productionPatchSha256: "c".repeat(64), + }, + productionPaths: ["src/components/card.tsx"], + tests: [browserTest], + capture: async () => Uint8Array.from([1, 2, 3]), + write: vi.fn(), + }), + ).rejects.toThrow("PNG"); + }); + + it("rejects forged writer digests and stale handoff bindings", async () => { + await expect( + captureValidationScreenshots({ + binding: { + repositoryFullName: "ncolesummers/loopworks", + commitSha: "a".repeat(40), + testPlanSha256: "b".repeat(64), + productionPatchSha256: "c".repeat(64), + }, + productionPaths: ["src/components/card.tsx"], + tests: [browserTest], + capture: async ({ viewport }) => png(viewport.width, viewport.height), + write: async () => ({ + uri: "artifact://screenshots/forged.png", + sha256: "0".repeat(64), + byteCount: png(390, 844).byteLength, + }), + }), + ).rejects.toThrow("digest"); + + await expect( + captureValidationScreenshots({ + binding: { + repositoryFullName: "ncolesummers/loopworks", + commitSha: "a".repeat(40), + testPlanSha256: "b".repeat(64), + productionPatchSha256: "c".repeat(64), + }, + productionPaths: ["src/components/card.tsx"], + tests: [browserTest], + capture: async ({ viewport }) => png(viewport.width, viewport.height), + write: async () => ({ uri: "artifact://screenshots/collision.png" }), + }), + ).rejects.toThrow("Duplicate screenshot URI"); + + const evidence = screenshotEvidenceSchema.parse({ + version: 1, + schemaId: screenshotEvidenceSchemaId, + binding: { + repositoryFullName: "ncolesummers/loopworks", + commitSha: "a".repeat(40), + testPlanSha256: "b".repeat(64), + productionPatchSha256: "c".repeat(64), + }, + uiAffecting: false, + browserTestIds: [], + captures: [], + }); + expect(() => + assertScreenshotEvidenceBinding(evidence, { + ...evidence.binding, + productionPatchSha256: "d".repeat(64), + }), + ).toThrow("bound"); + }); + + it("rejects declared dimensions and browser ids that do not match captured evidence", async () => { + const binding = { + repositoryFullName: "ncolesummers/loopworks", + commitSha: "a".repeat(40), + testPlanSha256: "b".repeat(64), + productionPatchSha256: "c".repeat(64), + }; + await expect( + captureValidationScreenshots({ + binding, + productionPaths: ["src/components/card.tsx"], + tests: [browserTest], + capture: async () => png(390, 844), + write: async ({ id }) => ({ uri: `artifact://screenshots/${id}.png` }), + }), + ).rejects.toThrow("dimensions"); + + const valid = await captureValidationScreenshots({ + binding, + productionPaths: ["src/components/card.tsx"], + tests: [browserTest], + capture: async ({ viewport }) => png(viewport.width, viewport.height), + write: async ({ id }) => ({ uri: `artifact://screenshots/${id}.png` }), + }); + expect(() => + assertScreenshotEvidenceCoverage(valid, { + uiAffecting: true, + browserTestIds: ["different-browser-test"], + }), + ).toThrow("browser tests"); + }); +}); From b2a16406744a5ec22a13f75d029f76637ea818f4 Mon Sep 17 00:00:00 2001 From: Nate Summers Date: Tue, 14 Jul 2026 10:52:32 -0700 Subject: [PATCH 2/2] fix(validation): decouple review fixture runtime Infer the fixture return type so the validation evidence slice does not depend on the later Eve reviewer context module. Co-Authored-By: OpenAI Codex GPT-5 --- agent/validation-review-fixture.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/agent/validation-review-fixture.ts b/agent/validation-review-fixture.ts index 3d3e90c..44c662a 100644 --- a/agent/validation-review-fixture.ts +++ b/agent/validation-review-fixture.ts @@ -12,13 +12,12 @@ import { implementationResultSchemaId, } from "./implementation-agent"; import { createPlanningAgentSeedPlan } from "./planning-agent"; -import type { ValidationReviewContext } from "./subagents/validation-reviewer/lib/context"; import { computeTestPlanDigest, testPlanSchemaId } from "./test-writing-agent"; import { computeValidationReviewDigest } from "./validation-review-agent"; const sha256 = (value: string) => createHash("sha256").update(value).digest("hex"); -export function createValidationReviewFixtureContext(): ValidationReviewContext { +export function createValidationReviewFixtureContext() { const plan = createPlanningAgentSeedPlan({ body: "## Acceptance Criteria\n- Review cites deterministic results and relevant screenshots.", issueNumber: 49,