Files
PaperClipAI/tests/runner-e2e/product-journeys/routine-schedule-flow.ts
T
DottaandPaperclip e5713d95c6 test(e2e): add scheduled routine journey (#18728)
## Thinking Path

> - Paperclip manages agent work through tasks, projects, and routines.
> - Daily QA must exercise the staging app through normal browser and
API surfaces.
> - A schedule must create real work and retain its result and history.
> - Pausing that schedule must prevent the next invocation.
> - The routine journey in #18652 passed in QA, while its separate
completion journey has not passed.
> - This PR delivers the routine test independently and preserves its
measured flow and assertions.

## Linked Issues or Issue Description

Refs: #18652. This is the routine-only delivery from that PR. The
original completion implementation and its failed attempts remain on
#18652.

## What Changed

- Add the target-independent `routine-scheduled-run` journey with one
native run and seven required checks.
- Start from the measured routine source
`ac4efb7c6f4782ea806661683c656fd2b4cc3189`. Keep its request, seven
success assertions, one native-run limit, and 65-second pause check.
- Bound each observed GET by the original remaining journey deadline.
Preserve the normal API timeout for other callers. Keep the original
routine error if final trigger disabling also fails, save a closed
secondary receipt, and fail an otherwise successful journey when
disabling fails.
- Narrow the shared output-reader type to its one-argument GET
capability without changing its runtime code.
- Register only this case in the catalog, types, feature associations,
dispatcher, and definition hash. Keep the strict catalog expectation in
sync.
- Retain the real Routines component controls and the existing
project-flow coverage for the shared browser helper.
- Document resource cleanup and the 65-second pause observation.
Preserve the current routine map content.

## Verification

- The original source passed [QA staging run
41567574185](https://pages.paperclip.ing/evals-qa-smoke-41567574185-1/).
TestOps launch 1657 / result 3431 maps to case 718. All seven checks and
five phases passed. One scheduler-bound native run, saved output
attribution, the full pause window, and cleanup were verified.
- Current source `8f62137f130f59fd3e39276e3d60185ddff24517` passed
[fresh QA staging run
41582706162](https://pages.paperclip.ing/evals-qa-smoke-41582706162-1/)
against Core `09162fe893b464dd2ccdafd14bd3ef1611202a86`. TestOps launch
1855. All seven checks, five phases, and all three workflow jobs passed.
The run used one scheduler-bound native turn and observed 65.352 seconds
after pause and completion. Canonical evidence collection and normal
cleanup completed, with no active operations and the owned agent
terminal.
- This fresh pass covers the reviewed deadline and failure-reporting
changes in the current source. The earlier ac4 pass and the
byte-preserving b940 extraction remain historical records under their
original conditions; neither failed attempts nor prior source conditions
are regraded.
- Causal controls: six assertions failed on the old behavior, then all
62 focused controls passed after the repair. Local: 834 related support
tests, 41 shared chat/output-reader tests, and 26 unchanged real
component controls passed. Runner E2E typecheck, recursive typecheck,
build, maps, token gates, and `git diff --check` passed. The temporary
output-reader type mismatch exposed by typecheck was corrected by
narrowing the capability type.
- The first restricted build/typecheck invocations failed because local
tsx IPC was denied. A restricted support run also lacked the report CLI
output. The same source passed with normal owned local IPC allowed; the
original logs remain retained.
- The original head's hosted e2e shard 2/8 failed at the unchanged
mobile preference frame-count test: expected more than five frames,
observed five. The cause is unknown. Its original log remains retained;
it is not labeled a proven flake. No CI rerun was requested.
- Current-head full CI run 41580423918, attempt 1, passed. The fresh
review is 5/5 with zero unresolved threads. The broad local suite was
not repeated. The earlier full local run recorded in #18643 had four
failures and did not reach 164 serialized groups; this PR does not claim
an all-local pass.
- Prior failed routine attempts remain recorded. They exposed composer
focus, collapsed schedule controls, pending browser events, and Markdown
serialization problems. The measured later pass does not regrade those
attempts.

## Risks

- The shared browser-event helper also serves task creation and project
creation. Its regression tests and existing consumers remain included.
- The staging pass qualifies the captured source and configuration. The
coordinator retains source merging, daily pinning, and plan selection.
- The repository test and the unpassed completion test are not
registered by this PR.

## Model Used

OpenAI GPT-6 through Codex, with tool use and code execution. The exact
deployment ID and context window are not exposed by 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 (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 issue/PR references)

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-02 23:25:56 -06:00

234 lines
18 KiB
TypeScript

import { createObservedReader } from "./observation-read.js";
import { createHash } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import { ConnectionFailure } from "../connection-config.js";
import { fillTaskPrompt, withBrowserEvent } from "../user-actions.js";
import { validateAgentTaskState, waitFor } from "./agent-flows.js";
import { openDocumentAuthoring } from "./task-document-flow.js";
import { scheduledInvocationMatches, schedulePauseMatches, schedulePauseObservationMs, type ScheduledInvocation, type ScheduledTask } from "./routine-schedule-evidence.js";
import { JourneyError, type JourneyContext, type JourneyEvidence, type TargetSession } from "./types.js";
type Row = Record<string, any>;
const journeyId = "routine-scheduled-run" as const;
type RoutineUiStep = "open_composer" | "select_responsible" | "select_project" | "fill_instructions" | "submit" | "enable_trigger" | "pause_trigger" | "open_history" | "reopen_result";
export async function routineUiStep<T>(step: RoutineUiStep, action: () => Promise<T>): Promise<T> {
try { return await action(); }
catch (error) {
if (error instanceof JourneyError) throw error;
// Browser failures can contain form values and URLs. Retain only this closed step code.
throw new JourneyError("failed", `routine_${step}_failed`);
}
}
export async function enableRoutineScheduleTrigger(page: Pick<Page, "getByRole" | "waitForResponse">, triggerRoute: string) {
const schedule = page.getByRole("region", { name: "Schedule trigger", exact: true });
// The enable/pause controls are children of the collapsed schedule editor.
await schedule.getByRole("button", { name: "Edit schedule", exact: true }).click();
return withBrowserEvent(
() => page.waitForResponse(response => response.request().method() === "PATCH"
&& new URL(response.url()).pathname === triggerRoute),
() => schedule.getByRole("button", { name: "Enable trigger", exact: true }).click());
}
export async function withRoutineTriggerCleanup<T>(input: {
api: Pick<JourneyContext["api"], "patch">; triggerRoute: string; directory: string; run(): Promise<T>;
}): Promise<T> {
let primary: { error: unknown } | undefined;
try { return await input.run(); }
catch (error) { primary = { error }; throw error; }
finally {
try { await input.api.patch(input.triggerRoute, { enabled: false }); }
catch (error) {
const failure = new JourneyError("failed", "routine_trigger_disable_failed");
try {
await mkdir(input.directory, { recursive: true, mode: 0o700 });
await writeFile(path.join(input.directory, "scheduled-routine-cleanup-failure.json"), JSON.stringify({
schema: "paperclip.product-journey.routine-cleanup-failure/v1", code: failure.code,
triggerRoute: input.triggerRoute, primaryFailurePreserved: Boolean(primary),
transportCode: error instanceof ConnectionFailure && /^(api_request_failed|api_status_[1-5][0-9]{2})$/.test(error.message)
? error.message : null,
}, null, 2), { mode: 0o600 });
} catch {
if (primary) throw primary.error;
throw failure;
}
// The outer lifecycle still grades cleanup and reconciles the owned trigger.
if (!primary) throw failure;
}
}
}
export async function runScheduledRoutine(context: JourneyContext, session: TargetSession, campaignId: string,
evidence: JourneyEvidence, options: { deadlineAt: number; directory: string }) {
const { api, page, company, user, agent } = context;
const observations = createObservedReader(api, options.deadlineAt);
const readObserved = observations.get;
try {
if (!agent || agent.runtimeMode !== "native" || !context.environmentId) throw new JourneyError("unsupported", "native_agent_and_environment_required");
const title = `Journey QA scheduled routine ${campaignId}`, projectName = `Journey QA routine project ${campaignId}`;
const check = (id: string, passed: boolean) => {
evidence.checks.push({ id, passed });
if (!passed) throw new JourneyError("failed", id.replaceAll("-", "_"));
};
const bodyReceipt = (body: unknown) => typeof body === "string" ? { byteSize: Buffer.byteLength(body),
sha256: createHash("sha256").update(body).digest("hex"), trimmedSha256: createHash("sha256").update(body.trim()).digest("hex"),
lineFeedCount: body.split("\n").length - 1, backslashCount: body.split("\\").length - 1 } : null;
await session.own({ kind: "project", name: projectName, journeyId });
const project = await api.post<Row>(`/api/companies/${company.id}/projects`, { name: projectName, status: "planned" });
await session.own({ kind: "project", name: projectName, id: project.id, journeyId });
const key = "scheduled-summary", marker = `scheduled-${campaignId}`;
const expectedBody = `${marker}\nSchedule check complete.`;
const prompt = `Prepare the scheduled status check. Save a task document with key ${key} and title Scheduled check. `
+ "Copy exactly the two lines inside this block, including the final period. Do not include the fences in the document body.\n\n"
+ `\`\`\`text\n${expectedBody}\n\`\`\`\n\n`
+ "Then finish the task. Do not create other tasks or agents.";
await routineUiStep("open_composer", async () => {
await page.goto(`${api.origin}/${company.issuePrefix}/routines`, { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "Create routine", exact: true }).first().click();
});
const dialog = page.getByRole("dialog");
await routineUiStep("select_responsible", async () => {
await dialog.getByPlaceholder("Routine title", { exact: true }).fill(title);
await dialog.getByRole("button", { name: "Responsible", exact: true }).click();
await page.getByPlaceholder("Search responsible...", { exact: true }).fill(agent.name);
await page.getByRole("button", { name: agent.name, exact: true }).click();
});
await routineUiStep("select_project", async () => {
// Selecting Responsible focuses Project and opens its picker. Clicking
// its trigger again closes the picker before the search field can fill.
const projectSearch = page.getByPlaceholder("Search projects...", { exact: true });
await expect(projectSearch).toBeVisible();
await projectSearch.fill(projectName);
await page.getByRole("button", { name: projectName, exact: true }).click();
});
await routineUiStep("fill_instructions", () => fillTaskPrompt(dialog.locator('[contenteditable="true"]'), prompt));
await session.own({ kind: "routine", name: title, journeyId });
const response = await routineUiStep("submit", () => withBrowserEvent(
() => page.waitForResponse(response => response.request().method() === "POST"
&& new URL(response.url()).pathname === `/api/companies/${company.id}/routines`),
() => dialog.getByRole("button", { name: "Create routine", exact: true }).click()));
if (response.status() !== 201) throw new JourneyError("failed", "routine_ui_creation_failed");
const routine = await response.json();
if (typeof routine.id !== "string") throw new JourneyError("failed", "routine_creation_receipt_missing");
await session.own({ kind: "routine", name: title, id: routine.id, journeyId });
const route = `/api/routines/${routine.id}`;
// Fixture policy makes duplicate scheduler work observable instead of coalescing it.
await api.patch(route, { concurrencyPolicy: "always_enqueue" });
const saved = await readObserved<Row>(route);
const creationMatches = { company: saved.companyId === company.id, title: saved.title === title,
project: saved.projectId === project.id, assignee: saved.assigneeAgentId === agent.id,
userCreator: saved.createdByUserId === user.id, agentCreatorNull: saved.createdByAgentId === null,
description: saved.description === prompt, concurrency: saved.concurrencyPolicy === "always_enqueue", zeroTriggers: saved.triggers.length === 0 };
await mkdir(options.directory, { recursive: true, mode: 0o700 });
await writeFile(path.join(options.directory, "scheduled-routine-creation.json"), JSON.stringify({
schema: "paperclip.product-journey.routine-creation/v1", matches: creationMatches,
expectedDescription: bodyReceipt(prompt), savedDescription: bodyReceipt(saved.description),
expected: { companyId: company.id, titleSha256: bodyReceipt(title)!.sha256, projectId: project.id, agentId: agent.id, userId: user.id },
saved: { id: saved.id, companyId: saved.companyId, titleSha256: bodyReceipt(saved.title)?.sha256,
projectId: saved.projectId, assigneeAgentId: saved.assigneeAgentId, createdByUserId: saved.createdByUserId,
createdByAgentId: saved.createdByAgentId, concurrencyPolicy: saved.concurrencyPolicy, triggerCount: saved.triggers.length,
createdAt: saved.createdAt, updatedAt: saved.updatedAt },
}, null, 2), { mode: 0o600 });
check("routine-browser-created", Object.values(creationMatches).every(Boolean));
const createdTrigger = await api.post<Row>(`${route}/triggers`, { kind: "schedule", label: `QA schedule ${campaignId}`,
cronExpression: "* * * * *", timezone: "America/Chicago", enabled: false });
const trigger = createdTrigger.trigger;
if (!trigger?.id || trigger.companyId !== company.id || trigger.routineId !== routine.id || trigger.kind !== "schedule"
|| trigger.cronExpression !== "* * * * *" || trigger.timezone !== "America/Chicago" || trigger.enabled !== false) {
throw new JourneyError("failed", "routine_schedule_fixture_mismatch");
}
const triggerRoute = `/api/routine-triggers/${trigger.id}`;
const readTrigger = async () => {
const detail = await readObserved<Row>(route);
const matching = detail.triggers?.filter((row: Row) => row.id === trigger.id);
if (!Array.isArray(matching) || matching.length !== 1 || matching[0].companyId !== company.id || matching[0].routineId !== routine.id) {
throw new JourneyError("failed", "routine_trigger_readback_mismatch");
}
return matching[0] as Row;
};
const runs = () => readObserved<ScheduledInvocation[]>(`${route}/runs`);
let pausedAt = 0;
await withRoutineTriggerCleanup({ api, triggerRoute, directory: options.directory, run: async () => {
const enabledResponse = await routineUiStep("enable_trigger", async () => {
await page.goto(`${api.origin}/${company.issuePrefix}/routines/${routine.id}/triggers`, { waitUntil: "domcontentloaded" });
return enableRoutineScheduleTrigger(page, triggerRoute);
});
if (enabledResponse.status() !== 200) throw new JourneyError("failed", "routine_schedule_enable_failed");
const enabledTrigger = await enabledResponse.json();
const enabledAt = Date.parse(enabledTrigger?.updatedAt);
if (enabledTrigger?.id !== trigger.id || enabledTrigger.companyId !== company.id || enabledTrigger.routineId !== routine.id
|| enabledTrigger.enabled !== true || !Number.isFinite(enabledAt)) throw new JourneyError("failed", "routine_schedule_enable_receipt_missing");
await expect.poll(async () => (await readTrigger()).enabled).toBe(true);
const first = await waitFor(options.deadlineAt, runs, rows => rows.length > 0 && Boolean(rows[0]?.linkedIssueId), "scheduled_invocation_missing");
await routineUiStep("pause_trigger", () => page.getByRole("button", { name: "Pause trigger", exact: true }).click());
await expect.poll(async () => (await readTrigger()).enabled).toBe(false);
pausedAt = performance.now();
const task = await readObserved<ScheduledTask & { title: string }>(`/api/issues/${first[0]!.linkedIssueId}`);
await session.own({ kind: "issue", name: task.title, id: task.id, journeyId });
const expected = { companyId: company.id, routineId: routine.id, triggerId: trigger.id, projectId: project.id, agentId: agent.id, enabledAt };
check("routine-scheduler-created-execution", scheduledInvocationMatches(first, task, expected));
const taskRoute = `/api/issues/${task.id}`;
const state = await waitFor(options.deadlineAt, async () => {
const [currentIssue, summaries, comments, interactions] = await Promise.all([
readObserved<Row & { id: string; status: string }>(taskRoute), readObserved<Row[]>(`${taskRoute}/runs`), readObserved<Row[]>(`${taskRoute}/comments?order=asc`),
readObserved<Array<Row & { id: string; status: string }>>(`${taskRoute}/interactions`),
]);
const taskRuns = await Promise.all(summaries.map(row => readObserved<Row & { status: string }>(`/api/heartbeat-runs/${row.runId}`)));
const value = { currentIssue, taskRuns, comments, interactions, attachments: [] };
validateAgentTaskState(context, task.id, value, 1);
return value;
}, value => value.currentIssue.status === "done" && value.taskRuns.length === 1 && value.taskRuns[0]!.status === "succeeded"
&& value.interactions.every(row => row.status !== "pending"), "routine_task_not_completed");
const completedAt = performance.now();
const document = await readObserved<Row>(`${taskRoute}/documents/${key}`);
const revision = await readObserved<Row>(`${taskRoute}/documents/${key}/revisions/${document.latestRevisionId}`);
const matches = { documentCompany: document.companyId === company.id, documentIssue: document.issueId === task.id,
documentExactBody: document.body?.trim() === expectedBody, documentAuthor: document.createdByAgentId === agent.id,
documentUserNull: document.createdByUserId === null, revisionDocument: revision.documentId === document.id,
revisionExactBody: revision.body?.trim() === expectedBody, revisionAuthor: revision.createdByAgentId === agent.id,
revisionRun: revision.createdByRunId === state.taskRuns[0]!.id };
// A failed grade must retain its attribution inputs without the prompt or output body.
await mkdir(options.directory, { recursive: true, mode: 0o700 });
await writeFile(path.join(options.directory, "scheduled-routine-result.json"), JSON.stringify({
schema: "paperclip.product-journey.routine-result/v1", matches, expectedBody: bodyReceipt(expectedBody),
documentBody: bodyReceipt(document.body), revisionBody: bodyReceipt(revision.body),
document: { id: document.id, companyId: document.companyId, issueId: document.issueId,
latestRevisionId: document.latestRevisionId, createdByAgentId: document.createdByAgentId,
createdByUserId: document.createdByUserId, createdAt: document.createdAt, updatedAt: document.updatedAt },
revision: { id: revision.id, documentId: revision.documentId, createdByAgentId: revision.createdByAgentId,
createdByRunId: revision.createdByRunId, createdAt: revision.createdAt },
run: { id: state.taskRuns[0]!.id, status: state.taskRuns[0]!.status, nativePhase: state.taskRuns[0]!.nativePhase,
agentId: state.taskRuns[0]!.agentId, companyId: state.taskRuns[0]!.companyId,
environmentId: state.taskRuns[0]!.contextSnapshot?.paperclipEnvironment?.id,
provider: state.taskRuns[0]!.runnerProfileJson?.nativeExecutionInput?.provider?.kind,
model: state.taskRuns[0]!.runnerProfileJson?.nativeExecutionInput?.provider?.model },
}, null, 2), { mode: 0o600 });
check("routine-result-attributed", Object.values(matches).every(Boolean));
if (typeof state.currentIssue.identifier !== "string" || !state.currentIssue.identifier) throw new JourneyError("failed", "routine_task_identifier_missing");
await routineUiStep("open_history", async () => {
await page.goto(`${api.origin}/${company.issuePrefix}/routines/${routine.id}/runs`, { waitUntil: "domcontentloaded" });
await page.reload();
await expect(page.getByRole("link", { name: state.currentIssue.identifier, exact: false }).first()).toBeVisible();
});
const final = await waitFor(options.deadlineAt, async () => ({ rows: await runs(), now: performance.now() }), value => {
if (value.rows.length !== 1) throw new JourneyError("failed", "routine_duplicate_invocation");
return value.rows[0]?.status === "completed" && value.now >= Math.max(pausedAt, completedAt) + schedulePauseObservationMs;
}, "routine_pause_not_observed");
check("routine-history-preserved", scheduledInvocationMatches(final.rows, await readObserved(taskRoute), expected));
check("routine-pause-prevented-next-run", schedulePauseMatches({ enabled: (await readTrigger()).enabled,
pausedAt, completedAt, observedUntil: final.now, first, final: final.rows }));
await routineUiStep("reopen_result", async () => {
await page.goto(`${api.origin}/${company.issuePrefix}/issues/${state.currentIssue.identifier}`, { waitUntil: "domcontentloaded" });
await page.reload();
await openDocumentAuthoring(context);
await expect(page.locator(`#document-${key}`)).toContainText(marker);
});
check("routine-result-reopened", (await readObserved<Row>(`${taskRoute}/documents/${key}`)).latestRevisionId === document.latestRevisionId);
check("configured-model-and-environment", true);
await mkdir(options.directory, { recursive: true, mode: 0o700 });
await writeFile(path.join(options.directory, "scheduled-routine-checks.json"), JSON.stringify({ routineId: routine.id,
triggerId: trigger.id, invocationId: first[0]!.id, taskId: task.id, runId: state.taskRuns[0]!.id, enabledServerAt: enabledAt, pausedMonotonicAt: pausedAt, completedMonotonicAt: completedAt,
observedUntilMonotonicAt: final.now, documentId: document.id, revisionId: revision.id }, null, 2), { mode: 0o600 });
} });
} finally { await observations.preserve(options.directory); }
}