mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
## Thinking Path > - Paperclip manages agents and the work they perform. > - Product E2E checks verify real user paths through those agents. > - A recorded GitHub review-bot walkthrough had no repeatable registered case. > - App setup, automatic triggering, publication, and task completion need separate evidence. > - This pull request adds an explicit attended journey to the existing catalog and report tools. > - Reusable setup profiles and independent assertions preserve the same outcome across compatible targets. ## Linked Issues or Issue Description Refs: #17262. This case remains an explicit attended attached-target flow. It does not replace the shared deployment-target or paid-operations framework. **What existing behavior does this improve?** The Product E2E catalog can now select and retain evidence for GitHub review-bot setup and its first automatic review. **Current behavior** Recorded walkthroughs cannot be rerun as a fixed registered case. A published review alone can hide a failed run or unfinished task. **Proposed behavior** Create and install an owned App, save automatic new-PR settings, create a defective PR, and require correlated review delivery, an inline finding, a consistent check, a successful run, and a Done task. Retain failures and confirm owned-resource cleanup. **Reason and benefit** The case makes a useful external integration reproducible without treating setup, unit checks, or report rendering as proof of a completed live journey. ## What Changed - Add two explicit attended cells for an existing configured agent in local or Daytona execution. - Declare hosting, account, connection, and credential prerequisites through private setup bindings. - Add fixed browser controls, ownership checks, independent grading, and cleanup. - Package declared screenshots through the existing sanitizer, manifest, validator, and dashboard. - Reject operations-managed execution until budgeted routing is qualified. - Document hosted QA credential setup and evidence limits. ## Verification - Focused catalog, oracle, report, connection, and ownership checks: 127 tests passed. - Inherited-environment preflight regression failed against the previous commit and passed after the fix. Actual-run environment verification remains required. - Harness TypeScript check and full workspace typecheck passed. Full build passed. The local full-test attempt was interrupted after repeated embedded PostgreSQL startup failures; its exit code of zero does not establish a pass; Exact-head Linux CI passed the full test, typecheck, build, Runner, serialized, browser, and canary gates on `abbddeade848edefc8de75f11cc9008b2cd52764`. - A real hosted attempt published a rated review and inline finding. It failed when its native session timed out before the task reached Done. Cleanup passed. Full live qualification remains open. - Credential scan found no known credential values in the authored files or retained report corpus. ## Risks - The full case creates external test resources and uses the configured provider. It requires an explicit selection and current task authority. It is excluded from default and CI matrices. - Time and run limits are not a hard dollar cap. Missing dollar cost stays unknown. Fixture setup does not weaken budget policy. - Fresh personal OAuth, unattended authentication, runtime skill-read proof, and cross-host qualification are separate claims. - The selected live fixture needs environment/lifecycle recovery before it can adopt a longer provider deadline. ## Model Used OpenAI GPT-6 family through Codex, with reasoning, code execution, and browser tools. The exact deployment model ID and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used, including the available version and capability details - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have described the issue in-PR following the enhancement template - [x] I have not referenced internal or instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket ID - [x] I have run focused tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation - [x] I have considered and documented risks - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open findings - [x] I will address all Greptile and reviewer comments before merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
272 lines
23 KiB
TypeScript
272 lines
23 KiB
TypeScript
import { randomBytes, createHash } from "node:crypto";
|
|
import { execFileSync } from "node:child_process";
|
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import type { Page } from "@playwright/test";
|
|
import { expect } from "@playwright/test";
|
|
import { ConnectionBlock, ConnectionFailure } from "./connection-config.js";
|
|
import { ConnectionApi, verifyConnectionTarget } from "./connection-target.js";
|
|
import { openConnectionBrowser, withConnectionCampaignCancellation } from "./connection-launch.js";
|
|
import { createPrivateJourneyRepository, ensureGitHubJourneyLogin, closeOwnedGitHubPullRequest, awaitJourneyCondition, observeGitHubJourneyAccount, isGitHubJourneyEvidencePage } from "./github-review-browser.js";
|
|
import { loadGitHubReviewJourneyConfig } from "./github-review-config.js";
|
|
import { gradeGitHubReviewJourney, type GitHubReviewJourneyEvidence } from "./github-review-evidence.js";
|
|
import { runGitHubReviewFlow } from "./github-review-flow.js";
|
|
import { captureOwnedGitHubApp, readOwnedGitHubReviewRuns, type GitHubJourneyResources } from "./github-review-ownership.js";
|
|
import { JourneyDependencyError, resolveJourneyCredential } from "./journey-dependencies.js";
|
|
import { sanitizeJson, assertSecretFree } from "./redaction.js";
|
|
import { writeGitHubReviewReport } from "./github-review-report.js";
|
|
import { summarizeExecutionBilling, buildRuntimeUsage } from "./billing.js";
|
|
import type { MatrixExecution, RunnerE2EResult } from "./types.js";
|
|
|
|
export async function runGitHubReviewCampaign(input: { executions: readonly MatrixExecution[]; catalog: readonly MatrixExecution[]; configFile?: string; repositoryRoot: string; provisionRepository?: boolean }) {
|
|
if (input.executions.length !== 1 || input.executions[0]!.task.flow !== "github_review_journey") throw new ConnectionFailure("Select exactly one GitHub review journey cell separately.");
|
|
if (process.env.PAPERCLIP_E2E_OPERATIONS_MANIFEST) throw new ConnectionFailure("GitHub review journey has no budgeted routing qualification");
|
|
return withConnectionCampaignCancellation(async campaignSignal => {
|
|
const deadline = new AbortController();
|
|
const signal = AbortSignal.any([campaignSignal, deadline.signal]);
|
|
const execution = input.executions[0]!;
|
|
const startedAt = new Date().toISOString();
|
|
const nonce = randomBytes(5).toString("hex");
|
|
const campaignId = `github-review-${startedAt.replace(/[:.]/g, "-")}-${nonce}`;
|
|
const output = path.join(input.repositoryRoot, "tests/runner-e2e/results", campaignId);
|
|
await mkdir(output, { recursive: true, mode: 0o700 });
|
|
const secrets: string[] = [];
|
|
const save = async (name: string, value: unknown) => {
|
|
const text = JSON.stringify(sanitizeJson(value, secrets), null, 2) + "\n";
|
|
assertSecretFree(text, secrets, name); await writeFile(path.join(output, name), text, { mode: 0o600 });
|
|
};
|
|
const evidence: GitHubReviewJourneyEvidence = { version: 1, setupId: "unresolved", hosting: "self-hosted", targetCommit: null, assisted: false,
|
|
outcome: "running", phase: "configuration", dependencies: [], checkpoints: {}, cleanup: { endpoint: true, pullRequest: true, branch: true, app: true } };
|
|
const sha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: input.repositoryRoot, encoding: "utf8" }).trim();
|
|
const result: RunnerE2EResult = { schema: "paperclip.runner-e2e.result/v2", executionId: execution.id, suiteId: execution.suite.id, suiteDefinitionHash: execution.suiteDefinitionHash,
|
|
source: { sha, ref: "working-tree", workflowRunUrl: null }, attempt: 1, status: "failed", profileId: execution.profile.id, environmentId: execution.environment.id, caseId: execution.task.id,
|
|
provider: execution.profile.provider, model: execution.profile.model, runtimeMode: "native", startedAt, finishedAt: startedAt, durationMs: 0, cleanup: "not_started", githubReviewJourney: evidence, screenshots: [] };
|
|
const resources: GitHubJourneyResources = { branch: `codex/ponytail-eval-${nonce}`, appName: `Ponytail QA ${nonce}`, runIds: [] };
|
|
let browser: Awaited<ReturnType<typeof openConnectionBrowser>> | undefined;
|
|
let api: ConnectionApi | undefined; let github: Page | undefined; let page: Page | undefined;
|
|
let config: Awaited<ReturnType<typeof loadGitHubReviewJourneyConfig>> | undefined;
|
|
const checkpoint = async (id: string) => { evidence.phase = id; await save("progress.json", { executionId: execution.id, ...evidence }); };
|
|
const abort = () => { for (const tab of browser?.context.pages() ?? []) void tab.close().catch(() => {}); };
|
|
signal.addEventListener("abort", abort, { once: true });
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
let repositoryCreated = false;
|
|
try {
|
|
config = await loadGitHubReviewJourneyConfig(input.configFile);
|
|
timer = setTimeout(() => deadline.abort(), config.deadlineMs);
|
|
const authentication = config.authentication ? await import(pathToFileURL(config.authentication.module).href) : undefined;
|
|
if (config.executionEnvironment !== execution.environment.id) throw new ConnectionBlock("blocked_target", "selected_execution_environment_mismatch");
|
|
evidence.setupId = config.setupId; evidence.hosting = config.hosting;
|
|
result.provider = config.agent.provider; result.model = config.agent.model; result.runtimeMode = config.agent.runtimeMode;
|
|
evidence.definitionHash = createHash("sha256").update(execution.suiteDefinitionHash).update(JSON.stringify({ setup: config.setupId, hosting: config.hosting, agent: config.agent, repository: config.github.repository, login: config.github.login })).digest("hex");
|
|
const common = { ...config, models: {}, routes: {}, credentials: {}, budgetCents: 1, maxRuns: config.maximumRuns, turnTimeoutMs: config.deadlineMs, retainCompany: true };
|
|
const username = await resolveJourneyCredential(config.github.username, { signal, registerSecret: value => secrets.push(value) });
|
|
evidence.dependencies.push({ alias: "github-user", consumer: "browser", status: "ready", source: config.github.username.source });
|
|
evidence.dependencies.push({ alias: "review-agent-provider", consumer: "product-agent", status: "unverified", source: "product-connection" });
|
|
evidence.checkpoints.dependencies = true;
|
|
browser = await openConnectionBrowser(common, input.repositoryRoot, config.target.baseURL);
|
|
page = await browser.context.newPage();
|
|
github = await browser.context.newPage();
|
|
await checkpoint("github_authentication");
|
|
await github.goto(`https://github.com/${config.github.repository}`);
|
|
if (authentication?.authenticateGitHubJourneyUser) {
|
|
await authentication.authenticateGitHubJourneyUser({ page: github, configFile: config.authentication!.configFile, signal,
|
|
deadlineAt: Date.now() + config.browser.loginTimeoutMs, registerSecret: (value: string) => secrets.push(value), checkpoint: (id: string) => checkpoint(id) });
|
|
}
|
|
await ensureGitHubJourneyLogin({ page: github, config, username, signal, checkpoint: async id => { evidence.assisted = true; await checkpoint(id); },
|
|
...(config.github.password ? { password: () => resolveJourneyCredential(config!.github.password!, { signal, registerSecret: value => secrets.push(value) }) } : {}) });
|
|
if (input.provisionRepository) {
|
|
// Bootstrap is intentionally not graded as a setup-and-review attempt.
|
|
await createPrivateJourneyRepository(github, config.github.repository);
|
|
await save("repository-receipt.json", { version: 1, repository: config.github.repository, visibility: "private", createdAt: new Date().toISOString(), retained: true });
|
|
repositoryCreated = true;
|
|
console.log("[github-review-journey] Private reusable QA repository created; receipt saved. Run the journey separately.");
|
|
return output;
|
|
}
|
|
// Authentication adapters can finish at GitHub home. Re-enter the declared
|
|
// repository before checking its visibility and preparing the measured PR.
|
|
await checkpoint("github_repository");
|
|
await github.goto(`https://github.com/${config.github.repository}`);
|
|
await expect(github.getByText("Private", { exact: true }).first()).toBeVisible();
|
|
await checkpoint("paperclip_authentication");
|
|
await page.goto(config.target.baseURL);
|
|
if (config.authentication) {
|
|
if (typeof authentication?.authenticateGitHubJourney !== "function") throw new ConnectionBlock("blocked_target", "invalid_journey_authentication_adapter");
|
|
await authentication.authenticateGitHubJourney({ page, origin: config.target.baseURL, configFile: config.authentication.configFile, signal,
|
|
deadlineAt: Date.now() + config.browser.loginTimeoutMs, registerSecret: (value: string) => secrets.push(value) });
|
|
}
|
|
api = new ConnectionApi(browser.context.request, config.target.baseURL);
|
|
await checkpoint("awaiting_board_login");
|
|
await awaitJourneyCondition(async () => { try { await api!.get(`/api/companies/${config!.target.companyId}`); return true; } catch { return false; } }, config.browser.loginTimeoutMs, "paperclip_login_required", signal);
|
|
const identity = await verifyConnectionTarget(config.target.baseURL, common, browser.context.request);
|
|
evidence.targetCommit = identity.commit; evidence.checkpoints.target = true;
|
|
await runGitHubReviewFlow({ page, github, githubUsername: config.github.account, api, config, resources, evidence, signal,
|
|
phase: checkpoint, persistResources: () => save("owned-resources.json", resources), checkpoint: async (id, tab) => {
|
|
evidence.checkpoints[id] = true;
|
|
// Authentication pages and forms are deliberately never captured.
|
|
const consentUrl = new URL(tab.url());
|
|
const appCreation = id === "app_creation_consent" && consentUrl.origin === "https://github.com"
|
|
&& await tab.getByRole("button", { name: new RegExp(`^Create GitHub App(?: for ${config!.github.appOwner})?$`) }).isVisible();
|
|
if (id === "app_creation_consent") await save("app-creation-boundary.json", { origin: consentUrl.origin, path: consentUrl.pathname });
|
|
if ((appCreation || isGitHubJourneyEvidencePage(tab.url(), config!.target.baseURL, config!.github.repository, resources.appSlug))
|
|
&& !await tab.locator('input[type="password"]:visible,input[autocomplete="one-time-code"]:visible').count()) {
|
|
const file = `${id}.png`; const bytes = await tab.screenshot({ path: path.join(output, file) });
|
|
result.screenshots!.push({ id, label: id.replaceAll("_", " "), file, sha256: createHash("sha256").update(bytes).digest("hex") });
|
|
}
|
|
await checkpoint(id);
|
|
await save("owned-resources.json", resources);
|
|
} });
|
|
await verifyConnectionTarget(config.target.baseURL, common, browser.context.request);
|
|
result.issueId = resources.issueId; evidence.outcome = "passed";
|
|
result.status = "passed";
|
|
} catch (error) {
|
|
evidence.outcome = error instanceof ConnectionBlock ? error.reason : error instanceof JourneyDependencyError ? "missing_credential" : "failed";
|
|
result.error = error instanceof ConnectionBlock ? error.code : error instanceof JourneyDependencyError ? error.code : error instanceof ConnectionFailure ? error.message : `${evidence.phase}_failed`;
|
|
result.failureClass = evidence.outcome === "failed" ? "candidate_failure" : "permanent_infrastructure";
|
|
const frame = error instanceof Error ? error.stack?.match(/(?:github-review-(?:browser|flow|launch)|qa-authentication|github-authentication)\.(?:ts|mjs):\d+:\d+/)?.[0] : undefined;
|
|
if (frame) result.error += ` (${frame})`;
|
|
await save("attempt-error.json", { error: result.error, outcome: evidence.outcome, phase: evidence.phase });
|
|
await save("progress.json", { executionId: execution.id, ...evidence });
|
|
await save("owned-resources.json", resources);
|
|
if (github && config && !github.isClosed()) {
|
|
const url = new URL(github.url());
|
|
await save("github-boundary.json", { origin: url.origin, path: url.pathname });
|
|
const repositoryPath = config.github.repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const pull = new RegExp(`^/${repositoryPath}/pull/(\\d+)$`).exec(url.pathname);
|
|
const title = `Ponytail precision eval ${resources.branch}`.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
if (!resources.pullNumber && pull && url.origin === "https://github.com"
|
|
&& await github.getByRole("heading", { name: new RegExp(`^${title}(?:\\s|$)`) }).first().isVisible()
|
|
&& await github.getByRole("link", { name: resources.branch, exact: true }).first().isVisible()) {
|
|
resources.pullNumber = Number(pull[1]); evidence.cleanup.pullRequest = false;
|
|
await save("owned-resources.json", resources);
|
|
}
|
|
if (url.origin === "https://github.com" && url.pathname === `/${config.github.repository}`
|
|
&& !await github.locator('input[type="password"]:visible,input[autocomplete="one-time-code"]:visible').count()) {
|
|
await save("github-controls.json", { headings: await github.getByRole("heading").allTextContents(), buttons: await github.getByRole("button").allTextContents() });
|
|
const bytes = await github.screenshot({ path: path.join(output, "github-failure.png") }).catch(() => null);
|
|
if (bytes) result.screenshots!.push({ id: "github-failure", label: evidence.phase, file: "github-failure.png", sha256: createHash("sha256").update(bytes).digest("hex") });
|
|
}
|
|
}
|
|
if (page && config && !page.isClosed() && new URL(page.url()).origin === config.target.baseURL
|
|
&& !/login|auth/.test(new URL(page.url()).pathname) && !await page.locator('input[type="password"]:visible,input[autocomplete="one-time-code"]:visible').count()) {
|
|
const bytes = await page.screenshot({ path: path.join(output, "failure.png") }).catch(() => null);
|
|
if (bytes) result.screenshots!.push({ id: "failure", label: evidence.phase, file: "failure.png", sha256: createHash("sha256").update(bytes).digest("hex") });
|
|
await save("failure-controls.json", { phase: evidence.phase,
|
|
buttons: await page.getByRole("button").allTextContents().catch(() => []),
|
|
headings: await page.getByRole("heading").allTextContents().catch(() => []) });
|
|
}
|
|
} finally {
|
|
if (timer) clearTimeout(timer);
|
|
signal.removeEventListener("abort", abort);
|
|
const cleanupSignal = AbortSignal.timeout(Math.min(config?.browser.loginTimeoutMs ?? 180_000, 600_000));
|
|
const cleanupErrors: string[] = [];
|
|
if (resources.endpointId && api) {
|
|
evidence.cleanup.endpoint = false;
|
|
try {
|
|
const owned = await api.get(`/api/chat-endpoints/${resources.endpointId}`);
|
|
if (owned.companyId !== config!.target.companyId || owned.assignedAgentId !== config!.agent.id) throw new ConnectionFailure("cleanup_endpoint_owner_mismatch");
|
|
if (owned.botExternalId) {
|
|
evidence.cleanup.app = false;
|
|
try {
|
|
if (!captureOwnedGitHubApp(resources, owned, config!)) cleanupErrors.push("owned_app_identity_unconfirmed");
|
|
} catch { cleanupErrors.push("owned_app_binding_mismatch"); }
|
|
}
|
|
if (resources.issueId) {
|
|
const issue = await api.get(`/api/issues/${resources.issueId}`);
|
|
if (issue.companyId !== config!.target.companyId || issue.assigneeAgentId !== config!.agent.id) throw new ConnectionFailure("cleanup_issue_owner_mismatch");
|
|
const boundRuns = await readOwnedGitHubReviewRuns(api, { companyId: config!.target.companyId, agentId: config!.agent.id, issueId: resources.issueId });
|
|
resources.runIds = [...new Set([...resources.runIds, ...boundRuns.map(run => run.id)])];
|
|
}
|
|
await save("owned-resources.json", resources);
|
|
await api.post(`/api/chat-endpoints/${resources.endpointId}/setup`, { action: "remove" });
|
|
for (const runId of resources.runIds) {
|
|
const run = await api.get(`/api/heartbeat-runs/${runId}`);
|
|
if (run.companyId !== config!.target.companyId || run.agentId !== config!.agent.id) throw new ConnectionFailure("cleanup_run_owner_mismatch");
|
|
if (["queued", "running"].includes(run.status)) {
|
|
await api.post(`/api/heartbeat-runs/${runId}/cancel`);
|
|
await awaitJourneyCondition(async () => !["queued", "running"].includes((await api!.get(`/api/heartbeat-runs/${runId}`)).status), 30_000, "owned_run_cancellation_unconfirmed", cleanupSignal, "failed");
|
|
}
|
|
}
|
|
if (resources.issueId && result.status !== "passed") {
|
|
const issue = await api.get(`/api/issues/${resources.issueId}`);
|
|
if (issue.companyId !== config!.target.companyId || issue.assigneeAgentId !== config!.agent.id) throw new ConnectionFailure("cleanup_issue_owner_mismatch");
|
|
if (!["done", "cancelled"].includes(issue.status)) {
|
|
await api.patch(`/api/issues/${issue.id}`, { status: "cancelled" });
|
|
if ((await api.get(`/api/issues/${issue.id}`)).status !== "cancelled") throw new ConnectionFailure("owned_task_settlement_unconfirmed");
|
|
await save("owned-task-cleanup.json", { issueId: issue.id, previousStatus: issue.status, status: "cancelled", reason: "failed_attempt" });
|
|
}
|
|
}
|
|
evidence.cleanup.endpoint = (await api.get(`/api/chat-endpoints/${resources.endpointId}`)).status === "archived";
|
|
} catch { cleanupErrors.push("endpoint_cleanup_failed"); }
|
|
}
|
|
if (resources.pullNumber && github && config) {
|
|
evidence.cleanup.pullRequest = false; evidence.cleanup.branch = false;
|
|
try {
|
|
if (github.isClosed() && browser) github = await browser.context.newPage();
|
|
await github.goto(`https://github.com/${config.github.repository}/pull/${resources.pullNumber}`);
|
|
if ((await observeGitHubJourneyAccount(github)).toLowerCase() !== config.github.account.toLowerCase()) throw new ConnectionFailure("cleanup_github_account_mismatch");
|
|
await closeOwnedGitHubPullRequest(github);
|
|
evidence.cleanup.pullRequest = true; evidence.cleanup.branch = true;
|
|
} catch { cleanupErrors.push("pull_request_cleanup_failed"); }
|
|
} else if (!evidence.cleanup.branch && github && config) {
|
|
try {
|
|
if (github.isClosed() && browser) github = await browser.context.newPage();
|
|
await github.goto(`https://github.com/${config.github.repository}`);
|
|
const repository = await github.request.get(`https://github.com/${config.github.repository}`, { maxRedirects: 0 });
|
|
const branch = await github.request.get(`https://github.com/${config.github.repository}/tree/${resources.branch}`, { maxRedirects: 0 });
|
|
evidence.cleanup.branch = repository.status() === 200 && branch.status() === 404
|
|
&& (await observeGitHubJourneyAccount(github)).toLowerCase() === config.github.account.toLowerCase();
|
|
if (!evidence.cleanup.branch) cleanupErrors.push("owned_branch_cleanup_required");
|
|
} catch { cleanupErrors.push("owned_branch_cleanup_unconfirmed"); }
|
|
}
|
|
if (evidence.checkpoints.app_created || resources.appId || !evidence.cleanup.app) {
|
|
evidence.cleanup.app = false;
|
|
// App deletion is an explicit human action; never delete by an inferred name/URL.
|
|
if (resources.appSettingsUrl && github && config && new URL(resources.appSettingsUrl).origin === "https://github.com") {
|
|
try {
|
|
if (github.isClosed() && browser) github = await browser.context.newPage();
|
|
await github.goto(resources.appSettingsUrl);
|
|
await github.bringToFront();
|
|
console.log(`[github-review-journey] Remove only the disposable App named ${resources.appName} in the QA browser. The shared repository and existing user/provider credentials must remain.`);
|
|
await awaitJourneyCondition(async () => {
|
|
const response = await github!.request.get(resources.appSettingsUrl!, { maxRedirects: 0, timeout: 15_000 });
|
|
if (response.status() !== 404) return false;
|
|
if ((await observeGitHubJourneyAccount(github!)).toLowerCase() !== config!.github.account.toLowerCase()) throw new ConnectionFailure("cleanup_github_account_mismatch");
|
|
return true;
|
|
}, Math.min(config.browser.loginTimeoutMs, 600_000), "owned_app_cleanup_required", cleanupSignal);
|
|
evidence.cleanup.app = true;
|
|
} catch { cleanupErrors.push("app_cleanup_unconfirmed"); }
|
|
} else cleanupErrors.push("app_cleanup_unconfirmed");
|
|
}
|
|
result.runIds = resources.runIds;
|
|
await save("owned-resources.json", resources);
|
|
if (api && resources.issueId && config) {
|
|
try {
|
|
const runs = await readOwnedGitHubReviewRuns(api, { companyId: config.target.companyId, agentId: config.agent.id, issueId: resources.issueId });
|
|
result.runtimeUsage = buildRuntimeUsage({ environmentId: execution.environment.id, runs, fallbackFinishedAt: new Date().toISOString() });
|
|
result.billing = summarizeExecutionBilling({ ...result, usage: { runs: runs.map(r => ({ usage: r.usageJson })) } });
|
|
} catch { /* Missing billing remains unavailable; never substitute zero. */ }
|
|
}
|
|
if (result.billing) { result.billing.complete = false; result.billing.llm.costStatus = "partial"; }
|
|
await browser?.close().catch(() => cleanupErrors.push("browser_cleanup_failed"));
|
|
result.cleanup = cleanupErrors.length || !Object.values(evidence.cleanup).every(Boolean) ? "failed" : "passed";
|
|
if (result.cleanup === "failed") { result.status = "failed"; result.failureClass = "cleanup_failure"; result.error = [result.error, ...cleanupErrors].filter(Boolean).join("; "); }
|
|
result.finishedAt = new Date().toISOString(); result.durationMs = Date.parse(result.finishedAt) - Date.parse(startedAt);
|
|
result.matcherResults = gradeGitHubReviewJourney(evidence);
|
|
if (!input.provisionRepository && result.status === "passed" && !result.matcherResults.every(check => check.passed)) {
|
|
result.status = "failed"; evidence.outcome = "failed"; result.failureClass = "candidate_failure"; result.error = "required_journey_evidence_missing";
|
|
}
|
|
if (!input.provisionRepository) {
|
|
const reported = await writeGitHubReviewReport({ output, campaignId, result, resources, catalog: input.catalog, secrets });
|
|
if (reported.status !== "passed" || reported.cleanup !== "passed") process.exitCode = 1;
|
|
} else {
|
|
await save("bootstrap-result.json", { created: repositoryCreated, outcome: repositoryCreated ? "passed" : evidence.outcome, error: result.error });
|
|
if (!repositoryCreated) process.exitCode = 1;
|
|
}
|
|
await save("progress.json", evidence);
|
|
console.log(`[github-review-journey] ${evidence.outcome}; report directory: ${output}`);
|
|
}
|
|
return output;
|
|
});
|
|
}
|