Files
PaperClipAI/tests/runner-e2e/task-workspaces-cases.ts
T
DottaandPaperclip 58bcb04bfd fix: preserve workspace ownership through project changes and recovery
Keep project edits organizational, validate immutable native task roots before materialization, align authorization fixtures, and correct live E2E evidence collection.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-09 21:19:26 -05:00

75 lines
6.3 KiB
TypeScript

import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import type { RunnerProfileFixture, RunnerTaskFixture } from "./types.js";
export const TASK_WORKSPACES_SUITE = "task-workspaces";
export const TASK_WORKSPACES_BUDGET_CENTS = 1_000;
export const TASK_REPOSITORY_URL = "https://github.com/octocat/Hello-World";
export function taskWorkspaceFiles(nonce: string) {
return { note: `task-note-${nonce}.txt`, proof: `task-proof-${nonce}.txt`, repositoryFile: `repository-proof-${nonce}.txt`,
noteBytes: (turn: number) => `task-files-turn-${turn}-${nonce}\n`, repositoryBytes: `repository-dirty-${nonce}\n` };
}
/** Remove the legacy operator cwd so this suite actually exercises default admission. */
export function taskWorkspaceProfile(profile: RunnerProfileFixture): RunnerProfileFixture {
return { ...profile, buildAgent(input) {
const agent = profile.buildAgent(input);
const { cwd: _cwd, ...adapterConfig } = agent.adapterConfig as Record<string, unknown>;
return { ...agent, adapterConfig, budgetMonthlyCents: TASK_WORKSPACES_BUDGET_CENTS };
} };
}
export function taskWorkspacePrompt(nonce: string, turn: 1 | 2 | 3, native: boolean) {
const files = taskWorkspaceFiles(nonce);
const inspect = native ? "Use get_workspace to inspect your admitted task workspace."
: "GET /api/issues/$PAPERCLIP_TASK_ID/workspace through the authenticated public API to inspect your task workspace.";
const api = native ? "Use the real Paperclip semantic tools."
: 'Use the injected PAPERCLIP_API_URL, PAPERCLIP_API_KEY and PAPERCLIP_RUN_ID for the public API. Normalize API_ORIGIN="${PAPERCLIP_API_URL%/}"; API_ORIGIN="${API_ORIGIN%/api}". Include Authorization: Bearer and X-Paperclip-Run-Id on API requests. Never print credentials.';
const content = turn === 1 ? [
"This is the first of three separately requested steps. Do only this step now; a later user message will request the next step.",
inspect,
`In the current task directory write ${files.note} with exactly ${JSON.stringify(files.noteBytes(1))}. Keep task files outside AGENT_HOME.`,
"Stage a repository for the NEXT normal admission; do not clone it yourself, create a project, switch roots, wait for a new run, or wake this task.",
native ? `Call prepare_repository twice with exactly {repository:{kind:"url",url:"${TASK_REPOSITORY_URL}"},requestKey:"repository-${nonce}"}. Both calls must report the same preparation receipt and next_normal_admission.`
: `POST /api/issues/$PAPERCLIP_TASK_ID/workspace/repositories twice with the same JSON {"repository":{"kind":"url","url":"${TASK_REPOSITORY_URL}"},"requestKey":"repository-${nonce}"}. Both replies must return the same operationId and next_normal_admission.`,
"Inspect the workspace again. Its identity/root must be unchanged and the repository should still be pending. The repository need not exist yet. The objective of this step is staged preparation, not a completed clone.",
] : turn === 2 ? [
"Continue this same task after the controller restart. Do only step two now.", inspect,
`Read ${files.note} and require its exact bytes to be ${JSON.stringify(files.noteBytes(1))}; fail visibly if missing or different.`,
"The previously staged repository must now be ready in the reported contained relativePath. Do not prepare or clone a replacement, create a project, or switch the task root.",
`Inside that checkout create ${files.repositoryFile} containing exactly ${JSON.stringify(`repository-committed-${nonce}\n`)}. Commit ONLY that file using git -c user.name='Workspace Fixture' -c user.email='fixture@example.test' commit with message workspace-proof-${nonce}. Do not push.`,
`Then overwrite that committed file with exactly ${JSON.stringify(files.repositoryBytes)}, leaving this local change uncommitted.`,
`Update the task-root ${files.note} to exactly ${JSON.stringify(files.noteBytes(2))}.`,
] : [
"Continue this same task with the final step. Do not recreate a missing repository or missing earlier files.", inspect,
`Read ${files.note}; require exactly ${JSON.stringify(files.noteBytes(2))}.`,
`Read the existing ${files.repositoryFile} in the prepared checkout. Copy its bytes unchanged to ${files.proof} in the task root. Do not edit or commit the repository in this step.`,
`Update ${files.note} to exactly ${JSON.stringify(files.noteBytes(3))}.`,
`Register ${files.proof} as a downloadable artifact work product with exact title ${files.proof}.`,
];
return [...content, "The quoted \\n escapes denote actual LF bytes. Include the final LF when writing each exact string; verify byte counts before finishing.", api,
turn < 3 ? "Save a short Paperclip task document describing this completed step as its durable work product. Then complete this step and mark the task Done; do not wait for the next user message."
: "Validate the written and downloaded-proof source bytes before completing the task.",
native ? "Use paperclip_finish with reportedWorkDisposition done, the current completion contract revision, satisfied objective criterion, real evidence references, and no remaining work for THIS requested step."
: "Complete through the normal public task API after saving the work product.",
`Use TASK-WORKSPACE-${turn}-${nonce} as the completion summary and final response. Do not do unrelated work.`,
].join("\n");
}
export const taskWorkspaceTask: RunnerTaskFixture = {
id: "task-directory-repository-resume", label: "Task files, repository preparation and restart", groups: [],
flow: "task_workspaces", workMode: "standard", expectedRunCount: 3, automaticRetryPolicy: "single_attempt",
attemptTimeoutMs: { local: 15 * 60_000, daytona: 15 * 60_000 }, turnTimeoutMs: 4 * 60_000,
expectedTerminalState: { issue: "done", run: "succeeded" },
buildTitle: nonce => `Task files without a project ${nonce}`,
buildVisibleMarker: nonce => `TASK-WORKSPACE-3-${nonce}`,
buildPrompt: nonce => taskWorkspacePrompt(nonce, 1, true), buildMatchers: () => [],
};
export function taskWorkspaceDefinitionDigest() {
const hash = createHash("sha256");
for (const file of ["task-workspaces-cases.ts", "task-workspaces-flow.ts", "task-workspaces-scoring.ts", "registered-artifact.ts", "live-fixtures.ts", "runner.spec.ts"]) {
hash.update(file).update(readFileSync(new URL(file, import.meta.url)));
}
return hash.digest("hex");
}