Files
PaperClipAI/tests/runner-e2e/product-journeys/launch.ts
T
DottaandPaperclip 7e595fd055 test(e2e): verify native hiring, delegation and teammate reuse (#18351)
## Thinking Path

> - Paperclip manages real work by AI agents.
> - Daily QA must verify that work on the staging deployment.
> - Classic Agent Chat coverage already exercises hiring and teammate
reuse.
> - The deployment journey should share that behavior and its ownership
oracle.
> - Saved documents, exact callback replies and physical cleanup
establish success.
> - The corrected journey and complete daily suite now pass on QA.

## Linked Issues or Issue Description

**Subsystem affected**

Product E2E deployment journeys and their shared browser controls.

**Problem or motivation**

QA must prove that one newly hired teammate completes two different
tasks and returns both saved results to the lead conversation. Names,
chat claims and successful-run counts alone cannot establish authorship
or completion.

**Proposed solution**

Reuse the classic hiring oracle and production composer. Verify
immutable creation audits, initial assignment, current project and
ownership, saved document revisions, exact native authors, durable
callbacks and their own task links after reload. Bound execution to five
lead turns and two child turns.

**Alternatives considered**

A separate QA implementation would duplicate the classic journey. Name
matching or later reassignment would accept broken initial ownership.
Links elsewhere in the conversation cannot substitute for either
callback's own result link.

**Roadmap alignment**

This verifies existing features. Related merged repairs: #18389, #18465,
#18459, #18463, #18502, #18503, #18504, #18506, #18507 and #18514.
Separate dependency #18517 repairs file preservation after lifecycle
cancellation and is merged as Core `050365e`, which also contains
#18514. This primary PR now differs from that protected mainline in only
16 unchanged H harness/documentation paths; it adds no application
production delta. The measured QA deployment serves Core `050365e`, with
its matching bundled provider pack and unchanged approved model/ROM.

## What Changed

- Add the shared `hire-delegate-reuse` journey, catalog, types,
dispatch, launch fingerprints, journey-feature entries and
documentation. Hire one teammate, delegate a checklist, then reuse the
same teammate for a saved review. Require seven canonical native runs:
five lead and two child turns.
- Require exact company, lead, chat and native creation receipts. Both
tasks must be created for the intended teammate using immutable
creation-audit assignee fields. Independently verify current project and
ownership. Creation-time project is not recorded in that audit and is
not claimed by this assertion.
- Verify both current document revisions belong to the exact child runs
and preserve the original checklist.
- Use ordinary production rich-editor agent/project autocomplete
controls. Verify exact reference chips, then send once. Preserve the
shared default sender.
- Require each durable, run-owned callback reply to contain its own
exact completed-task link. Collect links through the actual production
Markdown renderer and issue-reference decoration, then apply the
existing company-scoped route authority. An unrelated table or code
fence cannot hide a rendered link; foreign, wrong-task and code-only
links remain failures.
- Preserve approved connection, account, grant and responsible-user
attribution. Verify the child's independent installation and retain the
immutable binding snapshot. Do not copy credentials or broaden grants.
- Record ownership before later observations can fail. Retry only typed
transient observation GET failures. Preserve authority and cleanup
failures.
- Retain all 14 daily journeys, the model matrix, deadlines, seven-run
cap and 500-cent aggregate allowance split 300/200.

## Verification

- **Current composed head:** `afa3f5552ab0c1a9f0ee7327dfa5665d57cc7bcb`.
It normally merges mainline `1e8e322` and exact #18517 source `adb1d4e`,
retaining the intermediate `b2d9eb4` and `6fd1042` ancestry. The only
merge conflict was the Playwright support registration; it is resolved
to the exact previously qualified union. All 16 H harness/documentation
paths are byte-identical to `5c0830f`; all four #18517 paths are
byte-identical to `adb1d4e`. Every other tracked path matches actual
protected mainline `050365e`. A clean merge preview of `050365e` and
this head equals the current head tree, so no additional source commit
is needed. GitHub's three-dot display still lists the four already
merged helper files because #18517 used a squash merge; those blobs
equal protected mainline and add no net application change. No journey,
oracle, cap or model change accompanies this composition. A final clean
merge preview on intervening mainline
`006d5691a6245d5a710e25ba3df6d609754e163a` preserves all unrelated
changes and adds exactly the same 16 measured H blobs, with no net App
production delta. That later mainline is not represented as the AppCore
measured by the QA run.
- **Local qualification:** 149/149 focused tests across six suites,
including the report-catalog case, and 11/11 real Chromium checks passed
on `b2d9eb4`. All H modules, browser inputs and parser/package
dependencies are byte-identical at `afa3f55`; those unchanged suites
were not repeated. Current-source transitive types, maps, module
boundaries and diff checks pass. Official Node24 QA runtime imports pass
again using exact public `afa3f55` and private `8671fa9`, with a clean
credential-free environment and network disabled. The initial local
report-catalog failure is retained: the child `tsx` process was denied
its local IPC socket before importing the report or writing an artifact.
The same source and selected case pass after correcting only sandbox IPC
permission; no source workaround or deselection was used.
- **Current canonical CI and reviews pass.** [CI
41275109691](https://github.com/paperclipai/paperclip/actions/runs/41275109691)
completed successfully at exact `afa3f55`: all 59 contexts are terminal
and clean (55 success, three skipped, one neutral). Fresh exact-head
Greptile and official Paperclip reviews both scored 5/5; all 27 review
threads are resolved with pagination complete. The official review is
static and its check is neutral; it did not execute tests or live QA. A
separate exact-head independent composition assessment found no
actionable issues at 4/5 confidence and performed no live actions.
**Fresh QA qualification passed: all 14 daily journeys, including the
corrected H7, passed against deployed Core `050365e`.** Prior check
conclusions and review scores retain their actual heads. The previous
`5c0830f` completed all 59 contexts cleanly, actual exact-head Greptile
and official Paperclip reviews both scored 5/5, and all 27 review
threads were resolved. Its separate independent journey-source review at
`2b803f08` remained 4/5 with a unit-helper authorship caveat.
Intermediate `b2d9eb4` received an actual exact-head Greptile 5/5 with
zero open findings, then was updated to compose the valid dependency
review fix before live QA. That score and its pending CI are not
attributed to `afa3f55`.
- Existing causal controls remain unchanged: invalid immutable assignee,
either missing callback link, foreign/code/wrong-task links and an
earlier handoff fail against the relevant prior source. Renderer
controls prove that tables and fenced code elsewhere in a valid callback
do not hide its actual task link. Both composer and document-navigation
browser registrations remain present.
- #18503 merged after its actual QA document case passed four canonical
checks and five phases, positively established zero native work, and
completed cleanup. Its measured files are unchanged in this composition.
#18506 and #18507 merged after their own checks; their causal
worker-settlement controls remain intact.
- #18514 is merged as `1e8e322`. It reproduces the selected-task
metadata-only-project startup failure against deployed `756599e` and
passes 19 real-PostgreSQL selected controls after repair. Both direct
and assembled baseline controls fail before it. Real source, explicit
selection, policy and workspace-isolation guards remain enforced. It is
included in measured Core `050365e`; fresh H7 reached and completed both
delegated native tasks.
- #18517 initial source `6fd1042` passed 320/320 focused and sibling
Stop/recovery tests, including 20 preservation controls. Its actual
official review was 4/5 and found a valid follow-up: the new
live-controller guard should wait for actual lease expiry rather than
repeat locked checks every 30 seconds. Both an already-live lease and a
real under-lock renewal reproduce that issue against `6fd1042`.
Corrected source `adb1d4e` passes all 22 preservation controls and 124
directly affected tests across three suites, exact-head server
build/stamp, typecheck and structural checks. It defers before locking
and rechecks renewed expiry afterward, preserving the transactional
owner guard. The old score/failure and successful owner tests remain
attributed to their actual sources. Independent exact-head review of
`adb1d4e` found no actionable issues at 4/5 confidence. This is
local/source evidence, not physical QA recovery. Its current [CI
41274963476](https://github.com/paperclipai/paperclip/actions/runs/41274963476)
completed successfully with all 59 contexts clean; both actual current
reviews are 5/5 with zero unresolved threads. It merged normally as
`050365e` after those gates. QA now runs that image. The historical
allocation is positively stopped and retained; its files-only export
remains a separately tracked repair, not a passing-test assertion.
- The historical [first H7
pass](https://pages.paperclip.ing/evals-qa-smoke-41226065043-1/) remains
attributed to public `5d3f143`, private `dabad62` and App `b7f1c37`: all
checks and five phases passed, seven native runs succeeded, both saved
documents and exact callbacks were verified, and physical cleanup
completed. An optional runtime-metadata receipt was inconclusive and was
not grade authority.

Material failed attempts remain failed:

| Attempt | Actual result and correction |
| --- | --- |
|
[41231853638](https://pages.paperclip.ing/evals-qa-smoke-41231853638-1/)
| 13 passed; hiring omitted initial teammate/project assignment and
started extra work. The production composer repair retains the ownership
negative. Later recovery stopped physical work without changing the
failed grade. |
|
[41238711372](https://pages.paperclip.ing/evals-qa-smoke-41238711372-1/)
| All seven native runs succeeded, but the final callback had no durable
chat reply. Cleanup passed. #18502 repairs premature completion-outbox
retargeting; the fresh measured H7 now verifies both durable, run-owned
callback replies and their own task links. |
|
[41253428446](https://pages.paperclip.ing/evals-qa-smoke-41253428446-1/)
| 12 passed; hiring rejected a mentioned metadata-only project before
delegation, and document reopening hit an immediate-control-probe race.
#18504 and #18503 repair those separate boundaries. Historical
missing-task absence remains quarantined; physical termination does not
manufacture absence evidence. |
|
[41266385118](https://pages.paperclip.ing/evals-qa-smoke-41266385118-1/)
| On `5c0830f` and App `756599e`, 13 passed, including the exact
composed document helper. Hiring created the correctly assigned task,
but its child admission failed before runtime selection because the
organizational project had no repository source. #18514 repairs that
selected-task boundary. The cancelled native lead retained an active
prepared-workspace lease; cleanup 41271035231 failed with
`owned_work_stop_unconfirmed`. #18517 addresses its file-preservation
admission. Launch 1110, original evidence and automatic Slack FAIL
remain failed. |
| [CI
41257779002](https://github.com/paperclipai/paperclip/actions/runs/41257779002)
/ [CI
41263476916](https://github.com/paperclipai/paperclip/actions/runs/41263476916)
| Existing enrollment and snapshot-retention fixtures observed
intermediate I/O before worker settlement. #18506 and #18507 have
controlled causal failures, complete-settlement observers and preserved
deadlines/negative controls. Runtime scheduling behavior is unchanged. |

**Fresh measured acceptance:** [QA run
41285131220](https://github.com/paperclipai/paperclip-cloud/actions/runs/41285131220)
and its [permanent
report](https://pages.paperclip.ing/evals-qa-smoke-41285131220-1/)
passed **14/14**, zero failed or incomplete, with all five phases and
complete cleanup for every case. The strict verifier independently
matched original grades, current provenance and completed manifests:
public harness `afa3f5552ab0c1a9f0ee7327dfa5665d57cc7bcb`, private
workflow `8671fa93e27501fabbfffa377bec6dea453b5db7`, AppCore
`050365e09d88411960fc3bacc1c08c46c0d9b6fa` and matching Cloud
image/provider pack. All model, ROM, deadline and run-budget controls
remained unchanged. All three workflow jobs completed successfully; the
automatic Slack notification reported PASS with links to this run. The
passing TestOps launch was closed normally after evidence verification.

The hiring case passed all 11 check entries across nine IDs and five
phases, with exactly five lead and two child native runs. Both saved
documents match their live current revision/body hashes and exact
hired-agent run attribution. The immutable original checklist remains
intact; the same teammate authored the review. Both own callback replies
contain their exact delegated task link. Independent canonical readback
confirmed all seven native runs succeeded and committed their accepted
result, assessment and decision, with no live owner and zero pending or
failed effects; each of the seven exact owned leases has its own bound
DESTROYED receipt and successful cleanup. All seven final snapshot
operations match their source run, workspace, task, epoch, SHA,
generation and export location. The four earlier lead snapshots are
retained SUPERSEDED ancestors of the current committed snapshot; the
latest lead and both child snapshots are COMMITTED. The initial
diagnostic required current COMMITTED status for every historical
snapshot and correctly remained false until the separate lineage
diagnostic established this supported supersession. The product oracle
and original grade were not changed. Canonical Suite metadata is
`qa-basics`; the daily plan was saved with its standard suite-only query
and synchronized, retaining all 14 ACTIVE cases.

The older failed allocation remains separately stopped, retained,
isolated from reuse and owned by terminated actors. That permits fresh
campaigns without replaying old work. Its same-allocation files export
is still unresolved and has separate bounded files-only recovery work;
it is not claimed by this successful campaign. Original failed grades,
reports and automatic FAIL notifications remain unchanged. No failed
result was closed, relabelled, hidden or retried unchanged to obtain
green.

## Risks

The journey requires Agent Chat, normal hiring permission, a scoped lead
and a provider that finalizes owned resources. Missing or extra work,
wrong ownership, absent saved documents, missing callbacks and
unconfirmed cleanup must fail.

The deployed app and bundled runner pack must match the verified Core.
The old run's cancellation ACK and terminal database row do not prove
physical termination or saved files. Its coordinator has no accepted
result or decision; pending-effect absence cannot be inferred from that
missing decision. File recovery must preserve the exact allocation and
original destroy disposition without a provider turn.

Current source checks, reviews, fresh H7 and full-14 aggregate all pass.
Canonical Suite metadata and actual 14-case daily-plan synchronization
were verified after closing the genuine passing launch. The original
failed allocation still has a separate files-export debt; its STOPPED
receipt is not a SAVED or DESTROYED claim.

## Model Used

OpenAI Codex, GPT-6 family. The session does not expose a more specific
backend model ID or fixed context window. Used reasoning, repository
tools, TypeScript and browser test execution.

## 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 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
- [x] Historical passes and material failed attempts retain their actual
source and outcome
- [x] The composed document helper passed normal QA with complete
cleanup on its actual source
- [x] The corrected hiring journey passes fresh normal H7 with complete
cleanup
- [x] The active hiring case has canonical Suite and daily-plan
membership after the passing launch is closed

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-01 02:33:59 -06:00

338 lines
25 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 { taskDocumentPersistence } from "./task-document-flow.js";
import { projectTaskContext } from "./project-context-flow.js";
import { runAgentJourney } from "./agent-flows.js";
import { runActiveFollowup } from "./active-followup-flow.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", "task-document-flow.ts", "task-document-evidence.ts", "project-context-flow.ts", "project-context-readback.ts", "project-context-evidence.ts", "agent-flows.ts", "hire-delegate-flow.ts", "hire-delegate-composer.ts", "hire-delegate-evidence.ts", "active-followup-flow.ts", "active-followup-evidence.ts", "stop-redirect-evidence.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", "../../../ui/src/lib/markdown-reply-content.ts", "../../../ui/src/lib/issue-reference.ts", "../../../ui/src/lib/company-routes.ts", "../../../ui/package.json"]) 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", "chat-hardening.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 === "followup-while-running") await runActiveFollowup(ready, session!, campaignId, evidence,
{ deadlineAt, directory: path.join(outputDir, "private-stories", id) });
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 === "task-document-persistence") await taskDocumentPersistence(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;
}