mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-05 03:51:54 +01:00
## Thinking Path > - Paperclip keeps agent work inside company boundaries. > - Cloud users switch workspaces through the tenant account menu. > - A company-bound credential must work inside its permitted company. > - A missing resource cannot prove an access denial. > - This journey uses real controls in two registered test workspaces. > - It checks reads and task creation in both directions and revokes both keys. ## Linked Issues or Issue Description Refs: #18728, #18729, #18652. **What existing behavior does this improve?** Product E2E coverage for Cloud workspace selection and company-bound agent API access. **Current behavior** The journey catalog lacks a two-workspace boundary check with real allowed and denied controls. **Proposed behavior** Use the visible Cloud menu to switch workspaces and return. Read a real task and create one task in each credential's own company. Require real foreign reads and the same task-creation operation to return 401 or 403 in both directions. Verify that no foreign task was created and that protected content was not returned. Revoke both credentials and require their earlier allowed reads to return 401. ## What Changed - Add a browser journey and a narrow target-provider fixture contract. - Require independent owner reads, exact agent authorship, zero-to-one task creation, stable run history, no unexpected work and no foreign creation. - Validate observed workspace entry links against the exact registered targets. - Add wrong, missing, leaked, foreign and invalid-entry evidence controls. Saved passes reject collected runs or nonempty model usage. - Register all seven required checks, definition-hash inputs, feature associations. The feature-map documents are unchanged, as requested. - Base the final tree on verified repository master `7b8aa60b6d79b6cd9e4d46ca1369515c33e2d5d4` plus only 15 access journey and catalog files. Preserve all other master files and modes. The held completion journey is outside this PR's diff. ## Verification - 851 catalog, journey, completion-helper, artifact and browser-action support tests passed across 40 files. - E2E TypeScript, credential-free discovery, feature-map validation, full workspace recursive typecheck, full build and `git diff --check` passed. - The broad local `pnpm test:run` attempt stopped in chat integration shard 1 before any assertion. Embedded Postgres initialization exited with code null. No chat tests or later unit shards ran. The startup cause is unresolved, the failure is retained, and the command was not retried. The database/chat/test-runner source is unchanged from the fixed master snapshot. The separate hosted full CI result is recorded below. - The first local E2E typecheck read stale compiled plugin SDK declarations after the merge. The SDK source already declared the field. Rebuilding its declarations cleared the diagnostic without a source edit. - The complete tree comparison proves exactly 17 changed paths before the documentation removal against the fixed master snapshot. Every other master file and mode is preserved. All four access flow, contract and test blobs match the previously passing source `fab9a1fb2fefb41a7248e4fc1aa239d77455f5e2` byte for byte. - The historical staging attempt on source `fab9a1fb2fefb41a7248e4fc1aa239d77455f5e2`, the earlier Core42 deployment and the earlier adapter passed all seven checks: owner resource controls, visible Cloud switch and return, scoped data, both restricted-agent positive controls, cross-workspace read/task-creation denials, credential revocation and no agent work. Both credentials were revoked, owned credential agents had terminal receipts, and evidence cleanup was complete. No agent or model work was recorded. - The earlier attempt failed before journey checks while reading the borrowed fixture agent's historical details. That failed result remains preserved. The provider then supplied a separately owned primary identity. The public history assertions remain unchanged. - The strict root catalog expectation first failed because it omitted the new access case. Adding only that expected entry made the test pass. The ordered list and default-selection assertions remain unchanged. - The first combined-head full CI run failed two unchanged attention-retention tests at their original 15-second limits. The catalog fix does not change those tests or their timing. The initial failures are retained, their cause is unresolved, and that failed run was not retried. - Normal full CI passed on the new commit in [run 41595755161](https://github.com/paperclipai/paperclip/actions/runs/41595755161), attempt 1: 48 successful jobs and one intended skip. The two unchanged attention-retention cases passed at about 12 seconds; the earlier timeout cause is still unknown. The fresh review on commit `4c0ea682444738ad6fa17017a6afd25a49037559` is 5/5 with no open findings. The fresh staging result is recorded below. This PR changes no daily plan, private configuration, model or live workspace. - Fresh staging qualification on commit `4c0ea682444738ad6fa17017a6afd25a49037559` passed all seven checks and all five phases. Both ordinary same-workspace task creations returned 201. Both cross-workspace task creations returned 401. Both credential agents reached terminal state. Both keys were revoked, and both revoked-key reads returned 401. The manifest records complete cleanup and no active operations. No model or task run was recorded. All three workflow jobs passed. - The coordinator verified TestOps case 719 and result 3761 as passed, then closed launch 1987. Its report shows model none and the current qualification run. The automatic Slack report recorded one pass and zero failures. - The original normalized result was passed. The first evidence collector left its own qualification false because its allowlist omitted the standard `dashboard.html` artifact. The adapter already declared that file, and the public launcher generated it. An exact-object read matched the 118,791-byte object, ETag and SHA256 archive receipt. This completed the three-artifact inventory. The original result, collector proof and seal remain unchanged. No HTML was executed or rendered, and no test was rerun or regraded. | Fresh staging provenance | Verified value | | --- | --- | | Public commit | `4c0ea682444738ad6fa17017a6afd25a49037559` | | Qualification workflow | `41597774599`, attempt 1 | | Serving Core commit | `09162fe893b464dd2ccdafd14bd3ef1611202a86` | | Target adapter commit | `7367cf6fd0ee1cf52c2651a8a6671e087728a9c1` | | Actual workflow bootstrap commit | `55351ffba2704a4e13c1ead3e50665acd51aef87` | | Exact 117-file QA contract SHA256 | `14d95c29059c82563ab0e1d65a01aa4ac6c4f60f77a236803cf06ad5d9e7d27b` | | Provider-pack configuration version | `9` | - At the user’s request, follow-up commit `7e0dbc4bdba8a25ff6486648b0ca0d0db2616a4c` removes both feature-map additions. The final diff contains 15 paths and no `maps/feature-map/` changes. An entire-tree comparison against the QA-passed commit proves these two Markdown files are the only changes; every executable file, test, fixture, registration and file mode is unchanged. `node scripts/check-maps.mjs` and `git diff --check` pass after the removal. The live QA run above was not repeated for this documentation-only change. Fresh current-head full CI passed in [run 41633385961](https://github.com/paperclipai/paperclip/actions/runs/41633385961), attempt 1. The current-head Greptile review is 5/5 with zero unresolved threads. The normal merge preserves every newer master file outside the 15 PR paths and keeps all 15 PR files byte-for-byte intact. ## Risks The private target provider must register two exact test workspaces and preserve their separate ownership and cleanup. The case verifies company-bound agent credentials. It does not certify human membership roles. It tests task creation because ordinary agent task edits require a real execution context. No execution context is forged and no product authorization guard changes. The local broad unit run could not start embedded Postgres, so it does not establish a full local unit pass. The earlier hosted attention-retention timeouts also remain unexplained. Passing a later run would not prove the source of those timeouts. The four case files retain their historical passing bytes. The fresh qualification verifies the executable test code retained in this PR against the deployed staging stack. Daily admission remains a coordinator action after the normal source merge. The case does not cover future deployments without another run. ## Model Used OpenAI GPT-6 through Codex with reasoning and code execution. The runtime does not expose a more specific model identifier or context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Codex <noreply@openai.com>
350 lines
26 KiB
TypeScript
350 lines
26 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 { runRepositoryContinuation } from "./repository-continuation-flow.js";
|
|
import { runScheduledRoutine } from "./routine-schedule-flow.js";
|
|
import { runInputFile } from "./input-file-flow.js";
|
|
import { companyAccessIsolation } from "./company-access-flow.js";
|
|
import { inboxTriage } from "./inbox-triage-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, isProductScreenshotUrl } 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 ["observation-read.ts", "../connection-config.ts", "catalog.ts", "company-access-flow.ts", "company-access-contract.ts", "repository-continuation-flow.ts", "repository-continuation-contract.ts", "repository-continuation-evidence.ts", "../registered-artifact.ts", "../user-actions.ts", "../connection-target.ts", "../../../packages/shared/src/project-mentions.ts", "routine-schedule-flow.ts", "routine-schedule-evidence.ts", "input-file-flow.ts", "input-file-evidence.ts", "inbox-triage-flow.ts", "inbox-triage-evidence.ts", "inbox-triage-fixture.ts", "inbox-author-contract.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 === "repository-continuation") await runRepositoryContinuation(ready, session!, campaignId, evidence,
|
|
{ deadlineAt, directory: path.join(outputDir, "private-stories", id) });
|
|
else if (id === "routine-scheduled-run") await runScheduledRoutine(ready, session!, campaignId, evidence,
|
|
{ deadlineAt, directory: path.join(outputDir, "private-stories", id) });
|
|
else if (id === "file-input-answer") await runInputFile(ready, session!, campaignId, evidence,
|
|
{ deadlineAt, directory: path.join(outputDir, "private-stories", id) });
|
|
else 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 === "company-access-isolation") await companyAccessIsolation(ready, session!, campaignId, evidence);
|
|
else if (id === "inbox-triage") await inboxTriage(ready, session!, campaignId, evidence);
|
|
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 {
|
|
if (isProductScreenshotUrl(ready.page.url(), ready.api.origin)) {
|
|
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;
|
|
}
|