mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 12:04:41 +01:00
## Thinking Path > - Paperclip is the control plane people use to manage AI-agent companies and their work > - The activity log is the generic audit spine for mutations across the control plane > - Activity rows identify agents and runs, but they do not persist the responsible human upstream > - Relying only on run joins loses attribution after run pruning and misses agent API-key actions outside a run > - This pull request resolves responsible-user attribution when each activity row is written and stores it directly > - The benefit is durable, queryable agent audit feeds without rewriting historical provenance ## Linked Issues or Issue Description ### Problem or motivation Agent activity records do not persist the responsible user, so attribution can disappear when runs are pruned and no-run API-key mutations cannot be attributed correctly. ### Proposed solution Resolve attribution for each new activity row from the run, related issue, active agent API key, or company default, in that order, and persist the result directly. ### Alternatives considered Read-time joins alone were rejected because pruned runs lose durable attribution and out-of-run agent-key actions have no run to join. Historical backfill was rejected because it would invent provenance. ### Roadmap alignment This strengthens the durable audit-trail direction described in `ROADMAP.md` without adding a new product surface. ## What Changed - Added nullable `activity_log.responsible_user_id` plus company/agent/time and company/responsible-user/time indexes. - Added an idempotent forward-only migration with no historical backfill. - Added centralized write-time resolution: heartbeat run → issue attribution → active agent API key → company default. - Propagated authenticated API-key IDs through existing request-backed `logActivity()` calls. - Added unit coverage for every fallback and an embedded-Postgres assertion for the no-run API-key stamping path. ## Verification - `pnpm --filter @paperclipai/db typecheck` - `pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/activity-log-responsible-user.test.ts src/__tests__/authz-company-access.test.ts` - Initial focused verification: 25 tests passed; migration safety passed. - Follow-up regression verification: 54 focused attribution/company-skill/environment/issue-tree/tool-gateway tests passed; server typecheck passed. - GitHub: full build, typecheck, server shards, serialized suites, e2e, security, and policy checks passed. ## Risks - Adding two indexes to an existing large table can hold a write lock while the transactional migration runs. The migration safety suppressions document why `CONCURRENTLY` is unavailable under the current Drizzle migration runner. - Historical rows remain nullable by design; this avoids inventing provenance and keeps the migration forward-only. - API-key attribution requires request-backed activity call sites to pass the authenticated key ID; this PR mechanically updates the existing actor-based activity calls and covers the no-run path with integration testing. > 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, `gpt-5.4`; context-window size was not exposed by the runtime. Medium reasoning with repository editing, terminal execution, and test execution capabilities was used. ## 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>
176 lines
6.1 KiB
TypeScript
176 lines
6.1 KiB
TypeScript
import { Router } from "express";
|
|
import type { Db } from "@paperclipai/db";
|
|
import {
|
|
createFolderSchema,
|
|
ensureMySkillFolderSchema,
|
|
folderKindSchema,
|
|
moveFolderItemSchema,
|
|
moveFolderSchema,
|
|
updateFolderSchema,
|
|
} from "@paperclipai/shared";
|
|
import { validate } from "../middleware/validate.js";
|
|
import { badRequest, forbidden } from "../errors.js";
|
|
import { folderService, logActivity } from "../services/index.js";
|
|
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
|
|
|
export function folderRoutes(db: Db) {
|
|
const router = Router();
|
|
const svc = folderService(db);
|
|
|
|
function parseKind(value: unknown) {
|
|
const result = folderKindSchema.safeParse(value);
|
|
if (!result.success) throw badRequest("Folder kind query parameter is required");
|
|
return result.data;
|
|
}
|
|
|
|
router.get("/companies/:companyId/folders", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
res.json(await svc.list(companyId, parseKind(req.query.kind)));
|
|
});
|
|
|
|
router.post("/companies/:companyId/folders", validate(createFolderSchema), async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
const created = await svc.create(companyId, req.body);
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
agentApiKeyId: actor.agentApiKeyId,
|
|
action: "folder.created",
|
|
entityType: "folder",
|
|
entityId: created.id,
|
|
details: { kind: created.kind, name: created.name, path: created.path, parentId: created.parentId, position: created.position },
|
|
});
|
|
res.status(201).json(created);
|
|
});
|
|
|
|
router.post(
|
|
"/companies/:companyId/folders/ensure-my",
|
|
validate(ensureMySkillFolderSchema),
|
|
async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
if (req.actor.type !== "board" || !req.actor.userId) {
|
|
throw forbidden("A signed-in board user is required to create a personal skill folder");
|
|
}
|
|
const folder = await svc.ensureMyFolder(companyId, req.actor.userId, req.actor.userName ?? null, req.body.slug);
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
agentApiKeyId: actor.agentApiKeyId,
|
|
action: "folder.personal_ensured",
|
|
entityType: "folder",
|
|
entityId: folder.id,
|
|
details: { path: folder.path, systemKey: folder.systemKey },
|
|
});
|
|
res.json(folder);
|
|
},
|
|
);
|
|
|
|
router.patch("/companies/:companyId/folders/:folderId", validate(updateFolderSchema), async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
const folderId = req.params.folderId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
const updated = await svc.update(companyId, folderId, req.body);
|
|
if (!updated) {
|
|
res.status(404).json({ error: "Folder not found" });
|
|
return;
|
|
}
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
agentApiKeyId: actor.agentApiKeyId,
|
|
action: "folder.updated",
|
|
entityType: "folder",
|
|
entityId: updated.id,
|
|
details: { kind: updated.kind, name: updated.name, path: updated.path, position: updated.position },
|
|
});
|
|
res.json(updated);
|
|
});
|
|
|
|
router.post("/companies/:companyId/folders/items/move", validate(moveFolderItemSchema), async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
const moved = await svc.moveItem(companyId, req.body);
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
agentApiKeyId: actor.agentApiKeyId,
|
|
action: "folder.item_moved",
|
|
entityType: req.body.kind === "routine" ? "routine" : "company_skill",
|
|
entityId: moved.itemId,
|
|
details: { kind: moved.kind, folderId: moved.folderId },
|
|
});
|
|
res.json(moved);
|
|
});
|
|
|
|
router.post("/companies/:companyId/folders/:folderId/move", validate(moveFolderSchema), async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
const folderId = req.params.folderId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
const updated = await svc.moveFolder(companyId, folderId, req.body);
|
|
if (!updated) {
|
|
res.status(404).json({ error: "Folder not found" });
|
|
return;
|
|
}
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
agentApiKeyId: actor.agentApiKeyId,
|
|
action: "folder.moved",
|
|
entityType: "folder",
|
|
entityId: updated.id,
|
|
details: { kind: updated.kind, parentId: updated.parentId, path: updated.path, position: updated.position },
|
|
});
|
|
res.json(updated);
|
|
});
|
|
|
|
router.delete("/companies/:companyId/folders/:folderId", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
const folderId = req.params.folderId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
const deleted = await svc.deleteFolder(companyId, folderId);
|
|
if (!deleted) {
|
|
res.status(404).json({ error: "Folder not found" });
|
|
return;
|
|
}
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
agentApiKeyId: actor.agentApiKeyId,
|
|
action: "folder.deleted",
|
|
entityType: "folder",
|
|
entityId: deleted.id,
|
|
details: { kind: deleted.kind, name: deleted.name },
|
|
});
|
|
res.json({ deleted });
|
|
});
|
|
|
|
return router;
|
|
}
|