mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-02 16:58:52 +01:00
## Thinking Path
> - Paperclip helps people manage agent work and keep human decisions
attached to tasks.
> - Product E2E journeys verify the browser, native agent, saved state,
and cleanup.
> - The classic suite already checks a choice question followed by a
text question.
> - Deployment-targeted journeys need the same behavior on an attached
instance.
> - Production questions can use provider input, the dedicated semantic
tool, or the authenticated native API fallback.
> - The basic journey must verify the original card and native
continuation for each supported path.
> - This pull request shares the scenario and browser actions, adds
strict API receipt evidence, and verifies the saved document after
reload.
> - Separate tool-documentation qualification and the original failed
measurements keep their existing scope and outcomes.
## Linked Issues or Issue Description
**What existing behavior does this improve?**
It adds portable coverage for answering agent questions and resuming the
same task on an attached deployment.
**Current behavior**
The classic suite has the two-question scenario. The portable catalog
does not expose it. Its shared native-path grader recognizes provider
and dedicated semantic receipts, but rejects a valid completed native
API creation receipt.
**Proposed behavior**
Expose `question-answer-resume` as a deployment-targeted journey. Answer
one choice question and one text question in the browser. Require
server-owned creation evidence, unchanged forms, exact board answers,
bound continuation, an agent-authored task document, and readback after
reload.
**Reason and benefit**
The same story can qualify local and hosted targets through their normal
provider setup and cleanup. It catches lost answers, duplicate or
foreign work, premature output, and stale documents.
## What Changed
- Register the journey with a three-run maximum and positive model
allowance. Accept one to three attributed work runs at both collection
and retained-result boundaries.
- Share the existing two-question scenario, browser answer actions, and
behavioral grader. Keep separate tool-documentation grading unchanged.
- Recognize a completed native API creation receipt only when its HTTP
201 result retains the original pending card, exact
task/company/agent/source run, idempotency key, and full normalized
form. Require the receipt to remain unchanged after the answer and
through the final checkpoint. Missing, pending, foreign, or altered
proof still fails.
- Check signed-in board ownership, each native continuation, no
premature or extra output, document authorship, and the saved revision
after reload.
- Wait for the authenticated product region before screenshots. Recheck
origin and path after readiness. Bound readiness and capture by the
campaign deadline.
- Add closed stage-specific errors, catalog coverage, sanitized receipt
calibration, and documentation. Include the provider-form and
browser-question presentation helpers in the definition fingerprint.
## Verification
- Fresh QA staging qualification passed at
`7f7de8be286a436010a48af89aba1727b4451493`: all 11 required checks
passed, cleanup passed, and all three native runs used the configured QA
target and model. The [sanitized run
report](https://pages.paperclip.ing/evals-qa-smoke-41121966520-1/)
records the measured pass. This is one successful qualification, not a
reliability claim.
- Final integration head `ae0d9d289831c770a413658d3520fb1dc8ad60e3`
contains the one main merge through
`fd008979f5794fda3d5f54151d36acd31d0de140` and an exact cherry-pick of
the four-file qualified search fix
`4edd10804e2b42941aa2038e90c362c13a0b98dc` from #18370. Patch identity
matches. It preserves the project and search registrations and atomic
search readback. The question live qualification ran at the earlier SHA
above; the final integration head has separate local and CI
verification.
- Eighteen question journey, helper, and test file blobs are identical
between the qualified and integration SHAs. This includes clarification
flow and evidence, question grader and browser helper, the sanitized
receipt fixture, screenshot helper, provider-form helper, browser
question presentation, continuation scenario/readiness/flow, shared
agent validation, and user actions. The question run-count contract and
dispatch branch are also unchanged. Reviewers can compare those paths
directly between the two commits.
- All 622 selected Product E2E, catalog, question-resumption,
necessary-question, report, and readiness tests pass after the main
merge. After the exact search delta, all 145 narrow
question/search/project/contract tests and all nine real Chromium search
regressions pass. Map checks, combined credential-free preview, source
fingerprints, and the earlier workspace-link preflight pass. The local
TypeScript attempt reports stale Runner declaration errors for
`onAccountingEvent` in unchanged native-session code; fresh official CI
provides the clean-build type gate. The three search source/test blobs
match the qualified search merge, and the three project source blobs are
unchanged.
- An in-memory probe executes the actual launcher fingerprint
expression. Each project, search, and question implementation input
changes the digest. Separate red probes first reproduced the omitted
provider-form and browser presentation helpers; both pass after their
source inputs are added.
- The sanitized retained native API response-shape regression fails
before the grading correction and passes afterward. Negative controls
reject missing, pending, foreign, changed, spoofed, or wrongly bound
receipts, answers, continuation, model, environment, and document
evidence. All nine provider/semantic/API path combinations pass
calibration.
- The real local Chromium screenshot probe fails before the readiness
repair and passes afterward. Login redirects and sign-in pages at task
URLs remain excluded with no screenshot files.
- The two original attached failures remain retained: the first stopped
at early screenshot capture; the second stopped before answering at the
former API provenance gate. An earlier sandbox run also retained one
report subprocess failure; the full selected tests then passed with
local subprocess IPC access.
- The prior integration CI had an unchanged canary mock `EADDRINUSE`
failure. That history is retained; its documented retry is owned by the
coordinator. No application API, schema, production UI, model, runtime
policy, credential, or deployment configuration change is included.
Final integration head ae0d9d2898 passed
all 58 CI checks, the external Snyk status, and fresh Greptile 5/5 with
no unresolved findings. This clean CI includes the official TypeScript
and build gates. Recurring selection follows merge and a separate normal
TestOps job verification.
## Risks
An API result must prove native creation; form shape and run count
cannot establish it. The grader checks the server receipt, exact scope,
unchanged card and receipt, real board answers, and bound native
continuation. The shared answer helper still serves classic journeys.
The screenshot wait affects attached journeys and remains limited to the
authenticated product region and campaign deadline. One live QA pass
establishes the measured journey outcome at the qualified SHA. It does
not establish reliability across models or deployments. The integration
SHA preserves its journey and helper blobs, while shared registry
changes receive fresh CI and review.
## Model Used
OpenAI Codex, GPT-6 family, with reasoning, source inspection, and code
execution. The host does not expose the exact underlying model version
or context-window size.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with 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 either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
333 lines
24 KiB
TypeScript
333 lines
24 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
|
import { randomUUID, createHash } from "node:crypto";
|
|
import { mkdir, readFile, writeFile, rename } from "node:fs/promises";
|
|
import { renameSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { chromium, type Browser, type BrowserContext } from "@playwright/test";
|
|
import { runnerSuites } from "../catalog.js";
|
|
import { productJourneys, parseTargetConfig, preparationPreview, isAgentJourney } from "./catalog.js";
|
|
import { agentJourneyRunCountValid } from "./success-contract.js";
|
|
import { journeyFixtures } from "./fixtures.js";
|
|
import { taskProperties } from "./task-properties.js";
|
|
import { taskSearchFilter } from "./task-search-filter.js";
|
|
import { askQuestion, taskCreate } from "./basic-flows.js";
|
|
import { projectTaskContext } from "./project-context-flow.js";
|
|
import { runAgentJourney } from "./agent-flows.js";
|
|
import { runClarificationJourney } from "./clarification-flow.js";
|
|
import { JourneyError, type JourneyContext, type JourneyEvidence, type JourneyOutcome, type TargetLifecycleInput, type TargetProvider, type TargetSession } from "./types.js";
|
|
import type { RunnerE2EResult } from "../types.js";
|
|
import { regenerateRunnerDashboard } from "../dashboard-regenerate.js";
|
|
import { resolveRunnerE2ESource } from "../source.js";
|
|
import { validateRetainedRunnerResult } from "../result-validation.js";
|
|
import { validateJourneyCollection } from "./collection.js";
|
|
import { captureProductScreenshot } from "./product-screenshot.js";
|
|
import { validateOnboardingArtifact } from "./artifact-evidence.js";
|
|
|
|
const cleanupGraceMs = 600_000;
|
|
const stopReserveMs = 20_000;
|
|
const browserCloseReserveMs = 10_000;
|
|
|
|
/** Providers must fence late mutations when this signal is aborted. */
|
|
async function withinLifecycleDeadline<T>(deadlineAt: number, code: string, operation: (input: TargetLifecycleInput) => Promise<T>, parentSignal?: AbortSignal): Promise<T> {
|
|
const controller = new AbortController();
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
let cancel: () => void = () => {};
|
|
const expiration = new Promise<never>((_resolve, reject) => {
|
|
cancel = () => { controller.abort(); reject(new JourneyError("failed", code)); };
|
|
if (deadlineAt <= Date.now() || parentSignal?.aborted) cancel();
|
|
else {
|
|
timer = setTimeout(cancel, deadlineAt - Date.now());
|
|
parentSignal?.addEventListener("abort", cancel, { once: true });
|
|
}
|
|
});
|
|
try {
|
|
const value = await Promise.race([expiration, Promise.resolve().then(() => {
|
|
if (controller.signal.aborted) throw new JourneyError("failed", code);
|
|
return operation({ signal: controller.signal, deadlineAt });
|
|
})]);
|
|
if (controller.signal.aborted || parentSignal?.aborted || Date.now() >= deadlineAt) {
|
|
controller.abort();
|
|
throw new JourneyError("failed", code);
|
|
}
|
|
return value;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
parentSignal?.removeEventListener("abort", cancel);
|
|
}
|
|
}
|
|
|
|
export function journeyFailure(error: unknown): { outcome: Exclude<JourneyOutcome, "passed">; code: string } {
|
|
if (error instanceof JourneyError) return error;
|
|
const candidate = error as { outcome?: string; code?: string } | null;
|
|
if (candidate && ["failed", "blocked", "unsupported"].includes(candidate.outcome ?? "") && /^[a-z][a-z0-9_-]{0,100}$/.test(candidate.code ?? "")) {
|
|
return { outcome: candidate.outcome as Exclude<JourneyOutcome, "passed">, code: candidate.code! };
|
|
}
|
|
// Browser errors may contain keys, codes, URLs and form values. Never retain them.
|
|
return { outcome: "failed", code: "journey_operation_failed" };
|
|
}
|
|
|
|
export async function launchProductJourneys(args: string[], repositoryRoot: string) {
|
|
args = args.filter(arg => arg !== "--");
|
|
if (args.includes("--list") && !args.includes("--target-config")) { console.log(JSON.stringify(productJourneys, null, 2)); return; }
|
|
const allowed = new Set(["--target-config", "--output", "--preview", "--list", "--suite"]);
|
|
const values = new Map<string, string>();
|
|
for (let i = 0; i < args.length; i++) {
|
|
const flag = args[i]!;
|
|
if (!allowed.has(flag)) throw new Error("unknown_product_journey_option");
|
|
if (!["--preview", "--list"].includes(flag)) {
|
|
const value = args[++i]; if (!value || value.startsWith("--") || values.has(flag)) throw new Error("invalid_product_journey_option");
|
|
values.set(flag, value);
|
|
}
|
|
}
|
|
const file = values.get("--target-config");
|
|
if (!file) throw new Error("target_config_required");
|
|
const config = parseTargetConfig(JSON.parse(await readFile(file, "utf8")));
|
|
if (args.includes("--preview") || args.includes("--list")) { console.log(JSON.stringify(preparationPreview(config), null, 2)); return; }
|
|
const campaignId = `journey-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
|
const outputDir = path.resolve(values.get("--output") ?? path.join(repositoryRoot, "tests/runner-e2e/results", campaignId));
|
|
// A campaign directory is exclusive; attempts are never overwritten.
|
|
await mkdir(path.dirname(outputDir), { recursive: true, mode: 0o700 });
|
|
await mkdir(outputDir, { recursive: false, mode: 0o700 });
|
|
const deadlineAt = Date.now() + config.limits.durationMs;
|
|
const controller = new AbortController();
|
|
let browser: Browser | undefined;
|
|
let context: BrowserContext | undefined;
|
|
const abort = () => { controller.abort(); for (const page of context?.pages() ?? []) void page.close().catch(() => {}); };
|
|
let session: TargetSession | undefined;
|
|
let ready: JourneyContext | undefined;
|
|
let fixtures: ReturnType<typeof journeyFixtures> | undefined;
|
|
let prerequisiteFailure: ReturnType<typeof journeyFailure> | undefined;
|
|
let releaseSettled = false;
|
|
let cleanupDeadlineAt = 0;
|
|
let cleanupWorkDeadlineAt = 0;
|
|
const results: RunnerE2EResult[] = [];
|
|
let sha: string | null = null;
|
|
try { sha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repositoryRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); } catch { /* Packaged harnesses may not include Git metadata. */ }
|
|
const source = resolveRunnerE2ESource({ sha, ref: null, workflowRunUrl: null });
|
|
const definitionHash = createHash("sha256").update(JSON.stringify(productJourneys));
|
|
for (const name of ["catalog.ts", "task-properties.ts", "task-search-filter.ts", "task-search-evidence.ts", "basic-flows.ts", "basic-evidence.ts", "project-context-flow.ts", "project-context-readback.ts", "project-context-evidence.ts", "agent-flows.ts", "clarification-flow.ts", "clarification-evidence.ts", "../question-resume-ui.ts", "../question-resume-scoring.ts", "../runtime-question-readiness.ts", "../chat-flow.ts", "../continuation-readiness.ts", "../continuation-cases.ts", "success-contract.ts", "product-screenshot.ts", "cloud-onboarding.ts", "onboarding-connect.ts", "onboarding-first-task.ts", "workspace-deferral.ts", "onboarding-pre-acceptance.ts", "artifact-evidence.ts", "fixtures.ts", "launch.ts", "types.ts", "collection.ts", "local-target.ts"]) definitionHash.update(await readFile(path.join(import.meta.dirname, name)));
|
|
for (const file of ["catalog.ts", "api.ts", "run-observations.ts", "native-task-reply.ts", "native-event-envelope.ts", "user-actions.ts", "plan-revision-flow.ts", "skill-studio-flow.ts", "protected-action-flow.ts", "story-artifact.ts", "stop-redirect-flow.ts", "everyday-cases.ts", "everyday-delivery.ts", "everyday-decisions.ts", "connection-guidance-evidence.ts", "everyday-artifact.py"]) definitionHash.update(await readFile(path.join(import.meta.dirname, "..", file)));
|
|
definitionHash.update(runnerSuites.flatMap(suite => suite.tasks).find(task => task.id === "ask-question")!.buildPrompt("definition"));
|
|
const suiteDefinitionHash = definitionHash.digest("hex");
|
|
const expected = config.journeys.map(id => `product-journeys.${config.id}.${id === "cloud-onboarding" ? config.modelProfileId : isAgentJourney(id) ? "configured-agent" : "browser"}.${config.executionEnvironment}.${id}`);
|
|
let reportPersisted = false;
|
|
const failFinalization = (code: string) => {
|
|
for (const result of results) {
|
|
result.status = "failed"; result.cleanup = "failed"; result.failureClass = "cleanup_failure";
|
|
result.error ??= code;
|
|
if (result.productJourney!.outcome === "passed") result.productJourney!.outcome = "failed";
|
|
result.productJourney!.resourceDisposition = "cleanup-failed";
|
|
if (!result.productJourney!.phases.some(phase => phase.phase === "cleanup" && phase.code === code)) {
|
|
result.productJourney!.phases.push({ phase: "cleanup", outcome: "failed", code });
|
|
}
|
|
}
|
|
};
|
|
const persist = async () => {
|
|
try {
|
|
await writeFile(path.join(outputDir, "normalized-results.tmp"), JSON.stringify({ schema: "paperclip.runner-e2e.campaign/v2", campaignId, generatedAt: new Date().toISOString(), expected, source, results }, null, 2), { mode: 0o600 });
|
|
await rename(path.join(outputDir, "normalized-results.tmp"), path.join(outputDir, "normalized-results.json"));
|
|
reportPersisted = true;
|
|
} catch {
|
|
reportPersisted = false;
|
|
failFinalization("evidence_persistence_failed");
|
|
throw new JourneyError("failed", "evidence_persistence_failed");
|
|
}
|
|
};
|
|
const persistSafely = async () => { try { await persist(); return true; } catch { return false; } };
|
|
const renderDashboard = async () => {
|
|
// An uncooperative late renderer can only write its own private staging area.
|
|
// The launcher's normalized results remain authoritative, including failures.
|
|
const staging = path.join(outputDir, `.dashboard-${randomUUID()}`);
|
|
await withinLifecycleDeadline(cleanupWorkDeadlineAt, "report_generation_timeout", async () => {
|
|
await mkdir(staging, { mode: 0o700 });
|
|
await regenerateRunnerDashboard({ bundle: outputDir, historyFile: null, outputDirectory: staging });
|
|
});
|
|
// Publication has no async gap in which a timeout can persist a newer failure.
|
|
if (Date.now() >= cleanupWorkDeadlineAt) throw new JourneyError("failed", "report_generation_timeout");
|
|
renameSync(path.join(staging, "index.html"), path.join(outputDir, "index.html"));
|
|
renameSync(path.join(staging, "dashboard.html"), path.join(outputDir, "dashboard.html"));
|
|
};
|
|
process.once("SIGINT", abort); process.once("SIGTERM", abort);
|
|
const timer = setTimeout(abort, config.limits.durationMs);
|
|
try {
|
|
try {
|
|
browser = await chromium.launch({ headless: !config.headed, ...(process.env.PAPERCLIP_RUNNER_E2E_CHROMIUM_EXECUTABLE ? { executablePath: process.env.PAPERCLIP_RUNNER_E2E_CHROMIUM_EXECUTABLE } : {}) });
|
|
context = await browser.newContext();
|
|
context.setDefaultTimeout(30_000);
|
|
if (controller.signal.aborted) throw new JourneyError("blocked", "campaign_deadline_or_interruption");
|
|
const loaded = config.mode === "managed-self-hosted" ? await import("./local-target.js") : await import(pathToFileURL(path.resolve(config.adapterModule!)).href);
|
|
const provider = loaded.provider as TargetProvider;
|
|
if (provider?.version !== 1 || typeof provider.acquire !== "function") throw new JourneyError("blocked", "unsupported_target_provider_contract");
|
|
session = await provider.acquire({ config, campaignId, deadlineAt, browser: context, signal: controller.signal, repositoryRoot, outputDir, harness: { revision: source.sha, definitionHash: suiteDefinitionHash } });
|
|
await session.authenticate();
|
|
fixtures = journeyFixtures(session, campaignId);
|
|
} catch (error) { prerequisiteFailure = journeyFailure(error); }
|
|
for (const [index, id] of config.journeys.entries()) {
|
|
const startedAt = new Date().toISOString();
|
|
const evidence: JourneyEvidence = { targetId: config.id, outcome: "blocked", startingState: ready?.startingState ?? "recipe", phases: [], checks: [], resourceDisposition: "cleanup-failed", privacy: "private", campaignId, attemptId: `${campaignId}:${id}:1` };
|
|
const profile = id === "cloud-onboarding" ? runnerSuites.flatMap(suite => suite.profiles).find(p => p.id === config.modelProfileId) : undefined;
|
|
const result: RunnerE2EResult = {
|
|
schema: "paperclip.runner-e2e.result/v2", executionId: expected[index]!, suiteId: "product-journeys", suiteDefinitionHash,
|
|
source, attempt: 1, status: "failed", profileId: isAgentJourney(id) ? "configured-agent" : profile?.id ?? "browser", environmentId: config.executionEnvironment, caseId: id,
|
|
provider: profile?.provider ?? null, model: profile?.model ?? null, runtimeMode: profile?.generation ?? "none", startedAt, finishedAt: startedAt, durationMs: 0, cleanup: "not_started", productJourney: evidence,
|
|
};
|
|
results.push(result);
|
|
let phase: JourneyEvidence["phases"][number]["phase"] = "authentication";
|
|
try {
|
|
if (prerequisiteFailure) {
|
|
if (index === 0) throw new JourneyError(prerequisiteFailure.outcome, prerequisiteFailure.code);
|
|
throw new JourneyError("blocked", "prerequisite_journey_failed");
|
|
}
|
|
if (controller.signal.aborted) throw new JourneyError("blocked", "campaign_deadline_or_interruption");
|
|
evidence.phases.push({ phase: "authentication", outcome: "passed" });
|
|
await persist();
|
|
phase = id === "cloud-onboarding" ? "journey" : "setup";
|
|
if (id === "cloud-onboarding") {
|
|
if (!session!.onboard) throw new JourneyError("unsupported", "target_has_no_onboarding");
|
|
ready = await session!.onboard(evidence);
|
|
if (!evidence.artifact) throw new JourneyError("failed", "onboarding_artifact_evidence_missing");
|
|
validateOnboardingArtifact(evidence.artifact);
|
|
fixtures!.provide(ready);
|
|
} else {
|
|
ready = await fixtures!.resolve(productJourneys.find(j => j.id === id)!.requires);
|
|
evidence.phases.push({ phase: "setup", outcome: "passed" });
|
|
evidence.deployment = ready.identity;
|
|
evidence.startingState = ready.startingState;
|
|
await persist();
|
|
phase = "journey";
|
|
if (isAgentJourney(id)) {
|
|
if (!ready.agent) throw new JourneyError("blocked", "configured_agent_required");
|
|
result.profileId = "configured-agent";
|
|
result.provider = ready.agent.provider;
|
|
result.model = ready.agent.model;
|
|
result.runtimeMode = ready.agent.runtimeMode;
|
|
if (id === "ask-question") await askQuestion(ready, session!, campaignId, evidence);
|
|
else if (id === "question-answer-resume") await runClarificationJourney(ready, session!, campaignId, evidence, { deadlineAt,
|
|
directory: path.join(outputDir, "private-stories", id),
|
|
capture: async (_id, _label, file) => {
|
|
const directory = path.join(outputDir, "private-stories", id);
|
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
await captureProductScreenshot(ready!.page, ready!.api.origin, path.join(directory, file), deadlineAt);
|
|
} });
|
|
else await runAgentJourney(id, ready, session!, campaignId, evidence, { deadlineAt,
|
|
directory: path.join(outputDir, "private-stories", id),
|
|
capture: async (_id, _label, file) => {
|
|
const directory = path.join(outputDir, "private-stories", id);
|
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
await captureProductScreenshot(ready!.page, ready!.api.origin, path.join(directory, file), deadlineAt);
|
|
} });
|
|
} else if (id === "task-create") await taskCreate(ready, session!, campaignId, evidence);
|
|
else if (id === "project-task-context") await projectTaskContext(ready, session!, campaignId, evidence);
|
|
else if (id === "task-search-filter") await taskSearchFilter(ready, session!, campaignId, evidence);
|
|
else await taskProperties(ready, session!, campaignId, evidence);
|
|
}
|
|
evidence.deployment = ready.identity;
|
|
evidence.startingState = ready.startingState;
|
|
if (JSON.stringify(await session!.identity()) !== JSON.stringify(ready.identity)) throw new JourneyError("failed", "deployment_changed_during_journey");
|
|
evidence.outcome = "passed";
|
|
evidence.phases.push({ phase: "journey", outcome: "passed" });
|
|
result.status = "passed";
|
|
const evidenceDir = path.join(outputDir, "evidence", result.executionId, "attempt-1");
|
|
await mkdir(evidenceDir, { recursive: true, mode: 0o700 });
|
|
await captureProductScreenshot(ready.page, ready.api.origin, path.join(evidenceDir, `${id}.png`), deadlineAt);
|
|
result.screenshots = [{ id, label: productJourneys.find(j => j.id === id)!.label, file: `${id}.png` }];
|
|
} catch (error) {
|
|
const failure = journeyFailure(error);
|
|
evidence.outcome = failure.outcome;
|
|
evidence.phases.push({ phase, ...failure });
|
|
result.status = "failed";
|
|
result.error = failure.code;
|
|
result.failureClass = failure.outcome === "failed" ? "candidate_failure" : "permanent_infrastructure";
|
|
// Retain failed product screens only after tenant setup, never login screens.
|
|
if (ready && phase === "journey") {
|
|
try {
|
|
const url = new URL(ready.page.url());
|
|
if (url.origin === ready.api.origin && /^\/[^/]+\/(issues|search)(\/|$)/.test(url.pathname)) {
|
|
const evidenceDir = path.join(outputDir, "evidence", result.executionId, "attempt-1");
|
|
await mkdir(evidenceDir, { recursive: true, mode: 0o700 });
|
|
await captureProductScreenshot(ready.page, ready.api.origin, path.join(evidenceDir, `${id}-failed.png`), deadlineAt);
|
|
result.screenshots = [{ id: `${id}-failed`, label: "Failed journey screen", file: `${id}-failed.png` }];
|
|
}
|
|
} catch { /* Preserve the original failure when its page is unavailable. */ }
|
|
}
|
|
if (id === "cloud-onboarding" || !ready) prerequisiteFailure = failure;
|
|
}
|
|
result.finishedAt = new Date().toISOString(); result.durationMs = Date.parse(result.finishedAt) - Date.parse(startedAt);
|
|
if (!await persistSafely()) prerequisiteFailure ??= { outcome: "failed", code: "evidence_persistence_failed" };
|
|
}
|
|
} finally {
|
|
try {
|
|
const collected = await withinLifecycleDeadline(deadlineAt, "evidence_collection_timeout", input => session?.collect(input) ?? Promise.resolve([]), controller.signal) ?? [];
|
|
const seen = new Set<string>();
|
|
for (const receipt of collected) {
|
|
validateJourneyCollection(receipt);
|
|
const result = results.find(r => r.caseId === receipt.journeyId);
|
|
if (!result || seen.has(receipt.journeyId) || receipt.operations && (receipt.operations.campaignId !== campaignId || receipt.operations.attemptId !== result.productJourney!.attemptId)) throw new JourneyError("failed", "foreign_or_duplicate_operations_receipt");
|
|
seen.add(receipt.journeyId);
|
|
// Validate the normalized boundary before retaining any adapter output.
|
|
const candidate = { ...result, status: "failed", runIds: receipt.runIds, usage: receipt.usage, productJourney: { ...result.productJourney!, operations: receipt.operations } };
|
|
validateRetainedRunnerResult(candidate);
|
|
result.runIds = receipt.runIds;
|
|
result.usage = receipt.usage;
|
|
if (receipt.operations) result.productJourney!.operations = receipt.operations;
|
|
}
|
|
for (const result of results) {
|
|
if (isAgentJourney(result.caseId as import("./types.js").JourneyId) && result.status === "passed"
|
|
&& (!seen.has(result.caseId) || !agentJourneyRunCountValid(result.caseId, result.runIds?.length))) throw new JourneyError("failed", "paid_journey_receipt_missing");
|
|
if (result.caseId === "cloud-onboarding" && result.status === "passed" && (!seen.has(result.caseId) || !result.runIds?.length || !result.productJourney?.operations || !result.productJourney.operations.traceIds.length)) throw new JourneyError("failed", "paid_journey_receipt_missing");
|
|
// Success-only checks must run before a disposable target can be deleted.
|
|
// Cleanup is provisional here; the observed cleanup result is retained below.
|
|
if (result.status === "passed") validateRetainedRunnerResult({ ...result, cleanup: "passed" });
|
|
}
|
|
for (const result of results) result.productJourney!.phases.push({ phase: "collection", outcome: "passed" });
|
|
} catch (error) {
|
|
const failure = journeyFailure(error);
|
|
for (const result of results) { result.status = "failed"; if (result.productJourney!.outcome === "passed") { result.productJourney!.outcome = "failed"; result.error = failure.code; } result.productJourney!.phases.push({ phase: "collection", outcome: "failed", code: ["paid_journey_receipt_missing", "evidence_collection_timeout"].includes(failure.code) ? failure.code : "evidence_collection_failed" }); }
|
|
}
|
|
clearTimeout(timer);
|
|
cleanupDeadlineAt = Date.now() + cleanupGraceMs;
|
|
cleanupWorkDeadlineAt = cleanupDeadlineAt - stopReserveMs - browserCloseReserveMs;
|
|
// A private provider checkpoints the measured evidence before deleting resources.
|
|
await persistSafely();
|
|
try {
|
|
const disposition = await withinLifecycleDeadline(cleanupWorkDeadlineAt, "target_cleanup_timeout", input => session?.release(results.every(r => r.status === "passed") ? "passed" : "failed", input) ?? Promise.resolve("released" as const));
|
|
releaseSettled = true;
|
|
for (const result of results) { if (result.cleanup !== "failed") result.cleanup = "passed"; result.productJourney!.resourceDisposition = disposition; result.productJourney!.phases.push({ phase: "cleanup", outcome: "passed" }); }
|
|
} catch (error) {
|
|
failFinalization(error instanceof JourneyError && error.code === "target_cleanup_timeout" ? error.code : "target_cleanup_failed");
|
|
// A timed-out release may still have an in-flight request. Stop-only recovery
|
|
// gets its own bounded signal and cannot delete resources or free the lease.
|
|
try { await withinLifecycleDeadline(Math.min(cleanupDeadlineAt - browserCloseReserveMs, Date.now() + stopReserveMs), "target_stop_timeout", input => session?.stop?.(input) ?? Promise.resolve()); }
|
|
catch { failFinalization("target_stop_failed"); }
|
|
}
|
|
try { await withinLifecycleDeadline(Math.min(cleanupDeadlineAt, Date.now() + browserCloseReserveMs), "browser_cleanup_timeout", () => browser?.close() ?? Promise.resolve()); }
|
|
catch (error) { failFinalization(error instanceof JourneyError && error.code === "browser_cleanup_timeout" ? error.code : "browser_cleanup_failed"); }
|
|
process.removeListener("SIGINT", abort); process.removeListener("SIGTERM", abort);
|
|
await persistSafely();
|
|
}
|
|
// If the final write failed, make one bounded attempt to retain that failure itself.
|
|
if (!reportPersisted) await persistSafely();
|
|
if (reportPersisted) {
|
|
try { await renderDashboard(); }
|
|
catch { failFinalization("report_generation_failed"); await persistSafely(); }
|
|
}
|
|
try {
|
|
if (reportPersisted) await withinLifecycleDeadline(cleanupWorkDeadlineAt, "evidence_finalization_timeout", input => {
|
|
// Only a settled release can authorize lease-releasing finalization.
|
|
return (releaseSettled ? session?.finalizeEvidence?.(input) : session?.checkpointEvidence?.(input)) ?? Promise.resolve();
|
|
});
|
|
}
|
|
catch {
|
|
failFinalization("evidence_finalization_failed");
|
|
await persistSafely();
|
|
if (reportPersisted) {
|
|
try { await renderDashboard(); } catch { /* Durable provider checkpoint and cleanup failure remain recoverable. */ }
|
|
try { await withinLifecycleDeadline(cleanupWorkDeadlineAt, "evidence_checkpoint_timeout", input => session?.checkpointEvidence?.(input) ?? Promise.resolve()); } catch { /* Keep the provider's pending manifest and exclusive leases for recovery. */ }
|
|
}
|
|
}
|
|
if (!reportPersisted) console.error("evidence_persistence_failed");
|
|
console.log(`Product journeys: ${results.filter(r => r.status === "passed").length}/${results.length} passed; ${reportPersisted ? `private report ${outputDir}/dashboard.html` : "evidence pending recovery"}`);
|
|
if (!reportPersisted || results.some(r => r.status !== "passed")) process.exitCode = 1;
|
|
}
|