mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 04:02:18 +01:00
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 <noreply@paperclip.ing>
130 lines
5.0 KiB
TypeScript
130 lines
5.0 KiB
TypeScript
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<HeartbeatRunRow, "id" | "companyId" | "agentId" | "scopeKind" | "nativeIssueId" | "issueId"> & {
|
|
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: CreationSourceRun;
|
|
sourceIssue: IssueRow;
|
|
}
|
|
|
|
const CREATION_ACTIVITY_ACTIONS = ["issue.created", "issue.child_created"];
|
|
|
|
function readNonEmptyString(value: unknown): string | null {
|
|
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
}
|
|
|
|
/**
|
|
* The issue a heartbeat run is bound to: native run identity first, then the
|
|
* run's issue scope, then the persisted legacy task context. Returns a UUID or
|
|
* an issue identifier such as `PAP-168`; the caller resolves identifiers.
|
|
*/
|
|
export function runBoundIssueReference(run: Pick<HeartbeatRunRow, "nativeIssueId" | "issueId" | "contextSnapshot">): string | null {
|
|
const context = run.contextSnapshot && typeof run.contextSnapshot === "object"
|
|
? (run.contextSnapshot as Record<string, unknown>)
|
|
: null;
|
|
const paperclipIssue = context?.paperclipIssue && typeof context.paperclipIssue === "object"
|
|
? (context.paperclipIssue as Record<string, unknown>)
|
|
: null;
|
|
return (
|
|
run.nativeIssueId ??
|
|
run.issueId ??
|
|
readNonEmptyString(context?.issueId) ??
|
|
readNonEmptyString(context?.taskId) ??
|
|
readNonEmptyString(paperclipIssue?.id) ??
|
|
readNonEmptyString(context?.taskKey)
|
|
);
|
|
}
|
|
|
|
/** Loads the run's bound issue within the run's company, or null. */
|
|
export async function loadRunBoundIssue(
|
|
db: Db,
|
|
run: Pick<HeartbeatRunRow, "companyId" | "nativeIssueId" | "issueId" | "contextSnapshot">,
|
|
): Promise<IssueRow | null> {
|
|
const reference = runBoundIssueReference(run);
|
|
if (!reference) return null;
|
|
const condition = isUuidLike(reference)
|
|
? eq(issues.id, reference)
|
|
: eq(issues.identifier, reference);
|
|
const [issue] = await db
|
|
.select()
|
|
.from(issues)
|
|
.where(and(condition, eq(issues.companyId, run.companyId)))
|
|
.limit(1);
|
|
return issue ?? null;
|
|
}
|
|
|
|
/**
|
|
* Resolves the task from which an issue was created, using recorded run
|
|
* provenance: the issue's origin run, or for historical rows without one, the
|
|
* run on its creation activity. Company-scoped; a run or source task from
|
|
* another company never resolves. Does not check viewer access.
|
|
*/
|
|
export async function resolveIssueCreationSource(
|
|
db: Db,
|
|
issue: Pick<IssueRow, "id" | "companyId" | "originRunId">,
|
|
): Promise<IssueCreationSourceRecord | null> {
|
|
let runId = issue.originRunId;
|
|
if (!runId) {
|
|
const [creation] = await db
|
|
.select({ runId: activityLog.runId })
|
|
.from(activityLog)
|
|
.where(and(
|
|
eq(activityLog.companyId, issue.companyId),
|
|
eq(activityLog.entityType, "issue"),
|
|
eq(activityLog.entityId, issue.id),
|
|
inArray(activityLog.action, CREATION_ACTIVITY_ACTIONS),
|
|
isNotNull(activityLog.runId),
|
|
))
|
|
.orderBy(asc(activityLog.createdAt))
|
|
.limit(1);
|
|
runId = creation?.runId ?? null;
|
|
}
|
|
if (!runId || !isUuidLike(runId)) return null;
|
|
const [run] = await db
|
|
.select({
|
|
id: heartbeatRuns.id,
|
|
companyId: heartbeatRuns.companyId,
|
|
agentId: heartbeatRuns.agentId,
|
|
scopeKind: heartbeatRuns.scopeKind,
|
|
nativeIssueId: heartbeatRuns.nativeIssueId,
|
|
issueId: heartbeatRuns.issueId,
|
|
contextSnapshot: sql<CreationSourceRun["contextSnapshot"]>`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);
|
|
if (!run) return null;
|
|
const sourceIssue = await loadRunBoundIssue(db, run);
|
|
if (!sourceIssue || sourceIssue.id === issue.id) return null;
|
|
return { run, sourceIssue };
|
|
}
|
|
|
|
/** Company-scoped agent summary for a creation source. */
|
|
export async function loadCreationSourceAgent(
|
|
db: Db,
|
|
companyId: string,
|
|
agentId: string,
|
|
): Promise<{ id: string; name: string } | null> {
|
|
const [agent] = await db
|
|
.select({ id: agents.id, name: agents.name })
|
|
.from(agents)
|
|
.where(and(eq(agents.id, agentId), eq(agents.companyId, companyId)))
|
|
.limit(1);
|
|
return agent ?? null;
|
|
}
|