mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-02 16:58:52 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Product E2E journeys test the browser, native work and saved outcomes on a selected deployment. > - The Stop journey must interrupt real work before it sends a new direction. > - Its old boundary accepted a turn-start event before any command or usage report. > - A stopped attempt with incomplete usage correctly blocks later work under the existing budget policy. > - This pull request waits for a live native command and bound usage, then checks actual accounting settlement before it sends the redirect. > - The provider reports first usage after its initial command completes, so the fixture finishes a harmless readiness call before the long interruptible hold. > - The benefit is a stronger test of Stop and continuation with the same three-run limit. ## Linked Issues or Issue Description Refs: #18098, #18345, #18354. Related accounting work: #18035. Integrated upstream prerequisites: #18387, #18465 and #18409. Duplicate selector PR #18472 was closed as superseded by actual merged #18409. **What happened?** A QA attempt clicked Stop soon after a native turn started. The owned event stream had no command or usage report. Its cancelled run retained incomplete accounting. The saved redirect received a skipped wake, and no third run started. The budget guard must keep that missing usage visible. **Expected behavior** The journey must first prove that actual native work is active and that its current turn has reported complete usage. The fixture must finish a harmless readiness command before it starts a separate bounded hold. After Stop and physical cleanup, it must verify a complete accounting receipt and saved settlement before sending the redirect. **Steps to reproduce** The fresh staging attempt failed before Stop because its first 90-second command ended before the provider emitted its first complete usage. Its only later eligible commands lasted less than one second. The controlled browser sequence reproduces that ordering without accepting it. Completed readiness usage followed by a separate still-active hold qualifies under the unchanged oracle. Startup alone, command alone, usage alone, partial usage, incomplete accounting and a missing settlement timestamp still hold the next action. ## What Changed - Request a harmless `printf` readiness command first. Wait for its result, then request `sleep 90` exactly once in a separate shell call before file edits. This gives the provider an opportunity to emit current-turn usage before the interruptible hold. - Reuse the strict native-command observer. Require a bound current-turn complete usage report with valid nonzero token counts. Reject stale, partial, foreign and semantic-tool evidence. - Wait for the stopped run's complete accounting receipt, `accountingReceiptReady: true`, `costAccountingPending: false` and a valid saved settlement time before redirecting. A receipt already ready before Stop is intentional only when the exact cancelled run is actually accounted with no pending debt; no source/sequence advance or callback clock order is assumed. - Exclude canonical raw, partial and complete `item.completed` usage only when the API row and PRP envelope match the authoritative owned run, company and agent, and their source event ID, instance ID and positive sequence agree. Missing context keeps the strict work check. A self-consistent accounting source can change after Stop. Keep the original snapshot and reject real tools, turns, messages and other item events, including productive output from a new source. - Retain private boundary and accounting proof. Source and sequence observations do not impose unsupported controller or clock order. Include the new oracle in the suite definition hash. Keep existing success-check IDs, exact three runs, model, budget, deadline, Stop proof and final outcome checks. - Reuse one binding validator in the active-work boundary and post-Stop filter. Pre-Stop keeps its current Runner/current-turn restriction. Add focused evidence tests and a standalone Chromium/control-API sequence check. The optional pre-Stop hook leaves existing classic callers unchanged; they do not call the accounting filter. ## Verification - Current source is `47578138ad357fc11b8b346bfada36d6aa5ab484`, one narrow fixture commit on reviewed `b9668ccfba61c59eb496ba18269aee06e2921d8f`. Only the prompt constant, two ordering controls, the existing Chromium sequence and the journey documentation changed. All oracle functions, run counts, model, budget, deadline and check IDs are byte-identical to b966. There is no new upstream merge or app change. - Fresh staging failure is preserved in the [sanitized report](https://pages.paperclip.ing/evals-qa-smoke-41227920892-1/). It measured harness `5d3f143d0aed4dc7cf472b7891ea3d4d95ab843b` against app `b7f1c3769c00a107ffe4128b48b0ef8cc452cf3f`; the reviewed Stop helpers and its flow were unchanged in that harness. The original grade has `stop_boundary_already_settled`, two succeeded native runs, and complete cleanup. Authentication, setup, collection and cleanup passed; the journey failed. This attempt remains failed. - Causal timing evidence: the first shell command started at event108 and completed at111 about 90 seconds later. The first structured complete current-turn usage arrived at113, after the hold ended. Replaying the exact retained event metadata never qualifies that hold. The only later eligible command intervals lasted about 0.90 and 0.18 seconds. The emitter publishes positive usage when the provider sends its assistant-message usage update. The fixture now completes a harmless first call before requesting the long hold; no usage is supplied or invented by setup. - On current source, all 137 focused tests and journey contracts passed. Use `node node_modules/vitest/vitest.mjs run --config tests/runner-e2e/vitest.config.ts product-journeys/stop-redirect-evidence.test.ts product-journeys/agent-evidence.test.ts product-journeys/active-followup.test.ts native-event-envelope.test.ts product-journeys/contracts.test.ts --maxWorkers 1`. New controls reject every prefix when first usage arrives only after the hold completes. They accept the same-turn sequence of completed readiness, genuine complete usage and a separate active hold, and reject it after that hold completes. - Manual Chromium qualification passed: `node --import ./cli/node_modules/tsx/dist/loader.mjs tests/runner-e2e/stop-redirect-boundary.browser.mjs`. The browser does not click Stop for the failed ordering or completed readiness alone. It clicks once for a separate active hold with bound usage. It sends one redirect only after actual settlement. Foreign usage, productive output and missing accounting still fail. This isolated control uses controlled responses and does not call a model or the deployed app. - Strict scoped TypeScript, maps, diff and secret checks passed. The official credential-free Node24 QA runtime-import preflight passed without network access. Full local E2E typing previously encountered old borrowed workspace declarations; clean CI typecheck passed on b966. A full local repository build/test was not repeated for this four-file fixture change. Fresh canonical CI must supply the clean full gates. - Historical binding proof remains valid: the exact old d499 filter failed 17 of the final61 Stop tests. The shared validator checks authoritative run/company/agent plus consistent row/envelope source identity. Bound raw/partial/complete accounting is allowed after Stop, including a consistent source change; productive output remains strict. Pre-Stop still requires its current Runner and turn, complete nonzero usage and a running command. - Historical b966 CI `41224054720` passed all59 terminal checks, with exact-head Greptile and Paperclip Review each5/5 and no open findings. These are not approvals of current475. Current475 has fresh exact-head Greptile5/5 and Paperclip Review5/5, with zero unresolved findings and no omitted thread pages. Canonical CI [`41229185200`](https://github.com/paperclipai/paperclip/actions/runs/41229185200) passed on exact475. All59 checks are terminal:55 success,3 expected skips and1 optional neutral review, with zero failed or pending checks. No old job was retried. Fresh normal staging run41229965802 passed. Its [sanitized report](https://pages.paperclip.ing/evals-qa-smoke-41229965802-1/) agrees with the independently read final original grade:11 checks, all5 phases, exactly3 native runs and cleanup passed. The final manifest is complete, with cleanup complete and no pending evidence. Smoke, report publication and Slack outcome jobs all succeeded. - The live pass measured harness `6be20bef6d0c5077adc1ae99562ad84e756ab167` against app `b7f1c3769c00a107ffe4128b48b0ef8cc452cf3f`. Its Stop oracle, flow, shared binding validator, unit cases and Chromium control are byte-identical to reviewed475. The shared agent-flow file adds only the hiring import and early hiring dispatch; all remaining bytes match. This records the actual harness and app identities separately. - Canonical run readback confirmed the first run succeeded, the second was cancelled and the third succeeded. The cancelled run had a ready native-producer accounting receipt, no pending accounting and a saved settlement time. After verified file copyback, Core queued final destruction and followed its normal five-minute cleanup backoff. The exact lease naturally reached expired/cleanup-success with a bound destroyed receipt before the third run started. No operator cleanup, source change, timeout extension or workflow retry was used. The earlier failed staging attempt remains failed. - Current475 is ready for the normal reviewed merge and QA admission process. Source stays frozen; merge and configuration changes belong to that process. ## Risks - Some provider profiles may not expose a valid current-turn usage report while the command runs. The stronger boundary fails closed in that case. - Accounting may remain incomplete after Stop. The redirect stays unsent within the original deadline. This change does not waive missing debt, treat it as zero, raise a budget or change provider behavior. - This repairs the test boundary. It does not prove or repair a general provider shutdown-usage issue. A separate local late-usage ordering control is not attributed to this QA incident. ## Model Used - OpenAI Codex, GPT-6 family. The exact deployment variant and context-window size are not exposed in this session. Used code reasoning, repository tools, test execution and browser automation. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
338 lines
24 KiB
TypeScript
338 lines
24 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
|
import { randomUUID, createHash } from "node:crypto";
|
|
import { mkdir, readFile, writeFile, rename } from "node:fs/promises";
|
|
import { renameSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { chromium, type Browser, type BrowserContext } from "@playwright/test";
|
|
import { runnerSuites } from "../catalog.js";
|
|
import { productJourneys, parseTargetConfig, preparationPreview, isAgentJourney } from "./catalog.js";
|
|
import { agentJourneyRunCountValid } from "./success-contract.js";
|
|
import { journeyFixtures } from "./fixtures.js";
|
|
import { taskProperties } from "./task-properties.js";
|
|
import { taskSearchFilter } from "./task-search-filter.js";
|
|
import { askQuestion, taskCreate } from "./basic-flows.js";
|
|
import { 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", "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"]) definitionHash.update(await readFile(path.join(import.meta.dirname, name)));
|
|
for (const file of ["catalog.ts", "api.ts", "run-observations.ts", "native-task-reply.ts", "native-event-envelope.ts", "user-actions.ts", "plan-revision-flow.ts", "skill-studio-flow.ts", "protected-action-flow.ts", "story-artifact.ts", "stop-redirect-flow.ts", "everyday-cases.ts", "everyday-delivery.ts", "everyday-decisions.ts", "connection-guidance-evidence.ts", "everyday-artifact.py"]) definitionHash.update(await readFile(path.join(import.meta.dirname, "..", file)));
|
|
definitionHash.update(runnerSuites.flatMap(suite => suite.tasks).find(task => task.id === "ask-question")!.buildPrompt("definition"));
|
|
const suiteDefinitionHash = definitionHash.digest("hex");
|
|
const expected = config.journeys.map(id => `product-journeys.${config.id}.${id === "cloud-onboarding" ? config.modelProfileId : isAgentJourney(id) ? "configured-agent" : "browser"}.${config.executionEnvironment}.${id}`);
|
|
let reportPersisted = false;
|
|
const failFinalization = (code: string) => {
|
|
for (const result of results) {
|
|
result.status = "failed"; result.cleanup = "failed"; result.failureClass = "cleanup_failure";
|
|
result.error ??= code;
|
|
if (result.productJourney!.outcome === "passed") result.productJourney!.outcome = "failed";
|
|
result.productJourney!.resourceDisposition = "cleanup-failed";
|
|
if (!result.productJourney!.phases.some(phase => phase.phase === "cleanup" && phase.code === code)) {
|
|
result.productJourney!.phases.push({ phase: "cleanup", outcome: "failed", code });
|
|
}
|
|
}
|
|
};
|
|
const persist = async () => {
|
|
try {
|
|
await writeFile(path.join(outputDir, "normalized-results.tmp"), JSON.stringify({ schema: "paperclip.runner-e2e.campaign/v2", campaignId, generatedAt: new Date().toISOString(), expected, source, results }, null, 2), { mode: 0o600 });
|
|
await rename(path.join(outputDir, "normalized-results.tmp"), path.join(outputDir, "normalized-results.json"));
|
|
reportPersisted = true;
|
|
} catch {
|
|
reportPersisted = false;
|
|
failFinalization("evidence_persistence_failed");
|
|
throw new JourneyError("failed", "evidence_persistence_failed");
|
|
}
|
|
};
|
|
const persistSafely = async () => { try { await persist(); return true; } catch { return false; } };
|
|
const renderDashboard = async () => {
|
|
// An uncooperative late renderer can only write its own private staging area.
|
|
// The launcher's normalized results remain authoritative, including failures.
|
|
const staging = path.join(outputDir, `.dashboard-${randomUUID()}`);
|
|
await withinLifecycleDeadline(cleanupWorkDeadlineAt, "report_generation_timeout", async () => {
|
|
await mkdir(staging, { mode: 0o700 });
|
|
await regenerateRunnerDashboard({ bundle: outputDir, historyFile: null, outputDirectory: staging });
|
|
});
|
|
// Publication has no async gap in which a timeout can persist a newer failure.
|
|
if (Date.now() >= cleanupWorkDeadlineAt) throw new JourneyError("failed", "report_generation_timeout");
|
|
renameSync(path.join(staging, "index.html"), path.join(outputDir, "index.html"));
|
|
renameSync(path.join(staging, "dashboard.html"), path.join(outputDir, "dashboard.html"));
|
|
};
|
|
process.once("SIGINT", abort); process.once("SIGTERM", abort);
|
|
const timer = setTimeout(abort, config.limits.durationMs);
|
|
try {
|
|
try {
|
|
browser = await chromium.launch({ headless: !config.headed, ...(process.env.PAPERCLIP_RUNNER_E2E_CHROMIUM_EXECUTABLE ? { executablePath: process.env.PAPERCLIP_RUNNER_E2E_CHROMIUM_EXECUTABLE } : {}) });
|
|
context = await browser.newContext();
|
|
context.setDefaultTimeout(30_000);
|
|
if (controller.signal.aborted) throw new JourneyError("blocked", "campaign_deadline_or_interruption");
|
|
const loaded = config.mode === "managed-self-hosted" ? await import("./local-target.js") : await import(pathToFileURL(path.resolve(config.adapterModule!)).href);
|
|
const provider = loaded.provider as TargetProvider;
|
|
if (provider?.version !== 1 || typeof provider.acquire !== "function") throw new JourneyError("blocked", "unsupported_target_provider_contract");
|
|
session = await provider.acquire({ config, campaignId, deadlineAt, browser: context, signal: controller.signal, repositoryRoot, outputDir, harness: { revision: source.sha, definitionHash: suiteDefinitionHash } });
|
|
await session.authenticate();
|
|
fixtures = journeyFixtures(session, campaignId);
|
|
} catch (error) { prerequisiteFailure = journeyFailure(error); }
|
|
for (const [index, id] of config.journeys.entries()) {
|
|
const startedAt = new Date().toISOString();
|
|
const evidence: JourneyEvidence = { targetId: config.id, outcome: "blocked", startingState: ready?.startingState ?? "recipe", phases: [], checks: [], resourceDisposition: "cleanup-failed", privacy: "private", campaignId, attemptId: `${campaignId}:${id}:1` };
|
|
const profile = id === "cloud-onboarding" ? runnerSuites.flatMap(suite => suite.profiles).find(p => p.id === config.modelProfileId) : undefined;
|
|
const result: RunnerE2EResult = {
|
|
schema: "paperclip.runner-e2e.result/v2", executionId: expected[index]!, suiteId: "product-journeys", suiteDefinitionHash,
|
|
source, attempt: 1, status: "failed", profileId: isAgentJourney(id) ? "configured-agent" : profile?.id ?? "browser", environmentId: config.executionEnvironment, caseId: id,
|
|
provider: profile?.provider ?? null, model: profile?.model ?? null, runtimeMode: profile?.generation ?? "none", startedAt, finishedAt: startedAt, durationMs: 0, cleanup: "not_started", productJourney: evidence,
|
|
};
|
|
results.push(result);
|
|
let phase: JourneyEvidence["phases"][number]["phase"] = "authentication";
|
|
try {
|
|
if (prerequisiteFailure) {
|
|
if (index === 0) throw new JourneyError(prerequisiteFailure.outcome, prerequisiteFailure.code);
|
|
throw new JourneyError("blocked", "prerequisite_journey_failed");
|
|
}
|
|
if (controller.signal.aborted) throw new JourneyError("blocked", "campaign_deadline_or_interruption");
|
|
evidence.phases.push({ phase: "authentication", outcome: "passed" });
|
|
await persist();
|
|
phase = id === "cloud-onboarding" ? "journey" : "setup";
|
|
if (id === "cloud-onboarding") {
|
|
if (!session!.onboard) throw new JourneyError("unsupported", "target_has_no_onboarding");
|
|
ready = await session!.onboard(evidence);
|
|
if (!evidence.artifact) throw new JourneyError("failed", "onboarding_artifact_evidence_missing");
|
|
validateOnboardingArtifact(evidence.artifact);
|
|
fixtures!.provide(ready);
|
|
} else {
|
|
ready = await fixtures!.resolve(productJourneys.find(j => j.id === id)!.requires);
|
|
evidence.phases.push({ phase: "setup", outcome: "passed" });
|
|
evidence.deployment = ready.identity;
|
|
evidence.startingState = ready.startingState;
|
|
await persist();
|
|
phase = "journey";
|
|
if (isAgentJourney(id)) {
|
|
if (!ready.agent) throw new JourneyError("blocked", "configured_agent_required");
|
|
result.profileId = "configured-agent";
|
|
result.provider = ready.agent.provider;
|
|
result.model = ready.agent.model;
|
|
result.runtimeMode = ready.agent.runtimeMode;
|
|
if (id === "ask-question") await askQuestion(ready, session!, campaignId, evidence);
|
|
else if (id === "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;
|
|
}
|