diff --git a/server/src/__tests__/issue-queued-comments-routes.test.ts b/server/src/__tests__/issue-queued-comments-routes.test.ts index 6db7e9432d..41e7bd3992 100644 --- a/server/src/__tests__/issue-queued-comments-routes.test.ts +++ b/server/src/__tests__/issue-queued-comments-routes.test.ts @@ -251,6 +251,68 @@ describeEmbeddedPostgres("issue queued-comment routes", () => { expect(stale.body.details?.code).toBe("queued_comment_revision_conflict"); }); + it("writes one activity log row for each successful queue mutation, and none for a rejected one", async () => { + const seeded = await seedQueue(); + const initial = await request(app(seeded.companyId)) + .get(`/api/issues/${seeded.issueId}/queued-comments`); + + const edited = await request(app(seeded.companyId)) + .patch(`/api/issues/${seeded.issueId}/queued-comments/${seeded.commentIds[0]}`) + .send({ queueId: seeded.wakeId, revision: initial.body.revision, body: "edited body" }); + expect(edited.status, JSON.stringify(edited.body)).toBe(200); + + const staleEdit = await request(app(seeded.companyId)) + .patch(`/api/issues/${seeded.issueId}/queued-comments/${seeded.commentIds[0]}`) + .send({ queueId: seeded.wakeId, revision: initial.body.revision, body: "stale" }); + expect(staleEdit.status).toBe(409); + + const editRows = await db + .select({ details: activityLog.details }) + .from(activityLog) + .where(eq(activityLog.action, "issue.queued_comment_edited")); + expect(editRows).toHaveLength(1); + expect(editRows[0]?.details).toMatchObject({ + commentId: seeded.commentIds[0], + queueId: seeded.wakeId, + revision: edited.body.revision, + }); + + const reordered = await request(app(seeded.companyId)) + .put(`/api/issues/${seeded.issueId}/queued-comments/order`) + .send({ + queueId: seeded.wakeId, + revision: edited.body.revision, + orderedCommentIds: [...seeded.commentIds].reverse(), + }); + expect(reordered.status, JSON.stringify(reordered.body)).toBe(200); + const reorderRow = await db + .select({ details: activityLog.details }) + .from(activityLog) + .where(eq(activityLog.action, "issue.queued_comments_reordered")) + .then((rows) => rows[0]); + expect(reorderRow?.details).toMatchObject({ + queueId: seeded.wakeId, + revision: reordered.body.revision, + orderedCommentIds: [...seeded.commentIds].reverse(), + }); + + const discarded = await request(app(seeded.companyId)) + .delete(`/api/issues/${seeded.issueId}/queued-comments/${seeded.commentIds[1]}`) + .send({ queueId: seeded.wakeId, revision: reordered.body.revision }); + expect(discarded.status, JSON.stringify(discarded.body)).toBe(200); + const discardRow = await db + .select({ details: activityLog.details }) + .from(activityLog) + .where(eq(activityLog.action, "issue.queued_comment_discarded")) + .then((rows) => rows[0]); + expect(discardRow?.details).toMatchObject({ + commentId: seeded.commentIds[1], + queueId: seeded.wakeId, + revision: discarded.body.revision, + cancelledRunId: null, + }); + }); + it("preserves reordered messages across promotion and cancels the queued run after final trash", async () => { const seeded = await seedQueue(); const initial = await request(app(seeded.companyId)) @@ -311,6 +373,21 @@ describeEmbeddedPostgres("issue queued-comment routes", () => { ]); expect(queueRun?.status).toBe("cancelled"); expect(storedIssue?.executionRunId).toBeNull(); + + // Two discards happen in this scenario (the first trash, then the final + // one that empties the queue), so match the row by its own commentId + // instead of assuming insertion order. + const discardRows = await db + .select({ details: activityLog.details }) + .from(activityLog) + .where(eq(activityLog.action, "issue.queued_comment_discarded")); + const finalDiscardRow = discardRows.find( + (row) => (row.details as { commentId?: string } | null)?.commentId === seeded.commentIds[0], + ); + expect(finalDiscardRow?.details).toMatchObject({ + commentId: seeded.commentIds[0], + cancelledRunId: queueRunId, + }); }); it("keeps a mutation response's steering disposition in step with a fresh GET after promotion", async () => { diff --git a/server/src/modules/wake-queue/adapters/queued-comment-postgres.test.ts b/server/src/modules/wake-queue/adapters/queued-comment-postgres.test.ts index 9ca96964b8..d8e30032d7 100644 --- a/server/src/modules/wake-queue/adapters/queued-comment-postgres.test.ts +++ b/server/src/modules/wake-queue/adapters/queued-comment-postgres.test.ts @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto"; import { eq } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import type { Db } from "@paperclipai/db"; -import { agentWakeupRequests, agents, companies, createDb, heartbeatRuns, issueComments, issues } from "@paperclipai/db"; +import { activityLog, agentWakeupRequests, agents, companies, createDb, heartbeatRuns, issueComments, issues } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, @@ -44,6 +44,9 @@ describeEmbeddedPostgres("queued-comment postgres adapter", () => { }, 20_000); afterEach(async () => { + // Deleted first: activity_log rows reference companies, agents, and + // heartbeat_runs, and none of those foreign keys cascade. + await db.delete(activityLog); await db.delete(issueComments); await db.delete(heartbeatRuns); await db.delete(agentWakeupRequests); @@ -148,7 +151,7 @@ describeEmbeddedPostgres("queued-comment postgres adapter", () => { // context; that single value binds every read and write for the // whole transaction, so it alone must decide what is visible. issue: { id: issueId, companyId: otherCompanyId, assigneeAgentId: agentId, executionRunId: null }, - actor: { actorType: "user", actorId: "user-1", agentId: null }, + actor: { actorType: "user", actorId: "user-1", agentId: null, runId: null, agentApiKeyId: null }, queueId: wakeId, }, async () => { @@ -176,7 +179,7 @@ describeEmbeddedPostgres("queued-comment postgres adapter", () => { issueLock.withLockedQueue( { issue: { id: issueId, companyId, assigneeAgentId: agentId, executionRunId: null }, - actor: { actorType: "user", actorId: "user-1", agentId: null }, + actor: { actorType: "user", actorId: "user-1", agentId: null, runId: null, agentApiKeyId: null }, queueId: wakeId, }, async (_locked, transaction) => { @@ -216,7 +219,7 @@ describeEmbeddedPostgres("queued-comment postgres adapter", () => { const queue = await issueLock.withLockedQueue( { issue: { id: issueId, companyId, assigneeAgentId: agentId, executionRunId: null }, - actor: { actorType: "user", actorId: "user-1", agentId: null }, + actor: { actorType: "user", actorId: "user-1", agentId: null, runId: null, agentApiKeyId: null }, queueId: wakeId, }, async (locked, transaction) => { @@ -231,7 +234,7 @@ describeEmbeddedPostgres("queued-comment postgres adapter", () => { await transaction.syncCommentReferences(commentId); return transaction.buildQueueSnapshot({ issue: { id: issueId, companyId, assigneeAgentId: agentId, executionRunId: null }, - actor: { actorType: "user", actorId: "user-1", agentId: null }, + actor: { actorType: "user", actorId: "user-1", agentId: null, runId: null, agentApiKeyId: null }, wake: locked.wake, state: locked.state, queueRun: locked.queueRun, @@ -247,6 +250,54 @@ describeEmbeddedPostgres("queued-comment postgres adapter", () => { expect(commentRow?.body).toBe("edited body"); }); + it("rolls back an already-applied comment edit when its own activity insert fails", async () => { + const companyId = await seedCompany(); + const agentId = await seedAgent({ companyId }); + const issueId = await seedIssue({ companyId, assigneeAgentId: agentId }); + const commentId = await seedComment({ companyId, issueId, authorUserId: "user-1" }); + const wakeId = await seedDeferredWake({ companyId, agentId, issueId, commentIds: [commentId] }); + + const issueLock = createQueuedCommentIssueLockWriter(db, noopDeps); + const missingAgentId = randomUUID(); + + await expect( + issueLock.withLockedQueue( + { + issue: { id: issueId, companyId, assigneeAgentId: agentId, executionRunId: null }, + actor: { actorType: "user", actorId: "user-1", agentId: null, runId: null, agentApiKeyId: null }, + queueId: wakeId, + }, + async (_locked, transaction) => { + await transaction.updateCommentBody({ + issueId, + commentId, + body: "edited body", + updatedAt: new Date(), + }); + // `agentId` carries a foreign key to `agents.id`; naming an agent + // that was never seeded forces the activity insert to fail, which + // must roll back the comment edit issued moments earlier on the + // same transaction. + await transaction.logActivity({ + actorType: "agent", + actorId: missingAgentId, + agentId: missingAgentId, + runId: null, + agentApiKeyId: null, + action: "issue.queued_comment_edited", + entityId: issueId, + details: {}, + }); + }, + ), + ).rejects.toMatchObject({ cause: { code: "23503" } }); + + const commentRow = (await db.select().from(issueComments).where(eq(issueComments.id, commentId)))[0]; + expect(commentRow?.body).toBe("queued message"); + const activityRows = await db.select().from(activityLog).where(eq(activityLog.entityId, issueId)); + expect(activityRows).toHaveLength(0); + }); + // Pins a fact the database itself cannot persist today: `runtime_mode` is // a NOT NULL column, so a real active run's own field is never null. The // port type allows it (`runtimeMode: string | null`), so this test builds @@ -263,14 +314,14 @@ describeEmbeddedPostgres("queued-comment postgres adapter", () => { const queue = await issueLock.withLockedQueue( { issue: { id: issueId, companyId, assigneeAgentId: agentId, executionRunId: null }, - actor: { actorType: "user", actorId: "user-1", agentId: null }, + actor: { actorType: "user", actorId: "user-1", agentId: null, runId: null, agentApiKeyId: null }, queueId: wakeId, }, async (locked, transaction) => { expect(locked.state).toBe("deferred"); return transaction.buildQueueSnapshot({ issue: { id: issueId, companyId, assigneeAgentId: agentId, executionRunId: null }, - actor: { actorType: "user", actorId: "user-1", agentId: null }, + actor: { actorType: "user", actorId: "user-1", agentId: null, runId: null, agentApiKeyId: null }, wake: locked.wake, state: "deferred", queueRun: null, diff --git a/server/src/modules/wake-queue/adapters/queued-comment-postgres.ts b/server/src/modules/wake-queue/adapters/queued-comment-postgres.ts index 0d8d4e01c8..375a99eb63 100644 --- a/server/src/modules/wake-queue/adapters/queued-comment-postgres.ts +++ b/server/src/modules/wake-queue/adapters/queued-comment-postgres.ts @@ -9,11 +9,14 @@ import { 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, @@ -175,6 +178,27 @@ function buildTransaction(tx: Db, companyId: string, deps: QueuedCommentQueuePos async syncCommentExternalObjectsSafely(commentId) { await deps.syncCommentExternalObjectsSafely(commentId, tx); }, + + async logActivity(input: QueuedCommentActivityLogInput): Promise { + 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]; + }, }; } diff --git a/server/src/modules/wake-queue/application/queued-comment-ports.ts b/server/src/modules/wake-queue/application/queued-comment-ports.ts index 36ca88c440..a30d0c6bc0 100644 --- a/server/src/modules/wake-queue/application/queued-comment-ports.ts +++ b/server/src/modules/wake-queue/application/queued-comment-ports.ts @@ -13,6 +13,33 @@ export type QueuedCommentActor = { actorId: string; /** Null for a user actor. */ agentId: string | null; + runId: string | null; + agentApiKeyId: string | null; +}; + +/** The fields a mutation needs to log its own activity row; entity type is always "issue". */ +export type QueuedCommentActivityLogInput = { + actorType: "agent" | "user"; + actorId: string; + agentId: string | null; + runId: string | null; + agentApiKeyId: string | null; + action: string; + entityId: string; + details: Record; +}; + +/** + * Structurally mirrors the server's `ActivityPublication` (from + * `services/activity-log.ts`), which this layer cannot import by name -- + * the module boundary check forbids the application layer from importing + * server services. The route casts this back to `ActivityPublication` + * before calling `publishActivity`. + */ +export type QueuedCommentActivityPublication = { + companyId: string; + payload: Record; + pluginEvent: unknown; }; export type QueuedCommentIssueContext = { @@ -121,6 +148,13 @@ export interface QueuedCommentQueueTransaction { syncCommentReferences(commentId: string): Promise; deleteCommentReferenceSource(commentId: string): Promise; syncCommentExternalObjectsSafely(commentId: string): Promise; + /** + * Persists the activity row on this same transaction, so a mutation and + * its audit record commit or roll back together. Returns the publication + * for the caller to publish once the transaction has committed; this + * write never publishes the live event itself. + */ + logActivity(input: QueuedCommentActivityLogInput): Promise; } export interface QueuedCommentIssueLockWriter { diff --git a/server/src/modules/wake-queue/application/queued-comment-use-cases.test.ts b/server/src/modules/wake-queue/application/queued-comment-use-cases.test.ts index 6be0b8ba2a..d7baf86438 100644 --- a/server/src/modules/wake-queue/application/queued-comment-use-cases.test.ts +++ b/server/src/modules/wake-queue/application/queued-comment-use-cases.test.ts @@ -9,6 +9,7 @@ import { } from "./queued-comment-use-cases.js"; import type { LockedQueuedCommentState, + QueuedCommentActivityPublication, QueuedCommentActor, QueuedCommentEntrySnapshot, QueuedCommentIssueContext, @@ -26,8 +27,24 @@ const ISSUE: QueuedCommentIssueContext = { executionRunId: null, }; -const USER_ACTOR: QueuedCommentActor = { actorType: "user", actorId: "user-1", agentId: null }; -const AGENT_ACTOR: QueuedCommentActor = { actorType: "agent", actorId: "agent-1", agentId: "agent-1" }; +const USER_ACTOR: QueuedCommentActor = { + actorType: "user", + actorId: "user-1", + agentId: null, + runId: null, + agentApiKeyId: null, +}; +const AGENT_ACTOR: QueuedCommentActor = { + actorType: "agent", + actorId: "agent-1", + agentId: "agent-1", + runId: "run-1", + agentApiKeyId: "api-key-1", +}; + +function activityPublicationFixture(overrides: Partial = {}): QueuedCommentActivityPublication { + return { companyId: ISSUE.companyId, payload: {}, pluginEvent: null, ...overrides }; +} function wakeRow(overrides: Partial = {}): QueuedCommentWakeRow { return { id: "wake-1", agentId: "agent-1", status: "deferred_issue_execution", runId: null, payload: {}, ...overrides }; @@ -103,6 +120,7 @@ function createFakeTransaction(overrides: Partial syncCommentReferences: vi.fn(async () => {}), deleteCommentReferenceSource: vi.fn(async () => {}), syncCommentExternalObjectsSafely: vi.fn(async () => {}), + logActivity: vi.fn(async () => activityPublicationFixture()), ...overrides, }; } @@ -135,7 +153,20 @@ describe("editQueuedComment", () => { ); expect(transaction.syncCommentReferences).toHaveBeenCalledWith("comment-1"); expect(transaction.syncCommentExternalObjectsSafely).toHaveBeenCalledWith("comment-1"); - expect(result).toEqual(queueSnapshot()); + expect(result.queue).toEqual(queueSnapshot()); + // Proves the activity write runs on the same locked transaction as the + // mutation, not as a separate statement after it commits. + expect(transaction.logActivity).toHaveBeenCalledWith({ + actorType: "user", + actorId: "user-1", + agentId: null, + runId: null, + agentApiKeyId: null, + action: "issue.queued_comment_edited", + entityId: "issue-1", + details: { commentId: "comment-1", queueId: "wake-1", revision: "rev-1" }, + }); + expect(result.activityPublication).toEqual(activityPublicationFixture()); }); it("rejects a stale queue id with queued_comment_stale_queue", async () => { @@ -219,7 +250,7 @@ describe("reorderQueuedComments", () => { const transaction = createFakeTransaction(); const reorderQueuedComments = createReorderQueuedComments({ issueLock: createFakeIssueLock(locked, transaction) }); - await reorderQueuedComments({ + const result = await reorderQueuedComments({ issue: ISSUE, actor: USER_ACTOR, queueId: "wake-1", @@ -231,6 +262,19 @@ describe("reorderQueuedComments", () => { expect(transaction.updateWakeQueuedCommentIds).toHaveBeenCalledWith( expect.objectContaining({ wakeId: "wake-1", ids: ["b", "a"] }), ); + // Proves the activity write runs on the same locked transaction as the + // mutation, not as a separate statement after it commits. + expect(transaction.logActivity).toHaveBeenCalledWith({ + actorType: "user", + actorId: "user-1", + agentId: null, + runId: null, + agentApiKeyId: null, + action: "issue.queued_comments_reordered", + entityId: "issue-1", + details: { queueId: "wake-1", revision: "rev-1", orderedCommentIds: ["b", "a"] }, + }); + expect(result.activityPublication).toEqual(activityPublicationFixture()); }); it("rejects an order that is not a permutation of the current queue", async () => { @@ -266,6 +310,7 @@ describe("discardQueuedComment", () => { queueId: "wake-1", revision: "rev-1", now: new Date(), + logActivity: true, }); expect(transaction.cancelWake).toHaveBeenCalledWith(expect.objectContaining({ wakeId: "wake-1" })); @@ -274,6 +319,37 @@ describe("discardQueuedComment", () => { expect.objectContaining({ executionRunId: "run-1" }), ); expect(result.cancelledRun).toEqual({ id: "run-1" }); + // Proves the activity write runs on the same locked transaction as the + // mutation, and that the cancelled run's own id lands in its details. + expect(transaction.logActivity).toHaveBeenCalledWith({ + actorType: "user", + actorId: "user-1", + agentId: null, + runId: null, + agentApiKeyId: null, + action: "issue.queued_comment_discarded", + entityId: "issue-1", + details: { commentId: "comment-1", queueId: "wake-1", revision: "rev-1", cancelledRunId: "run-1" }, + }); + expect(result.activityPublication).toEqual(activityPublicationFixture()); + }); + + it("logs no activity row when the caller does not request one, matching the comment-delete route's cancellation call site", async () => { + const locked = lockedState({ state: "queued", queueRun: runRow({ id: "run-1" }) }); + const transaction = createFakeTransaction(); + const discardQueuedComment = createDiscardQueuedComment({ issueLock: createFakeIssueLock(locked, transaction) }); + + const result = await discardQueuedComment({ + issue: ISSUE, + actor: USER_ACTOR, + commentId: "comment-1", + queueId: "wake-1", + revision: "rev-1", + now: new Date(), + }); + + expect(transaction.logActivity).not.toHaveBeenCalled(); + expect(result.activityPublication).toBeNull(); }); it("rewrites the remaining ids when other queued comments are left", async () => { diff --git a/server/src/modules/wake-queue/application/queued-comment-use-cases.ts b/server/src/modules/wake-queue/application/queued-comment-use-cases.ts index 9d9b6a3f94..8fc1624559 100644 --- a/server/src/modules/wake-queue/application/queued-comment-use-cases.ts +++ b/server/src/modules/wake-queue/application/queued-comment-use-cases.ts @@ -4,6 +4,7 @@ import { decideQueuedCommentReorder, } from "../domain/policy.js"; import type { + QueuedCommentActivityPublication, QueuedCommentActor, QueuedCommentIssueContext, QueuedCommentIssueLockWriter, @@ -74,8 +75,13 @@ export type EditQueuedCommentInput = { now: Date; }; +export type EditQueuedCommentResult = { + queue: QueuedCommentQueueSnapshot; + activityPublication: QueuedCommentActivityPublication; +}; + export function createEditQueuedComment(deps: { issueLock: QueuedCommentIssueLockWriter }) { - return async function editQueuedComment(input: EditQueuedCommentInput): Promise { + return async function editQueuedComment(input: EditQueuedCommentInput): Promise { return deps.issueLock.withLockedQueue( { issue: input.issue, actor: input.actor, queueId: input.queueId }, async (locked, tx) => { @@ -108,7 +114,7 @@ export function createEditQueuedComment(deps: { issueLock: QueuedCommentIssueLoc updatedAt: input.now, }); - return tx.buildQueueSnapshot({ + const queue = await tx.buildQueueSnapshot({ issue: input.issue, actor: input.actor, wake: locked.wake, @@ -116,6 +122,23 @@ export function createEditQueuedComment(deps: { issueLock: QueuedCommentIssueLoc queueRun: updatedQueueRun ?? locked.queueRun, activeRun: locked.activeRun, }); + + const activityPublication = await tx.logActivity({ + actorType: input.actor.actorType, + actorId: input.actor.actorId, + agentId: input.actor.agentId, + runId: input.actor.runId, + agentApiKeyId: input.actor.agentApiKeyId, + action: "issue.queued_comment_edited", + entityId: input.issue.id, + details: { + commentId: input.commentId, + queueId: input.queueId, + revision: queue.revision, + }, + }); + + return { queue, activityPublication }; }, ); }; @@ -130,8 +153,13 @@ export type ReorderQueuedCommentsInput = { now: Date; }; +export type ReorderQueuedCommentsResult = { + queue: QueuedCommentQueueSnapshot; + activityPublication: QueuedCommentActivityPublication; +}; + export function createReorderQueuedComments(deps: { issueLock: QueuedCommentIssueLockWriter }) { - return async function reorderQueuedComments(input: ReorderQueuedCommentsInput): Promise { + return async function reorderQueuedComments(input: ReorderQueuedCommentsInput): Promise { return deps.issueLock.withLockedQueue( { issue: input.issue, actor: input.actor, queueId: input.queueId }, async (locked, tx) => { @@ -158,7 +186,7 @@ export function createReorderQueuedComments(deps: { issueLock: QueuedCommentIssu updatedAt: input.now, }); - return tx.buildQueueSnapshot({ + const queue = await tx.buildQueueSnapshot({ issue: input.issue, actor: input.actor, wake: updatedWake, @@ -166,6 +194,23 @@ export function createReorderQueuedComments(deps: { issueLock: QueuedCommentIssu queueRun: updatedQueueRun ?? locked.queueRun, activeRun: locked.activeRun, }); + + const activityPublication = await tx.logActivity({ + actorType: input.actor.actorType, + actorId: input.actor.actorId, + agentId: input.actor.agentId, + runId: input.actor.runId, + agentApiKeyId: input.actor.agentApiKeyId, + action: "issue.queued_comments_reordered", + entityId: input.issue.id, + details: { + queueId: input.queueId, + revision: queue.revision, + orderedCommentIds: input.orderedCommentIds, + }, + }); + + return { queue, activityPublication }; }, ); }; @@ -179,6 +224,13 @@ export type DiscardQueuedCommentInput = { /** Skipped entirely when omitted, matching the comment-delete route's cancellation call site, which does not carry a revision. */ revision?: string; now: Date; + /** + * Set only by the queue-discard route. The comment-delete route's + * cancellation call site omits this: it already logs its own + * `issue.comment_cancelled` row outside this use case, and this flag + * would otherwise double-log that same discard. + */ + logActivity?: boolean; }; export type DiscardQueuedCommentResult = { @@ -187,6 +239,8 @@ export type DiscardQueuedCommentResult = { queue: QueuedCommentQueueSnapshot; /** Set only when the discard emptied the queue and cancelled a queued run; the caller emits telemetry for it after the transaction commits. */ cancelledRun: { id: string } | null; + /** Set only when `input.logActivity` was true; the caller publishes it once the transaction commits. */ + activityPublication: QueuedCommentActivityPublication | null; }; export function createDiscardQueuedComment(deps: { issueLock: QueuedCommentIssueLockWriter }) { @@ -278,7 +332,25 @@ export function createDiscardQueuedComment(deps: { issueLock: QueuedCommentIssue activeRun: locked.activeRun, }); - return { deleted, queue, cancelledRun }; + const activityPublication = input.logActivity + ? await tx.logActivity({ + actorType: input.actor.actorType, + actorId: input.actor.actorId, + agentId: input.actor.agentId, + runId: input.actor.runId, + agentApiKeyId: input.actor.agentApiKeyId, + action: "issue.queued_comment_discarded", + entityId: input.issue.id, + details: { + commentId: input.commentId, + queueId: input.queueId, + revision: queue.revision, + cancelledRunId: cancelledRun?.id ?? null, + }, + }) + : null; + + return { deleted, queue, cancelledRun, activityPublication }; }, ); }; diff --git a/server/src/modules/wake-queue/index.ts b/server/src/modules/wake-queue/index.ts index 7f6e086679..b725f6a805 100644 --- a/server/src/modules/wake-queue/index.ts +++ b/server/src/modules/wake-queue/index.ts @@ -44,10 +44,13 @@ export type { DiscardQueuedCommentInput, DiscardQueuedCommentResult, EditQueuedCommentInput, + EditQueuedCommentResult, QueuedCommentMutationErrorCode, ReorderQueuedCommentsInput, + ReorderQueuedCommentsResult, } from "./application/queued-comment-use-cases.js"; export type { + QueuedCommentActivityPublication, QueuedCommentActor, QueuedCommentIssueContext, QueuedCommentQueueSnapshot, diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 31cabea88e..0cc963a4e0 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -15141,7 +15141,7 @@ export function issueRoutes( ); if (!issue) return; const actor = getActorInfo(req); - const queue = await runQueuedCommentMutation(() => + const { queue, activityPublication } = await runQueuedCommentMutation(() => queuedCommentQueue.editQueuedComment({ issue: buildQueuedCommentIssueContext(issue), actor, @@ -15152,6 +15152,7 @@ export function issueRoutes( now: new Date(), }), ); + publishActivity(activityPublication as ActivityPublication); res.json(await runRedactions.redactForIssue(issue.companyId, issue.id, queue)); }, ); @@ -15171,7 +15172,7 @@ export function issueRoutes( ); if (!issue) return; const actor = getActorInfo(req); - const queue = await runQueuedCommentMutation(() => + const { queue, activityPublication } = await runQueuedCommentMutation(() => queuedCommentQueue.reorderQueuedComments({ issue: buildQueuedCommentIssueContext(issue), actor, @@ -15181,6 +15182,7 @@ export function issueRoutes( now: new Date(), }), ); + publishActivity(activityPublication as ActivityPublication); res.json(await runRedactions.redactForIssue(issue.companyId, issue.id, queue)); }, ); @@ -15467,8 +15469,10 @@ export function issueRoutes( queueId: req.body.queueId, revision: req.body.revision, now: new Date(), + logActivity: true, }), ); + publishActivity(result.activityPublication as ActivityPublication); // Telemetry is best-effort background work; it must not delay the // response with a slow lookup, so fire it and do not await it. if (result.cancelledRun) { diff --git a/ui/src/lib/activity-format.test.ts b/ui/src/lib/activity-format.test.ts index e314e9e116..de96201047 100644 --- a/ui/src/lib/activity-format.test.ts +++ b/ui/src/lib/activity-format.test.ts @@ -66,6 +66,15 @@ describe("activity formatting", () => { expect(formatIssueActivityAction("issue.monitor_recovery_issue_created")).toBe("created a monitor recovery issue"); }); + it("labels each queued-comment queue mutation", () => { + expect(formatActivityVerb("issue.queued_comment_edited")).toBe("edited a queued comment on"); + expect(formatActivityVerb("issue.queued_comments_reordered")).toBe("reordered queued comments on"); + expect(formatActivityVerb("issue.queued_comment_discarded")).toBe("discarded a queued comment on"); + expect(formatIssueActivityAction("issue.queued_comment_edited")).toBe("edited a queued comment"); + expect(formatIssueActivityAction("issue.queued_comments_reordered")).toBe("reordered queued comments"); + expect(formatIssueActivityAction("issue.queued_comment_discarded")).toBe("discarded a queued comment"); + }); + // PAP-16506 P4: agents can now resolve an interaction, including a review of // their own work, so an outcome has to read as an outcome in the timeline // instead of leaking the raw action id. diff --git a/ui/src/lib/activity-format.ts b/ui/src/lib/activity-format.ts index 387ab35def..1e98eca665 100644 --- a/ui/src/lib/activity-format.ts +++ b/ui/src/lib/activity-format.ts @@ -29,6 +29,9 @@ const ACTIVITY_ROW_VERBS: Record = { "issue.released": "released", "issue.comment_added": "commented on", "issue.comment_cancelled": "cancelled a queued comment on", + "issue.queued_comment_edited": "edited a queued comment on", + "issue.queued_comments_reordered": "reordered queued comments on", + "issue.queued_comment_discarded": "discarded a queued comment on", "issue.comment_deleted": "deleted a comment on", "issue.attachment_added": "attached file to", "issue.attachment_removed": "removed attachment from", @@ -124,6 +127,9 @@ const ISSUE_ACTIVITY_LABELS: Record = { "issue.released": "released the issue", "issue.comment_added": "added a comment", "issue.comment_cancelled": "cancelled a queued comment", + "issue.queued_comment_edited": "edited a queued comment", + "issue.queued_comments_reordered": "reordered queued comments", + "issue.queued_comment_discarded": "discarded a queued comment", "issue.comment_deleted": "deleted a comment", "issue.feedback_vote_saved": "saved feedback on an AI output", "issue.attachment_added": "added an attachment",