mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
fix(decisions): serialize triage updates
Lock each company/source triage identity before reading current state so concurrent partial updates preserve both fields and emit monotonic history versions. Document the enforced serialization invariant. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
c92b8851a6
commit
0e40228016
3 files changed
+36
No files matched your search
@@ -183,6 +183,8 @@ The decisions desk stores queue membership and decide-by/snooze state in `decisi
|
||||
|
||||
`decision_triage_events` is append-only history for queue and triage changes. Current rows and history both carry server-derived user/agent, heartbeat run, API-key, and responsible-user attribution where applicable. Queue reads must resolve and authorize their source rows at read time; a sidecar row is never a visibility grant.
|
||||
|
||||
Triage writes serialize on the company and attention-source identity so concurrent partial updates preserve both fields and produce monotonic history versions.
|
||||
|
||||
## Plugin database namespaces
|
||||
|
||||
The plugin runtime tracks plugin-owned database namespaces and migrations in `plugin_database_namespaces` and `plugin_migrations`. Hosted deployments that separate runtime and migration connections should set `DATABASE_MIGRATION_URL`; plugin namespace migration work uses the migration connection when present.
|
||||
|
||||
@@ -396,6 +396,38 @@ describeEmbeddedPostgres("decision queue routes", () => {
|
||||
expect(current[0]?.setByUserId).toBe("override-user");
|
||||
});
|
||||
|
||||
it("serializes concurrent partial triage updates without losing state or reusing a version", async () => {
|
||||
const { companyId, issueId } = await seed();
|
||||
const board = boardActor(companyId);
|
||||
const [decideByResult, snoozeResult] = await Promise.all([
|
||||
request(app(board))
|
||||
.put(`/api/companies/${companyId}/decision-triage/review/${issueId}`)
|
||||
.send({ decideBy: "today" }),
|
||||
request(app(board))
|
||||
.put(`/api/companies/${companyId}/decision-triage/review/${issueId}`)
|
||||
.send({ snoozedUntil: "2026-08-03T12:00:00.000Z" }),
|
||||
]);
|
||||
|
||||
expect(decideByResult.status).toBe(200);
|
||||
expect(snoozeResult.status).toBe(200);
|
||||
expect([decideByResult.body.version, snoozeResult.body.version].sort()).toEqual([1, 2]);
|
||||
|
||||
const current = await request(app(board))
|
||||
.get(`/api/companies/${companyId}/decision-triage/review/${issueId}`)
|
||||
.expect(200);
|
||||
expect(current.body).toMatchObject({
|
||||
decideBy: "today",
|
||||
snoozedUntil: "2026-08-03T12:00:00.000Z",
|
||||
version: 2,
|
||||
});
|
||||
const events = await db.select().from(decisionTriageEvents).where(and(
|
||||
eq(decisionTriageEvents.companyId, companyId),
|
||||
eq(decisionTriageEvents.sourceKind, "review"),
|
||||
eq(decisionTriageEvents.sourceId, issueId),
|
||||
));
|
||||
expect(events.map((event) => event.details.version).sort()).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("rejects task-bridge JWTs and returns the same 404 for missing and unauthorized sources", async () => {
|
||||
const { companyId, agentId, approvalId } = await seed();
|
||||
const bridge = {
|
||||
|
||||
@@ -597,6 +597,8 @@ export function decisionQueueService(db: Db) {
|
||||
await requireSourceRead(db, input.authActor, input.companyId, input.sourceKind, input.sourceId);
|
||||
return db.transaction(async (tx) => {
|
||||
const txDb = tx as unknown as Db;
|
||||
const lockKey = `decision-triage:${input.companyId}:${input.sourceKind}:${input.sourceId}`;
|
||||
await txDb.execute(sql`select pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`);
|
||||
const current = await txDb.select().from(decisionTriage).where(and(
|
||||
eq(decisionTriage.companyId, input.companyId),
|
||||
eq(decisionTriage.sourceKind, input.sourceKind),
|
||||
|
||||
Reference in new issue
Block a user