feat(server): add permission-gated agent action audit API (#9735)

## Thinking Path

> - Paperclip is the open source control plane for managing AI-agent
companies.
> - Company activity logs already capture agent-attributed mutations and
responsible-user context.
> - Operators need one company-scoped feed for reviewing those actions
without issuing N+1 entity lookups.
> - Full-company audit visibility is security-sensitive and must not be
available to agents by default.
> - Existing activity-log sanitization and responsible-user attribution
should remain the source of truth.
> - This pull request adds a permission-gated, cursor-paginated agent
action audit API with bulk entity enrichment.
> - The benefit is a render-ready audit feed that preserves company
boundaries, redaction, and legacy attribution.

## Linked Issues or Issue Description

### Subsystem affected

`server/` — REST API and orchestration services, plus the shared
permission-key contract.

### Problem or motivation

Board operators cannot retrieve a unified feed of agent actions with
responsible-user attribution and entity context. Existing activity reads
lack the dedicated permission boundary, cursor pagination, filter set,
and render-ready comment/issue/document snippets needed by audit views.

### Proposed solution

Add `GET /api/companies/:companyId/audit/agent-actions`, gated by
`audit:view_agent_actions`, with stable cursor pagination,
agent/responsible-user/run/entity/action/time/actor filters, read-time
redaction, heartbeat-run fallback attribution, and bulk entity
enrichment.

### Alternatives considered

Extending the broad company activity endpoint would preserve overly
broad authorization semantics and still require UI-side N+1 fetches. A
separate audit route makes the sensitive permission boundary explicit.

### Roadmap alignment

Supports the roadmap direction toward durable audit trails and explicit
review workflows; no duplicate open pull request was found.

### Additional context

The response is designed for a board audit UI that needs to render
entries such as an agent commenting on an issue without issuing
follow-up entity requests.

## What Changed

- Added the `audit:view_agent_actions` permission key.
- Added a stable `(createdAt,id)` cursor-paginated audit query with all
requested filters.
- Added read-time responsible-user fallback through `heartbeat_runs` for
legacy rows.
- Added bulk issue, comment excerpt, and issue-document key enrichment
without N+1 queries.
- Reused activity-log sanitization and username redaction on returned
details.
- Added embedded-Postgres route tests for denial, explicit grants,
pagination, filters, enrichment, and fallback attribution.

## Verification

- `pnpm --filter @paperclipai/server typecheck`
- `pnpm exec vitest run
server/src/__tests__/agent-action-audit-routes.test.ts`

## Risks

- The endpoint exposes company-wide agent audit data, so authorization
mistakes would be high impact; the route rejects agent actors and
requires an explicit board permission except for trusted
local/instance-admin contexts.
- Entity snippets are intentionally nullable for deleted or unsupported
entity types.
- No database migration is included; the query uses indexes added with
responsible-user attribution work and falls back through the existing
run relation.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex using GPT-5.4 with tool use, repository editing, command
execution, and medium reasoning. Context window size was not exposed by
the runtime.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-07-27 19:15:42 -05:00
1 parent 5d42382df4
commit 5e33090e82
6 files changed
+578 -7

No files matched your search

+1
View File
@@ -918,6 +918,7 @@ export const PERMISSION_KEYS = [
"tools:manage_connections",
"tools:manage_profiles",
"tools:view_audit",
"audit:view_agent_actions",
"tools:use",
"tools:manage_runtime",
"inbox:manage",
@@ -0,0 +1,310 @@
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agents,
companies,
companyMemberships,
createDb,
documents,
heartbeatRuns,
issueComments,
issueDocuments,
issues,
principalPermissionGrants,
} from "@paperclipai/db";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
const support = await getEmbeddedPostgresTestSupport();
const describePostgres = support.supported ? describe : describe.skip;
type Db = ReturnType<typeof createDb>;
async function createApp(db: Db, actor: Express.Request["actor"]) {
const { activityRoutes } = await import("../routes/activity.js");
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = actor;
next();
});
app.use("/api", activityRoutes(db));
app.use((error: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
res.status(error.status ?? 500).json({ error: error.message ?? "Internal server error" });
});
return app;
}
describePostgres("agent action audit routes", () => {
let db!: Db;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-agent-action-audit-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(activityLog);
await db.delete(issueDocuments);
await db.delete(documents);
await db.delete(issueComments);
await db.delete(principalPermissionGrants);
await db.delete(companyMemberships);
await db.delete(heartbeatRuns);
await db.delete(issues);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => tempDb?.cleanup());
async function seed() {
const company = await db.insert(companies).values({
name: "Audit Company",
issuePrefix: `AU${randomUUID().slice(0, 6).toUpperCase()}`,
}).returning().then((rows) => rows[0]!);
const [agent, otherAgent] = await db.insert(agents).values([1, 2].map((index) => ({
companyId: company.id,
name: `Audit Agent ${index}`,
role: "engineer",
status: "active" as const,
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
}))).returning();
const issue = await db.insert(issues).values({
companyId: company.id,
identifier: `${company.issuePrefix}-1`,
title: "Audit target",
status: "todo",
priority: "medium",
}).returning().then((rows) => rows[0]!);
const comment = await db.insert(issueComments).values({
companyId: company.id,
issueId: issue.id,
authorAgentId: agent.id,
body: "A useful comment excerpt for the audit feed.",
}).returning().then((rows) => rows[0]!);
const document = await db.insert(documents).values({
companyId: company.id,
title: "Plan",
latestBody: "Plan body",
createdByAgentId: agent.id,
updatedByAgentId: agent.id,
}).returning().then((rows) => rows[0]!);
const issueDocument = await db.insert(issueDocuments).values({
companyId: company.id,
issueId: issue.id,
documentId: document.id,
key: "plan",
}).returning().then((rows) => rows[0]!);
const run = await db.insert(heartbeatRuns).values({
companyId: company.id,
agentId: agent.id,
responsibleUserId: "legacy-user",
}).returning().then((rows) => rows[0]!);
const base = new Date("2026-07-17T00:00:00.000Z");
await db.insert(activityLog).values([
{ companyId: company.id, actorType: "agent", actorId: agent.id, action: "issue.comment.created", entityType: "issue_comment", entityId: comment.id, agentId: agent.id, runId: run.id, responsibleUserId: null, createdAt: new Date(base.getTime() + 3000) },
{ companyId: company.id, actorType: "system", actorId: "system", action: "issue.document.updated", entityType: "issue_document", entityId: issueDocument.id, agentId: agent.id, runId: run.id, responsibleUserId: "direct-user", createdAt: new Date(base.getTime() + 2000) },
{ companyId: company.id, actorType: "agent", actorId: otherAgent.id, action: "issue.updated", entityType: "issue", entityId: issue.id, agentId: otherAgent.id, responsibleUserId: "other-user", createdAt: new Date(base.getTime() + 1000) },
]);
return { company, agent, otherAgent, issue, comment, issueDocument, run };
}
it("denies agents and board users without the audit permission", async () => {
const { company, agent } = await seed();
const agentResponse = await request(await createApp(db, {
type: "agent", agentId: agent.id, companyId: company.id, runId: null, source: "agent_jwt",
})).get(`/api/companies/${company.id}/audit/agent-actions`);
expect(agentResponse.status).toBe(403);
const boardResponse = await request(await createApp(db, {
type: "board", userId: "reader", companyIds: [company.id], source: "session", isInstanceAdmin: false,
})).get(`/api/companies/${company.id}/audit/agent-actions`);
expect(boardResponse.status).toBe(403);
expect(boardResponse.body.error).toContain("audit:view_agent_actions");
});
it("returns a client error for invalid audit query parameters", async () => {
const { company } = await seed();
const response = await request(await createApp(db, {
type: "board", userId: "local-board", companyIds: [company.id], source: "local_implicit", isInstanceAdmin: false,
})).get(`/api/companies/${company.id}/audit/agent-actions?limit=invalid`);
expect(response.status).toBe(400);
expect(response.body.error).toBe("Invalid agent action audit query");
const cursorResponse = await request(await createApp(db, {
type: "board", userId: "local-board", companyIds: [company.id], source: "local_implicit", isInstanceAdmin: false,
})).get(`/api/companies/${company.id}/audit/agent-actions?cursor=invalid`);
expect(cursorResponse.status).toBe(400);
expect(cursorResponse.body.error).toBe("Invalid audit cursor");
const nonUuidCursor = Buffer.from(JSON.stringify({
createdAt: "2026-07-17T00:00:00.000000Z",
id: "not-a-uuid",
}), "utf8").toString("base64url");
const nonUuidCursorResponse = await request(await createApp(db, {
type: "board", userId: "local-board", companyIds: [company.id], source: "local_implicit", isInstanceAdmin: false,
})).get(`/api/companies/${company.id}/audit/agent-actions?cursor=${encodeURIComponent(nonUuidCursor)}`);
expect(nonUuidCursorResponse.status).toBe(400);
expect(nonUuidCursorResponse.body.error).toBe("Invalid audit cursor");
});
it("preserves sub-millisecond cursor precision across pages", async () => {
const { company, agent } = await seed();
await db.delete(activityLog);
const newerId = randomUUID();
const olderId = randomUUID();
await db.execute(sql`
insert into activity_log (
id, company_id, actor_type, actor_id, action, entity_type, entity_id, agent_id, created_at
) values
(${newerId}::uuid, ${company.id}::uuid, 'agent', ${agent.id}, 'audit.precision', 'company', ${company.id}, ${agent.id}::uuid, '2026-07-17T00:00:00.001900Z'::timestamptz),
(${olderId}::uuid, ${company.id}::uuid, 'agent', ${agent.id}, 'audit.precision', 'company', ${company.id}, ${agent.id}::uuid, '2026-07-17T00:00:00.001100Z'::timestamptz)
`);
const app = await createApp(db, {
type: "board", userId: "local-board", companyIds: [company.id], source: "local_implicit", isInstanceAdmin: false,
});
const first = await request(app).get(`/api/companies/${company.id}/audit/agent-actions?action=audit.precision&limit=1`);
expect(first.status, JSON.stringify(first.body)).toBe(200);
expect(first.body.items.map((item: { id: string }) => item.id)).toEqual([newerId]);
expect(first.body.nextCursor).toEqual(expect.any(String));
const second = await request(app).get(
`/api/companies/${company.id}/audit/agent-actions?action=audit.precision&limit=1&cursor=${encodeURIComponent(first.body.nextCursor)}`,
);
expect(second.status, JSON.stringify(second.body)).toBe(200);
expect(second.body.items.map((item: { id: string }) => item.id)).toEqual([olderId]);
expect(second.body.nextCursor).toBeNull();
});
it("paginates, filters, enriches entities, and falls back to the run responsible user", async () => {
const { company, agent, otherAgent, issue, comment, issueDocument, run } = await seed();
const app = await createApp(db, { type: "board", userId: "local-board", companyIds: [company.id], source: "local_implicit", isInstanceAdmin: false });
const first = await request(app).get(`/api/companies/${company.id}/audit/agent-actions?limit=1`);
expect(first.status, JSON.stringify(first.body)).toBe(200);
expect(first.body.items).toHaveLength(1);
expect(first.body.items[0].responsibleUserId).toBe("legacy-user");
expect(first.body.items[0].entity.comment).toEqual({ id: comment.id, excerpt: "A useful comment excerpt for the audit feed." });
expect(first.body.items[0].entity.issue).toMatchObject({ id: issue.id, identifier: issue.identifier, title: issue.title });
expect(first.body.nextCursor).toEqual(expect.any(String));
const second = await request(app).get(`/api/companies/${company.id}/audit/agent-actions?limit=1&cursor=${encodeURIComponent(first.body.nextCursor)}`);
expect(second.body.items[0].entity.document).toEqual({ id: expect.any(String), key: "plan" });
const cases = [
[`agentId=${agent.id}`, 2],
["responsibleUserId=legacy-user", 1],
[`runId=${run.id}`, 2],
["entityType=issue_document", 1],
[`entityId=${issueDocument.id}`, 1],
["action=issue.comment", 1],
["actorType=system", 1],
["from=2026-07-17T00%3A00%3A01.500Z&to=2026-07-17T00%3A00%3A02.500Z", 1],
[`agentId=${otherAgent.id}`, 1],
] as const;
for (const [query, count] of cases) {
const response = await request(app).get(`/api/companies/${company.id}/audit/agent-actions?${query}`);
expect(response.status, `${query}: ${JSON.stringify(response.body)}`).toBe(200);
expect(response.body.items, query).toHaveLength(count);
}
});
it("does not enrich hidden issues or mismatched entity types", async () => {
const { company, agent, comment, run } = await seed();
const hiddenIssue = await db.insert(issues).values({
companyId: company.id,
identifier: `${company.issuePrefix}-HIDDEN`,
title: "Hidden audit target",
status: "todo",
priority: "medium",
hiddenAt: new Date(),
}).returning().then((rows) => rows[0]!);
const hiddenComment = await db.insert(issueComments).values({
companyId: company.id,
issueId: hiddenIssue.id,
authorAgentId: agent.id,
body: "Hidden audit comment",
}).returning().then((rows) => rows[0]!);
const [hiddenActivity, hiddenDocumentActivity, mismatchedActivity] = await db.insert(activityLog).values([
{
companyId: company.id,
actorType: "agent",
actorId: agent.id,
action: "issue.comment.created",
entityType: "issue",
entityId: hiddenIssue.id,
agentId: agent.id,
runId: run.id,
details: {
commentId: hiddenComment.id,
bodySnippet: hiddenComment.body,
identifier: hiddenIssue.identifier,
issueTitle: hiddenIssue.title,
},
},
{
companyId: company.id,
actorType: "agent",
actorId: agent.id,
action: "issue.document_updated",
entityType: "issue",
entityId: hiddenIssue.id,
agentId: agent.id,
runId: run.id,
details: {
documentId: randomUUID(),
key: "plan",
title: "Hidden document title",
},
},
{
companyId: company.id,
actorType: "agent",
actorId: agent.id,
action: "company.updated",
entityType: "company",
entityId: comment.id,
agentId: agent.id,
runId: run.id,
},
]).returning();
const response = await request(await createApp(db, {
type: "board", userId: "local-board", companyIds: [company.id], source: "local_implicit", isInstanceAdmin: false,
})).get(`/api/companies/${company.id}/audit/agent-actions`);
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(response.body.items.find((item: { id: string }) => item.id === hiddenActivity.id)?.entity).toEqual({
issue: null,
comment: null,
document: null,
});
expect(response.body.items.find((item: { id: string }) => item.id === hiddenActivity.id)?.details).toBeNull();
expect(response.body.items.find((item: { id: string }) => item.id === hiddenDocumentActivity.id)?.details).toBeNull();
expect(response.body.items.find((item: { id: string }) => item.id === mismatchedActivity.id)?.entity).toEqual({
issue: null,
comment: null,
document: null,
});
});
it("allows a signed-in board user with the explicit permission", async () => {
const { company } = await seed();
await db.insert(companyMemberships).values({
companyId: company.id, principalType: "user", principalId: "reader", status: "active", membershipRole: "viewer",
});
await db.insert(principalPermissionGrants).values({
companyId: company.id, principalType: "user", principalId: "reader", permissionKey: "audit:view_agent_actions", scope: null, grantedByUserId: null,
});
const response = await request(await createApp(db, {
type: "board", userId: "reader", companyIds: [company.id], source: "session", isInstanceAdmin: false,
})).get(`/api/companies/${company.id}/audit/agent-actions`);
expect(response.status, JSON.stringify(response.body)).toBe(200);
expect(response.body.items).toHaveLength(3);
});
});
+35
View File
@@ -7,6 +7,8 @@ import { activityService, normalizeActivityLimit } from "../services/activity.js
import { assertAuthenticated, assertBoard, assertCompanyAccess, getAccessibleResource, hasCompanyAccess } from "./authz.js";
import { accessService, heartbeatService, issueService } from "../services/index.js";
import { sanitizeRecord } from "../redaction.js";
import { badRequest, forbidden } from "../errors.js";
import { agentActionAuditService } from "../services/agent-action-audit.js";
const createActivitySchema = z.object({
actorType: z.enum(["agent", "user", "system", "plugin"]).optional().default("system"),
@@ -18,12 +20,35 @@ const createActivitySchema = z.object({
details: z.record(z.unknown()).optional().nullable(),
});
const agentActionAuditQuerySchema = z.object({
agentId: z.string().uuid().optional(),
responsibleUserId: z.string().min(1).optional(),
runId: z.string().uuid().optional(),
entityType: z.string().min(1).optional(),
entityId: z.string().min(1).optional(),
action: z.string().min(1).optional(),
actorType: z.enum(["agent", "user", "system", "plugin"]).optional(),
from: z.coerce.date().optional(),
to: z.coerce.date().optional(),
cursor: z.string().min(1).optional(),
limit: z.coerce.number().int().min(1).max(200).default(50),
});
export function activityRoutes(db: Db) {
const router = Router();
const svc = activityService(db);
const access = accessService(db);
const heartbeat = heartbeatService(db);
const issueSvc = issueService(db);
const agentAudit = agentActionAuditService(db);
async function assertAgentAuditPermission(req: import("express").Request, companyId: string) {
assertBoard(req);
assertCompanyAccess(req, companyId);
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
if (req.actor.userId && await access.canUser(companyId, req.actor.userId, "audit:view_agent_actions")) return;
throw forbidden("Missing permission: audit:view_agent_actions");
}
async function assertCompanyScopeReadAllowed(req: Parameters<typeof assertCompanyAccess>[0], res: any, companyId: string) {
const decision = await access.decide({
@@ -88,6 +113,16 @@ export function activityRoutes(db: Db) {
res.json(result);
});
router.get("/companies/:companyId/audit/agent-actions", async (req, res) => {
const companyId = req.params.companyId as string;
await assertAgentAuditPermission(req, companyId);
const parsedQuery = agentActionAuditQuerySchema.safeParse(req.query);
if (!parsedQuery.success) {
throw badRequest("Invalid agent action audit query", parsedQuery.error.issues);
}
res.json(await agentAudit.list({ companyId, ...parsedQuery.data }));
});
router.post("/companies/:companyId/activity", validate(createActivitySchema), async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
+24
View File
@@ -2885,6 +2885,30 @@ registry.registerPath({
responses: { 200: r.ok(), 401: r.unauthorized },
});
registry.registerPath({
method: "get",
path: "/api/companies/{companyId}/audit/agent-actions",
tags: ["activity"],
summary: "List agent action audit entries",
request: {
params: z.object({ companyId: z.string() }),
query: z.object({
agentId: z.string().uuid().optional(),
responsibleUserId: z.string().min(1).optional(),
runId: z.string().uuid().optional(),
entityType: z.string().min(1).optional(),
entityId: z.string().min(1).optional(),
action: z.string().min(1).optional(),
actorType: z.enum(["agent", "user", "system", "plugin"]).optional(),
from: z.string().datetime().optional(),
to: z.string().datetime().optional(),
cursor: z.string().min(1).optional(),
limit: z.coerce.number().int().min(1).max(200).optional(),
}),
},
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden },
});
registry.registerPath({
method: "post",
path: "/api/companies/{companyId}/activity",
+15 -7
View File
@@ -65,6 +65,20 @@ export interface LogActivityInput {
details?: Record<string, unknown> | null;
}
export async function createActivityDetailsRedactor(db: Db) {
const currentUserRedactionOptions = {
enabled: (await instanceSettingsService(db).getGeneral()).censorUsernameInLogs,
};
return (details: Record<string, unknown> | null) => (
details ? redactCurrentUserValue(sanitizeRecord(details), currentUserRedactionOptions) : null
);
}
export async function redactActivityDetails(db: Db, details: Record<string, unknown> | null) {
if (!details) return null;
return (await createActivityDetailsRedactor(db))(details);
}
function readNonEmptyString(value: unknown) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
@@ -125,13 +139,7 @@ export async function resolveResponsibleUserIdForActivity(db: Db, input: LogActi
}
export async function logActivity(db: Db, input: LogActivityInput) {
const currentUserRedactionOptions = {
enabled: (await instanceSettingsService(db).getGeneral()).censorUsernameInLogs,
};
const sanitizedDetails = input.details ? sanitizeRecord(input.details) : null;
const redactedDetails = sanitizedDetails
? redactCurrentUserValue(sanitizedDetails, currentUserRedactionOptions)
: null;
const redactedDetails = await redactActivityDetails(db, input.details ?? null);
const responsibleUserId = await resolveResponsibleUserIdForActivity(db, input);
await db.insert(activityLog).values({
companyId: input.companyId,
+193
View File
@@ -0,0 +1,193 @@
import { and, desc, eq, gte, inArray, isNotNull, isNull, lt, lte, or, sql } from "drizzle-orm";
import { z } from "zod";
import type { Db } from "@paperclipai/db";
import { activityLog, heartbeatRuns, issueComments, issueDocuments, issues } from "@paperclipai/db";
import { createActivityDetailsRedactor } from "./activity-log.js";
import { badRequest } from "../errors.js";
import { visibleIssueCondition } from "./issue-visibility.js";
export interface AgentActionAuditFilters {
companyId: string;
agentId?: string;
responsibleUserId?: string;
runId?: string;
entityType?: string;
entityId?: string;
action?: string;
actorType?: "agent" | "user" | "system" | "plugin";
from?: Date;
to?: Date;
cursor?: string;
limit: number;
}
type CursorValue = { createdAt: string; id: string };
const cursorValueSchema = z.object({
createdAt: z.string().datetime({ offset: true }),
id: z.string().uuid(),
});
function decodeCursor(cursor: string | undefined): CursorValue | null {
if (!cursor) return null;
try {
const parsed = cursorValueSchema.safeParse(JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")));
return parsed.success ? parsed.data : null;
} catch {
return null;
}
}
function encodeCursor(value: CursorValue) {
return Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
}
function excerpt(value: string, maxLength = 280) {
const normalized = value.replace(/\s+/g, " ").trim();
return normalized.length <= maxLength ? normalized : `${normalized.slice(0, maxLength - 1)}…`;
}
export function agentActionAuditService(db: Db) {
return {
list: async (filters: AgentActionAuditFilters) => {
const cursor = decodeCursor(filters.cursor);
if (filters.cursor && !cursor) throw badRequest("Invalid audit cursor");
const effectiveResponsibleUserId = sql<string | null>`coalesce(${activityLog.responsibleUserId}, ${heartbeatRuns.responsibleUserId})`;
const conditions = [eq(activityLog.companyId, filters.companyId), isNotNull(activityLog.agentId)];
if (filters.agentId) conditions.push(eq(activityLog.agentId, filters.agentId));
if (filters.responsibleUserId) conditions.push(or(
eq(activityLog.responsibleUserId, filters.responsibleUserId),
and(
isNull(activityLog.responsibleUserId),
eq(heartbeatRuns.responsibleUserId, filters.responsibleUserId),
),
)!);
if (filters.runId) conditions.push(eq(activityLog.runId, filters.runId));
if (filters.entityType) conditions.push(eq(activityLog.entityType, filters.entityType));
if (filters.entityId) conditions.push(eq(activityLog.entityId, filters.entityId));
if (filters.action) conditions.push(sql<boolean>`starts_with(${activityLog.action}, ${filters.action})`);
if (filters.actorType) conditions.push(eq(activityLog.actorType, filters.actorType));
if (filters.from) conditions.push(gte(activityLog.createdAt, filters.from));
if (filters.to) conditions.push(lte(activityLog.createdAt, filters.to));
if (cursor) {
conditions.push(or(
sql<boolean>`${activityLog.createdAt} < ${cursor.createdAt}::timestamptz`,
and(
sql<boolean>`${activityLog.createdAt} = ${cursor.createdAt}::timestamptz`,
lt(activityLog.id, cursor.id),
),
)!);
}
const rows = await db.select({
id: activityLog.id,
companyId: activityLog.companyId,
actorType: activityLog.actorType,
actorId: activityLog.actorId,
action: activityLog.action,
entityType: activityLog.entityType,
entityId: activityLog.entityId,
agentId: activityLog.agentId,
runId: activityLog.runId,
responsibleUserId: effectiveResponsibleUserId,
details: activityLog.details,
createdAt: activityLog.createdAt,
cursorCreatedAt: sql<string>`to_char(${activityLog.createdAt} at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`.as("cursor_created_at"),
}).from(activityLog).leftJoin(heartbeatRuns, and(
eq(heartbeatRuns.companyId, activityLog.companyId),
eq(heartbeatRuns.id, activityLog.runId),
)).where(and(...conditions)).orderBy(desc(activityLog.createdAt), desc(activityLog.id)).limit(filters.limit + 1);
const page = rows.slice(0, filters.limit);
const commentEntityIds = [...new Set(page
.filter((row) => row.entityType === "issue_comment")
.map((row) => row.entityId))];
const issueEntityIds = [...new Set(page
.filter((row) => row.entityType === "issue")
.map((row) => row.entityId))];
const documentEntityIds = [...new Set(page
.filter((row) => row.entityType === "issue_document")
.map((row) => row.entityId))];
const commentRows = commentEntityIds.length === 0 ? [] : await db.select({
id: issueComments.id, body: issueComments.body, issueId: issues.id, identifier: issues.identifier, title: issues.title,
}).from(issueComments).innerJoin(issues, and(
eq(issues.id, issueComments.issueId),
eq(issues.companyId, filters.companyId),
visibleIssueCondition(),
)).where(and(
eq(issueComments.companyId, filters.companyId),
inArray(issueComments.id, commentEntityIds),
));
const issueRows = issueEntityIds.length === 0 ? [] : await db.select({
id: issues.id, identifier: issues.identifier, title: issues.title,
}).from(issues).where(and(
eq(issues.companyId, filters.companyId),
visibleIssueCondition(),
inArray(issues.id, issueEntityIds),
));
const documentRows = documentEntityIds.length === 0 ? [] : await db.select({
id: issueDocuments.id, documentId: issueDocuments.documentId, key: issueDocuments.key,
issueId: issues.id, identifier: issues.identifier, title: issues.title,
}).from(issueDocuments).innerJoin(issues, and(
eq(issues.id, issueDocuments.issueId),
eq(issues.companyId, filters.companyId),
visibleIssueCondition(),
)).where(and(
eq(issueDocuments.companyId, filters.companyId),
or(
inArray(issueDocuments.id, documentEntityIds),
inArray(issueDocuments.documentId, documentEntityIds),
),
));
const comments = new Map(commentRows.map((row) => [row.id, row]));
const issueMap = new Map(issueRows.map((row) => [row.id, row]));
const documents = new Map<string, (typeof documentRows)[number]>();
for (const row of documentRows) {
documents.set(row.id, row);
documents.set(row.documentId, row);
}
const redactDetails = await createActivityDetailsRedactor(db);
const items = page.map((row) => {
const comment = row.entityType === "issue_comment" ? comments.get(row.entityId) : undefined;
const issue = row.entityType === "issue" ? issueMap.get(row.entityId) : undefined;
const document = row.entityType === "issue_document" ? documents.get(row.entityId) : undefined;
const issueSnippet = comment
? { id: comment.issueId, identifier: comment.identifier, title: comment.title }
: document
? { id: document.issueId, identifier: document.identifier, title: document.title }
: issue ? { id: issue.id, identifier: issue.identifier, title: issue.title } : null;
const isIssueDerived = row.entityType === "issue"
|| row.entityType === "issue_comment"
|| row.entityType === "issue_document";
return {
id: row.id,
companyId: row.companyId,
actorType: row.actorType,
actorId: row.actorId,
action: row.action,
entityType: row.entityType,
entityId: row.entityId,
agentId: row.agentId,
runId: row.runId,
responsibleUserId: row.responsibleUserId,
createdAt: row.createdAt,
details: isIssueDerived && !issueSnippet ? null : redactDetails(row.details),
entity: {
issue: issueSnippet,
comment: comment ? { id: comment.id, excerpt: excerpt(comment.body) } : null,
document: document ? { id: document.documentId, key: document.key } : null,
},
};
});
const last = page.at(-1);
return {
items,
nextCursor: rows.length > filters.limit && last
? encodeCursor({ createdAt: last.cursorCreatedAt, id: last.id })
: null,
};
},
};
}