refactor(server): move the queued-comment queue mutations into the wake-queue module (#13145)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The server coordinates issue execution and agent wake events
> - Queued comment mutations belong to the wake queue that owns their
state
> - Route-local database writes split queue rules across two layers
> - This pull request moves those mutations into the wake-queue module
and keeps route authorization and response mapping
> - The benefit is one transaction boundary with company-scoped writes
and a shared checked response contract

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The queued-comment edit, reorder, and discard endpoints write queue
state directly from the route layer.

**Subsystem affected**

server/ — REST API and orchestration services.

**Current behavior**

The route layer owns database transactions, locks, queue writes, and
wake-row writes for queued comments.

**Proposed behavior**

The wake-queue module owns these operations. The routes keep
authorization, input checks, error mapping, and response mapping.

**Reason and benefit**

The module gives all queued-comment callers one transaction boundary and
applies company predicates to every adapter read and write.

**Breaking changes**

None. The endpoints keep their existing paths and response behavior.

## What Changed

- Move queued-comment edit, reorder, and discard operations into the
wake-queue module.
- Add company predicates to seven queue writes.
- Use the shared queue contract type for mutation responses.
- Add module tests and route tests for the moved operations.

## Verification

- `server/src/modules/wake-queue`: 128 tests pass across 6 files.
- `server/src/__tests__/issue-queued-comments-routes.test.ts`: 19 tests
pass.
- The server TypeScript check reports the same 141 pre-existing errors
before and after this change.
- GitHub Actions must pass the required pull-request checks.

## Risks

The change moves transaction and lock ownership across module
boundaries. The new adapter, use-case, and route tests cover the moved
behavior. No database schema changes occur.

## Model Used

OpenAI Codex, GPT-5, current agent runtime, tool use and code review
support.

## 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>
This commit is contained in:
Nicky LeachandPaperclip authored and GitHub committed 2026-09-10 08:41:59 -07:00
1 parent 889947c238
commit 0d8bbf7cf4
12 files changed
+2077 -407

No files matched your search

@@ -0,0 +1,145 @@
// Ports for the three queued-comment queue mutations (edit, reorder,
// discard). Mirrors the release half's `IssueLockWriter` shape: the adapter
// owns the one transaction each mutation runs in, locks the issue and the
// wake row the caller named, classifies the locked state, and hands the
// caller a `LockedQueuedCommentState` plus a `QueuedCommentQueueTransaction`
// bound to that same transaction for every further read and write.
import type { IssueComment, IssueQueuedCommentEntry, IssueQueuedCommentQueue } from "@paperclipai/shared";
export type QueuedCommentActor = {
actorType: "agent" | "user";
/** The user id for a user actor, the agent id for an agent actor -- the same value `getActorInfo` names `actorId`. */
actorId: string;
/** Null for a user actor. */
agentId: string | null;
};
export type QueuedCommentIssueContext = {
id: string;
companyId: string;
assigneeAgentId: string | null;
executionRunId: string | null;
};
export type QueuedCommentWakeRow = {
id: string;
agentId: string;
status: string;
runId: string | null;
payload: Record<string, unknown>;
};
export type QueuedCommentRunRow = {
id: string;
status: string;
runtimeMode: string | null;
contextSnapshot: Record<string, unknown>;
};
/** The module's entry shape is the shared contract, so the compiler checks it directly; the route needs no cast. */
export type QueuedCommentEntrySnapshot = IssueQueuedCommentEntry;
/** The module's queue-snapshot shape is the shared contract, so the compiler checks it directly; the route needs no cast. */
export type QueuedCommentQueueSnapshot = IssueQueuedCommentQueue;
/** The locked, transaction-scoped state a mutation reads before it decides what to write. */
export type LockedQueuedCommentState = {
wake: QueuedCommentWakeRow;
state: "deferred" | "queued";
queueRun: QueuedCommentRunRow | null;
activeRun: QueuedCommentRunRow | null;
queue: QueuedCommentQueueSnapshot;
};
/**
* Every member is bound to the one transaction `withLockedQueue` owns, and
* to the one company that transaction is open for. Every read and every
* write names that bound `companyId` in its own predicate; a
* caller-supplied `issue`/`wake`/`queueRun` value is never trusted as an
* authorization boundary by itself.
*/
export interface QueuedCommentQueueTransaction {
updateCommentBody(input: {
issueId: string;
commentId: string;
body: string;
updatedAt: Date;
}): Promise<boolean>;
touchIssueUpdatedAt(input: { issueId: string; updatedAt: Date }): Promise<void>;
/** Compare-and-set on `id`; the wake's current status is not re-checked here because the row is already locked for the duration of this transaction. */
updateWakeQueuedCommentIds(input: {
wakeId: string;
payload: Record<string, unknown>;
ids: string[];
updatedAt: Date;
}): Promise<QueuedCommentWakeRow>;
/** Guarded on the run's current `queued` status. Returns `null` when a concurrent writer already moved the run off `queued`. */
updateQueueRunCommentIds(input: {
queueRunId: string;
/** The run's own context snapshot, as already read under lock; the rewrite is derived from this base. */
contextSnapshot: Record<string, unknown>;
ids: string[];
updatedAt: Date;
}): Promise<QueuedCommentRunRow | null>;
/** Returns the full deleted comment row so the caller can echo it back as the delete route's response body. */
deleteComment(input: {
issueId: string;
commentId: string;
}): Promise<IssueComment | null>;
cancelWake(input: { wakeId: string; reason: string; now: Date }): Promise<void>;
/**
* Guarded on the run's current `queued` status. Returns `null` when a
* concurrent writer already moved the run off `queued`; returns just the
* cancelled run's id, which is all a post-commit telemetry emission needs.
*/
cancelQueueRun(input: {
queueRunId: string;
reason: string;
now: Date;
}): Promise<{ id: string } | null>;
/**
* Clears the issue's execution-lock columns and performs the `updatedAt`
* touch, in the one update the original route issued for a discard that
* empties the queue with a live queue run. The write is guarded on the
* issue's current `executionRunId`; a lost guard silently skips the whole
* update, matching the pre-existing behavior of this best-effort touch.
*/
clearExecutionLockAndTouchIssue(input: {
issueId: string;
executionRunId: string;
updatedAt: Date;
}): Promise<void>;
buildQueueSnapshot(input: {
issue: QueuedCommentIssueContext;
actor: QueuedCommentActor;
wake: QueuedCommentWakeRow | null;
state: "deferred" | "queued" | null;
queueRun: QueuedCommentRunRow | null;
activeRun: QueuedCommentRunRow | null;
}): Promise<QueuedCommentQueueSnapshot>;
syncCommentReferences(commentId: string): Promise<void>;
deleteCommentReferenceSource(commentId: string): Promise<void>;
syncCommentExternalObjectsSafely(commentId: string): Promise<void>;
}
export interface QueuedCommentIssueLockWriter {
/**
* Opens the one transaction a mutation runs in: locks the issue row, locks
* the wake row named by `queueId`, classifies it (reading and locking the
* linked heartbeat run when the classification needs it), and builds the
* queue snapshot the caller's mutation target check compares against.
* Throws `QueuedCommentMutationError` with code `queued_comment_not_pending`
* or `queued_comment_already_dispatching` when the lock step itself cannot
* resolve a live queue; `fn` never runs in that case.
*/
withLockedQueue<T>(
input: {
/** Also carries the company id; every locked read and write binds its `companyId` predicate to `issue.companyId`. */
issue: QueuedCommentIssueContext;
actor: QueuedCommentActor;
queueId: string;
},
fn: (locked: LockedQueuedCommentState, transaction: QueuedCommentQueueTransaction) => Promise<T>,
): Promise<T>;
}
@@ -0,0 +1,374 @@
import { describe, expect, it, vi } from "vitest";
import type { IssueComment } from "@paperclipai/shared";
import {
createDiscardQueuedComment,
createEditQueuedComment,
createReorderQueuedComments,
QueuedCommentMutationError,
QueuedCommentMutationForbiddenError,
} from "./queued-comment-use-cases.js";
import type {
LockedQueuedCommentState,
QueuedCommentActor,
QueuedCommentEntrySnapshot,
QueuedCommentIssueContext,
QueuedCommentIssueLockWriter,
QueuedCommentQueueSnapshot,
QueuedCommentQueueTransaction,
QueuedCommentRunRow,
QueuedCommentWakeRow,
} from "./queued-comment-ports.js";
const ISSUE: QueuedCommentIssueContext = {
id: "issue-1",
companyId: "company-1",
assigneeAgentId: "agent-1",
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" };
function wakeRow(overrides: Partial<QueuedCommentWakeRow> = {}): QueuedCommentWakeRow {
return { id: "wake-1", agentId: "agent-1", status: "deferred_issue_execution", runId: null, payload: {}, ...overrides };
}
function runRow(overrides: Partial<QueuedCommentRunRow> = {}): QueuedCommentRunRow {
return { id: "run-1", status: "queued", runtimeMode: null, contextSnapshot: {}, ...overrides };
}
function commentFixture(overrides: Partial<IssueComment> = {}): IssueComment {
return {
id: "comment-1",
companyId: "company-1",
issueId: "issue-1",
authorType: "user",
authorAgentId: null,
authorUserId: "user-1",
body: "queued comment",
presentation: null,
metadata: null,
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
...overrides,
};
}
function entry(overrides: Partial<QueuedCommentEntrySnapshot> = {}): QueuedCommentEntrySnapshot {
return {
comment: commentFixture(),
position: 0,
canEdit: true,
canDiscard: true,
...overrides,
};
}
function queueSnapshot(overrides: Partial<QueuedCommentQueueSnapshot> = {}): QueuedCommentQueueSnapshot {
return {
issueId: ISSUE.id,
queueId: "wake-1",
state: "deferred",
targetRunId: null,
revision: "rev-1",
protocol: "legacy",
steeringDisposition: "unsupported",
entries: [entry()],
...overrides,
};
}
function lockedState(overrides: Partial<LockedQueuedCommentState> = {}): LockedQueuedCommentState {
return {
wake: wakeRow(),
state: "deferred",
queueRun: null,
activeRun: null,
queue: queueSnapshot(),
...overrides,
};
}
function createFakeTransaction(overrides: Partial<QueuedCommentQueueTransaction> = {}): QueuedCommentQueueTransaction {
return {
updateCommentBody: vi.fn(async () => true),
touchIssueUpdatedAt: vi.fn(async () => {}),
updateWakeQueuedCommentIds: vi.fn(async (input) => wakeRow({ id: input.wakeId })),
updateQueueRunCommentIds: vi.fn(async (input) => runRow({ id: input.queueRunId })),
deleteComment: vi.fn(async () => commentFixture()),
cancelWake: vi.fn(async () => {}),
cancelQueueRun: vi.fn(async () => ({ id: "run-1" })),
clearExecutionLockAndTouchIssue: vi.fn(async () => {}),
buildQueueSnapshot: vi.fn(async () => queueSnapshot()),
syncCommentReferences: vi.fn(async () => {}),
deleteCommentReferenceSource: vi.fn(async () => {}),
syncCommentExternalObjectsSafely: vi.fn(async () => {}),
...overrides,
};
}
function createFakeIssueLock(locked: LockedQueuedCommentState, transaction: QueuedCommentQueueTransaction): QueuedCommentIssueLockWriter {
return {
withLockedQueue: vi.fn(async (_input, fn) => fn(locked, transaction)),
};
}
describe("editQueuedComment", () => {
it("updates the comment body and rebuilds the queue snapshot", async () => {
const locked = lockedState();
const transaction = createFakeTransaction();
const issueLock = createFakeIssueLock(locked, transaction);
const editQueuedComment = createEditQueuedComment({ issueLock });
const result = await editQueuedComment({
issue: ISSUE,
actor: USER_ACTOR,
commentId: "comment-1",
queueId: "wake-1",
revision: "rev-1",
body: "updated body",
now: new Date("2026-01-01T00:00:00Z"),
});
expect(transaction.updateCommentBody).toHaveBeenCalledWith(
expect.objectContaining({ issueId: "issue-1", commentId: "comment-1", body: "updated body" }),
);
expect(transaction.syncCommentReferences).toHaveBeenCalledWith("comment-1");
expect(transaction.syncCommentExternalObjectsSafely).toHaveBeenCalledWith("comment-1");
expect(result).toEqual(queueSnapshot());
});
it("rejects a stale queue id with queued_comment_stale_queue", async () => {
const locked = lockedState({ queue: queueSnapshot({ queueId: "wake-1" }) });
const editQueuedComment = createEditQueuedComment({ issueLock: createFakeIssueLock(locked, createFakeTransaction()) });
await expect(
editQueuedComment({
issue: ISSUE,
actor: USER_ACTOR,
commentId: "comment-1",
queueId: "wake-2",
revision: "rev-1",
body: "x",
now: new Date(),
}),
).rejects.toMatchObject({ code: "queued_comment_stale_queue" });
});
it("rejects a stale revision with queued_comment_revision_conflict", async () => {
const locked = lockedState();
const editQueuedComment = createEditQueuedComment({ issueLock: createFakeIssueLock(locked, createFakeTransaction()) });
await expect(
editQueuedComment({
issue: ISSUE,
actor: USER_ACTOR,
commentId: "comment-1",
queueId: "wake-1",
revision: "stale-rev",
body: "x",
now: new Date(),
}),
).rejects.toBeInstanceOf(QueuedCommentMutationError);
});
it("rejects a comment the actor cannot edit", async () => {
const locked = lockedState({ queue: queueSnapshot({ entries: [entry({ canEdit: false })] }) });
const editQueuedComment = createEditQueuedComment({ issueLock: createFakeIssueLock(locked, createFakeTransaction()) });
await expect(
editQueuedComment({
issue: ISSUE,
actor: USER_ACTOR,
commentId: "comment-1",
queueId: "wake-1",
revision: "rev-1",
body: "x",
now: new Date(),
}),
).rejects.toBeInstanceOf(QueuedCommentMutationForbiddenError);
});
it("surfaces queued_comment_already_dispatching when the linked run left the queued status mid-mutation", async () => {
const locked = lockedState({ queueRun: runRow({ id: "run-1" }) });
const transaction = createFakeTransaction({ updateQueueRunCommentIds: vi.fn(async () => null) });
const editQueuedComment = createEditQueuedComment({ issueLock: createFakeIssueLock(locked, transaction) });
await expect(
editQueuedComment({
issue: ISSUE,
actor: USER_ACTOR,
commentId: "comment-1",
queueId: "wake-1",
revision: "rev-1",
body: "x",
now: new Date(),
}),
).rejects.toMatchObject({ code: "queued_comment_already_dispatching" });
});
});
describe("reorderQueuedComments", () => {
it("rewrites the wake payload with the submitted order", async () => {
const locked = lockedState({
queue: queueSnapshot({ entries: [
entry({ comment: commentFixture({ id: "a" }), position: 0 }),
entry({ comment: commentFixture({ id: "b" }), position: 1 }),
] }),
});
const transaction = createFakeTransaction();
const reorderQueuedComments = createReorderQueuedComments({ issueLock: createFakeIssueLock(locked, transaction) });
await reorderQueuedComments({
issue: ISSUE,
actor: USER_ACTOR,
queueId: "wake-1",
revision: "rev-1",
orderedCommentIds: ["b", "a"],
now: new Date(),
});
expect(transaction.updateWakeQueuedCommentIds).toHaveBeenCalledWith(
expect.objectContaining({ wakeId: "wake-1", ids: ["b", "a"] }),
);
});
it("rejects an order that is not a permutation of the current queue", async () => {
const locked = lockedState({ queue: queueSnapshot({ entries: [
entry({ comment: commentFixture({ id: "a" }) }),
entry({ comment: commentFixture({ id: "b" }) }),
] }) });
const reorderQueuedComments = createReorderQueuedComments({ issueLock: createFakeIssueLock(locked, createFakeTransaction()) });
await expect(
reorderQueuedComments({
issue: ISSUE,
actor: USER_ACTOR,
queueId: "wake-1",
revision: "rev-1",
orderedCommentIds: ["a"],
now: new Date(),
}),
).rejects.toMatchObject({ code: "queued_comment_order_mismatch" });
});
});
describe("discardQueuedComment", () => {
it("cancels the wake and the queued run when the discard empties the queue", 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.cancelWake).toHaveBeenCalledWith(expect.objectContaining({ wakeId: "wake-1" }));
expect(transaction.cancelQueueRun).toHaveBeenCalledWith(expect.objectContaining({ queueRunId: "run-1" }));
expect(transaction.clearExecutionLockAndTouchIssue).toHaveBeenCalledWith(
expect.objectContaining({ executionRunId: "run-1" }),
);
expect(result.cancelledRun).toEqual({ id: "run-1" });
});
it("rewrites the remaining ids when other queued comments are left", async () => {
const locked = lockedState({
queue: queueSnapshot({ entries: [
entry({ comment: commentFixture({ id: "comment-1" }) }),
entry({ comment: commentFixture({ id: "comment-2" }) }),
] }),
});
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.updateWakeQueuedCommentIds).toHaveBeenCalledWith(expect.objectContaining({ ids: ["comment-2"] }));
expect(transaction.cancelWake).not.toHaveBeenCalled();
expect(transaction.touchIssueUpdatedAt).toHaveBeenCalledWith(expect.objectContaining({ issueId: ISSUE.id }));
expect(result.cancelledRun).toBeNull();
});
it("skips the mutation-target check when no revision is submitted, matching the comment-delete route's cancellation call site", async () => {
const locked = lockedState({ queue: queueSnapshot({ revision: "some-other-revision" }) });
const transaction = createFakeTransaction();
const discardQueuedComment = createDiscardQueuedComment({ issueLock: createFakeIssueLock(locked, transaction) });
await expect(
discardQueuedComment({
issue: ISSUE,
actor: USER_ACTOR,
commentId: "comment-1",
queueId: "wake-1",
now: new Date(),
}),
).resolves.toBeDefined();
});
it("rejects a discard from an actor who did not author the comment", async () => {
const locked = lockedState({ queue: queueSnapshot({ entries: [
entry({ comment: commentFixture({ authorUserId: "user-2", authorAgentId: null }) }),
] }) });
const discardQueuedComment = createDiscardQueuedComment({ issueLock: createFakeIssueLock(locked, createFakeTransaction()) });
await expect(
discardQueuedComment({
issue: ISSUE,
actor: USER_ACTOR,
commentId: "comment-1",
queueId: "wake-1",
revision: "rev-1",
now: new Date(),
}),
).rejects.toBeInstanceOf(QueuedCommentMutationForbiddenError);
});
it("authorizes an agent actor discarding its own queued message", async () => {
const locked = lockedState({
queue: queueSnapshot({ entries: [
entry({ comment: commentFixture({ authorUserId: null, authorAgentId: "agent-1" }) }),
] }),
});
const transaction = createFakeTransaction();
const discardQueuedComment = createDiscardQueuedComment({ issueLock: createFakeIssueLock(locked, transaction) });
await expect(
discardQueuedComment({
issue: ISSUE,
actor: AGENT_ACTOR,
commentId: "comment-1",
queueId: "wake-1",
now: new Date(),
}),
).resolves.toBeDefined();
});
it("rolls back when the queued run to cancel already left the queued status", async () => {
const locked = lockedState({ queueRun: runRow({ id: "run-1" }) });
const transaction = createFakeTransaction({ cancelQueueRun: vi.fn(async () => null) });
const discardQueuedComment = createDiscardQueuedComment({ issueLock: createFakeIssueLock(locked, transaction) });
await expect(
discardQueuedComment({
issue: ISSUE,
actor: USER_ACTOR,
commentId: "comment-1",
queueId: "wake-1",
revision: "rev-1",
now: new Date(),
}),
).rejects.toMatchObject({ code: "queued_comment_already_dispatching" });
});
});
@@ -0,0 +1,285 @@
import type { IssueComment } from "@paperclipai/shared";
import {
decideQueuedCommentActorOwnsEntry,
decideQueuedCommentReorder,
} from "../domain/policy.js";
import type {
QueuedCommentActor,
QueuedCommentIssueContext,
QueuedCommentIssueLockWriter,
QueuedCommentQueueSnapshot,
QueuedCommentQueueTransaction,
QueuedCommentRunRow,
} from "./queued-comment-ports.js";
export type QueuedCommentMutationErrorCode =
| "queued_comment_not_pending"
| "queued_comment_already_dispatching"
| "queued_comment_stale_queue"
| "queued_comment_revision_conflict"
| "queued_comment_order_mismatch";
/** The route maps this 1:1 onto the `conflict(...)` HTTP error it threw before this move, using `code` and `message` unchanged. */
export class QueuedCommentMutationError extends Error {
constructor(
readonly code: QueuedCommentMutationErrorCode,
message: string,
) {
super(message);
this.name = "QueuedCommentMutationError";
}
}
/** The route maps this onto the `forbidden(...)` HTTP error it threw before this move, using `message` unchanged. */
export class QueuedCommentMutationForbiddenError extends Error {
constructor(message: string) {
super(message);
this.name = "QueuedCommentMutationForbiddenError";
}
}
function requireMutationTarget(queue: QueuedCommentQueueSnapshot, queueId: string, revision: string): void {
if (queue.queueId !== queueId) {
throw new QueuedCommentMutationError("queued_comment_stale_queue", "The queued message targets a stale queue");
}
if (queue.revision !== revision) {
throw new QueuedCommentMutationError("queued_comment_revision_conflict", "The queued messages changed in another session");
}
}
async function updateQueueRunCommentIdsGuarded(
tx: QueuedCommentQueueTransaction,
input: { queueRun: QueuedCommentRunRow | null; ids: string[]; updatedAt: Date },
): Promise<QueuedCommentRunRow | null> {
if (!input.queueRun) return null;
const updated = await tx.updateQueueRunCommentIds({
queueRunId: input.queueRun.id,
contextSnapshot: input.queueRun.contextSnapshot,
ids: input.ids,
updatedAt: input.updatedAt,
});
if (!updated) {
throw new QueuedCommentMutationError("queued_comment_already_dispatching", "The queued message is already being dispatched");
}
return updated;
}
export type EditQueuedCommentInput = {
issue: QueuedCommentIssueContext;
actor: QueuedCommentActor;
commentId: string;
queueId: string;
revision: string;
body: string;
now: Date;
};
export function createEditQueuedComment(deps: { issueLock: QueuedCommentIssueLockWriter }) {
return async function editQueuedComment(input: EditQueuedCommentInput): Promise<QueuedCommentQueueSnapshot> {
return deps.issueLock.withLockedQueue(
{ issue: input.issue, actor: input.actor, queueId: input.queueId },
async (locked, tx) => {
requireMutationTarget(locked.queue, input.queueId, input.revision);
const entry = locked.queue.entries.find((candidate) => candidate.comment.id === input.commentId);
if (!entry) {
throw new QueuedCommentMutationError("queued_comment_not_pending", "The queued message is no longer pending");
}
if (!entry.canEdit) {
throw new QueuedCommentMutationForbiddenError("Only the queued message author can edit it");
}
const updated = await tx.updateCommentBody({
issueId: input.issue.id,
commentId: input.commentId,
body: input.body,
updatedAt: input.now,
});
if (!updated) {
throw new QueuedCommentMutationError("queued_comment_not_pending", "The queued message is no longer pending");
}
await tx.touchIssueUpdatedAt({ issueId: input.issue.id, updatedAt: input.now });
await tx.syncCommentReferences(input.commentId);
await tx.syncCommentExternalObjectsSafely(input.commentId);
const ids = locked.queue.entries.map((candidate) => candidate.comment.id);
const updatedQueueRun = await updateQueueRunCommentIdsGuarded(tx, {
queueRun: locked.queueRun,
ids,
updatedAt: input.now,
});
return tx.buildQueueSnapshot({
issue: input.issue,
actor: input.actor,
wake: locked.wake,
state: locked.state,
queueRun: updatedQueueRun ?? locked.queueRun,
activeRun: locked.activeRun,
});
},
);
};
}
export type ReorderQueuedCommentsInput = {
issue: QueuedCommentIssueContext;
actor: QueuedCommentActor;
queueId: string;
revision: string;
orderedCommentIds: string[];
now: Date;
};
export function createReorderQueuedComments(deps: { issueLock: QueuedCommentIssueLockWriter }) {
return async function reorderQueuedComments(input: ReorderQueuedCommentsInput): Promise<QueuedCommentQueueSnapshot> {
return deps.issueLock.withLockedQueue(
{ issue: input.issue, actor: input.actor, queueId: input.queueId },
async (locked, tx) => {
requireMutationTarget(locked.queue, input.queueId, input.revision);
const currentIds = locked.queue.entries.map((entry) => entry.comment.id);
const reorderDecision = decideQueuedCommentReorder({ currentIds, orderedIds: input.orderedCommentIds });
if (reorderDecision.kind === "mismatch") {
throw new QueuedCommentMutationError(
"queued_comment_order_mismatch",
"The queued message order does not match the current queue",
);
}
const updatedWake = await tx.updateWakeQueuedCommentIds({
wakeId: locked.wake.id,
payload: locked.wake.payload,
ids: input.orderedCommentIds,
updatedAt: input.now,
});
const updatedQueueRun = await updateQueueRunCommentIdsGuarded(tx, {
queueRun: locked.queueRun,
ids: input.orderedCommentIds,
updatedAt: input.now,
});
return tx.buildQueueSnapshot({
issue: input.issue,
actor: input.actor,
wake: updatedWake,
state: locked.state,
queueRun: updatedQueueRun ?? locked.queueRun,
activeRun: locked.activeRun,
});
},
);
};
}
export type DiscardQueuedCommentInput = {
issue: QueuedCommentIssueContext;
actor: QueuedCommentActor;
commentId: string;
queueId: string;
/** Skipped entirely when omitted, matching the comment-delete route's cancellation call site, which does not carry a revision. */
revision?: string;
now: Date;
};
export type DiscardQueuedCommentResult = {
/** The full deleted comment row; the comment-delete route echoes it back as its own response body. */
deleted: IssueComment;
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;
};
export function createDiscardQueuedComment(deps: { issueLock: QueuedCommentIssueLockWriter }) {
return async function discardQueuedComment(input: DiscardQueuedCommentInput): Promise<DiscardQueuedCommentResult> {
return deps.issueLock.withLockedQueue(
{ issue: input.issue, actor: input.actor, queueId: input.queueId },
async (locked, tx) => {
if (input.revision !== undefined) {
requireMutationTarget(locked.queue, input.queueId, input.revision);
}
const entry = locked.queue.entries.find((candidate) => candidate.comment.id === input.commentId);
if (!entry) {
throw new QueuedCommentMutationError("queued_comment_not_pending", "The queued message is no longer pending");
}
const owns = decideQueuedCommentActorOwnsEntry({
actorType: input.actor.actorType,
actorId: input.actor.actorId,
actorAgentId: input.actor.agentId,
authorAgentId: entry.comment.authorAgentId,
authorUserId: entry.comment.authorUserId,
});
if (!owns) {
throw new QueuedCommentMutationForbiddenError("Only the queued message author can discard it");
}
const deleted = await tx.deleteComment({ issueId: input.issue.id, commentId: input.commentId });
if (!deleted) {
throw new QueuedCommentMutationError("queued_comment_not_pending", "The queued message is no longer pending");
}
await tx.deleteCommentReferenceSource(input.commentId);
await tx.syncCommentExternalObjectsSafely(input.commentId);
const remainingIds = locked.queue.entries.map((candidate) => candidate.comment.id).filter((id) => id !== input.commentId);
const queueBecomesEmpty = remainingIds.length === 0;
let cancelledRun: { id: string } | null = null;
let nextWake = locked.wake;
let nextQueueRun = locked.queueRun;
if (queueBecomesEmpty) {
await tx.cancelWake({
wakeId: locked.wake.id,
reason: "Queued message discarded before dispatch",
now: input.now,
});
if (locked.queueRun) {
const cancelled = await tx.cancelQueueRun({
queueRunId: locked.queueRun.id,
reason: "Queued message discarded before dispatch",
now: input.now,
});
if (!cancelled) {
throw new QueuedCommentMutationError(
"queued_comment_already_dispatching",
"The queued message is already being dispatched",
);
}
cancelledRun = cancelled;
await tx.clearExecutionLockAndTouchIssue({
issueId: input.issue.id,
executionRunId: locked.queueRun.id,
updatedAt: input.now,
});
} else {
await tx.touchIssueUpdatedAt({ issueId: input.issue.id, updatedAt: input.now });
}
} else {
nextWake = await tx.updateWakeQueuedCommentIds({
wakeId: locked.wake.id,
payload: locked.wake.payload,
ids: remainingIds,
updatedAt: input.now,
});
nextQueueRun = await updateQueueRunCommentIdsGuarded(tx, {
queueRun: locked.queueRun,
ids: remainingIds,
updatedAt: input.now,
});
await tx.touchIssueUpdatedAt({ issueId: input.issue.id, updatedAt: input.now });
}
const queue = await tx.buildQueueSnapshot({
issue: input.issue,
actor: input.actor,
wake: queueBecomesEmpty ? null : nextWake,
state: queueBecomesEmpty ? null : locked.state,
queueRun: queueBecomesEmpty ? null : (nextQueueRun ?? locked.queueRun),
activeRun: locked.activeRun,
});
return { deleted, queue, cancelledRun };
},
);
};
}