mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 06:42:06 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Basic task journeys must prove that people can answer an agent and receive saved work. > - The question journey asks for a choice, then a typed reference, then a saved note. > - A native choice card can offer a real written-answer control as well as predefined options. > - The old observer rejected that form before a person could type the reference. > - This pull request accepts that explicit native control and keeps exact answer and work evidence. > - The separate text-only and named-tool qualifications keep their stricter contracts. ## Linked Issues or Issue Description Refs #18346 and #18456. The integration inherits the reviewed fixture prerequisite #18387 and Agent Chat recovery #18465 through their actual merged parent. The producer fix in #18456 is separate from this basic journey contract correction. Public duplicate PR and issue searches found no other correction for this form equivalence. **What happened?** The basic question journey requires the reference card to have `answerMode: text`. It rejects a native single-select card even when its canonical form explicitly enables a written answer. This makes the observer test a widget shape in addition to the requested behavior. **Expected behavior** The person must make a real choice and type the complete reference. A native card with an explicit written-answer control can satisfy the second step. Selecting a predefined option cannot satisfy it. The agent must resume the bound work and save both actual answers in its own task document. The document must survive reload. **Steps to reproduce** Use the focused recording and real QuestionForm browser test in this PR. Keep the reference as a single-select native question with one predefined option and `customAnswer.enabled: true`. The previous grader rejects `question-resume.forms`. The previous browser helper rejects `single_select` before it uses the written-answer control. **Paperclip version / commit** Original source base `f2db909393de124caf6a5380a52d4e0e1d621661`. Current integration `a417fd827347ed6f7998774a9de6aee70b76d867` merges exactly `12b0cc630471ee151ec5bc8e41a34f09236d6723` into reviewed question source `faec4ce6d775a34d303d200987e4e84d6dc26caf`. The scheduled failure on `82d15946c5844d3ae41c6ade62fa224053f20a9a` remains a failure. Its saved card had no canonical written-answer capability and would still fail this change. The retained normalized events do not prove whether the original provider flag was omitted or false. **Deployment mode** Attached native task journeys, including QA Cloud. This PR changes the observer and grader. It does not change the app, provider prompt, model, budget, timeouts, cleanup, or runtime deployment. ## What Changed - Accept one provider-bound single-select reference card with explicit canonical custom answers and valid, distinct options. Keep pure-text reference support. - Make the browser select the actual written-answer control and fill the real rich input. Opt in only for the basic question journey. - Keep the exact no-option typed response, immutable card, board actor, native request and continuation, saved agent document, reload, model, environment, checkpoints, and existing one-to-three-run checks. - Reject missing or disabled custom capability, inferred legacy capability, malformed or extra questions, foreign native identities, changed cards, substituted options, and wrong or missing reference text. - Add a real production QuestionForm and TaskChatRichInput Chromium probe. It first selects a predefined option, then proves the helper clears it and submits only the exact custom text. - Document the contract correction and preserve the historical failed attempt and its separate cleanup outcome. Keep necessary-question and named-tool documentation qualification unchanged. ## Verification - Before the observer change, the focused grader regression failed only the new valid-written-reference case; the other 108 cases passed. - Before the browser helper change, real Chromium passed the text control and four rejection controls but failed the valid native written-answer control at the old pure-text assertion. - Initial focused Runner E2E unit selection at `af07f4de`: **256 passed across 9 files**. This includes question/resumption, clarification evidence/flow/collection/deadline, necessary question, documentation, contracts, and report catalog tests. - Initial real Chromium selection at `af07f4de`: **12 passed**. Six new production QuestionForm/control checks and all six existing document-readiness checks pass. No form or rich-input component is mocked. - Historical test-only head `faec4ce6d775a34d303d200987e4e84d6dc26caf` adds a direct canonical-absent legacy `freeText` grader negative and renders the actual legacy presentation in the browser. All four production helper blobs remain identical to the reviewed head. A temporary legacy inference mutant makes the new negative fail. After restoring production source, **179 focused unit tests and all 6 real QuestionForm checks pass**. The six document-readiness specs and their helper are unchanged. - Original [CI run 41205748589](https://github.com/paperclipai/paperclip/actions/runs/41205748589) failed the staged-listen Runner handoff test; that failure is preserved and was not retried. Its Runner source closure was identical to an earlier passing canonical lane. The symptom also recurred on other heads and is tracked independently; no infrastructure attribution or live pass is claimed. - Current integration `a417fd827347ed6f7998774a9de6aee70b76d867` preserves **all 12 question files byte-for-byte** from faec. Every other tracked path is identical to exact merged parent `12b0cc630471ee151ec5bc8e41a34f09236d6723`, which contains actual prerequisite `baf651c8d0122efc93f3ef03e891919eeb666215`. There is no manual conflict resolution or additional production edit. - On that integrated head: **257 focused Vitest tests across 9 files, 206 required Node source/reporter tests, and 12 real Chromium tests pass**. Map/module-boundary checks and catalog discovery pass. The actual `--preview` command passes with a synthetic question target and an intentionally unavailable adapter; it performs no credential read, adapter loading, provisioning or model call. It retains maxRuns3, a 600000ms campaign bound and zero retries. - Exact-head [CI run 41210806950](https://github.com/paperclipai/paperclip/actions/runs/41210806950) passes, including official typecheck, build, all eight browser shards, both Runner Vitest lanes and aggregate verify. All 59 current PR contexts are terminal with no failures. Fresh [Greptile summary](https://github.com/paperclipai/paperclip/pull/18459#issuecomment-6543878408) explicitly reviews `a417fd827347ed6f7998774a9de6aee70b76d867` at 5/5 with no findings; its exact-head check succeeds and all threads are resolved. No fresh deployed journey pass is claimed yet. - Catalog discovery lists the existing question journey. Map checks and module-boundary checks pass. Diff checks pass. - `tsc -p tests/runner-e2e/tsconfig.json` was attempted. The fresh integrated checkout still lacks compiled Runner, plugin SDK, and adapter declarations. The attempted local check reports 344 diagnostic lines from dependency/type closure and zero diagnostics in all 12 question-owned paths. Official exact-head CI establishes the full typecheck/build gates. Full repository build, typecheck, and test suites were not repeated locally for this observer change. - The first broad local report selection lacked the CLI loader and then hit sandbox IPC restrictions. Locked dependency installation and an authorized local IPC run resolved those setup errors; the full selected 256 tests pass. The initial localhost probe also needed the normal local-listen permission. - No fresh QA pass is claimed. The coordinator must qualify the final reviewed runtime and harness together. Prior failed results remain preserved. ## Risks The basic journey accepts one additional form with explicit native provenance. It still rejects the original closed card without a written-answer capability. The separate producer default fix must reach the selected remote provider pack before it can affect a deployed run. Local browser and grader tests do not prove that artifact served or that a model completed the live journey. Existing cleanup and physical-stop obligations remain separate. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, source inspection, browser testing, and code execution. The host does not expose the exact underlying model version 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 available 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 related work above - [x] I have described the issue in-PR following the bug template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket or instance-derived details - [x] I have run the selected tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation - [x] I have considered and documented risks - [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
344 lines
15 KiB
TypeScript
344 lines
15 KiB
TypeScript
import { gradeNecessaryQuestion } from "./necessary-question.js";
|
|
import { necessaryQuestionContract } from "./continuation-cases.js";
|
|
import { validResumeQuestionForm, validResumeReferenceForm } from "./question-resume-scoring.js";
|
|
import { answerResumeQuestionThroughUi } from "./question-resume-ui.js";
|
|
import { gradeLifecycleBaseline, type LifecycleCheckpoint } from "./lifecycle-baseline.js";
|
|
import { lifecycleLiveCase, lifecycleLiveContinuation, gradeLifecycleNarrative } from "./lifecycle-live-cases.js";
|
|
import { prepareLegacyContinuationSkill, prepareContinuationBudget } from "./continuation-fixtures.js";
|
|
import { captureFirstTaskAttachments } from "./first-task-attachments.js";
|
|
import { answerableRuntimeRunIds, isSingleClaudeQuestion } from "./runtime-question-readiness.js";
|
|
import { expect, type Page } from "@playwright/test";
|
|
import path from "node:path";
|
|
import { continuationAnswerCommitted, continuationInitialReady, continuationCheckpointReady } from "./continuation-readiness.js";
|
|
import { captureLoadedContinuation } from "./continuation-screenshot.js";
|
|
import { seedContinuationContext } from "./continuation-workspace.js";
|
|
import { pollUntil, type RunnerApi } from "./api.js";
|
|
import {
|
|
chatQuestionPresentation,
|
|
sendChatMessage,
|
|
collectChatRunEvidence,
|
|
type ChatRun,
|
|
} from "./chat-flow.js";
|
|
import {
|
|
continuationScenario,
|
|
continuationScreenshotFile,
|
|
} from "./continuation-cases.js";
|
|
import {
|
|
gradeContinuation,
|
|
isContinuationPlan,
|
|
type ContinuationCheckpoint,
|
|
} from "./continuation-scoring.js";
|
|
import { createTaskThroughUi } from "./user-actions.js";
|
|
import type { LiveFixtureValues } from "./live-fixtures.js";
|
|
import type { MatrixExecution } from "./types.js";
|
|
type Row = Record<string, any>;
|
|
|
|
export async function runContinuationFlow(input: {
|
|
page: Page;
|
|
api: RunnerApi;
|
|
fixtures: LiveFixtureValues;
|
|
execution: MatrixExecution;
|
|
nonce: string;
|
|
secrets: readonly string[];
|
|
workspacePath: string;
|
|
deadlineAt: number;
|
|
restart(): Promise<void>;
|
|
observe(
|
|
issue: any,
|
|
runs: any[],
|
|
checks: ReturnType<typeof gradeContinuation>,
|
|
): void;
|
|
capture(id: string, label: string, file: string): Promise<void>;
|
|
evidence(name: string, value: unknown): Promise<void>;
|
|
}) {
|
|
const { page, api, fixtures, execution } = input;
|
|
const lifecycleProbe = lifecycleLiveCase(execution.task.id);
|
|
const scenario = lifecycleProbe
|
|
? lifecycleLiveContinuation(execution.task.id, input.nonce)
|
|
: continuationScenario(execution.task.id, input.nonce);
|
|
const checkpoints: LifecycleCheckpoint[] = [];
|
|
let budgetGuard: Awaited<ReturnType<typeof prepareContinuationBudget>> | undefined;
|
|
let issue: Row | undefined;
|
|
let runs: Row[] = [];
|
|
let checks: ReturnType<typeof gradeContinuation> = [];
|
|
const tasksPath = `/api/companies/${fixtures.company.id}/issues?limit=100`;
|
|
async function refresh() {
|
|
issue = await api.get<Row>(`/api/issues/${issue!.id}`);
|
|
const listed = await api.get<Row[]>(
|
|
`/api/companies/${fixtures.company.id}/heartbeat-runs?limit=100`,
|
|
);
|
|
runs = await Promise.all(
|
|
listed.map((r) => api.get<Row>(`/api/heartbeat-runs/${r.id}`)),
|
|
);
|
|
input.observe(issue, runs, checks);
|
|
return { issue, runs };
|
|
}
|
|
let pausedRuntimeRunIds = new Set<string>();
|
|
async function settle(prior: Set<string>, requireQuestion = false, answeredInteractionId?: string) {
|
|
let stable = "";
|
|
const previousPaused = pausedRuntimeRunIds;
|
|
await pollUntil({
|
|
label: `continuation ${scenario.id} settled`,
|
|
deadlineAt: input.deadlineAt,
|
|
intervalMs: 1000,
|
|
load: async () => ({
|
|
...await refresh(),
|
|
interactions: await api.get<Row[]>(`/api/issues/${issue!.id}/interactions`),
|
|
}),
|
|
accept: (state) => {
|
|
const paused = answerableRuntimeRunIds(state.interactions);
|
|
const idle =
|
|
continuationAnswerCommitted(state.interactions, answeredInteractionId) &&
|
|
continuationCheckpointReady({
|
|
issue: { status: state.issue.status, executionRunId: state.issue.executionRunId },
|
|
runs: state.runs.map(r => ({ id: r.id, status: r.status, runtimeMode: r.runtimeMode })),
|
|
interactions: state.interactions,
|
|
}) &&
|
|
state.runs.some((r) => !prior.has(r.id) || previousPaused.has(r.id)) &&
|
|
state.runs.every((r) => ["succeeded", "failed", "timed_out", "cancelled"].includes(r.status) ||
|
|
(r.status === "running" && paused.has(r.id))) &&
|
|
!state.issue.scheduledRetry &&
|
|
!state.issue.activeRecoveryAction &&
|
|
(!requireQuestion || continuationInitialReady(state.interactions));
|
|
const key = idle
|
|
? JSON.stringify([state.issue.status, state.issue.executionRunId,
|
|
state.runs.map(r => [r.id, r.status]), state.interactions.map(i => [i.id, i.status])])
|
|
: "";
|
|
const ready = !!key && key === stable;
|
|
stable = key;
|
|
if (ready) pausedRuntimeRunIds = paused;
|
|
return ready;
|
|
},
|
|
reject: (state) =>
|
|
state.runs.length > (scenario.id === "necessary-free-text" ? 2 : scenario.id === "question-answer-resume" ? 3 : 12)
|
|
? "Bounded continuation run count exceeded"
|
|
: state.runs.some((r) =>
|
|
["failed", "timed_out", "cancelled"].includes(r.status),
|
|
)
|
|
? `Provider execution failed: ${state.runs
|
|
.filter((r) => r.status !== "succeeded")
|
|
.map(
|
|
(r) => `${r.id} ${r.errorCode ?? r.status}: ${r.error ?? ""}`,
|
|
)
|
|
.join("; ")}`
|
|
: undefined,
|
|
});
|
|
}
|
|
async function open() {
|
|
await page.goto(
|
|
`/${fixtures.company.issuePrefix}/issues/${issue!.identifier ?? issue!.id}`,
|
|
{ waitUntil: "domcontentloaded" },
|
|
);
|
|
}
|
|
async function snapshot(phase: ContinuationCheckpoint["phase"]) {
|
|
const [tasks, summaries, comments, interactions, attachments, activity] =
|
|
await Promise.all([
|
|
api.get<Row[]>(tasksPath),
|
|
api.get<Row[]>(`/api/issues/${issue!.id}/documents`),
|
|
api.get<Row[]>(`/api/issues/${issue!.id}/comments?order=asc`),
|
|
api.get<Row[]>(`/api/issues/${issue!.id}/interactions`),
|
|
captureFirstTaskAttachments(api, [{ id: issue!.id }], input.secrets),
|
|
api.get<Row[]>(`/api/issues/${issue!.id}/activity`),
|
|
]);
|
|
const documents = await Promise.all(
|
|
summaries.map((d) =>
|
|
api.get<Row>(
|
|
`/api/issues/${issue!.id}/documents/${encodeURIComponent(d.key)}`,
|
|
),
|
|
),
|
|
);
|
|
checkpoints.push({
|
|
phase,
|
|
lifecycle: {
|
|
executionRunId: issue!.executionRunId ?? null,
|
|
scheduledRetry: issue!.scheduledRetry ?? null,
|
|
activeRecoveryAction: issue!.activeRecoveryAction ?? null,
|
|
monitorNextCheckAt: issue!.monitorNextCheckAt ?? null,
|
|
},
|
|
issue: issue as ContinuationCheckpoint["issue"],
|
|
children: tasks.filter(
|
|
(t) => t.parentId === issue!.id,
|
|
) as ContinuationCheckpoint["children"],
|
|
documents: documents as ContinuationCheckpoint["documents"],
|
|
comments,
|
|
activity,
|
|
interactions,
|
|
attachments,
|
|
runs: [...runs] as ContinuationCheckpoint["runs"],
|
|
});
|
|
await input.evidence("continuation.json", {
|
|
...scenario,
|
|
budgetGuard,
|
|
checkpoints,
|
|
checks,
|
|
});
|
|
await input.evidence("api-state.json", checkpoints.at(-1));
|
|
await open();
|
|
if (scenario.id === "necessary-free-text") {
|
|
if (phase === "initial") {
|
|
const contract = necessaryQuestionContract();
|
|
await expect(page.getByText(contract.prompt, { exact: true }).last()).toBeVisible({ timeout: 30_000 });
|
|
await expect(page.getByText(contract.context, { exact: true }).last()).toBeVisible({ timeout: 30_000 });
|
|
const question = interactions.find(interaction => interaction.kind === "ask_user_questions" && interaction.status === "pending");
|
|
checkpoints.at(-1)!.questionPresentation = { interactionId: question?.id ?? "", promptVisible: true, contextVisible: true };
|
|
} else if (phase === "final") {
|
|
expect(documents).toHaveLength(1);
|
|
const document = documents[0]!;
|
|
expect(document.latestRevisionId).toBeTruthy();
|
|
const href = `/${encodeURIComponent(fixtures.company.issuePrefix!)}/issues/${encodeURIComponent(issue!.identifier ?? issue!.id)}#document-${encodeURIComponent(document.key)}`;
|
|
const url = new URL(href, page.url()).href;
|
|
const link = page.getByTestId("task-chat-agent-bubble").locator(`a[href=${JSON.stringify(href)}]`).last();
|
|
await expect(link).toBeVisible({ timeout: 30_000 });
|
|
await link.click();
|
|
await expect(page).toHaveURL(url);
|
|
const target = page.locator([
|
|
`[id=${JSON.stringify(`document-${document.key}`)}]:visible`,
|
|
`[id=${JSON.stringify(`side-panel-content-document:${document.key}`)}]:visible`,
|
|
].join(", "));
|
|
await expect(target).toHaveCount(1);
|
|
await expect(target).toBeVisible();
|
|
const visibleBody = `Booking confirmed: ${scenario.answer}`;
|
|
await expect(target).toContainText(visibleBody);
|
|
checkpoints.at(-1)!.outputDocument = { issueId: issue!.id, key: document.key, revisionId: document.latestRevisionId, href, visibleBody };
|
|
}
|
|
}
|
|
await captureLoadedContinuation(page, String(issue!.title), () => input.capture(
|
|
phase,
|
|
`Continuation: ${phase}`,
|
|
continuationScreenshotFile(phase),
|
|
));
|
|
}
|
|
async function answer(choice?: string) {
|
|
const interactions = await api.get<Row[]>(
|
|
`/api/issues/${issue!.id}/interactions`,
|
|
);
|
|
const questions = interactions.filter(
|
|
(i) => i.kind === "ask_user_questions" && i.status === "pending",
|
|
);
|
|
expect(questions, "one real question must be shown").toHaveLength(1);
|
|
const set = chatQuestionPresentation(questions[0].payload);
|
|
if (scenario.id === "necessary-free-text") {
|
|
expect(validResumeQuestionForm(checkpoints[0]!, questions[0], false), "a bound text-only question").toBe(true);
|
|
} else if (scenario.id === "question-answer-resume") {
|
|
expect(choice ? validResumeQuestionForm(checkpoints.at(-1)!, questions[0], true)
|
|
: validResumeReferenceForm(checkpoints.at(-1)!, questions[0]), "a verified native question path and the requested form").toBe(true);
|
|
} else if (scenario.id === "provider-question-bridge") {
|
|
expect(isSingleClaudeQuestion(set.questions), "one choice question with only the optional provider Other field").toBe(true);
|
|
} else expect(set.questions, "ask only the requested next question").toHaveLength(1);
|
|
const before = new Set(runs.map((r) => r.id));
|
|
await answerResumeQuestionThroughUi(page, questions[0], choice ? { choice } : {
|
|
text: scenario.answer, allowProviderCustomAnswer: scenario.id === "question-answer-resume",
|
|
});
|
|
await settle(before, false, questions[0].id);
|
|
}
|
|
async function reply(body: string) {
|
|
const before = new Set(runs.map((r) => r.id));
|
|
await sendChatMessage(page, body);
|
|
await settle(before);
|
|
}
|
|
function assertWaiting() {
|
|
const c = checkpoints.at(-1)!;
|
|
expect(
|
|
c.documents.filter((d) => !isContinuationPlan(d, c)),
|
|
"no deliverable before authorization",
|
|
).toHaveLength(0);
|
|
expect(c.attachments, "no attachment before authorization").toHaveLength(0);
|
|
expect(c.issue.status, "waiting is not complete").not.toBe("done");
|
|
}
|
|
try {
|
|
budgetGuard = await prepareContinuationBudget(api, fixtures.company.id, fixtures.agent.id);
|
|
if (execution.profile.generation === "legacy") await prepareLegacyContinuationSkill(api, fixtures.company.id, fixtures.agent.id);
|
|
await api.patch("/api/instance/settings/experimental", {
|
|
enableClassicTaskInterface: false,
|
|
});
|
|
const createdTask = await createTaskThroughUi({
|
|
page,
|
|
issuePrefix: fixtures.company.issuePrefix!,
|
|
agentName: fixtures.agent.name,
|
|
title: execution.task.buildTitle(input.nonce),
|
|
prompt: scenario.prompt,
|
|
workMode: "standard",
|
|
});
|
|
issue = await pollUntil({
|
|
label: "continuation task created",
|
|
deadlineAt: input.deadlineAt,
|
|
load: async () =>
|
|
(await api.get<Row[]>(tasksPath)).find(
|
|
(t) => t.id === createdTask.issueId,
|
|
),
|
|
accept: Boolean,
|
|
});
|
|
if (!issue) throw new Error("Missing continuation task");
|
|
await settle(new Set(), scenario.id !== "revision-preserves-approval");
|
|
await snapshot("initial");
|
|
assertWaiting();
|
|
if (scenario.id === "untrusted-evidence") {
|
|
const parentRun = runs.find((r) => r.contextSnapshot?.issueId === issue!.id);
|
|
await seedContinuationContext({
|
|
isolatedRoot: path.dirname(input.workspacePath),
|
|
recordedCwd: parentRun?.contextSnapshot?.paperclipWorkspace?.cwd,
|
|
body: scenario.context,
|
|
});
|
|
}
|
|
if (scenario.id === "completed-action-resume") {
|
|
await input.restart();
|
|
await open();
|
|
}
|
|
if (["question-tool-documentation", "question-answer-resume"].includes(scenario.id)) {
|
|
await answer("Afternoon");
|
|
await snapshot("answered");
|
|
assertWaiting();
|
|
await answer();
|
|
} else if (scenario.id === "provider-question-bridge") await answer(scenario.marker);
|
|
else if (scenario.id === "revision-preserves-approval")
|
|
await reply(scenario.revision);
|
|
else await answer();
|
|
if (scenario.gate) {
|
|
await snapshot(
|
|
scenario.id === "revision-preserves-approval" ? "revised" : "answered",
|
|
);
|
|
assertWaiting();
|
|
await reply(scenario.approval);
|
|
}
|
|
await snapshot("final");
|
|
} finally {
|
|
checks = gradeContinuation({
|
|
...scenario,
|
|
checkpoints,
|
|
runtimeMode: execution.profile.expectedRuntimeMode,
|
|
});
|
|
checks.push(...gradeLifecycleBaseline(checkpoints));
|
|
if (scenario.id === "necessary-free-text") checks.push(...gradeNecessaryQuestion(checkpoints, scenario.answer));
|
|
if (lifecycleProbe) checks.push(gradeLifecycleNarrative({
|
|
narrative: lifecycleProbe.narrative,
|
|
agentId: fixtures.agent.id,
|
|
initial: checkpoints.find(c => c.phase === "initial"),
|
|
}));
|
|
if (issue) input.observe(issue, runs, checks);
|
|
await input.evidence("continuation.json", {
|
|
...scenario,
|
|
budgetGuard,
|
|
checkpoints,
|
|
checks,
|
|
});
|
|
await input.evidence(
|
|
"continuation-run-evidence.json",
|
|
await Promise.all(
|
|
runs.map(async (r) => {
|
|
try {
|
|
return await collectChatRunEvidence(api, r as ChatRun);
|
|
} catch (error) {
|
|
return { runId: r.id, evidenceCaptureError: String(error) };
|
|
}
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
const failures = checks.filter((c) => !c.passed);
|
|
if (failures.length)
|
|
throw new Error(
|
|
`Continuation matcher failures: ${failures.map((c) => `${c.id}: ${c.detail}`).join("; ")}`,
|
|
);
|
|
return { issue: issue!, runs, checks };
|
|
}
|