mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-14 17:40:37 +02:00
Give tasks durable company-scoped workspace bindings independent of project assignment, channel defaults and authorized repository preparation. Reuse verified input seeds and sparse output recovery while preserving trust, privacy and controller provisioning boundaries.
Full CI and E2E, Superagent, legacy checks and Greptile Apex 5/5 passed on reviewed head 753680b61c. All review findings are resolved; the PR records focused verification and historical acceptance limits.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
74 lines
6.2 KiB
TypeScript
74 lines
6.2 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` };
|
|
}
|
|
|
|
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");
|
|
}
|