mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 02:00:52 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The server records actions that change issues and their queued comments > - The queued-comment edit, reorder, and discard routes changed queue state without activity rows > - Operators could not inspect these queue mutations in the activity feed > - This pull request adds one identifier-only activity row for each successful queue mutation > - The benefit is a durable audit trail with no comment text in the activity log ## Linked Issues or Issue Description **What existing behavior does this improve?** The queued-comment edit, reorder, and discard routes now record their successful mutations in the activity feed. **Subsystem affected** Cross-cutting (server and ui). **Current behavior** The three queue mutation routes change queued comments but do not write an activity row. The activity feed has no label for these actions. **Proposed behavior** Each successful route writes one activity row with the actor fields, entity fields, queue identifiers, and queue revision. The discard row also includes the cancelled run identifier. The activity feed shows a label for each action. **Reason and benefit** Operators need a durable record of queue changes. Identifier-only details support audit and troubleshooting without storing comment text. **Breaking changes** None. The routes keep their existing response and authorization behavior. **Additional context** Each mutation writes its activity row on the same locked transaction that applies the mutation, so the two commit or roll back together. The route publishes the live activity event only after that transaction commits. The separate comment-cancel route opts out of this write and keeps its existing single activity row. ## What Changed - Add activity rows for queued-comment edit, reorder, and discard mutations. - Include queue identifiers, revisions, ordered comment identifiers, and cancelled run identifiers as applicable. - Add activity-feed labels for the three new actions. - Add route and activity-format tests for the new behavior. - Write each activity row on the same transaction as the mutation it records, through a new port method that the adapter implements. - Keep the comment-delete route opted out of that write, so a cancellation does not log two rows. ## Verification - [x] `npx vitest run server/src/__tests__/issue-queued-comments-routes.test.ts` passes. - [x] `npx vitest run ui/src/lib/activity-format.test.ts` passes. - [x] `pnpm --filter @paperclipai/server typecheck` exits 0. - [x] `pnpm --filter @paperclipai/ui typecheck` exits 0. - [x] `node scripts/check-module-boundaries.mjs` passes. - [x] The full CI suite is green. ## Risks Low risk. The change adds activity rows after successful mutations and does not change route responses, authorization, or stored comment text. ## Model Used OpenAI Codex, GPT-5 Codex. The model used repository inspection, Git operations, and command execution. The context window and reasoning mode are not exposed by this runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes: #` / `Refs: #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
312 lines
12 KiB
TypeScript
312 lines
12 KiB
TypeScript
import { and, eq, inArray } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { agentWakeupRequests, agents, heartbeatRuns, issueComments, issues } from "@paperclipai/db";
|
|
import type { IssueComment, IssueQueuedCommentQueue } from "@paperclipai/shared";
|
|
import {
|
|
buildQueuedCommentQueueSnapshot,
|
|
decideQueuedCommentQueueSteering,
|
|
queuedCommentIdsFromWakePayload,
|
|
withQueuedCommentIdsInRunContext,
|
|
withQueuedCommentIdsInWakePayload,
|
|
} from "../../../services/issue-queued-comment-queue.js";
|
|
import { logActivity as persistActivityLogRow, type ActivityPublication } from "../../../services/activity-log.js";
|
|
import { decideQueuedCommentWakeLookup } from "../domain/policy.js";
|
|
import { parseObject, readNonEmptyString } from "../domain/values.js";
|
|
import { QueuedCommentMutationError } from "../application/queued-comment-use-cases.js";
|
|
import type {
|
|
LockedQueuedCommentState,
|
|
QueuedCommentActivityLogInput,
|
|
QueuedCommentActivityPublication,
|
|
QueuedCommentIssueLockWriter,
|
|
QueuedCommentQueueTransaction,
|
|
QueuedCommentRunRow,
|
|
QueuedCommentWakeRow,
|
|
} from "../application/queued-comment-ports.js";
|
|
|
|
type WakeRow = typeof agentWakeupRequests.$inferSelect;
|
|
type RunRow = typeof heartbeatRuns.$inferSelect;
|
|
|
|
function toWakeRow(row: WakeRow): QueuedCommentWakeRow {
|
|
return { id: row.id, agentId: row.agentId, status: row.status, runId: row.runId, payload: parseObject(row.payload) };
|
|
}
|
|
|
|
function toRunRow(row: RunRow): QueuedCommentRunRow {
|
|
return { id: row.id, status: row.status, runtimeMode: row.runtimeMode, contextSnapshot: parseObject(row.contextSnapshot) };
|
|
}
|
|
|
|
export type QueuedCommentQueuePostgresAdapterDeps = {
|
|
/** `issueReferenceService(db).syncComment`; runs on the module's own transaction. */
|
|
syncCommentReferences(commentId: string, tx: Db): Promise<void>;
|
|
/** `issueReferenceService(db).deleteCommentSource`; runs on the module's own transaction. */
|
|
deleteCommentReferenceSource(commentId: string, tx: Db): Promise<void>;
|
|
/** `externalObjectService(db, opts).syncCommentSafely`; runs on the module's own transaction. */
|
|
syncCommentExternalObjectsSafely(commentId: string, tx: Db): Promise<void>;
|
|
};
|
|
|
|
function buildTransaction(tx: Db, companyId: string, deps: QueuedCommentQueuePostgresAdapterDeps): QueuedCommentQueueTransaction {
|
|
return {
|
|
async updateCommentBody({ issueId, commentId, body, updatedAt }) {
|
|
const updated = await tx
|
|
.update(issueComments)
|
|
.set({ body, updatedAt })
|
|
.where(and(eq(issueComments.id, commentId), eq(issueComments.issueId, issueId), eq(issueComments.companyId, companyId)))
|
|
.returning({ id: issueComments.id })
|
|
.then((rows) => rows[0] ?? null);
|
|
return updated !== null;
|
|
},
|
|
|
|
async touchIssueUpdatedAt({ issueId, updatedAt }) {
|
|
await tx.update(issues).set({ updatedAt }).where(and(eq(issues.id, issueId), eq(issues.companyId, companyId)));
|
|
},
|
|
|
|
async updateWakeQueuedCommentIds({ wakeId, payload, ids, updatedAt }) {
|
|
const row = await tx
|
|
.update(agentWakeupRequests)
|
|
.set({ payload: withQueuedCommentIdsInWakePayload(payload, ids), updatedAt })
|
|
.where(and(eq(agentWakeupRequests.id, wakeId), eq(agentWakeupRequests.companyId, companyId)))
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
return toWakeRow(row);
|
|
},
|
|
|
|
async updateQueueRunCommentIds({ queueRunId, contextSnapshot, ids, updatedAt }) {
|
|
const row = await tx
|
|
.update(heartbeatRuns)
|
|
.set({ contextSnapshot: withQueuedCommentIdsInRunContext(contextSnapshot, ids), updatedAt })
|
|
.where(and(eq(heartbeatRuns.id, queueRunId), eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.status, "queued")))
|
|
.returning()
|
|
.then((rows) => rows[0] ?? null);
|
|
return row ? toRunRow(row) : null;
|
|
},
|
|
|
|
async deleteComment({ issueId, commentId }) {
|
|
const row = await tx
|
|
.delete(issueComments)
|
|
.where(and(eq(issueComments.id, commentId), eq(issueComments.issueId, issueId), eq(issueComments.companyId, companyId)))
|
|
.returning()
|
|
.then((rows) => rows[0] ?? null);
|
|
return row ? (row as IssueComment) : null;
|
|
},
|
|
|
|
async cancelWake({ wakeId, reason, now }) {
|
|
await tx
|
|
.update(agentWakeupRequests)
|
|
.set({ status: "cancelled", finishedAt: now, error: reason, updatedAt: now })
|
|
.where(and(eq(agentWakeupRequests.id, wakeId), eq(agentWakeupRequests.companyId, companyId)));
|
|
},
|
|
|
|
async cancelQueueRun({ queueRunId, reason, now }) {
|
|
const row = await tx
|
|
.update(heartbeatRuns)
|
|
.set({ status: "cancelled", finishedAt: now, error: reason, errorCode: "queued_comment_discarded", updatedAt: now })
|
|
.where(and(eq(heartbeatRuns.id, queueRunId), eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.status, "queued")))
|
|
.returning({ id: heartbeatRuns.id })
|
|
.then((rows) => rows[0] ?? null);
|
|
return row ? { id: row.id } : null;
|
|
},
|
|
|
|
async clearExecutionLockAndTouchIssue({ issueId, executionRunId, updatedAt }) {
|
|
await tx
|
|
.update(issues)
|
|
.set({ executionRunId: null, executionAgentNameKey: null, executionLockedAt: null, updatedAt })
|
|
.where(and(eq(issues.id, issueId), eq(issues.companyId, companyId), eq(issues.executionRunId, executionRunId)));
|
|
},
|
|
|
|
async buildQueueSnapshot({ issue, actor, wake, state, queueRun, activeRun }): Promise<IssueQueuedCommentQueue> {
|
|
const commentIds = queuedCommentIdsFromWakePayload(wake?.payload ?? null);
|
|
const rows =
|
|
commentIds.length > 0
|
|
? await tx
|
|
.select()
|
|
.from(issueComments)
|
|
.where(
|
|
and(
|
|
eq(issueComments.companyId, companyId),
|
|
eq(issueComments.issueId, issue.id),
|
|
inArray(issueComments.id, commentIds),
|
|
),
|
|
)
|
|
: [];
|
|
const byId = new Map(rows.map((row) => [row.id, row]));
|
|
const comments = commentIds.flatMap((id) => {
|
|
const row = byId.get(id);
|
|
return row && !row.deletedAt ? [row] : [];
|
|
});
|
|
|
|
const assignedAgent = issue.assigneeAgentId
|
|
? await tx
|
|
.select({ adapterType: agents.adapterType })
|
|
.from(agents)
|
|
.where(and(eq(agents.id, issue.assigneeAgentId), eq(agents.companyId, companyId)))
|
|
.limit(1)
|
|
.then((agentRows) => agentRows[0] ?? null)
|
|
: null;
|
|
|
|
// A queue mutation never delivers same-turn steering itself, so this
|
|
// adapter never probes the live runner: it answers
|
|
// "temporarily_unavailable" wherever the shared rule says a caller
|
|
// may probe. Only the read path probes the live provider.
|
|
const steering = decideQueuedCommentQueueSteering({
|
|
state,
|
|
queueRunRuntimeMode: queueRun?.runtimeMode ?? null,
|
|
activeRun,
|
|
assignedAgentAdapterType: assignedAgent?.adapterType ?? null,
|
|
queuedCommentCount: comments.length,
|
|
});
|
|
const steeringDisposition: IssueQueuedCommentQueue["steeringDisposition"] =
|
|
steering.kind === "probe" ? "temporarily_unavailable" : steering.kind;
|
|
|
|
return buildQueuedCommentQueueSnapshot({
|
|
issueId: issue.id,
|
|
queueId: wake?.id ?? null,
|
|
state,
|
|
activeRunId: activeRun?.id ?? null,
|
|
protocol: steering.protocol,
|
|
steeringDisposition,
|
|
comments,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
});
|
|
},
|
|
|
|
async syncCommentReferences(commentId) {
|
|
await deps.syncCommentReferences(commentId, tx);
|
|
},
|
|
async deleteCommentReferenceSource(commentId) {
|
|
await deps.deleteCommentReferenceSource(commentId, tx);
|
|
},
|
|
async syncCommentExternalObjectsSafely(commentId) {
|
|
await deps.syncCommentExternalObjectsSafely(commentId, tx);
|
|
},
|
|
|
|
async logActivity(input: QueuedCommentActivityLogInput): Promise<QueuedCommentActivityPublication> {
|
|
const publications: ActivityPublication[] = [];
|
|
await persistActivityLogRow(
|
|
tx,
|
|
{
|
|
companyId,
|
|
actorType: input.actorType,
|
|
actorId: input.actorId,
|
|
agentId: input.agentId,
|
|
runId: input.runId,
|
|
agentApiKeyId: input.agentApiKeyId,
|
|
action: input.action,
|
|
entityType: "issue",
|
|
entityId: input.entityId,
|
|
details: input.details,
|
|
},
|
|
publications,
|
|
);
|
|
return publications[0];
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createQueuedCommentIssueLockWriter(db: Db, deps: QueuedCommentQueuePostgresAdapterDeps): QueuedCommentIssueLockWriter {
|
|
return {
|
|
async withLockedQueue(input, fn) {
|
|
return db.transaction(async (rawTx) => {
|
|
const tx = rawTx as unknown as Db;
|
|
const companyId = input.issue.companyId;
|
|
|
|
await tx
|
|
.select({ id: issues.id })
|
|
.from(issues)
|
|
.where(and(eq(issues.id, input.issue.id), eq(issues.companyId, companyId)))
|
|
.for("update");
|
|
|
|
const wakeRow = await tx
|
|
.select()
|
|
.from(agentWakeupRequests)
|
|
.where(
|
|
and(
|
|
eq(agentWakeupRequests.id, input.queueId),
|
|
eq(agentWakeupRequests.companyId, companyId),
|
|
input.issue.assigneeAgentId ? eq(agentWakeupRequests.agentId, input.issue.assigneeAgentId) : undefined,
|
|
),
|
|
)
|
|
.for("update")
|
|
.limit(1)
|
|
.then((rows) => rows[0] ?? null);
|
|
|
|
const wakePayload = parseObject(wakeRow?.payload);
|
|
const lookup = decideQueuedCommentWakeLookup({
|
|
wakePresent: wakeRow !== null,
|
|
wakeIssueIdMatches: readNonEmptyString(wakePayload.issueId) === input.issue.id,
|
|
hasQueuedCommentIds: queuedCommentIdsFromWakePayload(wakeRow?.payload ?? null).length > 0,
|
|
wakeStatus: wakeRow?.status ?? null,
|
|
wakeHasRunId: Boolean(wakeRow?.runId),
|
|
});
|
|
|
|
if (lookup.kind === "not_pending") {
|
|
throw new QueuedCommentMutationError("queued_comment_not_pending", "The queued message is no longer pending");
|
|
}
|
|
if (lookup.kind === "already_dispatching") {
|
|
throw new QueuedCommentMutationError("queued_comment_already_dispatching", "The queued message is already being dispatched");
|
|
}
|
|
|
|
// Unreachable: `decideQueuedCommentWakeLookup` only returns "deferred" or "check_queue_run" when the wake row is present.
|
|
if (!wakeRow) throw new Error("wake-queue: queued-comment lookup resolved without a wake row");
|
|
|
|
let state: "deferred" | "queued";
|
|
let queueRunRow: RunRow | null = null;
|
|
|
|
if (lookup.kind === "deferred") {
|
|
state = "deferred";
|
|
} else {
|
|
// check_queue_run: `wakeHasRunId` was true for this branch to have been reached.
|
|
queueRunRow = await tx
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(
|
|
and(
|
|
eq(heartbeatRuns.id, wakeRow.runId!),
|
|
eq(heartbeatRuns.companyId, companyId),
|
|
eq(heartbeatRuns.agentId, wakeRow.agentId),
|
|
eq(heartbeatRuns.wakeupRequestId, wakeRow.id),
|
|
),
|
|
)
|
|
.for("update")
|
|
.limit(1)
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!queueRunRow || queueRunRow.status !== "queued") {
|
|
throw new QueuedCommentMutationError(
|
|
"queued_comment_already_dispatching",
|
|
"The queued message is already being dispatched",
|
|
);
|
|
}
|
|
state = "queued";
|
|
}
|
|
|
|
const activeRunId = state === "deferred" ? input.issue.executionRunId : null;
|
|
const activeRunRow = activeRunId
|
|
? await tx
|
|
.select()
|
|
.from(heartbeatRuns)
|
|
.where(and(eq(heartbeatRuns.id, activeRunId), eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.status, "running")))
|
|
.for("update")
|
|
.limit(1)
|
|
.then((rows) => rows[0] ?? null)
|
|
: null;
|
|
|
|
const transaction = buildTransaction(tx, companyId, deps);
|
|
const wake = toWakeRow(wakeRow);
|
|
const queueRun = queueRunRow ? toRunRow(queueRunRow) : null;
|
|
const activeRun = activeRunRow ? toRunRow(activeRunRow) : null;
|
|
|
|
const queue = await transaction.buildQueueSnapshot({
|
|
issue: input.issue,
|
|
actor: input.actor,
|
|
wake,
|
|
state,
|
|
queueRun,
|
|
activeRun,
|
|
});
|
|
|
|
const locked: LockedQueuedCommentState = { wake, state, queueRun, activeRun, queue };
|
|
return fn(locked, transaction);
|
|
});
|
|
},
|
|
};
|
|
}
|