mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
## Thinking Path > - Paperclip manages agents and the work they perform. > - Product E2E checks verify real user paths through those agents. > - A recorded GitHub review-bot walkthrough had no repeatable registered case. > - App setup, automatic triggering, publication, and task completion need separate evidence. > - This pull request adds an explicit attended journey to the existing catalog and report tools. > - Reusable setup profiles and independent assertions preserve the same outcome across compatible targets. ## Linked Issues or Issue Description Refs: #17262. This case remains an explicit attended attached-target flow. It does not replace the shared deployment-target or paid-operations framework. **What existing behavior does this improve?** The Product E2E catalog can now select and retain evidence for GitHub review-bot setup and its first automatic review. **Current behavior** Recorded walkthroughs cannot be rerun as a fixed registered case. A published review alone can hide a failed run or unfinished task. **Proposed behavior** Create and install an owned App, save automatic new-PR settings, create a defective PR, and require correlated review delivery, an inline finding, a consistent check, a successful run, and a Done task. Retain failures and confirm owned-resource cleanup. **Reason and benefit** The case makes a useful external integration reproducible without treating setup, unit checks, or report rendering as proof of a completed live journey. ## What Changed - Add two explicit attended cells for an existing configured agent in local or Daytona execution. - Declare hosting, account, connection, and credential prerequisites through private setup bindings. - Add fixed browser controls, ownership checks, independent grading, and cleanup. - Package declared screenshots through the existing sanitizer, manifest, validator, and dashboard. - Reject operations-managed execution until budgeted routing is qualified. - Document hosted QA credential setup and evidence limits. ## Verification - Focused catalog, oracle, report, connection, and ownership checks: 127 tests passed. - Inherited-environment preflight regression failed against the previous commit and passed after the fix. Actual-run environment verification remains required. - Harness TypeScript check and full workspace typecheck passed. Full build passed. The local full-test attempt was interrupted after repeated embedded PostgreSQL startup failures; its exit code of zero does not establish a pass; Exact-head Linux CI passed the full test, typecheck, build, Runner, serialized, browser, and canary gates on `abbddeade848edefc8de75f11cc9008b2cd52764`. - A real hosted attempt published a rated review and inline finding. It failed when its native session timed out before the task reached Done. Cleanup passed. Full live qualification remains open. - Credential scan found no known credential values in the authored files or retained report corpus. ## Risks - The full case creates external test resources and uses the configured provider. It requires an explicit selection and current task authority. It is excluded from default and CI matrices. - Time and run limits are not a hard dollar cap. Missing dollar cost stays unknown. Fixture setup does not weaken budget policy. - Fresh personal OAuth, unattended authentication, runtime skill-read proof, and cross-host qualification are separate claims. - The selected live fixture needs environment/lifecycle recovery before it can adopt a longer provider deadline. ## Model Used OpenAI GPT-6 family through Codex, with reasoning, code execution, and browser tools. The exact deployment model ID and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used, including the 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 them above - [x] I have described the issue in-PR following the enhancement template - [x] I have not referenced internal or instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket ID - [x] I have run focused 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 findings - [x] I will address all Greptile and reviewer comments before merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
240 lines
7.5 KiB
TypeScript
240 lines
7.5 KiB
TypeScript
import { runnerMatrix } from "./catalog.js";
|
|
import type { MatrixExecution, MatrixJob } from "./types.js";
|
|
|
|
export interface RunnerSelectorOptions {
|
|
all: boolean;
|
|
list: boolean;
|
|
matrixJson: boolean;
|
|
installedStartupOnly?: boolean;
|
|
ids: string[];
|
|
suites: string[];
|
|
groups: string[];
|
|
profiles: string[];
|
|
environments: string[];
|
|
cases: string[];
|
|
headed: boolean;
|
|
ui: boolean;
|
|
debug: boolean;
|
|
maxParallel: number;
|
|
maxAutomaticRetries: number;
|
|
connectionConfig?: string;
|
|
githubReviewConfig?: string;
|
|
provisionGitHubRepository?: boolean;
|
|
}
|
|
|
|
export class RunnerSelectorError extends Error {}
|
|
|
|
function valueFor(args: string[], index: number, flag: string): string {
|
|
const value = args[index + 1];
|
|
if (!value || value.startsWith("--"))
|
|
throw new RunnerSelectorError(`${flag} requires a value`);
|
|
return value;
|
|
}
|
|
|
|
export function parseRunnerSelectors(
|
|
rawArgs: readonly string[],
|
|
): RunnerSelectorOptions {
|
|
const args = rawArgs.filter((value) => value !== "--");
|
|
const options: RunnerSelectorOptions = {
|
|
all: false,
|
|
list: false,
|
|
matrixJson: false,
|
|
ids: [],
|
|
suites: [],
|
|
groups: [],
|
|
profiles: [],
|
|
environments: [],
|
|
cases: [],
|
|
headed: false,
|
|
ui: false,
|
|
debug: false,
|
|
maxParallel: Number(process.env.PAPERCLIP_E2E_MAX_PARALLEL ?? "1"),
|
|
maxAutomaticRetries: 1,
|
|
};
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const flag = args[index];
|
|
if (flag === "--all") options.all = true;
|
|
else if (flag === "--list") options.list = true;
|
|
else if (flag === "--matrix-json") options.matrixJson = true;
|
|
else if (flag === "--installed-startup-only") options.installedStartupOnly = true;
|
|
else if (flag === "--headed") options.headed = true;
|
|
else if (flag === "--ui") options.ui = true;
|
|
else if (flag === "--debug") options.debug = true;
|
|
else if (flag === "--connection-config") {
|
|
options.connectionConfig = valueFor(args, index, flag);
|
|
index += 1;
|
|
}
|
|
else if (flag === "--github-review-config") {
|
|
options.githubReviewConfig = valueFor(args, index, flag);
|
|
index += 1;
|
|
}
|
|
else if (flag === "--provision-github-repository") options.provisionGitHubRepository = true;
|
|
else if (flag === "--max-parallel") {
|
|
const value = valueFor(args, index, flag);
|
|
index += 1;
|
|
options.maxParallel = Number(value);
|
|
} else if (flag === "--max-automatic-retries") {
|
|
const value = valueFor(args, index, flag);
|
|
index += 1;
|
|
options.maxAutomaticRetries = Number(value);
|
|
} else if (
|
|
[
|
|
"--id",
|
|
"--suite",
|
|
"--group",
|
|
"--profile",
|
|
"--environment",
|
|
"--case",
|
|
].includes(flag)
|
|
) {
|
|
const value = valueFor(args, index, flag);
|
|
index += 1;
|
|
if (flag === "--id") options.ids.push(value);
|
|
else if (flag === "--suite") options.suites.push(value);
|
|
else if (flag === "--group") options.groups.push(value);
|
|
else if (flag === "--profile") options.profiles.push(value);
|
|
else if (flag === "--environment") options.environments.push(value);
|
|
else options.cases.push(value);
|
|
} else {
|
|
throw new RunnerSelectorError(`Unknown runner E2E argument: ${flag}`);
|
|
}
|
|
}
|
|
|
|
if (!Number.isInteger(options.maxParallel) || options.maxParallel < 1) {
|
|
throw new RunnerSelectorError("--max-parallel must be a positive integer");
|
|
}
|
|
if (![0, 1].includes(options.maxAutomaticRetries)) {
|
|
throw new RunnerSelectorError(
|
|
"--max-automatic-retries must be 0 or 1",
|
|
);
|
|
}
|
|
|
|
const hasDimensions =
|
|
options.suites.length +
|
|
options.groups.length +
|
|
options.profiles.length +
|
|
options.environments.length +
|
|
options.cases.length >
|
|
0;
|
|
if (options.ids.length > 0 && (hasDimensions || options.all)) {
|
|
throw new RunnerSelectorError(
|
|
"--id is exclusive with --all and dimension filters",
|
|
);
|
|
}
|
|
if (options.all && hasDimensions) {
|
|
throw new RunnerSelectorError("--all is exclusive with dimension filters");
|
|
}
|
|
if (
|
|
!options.list &&
|
|
!options.matrixJson &&
|
|
!options.all &&
|
|
options.ids.length === 0 &&
|
|
!hasDimensions
|
|
) {
|
|
throw new RunnerSelectorError(
|
|
"Billable runner E2E runs require --all or an explicit selector",
|
|
);
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function assertKnown(
|
|
label: string,
|
|
selected: readonly string[],
|
|
known: Set<string>,
|
|
) {
|
|
const unknown = selected.filter((value) => !known.has(value));
|
|
if (unknown.length > 0)
|
|
throw new RunnerSelectorError(`Unknown ${label}: ${unknown.join(", ")}`);
|
|
}
|
|
|
|
export function selectRunnerExecutions(
|
|
options: RunnerSelectorOptions,
|
|
matrix: readonly MatrixExecution[] = runnerMatrix,
|
|
): MatrixExecution[] {
|
|
const knownGroups = new Set(matrix.flatMap((execution) => execution.groups));
|
|
assertKnown(
|
|
"suite",
|
|
options.suites,
|
|
new Set(matrix.map((execution) => execution.suite.id)),
|
|
);
|
|
assertKnown("group", options.groups, knownGroups);
|
|
assertKnown(
|
|
"profile",
|
|
options.profiles,
|
|
new Set(matrix.map((execution) => execution.profile.id)),
|
|
);
|
|
assertKnown(
|
|
"environment",
|
|
options.environments,
|
|
new Set(matrix.map((execution) => execution.environment.id)),
|
|
);
|
|
assertKnown(
|
|
"case",
|
|
options.cases,
|
|
new Set(matrix.map((execution) => execution.task.id)),
|
|
);
|
|
assertKnown(
|
|
"execution id",
|
|
options.ids,
|
|
new Set(matrix.map((execution) => execution.id)),
|
|
);
|
|
|
|
const selected = matrix.filter((execution) => {
|
|
if (options.ids.length > 0) return options.ids.includes(execution.id);
|
|
if (execution.suite.manualOnly && !options.suites.includes(execution.suite.id) && !options.list) return false;
|
|
if (
|
|
options.all ||
|
|
(options.list &&
|
|
options.groups.length === 0 &&
|
|
options.suites.length === 0 &&
|
|
options.profiles.length === 0 &&
|
|
options.environments.length === 0 &&
|
|
options.cases.length === 0)
|
|
)
|
|
return true;
|
|
return (
|
|
(options.suites.length === 0 ||
|
|
options.suites.includes(execution.suite.id)) &&
|
|
options.groups.every((group) => execution.groups.includes(group)) &&
|
|
(options.profiles.length === 0 ||
|
|
options.profiles.includes(execution.profile.id)) &&
|
|
(options.environments.length === 0 ||
|
|
options.environments.includes(execution.environment.id)) &&
|
|
(options.cases.length === 0 || options.cases.includes(execution.task.id))
|
|
);
|
|
});
|
|
if (selected.length === 0)
|
|
throw new RunnerSelectorError(
|
|
"Runner E2E selectors matched zero executions",
|
|
);
|
|
return selected;
|
|
}
|
|
|
|
export function buildMatrixJobs(
|
|
executions: readonly MatrixExecution[],
|
|
): MatrixJob[] {
|
|
if (executions.some(e => ["provider_connection", "github_review_journey"].includes(e.task.flow))) throw new RunnerSelectorError("Attended connection journeys require explicit private setup profiles; they are not admitted to paid CI matrix jobs yet.");
|
|
return executions
|
|
.map((execution) => ({
|
|
executionId: execution.id,
|
|
suiteId: execution.suite.id,
|
|
profileId: execution.profile.id,
|
|
credentialName: execution.profile.credential,
|
|
environmentId: execution.environment.id,
|
|
caseId: execution.task.id,
|
|
timeoutMinutes: Math.max(
|
|
execution.environment.id === "daytona" ? 40 : 25,
|
|
Math.ceil(
|
|
(2 *
|
|
(execution.task.attemptTimeoutMs[execution.environment.id] +
|
|
90_000) +
|
|
5 * 60_000) /
|
|
60_000,
|
|
),
|
|
),
|
|
needsDaytona: execution.environment.id === "daytona",
|
|
}))
|
|
.sort((left, right) => left.executionId.localeCompare(right.executionId));
|
|
}
|