mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 21:07:40 +01:00
feat: gate issue-bound heartbeat run data
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
6959c3d365
commit
d18b769fe4
18 files changed
+700
-20
No files matched your search
@@ -415,7 +415,7 @@ export function activityService(db: Db) {
|
||||
and(
|
||||
eq(heartbeatRuns.companyId, companyId),
|
||||
or(
|
||||
sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issueId}`,
|
||||
eq(heartbeatRuns.issueId, issueId),
|
||||
sql`exists (
|
||||
select 1
|
||||
from ${activityLog}
|
||||
@@ -522,7 +522,7 @@ export function activityService(db: Db) {
|
||||
const run = await db
|
||||
.select({
|
||||
companyId: heartbeatRuns.companyId,
|
||||
contextSnapshot: heartbeatRuns.contextSnapshot,
|
||||
issueId: heartbeatRuns.issueId,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
@@ -549,11 +549,7 @@ export function activityService(db: Db) {
|
||||
)
|
||||
.orderBy(issueIdAsText);
|
||||
|
||||
const context = run.contextSnapshot;
|
||||
const contextIssueId =
|
||||
context && typeof context === "object" && typeof (context as Record<string, unknown>).issueId === "string"
|
||||
? ((context as Record<string, unknown>).issueId as string)
|
||||
: null;
|
||||
const contextIssueId = run.issueId;
|
||||
if (!contextIssueId) return fromActivity;
|
||||
if (fromActivity.some((issue) => issue.issueId === contextIssueId)) return fromActivity;
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { AuthorizationActor, AuthorizationDecision } from "./authorization.js";
|
||||
|
||||
type RunIssueBinding = {
|
||||
companyId: string;
|
||||
issueId?: string | null;
|
||||
};
|
||||
|
||||
export async function canActorReadHeartbeatRun(
|
||||
access: {
|
||||
decide(input: {
|
||||
actor: AuthorizationActor;
|
||||
action: "issue:read";
|
||||
resource: {
|
||||
type: "issue";
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
projectId: null;
|
||||
parentIssueId: null;
|
||||
assigneeAgentId: null;
|
||||
assigneeUserId: null;
|
||||
};
|
||||
}): Promise<AuthorizationDecision>;
|
||||
},
|
||||
actor: AuthorizationActor,
|
||||
run: RunIssueBinding,
|
||||
): Promise<boolean> {
|
||||
if (!run.issueId) return true;
|
||||
|
||||
const decision = await access.decide({
|
||||
actor,
|
||||
action: "issue:read",
|
||||
resource: {
|
||||
type: "issue",
|
||||
companyId: run.companyId,
|
||||
issueId: run.issueId,
|
||||
projectId: null,
|
||||
parentIssueId: null,
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
},
|
||||
});
|
||||
return decision.allowed;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function firstFinite(...values: unknown[]): number | null {
|
||||
for (const value of values) {
|
||||
const parsed = finiteNumber(value);
|
||||
if (parsed !== null) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickTokenUsage(value: unknown) {
|
||||
const usage = asRecord(value);
|
||||
if (!usage) return null;
|
||||
const picked = {
|
||||
inputTokens: firstFinite(usage.inputTokens, usage.input_tokens),
|
||||
cachedInputTokens: firstFinite(usage.cachedInputTokens, usage.cached_input_tokens),
|
||||
outputTokens: firstFinite(usage.outputTokens, usage.output_tokens),
|
||||
totalTokens: firstFinite(usage.totalTokens, usage.total_tokens),
|
||||
};
|
||||
return Object.values(picked).some((item) => item !== null) ? picked : null;
|
||||
}
|
||||
|
||||
function runDurationMs(row: Record<string, unknown>) {
|
||||
const startedAt = row.startedAt instanceof Date ? row.startedAt : new Date(String(row.startedAt ?? ""));
|
||||
const finishedAt = row.finishedAt instanceof Date ? row.finishedAt : new Date(String(row.finishedAt ?? ""));
|
||||
if (Number.isNaN(startedAt.getTime())) return null;
|
||||
const endMs = Number.isNaN(finishedAt.getTime()) ? Date.now() : finishedAt.getTime();
|
||||
return Math.max(0, endMs - startedAt.getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* Locked list-row disclosure for an issue-bound run the actor cannot read.
|
||||
* Keep only identity, timing, status, and budget-oversight fields.
|
||||
*/
|
||||
export function redactHeartbeatRunListRow<T extends Record<string, unknown>>(row: T) {
|
||||
const usage = asRecord(row.usageJson);
|
||||
const result = asRecord(row.resultJson);
|
||||
const costUsd = firstFinite(
|
||||
row.costUsd,
|
||||
result?.costUsd,
|
||||
result?.cost_usd,
|
||||
result?.total_cost_usd,
|
||||
usage?.costUsd,
|
||||
usage?.cost_usd,
|
||||
usage?.total_cost_usd,
|
||||
);
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
agentId: row.agentId,
|
||||
agentName: row.agentName,
|
||||
adapterType: row.adapterType,
|
||||
invocationSource: row.invocationSource,
|
||||
status: row.status,
|
||||
startedAt: row.startedAt ?? null,
|
||||
finishedAt: row.finishedAt ?? null,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
durationMs: runDurationMs(row),
|
||||
costUsd,
|
||||
usageJson: pickTokenUsage(row.usageJson ?? {
|
||||
inputTokens: row.inputTokens,
|
||||
cachedInputTokens: row.cachedInputTokens,
|
||||
outputTokens: row.outputTokens,
|
||||
totalTokens: row.totalTokens,
|
||||
}),
|
||||
redacted: true,
|
||||
};
|
||||
}
|
||||
@@ -1938,9 +1938,19 @@ const heartbeatRunListColumns = {
|
||||
id: heartbeatRuns.id,
|
||||
companyId: heartbeatRuns.companyId,
|
||||
agentId: heartbeatRuns.agentId,
|
||||
issueId: heartbeatRuns.issueId,
|
||||
invocationSource: heartbeatRuns.invocationSource,
|
||||
triggerDetail: heartbeatRuns.triggerDetail,
|
||||
status: heartbeatRuns.status,
|
||||
inputTokens: sql<number | null>`(${heartbeatRuns.usageJson} ->> 'inputTokens')::numeric`.as("inputTokens"),
|
||||
cachedInputTokens: sql<number | null>`(${heartbeatRuns.usageJson} ->> 'cachedInputTokens')::numeric`.as("cachedInputTokens"),
|
||||
outputTokens: sql<number | null>`(${heartbeatRuns.usageJson} ->> 'outputTokens')::numeric`.as("outputTokens"),
|
||||
totalTokens: sql<number | null>`(${heartbeatRuns.usageJson} ->> 'totalTokens')::numeric`.as("totalTokens"),
|
||||
costUsd: sql<number | null>`coalesce(
|
||||
(${heartbeatRuns.resultJson} ->> 'costUsd')::numeric,
|
||||
(${heartbeatRuns.resultJson} ->> 'cost_usd')::numeric,
|
||||
(${heartbeatRuns.resultJson} ->> 'total_cost_usd')::numeric
|
||||
)`.as("costUsd"),
|
||||
startedAt: heartbeatRuns.startedAt,
|
||||
finishedAt: heartbeatRuns.finishedAt,
|
||||
error: heartbeatRuns.error,
|
||||
@@ -1995,7 +2005,10 @@ const heartbeatRunSummaryListColumns = {
|
||||
} as const;
|
||||
|
||||
const heartbeatRunListContextColumns = {
|
||||
contextIssueId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`.as("contextIssueId"),
|
||||
contextIssueId: sql<string | null>`coalesce(
|
||||
${heartbeatRuns.issueId}::text,
|
||||
${heartbeatRuns.contextSnapshot} ->> 'issueId'
|
||||
)`.as("contextIssueId"),
|
||||
contextTaskId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'taskId'`.as("contextTaskId"),
|
||||
contextTaskKey: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'taskKey'`.as("contextTaskKey"),
|
||||
contextCommentId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'commentId'`.as("contextCommentId"),
|
||||
@@ -2079,6 +2092,7 @@ const heartbeatRunSqlAsciiSafeColumns = {
|
||||
const heartbeatRunLogAccessColumns = {
|
||||
id: heartbeatRuns.id,
|
||||
companyId: heartbeatRuns.companyId,
|
||||
issueId: heartbeatRuns.issueId,
|
||||
logStore: heartbeatRuns.logStore,
|
||||
logRef: heartbeatRuns.logRef,
|
||||
} as const;
|
||||
@@ -2105,7 +2119,7 @@ const heartbeatRunIssueSummaryColumns = {
|
||||
lastOutputSeq: heartbeatRuns.lastOutputSeq,
|
||||
lastOutputStream: heartbeatRuns.lastOutputStream,
|
||||
lastOutputBytes: heartbeatRuns.lastOutputBytes,
|
||||
issueId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`.as("issueId"),
|
||||
issueId: heartbeatRuns.issueId,
|
||||
} as const;
|
||||
|
||||
function appendExcerpt(prev: string, chunk: string) {
|
||||
@@ -9351,6 +9365,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
.values({
|
||||
companyId: run.companyId,
|
||||
agentId: run.agentId,
|
||||
issueId,
|
||||
invocationSource: "automation",
|
||||
triggerDetail: "system",
|
||||
status: "queued",
|
||||
@@ -9601,6 +9616,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
.values({
|
||||
companyId: run.companyId,
|
||||
agentId: run.agentId,
|
||||
issueId,
|
||||
invocationSource: "automation",
|
||||
triggerDetail: "system",
|
||||
status: "queued",
|
||||
@@ -10881,6 +10897,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
.values({
|
||||
companyId: run.companyId,
|
||||
agentId: run.agentId,
|
||||
issueId,
|
||||
invocationSource: "automation",
|
||||
triggerDetail: "system",
|
||||
status: "scheduled_retry",
|
||||
@@ -15820,6 +15837,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
.values({
|
||||
companyId: deferredAgent.companyId,
|
||||
agentId: deferredAgent.id,
|
||||
issueId: issue.id,
|
||||
invocationSource: promotedSource,
|
||||
triggerDetail: promotedTriggerDetail,
|
||||
status: "queued",
|
||||
@@ -15974,6 +15992,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
.values({
|
||||
companyId: issue.companyId,
|
||||
agentId: recoveryAgent.id,
|
||||
issueId: issue.id,
|
||||
invocationSource: "automation",
|
||||
triggerDetail: "system",
|
||||
status: "queued",
|
||||
@@ -16140,6 +16159,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
.values({
|
||||
companyId: issue.companyId,
|
||||
agentId: recoveryAgent.id,
|
||||
issueId: issue.id,
|
||||
invocationSource: "automation",
|
||||
triggerDetail: "system",
|
||||
status: "queued",
|
||||
@@ -17257,6 +17277,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
.values({
|
||||
companyId: agent.companyId,
|
||||
agentId,
|
||||
issueId: readNonEmptyString(enrichedContextSnapshot.issueId),
|
||||
invocationSource: source,
|
||||
triggerDetail,
|
||||
status: "queued",
|
||||
@@ -17431,6 +17452,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
.values({
|
||||
companyId: agent.companyId,
|
||||
agentId,
|
||||
issueId: readNonEmptyString(enrichedContextSnapshot.issueId),
|
||||
invocationSource: source,
|
||||
triggerDetail,
|
||||
status: "queued",
|
||||
|
||||
@@ -3052,6 +3052,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
.values({
|
||||
companyId: input.issue.companyId,
|
||||
agentId: input.agentId,
|
||||
issueId: input.issue.id,
|
||||
invocationSource: "automation",
|
||||
triggerDetail: "system",
|
||||
status: "scheduled_retry",
|
||||
|
||||
Reference in new issue
Block a user