feat(audit): agent audit UI — company page + per-agent tab (#9744)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Operators need an audit record of agent actions across tasks, comments, documents, approvals, and runs > - The permission-gated audit read API provides that record, but operators cannot inspect it in the product > - A readable UI must preserve company boundaries, server-side permission decisions, and redaction > - Audit exports must also be safe to open in spreadsheet software and must record the export itself > - This pull request adds company and per-agent audit views plus a guarded CSV export > - The benefit is a searchable, filterable, and reviewable agent action history with direct links back to work ## Linked Issues or Issue Description **Feature.** This change adds the frontend and CSV export for the agent action audit log. Refs #9731 and #9735. - Problem: agent actions are recorded, but operators have no readable product surface to inspect or export them. - Solution: add a company audit page and a per-agent Audit tab that use the permission-gated audit API. - Alternative: build a separate plugin-only surface. This was rejected because the existing permission model already supports a unified, server-authoritative view. This pull request targets the audit epic branch, which contains the merged #9735 audit API. ## What Changed - Added a company Audit page and sidebar entry. - Added a per-agent Audit tab with a fixed agent filter. - Added filters for agent, responsible user, action domain, entity type, and date range. - Added task and run links, responsible-user context, cursor pagination, and readable action text. - Added a permission-denied Enterprise card for callers without `audit:view_agent_actions`. - Added a CSV export that is permission-gated, capped, self-audited, CSV-escaped, and protected against spreadsheet formula injection. - Preserved the merged audit API cursor validation, redaction, and sub-millisecond pagination behavior. ## Verification - `pnpm exec vitest run ui/src/pages/audit/AuditFeed.test.tsx` — 6 passed. - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/agent-action-audit-routes.test.ts` — 8 passed with embedded PostgreSQL. - `pnpm -r typecheck` — passed across all workspaces. - `pnpm build` — passed across all workspaces. - `pnpm test:run` — all completed shards passed except one environment-sensitive CLI assertion caused by injected static AWS credential variables; the exact test passes 8/8 with those variables unset. - Manual Chromium QA exercised the populated feed, active filters, permission-denied card, per-agent tab, and CSV export. ## Screenshots and Manual QA - [All audit states exercised in Chromium](https://github.com/paperclipai/paperclip/pull/9744#issuecomment-4998997001) - [Detailed browser report and per-agent tab root cause](https://github.com/paperclipai/paperclip/pull/9744#issuecomment-4998771061) The per-agent redirect defect found during QA is fixed in this branch. ## Risks Low to moderate risk. The UI and export route are additive and use the existing company-scoped permission gate. The main risks are large exports and spreadsheet interpretation. The export is capped at 10,000 rows, records truncation accurately, and prefixes formula-like cells as text. There are no schema changes or migrations. > 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 - Anthropic Claude Opus 4.8, 1M context, extended thinking, tool use, and code execution produced the original implementation. - OpenAI Codex, GPT-5 (deployment ID and context window not exposed), reasoning, tool use, code execution, browser-test orchestration, and GitHub review tooling repaired and verified the pull request. ## 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) - [ ] 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> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
86b265bb85
commit
71231dfa38
|
|
@ -934,6 +934,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,352 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { eq, 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("exports the audit feed as CSV and logs the export action", async () => {
|
||||
const { company, issue, comment } = await seed();
|
||||
await db.update(issues).set({ title: "=2+2" }).where(eq(issues.id, issue.id));
|
||||
await db.update(issueComments).set({ body: "@SUM(A1)" }).where(eq(issueComments.id, comment.id));
|
||||
const app = await createApp(db, {
|
||||
type: "board", userId: "local-board", companyIds: [company.id], source: "local_implicit", isInstanceAdmin: false,
|
||||
});
|
||||
const response = await request(app).get(`/api/companies/${company.id}/audit/agent-actions.csv`);
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(200);
|
||||
expect(response.headers["content-type"]).toContain("text/csv");
|
||||
expect(response.headers["content-disposition"]).toContain(`agent-audit-${company.id}.csv`);
|
||||
|
||||
const lines = response.text.trim().split("\r\n");
|
||||
expect(lines[0]).toBe(
|
||||
"createdAt,action,actorType,actorId,agentId,runId,responsibleUserId,entityType,entityId,issueIdentifier,issueTitle,commentExcerpt,documentKey",
|
||||
);
|
||||
// Three seeded activity rows → three CSV data rows (the export reads before it logs itself).
|
||||
expect(lines).toHaveLength(4);
|
||||
// User-controlled cells are preserved as text rather than executable formulas.
|
||||
expect(response.text).toContain("'=2+2");
|
||||
expect(response.text).toContain("'@SUM(A1)");
|
||||
|
||||
// The export is itself recorded as an auditable action.
|
||||
const logged = (await db.select().from(activityLog)).filter((row) => row.action === "audit.exported");
|
||||
expect(logged).toHaveLength(1);
|
||||
expect(logged[0]!.entityType).toBe("company");
|
||||
expect(logged[0]!.entityId).toBe(company.id);
|
||||
expect(logged[0]!.details).toMatchObject({ format: "csv", rowCount: 3, truncated: false });
|
||||
});
|
||||
|
||||
it("denies CSV export without the audit permission and logs nothing", async () => {
|
||||
const { company } = await seed();
|
||||
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.csv`);
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body.error).toContain("audit:view_agent_actions");
|
||||
|
||||
const logged = (await db.select().from(activityLog)).filter((row) => row.action === "audit.exported");
|
||||
expect(logged).toHaveLength(0);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -7,6 +7,87 @@ 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";
|
||||
import { logActivity } from "../services/activity-log.js";
|
||||
|
||||
/** Max rows a single CSV export will stream (guards against runaway exports). */
|
||||
const AUDIT_CSV_EXPORT_MAX_ROWS = 10_000;
|
||||
const AUDIT_CSV_PAGE_SIZE = 200;
|
||||
const CSV_FORMULA_CHARS = /^[=+\-@\t\r]/;
|
||||
|
||||
const AUDIT_CSV_COLUMNS = [
|
||||
"createdAt",
|
||||
"action",
|
||||
"actorType",
|
||||
"actorId",
|
||||
"agentId",
|
||||
"runId",
|
||||
"responsibleUserId",
|
||||
"entityType",
|
||||
"entityId",
|
||||
"issueIdentifier",
|
||||
"issueTitle",
|
||||
"commentExcerpt",
|
||||
"documentKey",
|
||||
] as const;
|
||||
|
||||
function csvCell(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
const str = value instanceof Date ? value.toISOString() : String(value);
|
||||
// Prevent spreadsheet applications from interpreting user-controlled cells
|
||||
// as formulas when an operator opens the export.
|
||||
const safe = CSV_FORMULA_CHARS.test(str) ? `'${str}` : str;
|
||||
// Quote if the value contains a delimiter, quote, or newline; escape quotes by doubling.
|
||||
return /[",\r\n]/.test(safe) ? `"${safe.replaceAll('"', '""')}"` : safe;
|
||||
}
|
||||
|
||||
function readNested(value: unknown, ...keys: string[]): string | null {
|
||||
let cursor: unknown = value;
|
||||
for (const key of keys) {
|
||||
if (!cursor || typeof cursor !== "object") return null;
|
||||
cursor = (cursor as Record<string, unknown>)[key];
|
||||
}
|
||||
return typeof cursor === "string" ? cursor : null;
|
||||
}
|
||||
|
||||
type AuditCsvRow = {
|
||||
createdAt: Date | string;
|
||||
action: string;
|
||||
actorType: string | null;
|
||||
actorId: string | null;
|
||||
agentId: string | null;
|
||||
runId: string | null;
|
||||
responsibleUserId: string | null;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
// Enrichment snippet is redacted server-side into a plain record, so read it
|
||||
// defensively rather than assuming a fixed shape.
|
||||
entity: unknown;
|
||||
};
|
||||
|
||||
function auditRowsToCsv(rows: AuditCsvRow[]): string {
|
||||
const lines = [AUDIT_CSV_COLUMNS.join(",")];
|
||||
for (const row of rows) {
|
||||
lines.push([
|
||||
csvCell(row.createdAt),
|
||||
csvCell(row.action),
|
||||
csvCell(row.actorType),
|
||||
csvCell(row.actorId),
|
||||
csvCell(row.agentId),
|
||||
csvCell(row.runId),
|
||||
csvCell(row.responsibleUserId),
|
||||
csvCell(row.entityType),
|
||||
csvCell(row.entityId),
|
||||
csvCell(readNested(row.entity, "issue", "identifier")),
|
||||
csvCell(readNested(row.entity, "issue", "title")),
|
||||
csvCell(readNested(row.entity, "comment", "excerpt")),
|
||||
csvCell(readNested(row.entity, "document", "key")),
|
||||
].join(","));
|
||||
}
|
||||
// Trailing newline keeps POSIX tools + spreadsheet importers happy.
|
||||
return `${lines.join("\r\n")}\r\n`;
|
||||
}
|
||||
|
||||
const createActivitySchema = z.object({
|
||||
actorType: z.enum(["agent", "user", "system", "plugin"]).optional().default("system"),
|
||||
|
|
@ -18,12 +99,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 +192,70 @@ 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.get("/companies/:companyId/audit/agent-actions.csv", 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);
|
||||
}
|
||||
// Drive our own pagination for the export; a client-supplied cursor/limit
|
||||
// would silently truncate the export, so ignore them.
|
||||
const { cursor: _cursor, limit: _limit, ...filters } = parsedQuery.data;
|
||||
const rows: Awaited<ReturnType<typeof agentAudit.list>>["items"] = [];
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const page = await agentAudit.list({ companyId, ...filters, cursor, limit: AUDIT_CSV_PAGE_SIZE });
|
||||
for (const item of page.items) {
|
||||
if (rows.length >= AUDIT_CSV_EXPORT_MAX_ROWS) break;
|
||||
rows.push(item);
|
||||
}
|
||||
cursor = page.nextCursor ?? undefined;
|
||||
} while (cursor && rows.length < AUDIT_CSV_EXPORT_MAX_ROWS);
|
||||
|
||||
// The export is itself an auditable act (training-data export precedent):
|
||||
// record who exported what filter set and how many rows left the system.
|
||||
const actorUserId = req.actor.type === "board" ? req.actor.userId ?? null : null;
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actorUserId ? "user" : "system",
|
||||
actorId: actorUserId ?? "local-board",
|
||||
action: "audit.exported",
|
||||
entityType: "company",
|
||||
entityId: companyId,
|
||||
details: {
|
||||
format: "csv",
|
||||
rowCount: rows.length,
|
||||
truncated: rows.length >= AUDIT_CSV_EXPORT_MAX_ROWS && Boolean(cursor),
|
||||
filters: {
|
||||
agentId: filters.agentId ?? null,
|
||||
responsibleUserId: filters.responsibleUserId ?? null,
|
||||
runId: filters.runId ?? null,
|
||||
entityType: filters.entityType ?? null,
|
||||
entityId: filters.entityId ?? null,
|
||||
action: filters.action ?? null,
|
||||
actorType: filters.actorType ?? null,
|
||||
from: filters.from ? filters.from.toISOString() : null,
|
||||
to: filters.to ? filters.to.toISOString() : null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
res.setHeader("Content-Type", "text/csv; charset=utf-8");
|
||||
res.setHeader("Content-Disposition", `attachment; filename="agent-audit-${companyId}.csv"`);
|
||||
res.send(auditRowsToCsv(rows));
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/activity", validate(createActivitySchema), async (req, res) => {
|
||||
assertBoard(req);
|
||||
const companyId = req.params.companyId as string;
|
||||
|
|
|
|||
|
|
@ -3123,6 +3123,62 @@ 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: "get",
|
||||
path: "/api/companies/{companyId}/audit/agent-actions.csv",
|
||||
tags: ["activity"],
|
||||
summary: "Export agent action audit entries as CSV",
|
||||
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: {
|
||||
description: "Agent action audit export",
|
||||
content: { "text/csv": { schema: z.string() } },
|
||||
},
|
||||
400: r.badRequest,
|
||||
401: r.unauthorized,
|
||||
403: r.forbidden,
|
||||
},
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/api/companies/{companyId}/activity",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -40,6 +40,7 @@ import { Approvals } from "./pages/Approvals";
|
|||
import { ApprovalDetail } from "./pages/ApprovalDetail";
|
||||
import { Costs } from "./pages/Costs";
|
||||
import { Activity } from "./pages/Activity";
|
||||
import { CompanyAudit } from "./pages/audit/CompanyAudit";
|
||||
import { Inbox } from "./pages/Inbox";
|
||||
import { WhatNeedsMe } from "./pages/WhatNeedsMe";
|
||||
import { TrainingInspector, TrainingLibrary } from "./pages/Training";
|
||||
|
|
@ -258,6 +259,7 @@ function boardRoutes() {
|
|||
<Route path="approvals/:approvalId" element={<ApprovalDetail />} />
|
||||
<Route path="costs" element={<Costs />} />
|
||||
<Route path="activity" element={<Activity />} />
|
||||
<Route path="audit" element={<CompanyAudit />} />
|
||||
{/* Conference Room Chat surfaces (PAP-136/PAP-137): routes stay
|
||||
registered but redirect to the company home while the experimental
|
||||
flag is off. The board-level `artifacts` mount below is the new
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
import { api } from "./client";
|
||||
|
||||
/**
|
||||
* Agent audit API client.
|
||||
*
|
||||
* Consumes the unified read API shipped by Phase 2c
|
||||
* (`server/src/routes/activity.ts` → `services/agent-action-audit.ts`):
|
||||
* GET /companies/:companyId/audit/agent-actions
|
||||
* Gated server-side by the `audit:view_agent_actions` board permission — the
|
||||
* client renders an upsell/permission-denied state when the request 403s
|
||||
* (see `ui/src/pages/CompanyAudit.tsx`). CSV export streams from the sibling
|
||||
* `.csv` endpoint, which logs the export action itself.
|
||||
*/
|
||||
|
||||
/** Render-ready entity snippets attached to each row at read time (no N+1). */
|
||||
export interface AuditEntitySnippet {
|
||||
issue: { id: string; identifier: string | null; title: string | null } | null;
|
||||
comment: { id: string; excerpt: string } | null;
|
||||
document: { id: string; key: string } | null;
|
||||
}
|
||||
|
||||
/** One enriched `activity_log` row from the agent-action audit feed. */
|
||||
export interface AuditActionRecord {
|
||||
id: string;
|
||||
companyId: string;
|
||||
actorType: "agent" | "user" | "system" | "plugin" | null;
|
||||
actorId: string | null;
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
agentId: string | null;
|
||||
runId: string | null;
|
||||
responsibleUserId: string | null;
|
||||
details: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
entity: AuditEntitySnippet;
|
||||
}
|
||||
|
||||
export interface AuditActionsResponse {
|
||||
items: AuditActionRecord[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
/** Server-side filters for the audit feed. All optional. */
|
||||
export interface AuditActionFilters {
|
||||
agentId?: string | null;
|
||||
responsibleUserId?: string | null;
|
||||
runId?: string | null;
|
||||
entityType?: string | null;
|
||||
entityId?: string | null;
|
||||
/** Action-domain prefix, e.g. `issue.` or `issue.comment_added`. */
|
||||
action?: string | null;
|
||||
/** ISO-8601 with offset. */
|
||||
from?: string | null;
|
||||
to?: string | null;
|
||||
actorType?: "agent" | "user" | "system" | "plugin" | null;
|
||||
cursor?: string | null;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
function buildAuditQuery(filters: AuditActionFilters): URLSearchParams {
|
||||
const search = new URLSearchParams();
|
||||
if (filters.agentId) search.set("agentId", filters.agentId);
|
||||
if (filters.responsibleUserId) search.set("responsibleUserId", filters.responsibleUserId);
|
||||
if (filters.runId) search.set("runId", filters.runId);
|
||||
if (filters.entityType) search.set("entityType", filters.entityType);
|
||||
if (filters.entityId) search.set("entityId", filters.entityId);
|
||||
if (filters.action) search.set("action", filters.action);
|
||||
if (filters.from) search.set("from", filters.from);
|
||||
if (filters.to) search.set("to", filters.to);
|
||||
if (filters.actorType) search.set("actorType", filters.actorType);
|
||||
if (filters.cursor) search.set("cursor", filters.cursor);
|
||||
if (filters.limit != null) search.set("limit", String(filters.limit));
|
||||
return search;
|
||||
}
|
||||
|
||||
export const auditApi = {
|
||||
/**
|
||||
* Cursor-paginated agent-action feed. Returns `{ items, nextCursor }`.
|
||||
* Throws `ApiError` with status 403 when the caller lacks
|
||||
* `audit:view_agent_actions`.
|
||||
*/
|
||||
listAgentActions: (companyId: string, filters: AuditActionFilters = {}) => {
|
||||
const search = buildAuditQuery(filters);
|
||||
const qs = search.toString();
|
||||
return api.get<AuditActionsResponse>(
|
||||
`/companies/${companyId}/audit/agent-actions${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch the filtered feed as a CSV blob. The server logs an `audit.exported`
|
||||
* activity row for the export itself (training-data export precedent).
|
||||
*/
|
||||
exportAgentActionsCsv: async (
|
||||
companyId: string,
|
||||
filters: Omit<AuditActionFilters, "cursor" | "limit"> = {},
|
||||
): Promise<Blob> => {
|
||||
const search = buildAuditQuery(filters);
|
||||
const qs = search.toString();
|
||||
const res = await fetch(
|
||||
`/api/companies/${companyId}/audit/agent-actions.csv${qs ? `?${qs}` : ""}`,
|
||||
{ credentials: "include", headers: { Accept: "text/csv" } },
|
||||
);
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => null);
|
||||
const message = (body as { error?: string } | null)?.error ?? `Export failed: ${res.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
return res.blob();
|
||||
},
|
||||
};
|
||||
|
|
@ -22,6 +22,7 @@ import {
|
|||
AppWindow,
|
||||
MessagesSquare,
|
||||
GanttChartSquare,
|
||||
ScrollText,
|
||||
LayoutGrid,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
|
@ -283,6 +284,7 @@ export function Sidebar() {
|
|||
<SidebarNavItem to="/timeline" label="Timeline" icon={GanttChartSquare} />
|
||||
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
|
||||
<SidebarNavItem to="/activity" label="Activity" icon={History} />
|
||||
<SidebarNavItem to="/audit" label="Audit" icon={ScrollText} />
|
||||
<SidebarNavItem to="/company/settings" label="Settings" icon={Settings} />
|
||||
</SidebarSection>
|
||||
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ const ACTIVITY_ROW_VERBS: Record<string, string> = {
|
|||
"company.archived": "archived",
|
||||
"company.reactivated": "reactivated",
|
||||
"company.budget_updated": "updated budget for",
|
||||
"audit.exported": "exported the agent audit log for",
|
||||
};
|
||||
|
||||
const ISSUE_ACTIVITY_LABELS: Record<string, string> = {
|
||||
|
|
|
|||
|
|
@ -52,6 +52,34 @@ export const queryKeys = {
|
|||
filters.search ?? "",
|
||||
] as const,
|
||||
},
|
||||
audit: {
|
||||
agentActions: (
|
||||
companyId: string,
|
||||
filters: {
|
||||
agentId?: string | null;
|
||||
responsibleUserId?: string | null;
|
||||
runId?: string | null;
|
||||
entityType?: string | null;
|
||||
action?: string | null;
|
||||
from?: string | null;
|
||||
to?: string | null;
|
||||
actorType?: string | null;
|
||||
},
|
||||
) =>
|
||||
[
|
||||
"audit",
|
||||
companyId,
|
||||
"agent-actions",
|
||||
filters.agentId ?? "__all",
|
||||
filters.responsibleUserId ?? "__all",
|
||||
filters.runId ?? "__all",
|
||||
filters.entityType ?? "__all",
|
||||
filters.action ?? "__all",
|
||||
filters.actorType ?? "__all",
|
||||
filters.from ?? "",
|
||||
filters.to ?? "",
|
||||
] as const,
|
||||
},
|
||||
smokeLab: {
|
||||
services: (companyId: string) => ["smoke-lab", companyId, "services"] as const,
|
||||
runs: (companyId: string) => ["smoke-lab", companyId, "runs"] as const,
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import { EntityRow } from "../components/EntityRow";
|
|||
import { MembershipAction } from "../components/MembershipAction";
|
||||
import { StarToggle } from "../components/StarToggle";
|
||||
import { Identity } from "../components/Identity";
|
||||
import { AuditFeed } from "./audit/AuditFeed";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { AgentActionButtons } from "../components/AgentActionButtons";
|
||||
import { InlineBanner } from "../components/InlineBanner";
|
||||
|
|
@ -271,7 +272,7 @@ function scrollToContainerBottom(container: ScrollContainer, behavior: ScrollBeh
|
|||
container.scrollTo({ top: container.scrollHeight, behavior });
|
||||
}
|
||||
|
||||
type AgentDetailView = "dashboard" | "instructions" | "configuration" | "skills" | "tools" | "runs" | "budget";
|
||||
type AgentDetailView = "dashboard" | "instructions" | "configuration" | "skills" | "tools" | "runs" | "audit" | "budget";
|
||||
|
||||
function parseAgentDetailView(value: string | null): AgentDetailView {
|
||||
if (value === "instructions" || value === "prompts") return "instructions";
|
||||
|
|
@ -279,6 +280,7 @@ function parseAgentDetailView(value: string | null): AgentDetailView {
|
|||
if (value === "skills") return "skills";
|
||||
if (value === "tools") return "tools";
|
||||
if (value === "budget") return "budget";
|
||||
if (value === "audit") return "audit";
|
||||
if (value === "runs") return value;
|
||||
return "dashboard";
|
||||
}
|
||||
|
|
@ -898,8 +900,10 @@ export function AgentDetail() {
|
|||
? "tools"
|
||||
: activeView === "runs"
|
||||
? "runs"
|
||||
: activeView === "budget"
|
||||
? "budget"
|
||||
: activeView === "audit"
|
||||
? "audit"
|
||||
: activeView === "budget"
|
||||
? "budget"
|
||||
: "dashboard";
|
||||
if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) {
|
||||
navigate(`/agents/${canonicalAgentRef}/${canonicalTab}`, { replace: true });
|
||||
|
|
@ -1251,6 +1255,7 @@ export function AgentDetail() {
|
|||
{ value: "configuration", label: "Configuration" },
|
||||
{ value: "tools", label: "Tools" },
|
||||
{ value: "runs", label: "Runs" },
|
||||
{ value: "audit", label: "Audit" },
|
||||
{ value: "budget", label: "Budget" },
|
||||
]}
|
||||
value={activeView}
|
||||
|
|
@ -1383,6 +1388,10 @@ export function AgentDetail() {
|
|||
/>
|
||||
)}
|
||||
|
||||
{activeView === "audit" && resolvedCompanyId ? (
|
||||
<AuditFeed companyId={resolvedCompanyId} lockedAgentId={agent.id} hideHeader />
|
||||
) : null}
|
||||
|
||||
{activeView === "budget" && resolvedCompanyId ? (
|
||||
<div className="max-w-3xl">
|
||||
<BudgetPolicyCard
|
||||
|
|
|
|||
|
|
@ -0,0 +1,208 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import type { ReactNode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { AuditFeed } from "./AuditFeed";
|
||||
|
||||
const listAgentActionsMock = vi.hoisted(() => vi.fn());
|
||||
const exportCsvMock = vi.hoisted(() => vi.fn());
|
||||
const listAgentsMock = vi.hoisted(() => vi.fn());
|
||||
const listUserDirectoryMock = vi.hoisted(() => vi.fn());
|
||||
const pushToastMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/audit", () => ({
|
||||
auditApi: {
|
||||
listAgentActions: (companyId: string, filters: unknown) => listAgentActionsMock(companyId, filters),
|
||||
exportAgentActionsCsv: (companyId: string, filters: unknown) => exportCsvMock(companyId, filters),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/api/agents", () => ({
|
||||
agentsApi: { list: (companyId: string) => listAgentsMock(companyId) },
|
||||
}));
|
||||
|
||||
vi.mock("@/api/access", () => ({
|
||||
accessApi: { listUserDirectory: (companyId: string) => listUserDirectoryMock(companyId) },
|
||||
}));
|
||||
|
||||
vi.mock("@/context/ToastContext", () => ({
|
||||
useToastActions: () => ({ pushToast: pushToastMock }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => (
|
||||
<a href={to} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
await result;
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function record(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "evt-1",
|
||||
companyId: "company-1",
|
||||
actorType: "agent",
|
||||
actorId: "agent-1",
|
||||
action: "issue.comment_added",
|
||||
entityType: "issue",
|
||||
entityId: "issue-1",
|
||||
agentId: "agent-1",
|
||||
runId: "run-1",
|
||||
responsibleUserId: "user-1",
|
||||
details: { commentId: "c1" },
|
||||
createdAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
|
||||
entity: {
|
||||
issue: { id: "issue-1", identifier: "PAP-1", title: "Ship the audit UI" },
|
||||
comment: { id: "c1", excerpt: "Looks good to me" },
|
||||
document: null,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AuditFeed", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
listAgentActionsMock.mockResolvedValue({ items: [record()], nextCursor: null });
|
||||
listAgentsMock.mockResolvedValue([{ id: "agent-1", name: "Fable", icon: null }]);
|
||||
listUserDirectoryMock.mockResolvedValue({
|
||||
users: [{ principalId: "user-1", status: "active", user: { id: "user-1", name: "Dotta", email: null, image: null } }],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root?.unmount());
|
||||
container.remove();
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function render(props: { companyId?: string; lockedAgentId?: string } = {}) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={client}>
|
||||
<AuditFeed companyId={props.companyId ?? "company-1"} lockedAgentId={props.lockedAgentId} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
function clickButton(text: string) {
|
||||
const btn = Array.from(container.querySelectorAll("button")).find((b) => b.textContent?.includes(text));
|
||||
expect(btn, `button "${text}"`).toBeTruthy();
|
||||
return act(async () => {
|
||||
btn!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
it("renders the humanized sentence, the task link, the excerpt, and the on-behalf chip", async () => {
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Fable");
|
||||
expect(container.textContent).toContain("commented on");
|
||||
const taskLink = container.querySelector('a[href="/issues/PAP-1"]');
|
||||
expect(taskLink?.textContent).toContain("PAP-1");
|
||||
expect(container.textContent).toContain("Looks good to me");
|
||||
expect(container.textContent).toContain("on behalf of Dotta");
|
||||
expect(container.querySelector('a[href="/agents/agent-1/runs/run-1"]')).toBeTruthy();
|
||||
expect(container.textContent).toContain("Recorded by Paperclip");
|
||||
});
|
||||
|
||||
it("shows the permission-denied upsell when the feed 403s", async () => {
|
||||
listAgentActionsMock.mockRejectedValue(
|
||||
new ApiError("Missing permission: audit:view_agent_actions", 403, { error: "Missing permission" }),
|
||||
);
|
||||
await render();
|
||||
|
||||
expect(container.textContent).toContain("Paperclip Enterprise view");
|
||||
expect(container.textContent).toContain("audit:view_agent_actions");
|
||||
// The feed chrome (filters, footer) is not rendered in the denied state.
|
||||
expect(container.textContent).not.toContain("Recorded by Paperclip");
|
||||
});
|
||||
|
||||
it("hides the agent filter and pins the query when lockedAgentId is set", async () => {
|
||||
await render({ lockedAgentId: "agent-1" });
|
||||
|
||||
const [, filters] = listAgentActionsMock.mock.calls[0];
|
||||
expect((filters as { agentId?: string }).agentId).toBe("agent-1");
|
||||
// No "All agents" option means the agent filter is hidden on the per-agent tab.
|
||||
expect(container.textContent).not.toContain("All agents");
|
||||
});
|
||||
|
||||
it("only offers action domains present in the agent-action feed", async () => {
|
||||
await render();
|
||||
|
||||
await clickButton("All actions");
|
||||
expect(document.body.textContent).toContain("Tasks");
|
||||
expect(document.body.textContent).not.toContain("Audit exports");
|
||||
});
|
||||
|
||||
it("loads more when a cursor is returned", async () => {
|
||||
listAgentActionsMock.mockImplementation((_companyId: string, filters: { cursor?: string }) => {
|
||||
if (filters.cursor === "cursor-2") {
|
||||
return Promise.resolve({ items: [record({ id: "evt-2", entity: { issue: { id: "i2", identifier: "PAP-2", title: "Second" }, comment: null, document: null } })], nextCursor: null });
|
||||
}
|
||||
return Promise.resolve({ items: [record()], nextCursor: "cursor-2" });
|
||||
});
|
||||
await render();
|
||||
|
||||
expect(container.querySelector('a[href="/issues/PAP-2"]')).toBeFalsy();
|
||||
await clickButton("Load more");
|
||||
await flushReact();
|
||||
expect(container.querySelector('a[href="/issues/PAP-2"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it("exports CSV and toasts on success", async () => {
|
||||
exportCsvMock.mockResolvedValue(new Blob(["csv"], { type: "text/csv" }));
|
||||
// jsdom lacks URL.createObjectURL; stub it for the download path.
|
||||
const createUrl = vi.fn(() => "blob:mock");
|
||||
const revokeUrl = vi.fn();
|
||||
(URL as unknown as { createObjectURL: unknown }).createObjectURL = createUrl;
|
||||
(URL as unknown as { revokeObjectURL: unknown }).revokeObjectURL = revokeUrl;
|
||||
await render();
|
||||
const setTimeoutSpy = vi.spyOn(window, "setTimeout");
|
||||
|
||||
await clickButton("Export CSV");
|
||||
await flushReact();
|
||||
|
||||
expect(exportCsvMock).toHaveBeenCalledWith("company-1", expect.any(Object));
|
||||
expect(createUrl).toHaveBeenCalled();
|
||||
expect(revokeUrl).not.toHaveBeenCalled();
|
||||
const deferredRevoke = setTimeoutSpy.mock.calls.find(([, delay]) => delay === 5_000)?.[0];
|
||||
expect(deferredRevoke).toEqual(expect.any(Function));
|
||||
deferredRevoke!();
|
||||
expect(revokeUrl).toHaveBeenCalledWith("blob:mock");
|
||||
expect(pushToastMock).toHaveBeenCalledWith(expect.objectContaining({ tone: "success" }));
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,516 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { Download, ScrollText, ShieldAlert } from "lucide-react";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import { Link } from "@/lib/router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Identity } from "@/components/Identity";
|
||||
import { AgentIcon } from "@/components/AgentIconPicker";
|
||||
import { cn, relativeTime } from "@/lib/utils";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { formatActivityVerb } from "@/lib/activity-format";
|
||||
import { buildCompanyUserProfileMap, type CompanyUserProfile } from "@/lib/company-members";
|
||||
import { auditApi, type AuditActionRecord, type AuditActionFilters } from "@/api/audit";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { accessApi } from "@/api/access";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { useToastActions } from "@/context/ToastContext";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
const ALL = "__all";
|
||||
|
||||
/** Action-domain prefixes offered in the filter (server does a prefix match). */
|
||||
const ACTION_DOMAINS: { value: string; label: string }[] = [
|
||||
{ value: ALL, label: "All actions" },
|
||||
{ value: "issue.", label: "Tasks" },
|
||||
{ value: "agent.", label: "Agents" },
|
||||
{ value: "heartbeat.", label: "Runs" },
|
||||
{ value: "approval.", label: "Approvals" },
|
||||
{ value: "project.", label: "Projects" },
|
||||
{ value: "goal.", label: "Goals" },
|
||||
{ value: "tool_gateway.", label: "Tools" },
|
||||
{ value: "cost.", label: "Costs" },
|
||||
{ value: "company.", label: "Company" },
|
||||
];
|
||||
|
||||
/** Entity types offered in the filter (server does an exact match). */
|
||||
const ENTITY_TYPES: { value: string; label: string }[] = [
|
||||
{ value: ALL, label: "All entities" },
|
||||
{ value: "issue", label: "Task" },
|
||||
{ value: "agent", label: "Agent" },
|
||||
{ value: "project", label: "Project" },
|
||||
{ value: "goal", label: "Goal" },
|
||||
{ value: "company", label: "Company" },
|
||||
];
|
||||
|
||||
export interface AuditFeedProps {
|
||||
companyId: string;
|
||||
/**
|
||||
* When set, the feed is pinned to a single agent (per-agent Audit tab) — the
|
||||
* agent filter is hidden and every query/export carries this agentId.
|
||||
*/
|
||||
lockedAgentId?: string;
|
||||
/** Hide the section header/description (the AgentDetail tab supplies its own chrome). */
|
||||
hideHeader?: boolean;
|
||||
}
|
||||
|
||||
function toStartIso(value: string): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const date = new Date(`${value}T00:00:00.000Z`);
|
||||
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
||||
}
|
||||
|
||||
function toEndIso(value: string): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const date = new Date(`${value}T23:59:59.999Z`);
|
||||
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
||||
}
|
||||
|
||||
/** Actor avatar + name — agents render their icon glyph, humans their avatar. */
|
||||
function AuditActor({
|
||||
record,
|
||||
agentMap,
|
||||
userProfileMap,
|
||||
}: {
|
||||
record: AuditActionRecord;
|
||||
agentMap: Map<string, Agent>;
|
||||
userProfileMap: Map<string, CompanyUserProfile>;
|
||||
}) {
|
||||
const agent = record.agentId ? agentMap.get(record.agentId) : null;
|
||||
if (agent) {
|
||||
return (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5" title={agent.name}>
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<AgentIcon icon={agent.icon} className="h-3 w-3" />
|
||||
</span>
|
||||
<span className="truncate font-medium text-foreground">{agent.name}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (record.actorType === "user" && record.actorId) {
|
||||
const profile = userProfileMap.get(record.actorId);
|
||||
return (
|
||||
<Identity
|
||||
name={profile?.label ?? "User"}
|
||||
avatarUrl={profile?.image ?? null}
|
||||
size="sm"
|
||||
className="font-medium text-foreground"
|
||||
/>
|
||||
);
|
||||
}
|
||||
const label = record.actorType === "plugin" ? "Plugin" : "System";
|
||||
return <Identity name={label} size="sm" className="font-medium text-foreground" />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The clickable entity node inside the humanized sentence. The verb from
|
||||
* `formatActivityVerb` already encodes the relationship ("commented on",
|
||||
* "created document for", …) and expects the issue reference to follow it, so
|
||||
* this renders the task link (or a document/plain fallback) — never a phrase
|
||||
* that would duplicate the verb.
|
||||
*/
|
||||
function AuditEntityNode({ record }: { record: AuditActionRecord }) {
|
||||
const { issue, document } = record.entity;
|
||||
const issueRef = issue?.identifier ?? issue?.id ?? null;
|
||||
|
||||
if (issueRef) {
|
||||
return (
|
||||
<Link to={`/issues/${issueRef}`} className="font-medium text-primary hover:underline">
|
||||
{issue?.identifier ? `${issue.identifier}${issue.title ? ` · ${issue.title}` : ""}` : "the task"}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
if (document) {
|
||||
return <span className="font-medium text-foreground">{document.key}</span>;
|
||||
}
|
||||
// Non-linkable entities (company, agent, goal, …) — show a plain descriptor.
|
||||
return <span className="text-muted-foreground">{record.entityType}</span>;
|
||||
}
|
||||
|
||||
function AuditRow({
|
||||
record,
|
||||
agentMap,
|
||||
userProfileMap,
|
||||
}: {
|
||||
record: AuditActionRecord;
|
||||
agentMap: Map<string, Agent>;
|
||||
userProfileMap: Map<string, CompanyUserProfile>;
|
||||
}) {
|
||||
const verb = formatActivityVerb(record.action, record.details, { agentMap, userProfileMap });
|
||||
const responsible = record.responsibleUserId ? userProfileMap.get(record.responsibleUserId) : null;
|
||||
// Suppress the "on behalf of" chip when the human actor *is* the responsible user.
|
||||
const showOnBehalf = Boolean(
|
||||
record.responsibleUserId
|
||||
&& !(record.actorType === "user" && record.actorId === record.responsibleUserId),
|
||||
);
|
||||
const responsibleLabel = responsible?.label ?? (record.responsibleUserId ? "a user" : null);
|
||||
const excerpt = record.entity.comment?.excerpt?.trim();
|
||||
// Show the document key only when it isn't already the linked entity node.
|
||||
const documentKey = record.entity.issue && record.entity.document ? record.entity.document.key : null;
|
||||
|
||||
return (
|
||||
<li className="px-4 py-3 text-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-foreground">
|
||||
<AuditActor record={record} agentMap={agentMap} userProfileMap={userProfileMap} />
|
||||
<span className="text-muted-foreground">{verb}</span>
|
||||
<AuditEntityNode record={record} />
|
||||
</div>
|
||||
{excerpt ? (
|
||||
<p className="line-clamp-2 border-l-2 border-border pl-2 text-muted-foreground">
|
||||
“{excerpt}”
|
||||
</p>
|
||||
) : null}
|
||||
{documentKey ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Document <span className="font-mono text-(length:--text-micro)">{documentKey}</span>
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
{showOnBehalf && responsibleLabel ? (
|
||||
<span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5">
|
||||
on behalf of {responsibleLabel}
|
||||
</span>
|
||||
) : null}
|
||||
{record.runId && record.agentId ? (
|
||||
<Link
|
||||
to={`/agents/${record.agentId}/runs/${record.runId}`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
View run
|
||||
</Link>
|
||||
) : null}
|
||||
<span className="font-mono text-(length:--text-micro) opacity-70">{record.action}</span>
|
||||
</div>
|
||||
</div>
|
||||
<time
|
||||
className="shrink-0 whitespace-nowrap text-xs text-muted-foreground"
|
||||
dateTime={record.createdAt}
|
||||
title={new Date(record.createdAt).toLocaleString()}
|
||||
>
|
||||
{relativeTime(record.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** The permission-denied / upsell state shown when the caller lacks the grant. */
|
||||
function AuditUpsell() {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-3 py-14 text-center">
|
||||
<ShieldAlert className="h-10 w-10 text-muted-foreground/50" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Agent audit is a Paperclip Enterprise view</p>
|
||||
<p className="mx-auto mt-1 max-w-md text-sm text-muted-foreground">
|
||||
The agent audit log gives you a searchable, exportable record of everything your agents
|
||||
did — every comment, task change, approval, and run — with the responsible person for
|
||||
each action. Ask an administrator to grant you the{" "}
|
||||
<span className="font-mono text-(length:--text-micro)">audit:view_agent_actions</span>{" "}
|
||||
permission to view it.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuditFeed({ companyId, lockedAgentId, hideHeader }: AuditFeedProps) {
|
||||
const { pushToast } = useToastActions();
|
||||
const [agent, setAgent] = useState<string>(ALL);
|
||||
const [responsibleUser, setResponsibleUser] = useState<string>(ALL);
|
||||
const [actionDomain, setActionDomain] = useState<string>(ALL);
|
||||
const [entityType, setEntityType] = useState<string>(ALL);
|
||||
const [dateFrom, setDateFrom] = useState<string>("");
|
||||
const [dateTo, setDateTo] = useState<string>("");
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const agents = useQuery({
|
||||
queryKey: queryKeys.agents.list(companyId),
|
||||
queryFn: () => agentsApi.list(companyId),
|
||||
});
|
||||
const userDirectory = useQuery({
|
||||
queryKey: queryKeys.access.companyUserDirectory(companyId),
|
||||
queryFn: () => accessApi.listUserDirectory(companyId),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const agentMap = useMemo(
|
||||
() => new Map((agents.data ?? []).map((a) => [a.id, a])),
|
||||
[agents.data],
|
||||
);
|
||||
const userProfileMap = useMemo(
|
||||
() => buildCompanyUserProfileMap(userDirectory.data?.users),
|
||||
[userDirectory.data],
|
||||
);
|
||||
|
||||
const filters: AuditActionFilters = {
|
||||
agentId: lockedAgentId ?? (agent === ALL ? undefined : agent),
|
||||
responsibleUserId: responsibleUser === ALL ? undefined : responsibleUser,
|
||||
action: actionDomain === ALL ? undefined : actionDomain,
|
||||
entityType: entityType === ALL ? undefined : entityType,
|
||||
from: toStartIso(dateFrom),
|
||||
to: toEndIso(dateTo),
|
||||
};
|
||||
|
||||
const hasActiveFilters = Boolean(
|
||||
(!lockedAgentId && agent !== ALL)
|
||||
|| responsibleUser !== ALL
|
||||
|| actionDomain !== ALL
|
||||
|| entityType !== ALL
|
||||
|| dateFrom
|
||||
|| dateTo,
|
||||
);
|
||||
|
||||
const feed = useInfiniteQuery({
|
||||
queryKey: queryKeys.audit.agentActions(companyId, {
|
||||
agentId: filters.agentId,
|
||||
responsibleUserId: filters.responsibleUserId,
|
||||
action: filters.action,
|
||||
entityType: filters.entityType,
|
||||
from: filters.from,
|
||||
to: filters.to,
|
||||
}),
|
||||
queryFn: ({ pageParam }) =>
|
||||
auditApi.listAgentActions(companyId, { ...filters, limit: PAGE_SIZE, cursor: pageParam ?? undefined }),
|
||||
initialPageParam: null as string | null,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
||||
retry: (count, error) => !(error instanceof ApiError && error.status === 403) && count < 2,
|
||||
});
|
||||
|
||||
const items = useMemo(
|
||||
() => feed.data?.pages.flatMap((page) => page.items) ?? [],
|
||||
[feed.data],
|
||||
);
|
||||
|
||||
const permissionDenied = feed.error instanceof ApiError && feed.error.status === 403;
|
||||
|
||||
const clearFilters = () => {
|
||||
setAgent(ALL);
|
||||
setResponsibleUser(ALL);
|
||||
setActionDomain(ALL);
|
||||
setEntityType(ALL);
|
||||
setDateFrom("");
|
||||
setDateTo("");
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const blob = await auditApi.exportAgentActionsCsv(companyId, {
|
||||
agentId: filters.agentId,
|
||||
responsibleUserId: filters.responsibleUserId,
|
||||
action: filters.action,
|
||||
entityType: filters.entityType,
|
||||
from: filters.from,
|
||||
to: filters.to,
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `agent-audit-${companyId}.csv`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
// Browsers may read blob URLs lazily after click(), so keep the URL alive
|
||||
// long enough for the download to start.
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 5_000);
|
||||
pushToast({ title: "Audit exported", body: "Your CSV download has started.", tone: "success" });
|
||||
} catch (error) {
|
||||
pushToast({
|
||||
title: "Export failed",
|
||||
body: error instanceof Error ? error.message : "Could not export the audit log.",
|
||||
tone: "error",
|
||||
});
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (permissionDenied) {
|
||||
return <AuditUpsell />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{!hideHeader ? (
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-foreground">Audit</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||
Everything your agents did, newest first — each line is one recorded action, with the
|
||||
person responsible for it. Click through to the task or run for the full context.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{!lockedAgentId ? (
|
||||
<Select value={agent} onValueChange={setAgent}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="Agent" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>All agents</SelectItem>
|
||||
{(agents.data ?? []).map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : null}
|
||||
<Select value={responsibleUser} onValueChange={setResponsibleUser}>
|
||||
<SelectTrigger className="w-44">
|
||||
<SelectValue placeholder="Responsible user" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>All responsible users</SelectItem>
|
||||
{(userDirectory.data?.users ?? []).map((u) => (
|
||||
<SelectItem key={u.principalId} value={u.principalId}>
|
||||
{u.user?.name ?? u.user?.email ?? u.principalId.slice(0, 8)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={actionDomain} onValueChange={setActionDomain}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue placeholder="Action" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACTION_DOMAINS.map((d) => (
|
||||
<SelectItem key={d.value} value={d.value}>
|
||||
{d.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={entityType} onValueChange={setEntityType}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue placeholder="Entity" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ENTITY_TYPES.map((e) => (
|
||||
<SelectItem key={e.value} value={e.value}>
|
||||
{e.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="date"
|
||||
aria-label="From date"
|
||||
value={dateFrom}
|
||||
max={dateTo || undefined}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
className="w-36"
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
aria-label="To date"
|
||||
value={dateTo}
|
||||
min={dateFrom || undefined}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
className="w-36"
|
||||
/>
|
||||
{hasActiveFilters ? (
|
||||
<Button variant="ghost" size="sm" onClick={clearFilters}>
|
||||
Clear filters
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
onClick={handleExport}
|
||||
disabled={exporting || feed.isLoading || items.length === 0}
|
||||
>
|
||||
<Download className="mr-1.5 h-4 w-4" />
|
||||
{exporting ? "Exporting…" : "Export CSV"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{feed.isLoading ? (
|
||||
<Card>
|
||||
<CardContent className="py-14 text-center text-sm text-muted-foreground">Loading…</CardContent>
|
||||
</Card>
|
||||
) : feed.error ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-3 py-14 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{feed.error instanceof Error ? feed.error.message : "Failed to load the audit log."}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={() => feed.refetch()}>
|
||||
Try again
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : items.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-3 py-14 text-center">
|
||||
<ScrollText className="h-10 w-10 text-muted-foreground/40" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{hasActiveFilters ? "No actions match these filters" : "Nothing here yet"}
|
||||
</p>
|
||||
<p className="mt-1 max-w-md text-sm text-muted-foreground">
|
||||
{hasActiveFilters
|
||||
? "Try a wider date range or different filters."
|
||||
: "As soon as your agents start doing things, their actions show up here."}
|
||||
</p>
|
||||
</div>
|
||||
{hasActiveFilters ? (
|
||||
<Button variant="outline" size="sm" onClick={clearFilters}>
|
||||
Clear filters
|
||||
</Button>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="px-0 py-0">
|
||||
<ul className={cn("divide-y divide-border")}>
|
||||
{items.map((record) => (
|
||||
<AuditRow
|
||||
key={record.id}
|
||||
record={record}
|
||||
agentMap={agentMap}
|
||||
userProfileMap={userProfileMap}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{feed.hasNextPage ? (
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => feed.fetchNextPage()}
|
||||
disabled={feed.isFetchingNextPage}
|
||||
>
|
||||
{feed.isFetchingNextPage ? "Loading…" : "Load more"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Recorded by Paperclip — entries can't be edited. Sensitive values are never stored.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { useEffect } from "react";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { useCompany } from "../../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../../context/BreadcrumbContext";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { AuditFeed } from "./AuditFeed";
|
||||
|
||||
/**
|
||||
* Company-level agent audit page — a permission-gated
|
||||
* rich view in the unified codebase, matching the `tools:view_audit` precedent.
|
||||
* The feed itself renders the upsell/permission-denied state when the caller
|
||||
* lacks `audit:view_agent_actions` (server-authoritative, see `AuditFeed`).
|
||||
*/
|
||||
export function CompanyAudit() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Audit" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <EmptyState icon={ShieldCheck} message="Select a company to view the agent audit log." />;
|
||||
}
|
||||
|
||||
return <AuditFeed companyId={selectedCompanyId} />;
|
||||
}
|
||||
Loading…
Reference in New Issue