mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 09:37:19 +01: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
@@ -2,6 +2,9 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
decidePreDrain,
|
||||
decideQueuedCommentAction,
|
||||
decideQueuedCommentActorOwnsEntry,
|
||||
decideQueuedCommentReorder,
|
||||
decideQueuedCommentWakeLookup,
|
||||
decideReleaseRecovery,
|
||||
decideWakeAdmission,
|
||||
decideWakeOutcome,
|
||||
@@ -10,6 +13,9 @@ import {
|
||||
type DeferredWakeQueuedCommentFacts,
|
||||
type ImmediateRecoveryContextLabels,
|
||||
type PreDrainFacts,
|
||||
type QueuedCommentActorOwnershipFacts,
|
||||
type QueuedCommentReorderFacts,
|
||||
type QueuedCommentWakeLookupFacts,
|
||||
type ReleaseRecoveryFacts,
|
||||
type WakeAdmissionFacts,
|
||||
} from "./policy.js";
|
||||
@@ -544,3 +550,166 @@ describe("decideWakeAdmission", () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("decideQueuedCommentWakeLookup", () => {
|
||||
const baseFacts: QueuedCommentWakeLookupFacts = {
|
||||
wakePresent: true,
|
||||
wakeIssueIdMatches: true,
|
||||
hasQueuedCommentIds: true,
|
||||
wakeStatus: "deferred_issue_execution",
|
||||
wakeHasRunId: false,
|
||||
};
|
||||
|
||||
const cases: Array<{
|
||||
name: string;
|
||||
facts: QueuedCommentWakeLookupFacts;
|
||||
expected: ReturnType<typeof decideQueuedCommentWakeLookup>;
|
||||
}> = [
|
||||
{
|
||||
name: "not_pending: no wake row was found",
|
||||
facts: { ...baseFacts, wakePresent: false },
|
||||
expected: { kind: "not_pending" },
|
||||
},
|
||||
{
|
||||
name: "not_pending: the wake's payload names a different issue",
|
||||
facts: { ...baseFacts, wakeIssueIdMatches: false },
|
||||
expected: { kind: "not_pending" },
|
||||
},
|
||||
{
|
||||
name: "not_pending: the wake's payload carries no queued comment ids",
|
||||
facts: { ...baseFacts, hasQueuedCommentIds: false },
|
||||
expected: { kind: "not_pending" },
|
||||
},
|
||||
{
|
||||
name: "deferred: the wake is still waiting behind an active execution run",
|
||||
facts: baseFacts,
|
||||
expected: { kind: "deferred" },
|
||||
},
|
||||
{
|
||||
name: "check_queue_run: the wake is queued and carries a linked run id",
|
||||
facts: { ...baseFacts, wakeStatus: "queued", wakeHasRunId: true },
|
||||
expected: { kind: "check_queue_run" },
|
||||
},
|
||||
{
|
||||
name: "not_pending: the wake is queued but carries no linked run id",
|
||||
facts: { ...baseFacts, wakeStatus: "queued", wakeHasRunId: false },
|
||||
expected: { kind: "not_pending" },
|
||||
},
|
||||
{
|
||||
name: "already_dispatching: the wake was claimed",
|
||||
facts: { ...baseFacts, wakeStatus: "claimed" },
|
||||
expected: { kind: "already_dispatching" },
|
||||
},
|
||||
{
|
||||
name: "already_dispatching: the wake is running",
|
||||
facts: { ...baseFacts, wakeStatus: "running" },
|
||||
expected: { kind: "already_dispatching" },
|
||||
},
|
||||
{
|
||||
name: "already_dispatching: the wake succeeded and still carries a run id",
|
||||
facts: { ...baseFacts, wakeStatus: "succeeded", wakeHasRunId: true },
|
||||
expected: { kind: "already_dispatching" },
|
||||
},
|
||||
{
|
||||
name: "already_dispatching: the wake failed and still carries a run id",
|
||||
facts: { ...baseFacts, wakeStatus: "failed", wakeHasRunId: true },
|
||||
expected: { kind: "already_dispatching" },
|
||||
},
|
||||
{
|
||||
name: "not_pending: the wake succeeded but carries no run id",
|
||||
facts: { ...baseFacts, wakeStatus: "succeeded", wakeHasRunId: false },
|
||||
expected: { kind: "not_pending" },
|
||||
},
|
||||
{
|
||||
name: "not_pending: the wake was cancelled",
|
||||
facts: { ...baseFacts, wakeStatus: "cancelled" },
|
||||
expected: { kind: "not_pending" },
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
it(testCase.name, () => {
|
||||
expect(decideQueuedCommentWakeLookup(testCase.facts)).toEqual(testCase.expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("decideQueuedCommentReorder", () => {
|
||||
const cases: Array<{
|
||||
name: string;
|
||||
facts: QueuedCommentReorderFacts;
|
||||
expected: ReturnType<typeof decideQueuedCommentReorder>;
|
||||
}> = [
|
||||
{
|
||||
name: "ok: the submitted order is a permutation of the current ids",
|
||||
facts: { currentIds: ["a", "b", "c"], orderedIds: ["c", "a", "b"] },
|
||||
expected: { kind: "ok" },
|
||||
},
|
||||
{
|
||||
name: "mismatch: the submitted order carries a duplicate id",
|
||||
facts: { currentIds: ["a", "b"], orderedIds: ["a", "a"] },
|
||||
expected: { kind: "mismatch" },
|
||||
},
|
||||
{
|
||||
name: "mismatch: the submitted order drops an id",
|
||||
facts: { currentIds: ["a", "b", "c"], orderedIds: ["a", "b"] },
|
||||
expected: { kind: "mismatch" },
|
||||
},
|
||||
{
|
||||
name: "mismatch: the submitted order adds an id the queue does not have",
|
||||
facts: { currentIds: ["a", "b"], orderedIds: ["a", "b", "c"] },
|
||||
expected: { kind: "mismatch" },
|
||||
},
|
||||
{
|
||||
name: "mismatch: the submitted order names an id the current queue does not have",
|
||||
facts: { currentIds: ["a", "b"], orderedIds: ["a", "c"] },
|
||||
expected: { kind: "mismatch" },
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
it(testCase.name, () => {
|
||||
expect(decideQueuedCommentReorder(testCase.facts)).toEqual(testCase.expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("decideQueuedCommentActorOwnsEntry", () => {
|
||||
const cases: Array<{
|
||||
name: string;
|
||||
facts: QueuedCommentActorOwnershipFacts;
|
||||
expected: boolean;
|
||||
}> = [
|
||||
{
|
||||
name: "owns: a user actor authored the comment",
|
||||
facts: { actorType: "user", actorId: "user-1", actorAgentId: null, authorAgentId: null, authorUserId: "user-1" },
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "does not own: a user actor did not author the comment",
|
||||
facts: { actorType: "user", actorId: "user-1", actorAgentId: null, authorAgentId: null, authorUserId: "user-2" },
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "owns: an agent actor authored the comment as that agent",
|
||||
facts: { actorType: "agent", actorId: "agent-1", actorAgentId: "agent-1", authorAgentId: "agent-1", authorUserId: null },
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "does not own: an agent actor authored a different comment",
|
||||
facts: { actorType: "agent", actorId: "agent-1", actorAgentId: "agent-1", authorAgentId: "agent-2", authorUserId: null },
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "does not own: an agent actor with no resolved agent id",
|
||||
facts: { actorType: "agent", actorId: "agent-1", actorAgentId: null, authorAgentId: null, authorUserId: null },
|
||||
expected: false,
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
it(testCase.name, () => {
|
||||
expect(decideQueuedCommentActorOwnsEntry(testCase.facts)).toBe(testCase.expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -273,6 +273,98 @@ export function deriveImmediateRecoveryContextLabels(issueStatus: string): Immed
|
||||
};
|
||||
}
|
||||
|
||||
// Pure decision rules for the three queued-comment queue mutations (edit,
|
||||
// reorder, discard): the queue-mutation target check every mutation runs
|
||||
// first, the wake and queue-run status classification the initial lock
|
||||
// step needs, the reorder set-equality check, the queue-entry permission
|
||||
// fields the response carries, the actor-ownership check discard enforces,
|
||||
// and the empty-versus-partial outcome a discard resolves to. As with every
|
||||
// other decision in this file, the caller reads the database and packs the
|
||||
// result into a facts object; this file only branches on that object.
|
||||
|
||||
export type QueuedCommentWakeLookupFacts = {
|
||||
/** True when a wake row was found for the submitted queue id. */
|
||||
wakePresent: boolean;
|
||||
/** True when the wake's own payload still names this issue. */
|
||||
wakeIssueIdMatches: boolean;
|
||||
/** True when the wake's payload still carries one or more queued comment ids. */
|
||||
hasQueuedCommentIds: boolean;
|
||||
/** The wake row's own status. Meaningless when `wakePresent` is false. */
|
||||
wakeStatus: string | null;
|
||||
/** True when the wake row carries a linked heartbeat run id. */
|
||||
wakeHasRunId: boolean;
|
||||
};
|
||||
|
||||
export type QueuedCommentWakeLookupDecision =
|
||||
| { kind: "not_pending" }
|
||||
| { kind: "deferred" }
|
||||
/** The caller must read the linked heartbeat run next and confirm it is still queued. */
|
||||
| { kind: "check_queue_run" }
|
||||
| { kind: "already_dispatching" };
|
||||
|
||||
/**
|
||||
* Classifies a locked wake row into the queue state a mutation needs: still
|
||||
* waiting behind an active run (`deferred`), queued behind a not-yet-running
|
||||
* turn (needs a second read to confirm, `check_queue_run`), already being
|
||||
* dispatched, or no longer a pending queue at all.
|
||||
*/
|
||||
export function decideQueuedCommentWakeLookup(facts: QueuedCommentWakeLookupFacts): QueuedCommentWakeLookupDecision {
|
||||
if (!facts.wakePresent || !facts.wakeIssueIdMatches || !facts.hasQueuedCommentIds) {
|
||||
return { kind: "not_pending" };
|
||||
}
|
||||
if (facts.wakeStatus === "deferred_issue_execution") return { kind: "deferred" };
|
||||
if (facts.wakeStatus === "queued" && facts.wakeHasRunId) return { kind: "check_queue_run" };
|
||||
if (
|
||||
facts.wakeStatus === "claimed" ||
|
||||
facts.wakeStatus === "running" ||
|
||||
(facts.wakeHasRunId && (facts.wakeStatus === "succeeded" || facts.wakeStatus === "failed"))
|
||||
) {
|
||||
return { kind: "already_dispatching" };
|
||||
}
|
||||
return { kind: "not_pending" };
|
||||
}
|
||||
|
||||
export type QueuedCommentReorderFacts = {
|
||||
currentIds: string[];
|
||||
orderedIds: string[];
|
||||
};
|
||||
|
||||
export type QueuedCommentReorderDecision = { kind: "ok" } | { kind: "mismatch" };
|
||||
|
||||
/** Decides whether a submitted order is a permutation of the queue's current comment ids: no duplicates, no drops, no additions. */
|
||||
export function decideQueuedCommentReorder(facts: QueuedCommentReorderFacts): QueuedCommentReorderDecision {
|
||||
const orderedSet = new Set(facts.orderedIds);
|
||||
if (
|
||||
orderedSet.size !== facts.orderedIds.length ||
|
||||
facts.orderedIds.length !== facts.currentIds.length ||
|
||||
facts.currentIds.some((id) => !orderedSet.has(id))
|
||||
) {
|
||||
return { kind: "mismatch" };
|
||||
}
|
||||
return { kind: "ok" };
|
||||
}
|
||||
|
||||
export type QueuedCommentActorOwnershipFacts = {
|
||||
actorType: "agent" | "user";
|
||||
actorId: string;
|
||||
actorAgentId: string | null;
|
||||
authorAgentId: string | null;
|
||||
authorUserId: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Decides whether the actor discarding a queued message authored it. A user
|
||||
* actor must be the board user who wrote the message. An agent actor must be
|
||||
* the agent that wrote it, because an agent actor can discard its own queued
|
||||
* message through the general comment-delete route.
|
||||
*/
|
||||
export function decideQueuedCommentActorOwnsEntry(facts: QueuedCommentActorOwnershipFacts): boolean {
|
||||
if (facts.actorType === "agent") {
|
||||
return facts.actorAgentId !== null && facts.authorAgentId === facts.actorAgentId;
|
||||
}
|
||||
return facts.authorUserId === facts.actorId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides the release-recovery outcome once the deferred-wake queue is
|
||||
* empty and no wake was promoted. The review-participant branch and the
|
||||
|
||||
Reference in new issue
Block a user