Files
PaperClipAI/tests/runner-e2e/product-journeys/launch.ts
T
Dotta a4308b9791 test(e2e): add browser project and task context journey (#18353)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - People group tasks into projects and use those projects to keep work
in context.
> - Deployment journeys already test task creation and assignment.
> - They do not yet prove that a new project and its task keep the same
saved relationship after reload.
> - This pull request adds an explicit browser journey for that gap.
> - Independent API reads and visible task properties must agree on the
exact records and selected settings.
> - The journey uses no model runs. Each deployment must pass before a
daily plan can include it.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The deployment-targeted Product E2E suite can now check project creation
and task context through the production dialogs.

**Current behavior**

The suite covers standalone task creation and assignment. It does not
prove that a newly created project contains the task selected in the
composer, or that the relation survives reload.

**Proposed behavior**

Select `project-task-context`. Create a unique project and a
self-assigned task with that project selected. Reopen the project, open
its task, and reload. Require exact saved company, project, title,
prompt, user assignment and standard work mode. Require no agent runs
before or after reload.

**Reason and benefit**

The same journey can qualify different deployments. It catches lost
project links and incorrect saved context without a model call or a
separate QA-only test implementation.

## What Changed

- Add the browser flow and an independent saved-state oracle with
positive and negative cases.
- Scope project and task navigation to unique links in the main content
so sidebar links cannot bypass list coverage.
- Bind reopened routes to saved project and task references. Verify the
main project heading and visible task content instead of a hidden
duplicate sidebar label. Keep browser failures as closed stage codes.
- Register the journey, its closed success checks, and its source files
in the definition hash. Default model-matrix selections stay unchanged.
- Associate qualification-report coverage with project lifecycle,
project task context and task creation. Reject retained browser-only
passes containing run IDs, provider/model metadata or native/legacy
execution.
- Add `project` to the existing durable ownership resource contract. A
target provider remains responsible for cleanup of its own project
fixtures.
- Update discovery expectations, the deployment journey guide and the
projects feature map.

## Verification

- Focused runner tests: 110 passed across the new oracle, discovery,
journey contracts and existing report subprocess checks. The first
sandboxed attempt blocked the existing report subprocess IPC socket; the
same selection passed with local IPC enabled.
- Late review regression checks: both report association and unexpected
browser-only model work reproduced as failing tests before the fix. The
final contract, saved-state oracle and report catalog selection passed
all 72 tests. Runner typecheck passed again after these changes.
- Browser readback: the old project-name selector failed in Chromium
when a hidden sidebar label preceded the production project heading. All
six browser regression cases now pass, including missing rows, wrong
project/task destinations and a wrong project property. The fixture uses
the production heading and task-row overlay conventions. Credential-free
Node 24 imports pass.
- Current-master integration: 208 focused catalog, project/search,
campaign and reporting tests passed. All nine project/search browser
regressions passed. Runner typecheck, map checks and Node 24 imports for
both journeys passed. The combined catalog, dispatch, source hashes,
success checks, browser-only guard and feature associations retain both
selections.
- The first deployment qualification created both owned records but
failed during later readback with a generic browser code.
Authentication, setup, collection and cleanup passed. That failed grade
is retained. The corrected source subsequently passed a fresh attached
QA campaign; the original failure remains preserved.
- New oracle alone: 7 tests passed after the final catalog mapping
change.
- `node scripts/check-maps.mjs` — passed; structural checks only.
- Credential-free `--suite product-journeys --list` — passed and lists
this browser journey.
- `node node_modules/typescript/bin/tsc -p
tests/runner-e2e/tsconfig.json` — passed after building required plugin,
runner and eval-kernel declarations. The initial fresh-checkout check
failed because those declarations were not built.
- All current-head CI gates passed, including repository typecheck,
tests, build and browser checks. Greptile reviewed the exact head at 5/5
with no unresolved findings. Fresh attached QA passed at
`f19557c9eebde14c2def37195d6b154c23eb9690`: [sanitized
report](https://pages.paperclip.ing/evals-qa-smoke-41119722363-1/). All
five independent checks and authentication, setup, journey, collection
and cleanup passed; zero agent runs started.

## Risks

- This adds an opt-in test and changes no production behavior or schema.
UI control changes can fail the journey.
- The target provider must support durable `project` ownership before it
can run this case. Project cleanup must settle owned tasks, reject
foreign linked tasks and archive the project while preserving history.
- Source registration alone does not include the candidate in any saved
daily plan. A fresh pass against that deployment is required first.

## Model Used

- OpenAI Codex, GPT-6 family, with code inspection and tool execution.
The host does not expose the exact underlying model revision 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 (no duplicate journey found)
- [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
2026-10-30 15:07:21 -05:00

324 lines
23 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, agentJourneyRuns } from "./catalog.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 { 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", "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 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));
} });
} 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`));
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`));
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) || result.runIds?.length !== agentJourneyRuns[result.caseId as keyof typeof agentJourneyRuns])) 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;
}