mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-14 08:26:46 +02:00
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>
75 lines
6.3 KiB
TypeScript
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");
|
|
}
|