feat: gate issue-bound heartbeat run data

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-01 22:54:01 +00:00
1 parent 6959c3d365
commit d18b769fe4
18 files changed
+700 -20

No files matched your search

+3 -7
View File
@@ -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,
};
}
+24 -2
View File
@@ -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",
+1
View File
@@ -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",