Files
PaperClipAI/tests/runner-e2e/product-journeys/repository-continuation-flow.ts
T
DottaandPaperclip 7b8aa60b6d test(e2e): add repository continuation journey (#18729)
## Thinking Path

> - Paperclip keeps agent work in project workspaces.
> - A later user request must continue from the saved repository state.
> - A successful reply alone cannot prove file or Git history
preservation.
> - The repository journey in #18652 passed against QA staging with
independent byte and commit checks.
> - Its separate completion journey remains unpassed and must stay open.
> - This PR delivers only repository continuation and reuses the merged
shared support from #18728.

## Linked Issues or Issue Description

Refs: #18652. Builds on merged #18728. This is the repository-only
delivery from #18652. The original completion implementation and all
failed attempts remain on that open PR.

## What Changed

- Add `repository-continuation` with the exact measured source fixture,
two native turns, and eight required checks.
- Start from measured source `ac4efb7c6f4782ea806661683c656fd2b4cc3189`.
Keep the repository flow and Git/byte oracle unchanged. Clarify the two
file-content instructions to require the word first or verified followed
by exactly one LF newline; retain the exact expected bytes and the
original random-reference contract.
- Document the two-turn proof boundary: final saved hidden Git metadata
is not independently read. Keep all eight success assertions.
- Use the original-deadline and routine failure-reporting repairs from
merged #18728. Integrate its actual master squash
`e5713d95c6d9a258882649789ea5eb071cc17dc0` through one normal merge.
- Use a fixed public read-only repository commit. Verify downloaded Git
objects, parent commits, changed paths, saved workspace identity, and
exact source bytes across both turns. Reopen the registered outputs
through the browser.
- Keep the shared artifact reader behavior unchanged and narrow its type
to the API capabilities it uses.
- Add only this case to the catalog, feature association, dispatcher,
source hash, types, run/check contract, strict catalog expectation, and
journey documentation.

## Verification

- Current head `86808b7ec17e1727bd55a3fd46bc7e0409ca0e31` passed fresh
[QA staging run
41589522430](https://github.com/paperclipai/paperclip-cloud/actions/runs/41589522430)
and its [full
report](https://pages.paperclip.ing/evals-qa-smoke-41589522430-1/). All
eight checks and five phases passed. Both native turns succeeded and
committed in the same workspace. The independent same-buffer Git audit
verified that the second commit has the first commit as its parent. The
saved original files and browser downloads matched their expected bytes.
This does not add a final hidden `.git`/HEAD assertion.
- The fresh run used OpenCode with
`openrouter/deepseek/deepseek-v4.1-flash-1017` on QA staging/Daytona. It
used Core `09162fe893b464dd2ccdafd14bd3ef1611202a86`, Cloud
`cloud-41570439002-1`, and environment configuration version 9. Actual
checkouts matched public source
`86808b7ec17e1727bd55a3fd46bc7e0409ca0e31`, adapter
`7367cf6fd0ee1cf52c2651a8a6671e087728a9c1`, and protected workflow
bootstrap `a9ad6877de62fe3f65aacadc8a9dfba7d90729cf`. The bootstrap
matched the accepted 117-file QA contract.
- Normal cleanup completed. The original manifest is complete, evidence
cleanup is complete, no operations remain active, and the owned agent is
terminal. The coordinator verified ACTIVE TestOps case 685 / result 3695
and closed [launch 1921](https://paperclip.testops.cloud/launch/1921).
This records normal cleanup closure; no separate physical provider-stop
acknowledgement was collected.
- Measured source `ac4efb7c6f4782ea806661683c656fd2b4cc3189` passed [QA
staging run
41573331189](https://pages.paperclip.ing/evals-qa-smoke-41573331189-1/)
against Core `09162fe893b464dd2ccdafd14bd3ef1611202a86`. TestOps launch
1723 / result 3501 maps to case 685. All eight checks and five phases
passed. The two run bindings, independent saved-byte/Git audit, and
completed cleanup were verified.
- Initial extraction `54103639850d08d4d125ebe0493d8d3d49b58211`
preserved the measured case files. The current head also clarifies the
two LF instructions, documents the final Git-metadata limit, and
inherits the reviewed helper repairs. This changed the fixture
conditions. The current head now has its own live pass, listed below.
The original ac4 result remains a pass under its original conditions; it
was not used to qualify this head.
- The emitted-prompt control failed on the old quoted backslash+n
instructions, while 14 sibling controls passed. All 47 focused
repository/artifact controls then passed after the two-sentence repair.
Local: 891 related support tests, runner E2E typecheck, recursive
typecheck, build, maps, token gates, and `git diff --check` passed. The
inherited routine source has 62 causal read/cleanup controls and 26
unchanged real component controls.
- The initial repository head's normal full CI run 41578686494 passed.
Its 4/5 review and all original findings remain recorded. The later
stacked head `037b979477161881a4877230500e6f3de8faea5c` also passed full
CI run 41581342503 and a fresh 5/5 review. Current integration head
`86808b7ec17e1727bd55a3fd46bc7e0409ca0e31` passed normal full [CI run
41584981651](https://github.com/paperclipai/paperclip/actions/runs/41584981651),
attempt 1. Its fresh exact-head Greptile review is 5/5, with zero
unresolved threads and no open findings. 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.
- Earlier repository failures remain recorded: provider-pack transfer
timed out in one attempt; a later attempt could not read saved workspace
files. The later pass does not regrade those failures. The product
repairs are separate from this unchanged case delivery.
- The fixed actual #18728 squash is now integrated. The PR targets
master. Its aggregate diff has exactly 14 repository-only paths, each
identical to `037b979477161881a4877230500e6f3de8faea5c`; all other
master changes are preserved. No later master head was chased. The
current CI, review, and fresh staging qualification all passed for this
integration head.

## Risks

- Git bundles and workspace bytes are private evidence. Public reports
retain closed checks; the fixture never pushes to the public source
repository.
- The second committed output must retain the first commit as its
parent, the same workspace binding, and original saved file bytes. Final
hidden `.git`/HEAD after the second save is not independently exposed by
the supported remote workspace API; this test does not claim third-turn
Git metadata durability.
- Only JSON metadata observations retry transient transport failures.
Binary attachment and browser downloads remain single attempts.
Transport failures and digest mismatches remain visible failures.
- The existing artifact reader is shared. Its runtime code is unchanged
and its regression coverage remains included.
- Daily source pins and plan selection stay with the coordinator. This
PR does not register or bypass the unpassed completion case.

## 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 `#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>
2026-11-03 00:46:13 -06:00

147 lines
12 KiB
TypeScript

import { createObservedReader } from "./observation-read.js";
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect } from "@playwright/test";
import type { WorkspaceFileContent } from "../../../packages/shared/src/types/workspace-file-resource.js";
import { readRegisteredArtifacts } from "../registered-artifact.js";
import { createTaskThroughUi, submitTaskReply } from "../user-actions.js";
import { fixtureReplyStoredOnce, validateAgentTaskState, waitFor } from "./agent-flows.js";
import { repositoryContinuationSource, repositoryContinuationStory } from "./repository-continuation-contract.js";
import { inspectRepositoryBundle, repositoryCommitMatches, repositoryWorkspaceBindingMatches, repositoryWorkspaceFileMatches } from "./repository-continuation-evidence.js";
import { openDocumentAuthoring } from "./task-document-flow.js";
import { JourneyError, type JourneyContext, type JourneyEvidence, type TargetSession } from "./types.js";
type Row = Record<string, any>;
const id = "repository-continuation";
/** Attached and managed targets share the browser story and byte/Git oracle. */
export async function runRepositoryContinuation(context: JourneyContext, session: TargetSession, campaignId: string,
evidence: JourneyEvidence, options: { deadlineAt: number; directory: string }) {
const { api, page, company, 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 story = repositoryContinuationStory(campaignId), { files } = story, source = repositoryContinuationSource;
await mkdir(options.directory, { mode: 0o700, recursive: true });
const retain = (filename: string, value: unknown) => writeFile(path.join(options.directory, filename), JSON.stringify(value, null, 2), { mode: 0o600 });
const check = (name: string, passed: boolean) => {
evidence.checks.push({ id: name, passed });
if (!passed) throw new JourneyError("failed", name.replaceAll("-", "_"));
};
const projectName = `Journey QA repository ${campaignId}`;
await session.own({ kind: "project", name: projectName, journeyId: id });
const project = await api.post<Row>(`/api/companies/${company.id}/projects`, {
name: projectName, description: "Disposable read-only public repository continuation fixture.",
executionWorkspacePolicy: { enabled: true, defaultMode: "shared_workspace", sharedWorkspaceConcurrency: "serialize",
allowIssueOverride: false, environmentId: context.environmentId, workspaceStrategy: { type: "project_primary" } },
workspace: { name: "Primary", sourceType: "git_repo", repoUrl: source.url, repoRef: source.commit, isPrimary: true },
});
await session.own({ kind: "project", name: projectName, id: project.id, journeyId: id });
const configured = await readObserved<Row>(`/api/projects/${project.id}`);
const projectWorkspaces = configured.workspaces?.filter((workspace: Row) => workspace.repoUrl === source.url && workspace.repoRef === source.commit && workspace.isPrimary);
if (configured.companyId !== company.id || !Array.isArray(projectWorkspaces) || projectWorkspaces.length !== 1)
throw new JourneyError("failed", "repository_project_source_mismatch");
const projectWorkspaceId = projectWorkspaces[0]!.id;
const title = `Journey QA repository continuation ${campaignId}`;
const created = await createTaskThroughUi({ page, origin: api.origin, issuePrefix: company.issuePrefix,
agentName: agent.name, projectName, title, prompt: story.first, workMode: "standard", requireExplicitTitle: true,
beforeDispatch: () => session.own({ kind: "issue", name: title, journeyId: id }) });
await session.own({ kind: "issue", name: title, id: created.issueId, journeyId: id });
const route = `/api/issues/${created.issueId}`;
const read = async () => {
const [currentIssue, summaries, comments, interactions] = await Promise.all([
readObserved<Row & { id: string; status: string }>(route), readObserved<Row[]>(`${route}/runs`),
readObserved<Row[]>(`${route}/comments?order=asc`), readObserved<Array<Row & { id: string; status: string }>>(`${route}/interactions`),
]);
if (!Array.isArray(summaries) || summaries.length > 2) throw new JourneyError("failed", "repository_run_limit_exceeded");
const taskRuns = (await Promise.all(summaries.map(summary => readObserved<Row & { status: string }>(`/api/heartbeat-runs/${summary.runId}`))))
.sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)) || String(a.id).localeCompare(String(b.id)));
const state = { currentIssue, taskRuns, comments, interactions, attachments: [] };
await retain("repository-latest-task.json", state);
validateAgentTaskState(context, created.issueId, state, 2);
if (currentIssue.projectId !== project.id) throw new JourneyError("failed", "repository_project_binding_mismatch");
return state;
};
const settled = (count: number) => waitFor(options.deadlineAt, read, state => state.currentIssue.status === "done"
&& state.taskRuns.length === count && state.taskRuns.every(run => run.status === "succeeded"
&& run.nativePhase === "committed" && run.resultJson?.finalizationPhase === "committed")
&& state.interactions.every(row => row.status !== "pending"), "repository_task_not_completed");
// Use the attached target's origin for the common registration/download oracle.
const artifactApi = { get: <T>(route: string) => readObserved<T>(route), request: { get: (route: string) => api.response(route) } };
const bundle = async (name: string, runId: string) => {
const [artifact] = await readRegisteredArtifacts(artifactApi, created.issueId, runId, [name]);
const bytes = await api.bytes(`/api/attachments/${artifact!.attachmentId}/content?download=1`);
if (createHash("sha256").update(bytes).digest("hex") !== artifact!.sha256) throw new JourneyError("failed", "repository_bundle_download_changed");
await writeFile(path.join(options.directory, name), bytes, { mode: 0o600, flag: "wx" });
return { artifact: artifact!, bytes };
};
// Task-chat layouts keep generic output controls in Artifacts. Use the
// production download link for this exact attachment, not a coincidental
// URL in a model reply or an API-only readback.
const browserDownload = async (artifact: { attachmentId: string; sha256: string }, phase: string) => {
await openDocumentAuthoring(context);
const link = page.getByRole("link", { name: /^Download/ }).and(
page.locator(`a[href*="/api/attachments/${artifact.attachmentId}/content"]`)).first();
await expect(link).toBeVisible();
const [download] = await Promise.all([page.waitForEvent("download", { timeout: Math.max(1, Math.min(30_000, options.deadlineAt - Date.now())) }), link.click()]);
const filename = path.join(options.directory, `browser-${phase}`);
await download.saveAs(filename);
const bytes = await readFile(filename);
if (createHash("sha256").update(bytes).digest("hex") !== artifact.sha256) throw new JourneyError("failed", "repository_browser_download_changed");
};
const file = (name: string) => readObserved<WorkspaceFileContent>(`${route}/file-resources/content?workspace=execution&path=${encodeURIComponent(name)}`);
const first = await settled(1), firstRun = first.taskRuns[0]!;
const initialView = await readObserved<Row>(`${route}/workspace`), workspaceId = initialView.workspace?.id;
const binding = (issue: Row, view: Row, runs: Row[]) => repositoryWorkspaceBindingMatches({ companyId: company.id,
issueId: created.issueId, projectId: project.id, projectWorkspaceId, workspaceId, repoUrl: source.url, issue, view, runs });
check("repository-configured-source-bound", binding(first.currentIssue, initialView, first.taskRuns));
const [referenceArtifact] = await readRegisteredArtifacts(artifactApi, created.issueId, firstRun.id, [files.context]);
const original = referenceArtifact!.content;
if (!/^[a-f0-9]{64}\n$/.test(original)) throw new JourneyError("failed", "repository_original_reference_invalid");
const { artifact: firstBundle, bytes: originalBytes } = await bundle(files.firstBundle, firstRun.id);
const initialGit = await inspectRepositoryBundle(originalBytes, { sourceCommit: source.commit, files: [files.context, files.first, "README"] });
check("repository-first-commit-verified", repositoryCommitMatches(initialGit, { parent: source.commit, sourceReadme: source.readme,
files: { [files.context]: original, [files.first]: "first\n", README: source.readme }, added: [files.context, files.first] }));
check("repository-original-files-saved", repositoryWorkspaceFileMatches(await file(files.context), workspaceId, files.context, original)
&& repositoryWorkspaceFileMatches(await file(files.first), workspaceId, files.first, "first\n")
&& repositoryWorkspaceFileMatches(await file("README"), workspaceId, "README", source.readme));
await retain("repository-first-observation.json", { source, projectId: project.id, workspaceId, issueId: created.issueId,
runId: firstRun.id, referenceArtifact, initialGit, bundle: { attachmentId: firstBundle.attachmentId, sha256: firstBundle.sha256 } });
const url = `${api.origin}/${company.issuePrefix}/issues/${first.currentIssue.identifier ?? created.issueId}`;
await page.goto(url, { waitUntil: "domcontentloaded" });
await page.reload();
await browserDownload(firstBundle, "first-bundle");
// The original random reference and commit are not supplied by this follow-up.
await submitTaskReply(page, story.followup);
const withReply = await waitFor(Math.min(options.deadlineAt, Date.now() + 30_000), read,
state => state.comments.some(comment => comment.authorUserId === context.user.id && !first.comments.some(old => old.id === comment.id)),
"repository_followup_not_stored");
check("repository-followup-stored-once", fixtureReplyStoredOnce(first.comments, withReply.comments, context.user.id, story.followup));
const final = await settled(2), lastRun = final.taskRuns[1]!;
const finalView = await readObserved<Row>(`${route}/workspace`);
check("repository-same-workspace-retained", binding(final.currentIssue, finalView, final.taskRuns)
&& Date.parse(lastRun.startedAt) >= Date.parse(firstRun.finishedAt));
const { artifact: finalBundle, bytes: finalBytes } = await bundle(files.finalBundle, lastRun.id);
const finalGit = await inspectRepositoryBundle(finalBytes,
{ sourceCommit: source.commit, files: [files.context, files.first, files.followup, "README"] });
check("repository-second-commit-verified", repositoryCommitMatches(finalGit, { parent: initialGit.head, sourceReadme: source.readme,
files: { [files.context]: original, [files.first]: "first\n", [files.followup]: "verified\n", README: source.readme }, added: [files.followup] }));
await page.goto(url, { waitUntil: "domcontentloaded" });
await page.reload();
for (const [index, artifact] of [referenceArtifact!, firstBundle, finalBundle].entries()) await browserDownload(artifact, `reopened-${index}`);
const [retained, retainedBundle] = await readRegisteredArtifacts(artifactApi, created.issueId, firstRun.id, [files.context, files.firstBundle]);
check("repository-original-and-result-reopened", retained!.attachmentId === referenceArtifact!.attachmentId && retained!.content === original
&& retainedBundle!.attachmentId === firstBundle.attachmentId && retainedBundle!.sha256 === firstBundle.sha256
&& repositoryWorkspaceFileMatches(await file(files.context), workspaceId, files.context, original)
&& repositoryWorkspaceFileMatches(await file(files.first), workspaceId, files.first, "first\n")
&& repositoryWorkspaceFileMatches(await file(files.followup), workspaceId, files.followup, "verified\n")
&& repositoryWorkspaceFileMatches(await file("README"), workspaceId, "README", source.readme));
check("configured-model-and-environment", true); // Both actual native runs passed validateAgentTaskState.
await retain("repository-continuation-checks.json", { source, projectId: project.id, workspaceId, issueId: created.issueId,
initialGit, finalGit, referenceArtifact, firstBundle, finalBundle, runIds: final.taskRuns.map(run => run.id) });
} finally { await observations.preserve(options.directory); }
}