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

@@ -0,0 +1,281 @@
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
agents,
companies,
createDb,
heartbeatRunEvents,
heartbeatRuns,
issueAccessGrants,
issues,
workspaceOperations,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { errorHandler } from "../middleware/index.js";
import { activityRoutes } from "../routes/activity.js";
import { agentRoutes } from "../routes/agents.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
describeEmbeddedPostgres.sequential("heartbeat run privacy routes", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
const previousPrivacyMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-run-privacy-routes-");
db = createDb(tempDb.connectionString);
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce";
}, 20_000);
afterEach(async () => {
await db.delete(workspaceOperations);
await db.delete(heartbeatRunEvents);
await db.delete(issueAccessGrants);
await db.delete(heartbeatRuns);
await db.delete(issues);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
if (previousPrivacyMode === undefined) delete process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
else process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = previousPrivacyMode;
await tempDb?.cleanup();
});
async function seedFixture() {
const companyId = randomUUID();
const ownerAgentId = randomUUID();
const grantedAgentId = randomUUID();
const otherAgentId = randomUUID();
const issueId = randomUUID();
const privateRunId = randomUUID();
const maintenanceRunId = randomUUID();
const operationId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Run Privacy",
issuePrefix: "RPR",
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values([
{
id: ownerAgentId,
companyId,
name: "Owner",
role: "engineer",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
{
id: grantedAgentId,
companyId,
name: "Granted reviewer",
role: "security",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
{
id: otherAgentId,
companyId,
name: "Other agent",
role: "engineer",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
},
]);
await db.insert(issues).values({
id: issueId,
companyId,
identifier: "RPR-1",
title: "Confidential email triage",
description: "private@example.test",
visibility: "private",
privacyRootIssueId: issueId,
status: "in_progress",
assigneeAgentId: ownerAgentId,
});
await db.insert(issueAccessGrants).values({
issueId,
subjectType: "agent",
subjectId: grantedAgentId,
source: "explicit",
grantedByAgentId: ownerAgentId,
});
const startedAt = new Date("2026-07-31T12:00:00.000Z");
const finishedAt = new Date("2026-07-31T12:01:00.000Z");
await db.insert(heartbeatRuns).values([
{
id: privateRunId,
companyId,
agentId: ownerAgentId,
issueId,
invocationSource: "assignment",
status: "running",
startedAt,
finishedAt,
usageJson: { inputTokens: 11, cachedInputTokens: 2, outputTokens: 7 },
resultJson: { summary: "Read the confidential email", costUsd: 0.42 },
contextSnapshot: { issueId, title: "Confidential email triage" },
logStore: "local_file",
logRef: "missing-private-run-log.ndjson",
},
{
id: maintenanceRunId,
companyId,
agentId: ownerAgentId,
issueId: null,
invocationSource: "timer",
status: "succeeded",
startedAt,
finishedAt,
resultJson: { summary: "Maintenance complete", costUsd: 0.01 },
contextSnapshot: { wakeReason: "heartbeat_timer" },
},
]);
await db.insert(heartbeatRunEvents).values({
companyId,
runId: privateRunId,
agentId: ownerAgentId,
seq: 1,
eventType: "output",
stream: "stdout",
message: "private@example.test",
});
await db.insert(workspaceOperations).values({
id: operationId,
companyId,
heartbeatRunId: privateRunId,
issueId: null,
phase: "provision",
status: "succeeded",
command: "read private email",
stdoutExcerpt: "private@example.test",
logStore: "local_file",
logRef: "missing-private-operation-log.ndjson",
});
return { companyId, ownerAgentId, grantedAgentId, otherAgentId, issueId, privateRunId, maintenanceRunId, operationId };
}
function createApp(companyId: string, agentId: string) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = {
type: "agent",
agentId,
companyId,
source: "agent_jwt",
};
next();
});
app.use("/api", agentRoutes(db));
app.use("/api", activityRoutes(db));
app.use(errorHandler);
return app;
}
it("returns 404 for direct private-run content and metadata-only list stubs to a non-member", async () => {
const fixture = await seedFixture();
const app = createApp(fixture.companyId, fixture.otherAgentId);
for (const path of [
`/api/heartbeat-runs/${fixture.privateRunId}`,
`/api/heartbeat-runs/${fixture.privateRunId}/events`,
`/api/heartbeat-runs/${fixture.privateRunId}/log`,
`/api/heartbeat-runs/${fixture.privateRunId}/workspace-operations`,
`/api/workspace-operations/${fixture.operationId}/log`,
]) {
const response = await request(app).get(path);
expect(response.status, path).toBe(404);
expect(JSON.stringify(response.body)).not.toContain("private@example.test");
}
const history = await request(app).get(`/api/companies/${fixture.companyId}/heartbeat-runs`);
expect(history.status).toBe(200);
const privateStub = history.body.find((run: { id: string }) => run.id === fixture.privateRunId);
expect(privateStub).toMatchObject({
redacted: true,
durationMs: 60_000,
costUsd: 0.42,
usageJson: { inputTokens: 11, cachedInputTokens: 2, outputTokens: 7 },
});
expect(privateStub).not.toHaveProperty("issueId");
expect(privateStub).not.toHaveProperty("contextSnapshot");
expect(privateStub).not.toHaveProperty("resultJson");
expect(JSON.stringify(privateStub)).not.toContain("Confidential email triage");
expect(JSON.stringify(privateStub)).not.toContain("summary");
const summarizedHistory = await request(app)
.get(`/api/companies/${fixture.companyId}/heartbeat-runs?summary=true`);
const summarizedStub = summarizedHistory.body.find((run: { id: string }) => run.id === fixture.privateRunId);
expect(summarizedStub).toMatchObject({ redacted: true, durationMs: 60_000, costUsd: 0.42 });
expect(summarizedStub.usageJson).toMatchObject({ inputTokens: 11, outputTokens: 7 });
const live = await request(app).get(`/api/companies/${fixture.companyId}/live-runs`);
expect(live.status).toBe(200);
expect(live.body[0]).toMatchObject({ id: fixture.privateRunId, redacted: true, costUsd: 0.42 });
expect(live.body[0]).not.toHaveProperty("issueId");
const maintenance = await request(app).get(`/api/heartbeat-runs/${fixture.maintenanceRunId}`);
expect(maintenance.status).toBe(200);
expect(maintenance.body.resultJson.summary).toBe("Maintenance complete");
});
it("returns full private-run content to the owner and an explicitly granted agent", async () => {
const fixture = await seedFixture();
for (const agentId of [fixture.ownerAgentId, fixture.grantedAgentId]) {
const app = createApp(fixture.companyId, agentId);
const detail = await request(app).get(`/api/heartbeat-runs/${fixture.privateRunId}`);
expect(detail.status).toBe(200);
expect(detail.body).toMatchObject({ issueId: fixture.issueId });
expect(detail.body.resultJson.summary).toBe("Read the confidential email");
const events = await request(app).get(`/api/heartbeat-runs/${fixture.privateRunId}/events`);
expect(events.status).toBe(200);
expect(events.body[0].message).toBe("private@example.test");
const linkedIssues = await request(app).get(`/api/heartbeat-runs/${fixture.privateRunId}/issues`);
expect(linkedIssues.status).toBe(200);
expect(linkedIssues.body[0]).toMatchObject({ issueId: fixture.issueId, title: "Confidential email triage" });
}
});
it("returns no linked issue metadata to a non-member", async () => {
const fixture = await seedFixture();
const response = await request(createApp(fixture.companyId, fixture.otherAgentId))
.get(`/api/heartbeat-runs/${fixture.privateRunId}/issues`);
expect(response.status).toBe(200);
expect(response.body).toEqual([]);
});
it("logs would-deny decisions without enforcing them in shadow mode", async () => {
const fixture = await seedFixture();
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "shadow";
try {
const response = await request(createApp(fixture.companyId, fixture.otherAgentId))
.get(`/api/heartbeat-runs/${fixture.privateRunId}`);
expect(response.status).toBe(200);
expect(response.body.resultJson.summary).toBe("Read the confidential email");
} finally {
process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "enforce";
}
});
});
+5
View File
@@ -10,6 +10,7 @@ import { sanitizeRecord } from "../redaction.js";
import { badRequest, forbidden } from "../errors.js";
import { agentActionAuditService } from "../services/agent-action-audit.js";
import { logActivity } from "../services/activity-log.js";
import { canActorReadHeartbeatRun } from "../services/heartbeat-run-privacy.js";
/** Max rows a single CSV export will stream (guards against runaway exports). */
const AUDIT_CSV_EXPORT_MAX_ROWS = 10_000;
@@ -299,6 +300,10 @@ export function activityRoutes(db: Db) {
}
assertCompanyAccess(req, run.companyId);
if (!(await assertCompanyScopeReadAllowed(req, res, run.companyId))) return;
if (!(await canActorReadHeartbeatRun(access, req.actor, run))) {
res.json([]);
return;
}
const result = await svc.issuesForRun(runId);
res.json(result);
});
+62 -11
View File
@@ -113,6 +113,10 @@ import {
changeConsentGateService,
touchesAgentProfileChangeConsentFields,
} from "../services/change-consent-gate.js";
import {
canActorReadHeartbeatRun,
redactHeartbeatRunListRow,
} from "../services/heartbeat-run-privacy.js";
const RUN_LOG_DEFAULT_LIMIT_BYTES = 256_000;
const RUN_LOG_MAX_LIMIT_BYTES = 1024 * 1024;
@@ -201,6 +205,27 @@ export function agentRoutes(
const instanceSettings = instanceSettingsService(db);
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
async function actorCanReadRun(req: Request, run: { companyId: string; issueId?: string | null }) {
return canActorReadHeartbeatRun(access, req.actor, run);
}
async function assertRunReadAllowed(
req: Request,
res: Response,
run: { companyId: string; issueId?: string | null },
notFoundMessage = "Heartbeat run not found",
) {
if (await actorCanReadRun(req, run)) return true;
res.status(404).json({ error: notFoundMessage });
return false;
}
async function serializeRunListRow<
T extends Record<string, unknown> & { companyId: string; issueId?: string | null },
>(req: Request, run: T) {
return await actorCanReadRun(req, run) ? run : redactHeartbeatRunListRow(run);
}
async function assertAgentEnvironmentSelection(
companyId: string,
adapterType: string,
@@ -3649,7 +3674,7 @@ export function agentRoutes(
const limit = limitParam ? Math.max(1, Math.min(1000, parseInt(limitParam, 10) || 200)) : undefined;
const summary = req.query.summary === "true" || req.query.summary === "1";
const runs = await heartbeat.list(companyId, agentId, limit, { summary });
res.json(runs);
res.json(await Promise.all(runs.map((run) => serializeRunListRow(req, run))));
});
router.get("/companies/:companyId/live-runs", async (req, res) => {
@@ -3689,7 +3714,16 @@ export function agentRoutes(
lastOutputStream: heartbeatRuns.lastOutputStream,
lastOutputBytes: heartbeatRuns.lastOutputBytes,
processStartedAt: heartbeatRuns.processStartedAt,
issueId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`.as("issueId"),
issueId: heartbeatRuns.issueId,
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"),
};
const liveRunsQuery = db
@@ -3724,23 +3758,30 @@ export function agentRoutes(
.limit(targetRunCount - liveRuns.length);
const rows = [...liveRuns, ...recentRuns];
res.json(await Promise.all(rows.map(async (run) => ({
...heartbeat.decorateActiveRunStatus(run),
outputSilence: await heartbeat.buildRunOutputSilence(run),
}))));
res.json(await Promise.all(rows.map(async (run) => {
if (!(await actorCanReadRun(req, run))) return redactHeartbeatRunListRow(run);
return {
...heartbeat.decorateActiveRunStatus(run),
outputSilence: await heartbeat.buildRunOutputSilence(run),
};
})));
return;
}
res.json(await Promise.all(liveRuns.map(async (run) => ({
...heartbeat.decorateActiveRunStatus(run),
outputSilence: await heartbeat.buildRunOutputSilence(run),
}))));
res.json(await Promise.all(liveRuns.map(async (run) => {
if (!(await actorCanReadRun(req, run))) return redactHeartbeatRunListRow(run);
return {
...heartbeat.decorateActiveRunStatus(run),
outputSilence: await heartbeat.buildRunOutputSilence(run),
};
})));
});
router.get("/heartbeat-runs/:runId", async (req, res) => {
const runId = req.params.runId as string;
const run = await getAccessibleResource(req, res, heartbeat.getRun(runId), "Heartbeat run not found");
if (!run) return;
if (!(await assertRunReadAllowed(req, res, run))) return;
const retryExhaustedReason = await heartbeat.getRetryExhaustedReason(runId);
const decoratedRun = heartbeat.decorateActiveRunStatus(run);
res.json(
@@ -3809,6 +3850,7 @@ export function agentRoutes(
const runId = req.params.runId as string;
const run = await getAccessibleResource(req, res, heartbeat.getRun(runId), "Heartbeat run not found");
if (!run) return;
if (!(await assertRunReadAllowed(req, res, run))) return;
const afterSeq = Number(req.query.afterSeq ?? 0);
const limit = Number(req.query.limit ?? 200);
@@ -3827,6 +3869,7 @@ export function agentRoutes(
const runId = req.params.runId as string;
const run = await getAccessibleResource(req, res, heartbeat.getRunLogAccess(runId), "Heartbeat run not found");
if (!run) return;
if (!(await assertRunReadAllowed(req, res, run))) return;
const offset = Number(req.query.offset ?? 0);
const limitBytes = readRunLogLimitBytes(req.query.limitBytes);
@@ -3843,6 +3886,7 @@ export function agentRoutes(
const runId = req.params.runId as string;
const run = await getAccessibleResource(req, res, heartbeat.getRun(runId), "Heartbeat run not found");
if (!run) return;
if (!(await assertRunReadAllowed(req, res, run))) return;
const context = asRecord(run.contextSnapshot);
const executionWorkspaceId = asNonEmptyString(context?.executionWorkspaceId);
@@ -3854,6 +3898,13 @@ export function agentRoutes(
const operationId = req.params.operationId as string;
const operation = await getAccessibleResource(req, res, workspaceOperations.getById(operationId), "Workspace operation not found");
if (!operation) return;
const operationRun = operation.heartbeatRunId
? await heartbeat.getRunLogAccess(operation.heartbeatRunId)
: null;
if (!(await assertRunReadAllowed(req, res, {
companyId: operation.companyId,
issueId: operationRun?.issueId ?? operation.issueId ?? null,
}, "Workspace operation not found"))) return;
const offset = Number(req.query.offset ?? 0);
const limitBytes = readRunLogLimitBytes(req.query.limitBytes);
@@ -3910,7 +3961,7 @@ export function agentRoutes(
and(
eq(heartbeatRuns.companyId, issue.companyId),
inArray(heartbeatRuns.status, ["queued", "running"]),
sql`${heartbeatRuns.contextSnapshot} ->> 'issueId' = ${issue.id}`,
eq(heartbeatRuns.issueId, issue.id),
),
)
.orderBy(desc(heartbeatRuns.createdAt));
+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",