mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 23:43:16 +01:00
## 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
324 lines
23 KiB
TypeScript
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;
|
|
}
|