mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +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
@@ -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";
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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