mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
## Thinking Path > - Paperclip lets people manage agents that do useful work. > - A projectless task can prepare an authorized repository in its retained workspace. > - Setup must hand work to a later run without user supervision. > - Real task attempts exposed gaps across admission, completion, and queue dispatch. > - A successful provider exit did not prove that the requested result was delivered. > - This PR repairs the existing lifecycle and checks the complete repository journey. ## Linked Issues or Issue Description Refs #17047. This PR includes the preserved repairs from #17049, #17052, and #17082. **What happened?** A projectless native task could stop after requesting a repository. It could also continue through a timer or generic recovery path and lose its saved conversation lineage. The latest real Codex and Claude attempts delivered correct files, but the native continuation had been folded into the finishing predecessor. Both attempts remain failed measurements. **Expected behavior** Prepare the authorized repository, retain the original context, and dispatch one saved continuation after its predecessor finishes. Deliver the verified result in the originating conversation. Stop, current access, approval, and real blocker rules remain authoritative. **Steps to reproduce** Run the explicit automatic-repository suite for native Codex or Claude. Inspect the downloaded result, original context artifact, both committed run records, and continuation receipts. The suite uses one attempt and does not widen the default paid selection. ## What Changed - Resolve the retained local-board identity through current company membership and workspace authority. - Prepare required source dependencies before the isolated server imports native tool authority. - Explain the saved repository continuation in production completion tools. Correct only the exact pending-checkout blocker backed by a live receipt. Reject an unfinished response wait without a real response source. - Preserve the persisted task-session workspace source and its explicit absence of root repository context. Permit Git's exact no-repository result only for a validated plain task directory. Keep other Git errors and linked metadata strict. - Revalidate server-resolved same-agent continuation values against current ownership and the saved kind, summary, and key. Carry this shape through the internal bridge schema. Keep provider-facing schemas restrictive. - Preserve terminal result authority after a correlated successful controller response. Controller rejection creates no result authority. Bind the shared guidance and transport source in Copilot v55 and preserve historical identities. - Admit native status wakes with the existing no-coalescing contract. The existing deferred queue retains a distinct future dispatch instead of retagging the finishing run. - Recover deferred conversation lineage through the actual admitted dispatch. Accept a null outbox run binding only with matching decision, effect, dispatch actor, idempotency, task, company, agent, and requester evidence. Reject a conflicting nonnull binding. - Independently grade retained random context, actual checkout bytes and HEAD, accessible output, distinct continuation receipts, and admission order. ## Verification - Real database and Git regressions fail before their repairs. Prior focused cohorts cover local-board authority, optional repository waits, plain task directories, and first/reused workspace provenance. - The new valid decision/effect-backed assembled-heartbeat regression reproduces coalescing into its active predecessor before the queue repair. With the repair, it dispatches one successor after the predecessor finishes. Duplicate sweeps create no extra run. - The deferred-outbox conversation regression fails before its repair. The repaired test also rejects a conflicting predecessor binding. - The current queue, continuation, publication, cancellation, stale-admission, and domain cohort passes 344 checks across 11 suites. Server and Product E2E typechecks pass. The strengthened automatic-repository oracle passes 34 calibration checks. - Three omitted/null/resolved forms pass through the compiled Rust runner and controlled provider. The prior shared bridge cohort passes 492 TypeScript checks and 399 Rust core checks. Runner source identity, TypeScript, Rust formatting, and locked Rust typechecks pass. - Six real local development cells passed ordinary repository, conversion, and necessary-question journeys. Independent artifacts and rendered question/result evidence agree. They do not qualify staging or Slack. - Both latest automatic-repository attempts delivered correct files but failed the strict continuation check. They remain failures. A fresh replay of this queue repair is pending. - On the combined candidate `380047f4b923807a8c4f7ef6016e4fe130ab4a3a`, `pnpm -r typecheck` and `pnpm build` pass. Product E2E support passes 3,525 checks with two existing skips, plus 128 source checks. The complete local `pnpm test:run` is running and is not yet a pass. - The first replay selection failed catalog validation due to an operator profile-ID error. A second selection used the wrong default-model suite and was cancelled before any provider job existed. Both setup attempts remain recorded. The corrected campaign selects the recorded GPT-6.1 and Opus 5.5 models plus the local Stop/resume journey. - Exact-head CI, review, and deployment acceptance remain pending. GitHub reports delayed PR updates and webhook delivery; the current head and checks have now propagated. No earlier-head green badge establishes acceptance of this commit. ## Risks This changes several boundaries of repository setup. Full product and deployment acceptance remain required. The queue uses the existing deferred admission and delivery contracts. Batch deduplication remains in place. The provenance repair requires the actual bound dispatch and does not grant publication authority. Current authorization is still checked at publication. No accepted provider result is rewritten, and completed provider work is not rerun to repair persistence or delivery. ## Model Used OpenAI Codex, GPT-6 family, with repository tools and code execution. The runtime does not expose its exact deployment ID or context-window size. Those values are not inferred. ## 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 - [ ] All Paperclip CI gates are green - [ ] 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> Co-authored-by: Paperclip <paperclip@paperclip.ing>
135 lines
10 KiB
TypeScript
135 lines
10 KiB
TypeScript
import { measureRunPhase } from "./execution-preparation-timing.js";
|
|
import { agents, executionWorkspaces, heartbeatRuns, issues, projects, projectWorkspaces, type Db } from "@paperclipai/db";
|
|
import { and, eq, inArray, or, sql } from "drizzle-orm";
|
|
import { forbidden } from "../errors.js";
|
|
import { accessService } from "./access.js";
|
|
import { executionWorkspaceReadSqlCondition, type AuthorizationActor } from "./authorization.js";
|
|
import { canonicalExecutionUuid } from "./execution-identity.js";
|
|
import { assertSelectedWorkspaceTrust, runRetainsIssueIdentity, taskRepositoryRunColumns, taskRepositoryResponsibleUserMatches } from "./task-repository-acquisition.js";
|
|
import { resolveCoreTrustPreset } from "./trust-preset-resolver.js";
|
|
import { isDeepStrictEqual } from "node:util";
|
|
import { taskWorkspaceSelectionSchema, type TaskWorkspaceSelection } from "@paperclipai/shared";
|
|
|
|
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
|
|
|
export async function assertTaskWorkspaceSourceProjectAccess(
|
|
db: Db | DbTransaction, actor: AuthorizationActor, companyId: string, projectId: string | null,
|
|
) {
|
|
if (!projectId) return;
|
|
const decision = await measureRunPhase("workspace.access.authorization", () => accessService(db as Db).decide({
|
|
actor, action: "project:read", resource: { type: "project", companyId, projectId },
|
|
}));
|
|
if (!decision.allowed) throw forbidden(decision.explanation);
|
|
}
|
|
|
|
export async function assertExecutionWorkspaceRecordAccess(
|
|
db: Db | DbTransaction, actor: AuthorizationActor, companyId: string, workspaceId: string,
|
|
) {
|
|
const [workspace] = await measureRunPhase("workspace.access.inventory", async () => db.select().from(executionWorkspaces).where(and(
|
|
eq(executionWorkspaces.id, workspaceId), eq(executionWorkspaces.companyId, companyId),
|
|
await measureRunPhase("workspace.access.privacy", () => executionWorkspaceReadSqlCondition(db, actor)),
|
|
)));
|
|
if (!workspace) throw forbidden("Task workspace access is no longer available");
|
|
await assertTaskWorkspaceSourceProjectAccess(db, actor, companyId, workspace.projectId);
|
|
const retainedIssueIds = Object.keys(workspace.metadata?._issuePrivacySources ?? {});
|
|
if (workspace.sourceIssueId) retainedIssueIds.push(workspace.sourceIssueId);
|
|
const sources = await measureRunPhase("workspace.access.inventory", async () => db.select({ id: issues.id }).from(issues).where(and(
|
|
eq(issues.companyId, companyId),
|
|
or(eq(issues.executionWorkspaceId, workspaceId), retainedIssueIds.length > 0
|
|
? inArray(sql`${issues.id}`, retainedIssueIds.map(id => canonicalExecutionUuid(sql`${id}::text`))) : undefined),
|
|
)));
|
|
const sourceIds = new Set(sources.map(source => source.id));
|
|
if (retainedIssueIds.some(id => !sourceIds.has(id))) throw forbidden("Task workspace source is no longer accessible");
|
|
const access = accessService(db as Db);
|
|
if (sources.length === 0 && !workspace.projectId) {
|
|
const decision = await measureRunPhase("workspace.access.authorization", () => access.decide({ actor, action: "company_scope:read", resource: { type: "company", companyId } }));
|
|
if (!decision.allowed) throw forbidden("An unscoped workspace is outside this actor's authorization boundary");
|
|
}
|
|
for (const source of sources) {
|
|
const decision = await measureRunPhase("workspace.access.authorization", () => access.decide({ actor, action: "issue:read", resource: { type: "issue", companyId, issueId: source.id } }));
|
|
if (!decision.allowed) throw forbidden("Workspace record contains task data outside this actor's authorization boundary");
|
|
}
|
|
}
|
|
|
|
/** Authorize this task's selected execution inputs without classifying disk contents. */
|
|
export async function assertTaskWorkspaceExecutionAccess(
|
|
db: Db | DbTransaction,
|
|
actor: AuthorizationActor,
|
|
companyId: string,
|
|
input: { issueId: string; workspaceId?: string | null; projectWorkspaceId?: string | null; persistedSelection?: TaskWorkspaceSelection },
|
|
) {
|
|
return measureRunPhase("workspace.access.execution", async () => {
|
|
if (actor.companyId && actor.companyId !== companyId) throw forbidden("Task workspace belongs to another company");
|
|
const [task] = await measureRunPhase("workspace.access.task_read", async () => db.select({
|
|
id: issues.id, companyId: issues.companyId, projectId: issues.projectId,
|
|
executionPolicy: issues.executionPolicy, executionWorkspaceId: issues.executionWorkspaceId,
|
|
projectWorkspaceId: issues.projectWorkspaceId, workspaceSelection: issues.workspaceSelection,
|
|
workspacePendingSelection: issues.workspacePendingSelection, workspaceBindingRevision: issues.workspaceBindingRevision,
|
|
}).from(issues).where(and(eq(issues.id, input.issueId), eq(issues.companyId, companyId))));
|
|
if (!task) throw forbidden("Task workspace task is unavailable");
|
|
const access = accessService(db as Db);
|
|
const [agent] = actor.type === "agent" && actor.agentId
|
|
? await measureRunPhase("workspace.access.agent_read", async () => db.select({ id: agents.id, companyId: agents.companyId, permissions: agents.permissions })
|
|
.from(agents).where(and(eq(agents.id, actor.agentId!), eq(agents.companyId, companyId)))) : [];
|
|
if (actor.type === "agent" && !agent) throw forbidden("Task workspace agent is unavailable");
|
|
const [run] = actor.runId ? await measureRunPhase("workspace.access.run_read", async () => db.select(taskRepositoryRunColumns).from(heartbeatRuns)
|
|
.where(and(eq(heartbeatRuns.id, actor.runId!), eq(heartbeatRuns.companyId, companyId)))) : [];
|
|
if ((actor.source === "agent_jwt" && !actor.runId) || (actor.runId && (!run
|
|
|| run.agentId !== actor.agentId || !runRetainsIssueIdentity(run, task.id)))) {
|
|
throw forbidden("Task workspace run identity is unavailable");
|
|
}
|
|
if (run && !taskRepositoryResponsibleUserMatches(actor.onBehalfOfUserId, run.responsibleUserId)) {
|
|
throw forbidden("Task workspace responsible user changed");
|
|
}
|
|
const decision = await measureRunPhase("workspace.access.task_authorization", () => access.decide({ actor, action: "issue:read", resource: { type: "issue", companyId, issueId: task.id } }));
|
|
if (!decision.allowed) throw forbidden(decision.explanation);
|
|
const selection = task.workspaceSelection?.selection;
|
|
const pending = task.workspacePendingSelection;
|
|
const pendingSelection = pending?.expectedBindingRevision === task.workspaceBindingRevision ? pending.intent.selection : undefined;
|
|
const effectiveSelection = input.persistedSelection !== undefined
|
|
? taskWorkspaceSelectionSchema.parse(input.persistedSelection) : undefined;
|
|
if (effectiveSelection && (input.workspaceId != null || input.projectWorkspaceId != null
|
|
|| (!isDeepStrictEqual(effectiveSelection, selection) && !isDeepStrictEqual(effectiveSelection, pendingSelection)))) {
|
|
throw forbidden("Task workspace selection is not a current persisted intent");
|
|
}
|
|
const workspaceId = effectiveSelection
|
|
? effectiveSelection.kind === "existing" ? effectiveSelection.workspaceId : null
|
|
: input.workspaceId;
|
|
const [workspace] = workspaceId ? await measureRunPhase("workspace.access.workspace_read", async () => db.select({
|
|
id: executionWorkspaces.id, companyId: executionWorkspaces.companyId,
|
|
projectId: executionWorkspaces.projectId, projectWorkspaceId: executionWorkspaces.projectWorkspaceId,
|
|
}).from(executionWorkspaces).where(and(eq(executionWorkspaces.id, workspaceId), eq(executionWorkspaces.companyId, companyId)))) : [];
|
|
if (workspaceId && (!workspace || (task.executionWorkspaceId !== workspace.id
|
|
&& !(selection?.kind === "existing" && selection.workspaceId === workspace.id)
|
|
&& !(pendingSelection?.kind === "existing" && pendingSelection.workspaceId === workspace.id)))) {
|
|
throw forbidden("Task workspace binding is unavailable");
|
|
}
|
|
const selectedSourceId = effectiveSelection
|
|
? effectiveSelection.kind === "configured_source" ? effectiveSelection.projectWorkspaceId : workspace?.projectWorkspaceId ?? null
|
|
: input.projectWorkspaceId ?? workspace?.projectWorkspaceId ?? task.projectWorkspaceId;
|
|
if (input.projectWorkspaceId && input.projectWorkspaceId !== task.projectWorkspaceId
|
|
&& input.projectWorkspaceId !== workspace?.projectWorkspaceId
|
|
&& !(selection?.kind === "configured_source" && selection.projectWorkspaceId === input.projectWorkspaceId)
|
|
&& !(pendingSelection?.kind === "configured_source" && pendingSelection.projectWorkspaceId === input.projectWorkspaceId)) {
|
|
throw forbidden("Task workspace source binding is unavailable");
|
|
}
|
|
const [source] = selectedSourceId ? await measureRunPhase("workspace.access.source_read", async () => db.select({ id: projectWorkspaces.id, projectId: projectWorkspaces.projectId })
|
|
.from(projectWorkspaces).where(and(eq(projectWorkspaces.id, selectedSourceId), eq(projectWorkspaces.companyId, companyId)))) : [];
|
|
if (selectedSourceId && !source) throw forbidden("Task workspace source is unavailable");
|
|
const projectIds = [...new Set([task.projectId, workspace?.projectId, source?.projectId].filter((id): id is string => Boolean(id)))];
|
|
const selectedProjects = projectIds.length ? await measureRunPhase("workspace.access.projects_read", async () => db.select({ id: projects.id, companyId: projects.companyId, executionWorkspacePolicy: projects.executionWorkspacePolicy })
|
|
.from(projects).where(and(eq(projects.companyId, companyId), inArray(projects.id, projectIds)))) : [];
|
|
if (selectedProjects.length !== projectIds.length) throw forbidden("Task workspace project is unavailable");
|
|
for (const projectId of projectIds) await assertTaskWorkspaceSourceProjectAccess(db, actor, companyId, projectId);
|
|
const sourceProjectId = source?.projectId ?? workspace?.projectId;
|
|
const trust = resolveCoreTrustPreset({ companyId, agent, issue: task,
|
|
project: selectedProjects.find(project => project.id === task.projectId),
|
|
workspaceSourceProject: selectedProjects.find(project => project.id === sourceProjectId),
|
|
run: run ? { companyId: run.companyId, executionPolicy: run.contextSnapshot?.executionPolicy } : null });
|
|
if (trust.kind === "denied") throw forbidden(trust.detail);
|
|
await measureRunPhase("workspace.access.trust", () => assertSelectedWorkspaceTrust(db, { companyId, actor, issueId: task.id,
|
|
sourceProjectId: sourceProjectId ?? null, sourceWorkspaceId: effectiveSelection ? workspace?.id ?? null : workspace?.id }));
|
|
return { task, workspace, source };
|
|
});
|
|
}
|