mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-17 10:06:42 +02:00
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:
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 };
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user