mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The Mine inbox gives each human user a personal work queue. > - A human user can complete a task from the board. > - The status update did not update the user's inbox archive state. > - The completed task therefore stayed in the user's Mine inbox. > - This pull request archives the task for the human user who completes it. > - Agent completion does not change another user's inbox archive state. > - The benefit is that manual completion removes the task from Mine without an extra action or comment. ## Linked Issues or Issue Description **What happened?** When a human user changed a task status to `done`, the task stayed in that user's Mine inbox. **Expected behavior** The status change should archive the task from that user's Mine inbox. The operation should not add a task comment. **Steps to reproduce** 1. Open a task that appears in Mine. 2. Change the task status to `done`. 3. Return to Mine. 4. Observe that the completed task is still present. **Paperclip version or commit** `master` before this change. **Deployment mode** All deployment modes with a board user and the Mine inbox. **Access context** Board (human operator). ## What Changed - Archive the completed task for the board user who changes its status to `done`. - Persist the status update, inbox archive, and archive audit atomically. - Publish live and plugin activity only after the owning transaction commits, including recovery, decision, and approval completion paths. - Keep agent-driven completion from changing a human user's inbox archive state. - Add database-backed regression tests for the archive, audit, Mine filter, rollback, event publication, recovery, and agent paths. ## Verification - `pnpm exec vitest run server/src/__tests__/inbox-archive-routes.test.ts` passed all 7 tests. - `pnpm exec vitest run server/src/__tests__/issue-recovery-actions.test.ts` passed all 44 tests. - Five directly affected server test files passed all 74 tests; decision and comment-route suites passed all 105 tests. - `pnpm --filter @paperclipai/server typecheck` passed after the final fix. - `pnpm -r typecheck` and `pnpm build` passed. - The initial repo-wide `pnpm test:run` passed 3,223 tests; one unrelated plugin orchestration wake-reason test failed and reproduced in isolation. - All latest-head GitHub Actions gates are green, including all server and E2E shards. - Greptile reviewed the latest commit at 5/5 with no unresolved review threads. - Public GitHub search found no duplicate issue or pull request. ## Risks - Low risk. The behavior only runs on a board user's transition into `done`. - Reopening and completing the task again updates the existing per-user archive row. - Agent status updates do not archive a human user's inbox. - Transactional callers must supply a post-commit activity queue; covered completion paths do so and tests exercise rollback and publication order. - This bug fix does not duplicate planned core work in `ROADMAP.md`. > 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 from the GPT-5 model family assisted with this change. The runtime does not expose the exact serving model ID or context window. Reasoning, repository tools, code execution, GitHub CLI, and Paperclip API access were enabled. ## 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>
222 lines
8.1 KiB
TypeScript
222 lines
8.1 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { and, eq } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { activityLog, agentApiKeys, companies, heartbeatRuns, issues } from "@paperclipai/db";
|
|
import { isUuidLike, PLUGIN_EVENT_TYPES, type PluginEventType } from "@paperclipai/shared";
|
|
import type { PluginEvent } from "@paperclipai/plugin-sdk";
|
|
import { publishLiveEvent } from "./live-events.js";
|
|
import { redactCurrentUserValue } from "../log-redaction.js";
|
|
import { sanitizeRecord } from "../redaction.js";
|
|
import { logger } from "../middleware/logger.js";
|
|
import type { PluginEventBus } from "./plugin-event-bus.js";
|
|
import { instanceSettingsService } from "./instance-settings.js";
|
|
|
|
const PLUGIN_EVENT_SET: ReadonlySet<string> = new Set(PLUGIN_EVENT_TYPES);
|
|
const ACTIVITY_ACTION_TO_PLUGIN_EVENT: Readonly<Record<string, PluginEventType>> = {
|
|
issue_comment_added: "issue.comment.created",
|
|
issue_comment_created: "issue.comment.created",
|
|
issue_document_created: "issue.document.created",
|
|
issue_document_updated: "issue.document.updated",
|
|
issue_document_deleted: "issue.document.deleted",
|
|
issue_blockers_updated: "issue.relations.updated",
|
|
approval_approved: "approval.decided",
|
|
approval_rejected: "approval.decided",
|
|
approval_revision_requested: "approval.decided",
|
|
budget_soft_threshold_crossed: "budget.incident.opened",
|
|
budget_hard_threshold_crossed: "budget.incident.opened",
|
|
budget_incident_resolved: "budget.incident.resolved",
|
|
};
|
|
|
|
let _pluginEventBus: PluginEventBus | null = null;
|
|
|
|
/** Wire the plugin event bus so domain events are forwarded to plugins. */
|
|
export function setPluginEventBus(bus: PluginEventBus): void {
|
|
if (_pluginEventBus) {
|
|
logger.warn("setPluginEventBus called more than once, replacing existing bus");
|
|
}
|
|
_pluginEventBus = bus;
|
|
}
|
|
|
|
function eventTypeForActivityAction(action: string): PluginEventType | null {
|
|
if (PLUGIN_EVENT_SET.has(action)) return action as PluginEventType;
|
|
return ACTIVITY_ACTION_TO_PLUGIN_EVENT[action.replaceAll(".", "_")] ?? null;
|
|
}
|
|
|
|
export function publishPluginDomainEvent(event: PluginEvent): void {
|
|
if (!_pluginEventBus) return;
|
|
void _pluginEventBus.emit(event).then(({ errors }) => {
|
|
for (const { pluginId, error } of errors) {
|
|
logger.warn({ pluginId, eventType: event.eventType, err: error }, "plugin event handler failed");
|
|
}
|
|
}).catch(() => {});
|
|
}
|
|
|
|
export interface LogActivityInput {
|
|
companyId: string;
|
|
actorType: "agent" | "user" | "system" | "plugin";
|
|
actorId: string;
|
|
action: string;
|
|
entityType: string;
|
|
entityId: string;
|
|
agentId?: string | null;
|
|
runId?: string | null;
|
|
agentApiKeyId?: string | null;
|
|
issueId?: string | null;
|
|
details?: Record<string, unknown> | null;
|
|
responsibleUserIdOverride?: string | null;
|
|
}
|
|
|
|
export interface ActivityPublication {
|
|
companyId: string;
|
|
payload: Record<string, unknown>;
|
|
pluginEvent: PluginEvent | 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;
|
|
}
|
|
|
|
export async function resolveResponsibleUserIdForActivity(db: Db, input: LogActivityInput) {
|
|
if (input.responsibleUserIdOverride !== undefined) {
|
|
return readNonEmptyString(input.responsibleUserIdOverride);
|
|
}
|
|
if (input.actorType === "user") return readNonEmptyString(input.actorId);
|
|
|
|
const runId = readNonEmptyString(input.runId);
|
|
if (runId && isUuidLike(runId)) {
|
|
const run = await db
|
|
.select({ responsibleUserId: heartbeatRuns.responsibleUserId })
|
|
.from(heartbeatRuns)
|
|
.where(and(eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.id, runId)))
|
|
.then((rows) => rows[0] ?? null);
|
|
const runResponsibleUserId = readNonEmptyString(run?.responsibleUserId);
|
|
if (runResponsibleUserId) return runResponsibleUserId;
|
|
}
|
|
|
|
const issueIdCandidate = readNonEmptyString(input.issueId)
|
|
?? (input.entityType === "issue" ? readNonEmptyString(input.entityId) : null);
|
|
const issueId = isUuidLike(issueIdCandidate) ? issueIdCandidate : null;
|
|
if (issueId) {
|
|
const issue = await db
|
|
.select({
|
|
responsibleUserId: issues.responsibleUserId,
|
|
createdByUserId: issues.createdByUserId,
|
|
})
|
|
.from(issues)
|
|
.where(and(eq(issues.companyId, input.companyId), eq(issues.id, issueId)))
|
|
.then((rows) => rows[0] ?? null);
|
|
const issueResponsibleUserId = readNonEmptyString(issue?.responsibleUserId)
|
|
?? readNonEmptyString(issue?.createdByUserId);
|
|
if (issueResponsibleUserId) return issueResponsibleUserId;
|
|
}
|
|
|
|
const agentApiKeyId = readNonEmptyString(input.agentApiKeyId);
|
|
const agentId = readNonEmptyString(input.agentId);
|
|
if (agentApiKeyId && isUuidLike(agentApiKeyId)) {
|
|
const apiKey = await db
|
|
.select({ responsibleUserId: agentApiKeys.responsibleUserId })
|
|
.from(agentApiKeys)
|
|
.where(and(
|
|
eq(agentApiKeys.companyId, input.companyId),
|
|
eq(agentApiKeys.id, agentApiKeyId),
|
|
...(agentId && isUuidLike(agentId) ? [eq(agentApiKeys.agentId, agentId)] : []),
|
|
))
|
|
.then((rows) => rows[0] ?? null);
|
|
const apiKeyResponsibleUserId = readNonEmptyString(apiKey?.responsibleUserId);
|
|
if (apiKeyResponsibleUserId) return apiKeyResponsibleUserId;
|
|
}
|
|
|
|
const company = await db
|
|
.select({ defaultResponsibleUserId: companies.defaultResponsibleUserId })
|
|
.from(companies)
|
|
.where(eq(companies.id, input.companyId))
|
|
.then((rows) => rows[0] ?? null);
|
|
return readNonEmptyString(company?.defaultResponsibleUserId);
|
|
}
|
|
|
|
export function publishActivity(publication: ActivityPublication) {
|
|
publishLiveEvent({
|
|
companyId: publication.companyId,
|
|
type: "activity.logged",
|
|
payload: publication.payload,
|
|
});
|
|
if (publication.pluginEvent) publishPluginDomainEvent(publication.pluginEvent);
|
|
}
|
|
|
|
export async function persistActivity(db: Db, input: LogActivityInput) {
|
|
const redactedDetails = await redactActivityDetails(db, input.details ?? null);
|
|
const responsibleUserId = await resolveResponsibleUserIdForActivity(db, input);
|
|
const [activity] = await db.insert(activityLog).values({
|
|
companyId: input.companyId,
|
|
actorType: input.actorType,
|
|
actorId: input.actorId,
|
|
action: input.action,
|
|
entityType: input.entityType,
|
|
entityId: input.entityId,
|
|
agentId: input.agentId ?? null,
|
|
runId: input.runId ?? null,
|
|
responsibleUserId,
|
|
details: redactedDetails,
|
|
}).returning({ id: activityLog.id });
|
|
|
|
const payload = {
|
|
actorType: input.actorType,
|
|
actorId: input.actorId,
|
|
action: input.action,
|
|
entityType: input.entityType,
|
|
entityId: input.entityId,
|
|
agentId: input.agentId ?? null,
|
|
runId: input.runId ?? null,
|
|
responsibleUserId,
|
|
details: redactedDetails,
|
|
};
|
|
const pluginEventType = eventTypeForActivityAction(input.action);
|
|
const pluginEvent: PluginEvent | null = pluginEventType
|
|
? {
|
|
eventId: randomUUID(),
|
|
eventType: pluginEventType,
|
|
occurredAt: new Date().toISOString(),
|
|
actorId: input.actorId,
|
|
actorType: input.actorType,
|
|
entityId: input.entityId,
|
|
entityType: input.entityType,
|
|
companyId: input.companyId,
|
|
payload: {
|
|
...redactedDetails,
|
|
agentId: input.agentId ?? null,
|
|
runId: input.runId ?? null,
|
|
responsibleUserId,
|
|
},
|
|
}
|
|
: null;
|
|
|
|
return {
|
|
activity,
|
|
publication: {
|
|
companyId: input.companyId,
|
|
payload,
|
|
pluginEvent,
|
|
} satisfies ActivityPublication,
|
|
};
|
|
}
|
|
|
|
export async function logActivity(db: Db, input: LogActivityInput) {
|
|
const { activity, publication } = await persistActivity(db, input);
|
|
publishActivity(publication);
|
|
return activity;
|
|
}
|