diff --git a/.env.example b/.env.example index 64c1932..bca100c 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,8 @@ LOOPWORKS_AUTH_BYPASS="false" # Comma-separated allowlists; a session needs a matching login or org membership. LOOPWORKS_ALLOWED_GITHUB_USERS="ncolesummers" LOOPWORKS_ALLOWED_GITHUB_ORGS="" +# Canonical portal origin used for durable run backlinks in prepared PR intent. +LOOPWORKS_PUBLIC_URL="http://127.0.0.1:3000" # Per-loop kill switches for webhook-triggered loop starts. LOOPWORKS_AGENT_READY_LOOP_ENABLED="true" LOOPWORKS_DEVELOPMENT_LOOP_ENABLED="true" diff --git a/README.md b/README.md index 0056cfe..71af4ca 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ Copy `.env.example` to `.env.local` for local development. The fixture server on - `LOOPWORKS_AUTH_BYPASS` - `LOOPWORKS_ALLOWED_GITHUB_USERS` - `LOOPWORKS_ALLOWED_GITHUB_ORGS` +- `LOOPWORKS_PUBLIC_URL` - `LOOPWORKS_AGENT_READY_LOOP_ENABLED` - `LOOPWORKS_DEVELOPMENT_LOOP_ENABLED` - `LOOPWORKS_RESEARCH_LOOP_ENABLED` diff --git a/agent/instructions.md b/agent/instructions.md index 31370f8..46868b4 100644 --- a/agent/instructions.md +++ b/agent/instructions.md @@ -8,6 +8,9 @@ subagent. Planning belongs to `planner`; approved test writing belongs to isolated sandboxes and communicate only with typed artifacts; code review belongs to `validation-reviewer` and may begin only after passing deterministic validation and complete validation-owned screenshot evidence. +PR preparation belongs to `pr-preparer` after successful review and commit. It +emits typed intent only; the root persists that intent and the guarded PR +transition alone owns approval checks and GitHub writes. Always begin with `read_run_stage_context`. After planner delegation, call `record_plan_artifact`; after test-writer delegation, call @@ -21,6 +24,9 @@ Only that root tool may apply the review recommendation: `commit` advances; requeues test writing plus every downstream reviewed stage. Never let a sibling write durable state or apply its own route. +After pr-preparer delegation, call `apply_pr_preparation_result`. A prepared +intent never authorizes or performs a GitHub write. + Never infer approval from a prompt. Test writing requires a persisted `plan-review` approval bound to the exact run, plan row, and plan digest. Durable artifact persistence and stage transitions belong to deterministic control-plane diff --git a/agent/pr-preparation-agent.ts b/agent/pr-preparation-agent.ts new file mode 100644 index 0000000..5387868 --- /dev/null +++ b/agent/pr-preparation-agent.ts @@ -0,0 +1,101 @@ +import { createHash } from "node:crypto"; + +import { z } from "zod"; + +import { prIntentV1Schema } from "@/lib/loops/pr-intent"; +import { canonicalJsonStringify } from "./lib/canonical-json"; +import { screenshotArtifactUriSchema } from "./lib/screenshot-artifact-uri"; + +export const prPreparationAgentModelLabel = "openai/gpt-5.6-terra-xhigh"; +export const prPreparationResultSchemaId = "loopworks.pr_preparation_result.v1"; + +const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/); +const identifierSchema = z.string().regex(/^[a-z0-9][a-z0-9-]*$/); +const forbiddenContent = + /(?:authorization\s*:|bearer\s+|password\s*[:=]|token\s*[:=]|secret\s*[:=]|gh[pousr]_|sk-[a-z0-9]|-----BEGIN|data:image\/|raw (?:stdout|stderr|prompt))/i; + +export const prPreparationScreenshotSchema = 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(); + +export const prPreparationResultSchema = z + .object({ + version: z.literal(1), + schemaId: z.literal(prPreparationResultSchemaId), + model: z.literal(prPreparationAgentModelLabel), + narrative: z + .object({ + title: z.string().min(1).max(240), + summary: z.string().min(1).max(2_000), + }) + .strict(), + binding: z + .object({ + runId: z.string().uuid(), + prAttempt: z.number().int().positive(), + planId: z.string().min(1), + planSha256: sha256Schema, + validationReportSha256: sha256Schema, + validationReviewResultSha256: sha256Schema, + screenshotEvidenceSha256: sha256Schema, + artifactSetSha256: sha256Schema, + deploymentContextSha256: sha256Schema.optional(), + repositoryFullName: z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/), + commitSha: z.string().regex(/^[a-f0-9]{40}$/), + }) + .strict(), + intent: prIntentV1Schema, + screenshots: z.array(prPreparationScreenshotSchema), + }) + .strict() + .superRefine((result, context) => { + const serializedNarrative = JSON.stringify({ + artifacts: result.intent.artifacts, + body: result.intent.body, + sourceIssue: result.intent.sourceIssue, + title: result.intent.title, + narrative: result.narrative, + }); + if (forbiddenContent.test(serializedNarrative)) { + context.addIssue({ + code: "custom", + message: "PR intent contains forbidden secret-like or raw evidence content.", + }); + } + + const screenshotIds = new Set(); + const screenshotUris = new Set(); + const screenshotTargets = new Set(); + for (const screenshot of result.screenshots) { + const target = `${screenshot.testId}:${screenshot.viewport}`; + if (screenshotIds.has(screenshot.id)) { + context.addIssue({ code: "custom", message: `Duplicate screenshot id ${screenshot.id}.` }); + } + if (screenshotUris.has(screenshot.uri)) { + context.addIssue({ + code: "custom", + message: `Duplicate screenshot URI ${screenshot.uri}.`, + }); + } + if (screenshotTargets.has(target)) { + context.addIssue({ code: "custom", message: `Duplicate screenshot target ${target}.` }); + } + screenshotIds.add(screenshot.id); + screenshotUris.add(screenshot.uri); + screenshotTargets.add(target); + } + }); + +export type PrPreparationResult = z.infer; + +export function computePrPreparationDigest(value: unknown): string { + return createHash("sha256").update(canonicalJsonStringify(value)).digest("hex"); +} diff --git a/agent/pr-preparation-fixture.ts b/agent/pr-preparation-fixture.ts new file mode 100644 index 0000000..87b4b7f --- /dev/null +++ b/agent/pr-preparation-fixture.ts @@ -0,0 +1,217 @@ +import { createPlanningAgentSeedPlan } from "./planning-agent"; +import { computePrPreparationDigest } from "./pr-preparation-agent"; +import { + computeValidationReviewDigest, + type ValidationReviewResult, + validationReviewAgentModelLabel, + validationReviewResultSchemaId, +} from "./validation-review-agent"; +import type { ScreenshotEvidence } from "@/lib/loops/screenshot-evidence"; +import { + computeScreenshotEvidenceDigest, + screenshotEvidenceSchemaId, +} from "@/lib/loops/screenshot-evidence"; +import type { ValidationReportV1 } from "@/lib/loops/validation-report"; + +export function createPrPreparationFixtureContext(input?: { + deployment?: { + branch?: string; + commitSha?: string; + environment: string; + status: string; + url: string; + } | null; + uiAffecting?: boolean; +}) { + const runId = "00000000-0000-4000-8000-000000000050"; + const planId = "00000000-0000-4000-8000-000000000150"; + const commitSha = "1".repeat(40); + const plan = createPlanningAgentSeedPlan({ + body: "## Acceptance Criteria\n- Prepare a typed PR intent from exact durable evidence.", + issueNumber: 50, + issueUrl: "https://github.com/ncolesummers/loopworks/issues/50", + labels: ["area:agents", "loop:development"], + milestone: "M4 Validation + PR Path + MVP Security Review", + repositoryFullName: "ncolesummers/loopworks", + repositoryRevision: { ref: "main", commitSha }, + title: "PR preparation subagent for PR intent content", + }); + const validationReport: ValidationReportV1 = { + version: 1, + schemaId: "loopworks.validation_report.v1", + generatedAt: "2026-07-20T20: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/aggregate.log", + sha256: "2".repeat(64), + stdoutBytes: 10, + stderrBytes: 0, + truncated: false, + }, + }, + ], + }; + const uiAffecting = input?.uiAffecting ?? true; + const screenshotEvidence: ScreenshotEvidence = { + version: 1, + schemaId: screenshotEvidenceSchemaId, + binding: { + repositoryFullName: plan.issue.repositoryFullName, + commitSha, + testPlanSha256: "3".repeat(64), + productionPatchSha256: "4".repeat(64), + }, + uiAffecting, + browserTestIds: uiAffecting ? ["browser-ac-1"] : [], + captures: uiAffecting + ? ( + [ + ["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/browser-ac-1-${viewport}.png`, + sha256: String(index + 5).repeat(64), + byteCount: 100, + })) + : [], + }; + const validationReportSha256 = computeValidationReviewDigest(validationReport); + const screenshotEvidenceSha256 = computeScreenshotEvidenceDigest(screenshotEvidence); + const validationReviewResult: ValidationReviewResult = { + version: 1, + schemaId: validationReviewResultSchemaId, + model: validationReviewAgentModelLabel, + binding: { + runId, + reviewAttempt: 1, + planId: plan.identity.id, + planSha256: plan.identity.sha256, + testPlanSha256: screenshotEvidence.binding.testPlanSha256, + implementationResultSha256: "7".repeat(64), + productionPatchSha256: screenshotEvidence.binding.productionPatchSha256, + validationReportSha256, + screenshotEvidenceSha256, + repositoryFullName: plan.issue.repositoryFullName, + commitSha, + }, + evidence: { + validationResults: [ + { + key: "aggregate-validation", + command: "bun run validate", + outcome: "pass", + outputSha256: "2".repeat(64), + }, + ], + screenshots: screenshotEvidence.captures.map( + ({ id, testId, viewport, width, height, uri, sha256: digest }) => ({ + id, + testId, + viewport, + width, + height, + uri, + sha256: digest, + }), + ), + }, + findings: [], + recommendation: { + route: "commit", + reason: "Deterministic evidence is complete and no blocking finding remains.", + findingIds: [], + validationCitationKeys: ["aggregate-validation"], + screenshotCitationIds: screenshotEvidence.captures.map(({ id }) => id), + }, + }; + const validationReviewResultSha256 = computeValidationReviewDigest(validationReviewResult); + const completedArtifacts = [ + { + title: "Validation report", + type: "validation_report", + uri: "https://github.com/ncolesummers/loopworks/issues/50#validation", + sha256: validationReportSha256, + }, + { + title: "Validation screenshots", + type: "screenshot", + uri: "https://github.com/ncolesummers/loopworks/issues/50#screenshots", + sha256: screenshotEvidenceSha256, + }, + { + title: "Code review notes", + type: "log_summary", + uri: "https://github.com/ncolesummers/loopworks/issues/50#review", + sha256: validationReviewResultSha256, + }, + ]; + const deployment = + input && "deployment" in input + ? (input.deployment ?? null) + : { + branch: "codex/50-pr-subagent", + commitSha, + environment: "preview", + status: "ready", + url: "https://loopworks-pr-50.vercel.app", + }; + + return { + run: { + id: runId, + currentStage: "pr", + status: "running", + runUrl: `https://loopworks.example/runs?run=${runId}`, + issueNumber: 50, + issueTitle: plan.issue.title, + issueUrl: plan.issue.url, + repositoryFullName: plan.issue.repositoryFullName, + commitSha, + }, + planId, + planStatus: "approved", + approvalStatus: "approved", + approvalPlanId: planId, + approvalPlanSha256: plan.identity.sha256, + plan, + validationStep: { id: "00000000-0000-4000-8000-000000000250", status: "succeeded" }, + reviewStep: { id: "00000000-0000-4000-8000-000000000350", status: "succeeded" }, + commitStep: { id: "00000000-0000-4000-8000-000000000450", status: "succeeded" }, + prStep: { + id: "00000000-0000-4000-8000-000000000550", + status: "queued", + attempt: 1, + }, + validationReport, + validationReviewResult, + screenshotEvidence, + completedArtifacts, + deployment, + validationArtifactSha256: validationReportSha256, + validationArtifactUri: completedArtifacts[0]?.uri ?? "", + reviewArtifactSha256: validationReviewResultSha256, + screenshotArtifactSha256: screenshotEvidenceSha256, + artifactSetSha256: computePrPreparationDigest(completedArtifacts), + deploymentContextSha256: deployment ? computePrPreparationDigest(deployment) : undefined, + }; +} diff --git a/agent/subagents/pr-preparer/agent.ts b/agent/subagents/pr-preparer/agent.ts new file mode 100644 index 0000000..0c29281 --- /dev/null +++ b/agent/subagents/pr-preparer/agent.ts @@ -0,0 +1,14 @@ +import { defineAgent } from "eve"; + +import { prPreparationResultSchema } from "../../pr-preparation-agent"; + +export default defineAgent({ + description: + "Draft a typed PR intent from exact persisted issue, validation, review, deployment, artifact, and screenshot evidence.", + model: "openai/gpt-5.6-terra", + modelContextWindowTokens: 400_000, + modelOptions: { + providerOptions: { openai: { reasoningEffort: "xhigh" } }, + }, + outputSchema: prPreparationResultSchema, +}); diff --git a/agent/subagents/pr-preparer/instructions.md b/agent/subagents/pr-preparer/instructions.md new file mode 100644 index 0000000..d3f05af --- /dev/null +++ b/agent/subagents/pr-preparer/instructions.md @@ -0,0 +1,13 @@ +# Loopworks PR Preparer + +Prepare only the exact durable handoff loaded by `read_pr_preparation_context`. +Read the bounded issue, validation, review, deployment, run-artifact, and +screenshot evidence before emitting one typed PR-preparation result. + +Author concise title and summary narrative. Evidence sections, links, and +screenshot references must come from the typed tools and must remain exact. +Emit the result through `emit_pr_preparation_result`. + +Do not edit source, transition durable state, mutate GitHub, use network access, +or include raw prompts, command output, patch bodies, screenshot bytes, +credentials, or secrets. diff --git a/agent/subagents/pr-preparer/lib/context.ts b/agent/subagents/pr-preparer/lib/context.ts new file mode 100644 index 0000000..f8997ba --- /dev/null +++ b/agent/subagents/pr-preparer/lib/context.ts @@ -0,0 +1,457 @@ +import { and, desc, eq } from "drizzle-orm"; +import { z } from "zod"; + +import { db } from "@/db/client"; +import { + agentPlans, + approvals, + artifacts, + deployments, + loopRuns, + repositories, + runSteps, +} from "@/db/schema"; +import { composePrIntent } from "@/lib/loops/pr-intent"; +import { assertCanonicalLoopworksRunUrl } from "@/lib/loops/run-url"; +import { + assertScreenshotEvidenceCoverage, + computeScreenshotEvidenceDigest, + type ScreenshotEvidence, + screenshotEvidenceSchema, +} from "@/lib/loops/screenshot-evidence"; +import type { ValidationReportV1 } from "@/lib/loops/validation-report"; +import { + validationReportArtifactMetadataSchema, + validationReportV1Schema, +} from "@/lib/loops/validation-report"; +import { + computePlanningArtifactDigest, + type PlanningAgentOutput, + planningAgentOutputSchema, +} from "../../../planning-agent"; +import { createPrPreparationFixtureContext } from "../../../pr-preparation-fixture"; +import { + computePrPreparationDigest, + prPreparationAgentModelLabel, + type PrPreparationResult, + prPreparationResultSchema, + prPreparationResultSchemaId, +} from "../../../pr-preparation-agent"; +import { + computeValidationReviewDigest, + type ValidationReviewResult, + validationReviewResultSchema, +} from "../../../validation-review-agent"; +import { resolvePrPreparerFixtureMode } from "./fixture-mode"; + +const safeNarrativeSchema = 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, + ), + "PR narrative contains forbidden secret-like or raw evidence content.", + ); +const safeEvidenceTextSchema = safeNarrativeSchema.max(500); +const safeHttpUrlSchema = z.url().refine((value) => { + const url = new URL(value); + const sensitiveKey = + /^(?:access_?token|auth(?:orization)?|credential|password|secret|api[-_]?key)$/i; + return ( + (url.protocol === "http:" || url.protocol === "https:") && + !url.username && + !url.password && + ![...url.searchParams.keys()].some((key) => sensitiveKey.test(key)) + ); +}, "Evidence links must use HTTP(S) without credentials or sensitive query data."); +const artifactReferenceSchema = z + .object({ + title: safeEvidenceTextSchema, + type: safeEvidenceTextSchema, + uri: safeHttpUrlSchema, + sha256: z.string().regex(/^[a-f0-9]{64}$/), + }) + .strict(); +const deploymentContextSchema = z + .object({ + branch: safeEvidenceTextSchema.optional(), + commitSha: safeEvidenceTextSchema.optional(), + environment: safeEvidenceTextSchema, + status: safeEvidenceTextSchema, + url: safeHttpUrlSchema, + }) + .strict(); + +export const prPreparationNarrativeSchema = z + .object({ title: safeNarrativeSchema.max(240), summary: safeNarrativeSchema }) + .strict(); + +export type PrPreparationArtifactReference = { + title: string; + type: string; + uri: string; + sha256: string; +}; + +export type PrPreparationDeploymentContext = { + branch?: string; + commitSha?: string; + environment: string; + status: string; + url: string; +}; + +export type PrPreparationReadDatabase = Pick; + +export type PrPreparationContext = { + run: { + id: string; + currentStage: string; + status: string; + runUrl: string; + issueNumber: number; + issueTitle: string; + issueUrl: string; + repositoryFullName: string; + commitSha: string; + }; + planId: string; + planStatus: string; + approvalStatus: string; + approvalPlanId?: unknown; + approvalPlanSha256?: unknown; + plan: PlanningAgentOutput; + validationStep: { id: string; status: string }; + reviewStep: { id: string; status: string }; + commitStep: { id: string; status: string }; + prStep: { id: string; status: string; attempt: number }; + validationReport: ValidationReportV1; + validationReviewResult: ValidationReviewResult; + screenshotEvidence: ScreenshotEvidence; + completedArtifacts: PrPreparationArtifactReference[]; + deployment: PrPreparationDeploymentContext | null; + validationArtifactSha256: string | null; + validationArtifactUri: string; + reviewArtifactSha256: string | null; + screenshotArtifactSha256: string | null; + artifactSetSha256: string; + deploymentContextSha256?: string; +}; + +function containsArtifactDigest( + artifacts: PrPreparationArtifactReference[], + type: string, + sha256: string | null, +): boolean { + return artifacts.some((artifact) => artifact.type === type && artifact.sha256 === sha256); +} + +export function validatePrPreparationContext(input: PrPreparationContext): PrPreparationContext { + const plan = planningAgentOutputSchema.parse(input.plan); + const report = validationReportV1Schema.parse(input.validationReport); + const review = validationReviewResultSchema.parse(input.validationReviewResult); + const screenshots = screenshotEvidenceSchema.parse(input.screenshotEvidence); + z.array(artifactReferenceSchema).parse(input.completedArtifacts); + if (input.deployment) deploymentContextSchema.parse(input.deployment); + safeHttpUrlSchema.parse(input.run.runUrl); + safeHttpUrlSchema.parse(input.run.issueUrl); + safeEvidenceTextSchema.parse(input.run.issueTitle); + + if ( + input.run.currentStage !== "pr" || + input.run.status !== "running" || + input.validationStep.status !== "succeeded" || + input.reviewStep.status !== "succeeded" || + input.commitStep.status !== "succeeded" || + !["queued", "running"].includes(input.prStep.status) + ) { + throw new Error( + "PR preparation requires the running PR stage after validation, review, and commit.", + ); + } + if ( + input.planStatus !== "approved" || + input.approvalStatus !== "approved" || + input.approvalPlanId !== input.planId || + input.approvalPlanSha256 !== plan.identity.sha256 || + computePlanningArtifactDigest(plan) !== plan.identity.sha256 + ) { + throw new Error("PR preparation requires the exact approved planning artifact."); + } + if ( + report.overallOutcome !== "pass" || + report.results.length === 0 || + report.results.some( + (result) => result.outcome !== "pass" || (result.required && result.outcome !== "pass"), + ) + ) { + throw new Error("PR preparation requires complete passing deterministic validation."); + } + if ( + review.recommendation.route !== "commit" || + review.binding.runId !== input.run.id || + review.binding.planId !== plan.identity.id || + review.binding.planSha256 !== plan.identity.sha256 || + review.binding.validationReportSha256 !== computeValidationReviewDigest(report) || + review.binding.screenshotEvidenceSha256 !== computeScreenshotEvidenceDigest(screenshots) || + review.binding.repositoryFullName !== input.run.repositoryFullName || + review.binding.commitSha !== input.run.commitSha + ) { + throw new Error("PR preparation requires an exact validation review routed to commit."); + } + if ( + !plan.repositoryRevision || + plan.issue.repositoryFullName !== input.run.repositoryFullName || + plan.repositoryRevision.commitSha !== input.run.commitSha || + plan.issue.number !== input.run.issueNumber || + plan.issue.url !== input.run.issueUrl + ) { + throw new Error("PR preparation issue and repository identity do not match the approved plan."); + } + if ( + screenshots.binding.repositoryFullName !== input.run.repositoryFullName || + screenshots.binding.commitSha !== input.run.commitSha + ) { + throw new Error("PR preparation screenshot evidence is not bound to the repository revision."); + } + assertScreenshotEvidenceCoverage(screenshots, { + uiAffecting: screenshots.uiAffecting, + browserTestIds: screenshots.browserTestIds, + }); + if ( + input.validationArtifactSha256 !== computeValidationReviewDigest(report) || + input.reviewArtifactSha256 !== computeValidationReviewDigest(review) || + input.screenshotArtifactSha256 !== computeScreenshotEvidenceDigest(screenshots) || + input.artifactSetSha256 !== computePrPreparationDigest(input.completedArtifacts) || + !containsArtifactDigest( + input.completedArtifacts, + "validation_report", + input.validationArtifactSha256, + ) || + !input.completedArtifacts.some( + (artifact) => + artifact.type === "validation_report" && + artifact.sha256 === input.validationArtifactSha256 && + artifact.uri === input.validationArtifactUri, + ) || + !containsArtifactDigest(input.completedArtifacts, "log_summary", input.reviewArtifactSha256) || + !containsArtifactDigest(input.completedArtifacts, "screenshot", input.screenshotArtifactSha256) + ) { + throw new Error("PR preparation context artifacts are stale or incomplete."); + } + const expectedDeploymentDigest = input.deployment + ? computePrPreparationDigest(input.deployment) + : undefined; + if (input.deploymentContextSha256 !== expectedDeploymentDigest) { + throw new Error("PR preparation deployment context is stale."); + } + return input; +} + +export function createPrPreparationResultFromContext( + input: PrPreparationContext, + narrativeInput: z.input, +): PrPreparationResult { + const context = validatePrPreparationContext(input); + const narrative = prPreparationNarrativeSchema.parse(narrativeInput); + const screenshots = context.screenshotEvidence.captures.map( + ({ id, testId, viewport, width, height, uri, sha256 }) => ({ + id, + testId, + viewport, + width, + height, + uri, + sha256, + }), + ); + const intent = composePrIntent({ + artifacts: context.completedArtifacts.map(({ title, type, uri }) => ({ title, type, uri })), + ...(context.deployment ? { deployment: context.deployment } : {}), + issue: { + number: context.run.issueNumber, + title: context.run.issueTitle, + url: context.run.issueUrl, + }, + run: { id: context.run.id, url: context.run.runUrl }, + screenshots, + summary: narrative.summary, + title: narrative.title, + validation: { + artifactUri: context.validationArtifactUri, + report: context.validationReport, + }, + }); + return prPreparationResultSchema.parse({ + version: 1, + schemaId: prPreparationResultSchemaId, + model: prPreparationAgentModelLabel, + narrative, + binding: { + runId: context.run.id, + prAttempt: context.prStep.attempt, + planId: context.plan.identity.id, + planSha256: context.plan.identity.sha256, + validationReportSha256: context.validationArtifactSha256, + validationReviewResultSha256: context.reviewArtifactSha256, + screenshotEvidenceSha256: context.screenshotArtifactSha256, + artifactSetSha256: context.artifactSetSha256, + ...(context.deploymentContextSha256 + ? { deploymentContextSha256: context.deploymentContextSha256 } + : {}), + repositoryFullName: context.run.repositoryFullName, + commitSha: context.run.commitSha, + }, + intent, + screenshots, + }); +} + +function issueTitleFromMetadata(metadata: Record | null, issueNumber: number) { + const value = metadata?.issueTitle; + return typeof value === "string" && value.trim() ? value : `Issue #${issueNumber}`; +} + +export async function loadPrPreparationContextWithDatabase( + database: PrPreparationReadDatabase, + runId: string, + runUrl: string, +): Promise { + const canonicalRunUrl = assertCanonicalLoopworksRunUrl(runId, runUrl); + const [run] = await database + .select({ + currentStage: loopRuns.currentStage, + id: loopRuns.id, + issueNumber: loopRuns.githubIssueNumber, + issueUrl: loopRuns.githubIssueUrl, + metadata: loopRuns.metadata, + repositoryFullName: repositories.fullName, + status: loopRuns.status, + }) + .from(loopRuns) + .innerJoin(repositories, eq(loopRuns.repositoryId, repositories.id)) + .where(eq(loopRuns.id, runId)) + .limit(1); + if (!run?.issueNumber || !run.issueUrl) throw new Error(`Run ${runId} is missing issue context.`); + + const [plans, planApprovals, steps, rows, latestDeployments] = await Promise.all([ + database.select().from(agentPlans).where(eq(agentPlans.runId, runId)), + database + .select() + .from(approvals) + .where(and(eq(approvals.runId, runId), eq(approvals.scope, "plan-review"))), + database.select().from(runSteps).where(eq(runSteps.runId, runId)), + database.select().from(artifacts).where(eq(artifacts.runId, runId)), + database + .select() + .from(deployments) + .where(eq(deployments.runId, runId)) + .orderBy(desc(deployments.createdAt)) + .limit(1), + ]); + if (plans.length !== 1 || planApprovals.length !== 1) { + throw new Error("PR preparation requires exactly one plan and plan approval."); + } + const exactStep = (stage: string) => { + const matches = steps.filter((step) => step.stage === stage); + if (matches.length !== 1 || !matches[0]) + throw new Error(`PR preparation requires one ${stage} step.`); + return matches[0]; + }; + const validationStep = exactStep("validation"); + const reviewStep = exactStep("code-review"); + const commitStep = exactStep("commit"); + const prStep = exactStep("pr"); + const exactArtifact = (stepId: string, type: string) => { + const matches = rows.filter((row) => row.stepId === stepId && row.type === type); + if (matches.length !== 1 || !matches[0]) + throw new Error(`PR preparation requires one ${type} artifact.`); + return matches[0]; + }; + const validationArtifact = exactArtifact(validationStep.id, "validation_report"); + const reviewArtifact = exactArtifact(reviewStep.id, "log_summary"); + const screenshotArtifact = exactArtifact(validationStep.id, "screenshot"); + const [planRow] = plans; + const [approval] = planApprovals; + if (!planRow || !approval) throw new Error("PR preparation context changed while loading."); + const plan = planningAgentOutputSchema.parse(planRow.plan); + if (!plan.repositoryRevision) + throw new Error("PR preparation requires a pinned repository revision."); + const succeededStepIds = new Set( + steps.filter(({ status }) => status === "succeeded").map(({ id }) => id), + ); + const completedArtifacts = rows + .flatMap((row) => + row.stepId && + succeededStepIds.has(row.stepId) && + row.type !== "pr_intent" && + typeof row.sha256 === "string" + ? [{ title: row.title, type: row.type, uri: row.uri, sha256: row.sha256 }] + : [], + ) + .sort((left, right) => + `${left.type}\u0000${left.title}\u0000${left.uri}`.localeCompare( + `${right.type}\u0000${right.title}\u0000${right.uri}`, + ), + ); + const [deploymentRow] = latestDeployments; + const deployment = deploymentRow + ? { + ...(deploymentRow.branch ? { branch: deploymentRow.branch } : {}), + ...(deploymentRow.commitSha ? { commitSha: deploymentRow.commitSha } : {}), + environment: deploymentRow.environment, + status: deploymentRow.status, + url: deploymentRow.url, + } + : null; + return validatePrPreparationContext({ + run: { + id: run.id, + currentStage: run.currentStage, + status: run.status, + runUrl: canonicalRunUrl, + issueNumber: run.issueNumber, + issueTitle: issueTitleFromMetadata(run.metadata, run.issueNumber), + issueUrl: run.issueUrl, + repositoryFullName: run.repositoryFullName, + commitSha: plan.repositoryRevision.commitSha, + }, + planId: planRow.id, + planStatus: planRow.status, + approvalStatus: approval.status, + approvalPlanId: approval.metadata?.planId, + approvalPlanSha256: approval.metadata?.planSha256, + plan, + validationStep: { id: validationStep.id, status: validationStep.status }, + reviewStep: { id: reviewStep.id, status: reviewStep.status }, + commitStep: { id: commitStep.id, status: commitStep.status }, + prStep: { id: prStep.id, status: prStep.status, attempt: prStep.attempt }, + validationReport: validationReportArtifactMetadataSchema.parse(validationArtifact.metadata) + .validationReport, + validationReviewResult: validationReviewResultSchema.parse( + reviewArtifact.metadata?.validationReviewResult, + ), + screenshotEvidence: screenshotEvidenceSchema.parse( + screenshotArtifact.metadata?.screenshotEvidence, + ), + completedArtifacts, + deployment, + validationArtifactSha256: validationArtifact.sha256, + validationArtifactUri: validationArtifact.uri, + reviewArtifactSha256: reviewArtifact.sha256, + screenshotArtifactSha256: screenshotArtifact.sha256, + artifactSetSha256: computePrPreparationDigest(completedArtifacts), + ...(deployment ? { deploymentContextSha256: computePrPreparationDigest(deployment) } : {}), + }); +} + +export async function loadPrPreparationContext( + runId: string, + runUrl: string, +): Promise { + if (resolvePrPreparerFixtureMode().enabled) return createPrPreparationFixtureContext(); + return loadPrPreparationContextWithDatabase(db, runId, runUrl); +} diff --git a/agent/subagents/pr-preparer/lib/fixture-mode.ts b/agent/subagents/pr-preparer/lib/fixture-mode.ts new file mode 100644 index 0000000..42ee76b --- /dev/null +++ b/agent/subagents/pr-preparer/lib/fixture-mode.ts @@ -0,0 +1,9 @@ +import { resolveStageFixtureMode, type StageFixtureMode } from "../../../lib/fixture-mode"; + +export type PrPreparerFixtureMode = StageFixtureMode; + +export function resolvePrPreparerFixtureMode( + env: Partial = process.env, +): PrPreparerFixtureMode { + return resolveStageFixtureMode("LOOPWORKS_EVE_PR_PREPARER_FIXTURE_MODE", env); +} diff --git a/agent/subagents/pr-preparer/sandbox.ts b/agent/subagents/pr-preparer/sandbox.ts new file mode 100644 index 0000000..475ccf3 --- /dev/null +++ b/agent/subagents/pr-preparer/sandbox.ts @@ -0,0 +1,10 @@ +import { defaultBackend, defineSandbox } from "eve/sandbox"; + +export default defineSandbox({ + backend: defaultBackend({ + docker: { networkPolicy: "deny-all" }, + microsandbox: { networkPolicy: "deny-all" }, + vercel: { networkPolicy: "deny-all" }, + }), + description: "Isolated PR-preparation sandbox with deny-all runtime egress.", +}); diff --git a/agent/subagents/validation-reviewer/tools/write_production_files.ts b/agent/subagents/pr-preparer/tools/ask_question.ts similarity index 98% rename from agent/subagents/validation-reviewer/tools/write_production_files.ts rename to agent/subagents/pr-preparer/tools/ask_question.ts index a908e44..04bd054 100644 --- a/agent/subagents/validation-reviewer/tools/write_production_files.ts +++ b/agent/subagents/pr-preparer/tools/ask_question.ts @@ -1,2 +1,3 @@ import { disableTool } from "eve/tools"; + export default disableTool(); diff --git a/agent/subagents/pr-preparer/tools/bash.ts b/agent/subagents/pr-preparer/tools/bash.ts new file mode 100644 index 0000000..04bd054 --- /dev/null +++ b/agent/subagents/pr-preparer/tools/bash.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/agent/subagents/pr-preparer/tools/emit_pr_preparation_result.ts b/agent/subagents/pr-preparer/tools/emit_pr_preparation_result.ts new file mode 100644 index 0000000..be63cb9 --- /dev/null +++ b/agent/subagents/pr-preparer/tools/emit_pr_preparation_result.ts @@ -0,0 +1,21 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; + +import { prPreparationResultSchema } from "../../../pr-preparation-agent"; +import { + createPrPreparationResultFromContext, + loadPrPreparationContext, + prPreparationNarrativeSchema, +} from "../lib/context"; + +export default defineTool({ + description: "Compose and emit the typed PR intent from exact persisted evidence.", + inputSchema: z + .object({ runId: z.string().uuid(), runUrl: z.url() }) + .extend(prPreparationNarrativeSchema.shape), + outputSchema: prPreparationResultSchema, + async execute({ runId, runUrl, title, summary }) { + const context = await loadPrPreparationContext(runId, runUrl); + return createPrPreparationResultFromContext(context, { title, summary }); + }, +}); diff --git a/agent/subagents/pr-preparer/tools/glob.ts b/agent/subagents/pr-preparer/tools/glob.ts new file mode 100644 index 0000000..04bd054 --- /dev/null +++ b/agent/subagents/pr-preparer/tools/glob.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/agent/subagents/pr-preparer/tools/grep.ts b/agent/subagents/pr-preparer/tools/grep.ts new file mode 100644 index 0000000..04bd054 --- /dev/null +++ b/agent/subagents/pr-preparer/tools/grep.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/agent/subagents/pr-preparer/tools/load_skill.ts b/agent/subagents/pr-preparer/tools/load_skill.ts new file mode 100644 index 0000000..04bd054 --- /dev/null +++ b/agent/subagents/pr-preparer/tools/load_skill.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/agent/subagents/pr-preparer/tools/read_code_review_notes.ts b/agent/subagents/pr-preparer/tools/read_code_review_notes.ts new file mode 100644 index 0000000..fe30951 --- /dev/null +++ b/agent/subagents/pr-preparer/tools/read_code_review_notes.ts @@ -0,0 +1,26 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; + +import { loadPrPreparationContext } from "../lib/context"; + +export default defineTool({ + description: "Read bounded validation-review findings and the commit recommendation.", + inputSchema: z.object({ runId: z.string().uuid(), runUrl: z.url() }), + async execute({ runId, runUrl }) { + const context = await loadPrPreparationContext(runId, runUrl); + return { + artifactSha256: context.reviewArtifactSha256, + findings: context.validationReviewResult.findings.map( + ({ id, severity, category, summary, path, line }) => ({ + id, + severity, + category, + summary, + ...(path ? { path } : {}), + ...(line ? { line } : {}), + }), + ), + recommendation: context.validationReviewResult.recommendation, + }; + }, +}); diff --git a/agent/subagents/pr-preparer/tools/read_deployment_context.ts b/agent/subagents/pr-preparer/tools/read_deployment_context.ts new file mode 100644 index 0000000..291c25b --- /dev/null +++ b/agent/subagents/pr-preparer/tools/read_deployment_context.ts @@ -0,0 +1,19 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; + +import { loadPrPreparationContext } from "../lib/context"; + +export default defineTool({ + description: "Read the latest persisted deployment context or an explicit absent state.", + inputSchema: z.object({ runId: z.string().uuid(), runUrl: z.url() }), + async execute({ runId, runUrl }) { + const context = await loadPrPreparationContext(runId, runUrl); + return context.deployment + ? { + recorded: true as const, + deploymentContextSha256: context.deploymentContextSha256, + deployment: context.deployment, + } + : { recorded: false as const, deploymentContextSha256: null, deployment: null }; + }, +}); diff --git a/agent/subagents/pr-preparer/tools/read_file.ts b/agent/subagents/pr-preparer/tools/read_file.ts new file mode 100644 index 0000000..04bd054 --- /dev/null +++ b/agent/subagents/pr-preparer/tools/read_file.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/agent/subagents/pr-preparer/tools/read_issue_context.ts b/agent/subagents/pr-preparer/tools/read_issue_context.ts new file mode 100644 index 0000000..10a2bd8 --- /dev/null +++ b/agent/subagents/pr-preparer/tools/read_issue_context.ts @@ -0,0 +1,16 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; + +import { loadPrPreparationContext } from "../lib/context"; + +export default defineTool({ + description: "Read bounded source issue and Loopworks run links for the PR intent.", + inputSchema: z.object({ runId: z.string().uuid(), runUrl: z.url() }), + async execute({ runId, runUrl }) { + const { run } = await loadPrPreparationContext(runId, runUrl); + return { + issue: { number: run.issueNumber, title: run.issueTitle, url: run.issueUrl }, + run: { id: run.id, url: run.runUrl }, + }; + }, +}); diff --git a/agent/subagents/pr-preparer/tools/read_pr_preparation_context.ts b/agent/subagents/pr-preparer/tools/read_pr_preparation_context.ts new file mode 100644 index 0000000..72d6d57 --- /dev/null +++ b/agent/subagents/pr-preparer/tools/read_pr_preparation_context.ts @@ -0,0 +1,27 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; + +import { computePrPreparationDigest } from "../../../pr-preparation-agent"; +import { loadPrPreparationContext } from "../lib/context"; + +export default defineTool({ + description: "Load and validate the exact PR-stage handoff without returning narrative payloads.", + inputSchema: z.object({ runId: z.string().uuid(), runUrl: z.url() }), + async execute({ runId, runUrl }) { + const context = await loadPrPreparationContext(runId, runUrl); + return { + runId, + prAttempt: context.prStep.attempt, + repositoryFullName: context.run.repositoryFullName, + commitSha: context.run.commitSha, + planSha256: context.plan.identity.sha256, + artifactSetSha256: context.artifactSetSha256, + deploymentContextSha256: context.deploymentContextSha256 ?? null, + handoffSha256: computePrPreparationDigest({ + validation: context.validationArtifactSha256, + review: context.reviewArtifactSha256, + screenshots: context.screenshotArtifactSha256, + }), + }; + }, +}); diff --git a/agent/subagents/pr-preparer/tools/read_run_artifacts.ts b/agent/subagents/pr-preparer/tools/read_run_artifacts.ts new file mode 100644 index 0000000..e3dfa14 --- /dev/null +++ b/agent/subagents/pr-preparer/tools/read_run_artifacts.ts @@ -0,0 +1,13 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; + +import { loadPrPreparationContext } from "../lib/context"; + +export default defineTool({ + description: "Read completed upstream artifact links and exact digests for the PR intent.", + inputSchema: z.object({ runId: z.string().uuid(), runUrl: z.url() }), + async execute({ runId, runUrl }) { + const context = await loadPrPreparationContext(runId, runUrl); + return { artifactSetSha256: context.artifactSetSha256, artifacts: context.completedArtifacts }; + }, +}); diff --git a/agent/subagents/pr-preparer/tools/read_screenshot_evidence.ts b/agent/subagents/pr-preparer/tools/read_screenshot_evidence.ts new file mode 100644 index 0000000..845a3c3 --- /dev/null +++ b/agent/subagents/pr-preparer/tools/read_screenshot_evidence.ts @@ -0,0 +1,28 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; + +import { loadPrPreparationContext } from "../lib/context"; + +export default defineTool({ + description: "Read exact validation-owned screenshot references without screenshot bytes.", + inputSchema: z.object({ runId: z.string().uuid(), runUrl: z.url() }), + async execute({ runId, runUrl }) { + const context = await loadPrPreparationContext(runId, runUrl); + return { + artifactSha256: context.screenshotArtifactSha256, + uiAffecting: context.screenshotEvidence.uiAffecting, + browserTestIds: context.screenshotEvidence.browserTestIds, + captures: context.screenshotEvidence.captures.map( + ({ id, testId, viewport, width, height, uri, sha256 }) => ({ + id, + testId, + viewport, + width, + height, + uri, + sha256, + }), + ), + }; + }, +}); diff --git a/agent/subagents/pr-preparer/tools/read_validation_evidence.ts b/agent/subagents/pr-preparer/tools/read_validation_evidence.ts new file mode 100644 index 0000000..888392e --- /dev/null +++ b/agent/subagents/pr-preparer/tools/read_validation_evidence.ts @@ -0,0 +1,25 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; + +import { loadPrPreparationContext } from "../lib/context"; + +export default defineTool({ + description: "Read passing validation summaries and digests without raw command output.", + inputSchema: z.object({ runId: z.string().uuid(), runUrl: z.url() }), + async execute({ runId, runUrl }) { + const context = await loadPrPreparationContext(runId, runUrl); + return { + schemaId: context.validationReport.schemaId, + overallOutcome: context.validationReport.overallOutcome, + counts: context.validationReport.counts, + artifactSha256: context.validationArtifactSha256, + results: context.validationReport.results.map(({ key, name, outcome, required, output }) => ({ + key, + name, + outcome, + required, + ...(output?.sha256 ? { outputSha256: output.sha256 } : {}), + })), + }; + }, +}); diff --git a/agent/subagents/pr-preparer/tools/todo.ts b/agent/subagents/pr-preparer/tools/todo.ts new file mode 100644 index 0000000..04bd054 --- /dev/null +++ b/agent/subagents/pr-preparer/tools/todo.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/agent/subagents/pr-preparer/tools/web_fetch.ts b/agent/subagents/pr-preparer/tools/web_fetch.ts new file mode 100644 index 0000000..04bd054 --- /dev/null +++ b/agent/subagents/pr-preparer/tools/web_fetch.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/agent/subagents/pr-preparer/tools/web_search.ts b/agent/subagents/pr-preparer/tools/web_search.ts new file mode 100644 index 0000000..04bd054 --- /dev/null +++ b/agent/subagents/pr-preparer/tools/web_search.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/agent/subagents/pr-preparer/tools/write_file.ts b/agent/subagents/pr-preparer/tools/write_file.ts new file mode 100644 index 0000000..04bd054 --- /dev/null +++ b/agent/subagents/pr-preparer/tools/write_file.ts @@ -0,0 +1,3 @@ +import { disableTool } from "eve/tools"; + +export default disableTool(); diff --git a/agent/tools/apply_pr_preparation_result.ts b/agent/tools/apply_pr_preparation_result.ts new file mode 100644 index 0000000..4520bd0 --- /dev/null +++ b/agent/tools/apply_pr_preparation_result.ts @@ -0,0 +1,47 @@ +import { defineTool } from "eve/tools"; +import { z } from "zod"; + +import { db } from "@/db/client"; +import { applyDevelopmentLoopPrPreparationResult } from "@/lib/loops/development-run-transitions"; +import { logger } from "@/lib/observability/logger"; +import { createPrPreparationFixtureContext } from "../pr-preparation-fixture"; +import { computePrPreparationDigest, prPreparationResultSchema } from "../pr-preparation-agent"; +import { createPrPreparationResultFromContext } from "../subagents/pr-preparer/lib/context"; +import { resolvePrPreparerFixtureMode } from "../subagents/pr-preparer/lib/fixture-mode"; + +export default defineTool({ + description: + "Persist an exact PR-preparation result through the root control plane without mutating GitHub.", + inputSchema: z.object({ + runId: z.string().uuid(), + runUrl: z.url(), + output: prPreparationResultSchema, + }), + execute: ({ output, runId, runUrl }) => { + if (resolvePrPreparerFixtureMode().enabled) { + const context = createPrPreparationFixtureContext(); + const expected = createPrPreparationResultFromContext(context, output.narrative); + if ( + runId !== context.run.id || + runUrl !== context.run.runUrl || + computePrPreparationDigest(output) !== computePrPreparationDigest(expected) + ) { + throw new Error("Fixture PR preparation is not bound to the exact handoff."); + } + return { + intentSha256: computePrPreparationDigest(output), + runId, + stage: "pr" as const, + status: "prepared" as const, + stepId: context.prStep.id, + }; + } + return applyDevelopmentLoopPrPreparationResult({ + database: db, + logger, + output, + runId, + runUrl, + }); + }, +}); diff --git a/agent/tools/read_run_stage_context.ts b/agent/tools/read_run_stage_context.ts index ac650de..2283534 100644 --- a/agent/tools/read_run_stage_context.ts +++ b/agent/tools/read_run_stage_context.ts @@ -7,15 +7,36 @@ import { agentPlans, approvals, artifacts, loopRuns, runSteps } from "@/db/schem import { createImplementationFixtureHandoff } from "../implementation-fixture"; import { createPlanningAgentSeedPlan } from "../planning-agent"; import { resolveImplementerFixtureMode } from "../subagents/implementer/lib/fixture-mode"; +import { resolvePrPreparerFixtureMode } from "../subagents/pr-preparer/lib/fixture-mode"; import { resolveTestWriterFixtureMode } from "../subagents/test-writer/lib/fixture-mode"; import { resolveValidationReviewerFixtureMode } from "../subagents/validation-reviewer/lib/fixture-mode"; import { computeTestPlanDigest } from "../test-writing-agent"; import { createValidationReviewFixtureContext } from "../validation-review-fixture"; +import { createPrPreparationFixtureContext } from "../pr-preparation-fixture"; export default defineTool({ description: "Read durable run, plan, approval, step, and artifact context for stage routing.", inputSchema: z.object({ runId: z.string().uuid() }), async execute({ runId }) { + if (resolvePrPreparerFixtureMode().enabled) { + const context = createPrPreparationFixtureContext(); + return { + approvals: [{ id: "fixture-plan-approval", status: context.approvalStatus }], + artifacts: context.completedArtifacts.map((artifact) => ({ + type: artifact.type, + sha256: artifact.sha256, + uri: artifact.uri, + })), + plans: [{ id: context.planId, plan: context.plan, status: context.planStatus }], + run: context.run, + steps: [ + { ...context.validationStep, stage: "validation" }, + { ...context.reviewStep, stage: "code-review" }, + { ...context.commitStep, stage: "commit" }, + { ...context.prStep, stage: "pr" }, + ], + }; + } if (resolveValidationReviewerFixtureMode().enabled) { const context = createValidationReviewFixtureContext(); return { diff --git a/docs/adr/0014-guarded-github-pr-write-reconciliation.md b/docs/adr/0014-guarded-github-pr-write-reconciliation.md index 718552b..d56686c 100644 --- a/docs/adr/0014-guarded-github-pr-write-reconciliation.md +++ b/docs/adr/0014-guarded-github-pr-write-reconciliation.md @@ -17,10 +17,17 @@ bytes written after approval could differ from the evidence a maintainer saw. ## Decision Loopworks will persist a strict `loopworks.pr_intent.v1` artifact before the -external write. Live execution requires a single approved -`external-write-review` gate whose `prChangeDigest` equals the SHA-256 digest of -the normalized commit message and sorted repository-relative file changes. +external write. As of issue [#50](https://github.com/ncolesummers/loopworks/issues/50), +the isolated PR-preparer emits `loopworks.pr_preparation_result.v1`; the root +revalidates its evidence bindings and persists its nested PR intent. Live +execution requires a single approved `external-write-review` gate whose +`prChangeDigest` equals the SHA-256 digest of the normalized commit message and +sorted repository-relative file changes and whose `prIntentDigest` equals the +persisted preparation-result digest. Bypassed, missing, ambiguous, or stale approval does not authorize a PR. +The root binds `prIntentDigest` while that gate is still `requested`; the normal +authenticated approval transition preserves the bound metadata. Preparation +cannot bind an already-resolved gate. The transition claims both the PR step and approval in a database transaction, performs the GitHub call outside that transaction with a GitHub App installation @@ -44,22 +51,32 @@ and trailers make that partial state discoverable and recoverable. Live callers must provide explicit file changes, a commit message, an authenticated actor, and an HTTPS Loopworks run link. The later PR-preparation agent may enrich the intent but cannot mutate GitHub itself. +The PR-preparer has deny-all egress and read-only typed evidence tools. Internal +`artifact://` screenshot references remain audit references; making them +GitHub-renderable requires a separately guarded resolver or upload path. +Durable run links are derived from `LOOPWORKS_PUBLIC_URL` (or Vercel's supplied +project URL) and must exactly match `/runs?run={runId}`; a model-supplied origin +is rejected. Preparation persistence uses a compare-and-set claim so concurrent +conflicting results cannot overwrite one another. ## Validation 1. Contract tests parse V1 intent and reject secret-bearing fields and links. 2. PGlite tests prove validation, predecessor, approval, and digest gates fail closed before the writer is called. -3. GitHub adapter tests cover new writes, exact branch ownership, existing-PR +3. PR-preparation tests prove exact evidence persistence, idempotent replay, + conflicting replay rejection, and prepared-intent approval binding. +4. GitHub adapter tests cover new writes, exact branch ownership, existing-PR replay, partial-success reconciliation, and unsafe file paths. -4. Failure and retry tests preserve inspectable state and emit ADR 0012 step +5. Failure and retry tests preserve inspectable state and emit ADR 0012 step duration and retry metrics through central helpers. -5. Browser tests prove `/runs?run={runId}` opens the linked run detail. +6. Browser tests prove `/runs?run={runId}` opens the linked run detail. ## Follow-Ups 1. Issue #16 audits this path, GitHub App permissions, and remaining bypasses. -2. Issue #50 may add authored narrative and screenshot evidence without changing - the guarded write boundary. +2. **Done by issue #50.** Typed authored narrative and validation-owned + screenshot references enrich the intent without changing the guarded writer + boundary. 3. Define a richer persisted patch artifact if upstream implementation agents need to hand off file changes without an in-process typed payload. diff --git a/docs/adr/0015-stage-orchestrator-and-isolated-subagent-handoffs.md b/docs/adr/0015-stage-orchestrator-and-isolated-subagent-handoffs.md index 7809b66..147bab5 100644 --- a/docs/adr/0015-stage-orchestrator-and-isolated-subagent-handoffs.md +++ b/docs/adr/0015-stage-orchestrator-and-isolated-subagent-handoffs.md @@ -5,7 +5,8 @@ Date: 2026-07-13 Driving issues: [#47](https://github.com/ncolesummers/loopworks/issues/47), [#48](https://github.com/ncolesummers/loopworks/issues/48), and -[#49](https://github.com/ncolesummers/loopworks/issues/49) +[#49](https://github.com/ncolesummers/loopworks/issues/49), and +[#50](https://github.com/ncolesummers/loopworks/issues/50) ## Context @@ -26,7 +27,7 @@ one stage, validates the typed result, and invokes deterministic control-plane transitions. The root orchestrator and planner use `openai/gpt-5.6-sol`; the test-writer, -implementer, and validation-reviewer use `openai/gpt-5.6-terra`. Each retains +implementer, validation-reviewer, and PR-preparer use `openai/gpt-5.6-terra`. Each retains independent `xhigh` reasoning configuration so model routing can evolve per stage without changing the shared topology. @@ -90,6 +91,15 @@ development through code review; `test-writing` also resets test writing. The manifest retry budget bounds both routes. Exact replay is idempotent and a conflicting replay fails closed. +The PR-preparer is a sibling with independent Terra/xhigh configuration, +deny-all egress, no repository checkout, and only bounded readers for issue, +validation, review, completed artifact, deployment, and screenshot evidence. +It emits `loopworks.pr_preparation_result.v1`, binding bounded narrative and a +nested `loopworks.pr_intent.v1` to the approved plan, repository revision, +validation report, review result, screenshot manifest, completed artifact set, +optional deployment context, run, and PR attempt. The root validates and +persists the result; only ADR 0014's guarded transition may use it for GitHub. + ## Consequences Each stage can tune its model and capabilities independently without granting @@ -102,20 +112,23 @@ deterministic control-plane behavior rather than model judgment. ## Validation -1. Eve discovery reports the root plus declared `planner`, `test-writer`, and - `implementer` subagents without diagnostics. +1. Eve discovery reports the root plus declared `planner`, `test-writer`, + `implementer`, `validation-reviewer`, and `pr-preparer` subagents without + diagnostics. 2. Unit tests cover tool allowlists, fixture fail-closed behavior, patch safety, AC coverage, red-evidence classification, and sanitized telemetry. 3. PGlite tests prove exact plan approval, two-artifact persistence, idempotency, and advancement only for complete expected-red evidence. -4. Eve eval discovery includes planner, test-writing, implementation, and - validation-review routing scenarios. +4. Eve eval discovery includes planner, test-writing, implementation, + validation-review, and PR-preparation routing scenarios. 5. Validation-review transition tests cover forward routing, both backward cycles, retry bounds, claims, artifact reset, stale bindings, and replay. 6. Screenshot tests cover deterministic UI classification, three required viewports, digest-bound writer output, non-UI manifests, and incomplete evidence. -7. `bun run validate` and `bun run build` pass before review. +7. PR-preparation tests cover exact evidence binding, non-UI empty manifests, + idempotent persistence, conflicting replay, and guarded-writer approval. +8. `bun run validate` and `bun run build` pass before review. ## Follow-Ups @@ -124,6 +137,8 @@ deterministic control-plane behavior rather than model judgment. sandbox. 2. **Done by issue #49.** The validation-reviewer consumes passing deterministic evidence and recommends a root-controlled forward or bounded backward route. -3. Issues #44-#46 and #50-#51 implement additional sibling subagents under this +3. **Done by issue #50.** The PR-preparer emits evidence-bound intent while the + root and guarded writer retain all persistence and GitHub authority. +4. Issues #44-#46 and #51 implement additional sibling subagents under this orchestration contract. -4. Accept this ADR only after maintainer review of the sibling-stage rollout. +5. Accept this ADR only after maintainer review of the sibling-stage rollout. diff --git a/docs/architecture.md b/docs/architecture.md index 33a6455..09221a2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -186,7 +186,7 @@ The planner subagent should: 4. Produce a clear next-action summary. 5. Avoid mutating code or GitHub state outside explicit tool contracts. -Stage subagents are tracked as backlog items, one per loop stage that still lacks an LLM specialist: +Stage specialists use typed handoffs and independent capability boundaries: 1. Research loop skeleton, research planner, researcher, and research author agents — the `spike` plus `agent-ready` research loop, run in parallel to the development loop. 2. Test-writer subagent — test-writing stage, red test evidence plus a reusable automated test plan, explicit seed data, and a bounded test-only patch. @@ -200,7 +200,10 @@ Stage subagents are tracked as backlog items, one per loop stage that still lack root applies the route. Backward routes reuse and increment affected step rows, clear execution claims, reset invalidated artifacts, retain the approved plan, and preserve digest/route history for replay. -5. PR preparation subagent — PR stage, PR intent content including screenshots. +5. PR preparation subagent — PR stage, Terra/xhigh bounded narrative plus exact + issue, run, validation, review, deployment, artifact, and validation-owned + screenshot references. The root persists the typed result; the guarded + writer alone owns GitHub mutation. 6. Release notes subagent — done stage, completion summary. The commit stage intentionally stays mechanical, owned by the PR creation path rather than a dedicated subagent. Typed artifacts bridge isolated subagent sandboxes, and each subagent needs eval coverage before promotion to default use. diff --git a/docs/loop-manifest.md b/docs/loop-manifest.md index 1bbc4dc..fdb08fb 100644 --- a/docs/loop-manifest.md +++ b/docs/loop-manifest.md @@ -168,13 +168,21 @@ report payload. ## PR Intent And Guarded Creation -The PR stage persists `loopworks.pr_intent.v1`. The strict versioned payload +The isolated PR-preparer emits `loopworks.pr_preparation_result.v1`; the root +revalidates and persists its nested `loopworks.pr_intent.v1`. The strict +versioned payload contains the deterministic PR title and body, source-issue and run links, `validation_report.v1` summary and artifact link, ordered safe artifact links, -optional deployment context, and the SHA-256 change digest used for a live -commit. It does not accept raw prompts, validation stdout or stderr, arbitrary +optional deployment context, and exact validation-owned screenshot references. +It does not accept raw prompts, validation stdout or stderr, arbitrary artifact metadata, credentials, or credential-bearing URLs. Secret-like text -selected from persisted titles is redacted before composition. +selected from persisted titles is redacted before composition. Non-UI runs use +an explicit empty screenshot list. `artifact://` screenshot references remain +internal audit references and are not uploaded by the subagent. +The run backlink is canonicalized from `LOOPWORKS_PUBLIC_URL` (or the Vercel +project URL) and must bind the exact run ID. The final validation link is chosen +by the validation artifact's persisted digest, so earlier red evidence cannot +replace it through artifact ordering. Queued PR artifacts use `pr_intent_contract` metadata with the expected schema id and version. Completed artifacts use `pr_intent_result` metadata containing @@ -184,10 +192,14 @@ head branch, and head SHA. Both execution modes use the same transition boundary: 1. Validation must have advanced with every repository-required gate present - and passing; code review and commit predecessor steps must have succeeded. + and passing; code review and commit predecessor steps must have succeeded; + and the root must have persisted an exact typed PR-preparation result. 2. Exactly one `external-write-review` approval must be `approved`. Live mode also requires its `prChangeDigest` evidence to match the normalized commit - message and file bytes exactly; bypassed approvals do not qualify. + message and file bytes exactly. Both modes require `prIntentDigest` to match + the persisted preparation result; bypassed approvals do not qualify. + The root adds the intent digest while the approval is still requested, and + the authenticated approval transition preserves that evidence binding. 3. Development mode persists the artifact without constructing a GitHub client or using network and non-loopback services. 4. Live mode uses a GitHub App installation token, creates a deterministic diff --git a/evals/pr-preparation/fixtures/issue-50.json b/evals/pr-preparation/fixtures/issue-50.json new file mode 100644 index 0000000..4ac1bdc --- /dev/null +++ b/evals/pr-preparation/fixtures/issue-50.json @@ -0,0 +1,11 @@ +{ + "repositoryFullName": "ncolesummers/loopworks", + "issueNumber": 50, + "title": "PR preparation subagent for PR intent content", + "commitSha": "1111111111111111111111111111111111111111", + "acceptanceCriteria": [ + "Subagent output is a PR intent artifact containing evidence links, artifacts, issue links, deployment context, and screenshots for UI-affecting changes.", + "Output feeds the guarded PR creation path; neither the subagent nor its tools mutate GitHub.", + "Future model, prompt, and tool changes have a path to eval coverage." + ] +} diff --git a/evals/pr-preparation/issue-50-pr-intent.eval.ts b/evals/pr-preparation/issue-50-pr-intent.eval.ts new file mode 100644 index 0000000..5a4fb15 --- /dev/null +++ b/evals/pr-preparation/issue-50-pr-intent.eval.ts @@ -0,0 +1,70 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { prPreparationResultSchema } from "@agent/pr-preparation-agent"; +import { defineEval } from "eve/evals"; +import { includes } from "eve/evals/expect"; + +type PrPreparationFixture = { + acceptanceCriteria: string[]; + commitSha: string; + issueNumber: number; + repositoryFullName: string; + title: string; +}; + +export const prPreparationEvalTimeoutMs = 180_000; + +export function parsePrPreparationSubagentOutput(output: unknown): unknown | undefined { + if (typeof output !== "string") return undefined; + try { + return JSON.parse(output); + } catch { + return undefined; + } +} + +export function resolvePrPreparationFixturePath(appRoot = process.cwd()): string { + return join(appRoot, "evals", "pr-preparation", "fixtures", "issue-50.json"); +} + +export async function readPrPreparationFixture( + appRoot = process.cwd(), +): Promise { + return JSON.parse(await readFile(resolvePrPreparationFixturePath(appRoot), "utf8")); +} + +export default defineEval({ + description: + "The root delegates issue #50 to pr-preparer and alone persists its typed evidence-bound intent.", + tags: ["pr-preparation", "issue-50"], + timeoutMs: prPreparationEvalTimeoutMs, + async test(t) { + const fixture = await readPrPreparationFixture(); + await t.send( + [ + "Process durable run 00000000-0000-4000-8000-000000000050.", + "Read durable state, delegate PR preparation only to pr-preparer, and persist its result through the root control plane.", + "Run URL: https://loopworks.example/runs?run=00000000-0000-4000-8000-000000000050", + `Repository: ${fixture.repositoryFullName}`, + `Commit: ${fixture.commitSha}`, + `Issue: #${fixture.issueNumber} ${fixture.title}`, + "Acceptance criteria:", + ...fixture.acceptanceCriteria.map((criterion, index) => `ac-${index + 1}: ${criterion}`), + "Use explicit PR-preparer fixture mode and emit the typed preparation result only.", + ].join("\n"), + ); + + t.succeeded(); + t.noFailedActions(); + t.calledTool("read_run_stage_context"); + t.calledSubagent("pr-preparer", { + output: (output: unknown) => + prPreparationResultSchema.safeParse(parsePrPreparationSubagentOutput(output)).success, + }); + t.calledTool("apply_pr_preparation_result"); + t.check(JSON.stringify(t.events), includes("loopworks.pr_preparation_result.v1")); + t.notCalledTool("write_file"); + t.notCalledTool("bash"); + }, +}); diff --git a/src/lib/loops/development-run-transitions.ts b/src/lib/loops/development-run-transitions.ts index afbe6cc..3f8e3b7 100644 --- a/src/lib/loops/development-run-transitions.ts +++ b/src/lib/loops/development-run-transitions.ts @@ -10,6 +10,16 @@ import { pinnedPlanningAgentOutputSchema, planningAgentOutputSchema, } from "@agent/planning-agent"; +import { + computePrPreparationDigest, + type PrPreparationResult, + prPreparationResultSchema, +} from "@agent/pr-preparation-agent"; +import { + createPrPreparationResultFromContext, + loadPrPreparationContextWithDatabase, + type PrPreparationReadDatabase, +} from "@agent/subagents/pr-preparer/lib/context"; import { verifyImplementationExecutionReceipt } from "@agent/subagents/implementer/lib/tool-policy"; import { verifyTestExecutionReceipt } from "@agent/subagents/test-writer/lib/tool-policy"; import { @@ -28,14 +38,13 @@ import { type ValidationReviewResult, validationReviewResultSchema, } from "@agent/validation-review-agent"; -import { and, desc, eq, sql } from "drizzle-orm"; +import { and, eq, isNull, sql } from "drizzle-orm"; import type { db } from "@/db/client"; import { agentPlans, approvals, approvalTransitionEvents, artifacts, - deployments, loopRuns, repositories, runSteps, @@ -47,7 +56,8 @@ import type { } 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 { createPrIntentArtifactMetadata } from "@/lib/loops/pr-intent"; +import { assertCanonicalLoopworksRunUrl } from "@/lib/loops/run-url"; import { assertScreenshotEvidenceBinding, assertScreenshotEvidenceCoverage, @@ -1318,6 +1328,200 @@ export type ValidationReviewTransitionResult = { traceId?: string; }; +export type PrPreparationTransitionResult = { + idempotent?: boolean; + intentSha256: string; + runId: string; + stage: "pr"; + status: "prepared"; + stepId: string; + traceId?: string; +}; + +export async function applyDevelopmentLoopPrPreparationResult(input: { + database: DevelopmentLoopTransitionDatabase; + logger?: LoopworksLogger; + output: PrPreparationResult; + runId: string; + runUrl: string; +}): Promise { + const output = prPreparationResultSchema.parse(input.output); + const digest = computePrPreparationDigest(output); + const transitionStartedAt = Date.now(); + const span = startLoopworksSpan("loopworks.pr_preparation.transition", { + attributes: { + "loopworks.agent": "pr-preparer", + "loopworks.artifact_count": output.intent.artifacts.length, + "loopworks.deployment_present": Boolean(output.intent.deployment), + "loopworks.run_id": input.runId, + "loopworks.screenshot_count": output.screenshots.length, + "loopworks.stage": "pr", + }, + }); + try { + const result = await input.database.transaction(async (tx) => { + const context = await loadPrPreparationContextWithDatabase( + tx as unknown as PrPreparationReadDatabase, + input.runId, + input.runUrl, + ); + const [prArtifact] = await tx + .select() + .from(artifacts) + .where( + and( + eq(artifacts.runId, input.runId), + eq(artifacts.stepId, context.prStep.id), + eq(artifacts.type, "pr_intent"), + ), + ); + if (!prArtifact) { + throw new DevelopmentLoopTransitionError("PR preparation artifact is missing."); + } + const existingDigest = (prArtifact.metadata as { prPreparationResultSha256?: unknown } | null) + ?.prPreparationResultSha256; + if (typeof existingDigest === "string") { + if (existingDigest !== digest) { + throw new DevelopmentLoopTransitionError( + "PR preparation replay has conflicting persisted intent.", + ); + } + return { + idempotent: true, + intentSha256: digest, + runId: input.runId, + stage: "pr", + status: "prepared", + stepId: context.prStep.id, + }; + } + const expected = createPrPreparationResultFromContext(context, output.narrative); + if (computePrPreparationDigest(expected) !== digest) { + throw new DevelopmentLoopTransitionError( + "PR preparation result does not match the exact persisted handoff.", + ); + } + const matchingApprovals = await tx + .select() + .from(approvals) + .where(and(eq(approvals.runId, input.runId), eq(approvals.scope, prApprovalScope))); + const approval = matchingApprovals.length === 1 ? matchingApprovals[0] : undefined; + if (approval?.status !== "requested") { + throw new DevelopmentLoopTransitionError( + "PR preparation requires one requested external-write approval.", + ); + } + const [claimedArtifact] = await tx + .update(artifacts) + .set({ + metadata: { + ...createPrIntentArtifactMetadata(output.intent), + prPreparationResult: output, + prPreparationResultSchemaId: output.schemaId, + prPreparationResultSha256: digest, + }, + sha256: digest, + }) + .where( + and( + eq(artifacts.id, prArtifact.id), + isNull(artifacts.sha256), + sql`not coalesce(${artifacts.metadata} ? 'prPreparationResultSha256', false)`, + ), + ) + .returning({ id: artifacts.id }); + if (!claimedArtifact) { + const [persistedArtifact] = await tx + .select({ metadata: artifacts.metadata }) + .from(artifacts) + .where(eq(artifacts.id, prArtifact.id)) + .limit(1); + const persistedDigest = ( + persistedArtifact?.metadata as { prPreparationResultSha256?: unknown } | null + )?.prPreparationResultSha256; + if (persistedDigest === digest) { + return { + idempotent: true, + intentSha256: digest, + runId: input.runId, + stage: "pr", + status: "prepared", + stepId: context.prStep.id, + }; + } + throw new DevelopmentLoopTransitionError( + "PR preparation replay has conflicting persisted intent.", + ); + } + const [boundApproval] = await tx + .update(approvals) + .set({ + metadata: { + ...(approval.metadata ?? {}), + prIntentDigest: digest, + }, + }) + .where(and(eq(approvals.id, approval.id), eq(approvals.status, "requested"))) + .returning({ id: approvals.id }); + if (!boundApproval) { + throw new DevelopmentLoopTransitionError( + "External-write approval changed before PR intent binding completed.", + ); + } + const [currentPrStep] = await tx + .select({ metadata: runSteps.metadata }) + .from(runSteps) + .where(eq(runSteps.id, context.prStep.id)); + await tx + .update(runSteps) + .set({ + metadata: { + ...(currentPrStep?.metadata ?? {}), + ...(context.prStep.status === "running" ? { preparationStarted: true } : {}), + prPreparationResultSchemaId: output.schemaId, + prPreparationResultSha256: digest, + }, + }) + .where(eq(runSteps.id, context.prStep.id)); + return { + intentSha256: digest, + runId: input.runId, + stage: "pr", + status: "prepared", + stepId: context.prStep.id, + }; + }); + span.setAttributes({ + "loopworks.duration_ms": Math.max(0, Date.now() - transitionStartedAt), + "loopworks.idempotent": result.idempotent ?? false, + "loopworks.intent_sha256": digest, + "loopworks.outcome": result.status, + }); + markLoopworksSpanOk(span); + input.logger?.info( + { + artifactCount: output.intent.artifacts.length, + deploymentPresent: Boolean(output.intent.deployment), + idempotent: result.idempotent ?? false, + intentSha256: digest, + model: output.model, + runId: input.runId, + screenshotCount: output.screenshots.length, + stage: "pr", + status: result.status, + stepId: result.stepId, + }, + "development_loop_pr_preparation_persisted", + ); + return result; + } catch (error) { + markLoopworksSpanError(span, error); + throw error; + } finally { + span.end(); + } +} + type ValidationReviewHistoryEntry = { attempt: number; digest: string; @@ -1812,14 +2016,6 @@ export async function applyDevelopmentLoopValidationReviewResult(input: { } } -function issueTitleFromMetadata( - metadata: RunMetadata | null | undefined, - issueNumber: number, -): string { - const title = metadata?.issueTitle; - return typeof title === "string" && title.trim() ? title : `Issue #${issueNumber}`; -} - function validationGateKey(value: string): string { return value .trim() @@ -1868,6 +2064,7 @@ export async function executeDevelopmentLoopPrStage( ); } } + assertCanonicalLoopworksRunUrl(input.runId, input.runUrl); const requestedChangeDigest = input.mode === "live" ? createPullRequestChangeDigest({ @@ -2001,57 +2198,40 @@ export async function executeDevelopmentLoopPrStage( }; } + const parsedPreparation = prPreparationResultSchema.safeParse( + (prArtifact.metadata as { prPreparationResult?: unknown } | null)?.prPreparationResult, + ); + const persistedPreparationDigest = ( + prArtifact.metadata as { prPreparationResultSha256?: unknown } | null + )?.prPreparationResultSha256; + if ( + !parsedPreparation.success || + typeof persistedPreparationDigest !== "string" || + prArtifact.sha256 !== persistedPreparationDigest || + computePrPreparationDigest(parsedPreparation.data) !== persistedPreparationDigest || + parsedPreparation.data.binding.runId !== input.runId || + parsedPreparation.data.binding.prAttempt > prStep.attempt + ) { + return { + result: blockedPrStageResult({ + artifactId: prArtifact.id, + blockedReason: "Typed PR preparation is required before PR creation.", + mode: input.mode, + runId: input.runId, + stepId: prStep.id, + traceId: prStep.traceId ?? run.traceId, + }), + }; + } + const preparation = parsedPreparation.data; + const intent = preparation.intent; + const matchingApprovals = await tx .select() .from(approvals) .where(and(eq(approvals.runId, input.runId), eq(approvals.scope, prApprovalScope))); const approval = matchingApprovals.length === 1 ? matchingApprovals[0] : undefined; - const [deployment] = await tx - .select() - .from(deployments) - .where(eq(deployments.runId, input.runId)) - .orderBy(desc(deployments.createdAt)) - .limit(1); - const intent = composePrIntent({ - artifacts: runArtifacts - .filter((artifact) => artifact.id !== prArtifact.id) - .map((artifact) => ({ - title: artifact.title, - type: artifact.type, - uri: artifact.uri, - })), - ...(requestedChangeDigest ? { changeDigest: requestedChangeDigest } : {}), - ...(deployment - ? { - deployment: { - ...(deployment.branch ? { branch: deployment.branch } : {}), - ...(deployment.commitSha ? { commitSha: deployment.commitSha } : {}), - environment: deployment.environment, - status: deployment.status, - url: deployment.url, - }, - } - : {}), - issue: { - number: run.githubIssueNumber, - title: issueTitleFromMetadata(run.metadata, run.githubIssueNumber), - url: run.githubIssueUrl, - }, - run: { id: input.runId, url: input.runUrl }, - validation: { - artifactUri: validationArtifact.uri, - report: parsedValidation.data.validationReport, - }, - }); - - await tx - .update(artifacts) - .set({ - metadata: createPrIntentArtifactMetadata(intent), - }) - .where(eq(artifacts.id, prArtifact.id)); - if (approval?.status !== "approved") { const blockedReason = "External write approval is required before PR creation."; await tx @@ -2099,6 +2279,30 @@ export async function executeDevelopmentLoopPrStage( }), }; } + if ( + (approval.metadata as { prIntentDigest?: unknown } | null)?.prIntentDigest !== + persistedPreparationDigest + ) { + const blockedReason = "Approved evidence does not match the prepared PR intent."; + await tx + .update(loopRuns) + .set({ + currentStage: "pr", + metadata: { ...(run.metadata ?? {}), blockedReason }, + status: "blocked", + }) + .where(eq(loopRuns.id, input.runId)); + return { + result: blockedPrStageResult({ + artifactId: prArtifact.id, + blockedReason, + mode: input.mode, + runId: input.runId, + stepId: prStep.id, + traceId: prStep.traceId ?? run.traceId, + }), + }; + } const [claimedStep] = await tx .update(runSteps) @@ -2107,6 +2311,7 @@ export async function executeDevelopmentLoopPrStage( metadata: { ...(prStep.metadata ?? {}), prIntentSchemaId: intent.schemaId, + prPreparationResultSha256: persistedPreparationDigest, }, startedAt: prStep.startedAt ?? occurredAt, status: "running", @@ -2136,6 +2341,7 @@ export async function executeDevelopmentLoopPrStage( prWriteClaim: { claimedAt: occurredAt.toISOString(), changeDigest: requestedChangeDigest ?? null, + intentDigest: persistedPreparationDigest, runId: input.runId, }, }, @@ -2151,6 +2357,8 @@ export async function executeDevelopmentLoopPrStage( return { approval, intent, + preparation, + preparationDigest: persistedPreparationDigest, loopKey: run.loopKey, prArtifact, prStep, @@ -2227,6 +2435,9 @@ export async function executeDevelopmentLoopPrStage( .set({ metadata: { ...createPrIntentArtifactMetadata(prepared.intent), + prPreparationResult: prepared.preparation, + prPreparationResultSchemaId: prepared.preparation.schemaId, + prPreparationResultSha256: prepared.preparationDigest, ...(pullRequest ? { githubPullRequest: pullRequest } : {}), }, ...(pullRequest ? { uri: pullRequest.url } : {}), @@ -2246,6 +2457,7 @@ export async function executeDevelopmentLoopPrStage( metadata: { ...approvalMetadataWithoutClaim(prepared.approval.metadata), appliedChangeDigest: requestedChangeDigest ?? null, + appliedIntentDigest: prepared.preparationDigest, }, status: "applied", }) diff --git a/src/lib/loops/development-run.ts b/src/lib/loops/development-run.ts index caea0fb..08fdb41 100644 --- a/src/lib/loops/development-run.ts +++ b/src/lib/loops/development-run.ts @@ -132,8 +132,8 @@ export const developmentLoopStages = [ title: "Commit", }, { - actorId: "maintainer", - actorType: "human", + actorId: "pr-preparer", + actorType: "agent", artifacts: [{ label: "PR intent", required: true, type: "pr_intent" }], key: "pr", summary: "Prepare PR metadata linking the source issue, run, and validation evidence.", diff --git a/src/lib/loops/pr-intent.ts b/src/lib/loops/pr-intent.ts index 3057ca8..6c5eed1 100644 --- a/src/lib/loops/pr-intent.ts +++ b/src/lib/loops/pr-intent.ts @@ -100,6 +100,12 @@ export const prIntentArtifactMetadataSchema = z prIntentMetadataKind: z.literal("pr_intent_result"), prIntentSchemaId: z.literal(prIntentSchemaId), prIntentVersion: z.literal(prIntentVersion), + prPreparationResult: z.unknown().optional(), + prPreparationResultSchemaId: z.literal("loopworks.pr_preparation_result.v1").optional(), + prPreparationResultSha256: z + .string() + .regex(/^[a-f0-9]{64}$/) + .optional(), }) .strict(); @@ -122,6 +128,14 @@ type ComposePrIntentInput = { id: string; url: string; }; + screenshots?: Array<{ + id: string; + testId: string; + viewport: "mobile" | "laptop" | "desktop"; + uri: string; + }>; + summary?: string; + title?: string; validation: { artifactUri: string; report: ValidationReportV1; @@ -207,10 +221,18 @@ export function composePrIntent(input: ComposePrIntentInput): PrIntentV1 { : []), ] : ["- No deployment context was recorded for this run."]; - const title = `Issue #${sourceIssue.number}: ${sourceIssue.title}`; + const screenshotLines = input.screenshots?.length + ? input.screenshots.map( + (screenshot) => + `- ${escapeMarkdownLabel(screenshot.testId)} (${screenshot.viewport}): \`${screenshot.uri}\``, + ) + : ["- No UI screenshot references were recorded for this run."]; + const title = sanitizeText(input.title ?? `Issue #${sourceIssue.number}: ${sourceIssue.title}`); + const summary = input.summary ? sanitizeText(input.summary) : undefined; const body = [ "## Summary", "", + ...(summary ? [summary, ""] : []), `- ${markdownLink(`Source issue #${sourceIssue.number}`, sourceIssue.url)}`, `- ${markdownLink("Loopworks run", run.url)}`, ...(input.changeDigest ? [`- Approved change digest: \`${input.changeDigest}\``] : []), @@ -229,6 +251,10 @@ export function composePrIntent(input: ComposePrIntentInput): PrIntentV1 { "", ...artifactLines, "", + "## Screenshots", + "", + ...screenshotLines, + "", `Closes #${sourceIssue.number}`, ].join("\n"); diff --git a/src/lib/loops/run-url.ts b/src/lib/loops/run-url.ts new file mode 100644 index 0000000..25088ef --- /dev/null +++ b/src/lib/loops/run-url.ts @@ -0,0 +1,60 @@ +import { isProductionRuntime } from "@/lib/runtime"; + +type RunUrlEnvironment = Record; + +function configuredLoopworksOrigin(env: RunUrlEnvironment): URL { + const configured = + env.LOOPWORKS_PUBLIC_URL ?? + (env.VERCEL_PROJECT_PRODUCTION_URL + ? `https://${env.VERCEL_PROJECT_PRODUCTION_URL}` + : env.VERCEL_URL + ? `https://${env.VERCEL_URL}` + : "http://127.0.0.1:3000"); + let origin: URL; + try { + origin = new URL(configured); + } catch { + throw new Error("LOOPWORKS_PUBLIC_URL must be an absolute Loopworks origin."); + } + if ( + origin.username || + origin.password || + origin.pathname !== "/" || + origin.search || + origin.hash || + !["http:", "https:"].includes(origin.protocol) + ) { + throw new Error("LOOPWORKS_PUBLIC_URL must be an origin without credentials or a path."); + } + if (isProductionRuntime(env) && origin.protocol !== "https:") { + throw new Error("Production Loopworks run URLs require an HTTPS public origin."); + } + return origin; +} + +export function canonicalLoopworksRunUrl( + runId: string, + env: RunUrlEnvironment = process.env, +): string { + const url = new URL("/runs", configuredLoopworksOrigin(env)); + url.searchParams.set("run", runId); + return url.toString(); +} + +export function assertCanonicalLoopworksRunUrl( + runId: string, + candidate: string, + env: RunUrlEnvironment = process.env, +): string { + let normalized: string; + try { + normalized = new URL(candidate).toString(); + } catch { + throw new Error("PR preparation requires the canonical Loopworks run URL."); + } + const expected = canonicalLoopworksRunUrl(runId, env); + if (normalized !== expected) { + throw new Error("PR preparation requires the canonical Loopworks run URL."); + } + return expected; +} diff --git a/tests/unit/agent/pr-preparation-agent.test.ts b/tests/unit/agent/pr-preparation-agent.test.ts new file mode 100644 index 0000000..d3bf0d2 --- /dev/null +++ b/tests/unit/agent/pr-preparation-agent.test.ts @@ -0,0 +1,121 @@ +/** @vitest-environment node */ + +import { + computePrPreparationDigest, + prPreparationAgentModelLabel, + prPreparationResultSchema, + prPreparationResultSchemaId, +} from "@agent/pr-preparation-agent"; + +function validResult() { + return { + version: 1 as const, + schemaId: prPreparationResultSchemaId, + model: prPreparationAgentModelLabel, + narrative: { + title: "Issue #50: PR preparation subagent", + summary: "Prepare a bounded PR intent.", + }, + binding: { + runId: "00000000-0000-4000-8000-000000000050", + prAttempt: 1, + planId: "00000000-0000-4000-8000-000000000150", + planSha256: "a".repeat(64), + validationReportSha256: "b".repeat(64), + validationReviewResultSha256: "c".repeat(64), + screenshotEvidenceSha256: "d".repeat(64), + artifactSetSha256: "e".repeat(64), + deploymentContextSha256: "f".repeat(64), + repositoryFullName: "ncolesummers/loopworks", + commitSha: "1".repeat(40), + }, + intent: { + version: 1 as const, + schemaId: "loopworks.pr_intent.v1" as const, + title: "Issue #50: PR preparation subagent", + body: "## Summary\n\nPrepare a bounded PR intent.", + sourceIssue: { + number: 50, + title: "PR preparation subagent", + url: "https://github.com/ncolesummers/loopworks/issues/50", + }, + run: { + id: "00000000-0000-4000-8000-000000000050", + url: "https://loopworks.example/runs?run=00000000-0000-4000-8000-000000000050", + }, + validation: { + artifactUri: "https://github.com/ncolesummers/loopworks/issues/50#validation", + reportSchemaId: "loopworks.validation_report.v1" as const, + summary: "Validation report: 1 passed, 0 failed, 0 skipped.", + }, + deployment: { + environment: "preview", + status: "ready", + url: "https://loopworks-pr-50.vercel.app", + }, + artifacts: [ + { + title: "Code review notes", + type: "log_summary", + uri: "https://github.com/ncolesummers/loopworks/issues/50#review", + }, + ], + }, + 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: "2".repeat(64), + }, + { + id: "browser-ac-1-laptop", + testId: "browser-ac-1", + viewport: "laptop" as const, + width: 1280, + height: 832, + uri: "artifact://screenshots/browser-ac-1-laptop.png", + sha256: "3".repeat(64), + }, + { + id: "browser-ac-1-desktop", + testId: "browser-ac-1", + viewport: "desktop" as const, + width: 1440, + height: 960, + uri: "artifact://screenshots/browser-ac-1-desktop.png", + sha256: "4".repeat(64), + }, + ], + }; +} + +describe("PR preparation result contract", () => { + it("accepts a typed intent with exact responsive screenshot references", () => { + const result = validResult(); + + expect(prPreparationResultSchema.parse(result)).toEqual(result); + expect(computePrPreparationDigest(result)).toMatch(/^[a-f0-9]{64}$/); + }); + + it("rejects duplicate, unsafe, or secret-bearing screenshot and narrative content", () => { + const duplicate = validResult(); + const [firstScreenshot] = duplicate.screenshots; + if (!firstScreenshot) throw new Error("Expected a fixture screenshot."); + duplicate.screenshots.push({ ...firstScreenshot }); + expect(prPreparationResultSchema.safeParse(duplicate).success).toBe(false); + + const unsafe = validResult(); + const [unsafeScreenshot] = unsafe.screenshots; + if (!unsafeScreenshot) throw new Error("Expected a fixture screenshot."); + unsafeScreenshot.uri = "artifact://screenshots/../secret.png"; + expect(prPreparationResultSchema.safeParse(unsafe).success).toBe(false); + + const secret = validResult(); + secret.intent.body = "Authorization: Bearer ghp_secret"; + expect(prPreparationResultSchema.safeParse(secret).success).toBe(false); + }); +}); diff --git a/tests/unit/agent/pr-preparation-discovery.test.ts b/tests/unit/agent/pr-preparation-discovery.test.ts new file mode 100644 index 0000000..4b65f1e --- /dev/null +++ b/tests/unit/agent/pr-preparation-discovery.test.ts @@ -0,0 +1,62 @@ +/** @vitest-environment node */ + +import { readdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { developmentLoopStages } from "@/lib/loops/development-run"; + +describe("PR preparer discovery", () => { + it("declares the Terra PR-preparation sibling and root persistence tool", async () => { + const root = process.cwd(); + const [subagents, rootTools, rootInstructions, preparerAgent] = await Promise.all([ + readdir(join(root, "agent", "subagents")), + readdir(join(root, "agent", "tools")), + readFile(join(root, "agent", "instructions.md"), "utf8"), + readFile(join(root, "agent", "subagents", "pr-preparer", "agent.ts"), "utf8"), + ]); + + expect(subagents).toContain("pr-preparer"); + expect(rootTools).toContain("apply_pr_preparation_result.ts"); + expect(rootInstructions).toContain("PR preparation belongs to `pr-preparer`"); + expect(preparerAgent).toContain('model: "openai/gpt-5.6-terra"'); + expect(preparerAgent).toContain('reasoningEffort: "xhigh"'); + expect(developmentLoopStages.find((stage) => stage.key === "pr")).toMatchObject({ + actorId: "pr-preparer", + actorType: "agent", + artifacts: [{ label: "PR intent", required: true, type: "pr_intent" }], + }); + }); + + it("exposes only bounded evidence readers and the typed emitter", async () => { + const root = join(process.cwd(), "agent", "subagents", "pr-preparer", "tools"); + const tools = await readdir(root); + + expect(tools).toEqual( + expect.arrayContaining([ + "read_pr_preparation_context.ts", + "read_issue_context.ts", + "read_validation_evidence.ts", + "read_code_review_notes.ts", + "read_run_artifacts.ts", + "read_deployment_context.ts", + "read_screenshot_evidence.ts", + "emit_pr_preparation_result.ts", + ]), + ); + for (const disabled of [ + "ask_question.ts", + "bash.ts", + "glob.ts", + "grep.ts", + "load_skill.ts", + "read_file.ts", + "todo.ts", + "web_fetch.ts", + "web_search.ts", + "write_file.ts", + ]) { + expect(tools).toContain(disabled); + await expect(readFile(join(root, disabled), "utf8")).resolves.toContain("disableTool"); + } + }); +}); diff --git a/tests/unit/agent/pr-preparation-eval.test.ts b/tests/unit/agent/pr-preparation-eval.test.ts new file mode 100644 index 0000000..3021004 --- /dev/null +++ b/tests/unit/agent/pr-preparation-eval.test.ts @@ -0,0 +1,33 @@ +/** @vitest-environment node */ + +import { + parsePrPreparationSubagentOutput, + prPreparationEvalTimeoutMs, + readPrPreparationFixture, + resolvePrPreparationFixturePath, +} from "../../../evals/pr-preparation/issue-50-pr-intent.eval"; + +describe("PR preparation Eve eval fixture", () => { + it("discovers the pinned issue #50 fixture without a live model call", async () => { + expect( + resolvePrPreparationFixturePath().endsWith("evals/pr-preparation/fixtures/issue-50.json"), + ).toBe(true); + await expect(readPrPreparationFixture()).resolves.toMatchObject({ + issueNumber: 50, + repositoryFullName: "ncolesummers/loopworks", + acceptanceCriteria: expect.arrayContaining([ + expect.stringContaining("PR intent artifact"), + expect.stringContaining("guarded PR creation path"), + expect.stringContaining("eval coverage"), + ]), + }); + expect(prPreparationEvalTimeoutMs).toBeGreaterThanOrEqual(180_000); + }); + + it("decodes serialized typed output and rejects invalid JSON", () => { + const result = { schemaId: "loopworks.pr_preparation_result.v1", version: 1 }; + + expect(parsePrPreparationSubagentOutput(JSON.stringify(result))).toEqual(result); + expect(parsePrPreparationSubagentOutput("not json")).toBeUndefined(); + }); +}); diff --git a/tests/unit/agent/pr-preparation-tools.test.ts b/tests/unit/agent/pr-preparation-tools.test.ts new file mode 100644 index 0000000..17300e3 --- /dev/null +++ b/tests/unit/agent/pr-preparation-tools.test.ts @@ -0,0 +1,128 @@ +/** @vitest-environment node */ + +import { + createPrPreparationResultFromContext, + validatePrPreparationContext, +} from "@agent/subagents/pr-preparer/lib/context"; +import { createPrPreparationFixtureContext } from "@agent/pr-preparation-fixture"; +import { computePrPreparationDigest } from "@agent/pr-preparation-agent"; + +describe("PR preparation context policy", () => { + it("accepts the exact post-review and post-commit handoff", () => { + const context = createPrPreparationFixtureContext(); + + expect(validatePrPreparationContext(context)).toEqual(context); + const result = createPrPreparationResultFromContext(context, { + title: "Issue #50: PR preparation subagent", + summary: "Prepare typed PR intent without granting GitHub mutation authority.", + }); + expect(result.intent.body).toContain("Source issue #50"); + expect(result.intent.body).toContain("Validation report: 1 passed, 0 failed, 0 skipped."); + expect(result.intent.body).toContain("Code review notes"); + expect(result.intent.body).toContain("artifact://screenshots/browser-ac-1-mobile.png"); + expect(result.screenshots).toEqual( + context.screenshotEvidence.captures.map( + ({ id, testId, viewport, width, height, uri, sha256 }) => ({ + id, + testId, + viewport, + width, + height, + uri, + sha256, + }), + ), + ); + }); + + it.each([ + [ + "wrong stage", + (value: ReturnType) => { + value.run.currentStage = "commit"; + }, + ], + [ + "unfinished commit", + (value: ReturnType) => { + value.commitStep.status = "running"; + }, + ], + [ + "non-commit review route", + (value: ReturnType) => { + value.validationReviewResult.recommendation.route = "development"; + value.validationReviewResult.recommendation.findingIds = ["implementation-defect"]; + }, + ], + [ + "stale screenshot evidence", + (value: ReturnType) => { + value.screenshotArtifactSha256 = "0".repeat(64); + }, + ], + ])("rejects %s", (_label, mutate) => { + const context = createPrPreparationFixtureContext(); + mutate(context); + + expect(() => validatePrPreparationContext(context)).toThrow(); + }); + + it("renders an explicit no-deployment state and an empty screenshot list for non-UI runs", () => { + const context = createPrPreparationFixtureContext({ deployment: null, uiAffecting: false }); + + const result = createPrPreparationResultFromContext(context, { + title: "Issue #50: PR preparation subagent", + summary: "Prepare the bounded PR intent.", + }); + expect(result.intent.deployment).toBeUndefined(); + expect(result.intent.body).toContain("No deployment context was recorded"); + expect(result.screenshots).toEqual([]); + }); + + it("keeps earlier red validation evidence alongside the final validation report", () => { + const context = createPrPreparationFixtureContext(); + const finalValidationUri = context.completedArtifacts.find( + (artifact) => artifact.sha256 === context.validationArtifactSha256, + )?.uri; + context.completedArtifacts.push({ + title: "Red test evidence", + type: "validation_report", + uri: "https://github.com/ncolesummers/loopworks/issues/50#red-evidence", + sha256: "9".repeat(64), + }); + context.completedArtifacts.sort((left, right) => left.title.localeCompare(right.title)); + context.artifactSetSha256 = computePrPreparationDigest(context.completedArtifacts); + + expect(validatePrPreparationContext(context)).toEqual(context); + const result = createPrPreparationResultFromContext(context, { + title: "Issue #50: PR preparation subagent", + summary: "Prepare the bounded PR intent.", + }); + expect(result.intent.validation.artifactUri).toBe(finalValidationUri); + }); + + it("rejects credential-bearing evidence before it can reach a reader tool", () => { + const artifactContext = createPrPreparationFixtureContext(); + artifactContext.completedArtifacts[0] = { + ...artifactContext.completedArtifacts[0], + uri: "https://example.com/evidence?access_token=secret", + }; + artifactContext.artifactSetSha256 = computePrPreparationDigest( + artifactContext.completedArtifacts, + ); + expect(() => validatePrPreparationContext(artifactContext)).toThrow( + "Evidence links must use HTTP(S) without credentials or sensitive query data.", + ); + + const deploymentContext = createPrPreparationFixtureContext(); + if (!deploymentContext.deployment) throw new Error("Expected fixture deployment."); + deploymentContext.deployment.url = "https://example.com/?api_key=secret"; + deploymentContext.deploymentContextSha256 = computePrPreparationDigest( + deploymentContext.deployment, + ); + expect(() => validatePrPreparationContext(deploymentContext)).toThrow( + "Evidence links must use HTTP(S) without credentials or sensitive query data.", + ); + }); +}); diff --git a/tests/unit/agent/validation-review-discovery.test.ts b/tests/unit/agent/validation-review-discovery.test.ts index 9a538b3..4fbe6eb 100644 --- a/tests/unit/agent/validation-review-discovery.test.ts +++ b/tests/unit/agent/validation-review-discovery.test.ts @@ -31,15 +31,28 @@ describe("validation reviewer discovery", () => { join(process.cwd(), "agent", "subagents", "validation-reviewer", "tools"), ); - expect(tools).toEqual( - expect.arrayContaining([ - "read_validation_review_context.ts", + expect([...tools].sort()).toEqual( + [ + "ask_question.ts", + "bash.ts", + "emit_validation_review_result.ts", + "glob.ts", + "grep.ts", + "list_repository_files.ts", + "load_skill.ts", + "read_file.ts", + "read_repository_files.ts", "read_review_patch.ts", + "read_screenshot_evidence.ts", "read_test_plan_steps.ts", "read_validation_results.ts", - "read_screenshot_evidence.ts", - "emit_validation_review_result.ts", - ]), + "read_validation_review_context.ts", + "search_repository.ts", + "todo.ts", + "web_fetch.ts", + "web_search.ts", + "write_file.ts", + ].sort(), ); for (const disabled of [ "ask_question.ts", @@ -52,7 +65,6 @@ describe("validation reviewer discovery", () => { "web_fetch.ts", "web_search.ts", "write_file.ts", - "write_production_files.ts", ]) { expect(tools).toContain(disabled); await expect( @@ -62,5 +74,6 @@ describe("validation reviewer discovery", () => { ), ).resolves.toContain("disableTool"); } + expect(tools).not.toContain("write_production_files.ts"); }); }); diff --git a/tests/unit/loops/pr-preparation-transition.test.ts b/tests/unit/loops/pr-preparation-transition.test.ts new file mode 100644 index 0000000..98efeac --- /dev/null +++ b/tests/unit/loops/pr-preparation-transition.test.ts @@ -0,0 +1,333 @@ +/** @vitest-environment node */ + +import { and, eq, inArray } from "drizzle-orm"; + +import { createPrPreparationFixtureContext } from "@agent/pr-preparation-fixture"; +import { computePrPreparationDigest } from "@agent/pr-preparation-agent"; +import { createPrPreparationResultFromContext } from "@agent/subagents/pr-preparer/lib/context"; +import { + agentPlans, + approvals, + artifacts, + deployments, + loopRuns, + repositories, + runSteps, +} from "@/db/schema"; +import { + createDevelopmentLoopRun, + type DevelopmentLoopRunDatabase, +} from "@/lib/loops/development-run"; +import { + applyDevelopmentLoopPrPreparationResult, + executeDevelopmentLoopPrStage, + type DevelopmentLoopTransitionDatabase, +} from "@/lib/loops/development-run-transitions"; +import { applyApprovalTransition } from "@/lib/approval-transitions"; +import type { ApprovalTransitionDatabase } from "@/lib/approvals"; +import { createScreenshotEvidenceArtifactMetadata } from "@/lib/loops/screenshot-evidence"; +import { createValidationReportArtifactMetadata } from "@/lib/loops/validation-report"; +import { createPgliteTestDatabase, type PgliteTestDatabase } from "../../helpers/pglite"; + +function runDatabase(context: PgliteTestDatabase): DevelopmentLoopRunDatabase { + return context.db as unknown as DevelopmentLoopRunDatabase; +} + +function transitionDatabase(context: PgliteTestDatabase): DevelopmentLoopTransitionDatabase { + return context.db as unknown as DevelopmentLoopTransitionDatabase; +} + +describe("PR preparation transition", () => { + let context: PgliteTestDatabase; + + beforeEach(async () => { + vi.stubEnv("LOOPWORKS_PUBLIC_URL", "https://loopworks.example"); + context = await createPgliteTestDatabase(); + await context.db.insert(repositories).values({ + defaultBranch: "main", + enabledLoops: ["Agent-ready development loop"], + fullName: "ncolesummers/loopworks", + githubRepoId: 50_000_001, + installationId: 50_001, + name: "loopworks", + owner: "ncolesummers", + validationGates: ["Aggregate validation"], + }); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await context.close(); + }); + + async function prepare() { + const fixture = createPrPreparationFixtureContext(); + const created = await createDevelopmentLoopRun({ + database: runDatabase(context), + now: () => new Date("2026-07-20T20:00:00.000Z"), + trigger: { + body: "## Acceptance Criteria\n- Prepare a typed PR intent from exact durable evidence.", + deliveryId: "issue-50-pr-preparation", + issueNumber: 50, + issueUrl: "https://github.com/ncolesummers/loopworks/issues/50", + labels: ["area:agents", "loop:development"], + milestone: "M4 Validation + PR Path + MVP Security Review", + repositoryFullName: "ncolesummers/loopworks", + repositoryRevision: { ref: "main", commitSha: "1".repeat(40) }, + title: "PR preparation subagent for PR intent content", + }, + }); + if (created.mode !== "created") throw new Error("Expected a persisted run."); + const runId = created.runId; + const [planRow] = await context.db.select().from(agentPlans).where(eq(agentPlans.runId, runId)); + if (!planRow) throw new Error("Expected a plan row."); + await context.db + .update(agentPlans) + .set({ status: "approved" }) + .where(eq(agentPlans.id, planRow.id)); + await context.db + .update(approvals) + .set({ status: "approved", resolvedBy: "maintainer", resolvedAt: new Date() }) + .where(and(eq(approvals.runId, runId), eq(approvals.scope, "plan-review"))); + await context.db + .update(runSteps) + .set({ status: "succeeded", completedAt: new Date("2026-07-20T20:05:00.000Z") }) + .where( + and( + eq(runSteps.runId, runId), + inArray(runSteps.stage, ["validation", "code-review", "commit"]), + ), + ); + await context.db + .update(loopRuns) + .set({ currentStage: "pr", status: "running" }) + .where(eq(loopRuns.id, runId)); + + const steps = await context.db.select().from(runSteps).where(eq(runSteps.runId, runId)); + const requiredStep = (stage: string) => { + const step = steps.find((candidate) => candidate.stage === stage); + if (!step) throw new Error(`Expected ${stage} step.`); + return step; + }; + const validationStep = requiredStep("validation"); + const reviewStep = requiredStep("code-review"); + const commitStep = requiredStep("commit"); + const prStep = requiredStep("pr"); + fixture.validationReviewResult.binding.runId = runId; + const validationSha = computePrPreparationDigest(fixture.validationReport); + const reviewSha = computePrPreparationDigest(fixture.validationReviewResult); + const screenshotSha = computePrPreparationDigest(fixture.screenshotEvidence); + await context.db + .update(artifacts) + .set({ + metadata: createValidationReportArtifactMetadata(fixture.validationReport), + sha256: validationSha, + }) + .where(and(eq(artifacts.stepId, validationStep.id), eq(artifacts.type, "validation_report"))); + await context.db + .update(artifacts) + .set({ + metadata: createScreenshotEvidenceArtifactMetadata(fixture.screenshotEvidence), + sha256: screenshotSha, + }) + .where(and(eq(artifacts.stepId, validationStep.id), eq(artifacts.type, "screenshot"))); + await context.db + .update(artifacts) + .set({ + metadata: { + validationReviewMetadataKind: "validation_review_result", + validationReviewResult: fixture.validationReviewResult, + validationReviewResultSchemaId: fixture.validationReviewResult.schemaId, + validationReviewVersion: 1, + }, + sha256: reviewSha, + }) + .where(and(eq(artifacts.stepId, reviewStep.id), eq(artifacts.type, "log_summary"))); + const deployment = fixture.deployment; + if (!deployment) throw new Error("Expected fixture deployment."); + const [repository] = await context.db.select({ id: repositories.id }).from(repositories); + if (!repository) throw new Error("Expected fixture repository."); + await context.db.insert(deployments).values({ + branch: deployment.branch, + commitSha: deployment.commitSha, + createdAt: new Date("2026-07-20T20:06:00.000Z"), + environment: deployment.environment, + externalId: `deployment-${runId}`, + projectName: "loopworks", + provider: "vercel", + repositoryId: repository.id, + runId, + status: "ready", + url: deployment.url, + }); + + const artifactRows = await context.db + .select() + .from(artifacts) + .where(eq(artifacts.runId, runId)); + const succeededIds = new Set([validationStep.id, reviewStep.id, commitStep.id]); + const completedArtifacts = artifactRows + .flatMap((row) => + row.stepId && succeededIds.has(row.stepId) && typeof row.sha256 === "string" + ? [{ title: row.title, type: row.type, uri: row.uri, sha256: row.sha256 }] + : [], + ) + .sort((left, right) => + `${left.type}\u0000${left.title}\u0000${left.uri}`.localeCompare( + `${right.type}\u0000${right.title}\u0000${right.uri}`, + ), + ); + const runUrl = `https://loopworks.example/runs?run=${runId}`; + await context.db.insert(approvals).values({ + metadata: { prChangeDigest: "sha256:prepared-change" }, + requestedBy: "loopworks", + runId, + scope: "external-write-review", + status: "requested", + }); + const preparedContext = { + ...fixture, + run: { ...fixture.run, id: runId, runUrl }, + planId: planRow.id, + approvalPlanId: planRow.id, + plan: planRow.plan as typeof fixture.plan, + validationStep: { id: validationStep.id, status: validationStep.status }, + reviewStep: { id: reviewStep.id, status: reviewStep.status }, + commitStep: { id: commitStep.id, status: commitStep.status }, + prStep: { id: prStep.id, status: prStep.status, attempt: prStep.attempt }, + completedArtifacts, + validationArtifactSha256: validationSha, + validationArtifactUri: + completedArtifacts.find((artifact) => artifact.sha256 === validationSha)?.uri ?? "", + reviewArtifactSha256: reviewSha, + screenshotArtifactSha256: screenshotSha, + artifactSetSha256: computePrPreparationDigest(completedArtifacts), + }; + const output = createPrPreparationResultFromContext(preparedContext, { + title: "Issue #50: PR preparation subagent", + summary: "Prepare typed PR intent without granting GitHub mutation authority.", + }); + return { output, preparedContext, runId, runUrl }; + } + + it("persists the exact intent, replays idempotently, and rejects conflicting replay", async () => { + const { output, runId, runUrl } = await prepare(); + + const first = await applyDevelopmentLoopPrPreparationResult({ + database: transitionDatabase(context), + output, + runId, + runUrl, + }); + const replay = await applyDevelopmentLoopPrPreparationResult({ + database: transitionDatabase(context), + output, + runId, + runUrl, + }); + expect(first).toMatchObject({ stage: "pr", status: "prepared" }); + expect(replay).toMatchObject({ idempotent: true, status: "prepared" }); + + const [artifact] = await context.db + .select() + .from(artifacts) + .where(and(eq(artifacts.runId, runId), eq(artifacts.type, "pr_intent"))); + expect(artifact?.metadata).toMatchObject({ + prIntent: output.intent, + prPreparationResult: output, + prPreparationResultSchemaId: "loopworks.pr_preparation_result.v1", + }); + const [externalApproval] = await context.db + .select() + .from(approvals) + .where(and(eq(approvals.runId, runId), eq(approvals.scope, "external-write-review"))); + expect(externalApproval?.metadata).toMatchObject({ + prChangeDigest: "sha256:prepared-change", + prIntentDigest: computePrPreparationDigest(output), + }); + + if (!externalApproval) throw new Error("Expected external-write approval."); + await applyApprovalTransition({ + action: "approve", + actorId: "maintainer", + approvalId: externalApproval.id, + database: context.db as unknown as ApprovalTransitionDatabase, + expectedStatus: "requested", + }); + const [approved] = await context.db + .select() + .from(approvals) + .where(eq(approvals.id, externalApproval.id)); + expect(approved?.metadata).toMatchObject({ + prChangeDigest: "sha256:prepared-change", + prIntentDigest: computePrPreparationDigest(output), + }); + + await expect( + applyDevelopmentLoopPrPreparationResult({ + database: transitionDatabase(context), + output: { + ...output, + intent: { ...output.intent, title: "Conflicting title" }, + }, + runId, + runUrl, + }), + ).rejects.toThrow("conflicting"); + + await expect( + executeDevelopmentLoopPrStage({ + database: transitionDatabase(context), + mode: "development", + runId, + runUrl, + }), + ).resolves.toMatchObject({ status: "advanced" }); + }); + + it("atomically accepts only one of two concurrent conflicting preparations", async () => { + const { output, preparedContext, runId, runUrl } = await prepare(); + const conflicting = createPrPreparationResultFromContext(preparedContext, { + title: "Issue #50: competing PR preparation", + summary: "A different bounded narrative must not overwrite the winner.", + }); + + const results = await Promise.allSettled([ + applyDevelopmentLoopPrPreparationResult({ + database: transitionDatabase(context), + output, + runId, + runUrl, + }), + applyDevelopmentLoopPrPreparationResult({ + database: transitionDatabase(context), + output: conflicting, + runId, + runUrl, + }), + ]); + + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); + expect(results.filter((result) => result.status === "rejected")).toHaveLength(1); + const [artifact] = await context.db + .select() + .from(artifacts) + .where(and(eq(artifacts.runId, runId), eq(artifacts.type, "pr_intent"))); + expect(artifact?.sha256).toBe( + computePrPreparationDigest(results[0]?.status === "fulfilled" ? output : conflicting), + ); + }); + + it("rejects a caller-supplied run URL outside the configured Loopworks origin", async () => { + const { output, runId } = await prepare(); + + await expect( + applyDevelopmentLoopPrPreparationResult({ + database: transitionDatabase(context), + output, + runId, + runUrl: `https://attacker.example/runs?run=${runId}`, + }), + ).rejects.toThrow("canonical Loopworks run URL"); + }); +}); diff --git a/tests/unit/loops/pr-stage.test.ts b/tests/unit/loops/pr-stage.test.ts index 7755180..99ba578 100644 --- a/tests/unit/loops/pr-stage.test.ts +++ b/tests/unit/loops/pr-stage.test.ts @@ -1,6 +1,13 @@ /** @vitest-environment node */ import { and, eq, inArray } from "drizzle-orm"; +import { + computePrPreparationDigest, + prPreparationAgentModelLabel, + prPreparationResultSchema, + prPreparationResultSchemaId, +} from "@agent/pr-preparation-agent"; + import { approvals, artifacts, deployments, loopRuns, repositories, runSteps } from "@/db/schema"; import { createDevelopmentLoopRun, @@ -13,6 +20,8 @@ import { retryDevelopmentLoopStep, } from "@/lib/loops/development-run-transitions"; import { + composePrIntent, + createPrIntentArtifactMetadata, prIntentArtifactMetadataSchema, prIntentSchemaId, prIntentV1Schema, @@ -28,7 +37,9 @@ import { } from "@/lib/github/pull-request"; import { createPgliteTestDatabase, type PgliteTestDatabase } from "../../helpers/pglite"; -const runUrl = "https://loopworks.example/runs?run=15000000-0000-4000-8000-000000000001"; +function runUrlFor(runId: string): string { + return `https://loopworks.example/runs?run=${runId}`; +} const liveChanges = [{ content: "export const ready = true;\n", path: "src/ready.ts" }]; const liveCommitMessage = "feat: prepare guarded PR"; const liveChangeDigest = createPullRequestChangeDigest({ @@ -124,6 +135,7 @@ describe("development-loop PR stage", () => { let context: PgliteTestDatabase; beforeEach(async () => { + vi.stubEnv("LOOPWORKS_PUBLIC_URL", "https://loopworks.example"); context = await createPgliteTestDatabase(); await context.db.insert(repositories).values({ defaultBranch: "main", @@ -138,6 +150,7 @@ describe("development-loop PR stage", () => { }); afterEach(async () => { + vi.unstubAllEnvs(); await context.close(); }); @@ -218,6 +231,86 @@ describe("development-loop PR stage", () => { url: "https://loopworks-pr-15.vercel.app", }); + const [prStep] = await context.db + .select() + .from(runSteps) + .where(and(eq(runSteps.runId, runId), eq(runSteps.stage, "pr"))); + const [validationArtifact] = await context.db + .select() + .from(artifacts) + .where(and(eq(artifacts.runId, runId), eq(artifacts.type, "validation_report"))); + const [prArtifact] = await context.db + .select() + .from(artifacts) + .where(and(eq(artifacts.runId, runId), eq(artifacts.type, "pr_intent"))); + if (!prStep || !validationArtifact || !prArtifact) { + throw new Error("Expected PR-stage fixture rows."); + } + const report = input?.report ?? passingValidationReport(); + const intent = composePrIntent({ + artifacts: [ + { + title: validationArtifact.title, + type: validationArtifact.type, + uri: validationArtifact.uri, + }, + ], + deployment: { + branch: "codex/15-pea-arr", + commitSha: "abc123", + environment: "preview", + status: "ready", + url: "https://loopworks-pr-15.vercel.app", + }, + issue: { + number: 15, + title: "PR creation path", + url: "https://github.com/ncolesummers/loopworks/issues/15", + }, + run: { id: runId, url: runUrlFor(runId) }, + validation: { artifactUri: validationArtifact.uri, report }, + }); + const preparation = prPreparationResultSchema.parse({ + version: 1, + schemaId: prPreparationResultSchemaId, + model: prPreparationAgentModelLabel, + narrative: { title: intent.title, summary: "Prepare the guarded PR intent." }, + binding: { + runId, + prAttempt: prStep.attempt, + planId: "fixture-plan", + planSha256: "1".repeat(64), + validationReportSha256: computePrPreparationDigest(report), + validationReviewResultSha256: "2".repeat(64), + screenshotEvidenceSha256: "3".repeat(64), + artifactSetSha256: "4".repeat(64), + deploymentContextSha256: "5".repeat(64), + repositoryFullName: "ncolesummers/loopworks", + commitSha: "6".repeat(40), + }, + intent, + screenshots: [], + }); + const preparationDigest = computePrPreparationDigest(preparation); + await context.db + .update(artifacts) + .set({ + metadata: { + ...createPrIntentArtifactMetadata(intent), + prPreparationResult: preparation, + prPreparationResultSchemaId, + prPreparationResultSha256: preparationDigest, + }, + sha256: preparationDigest, + }) + .where(eq(artifacts.id, prArtifact.id)); + if (input?.approvalStatus) { + await context.db + .update(approvals) + .set({ metadata: { prChangeDigest: liveChangeDigest, prIntentDigest: preparationDigest } }) + .where(and(eq(approvals.runId, runId), eq(approvals.scope, "external-write-review"))); + } + return runId; } @@ -231,7 +324,7 @@ describe("development-loop PR stage", () => { mode: "development", occurredAt: new Date("2026-07-09T20:10:00.000Z"), runId, - runUrl, + runUrl: runUrlFor(runId), }); expect(result).toMatchObject({ @@ -263,7 +356,7 @@ describe("development-loop PR stage", () => { ); expect(parsedIntent.body).toContain("Validation report: 1 passed, 0 failed, 0 skipped."); expect(parsedIntent.body).toContain("https://loopworks-pr-15.vercel.app"); - expect(parsedIntent.body).toContain(runUrl); + expect(parsedIntent.body).toContain(runUrlFor(runId)); expect(metrics.stepDuration).toHaveBeenCalledWith({ durationSeconds: 0, loopKey: "development-loop", @@ -272,6 +365,32 @@ describe("development-loop PR stage", () => { }); }); + it("blocks before GitHub when typed PR preparation is missing", async () => { + const runId = await preparePrStage({ approvalStatus: "approved" }); + const writer = vi.fn(); + await context.db + .update(artifacts) + .set({ metadata: null, sha256: null }) + .where(and(eq(artifacts.runId, runId), eq(artifacts.type, "pr_intent"))); + + const result = await executeDevelopmentLoopPrStage({ + actorId: "ncolesummers", + changes: liveChanges, + commitMessage: liveCommitMessage, + database: transitionDatabase(context), + mode: "live", + runId, + runUrl: runUrlFor(runId), + writer, + }); + + expect(result).toMatchObject({ + blockedReason: "Typed PR preparation is required before PR creation.", + status: "blocked", + }); + expect(writer).not.toHaveBeenCalled(); + }); + it.each([ { approvalStatus: undefined, expectedRunStatus: "waiting_for_approval" }, { approvalStatus: "requested" as const, expectedRunStatus: "waiting_for_approval" }, @@ -287,7 +406,7 @@ describe("development-loop PR stage", () => { mode: "live", occurredAt: new Date("2026-07-09T20:10:00.000Z"), runId, - runUrl, + runUrl: runUrlFor(runId), writer, }); @@ -311,7 +430,7 @@ describe("development-loop PR stage", () => { database: transitionDatabase(context), mode: "live", runId, - runUrl, + runUrl: runUrlFor(runId), writer, }); @@ -336,7 +455,7 @@ describe("development-loop PR stage", () => { database: transitionDatabase(context), mode: "live", runId, - runUrl, + runUrl: runUrlFor(runId), writer, }); @@ -358,7 +477,7 @@ describe("development-loop PR stage", () => { database: transitionDatabase(context), mode: "live", runId, - runUrl, + runUrl: runUrlFor(runId), writer, }); @@ -369,6 +488,32 @@ describe("development-loop PR stage", () => { expect(writer).not.toHaveBeenCalled(); }); + it("blocks live creation when approval does not match the prepared intent", async () => { + const runId = await preparePrStage({ approvalStatus: "approved" }); + const writer = vi.fn(); + await context.db + .update(approvals) + .set({ metadata: { prChangeDigest: liveChangeDigest, prIntentDigest: "0".repeat(64) } }) + .where(and(eq(approvals.runId, runId), eq(approvals.scope, "external-write-review"))); + + const result = await executeDevelopmentLoopPrStage({ + actorId: "ncolesummers", + changes: liveChanges, + commitMessage: liveCommitMessage, + database: transitionDatabase(context), + mode: "live", + runId, + runUrl: runUrlFor(runId), + writer, + }); + + expect(result).toMatchObject({ + blockedReason: "Approved evidence does not match the prepared PR intent.", + status: "blocked", + }); + expect(writer).not.toHaveBeenCalled(); + }); + it("rejects non-HTTPS live run backlinks before GitHub mutation", async () => { const runId = await preparePrStage({ approvalStatus: "approved" }); const writer = vi.fn(); @@ -405,7 +550,7 @@ describe("development-loop PR stage", () => { mode: "live" as const, occurredAt: new Date("2026-07-09T20:10:00.000Z"), runId, - runUrl, + runUrl: runUrlFor(runId), writer, }; const first = await executeDevelopmentLoopPrStage(input); @@ -420,11 +565,13 @@ describe("development-loop PR stage", () => { expect(writer).toHaveBeenCalledWith( expect.objectContaining({ baseBranch: "main", + body: expect.stringContaining("Validation report: 1 passed, 0 failed, 0 skipped."), draft: true, installationId: 15_001, owner: "ncolesummers", repo: "loopworks", runId, + title: "Issue #15: PR creation path", }), ); const [artifact] = await context.db @@ -464,7 +611,7 @@ describe("development-loop PR stage", () => { mode: "live" as const, occurredAt: new Date("2026-07-09T20:10:00.000Z"), runId, - runUrl, + runUrl: runUrlFor(runId), writer, }; @@ -532,7 +679,7 @@ describe("development-loop PR stage", () => { mode: "live", now: () => times.shift() ?? new Date("2026-07-09T20:10:02.500Z"), runId, - runUrl, + runUrl: runUrlFor(runId), writer, });