From 3536b3649f98155d92b45cfb5dc3abcc2fea97d6 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Sun, 25 Oct 2026 06:30:25 -0700 Subject: [PATCH] Read only task creation provenance fields (#17604) Narrow saved-run provenance reads to current identity, scope, and typed legacy bindings. Preserve binding precedence, current viewer checks, and complete stored runs. Real PostgreSQL and route tests pass 23 cases; legacy full-context and type-coercion regressions are caught. Exact-head required CI is green; Greptile is 5/5 with Low impact and no unresolved comments. Co-Authored-By: Paperclip --- doc/DATABASE.md | 5 + .../issue-creation-source-read.test.ts | 109 ++++++++++++++++++ server/src/services/issue-creation-source.ts | 24 +++- 3 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 server/src/__tests__/issue-creation-source-read.test.ts diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 10e7bc7a1e6..c4d5c72e41e 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -162,6 +162,11 @@ startup and cancellation receipts, task binding, and the recorded adapter kind. Saved profiles, output excerpts, and unrelated launch context stay in storage. Continuation admission and its locked mutation reads retain their existing gates. +Task creation provenance reads current run identity, scope, and task binding. +Legacy task references retain their typed values and existing precedence. Saved +profiles, output, and unrelated context stay in storage. Viewer access to the +source task and run is still checked before provenance is returned. + The initial agent JWT run lookup reads only the cancellation state and conversation flag from saved run JSON. It does not transfer the full context or result. Run status, responsible-user identity, company and agent boundaries, diff --git a/server/src/__tests__/issue-creation-source-read.test.ts b/server/src/__tests__/issue-creation-source-read.test.ts new file mode 100644 index 00000000000..2b47f3af580 --- /dev/null +++ b/server/src/__tests__/issue-creation-source-read.test.ts @@ -0,0 +1,109 @@ +import { randomUUID } from "node:crypto"; +import { activityLog, agents, companies, createDb, heartbeatRuns, issues } from "@paperclipai/db"; +import { createDatabaseResponseCapture, installDatabaseResponseCapture } from "@paperclipai/db/response-capture"; +import { eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +import { resolveIssueCreationSource, runBoundIssueReference } from "../services/issue-creation-source.js"; + +const support = await getEmbeddedPostgresTestSupport(); + +describe.skipIf(!support.supported)("creation source run projections", () => { + const companyId = randomUUID(), otherCompanyId = randomUUID(), agentId = randomUUID(); + const sourceId = randomUUID(), secondId = randomUUID(), foreignId = randomUUID(); + let database: Awaited>; + let db: ReturnType; + let childNumber = 100; + + beforeAll(async () => { + database = await startEmbeddedPostgresTestDatabase("creation-source-read-"); + db = createDb(database.connectionString); + await db.insert(companies).values([ + { id: companyId, name: "Creation source fixture", issuePrefix: "CSR" }, + { id: otherCompanyId, name: "Foreign source fixture", issuePrefix: "FSR" }, + ]); + await db.insert(agents).values({ id: agentId, companyId, name: "Source agent", adapterType: "process" }); + await db.insert(issues).values([ + { id: sourceId, companyId, title: "Source task", identifier: "CSR-1", issueNumber: 1 }, + { id: secondId, companyId, title: "Second source task", identifier: "CSR-2", issueNumber: 2 }, + { id: foreignId, companyId: otherCompanyId, title: "Foreign task", identifier: "FSR-1", issueNumber: 1 }, + ]); + }, 60_000); + + afterAll(async () => { + await db?.$client.end(); + await database?.cleanup(); + }); + + async function fixture(values: Partial) { + const [run] = await db.insert(heartbeatRuns).values({ companyId, agentId, status: "succeeded", ...values }).returning(); + const number = childNumber++; + const [child] = await db.insert(issues).values({ companyId, title: "Created task", identifier: `CSR-${number}`, + issueNumber: number, originRunId: run.id }).returning(); + return { run, child }; + } + + it.each(["scoped", "legacy task key", "legacy nested identifier"])("omits saved run payloads for %s bindings while preserving provenance and storage", async (binding) => { + const bulk = "retained-source-context🙂".repeat(32_768); + const contextSnapshot = binding === "scoped" + ? { issueId: sourceId, paperclipIssue: { id: sourceId, retainedDescription: bulk }, retainedLaunchInput: bulk } + : binding === "legacy task key" + ? { taskKey: "CSR-1", paperclipIssue: { retainedDescription: bulk }, retainedLaunchInput: bulk } + : { paperclipIssue: { id: "CSR-1", retainedDescription: bulk }, retainedLaunchInput: bulk }; + const { run, child } = await fixture({ contextSnapshot, + runnerProfileJson: { retainedInput: bulk }, stdoutExcerpt: bulk, stderrExcerpt: bulk, resultJson: { retainedReceipt: bulk } }); + expect(run.issueId).toBe(binding === "scoped" ? sourceId : null); + expect(run.nativeIssueId).toBeNull(); + const observed = createDb(database.connectionString), events: Array> = [], now = Date.now(); + const capture = createDatabaseResponseCapture({ start: now - 100, end: now + 59_000 }, event => events.push(event)); + installDatabaseResponseCapture(observed, { heartbeatRuns, issues }, capture); + try { + const result = await resolveIssueCreationSource(observed, child); + capture.flush(); + const reads = events.filter(event => event.table === "heartbeat_runs" && event.operation === "select"); + expect(reads.reduce((count, event) => count + Number(event.calls), 0)).toBe(1); + expect(reads.reduce((bytes, event) => bytes + Number(event.responseValueBytes), 0)).toBeLessThan(8192); + expect(result).toMatchObject({ run: { id: run.id, companyId, agentId }, sourceIssue: { id: sourceId } }); + expect(runBoundIssueReference(result!.run)).toBe(binding === "scoped" ? sourceId : "CSR-1"); + for (const key of ["runnerProfileJson", "stdoutExcerpt", "stderrExcerpt", "resultJson"]) expect(result!.run).not.toHaveProperty(key); + expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run.id)))[0]).toEqual(run); + expect(JSON.stringify(events)).not.toContain("retained-source-context"); + } finally { + capture.flush(); + await observed.$client.end(); + } + }); + + it.each([ + { label: "native identity", nativeIssueId: sourceId, issueId: secondId, contextSnapshot: { issueId: secondId }, expected: sourceId }, + { label: "issue scope", issueId: sourceId, contextSnapshot: { issueId: secondId }, expected: sourceId }, + { label: "legacy issue id", contextSnapshot: { issueId: sourceId, taskId: secondId }, expected: sourceId }, + { label: "typed task id fallback", contextSnapshot: { issueId: 42, taskId: sourceId }, expected: sourceId }, + { label: "nested issue identifier", contextSnapshot: { issueId: " ", paperclipIssue: { id: "CSR-1", ignored: "payload" }, taskKey: "CSR-2" }, expected: sourceId }, + { label: "typed nested id fallback", contextSnapshot: { paperclipIssue: { id: 42 }, taskKey: "CSR-1" }, expected: sourceId }, + { label: "foreign context", contextSnapshot: { taskId: foreignId }, expected: null }, + { label: "null context", contextSnapshot: null, expected: null }, + ])("preserves binding precedence for $label", async ({ label: _label, expected, ...values }) => { + const { child } = await fixture(values); + expect((await resolveIssueCreationSource(db, child))?.sourceIssue.id ?? null).toBe(expected); + }); + + it.each([42, "scalar", ["issueId", sourceId]])("does not invent a task binding from malformed context %j", async (context) => { + const { child } = await fixture({ contextSnapshot: context as unknown as Record }); + expect(await resolveIssueCreationSource(db, child)).toBeNull(); + }); + + it("preserves historical activity lookup, current bindings, and missing or foreign source denial", async () => { + const { run, child } = await fixture({ contextSnapshot: { taskKey: "CSR-1" } }); + await db.update(issues).set({ originRunId: null }).where(eq(issues.id, child.id)); + await db.insert(activityLog).values({ companyId, actorType: "system", actorId: "fixture", action: "issue.created", + entityType: "issue", entityId: child.id, runId: run.id }); + expect((await resolveIssueCreationSource(db, { ...child, originRunId: null }))?.sourceIssue.id).toBe(sourceId); + await db.update(heartbeatRuns).set({ contextSnapshot: { taskKey: "CSR-2" } }).where(eq(heartbeatRuns.id, run.id)); + expect((await resolveIssueCreationSource(db, child))?.sourceIssue.id).toBe(secondId); + expect(await resolveIssueCreationSource(db, { ...child, companyId: otherCompanyId })).toBeNull(); + expect(await resolveIssueCreationSource(db, { ...child, originRunId: randomUUID() })).toBeNull(); + await db.update(heartbeatRuns).set({ contextSnapshot: { taskId: child.id } }).where(eq(heartbeatRuns.id, run.id)); + expect(await resolveIssueCreationSource(db, child)).toBeNull(); + }); +}); diff --git a/server/src/services/issue-creation-source.ts b/server/src/services/issue-creation-source.ts index 42f5ce816a7..c40c898fd39 100644 --- a/server/src/services/issue-creation-source.ts +++ b/server/src/services/issue-creation-source.ts @@ -1,14 +1,17 @@ -import { and, asc, eq, inArray, isNotNull } from "drizzle-orm"; +import { and, asc, eq, inArray, isNotNull, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { activityLog, agents, heartbeatRuns, issues } from "@paperclipai/db"; import { isUuidLike } from "@paperclipai/shared"; type IssueRow = typeof issues.$inferSelect; type HeartbeatRunRow = typeof heartbeatRuns.$inferSelect; +type CreationSourceRun = Pick & { + contextSnapshot: { issueId: unknown; taskId: unknown; taskKey: unknown; paperclipIssue: { id: unknown } } | null; +}; /** The task a run was executing when it created another task. */ export interface IssueCreationSourceRecord { - run: HeartbeatRunRow; + run: CreationSourceRun; sourceIssue: IssueRow; } @@ -86,7 +89,22 @@ export async function resolveIssueCreationSource( } if (!runId || !isUuidLike(runId)) return null; const [run] = await db - .select() + .select({ + id: heartbeatRuns.id, + companyId: heartbeatRuns.companyId, + agentId: heartbeatRuns.agentId, + scopeKind: heartbeatRuns.scopeKind, + nativeIssueId: heartbeatRuns.nativeIssueId, + issueId: heartbeatRuns.issueId, + contextSnapshot: sql`case + when coalesce(${heartbeatRuns.nativeIssueId}, ${heartbeatRuns.issueId}) is not null then null + else jsonb_build_object( + 'issueId', ${heartbeatRuns.contextSnapshot}->'issueId', + 'taskId', ${heartbeatRuns.contextSnapshot}->'taskId', + 'taskKey', ${heartbeatRuns.contextSnapshot}->'taskKey', + 'paperclipIssue', jsonb_build_object('id', ${heartbeatRuns.contextSnapshot}->'paperclipIssue'->'id') + ) end`, + }) .from(heartbeatRuns) .where(and(eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, issue.companyId))) .limit(1);