@triagepilot/db 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +105 -0
- package/dist/availability.d.ts +164 -0
- package/dist/availability.js +974 -0
- package/dist/database.d.ts +3 -0
- package/dist/database.js +9 -0
- package/dist/decisions.d.ts +107 -0
- package/dist/decisions.js +435 -0
- package/dist/deliveries.d.ts +30 -0
- package/dist/deliveries.js +82 -0
- package/dist/heartbeat.d.ts +11 -0
- package/dist/heartbeat.js +11 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +13 -0
- package/dist/jobs.d.ts +86 -0
- package/dist/jobs.js +641 -0
- package/dist/kysely.d.ts +202 -0
- package/dist/kysely.js +1 -0
- package/dist/migrate.d.ts +1 -0
- package/dist/migrate.js +45 -0
- package/dist/operations.d.ts +77 -0
- package/dist/operations.js +256 -0
- package/dist/outbox.d.ts +61 -0
- package/dist/outbox.js +230 -0
- package/dist/provider-connections.d.ts +34 -0
- package/dist/provider-connections.js +302 -0
- package/dist/retention.d.ts +6 -0
- package/dist/retention.js +44 -0
- package/dist/routing-recovery.d.ts +55 -0
- package/dist/routing-recovery.js +175 -0
- package/dist/workspaces.d.ts +50 -0
- package/dist/workspaces.js +43 -0
- package/migrations/0001_initial.sql +79 -0
- package/migrations/0002_selected_reviewers.sql +10 -0
- package/migrations/0003_human_review_policy.sql +10 -0
- package/migrations/0004_semantic_routing_deduplication.sql +15 -0
- package/migrations/0005_reviewer_availability.sql +58 -0
- package/migrations/0005_workspace_scope.sql +198 -0
- package/migrations/0006_decision_outbox.sql +24 -0
- package/migrations/0007_workspace_reviewer_availability.sql +192 -0
- package/migrations/0008_reviewer_mutation_intents.sql +160 -0
- package/migrations/0009_provider_connection_revocations.sql +18 -0
- package/migrations/0010_provider_connection_preemptive_revocations.sql +12 -0
- package/package.json +42 -0
|
@@ -0,0 +1,974 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { sql } from "kysely";
|
|
3
|
+
import { buildReviewerAbsenceActivationKey, } from "@triagepilot/contracts";
|
|
4
|
+
import { findReviewerReplacementCandidates, parseExternalActorId, parseOriginalReviewerPool, parseStrictActorList, } from "./decisions.js";
|
|
5
|
+
import { stagePlatformEvent } from "./outbox.js";
|
|
6
|
+
export class ReviewerAvailabilityValidationError extends Error {
|
|
7
|
+
}
|
|
8
|
+
export class ReviewerAbsenceConflictError extends Error {
|
|
9
|
+
}
|
|
10
|
+
export class ReviewerAbsenceRevisionError extends Error {
|
|
11
|
+
}
|
|
12
|
+
export class ProviderConnectionUnavailableError extends Error {
|
|
13
|
+
}
|
|
14
|
+
export function createWorkspaceReviewerAvailability(db, workspaceId) {
|
|
15
|
+
return {
|
|
16
|
+
async readSettings() {
|
|
17
|
+
const row = await ensureWorkspaceSettings(db, workspaceId);
|
|
18
|
+
return toSettings(row);
|
|
19
|
+
},
|
|
20
|
+
async updateTimezone(timezone, now) {
|
|
21
|
+
validateTimezone(timezone);
|
|
22
|
+
validateDate(now, "now");
|
|
23
|
+
const row = await db
|
|
24
|
+
.insertInto("workspace_operational_settings")
|
|
25
|
+
.values({ workspace_id: workspaceId, timezone, updated_at: now })
|
|
26
|
+
.onConflict((conflict) => conflict.column("workspace_id").doUpdateSet({ timezone, updated_at: now }))
|
|
27
|
+
.returningAll()
|
|
28
|
+
.executeTakeFirstOrThrow();
|
|
29
|
+
return toSettings(row);
|
|
30
|
+
},
|
|
31
|
+
async listAbsences() {
|
|
32
|
+
const rows = await db
|
|
33
|
+
.selectFrom("reviewer_absences")
|
|
34
|
+
.selectAll()
|
|
35
|
+
.where("workspace_id", "=", workspaceId)
|
|
36
|
+
.orderBy("start_at", "asc")
|
|
37
|
+
.orderBy("id", "asc")
|
|
38
|
+
.execute();
|
|
39
|
+
return rows.map(toAbsence);
|
|
40
|
+
},
|
|
41
|
+
async scheduleAbsence(input) {
|
|
42
|
+
const mutation = validateAbsenceMutation(input);
|
|
43
|
+
try {
|
|
44
|
+
return await db.transaction().execute(async (trx) => {
|
|
45
|
+
await requireActiveConnection(trx, workspaceId, input.providerConnectionId, input.provider);
|
|
46
|
+
await lockActorScopes(trx, workspaceId, input.provider, input.providerConnectionId, [mutation.externalActorId]);
|
|
47
|
+
const absence = await trx
|
|
48
|
+
.insertInto("reviewer_absences")
|
|
49
|
+
.values({
|
|
50
|
+
workspace_id: workspaceId,
|
|
51
|
+
provider: input.provider,
|
|
52
|
+
provider_connection_id: input.providerConnectionId,
|
|
53
|
+
external_actor_id: mutation.externalActorId,
|
|
54
|
+
start_at: mutation.startAt,
|
|
55
|
+
end_at: mutation.endAt,
|
|
56
|
+
updated_at: mutation.now,
|
|
57
|
+
})
|
|
58
|
+
.returningAll()
|
|
59
|
+
.executeTakeFirstOrThrow();
|
|
60
|
+
await enqueueActivation(trx, workspaceId, absence, mutation.startAt, mutation.now);
|
|
61
|
+
return toAbsence(absence);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
throw translateAbsenceConflict(error);
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
async reviseAbsence(input) {
|
|
69
|
+
const mutation = validateAbsenceMutation(input);
|
|
70
|
+
validateExpectedRevision(input.expectedRevision);
|
|
71
|
+
try {
|
|
72
|
+
return await db.transaction().execute(async (trx) => {
|
|
73
|
+
await requireActiveConnection(trx, workspaceId, input.providerConnectionId, input.provider);
|
|
74
|
+
const current = await lockCurrentAbsence(trx, workspaceId, input);
|
|
75
|
+
await lockActorScopes(trx, workspaceId, input.provider, input.providerConnectionId, [current.external_actor_id, mutation.externalActorId]);
|
|
76
|
+
const revision = current.revision + 1;
|
|
77
|
+
const absence = await trx
|
|
78
|
+
.updateTable("reviewer_absences")
|
|
79
|
+
.set({
|
|
80
|
+
external_actor_id: mutation.externalActorId,
|
|
81
|
+
start_at: mutation.startAt,
|
|
82
|
+
end_at: mutation.endAt,
|
|
83
|
+
revision,
|
|
84
|
+
updated_at: mutation.now,
|
|
85
|
+
})
|
|
86
|
+
.where("workspace_id", "=", workspaceId)
|
|
87
|
+
.where("provider", "=", input.provider)
|
|
88
|
+
.where("provider_connection_id", "=", input.providerConnectionId)
|
|
89
|
+
.where("id", "=", input.absenceId)
|
|
90
|
+
.returningAll()
|
|
91
|
+
.executeTakeFirstOrThrow();
|
|
92
|
+
await enqueueActivation(trx, workspaceId, absence, mutation.startAt, mutation.now);
|
|
93
|
+
return toAbsence(absence);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
throw translateAbsenceConflict(error);
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
async cancelAbsence(input) {
|
|
101
|
+
validateDate(input.now, "now");
|
|
102
|
+
validateExpectedRevision(input.expectedRevision);
|
|
103
|
+
return await db.transaction().execute(async (trx) => {
|
|
104
|
+
await requireActiveConnection(trx, workspaceId, input.providerConnectionId, input.provider);
|
|
105
|
+
const current = await lockCurrentAbsence(trx, workspaceId, input);
|
|
106
|
+
await lockActorScopes(trx, workspaceId, input.provider, input.providerConnectionId, [current.external_actor_id]);
|
|
107
|
+
const revision = current.revision + 1;
|
|
108
|
+
const absence = await trx
|
|
109
|
+
.updateTable("reviewer_absences")
|
|
110
|
+
.set({
|
|
111
|
+
status: "cancelled",
|
|
112
|
+
cancelled_at: input.now,
|
|
113
|
+
revision,
|
|
114
|
+
updated_at: input.now,
|
|
115
|
+
})
|
|
116
|
+
.where("workspace_id", "=", workspaceId)
|
|
117
|
+
.where("provider", "=", input.provider)
|
|
118
|
+
.where("provider_connection_id", "=", input.providerConnectionId)
|
|
119
|
+
.where("id", "=", input.absenceId)
|
|
120
|
+
.returningAll()
|
|
121
|
+
.executeTakeFirstOrThrow();
|
|
122
|
+
await enqueueActivation(trx, workspaceId, absence, input.now, input.now);
|
|
123
|
+
return toAbsence(absence);
|
|
124
|
+
});
|
|
125
|
+
},
|
|
126
|
+
async findActiveAbsences(input) {
|
|
127
|
+
validateDate(input.at, "at");
|
|
128
|
+
const actors = validActorList(input.actors);
|
|
129
|
+
if (actors.length === 0)
|
|
130
|
+
return [];
|
|
131
|
+
const connection = await findActiveConnection(db, workspaceId, input.providerConnectionId);
|
|
132
|
+
if (connection === null)
|
|
133
|
+
return [];
|
|
134
|
+
const rows = await db
|
|
135
|
+
.selectFrom("reviewer_absences")
|
|
136
|
+
.select(["external_actor_id", "start_at", "end_at"])
|
|
137
|
+
.where("workspace_id", "=", workspaceId)
|
|
138
|
+
.where("provider", "=", connection.provider)
|
|
139
|
+
.where("provider_connection_id", "=", input.providerConnectionId)
|
|
140
|
+
.where("external_actor_id", "in", actors)
|
|
141
|
+
.where("status", "=", "scheduled")
|
|
142
|
+
.where("start_at", "<=", input.at)
|
|
143
|
+
.where("end_at", ">", input.at)
|
|
144
|
+
.orderBy("start_at", "asc")
|
|
145
|
+
.orderBy("id", "asc")
|
|
146
|
+
.execute();
|
|
147
|
+
return rows.map((row) => ({
|
|
148
|
+
externalActorId: row.external_actor_id,
|
|
149
|
+
startAt: row.start_at,
|
|
150
|
+
endAt: row.end_at,
|
|
151
|
+
}));
|
|
152
|
+
},
|
|
153
|
+
async loadActivation(absenceId, revision) {
|
|
154
|
+
validateExpectedRevision(revision);
|
|
155
|
+
const absence = await db
|
|
156
|
+
.selectFrom("reviewer_absences")
|
|
157
|
+
.innerJoin("provider_connections", (join) => join
|
|
158
|
+
.onRef("provider_connections.workspace_id", "=", "reviewer_absences.workspace_id")
|
|
159
|
+
.onRef("provider_connections.provider", "=", "reviewer_absences.provider")
|
|
160
|
+
.onRef("provider_connections.id", "=", "reviewer_absences.provider_connection_id"))
|
|
161
|
+
.select([
|
|
162
|
+
"reviewer_absences.id",
|
|
163
|
+
"reviewer_absences.revision",
|
|
164
|
+
"reviewer_absences.provider",
|
|
165
|
+
"reviewer_absences.provider_connection_id",
|
|
166
|
+
"reviewer_absences.external_actor_id",
|
|
167
|
+
"reviewer_absences.start_at",
|
|
168
|
+
"reviewer_absences.end_at",
|
|
169
|
+
"reviewer_absences.status",
|
|
170
|
+
])
|
|
171
|
+
.where("reviewer_absences.workspace_id", "=", workspaceId)
|
|
172
|
+
.where("reviewer_absences.id", "=", absenceId)
|
|
173
|
+
.where("reviewer_absences.revision", "=", revision)
|
|
174
|
+
.where("reviewer_absences.status", "=", "scheduled")
|
|
175
|
+
.where("provider_connections.status", "=", "active")
|
|
176
|
+
.executeTakeFirst();
|
|
177
|
+
if (absence === undefined)
|
|
178
|
+
return null;
|
|
179
|
+
const candidates = await findReviewerReplacementCandidates(db, workspaceId, {
|
|
180
|
+
provider: absence.provider,
|
|
181
|
+
providerConnectionId: absence.provider_connection_id,
|
|
182
|
+
unavailableActorId: absence.external_actor_id,
|
|
183
|
+
recordedFor: { absenceId, absenceRevision: absence.revision },
|
|
184
|
+
});
|
|
185
|
+
return {
|
|
186
|
+
absenceId: absence.id,
|
|
187
|
+
revision: absence.revision,
|
|
188
|
+
provider: absence.provider,
|
|
189
|
+
providerConnectionId: absence.provider_connection_id,
|
|
190
|
+
externalActorId: absence.external_actor_id,
|
|
191
|
+
startAt: absence.start_at,
|
|
192
|
+
endAt: absence.end_at,
|
|
193
|
+
candidates,
|
|
194
|
+
};
|
|
195
|
+
},
|
|
196
|
+
async loadMutationIntent(input) {
|
|
197
|
+
validateMutationIntentKey(input, workspaceId);
|
|
198
|
+
const row = await mutationIntentQuery(db, workspaceId, input).executeTakeFirst();
|
|
199
|
+
return row === undefined ? null : toMutationIntent(row);
|
|
200
|
+
},
|
|
201
|
+
async loadReplacement(replacementId) {
|
|
202
|
+
if (!isNonBlank(replacementId))
|
|
203
|
+
throw new ReviewerAvailabilityValidationError("Replacement identifier is malformed");
|
|
204
|
+
const row = await db.selectFrom("reviewer_replacements")
|
|
205
|
+
.selectAll()
|
|
206
|
+
.where("workspace_id", "=", workspaceId)
|
|
207
|
+
.where("id", "=", replacementId)
|
|
208
|
+
.executeTakeFirst();
|
|
209
|
+
return row === undefined ? null : toReplacement(row);
|
|
210
|
+
},
|
|
211
|
+
async listUnfinalizedMutationIntents(input) {
|
|
212
|
+
validateExpectedRevision(input.absenceRevision);
|
|
213
|
+
const rows = await db.selectFrom("reviewer_mutation_intents")
|
|
214
|
+
.selectAll()
|
|
215
|
+
.where("workspace_id", "=", workspaceId)
|
|
216
|
+
.where("absence_id", "=", input.absenceId)
|
|
217
|
+
.where("absence_revision", "=", input.absenceRevision)
|
|
218
|
+
.orderBy("created_at", "asc")
|
|
219
|
+
.orderBy("id", "asc")
|
|
220
|
+
.execute();
|
|
221
|
+
const unfinalized = [];
|
|
222
|
+
for (const row of rows) {
|
|
223
|
+
const history = await db.selectFrom("reviewer_replacements")
|
|
224
|
+
.select("mutation_intent_id")
|
|
225
|
+
.where("workspace_id", "=", workspaceId)
|
|
226
|
+
.where("provider", "=", row.provider)
|
|
227
|
+
.where("provider_connection_id", "=", row.provider_connection_id)
|
|
228
|
+
.where("absence_id", "=", row.absence_id)
|
|
229
|
+
.where("absence_revision", "=", row.absence_revision)
|
|
230
|
+
.where("decision_id", "=", row.decision_id)
|
|
231
|
+
.executeTakeFirst();
|
|
232
|
+
if (history === undefined)
|
|
233
|
+
unfinalized.push(toMutationIntent(row));
|
|
234
|
+
else if (history.mutation_intent_id !== row.id) {
|
|
235
|
+
throw new ReviewerAvailabilityValidationError("Replacement history conflicts with reviewer mutation intent provenance");
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return unfinalized;
|
|
239
|
+
},
|
|
240
|
+
async prepareMutationIntent(input) {
|
|
241
|
+
validateMutationIntentInput(input, workspaceId);
|
|
242
|
+
return await db.transaction().execute(async (trx) => prepareMutationIntentTransaction(trx, workspaceId, input));
|
|
243
|
+
},
|
|
244
|
+
async listPendingFinalizers(input) {
|
|
245
|
+
validateExpectedRevision(input.absenceRevision);
|
|
246
|
+
return await listReplacementHistory(db, workspaceId, input.absenceId, input.absenceRevision, "finalizer_pending");
|
|
247
|
+
},
|
|
248
|
+
async listReplacementHistory(absenceId) {
|
|
249
|
+
return await listReplacementHistory(db, workspaceId, absenceId);
|
|
250
|
+
},
|
|
251
|
+
async persistReplacement(input) {
|
|
252
|
+
validateReplacementInput(input, workspaceId);
|
|
253
|
+
return await db.transaction().execute(async (trx) => {
|
|
254
|
+
const absence = await trx
|
|
255
|
+
.selectFrom("reviewer_absences")
|
|
256
|
+
.selectAll()
|
|
257
|
+
.where("workspace_id", "=", workspaceId)
|
|
258
|
+
.where("provider", "=", input.provider)
|
|
259
|
+
.where("provider_connection_id", "=", input.providerConnectionId)
|
|
260
|
+
.where("id", "=", input.absenceId)
|
|
261
|
+
.forUpdate()
|
|
262
|
+
.executeTakeFirst();
|
|
263
|
+
if (absence === undefined)
|
|
264
|
+
return staleReplacementResult();
|
|
265
|
+
const existing = await trx
|
|
266
|
+
.selectFrom("reviewer_replacements")
|
|
267
|
+
.selectAll()
|
|
268
|
+
.where("workspace_id", "=", workspaceId)
|
|
269
|
+
.where("provider", "=", input.provider)
|
|
270
|
+
.where("provider_connection_id", "=", input.providerConnectionId)
|
|
271
|
+
.where("absence_id", "=", input.absenceId)
|
|
272
|
+
.where("absence_revision", "=", input.absenceRevision)
|
|
273
|
+
.where("decision_id", "=", input.decisionId)
|
|
274
|
+
.forUpdate()
|
|
275
|
+
.executeTakeFirst();
|
|
276
|
+
if (existing !== undefined) {
|
|
277
|
+
assertReplacementRetryMatches(existing, input);
|
|
278
|
+
const hasPersistedEvent = await assertReplacementEventRetryMatches(trx, workspaceId, existing.id, input.event);
|
|
279
|
+
if (hasPersistedEvent) {
|
|
280
|
+
await stagePlatformEvent(trx, workspaceId, existing.id, input.event);
|
|
281
|
+
}
|
|
282
|
+
return { inserted: false, activationCurrent: true, replacement: toReplacement(existing) };
|
|
283
|
+
}
|
|
284
|
+
if (input.mutationIntentId === null) {
|
|
285
|
+
await requireActiveConnection(trx, workspaceId, input.providerConnectionId, input.provider);
|
|
286
|
+
}
|
|
287
|
+
if (absence.revision !== input.absenceRevision
|
|
288
|
+
|| absence.external_actor_id !== input.unavailableActorId
|
|
289
|
+
|| absence.status !== "scheduled"
|
|
290
|
+
|| absence.start_at > input.completedAt
|
|
291
|
+
|| absence.end_at <= input.completedAt)
|
|
292
|
+
return staleReplacementResult();
|
|
293
|
+
const decision = await trx
|
|
294
|
+
.selectFrom("routing_decisions")
|
|
295
|
+
.innerJoin("repositories", (join) => join
|
|
296
|
+
.onRef("repositories.workspace_id", "=", "routing_decisions.workspace_id")
|
|
297
|
+
.onRef("repositories.id", "=", "routing_decisions.repository_id"))
|
|
298
|
+
.select([
|
|
299
|
+
"routing_decisions.id",
|
|
300
|
+
"routing_decisions.change_request_id",
|
|
301
|
+
"routing_decisions.head_sha",
|
|
302
|
+
"routing_decisions.selected_reviewers",
|
|
303
|
+
"routing_decisions.details",
|
|
304
|
+
"routing_decisions.policy_check_state",
|
|
305
|
+
"repositories.provider",
|
|
306
|
+
"repositories.provider_connection_id",
|
|
307
|
+
"repositories.external_repository_id",
|
|
308
|
+
])
|
|
309
|
+
.where("routing_decisions.workspace_id", "=", workspaceId)
|
|
310
|
+
.where("routing_decisions.id", "=", input.decisionId)
|
|
311
|
+
.where("repositories.provider", "=", input.provider)
|
|
312
|
+
.where("repositories.provider_connection_id", "=", input.providerConnectionId)
|
|
313
|
+
.forUpdate("routing_decisions")
|
|
314
|
+
.executeTakeFirst();
|
|
315
|
+
if (decision === undefined || decision.head_sha !== input.expectedHeadRevision) {
|
|
316
|
+
return staleReplacementResult();
|
|
317
|
+
}
|
|
318
|
+
const selectedActors = parseStrictActorList(decision.selected_reviewers);
|
|
319
|
+
const original = parseOriginalReviewerPool(decision.details);
|
|
320
|
+
if (selectedActors === null
|
|
321
|
+
|| original === null
|
|
322
|
+
|| !selectedActors.includes(input.unavailableActorId)
|
|
323
|
+
|| decision.external_repository_id !== input.event.repositoryId
|
|
324
|
+
|| decision.change_request_id !== input.event.changeRequestId)
|
|
325
|
+
return staleReplacementResult();
|
|
326
|
+
await assertReplacementMutationIntent(trx, workspaceId, input);
|
|
327
|
+
if (replacesCohort(input.outcome)) {
|
|
328
|
+
if (!input.replaceCohort
|
|
329
|
+
|| input.replacementActorId === null
|
|
330
|
+
|| !original.eligibleActors.includes(input.replacementActorId)
|
|
331
|
+
|| selectedActors.includes(input.replacementActorId)
|
|
332
|
+
|| decision.policy_check_state === "success"
|
|
333
|
+
|| decision.policy_check_state === "failure")
|
|
334
|
+
return staleReplacementResult();
|
|
335
|
+
await lockActorScopes(trx, workspaceId, input.provider, input.providerConnectionId, [input.replacementActorId]);
|
|
336
|
+
const replacementAbsence = await trx
|
|
337
|
+
.selectFrom("reviewer_absences")
|
|
338
|
+
.select("id")
|
|
339
|
+
.where("workspace_id", "=", workspaceId)
|
|
340
|
+
.where("provider", "=", input.provider)
|
|
341
|
+
.where("provider_connection_id", "=", input.providerConnectionId)
|
|
342
|
+
.where("external_actor_id", "=", input.replacementActorId)
|
|
343
|
+
.where("status", "=", "scheduled")
|
|
344
|
+
.where("start_at", "<=", input.completedAt)
|
|
345
|
+
.where("end_at", ">", input.completedAt)
|
|
346
|
+
.forUpdate()
|
|
347
|
+
.executeTakeFirst();
|
|
348
|
+
if (replacementAbsence !== undefined)
|
|
349
|
+
return staleReplacementResult();
|
|
350
|
+
}
|
|
351
|
+
else if (input.replaceCohort || input.replacementActorId !== null) {
|
|
352
|
+
return staleReplacementResult();
|
|
353
|
+
}
|
|
354
|
+
const inserted = await trx
|
|
355
|
+
.insertInto("reviewer_replacements")
|
|
356
|
+
.values({
|
|
357
|
+
workspace_id: workspaceId,
|
|
358
|
+
provider: input.provider,
|
|
359
|
+
provider_connection_id: input.providerConnectionId,
|
|
360
|
+
absence_id: input.absenceId,
|
|
361
|
+
absence_revision: input.absenceRevision,
|
|
362
|
+
decision_id: input.decisionId,
|
|
363
|
+
unavailable_actor_id: input.unavailableActorId,
|
|
364
|
+
replacement_actor_id: input.replacementActorId,
|
|
365
|
+
mutation_intent_id: input.mutationIntentId,
|
|
366
|
+
outcome: input.outcome,
|
|
367
|
+
reason: input.reason,
|
|
368
|
+
state: input.state,
|
|
369
|
+
last_error: input.lastError,
|
|
370
|
+
started_at: input.startedAt,
|
|
371
|
+
completed_at: input.completedAt,
|
|
372
|
+
})
|
|
373
|
+
.returningAll()
|
|
374
|
+
.executeTakeFirstOrThrow();
|
|
375
|
+
if (replacesCohort(input.outcome) && input.replacementActorId !== null) {
|
|
376
|
+
const nextActors = selectedActors.map((actor) => actor === input.unavailableActorId ? input.replacementActorId : actor);
|
|
377
|
+
await trx
|
|
378
|
+
.updateTable("routing_decisions")
|
|
379
|
+
.set({
|
|
380
|
+
selected_reviewers: JSON.stringify(nextActors),
|
|
381
|
+
selected_reviewer: nextActors[0] ?? null,
|
|
382
|
+
})
|
|
383
|
+
.where("workspace_id", "=", workspaceId)
|
|
384
|
+
.where("id", "=", input.decisionId)
|
|
385
|
+
.execute();
|
|
386
|
+
}
|
|
387
|
+
await stagePlatformEvent(trx, workspaceId, inserted.id, input.event);
|
|
388
|
+
return { inserted: true, activationCurrent: true, replacement: toReplacement(inserted) };
|
|
389
|
+
});
|
|
390
|
+
},
|
|
391
|
+
async persistMutationIntentRecovery(input) {
|
|
392
|
+
validateReplacementInput(input, workspaceId);
|
|
393
|
+
if (input.outcome !== "permanent_failure"
|
|
394
|
+
|| input.state !== "permanent_failure"
|
|
395
|
+
|| input.mutationIntentId === null
|
|
396
|
+
|| input.replacementActorId !== null
|
|
397
|
+
|| input.replaceCohort)
|
|
398
|
+
throw new ReviewerAvailabilityValidationError("Mutation intent recovery audit is malformed");
|
|
399
|
+
return await db.transaction().execute(async (trx) => persistMutationIntentRecoveryTransaction(trx, workspaceId, input));
|
|
400
|
+
},
|
|
401
|
+
async updateReplacementState(input) {
|
|
402
|
+
validateReplacementState(input.state);
|
|
403
|
+
validateReplacementState(input.expectedState);
|
|
404
|
+
validateStateError(input.state, input.lastError);
|
|
405
|
+
return await db.transaction().execute(async (trx) => {
|
|
406
|
+
const scope = await trx.selectFrom("reviewer_replacements")
|
|
407
|
+
.select(["absence_id", "workspace_id", "provider", "provider_connection_id"])
|
|
408
|
+
.where("workspace_id", "=", workspaceId).where("id", "=", input.replacementId)
|
|
409
|
+
.executeTakeFirst();
|
|
410
|
+
if (scope === undefined)
|
|
411
|
+
return null;
|
|
412
|
+
await trx.selectFrom("reviewer_absences").select("id")
|
|
413
|
+
.where("workspace_id", "=", workspaceId).where("provider", "=", scope.provider)
|
|
414
|
+
.where("provider_connection_id", "=", scope.provider_connection_id)
|
|
415
|
+
.where("id", "=", scope.absence_id).forUpdate().executeTakeFirstOrThrow();
|
|
416
|
+
const current = await trx
|
|
417
|
+
.selectFrom("reviewer_replacements")
|
|
418
|
+
.selectAll()
|
|
419
|
+
.where("workspace_id", "=", workspaceId)
|
|
420
|
+
.where("id", "=", input.replacementId)
|
|
421
|
+
.forUpdate()
|
|
422
|
+
.executeTakeFirst();
|
|
423
|
+
if (current === undefined)
|
|
424
|
+
return null;
|
|
425
|
+
if (current.state === input.state && current.last_error === input.lastError) {
|
|
426
|
+
return toReplacement(current);
|
|
427
|
+
}
|
|
428
|
+
if (current.state !== input.expectedState || current.state !== "finalizer_pending")
|
|
429
|
+
return null;
|
|
430
|
+
const updated = await trx
|
|
431
|
+
.updateTable("reviewer_replacements")
|
|
432
|
+
.set({ state: input.state, last_error: input.lastError })
|
|
433
|
+
.where("workspace_id", "=", workspaceId)
|
|
434
|
+
.where("id", "=", input.replacementId)
|
|
435
|
+
.returningAll()
|
|
436
|
+
.executeTakeFirstOrThrow();
|
|
437
|
+
return toReplacement(updated);
|
|
438
|
+
});
|
|
439
|
+
},
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
export function buildMutationIntentRecoveryAudit(source, workspaceId, error) {
|
|
443
|
+
if (!isUnknownRecord(source) || !isUnknownRecord(source.event) || !isNonBlank(error)) {
|
|
444
|
+
throw new ReviewerAvailabilityValidationError("Mutation intent recovery source is malformed");
|
|
445
|
+
}
|
|
446
|
+
const hydrated = {
|
|
447
|
+
...source,
|
|
448
|
+
startedAt: hydrateDate(source.startedAt),
|
|
449
|
+
completedAt: hydrateDate(source.completedAt),
|
|
450
|
+
event: source.event,
|
|
451
|
+
};
|
|
452
|
+
validateReplacementInput(hydrated, workspaceId);
|
|
453
|
+
if (hydrated.mutationIntentId === null) {
|
|
454
|
+
throw new ReviewerAvailabilityValidationError("Mutation intent recovery has no durable intent");
|
|
455
|
+
}
|
|
456
|
+
const audit = {
|
|
457
|
+
...hydrated,
|
|
458
|
+
replacementActorId: null,
|
|
459
|
+
outcome: "permanent_failure",
|
|
460
|
+
reason: error,
|
|
461
|
+
state: "permanent_failure",
|
|
462
|
+
lastError: error,
|
|
463
|
+
replaceCohort: false,
|
|
464
|
+
event: {
|
|
465
|
+
...hydrated.event,
|
|
466
|
+
replacementActor: null,
|
|
467
|
+
outcome: "permanent_failure",
|
|
468
|
+
},
|
|
469
|
+
};
|
|
470
|
+
validateReplacementInput(audit, workspaceId);
|
|
471
|
+
return audit;
|
|
472
|
+
}
|
|
473
|
+
export async function persistMutationIntentRecoveryTransaction(trx, workspaceId, input) {
|
|
474
|
+
validateReplacementInput(input, workspaceId);
|
|
475
|
+
if (input.outcome !== "permanent_failure"
|
|
476
|
+
|| input.state !== "permanent_failure"
|
|
477
|
+
|| input.mutationIntentId === null
|
|
478
|
+
|| input.replacementActorId !== null
|
|
479
|
+
|| input.replaceCohort)
|
|
480
|
+
throw new ReviewerAvailabilityValidationError("Mutation intent recovery audit is malformed");
|
|
481
|
+
const absence = await trx.selectFrom("reviewer_absences")
|
|
482
|
+
.select(["id"])
|
|
483
|
+
.where("workspace_id", "=", workspaceId)
|
|
484
|
+
.where("provider", "=", input.provider)
|
|
485
|
+
.where("provider_connection_id", "=", input.providerConnectionId)
|
|
486
|
+
.where("id", "=", input.absenceId)
|
|
487
|
+
.forUpdate()
|
|
488
|
+
.executeTakeFirst();
|
|
489
|
+
if (absence === undefined) {
|
|
490
|
+
throw new ReviewerAvailabilityValidationError("Mutation intent recovery absence source is unavailable");
|
|
491
|
+
}
|
|
492
|
+
const existing = await trx.selectFrom("reviewer_replacements")
|
|
493
|
+
.selectAll()
|
|
494
|
+
.where("workspace_id", "=", workspaceId)
|
|
495
|
+
.where("provider", "=", input.provider)
|
|
496
|
+
.where("provider_connection_id", "=", input.providerConnectionId)
|
|
497
|
+
.where("absence_id", "=", input.absenceId)
|
|
498
|
+
.where("absence_revision", "=", input.absenceRevision)
|
|
499
|
+
.where("decision_id", "=", input.decisionId)
|
|
500
|
+
.forUpdate()
|
|
501
|
+
.executeTakeFirst();
|
|
502
|
+
if (existing !== undefined) {
|
|
503
|
+
assertReplacementRetryMatches(existing, input);
|
|
504
|
+
const hasPersistedEvent = await assertReplacementEventRetryMatches(trx, workspaceId, existing.id, input.event);
|
|
505
|
+
if (hasPersistedEvent)
|
|
506
|
+
await stagePlatformEvent(trx, workspaceId, existing.id, input.event);
|
|
507
|
+
return { inserted: false, activationCurrent: true, replacement: toReplacement(existing) };
|
|
508
|
+
}
|
|
509
|
+
await assertReplacementMutationIntent(trx, workspaceId, input);
|
|
510
|
+
const inserted = await trx.insertInto("reviewer_replacements").values({
|
|
511
|
+
workspace_id: workspaceId,
|
|
512
|
+
provider: input.provider,
|
|
513
|
+
provider_connection_id: input.providerConnectionId,
|
|
514
|
+
absence_id: input.absenceId,
|
|
515
|
+
absence_revision: input.absenceRevision,
|
|
516
|
+
decision_id: input.decisionId,
|
|
517
|
+
unavailable_actor_id: input.unavailableActorId,
|
|
518
|
+
replacement_actor_id: null,
|
|
519
|
+
mutation_intent_id: input.mutationIntentId,
|
|
520
|
+
outcome: "permanent_failure",
|
|
521
|
+
reason: input.reason,
|
|
522
|
+
state: "permanent_failure",
|
|
523
|
+
last_error: input.lastError,
|
|
524
|
+
started_at: input.startedAt,
|
|
525
|
+
completed_at: input.completedAt,
|
|
526
|
+
}).returningAll().executeTakeFirstOrThrow();
|
|
527
|
+
await stagePlatformEvent(trx, workspaceId, inserted.id, input.event);
|
|
528
|
+
return { inserted: true, activationCurrent: true, replacement: toReplacement(inserted) };
|
|
529
|
+
}
|
|
530
|
+
function isUnknownRecord(value) {
|
|
531
|
+
return typeof value === "object" && value !== null;
|
|
532
|
+
}
|
|
533
|
+
function hydrateDate(value) {
|
|
534
|
+
const date = value instanceof Date ? value : typeof value === "string" ? new Date(value) : new Date(Number.NaN);
|
|
535
|
+
validateDate(date, "recovery timestamp");
|
|
536
|
+
return date;
|
|
537
|
+
}
|
|
538
|
+
async function ensureWorkspaceSettings(db, workspaceId) {
|
|
539
|
+
return await db
|
|
540
|
+
.insertInto("workspace_operational_settings")
|
|
541
|
+
.values({ workspace_id: workspaceId, timezone: "UTC" })
|
|
542
|
+
.onConflict((conflict) => conflict.column("workspace_id").doUpdateSet({
|
|
543
|
+
workspace_id: workspaceId,
|
|
544
|
+
}))
|
|
545
|
+
.returningAll()
|
|
546
|
+
.executeTakeFirstOrThrow();
|
|
547
|
+
}
|
|
548
|
+
async function findActiveConnection(db, workspaceId, providerConnectionId, provider) {
|
|
549
|
+
let query = db
|
|
550
|
+
.selectFrom("provider_connections")
|
|
551
|
+
.select(["id", "provider"])
|
|
552
|
+
.where("workspace_id", "=", workspaceId)
|
|
553
|
+
.where("id", "=", providerConnectionId)
|
|
554
|
+
.where("status", "=", "active");
|
|
555
|
+
if (provider !== undefined)
|
|
556
|
+
query = query.where("provider", "=", provider);
|
|
557
|
+
return await query.executeTakeFirst() ?? null;
|
|
558
|
+
}
|
|
559
|
+
async function requireActiveConnection(db, workspaceId, providerConnectionId, provider) {
|
|
560
|
+
const connection = await findActiveConnection(db, workspaceId, providerConnectionId, provider);
|
|
561
|
+
if (connection === null) {
|
|
562
|
+
throw new ProviderConnectionUnavailableError("Provider connection is not active in this workspace");
|
|
563
|
+
}
|
|
564
|
+
return connection;
|
|
565
|
+
}
|
|
566
|
+
async function lockCurrentAbsence(trx, workspaceId, input) {
|
|
567
|
+
const absence = await trx
|
|
568
|
+
.selectFrom("reviewer_absences")
|
|
569
|
+
.selectAll()
|
|
570
|
+
.where("workspace_id", "=", workspaceId)
|
|
571
|
+
.where("provider", "=", input.provider)
|
|
572
|
+
.where("provider_connection_id", "=", input.providerConnectionId)
|
|
573
|
+
.where("id", "=", input.absenceId)
|
|
574
|
+
.forUpdate()
|
|
575
|
+
.executeTakeFirst();
|
|
576
|
+
if (absence === undefined || absence.revision !== input.expectedRevision || absence.status !== "scheduled") {
|
|
577
|
+
throw new ReviewerAbsenceRevisionError("Reviewer absence revision is stale");
|
|
578
|
+
}
|
|
579
|
+
return absence;
|
|
580
|
+
}
|
|
581
|
+
async function lockActorScopes(trx, workspaceId, provider, providerConnectionId, actors) {
|
|
582
|
+
for (const actor of [...new Set(actors)].sort()) {
|
|
583
|
+
const scope = `${workspaceId}:${provider}:${providerConnectionId}:${actor}`;
|
|
584
|
+
await sql `select pg_advisory_xact_lock(hashtextextended(${scope}, 182736154))`.execute(trx);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
async function enqueueActivation(trx, workspaceId, absence, requestedRunAt, now) {
|
|
588
|
+
const payload = {
|
|
589
|
+
kind: "activate_reviewer_absence",
|
|
590
|
+
workspaceId,
|
|
591
|
+
providerConnectionId: absence.provider_connection_id,
|
|
592
|
+
absenceId: absence.id,
|
|
593
|
+
absenceRevision: absence.revision,
|
|
594
|
+
};
|
|
595
|
+
const runAt = requestedRunAt > now ? requestedRunAt : now;
|
|
596
|
+
const idempotencyKey = buildReviewerAbsenceActivationKey(absence.id, absence.revision);
|
|
597
|
+
const inserted = await trx
|
|
598
|
+
.insertInto("jobs")
|
|
599
|
+
.values({
|
|
600
|
+
workspace_id: workspaceId,
|
|
601
|
+
provider: absence.provider,
|
|
602
|
+
provider_connection_id: absence.provider_connection_id,
|
|
603
|
+
kind: payload.kind,
|
|
604
|
+
payload,
|
|
605
|
+
idempotency_key: idempotencyKey,
|
|
606
|
+
run_at: runAt,
|
|
607
|
+
})
|
|
608
|
+
.onConflict((conflict) => conflict.columns(["workspace_id", "idempotency_key"]).doNothing())
|
|
609
|
+
.returning("id")
|
|
610
|
+
.executeTakeFirst();
|
|
611
|
+
if (inserted !== undefined)
|
|
612
|
+
return;
|
|
613
|
+
const existing = await trx
|
|
614
|
+
.selectFrom("jobs")
|
|
615
|
+
.select(["provider", "provider_connection_id", "kind", "payload", "run_at"])
|
|
616
|
+
.where("workspace_id", "=", workspaceId)
|
|
617
|
+
.where("idempotency_key", "=", idempotencyKey)
|
|
618
|
+
.forUpdate()
|
|
619
|
+
.executeTakeFirst();
|
|
620
|
+
if (existing === undefined
|
|
621
|
+
|| existing.provider !== absence.provider
|
|
622
|
+
|| existing.provider_connection_id !== absence.provider_connection_id
|
|
623
|
+
|| existing.kind !== payload.kind
|
|
624
|
+
|| existing.run_at.getTime() !== runAt.getTime()
|
|
625
|
+
|| !isDeepStrictEqual(existing.payload, payload))
|
|
626
|
+
throw new Error("reviewer absence activation key conflicts with a different job");
|
|
627
|
+
}
|
|
628
|
+
async function listReplacementHistory(db, workspaceId, absenceId, absenceRevision, state) {
|
|
629
|
+
let query = db
|
|
630
|
+
.selectFrom("reviewer_replacements")
|
|
631
|
+
.selectAll()
|
|
632
|
+
.where("workspace_id", "=", workspaceId);
|
|
633
|
+
if (absenceId !== undefined)
|
|
634
|
+
query = query.where("absence_id", "=", absenceId);
|
|
635
|
+
if (absenceRevision !== undefined)
|
|
636
|
+
query = query.where("absence_revision", "=", absenceRevision);
|
|
637
|
+
if (state !== undefined)
|
|
638
|
+
query = query.where("state", "=", state);
|
|
639
|
+
const rows = await query
|
|
640
|
+
.orderBy("completed_at", "desc")
|
|
641
|
+
.orderBy("id", "desc")
|
|
642
|
+
.execute();
|
|
643
|
+
return rows.map(toReplacement);
|
|
644
|
+
}
|
|
645
|
+
function validateAbsenceMutation(input) {
|
|
646
|
+
const externalActorId = parseExternalActorId(input.externalActorId);
|
|
647
|
+
if (externalActorId === null) {
|
|
648
|
+
throw new ReviewerAvailabilityValidationError("External actor identifier must not be empty");
|
|
649
|
+
}
|
|
650
|
+
validateDate(input.startAt, "startAt");
|
|
651
|
+
validateDate(input.endAt, "endAt");
|
|
652
|
+
validateDate(input.now, "now");
|
|
653
|
+
if (input.endAt <= input.startAt) {
|
|
654
|
+
throw new ReviewerAvailabilityValidationError("Absence end must be after its start");
|
|
655
|
+
}
|
|
656
|
+
return { ...input, externalActorId };
|
|
657
|
+
}
|
|
658
|
+
function validateReplacementInput(input, workspaceId) {
|
|
659
|
+
validateDate(input.startedAt, "startedAt");
|
|
660
|
+
validateDate(input.completedAt, "completedAt");
|
|
661
|
+
validateExpectedRevision(input.absenceRevision);
|
|
662
|
+
validateReplacementState(input.state);
|
|
663
|
+
validateStateError(input.state, input.lastError);
|
|
664
|
+
if (input.completedAt < input.startedAt) {
|
|
665
|
+
throw new ReviewerAvailabilityValidationError("Replacement completion cannot precede its start");
|
|
666
|
+
}
|
|
667
|
+
const unavailableActorId = parseExternalActorId(input.unavailableActorId);
|
|
668
|
+
const replacementActorId = input.replacementActorId === null
|
|
669
|
+
? null
|
|
670
|
+
: parseExternalActorId(input.replacementActorId);
|
|
671
|
+
if (unavailableActorId === null
|
|
672
|
+
|| unavailableActorId !== input.unavailableActorId
|
|
673
|
+
|| replacementActorId !== input.replacementActorId)
|
|
674
|
+
throw new ReviewerAvailabilityValidationError("Replacement actor identifiers must not be empty");
|
|
675
|
+
if ((input.mutationIntentId !== null && !isNonBlank(input.mutationIntentId))
|
|
676
|
+
|| (input.outcome === "replaced" && input.mutationIntentId === null)
|
|
677
|
+
|| (input.outcome === "simulated_replacement" && input.mutationIntentId !== null))
|
|
678
|
+
throw new ReviewerAvailabilityValidationError("Replacement mutation intent identifier is malformed");
|
|
679
|
+
const expectedState = input.outcome === "permanent_failure"
|
|
680
|
+
? "permanent_failure"
|
|
681
|
+
: hasMappedFinalizer(input.outcome)
|
|
682
|
+
? "finalizer_pending"
|
|
683
|
+
: "completed";
|
|
684
|
+
if (input.state !== expectedState) {
|
|
685
|
+
throw new ReviewerAvailabilityValidationError("Replacement state does not match outcome finalization");
|
|
686
|
+
}
|
|
687
|
+
if (input.event.workspaceId !== workspaceId
|
|
688
|
+
|| input.event.provider !== input.provider
|
|
689
|
+
|| input.event.providerConnectionId !== input.providerConnectionId
|
|
690
|
+
|| input.event.absenceId !== input.absenceId
|
|
691
|
+
|| input.event.absenceRevision !== input.absenceRevision
|
|
692
|
+
|| input.event.decisionId !== input.decisionId
|
|
693
|
+
|| input.event.unavailableActor !== input.unavailableActorId
|
|
694
|
+
|| input.event.replacementActor !== input.replacementActorId
|
|
695
|
+
|| input.event.outcome !== input.outcome
|
|
696
|
+
|| new Date(input.event.occurredAt).getTime() !== input.completedAt.getTime()) {
|
|
697
|
+
throw new ReviewerAvailabilityValidationError("Replacement event does not match replacement input");
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
function validateTimezone(timezone) {
|
|
701
|
+
const supported = timezone === "UTC"
|
|
702
|
+
|| (typeof Intl.supportedValuesOf === "function" && Intl.supportedValuesOf("timeZone").includes(timezone));
|
|
703
|
+
if (!supported) {
|
|
704
|
+
throw new ReviewerAvailabilityValidationError("Timezone must be UTC or a canonical IANA timezone identifier");
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
function validateDate(value, field) {
|
|
708
|
+
if (!(value instanceof Date) || !Number.isFinite(value.getTime())) {
|
|
709
|
+
throw new ReviewerAvailabilityValidationError(`${field} must be a finite date`);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
function validateExpectedRevision(value) {
|
|
713
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
714
|
+
throw new ReviewerAvailabilityValidationError("Revision must be a positive integer");
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
function validateReplacementState(value) {
|
|
718
|
+
if (value !== "finalizer_pending" && value !== "completed" && value !== "permanent_failure") {
|
|
719
|
+
throw new ReviewerAvailabilityValidationError("Unknown reviewer replacement state");
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
function validateStateError(state, lastError) {
|
|
723
|
+
if (state === "completed" && lastError !== null) {
|
|
724
|
+
throw new ReviewerAvailabilityValidationError("Completed replacements cannot retain a finalizer error");
|
|
725
|
+
}
|
|
726
|
+
if (state === "permanent_failure" && (lastError === null || lastError.trim() === "")) {
|
|
727
|
+
throw new ReviewerAvailabilityValidationError("Permanent finalizer failure requires an error");
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
function validActorList(actors) {
|
|
731
|
+
return [...new Set(actors.map(parseExternalActorId).filter((actor) => actor !== null))];
|
|
732
|
+
}
|
|
733
|
+
function replacesCohort(outcome) {
|
|
734
|
+
return outcome === "replaced" || outcome === "simulated_replacement";
|
|
735
|
+
}
|
|
736
|
+
function hasMappedFinalizer(outcome) {
|
|
737
|
+
return outcome === "replaced"
|
|
738
|
+
|| outcome === "skipped_policy_satisfied"
|
|
739
|
+
|| outcome === "no_replacement_available";
|
|
740
|
+
}
|
|
741
|
+
function staleReplacementResult() {
|
|
742
|
+
return { inserted: false, activationCurrent: false, replacement: null };
|
|
743
|
+
}
|
|
744
|
+
function assertReplacementRetryMatches(existing, input) {
|
|
745
|
+
if (existing.unavailable_actor_id !== input.unavailableActorId
|
|
746
|
+
|| existing.replacement_actor_id !== input.replacementActorId
|
|
747
|
+
|| existing.mutation_intent_id !== input.mutationIntentId
|
|
748
|
+
|| existing.outcome !== input.outcome
|
|
749
|
+
|| existing.reason !== input.reason
|
|
750
|
+
|| existing.started_at.getTime() !== input.startedAt.getTime()
|
|
751
|
+
|| existing.completed_at.getTime() !== input.completedAt.getTime())
|
|
752
|
+
throw new Error("reviewer replacement retry conflicts with persisted history");
|
|
753
|
+
}
|
|
754
|
+
async function assertReplacementEventRetryMatches(trx, workspaceId, replacementId, event) {
|
|
755
|
+
const existing = await trx
|
|
756
|
+
.selectFrom("decision_outbox")
|
|
757
|
+
.select(["event_id", "event_type", "schema_version", "payload", "occurred_at"])
|
|
758
|
+
.where("workspace_id", "=", workspaceId)
|
|
759
|
+
.where("reviewer_replacement_id", "=", replacementId)
|
|
760
|
+
.forUpdate()
|
|
761
|
+
.limit(2)
|
|
762
|
+
.execute();
|
|
763
|
+
if (existing.length === 0)
|
|
764
|
+
return false;
|
|
765
|
+
if (existing.length !== 1
|
|
766
|
+
|| existing[0].event_id !== event.eventId
|
|
767
|
+
|| existing[0].event_type !== event.eventType
|
|
768
|
+
|| existing[0].schema_version !== event.schemaVersion
|
|
769
|
+
|| existing[0].occurred_at.getTime() !== new Date(event.occurredAt).getTime()
|
|
770
|
+
|| !isDeepStrictEqual(existing[0].payload, event))
|
|
771
|
+
throw new Error("reviewer replacement retry conflicts with persisted platform event");
|
|
772
|
+
return true;
|
|
773
|
+
}
|
|
774
|
+
function translateAbsenceConflict(error) {
|
|
775
|
+
if (typeof error === "object"
|
|
776
|
+
&& error !== null
|
|
777
|
+
&& "constraint" in error
|
|
778
|
+
&& error.constraint === "reviewer_absences_no_overlap")
|
|
779
|
+
return new ReviewerAbsenceConflictError("Reviewer absence overlaps an existing scheduled interval");
|
|
780
|
+
return error instanceof Error ? error : new Error("Unable to persist reviewer absence");
|
|
781
|
+
}
|
|
782
|
+
function toSettings(row) {
|
|
783
|
+
return { workspaceId: row.workspace_id, timezone: row.timezone, updatedAt: row.updated_at };
|
|
784
|
+
}
|
|
785
|
+
function toAbsence(row) {
|
|
786
|
+
return {
|
|
787
|
+
id: row.id,
|
|
788
|
+
workspaceId: row.workspace_id,
|
|
789
|
+
provider: row.provider,
|
|
790
|
+
providerConnectionId: row.provider_connection_id,
|
|
791
|
+
externalActorId: row.external_actor_id,
|
|
792
|
+
startAt: row.start_at,
|
|
793
|
+
endAt: row.end_at,
|
|
794
|
+
status: row.status,
|
|
795
|
+
revision: row.revision,
|
|
796
|
+
cancelledAt: row.cancelled_at,
|
|
797
|
+
createdAt: row.created_at,
|
|
798
|
+
updatedAt: row.updated_at,
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
function toReplacement(row) {
|
|
802
|
+
validateReplacementState(row.state);
|
|
803
|
+
return {
|
|
804
|
+
id: row.id,
|
|
805
|
+
workspaceId: row.workspace_id,
|
|
806
|
+
provider: row.provider,
|
|
807
|
+
providerConnectionId: row.provider_connection_id,
|
|
808
|
+
absenceId: row.absence_id,
|
|
809
|
+
absenceRevision: row.absence_revision,
|
|
810
|
+
decisionId: row.decision_id,
|
|
811
|
+
unavailableActorId: row.unavailable_actor_id,
|
|
812
|
+
replacementActorId: row.replacement_actor_id,
|
|
813
|
+
mutationIntentId: row.mutation_intent_id,
|
|
814
|
+
outcome: row.outcome,
|
|
815
|
+
reason: row.reason,
|
|
816
|
+
state: row.state,
|
|
817
|
+
lastError: row.last_error,
|
|
818
|
+
startedAt: row.started_at,
|
|
819
|
+
completedAt: row.completed_at,
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
export async function prepareMutationIntentTransaction(trx, workspaceId, input) {
|
|
823
|
+
validateMutationIntentInput(input, workspaceId);
|
|
824
|
+
await requireActiveConnection(trx, workspaceId, input.providerConnectionId, input.provider);
|
|
825
|
+
const absence = await trx.selectFrom("reviewer_absences")
|
|
826
|
+
.select(["revision", "status", "external_actor_id"])
|
|
827
|
+
.where("workspace_id", "=", workspaceId).where("provider", "=", input.provider)
|
|
828
|
+
.where("provider_connection_id", "=", input.providerConnectionId).where("id", "=", input.absenceId)
|
|
829
|
+
.forUpdate().executeTakeFirst();
|
|
830
|
+
if (absence === undefined || absence.revision !== input.absenceRevision || absence.status !== "scheduled"
|
|
831
|
+
|| absence.external_actor_id !== input.unavailableActorId) {
|
|
832
|
+
throw new ReviewerAvailabilityValidationError("Reviewer mutation intent source is not current");
|
|
833
|
+
}
|
|
834
|
+
const source = await trx.selectFrom("routing_decisions")
|
|
835
|
+
.innerJoin("repositories", (join) => join
|
|
836
|
+
.onRef("repositories.workspace_id", "=", "routing_decisions.workspace_id")
|
|
837
|
+
.onRef("repositories.id", "=", "routing_decisions.repository_id"))
|
|
838
|
+
.select([
|
|
839
|
+
"routing_decisions.change_request_id as changeRequestId",
|
|
840
|
+
"routing_decisions.head_sha as expectedHeadRevision", "routing_decisions.selected_reviewers as selectedActors",
|
|
841
|
+
"routing_decisions.details as decisionDetails", "repositories.provider",
|
|
842
|
+
"repositories.provider_connection_id as providerConnectionId", "repositories.id as repositoryRecordId",
|
|
843
|
+
"repositories.external_repository_id as repositoryId",
|
|
844
|
+
])
|
|
845
|
+
.where("routing_decisions.workspace_id", "=", workspaceId)
|
|
846
|
+
.where("routing_decisions.id", "=", input.decisionId)
|
|
847
|
+
.forUpdate("routing_decisions")
|
|
848
|
+
.executeTakeFirst();
|
|
849
|
+
const selectedActors = parseStrictActorList(source?.selectedActors);
|
|
850
|
+
const original = parseOriginalReviewerPool(source?.decisionDetails);
|
|
851
|
+
if (source === undefined || source.provider !== input.provider || source.providerConnectionId !== input.providerConnectionId
|
|
852
|
+
|| source.repositoryId !== input.repositoryId || source.changeRequestId !== input.changeRequestId
|
|
853
|
+
|| source.expectedHeadRevision !== input.expectedHeadRevision || selectedActors === null || original === null
|
|
854
|
+
|| !selectedActors.includes(input.unavailableActorId) || selectedActors.includes(input.replacementActorId)
|
|
855
|
+
|| !original.eligibleActors.includes(input.replacementActorId)
|
|
856
|
+
|| input.replacementActorId === input.unavailableActorId) {
|
|
857
|
+
throw new ReviewerAvailabilityValidationError("Reviewer mutation intent source is not current");
|
|
858
|
+
}
|
|
859
|
+
const terminalHistory = await trx.selectFrom("reviewer_replacements")
|
|
860
|
+
.select(["id", "mutation_intent_id"])
|
|
861
|
+
.where("workspace_id", "=", workspaceId).where("provider", "=", input.provider)
|
|
862
|
+
.where("provider_connection_id", "=", input.providerConnectionId).where("absence_id", "=", input.absenceId)
|
|
863
|
+
.where("absence_revision", "=", input.absenceRevision).where("decision_id", "=", input.decisionId)
|
|
864
|
+
.forUpdate().executeTakeFirst();
|
|
865
|
+
if (terminalHistory !== undefined) {
|
|
866
|
+
if (terminalHistory.mutation_intent_id !== null) {
|
|
867
|
+
const durable = await mutationIntentQuery(trx, workspaceId, input).executeTakeFirst();
|
|
868
|
+
if (durable !== undefined && durable.id === terminalHistory.mutation_intent_id) {
|
|
869
|
+
assertMutationIntentMatches(durable, input);
|
|
870
|
+
return toMutationIntent(durable);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
throw new ReviewerAvailabilityValidationError("Reviewer replacement history is already terminal for this source");
|
|
874
|
+
}
|
|
875
|
+
const inserted = await trx.insertInto("reviewer_mutation_intents").values({
|
|
876
|
+
workspace_id: workspaceId, provider: input.provider, provider_connection_id: input.providerConnectionId,
|
|
877
|
+
absence_id: input.absenceId, absence_revision: input.absenceRevision, decision_id: input.decisionId,
|
|
878
|
+
repository_record_id: source.repositoryRecordId, repository_id: input.repositoryId,
|
|
879
|
+
change_request_id: input.changeRequestId, expected_head_revision: input.expectedHeadRevision,
|
|
880
|
+
unavailable_actor_id: input.unavailableActorId, replacement_actor_id: input.replacementActorId,
|
|
881
|
+
}).onConflict((conflict) => conflict.columns([
|
|
882
|
+
"workspace_id", "provider_connection_id", "absence_id", "absence_revision", "decision_id",
|
|
883
|
+
]).doNothing()).returningAll().executeTakeFirst();
|
|
884
|
+
const row = inserted ?? await mutationIntentQuery(trx, workspaceId, input).forUpdate().executeTakeFirstOrThrow();
|
|
885
|
+
assertMutationIntentMatches(row, input);
|
|
886
|
+
return toMutationIntent(row);
|
|
887
|
+
}
|
|
888
|
+
function validateMutationIntentKey(input, workspaceId) {
|
|
889
|
+
validateExpectedRevision(input.absenceRevision);
|
|
890
|
+
if (input.workspaceId !== workspaceId
|
|
891
|
+
|| !isNonBlank(input.providerConnectionId)
|
|
892
|
+
|| !isNonBlank(input.absenceId)
|
|
893
|
+
|| !isNonBlank(input.decisionId))
|
|
894
|
+
throw new ReviewerAvailabilityValidationError("Reviewer mutation intent key is malformed");
|
|
895
|
+
}
|
|
896
|
+
function validateMutationIntentInput(input, workspaceId) {
|
|
897
|
+
validateMutationIntentKey(input, workspaceId);
|
|
898
|
+
if (!isNonBlank(input.repositoryId)
|
|
899
|
+
|| !isNonBlank(input.changeRequestId)
|
|
900
|
+
|| !isNonBlank(input.expectedHeadRevision)
|
|
901
|
+
|| !isNonBlank(input.unavailableActorId)
|
|
902
|
+
|| !isNonBlank(input.replacementActorId))
|
|
903
|
+
throw new ReviewerAvailabilityValidationError("Reviewer mutation intent is malformed");
|
|
904
|
+
}
|
|
905
|
+
function mutationIntentQuery(db, workspaceId, input) {
|
|
906
|
+
return db.selectFrom("reviewer_mutation_intents")
|
|
907
|
+
.selectAll()
|
|
908
|
+
.where("workspace_id", "=", workspaceId)
|
|
909
|
+
.where("provider_connection_id", "=", input.providerConnectionId)
|
|
910
|
+
.where("absence_id", "=", input.absenceId)
|
|
911
|
+
.where("absence_revision", "=", input.absenceRevision)
|
|
912
|
+
.where("decision_id", "=", input.decisionId);
|
|
913
|
+
}
|
|
914
|
+
function assertMutationIntentMatches(row, input) {
|
|
915
|
+
if (row.workspace_id !== input.workspaceId
|
|
916
|
+
|| row.provider !== input.provider
|
|
917
|
+
|| row.provider_connection_id !== input.providerConnectionId
|
|
918
|
+
|| row.absence_id !== input.absenceId
|
|
919
|
+
|| row.absence_revision !== input.absenceRevision
|
|
920
|
+
|| row.decision_id !== input.decisionId
|
|
921
|
+
|| row.repository_id !== input.repositoryId
|
|
922
|
+
|| row.change_request_id !== input.changeRequestId
|
|
923
|
+
|| row.expected_head_revision !== input.expectedHeadRevision
|
|
924
|
+
|| row.unavailable_actor_id !== input.unavailableActorId
|
|
925
|
+
|| row.replacement_actor_id !== input.replacementActorId)
|
|
926
|
+
throw new ReviewerAvailabilityValidationError("Preparation conflicts with persisted reviewer mutation intent");
|
|
927
|
+
}
|
|
928
|
+
async function assertReplacementMutationIntent(trx, workspaceId, input) {
|
|
929
|
+
if (input.mutationIntentId === null)
|
|
930
|
+
return;
|
|
931
|
+
const row = await mutationIntentQuery(trx, workspaceId, { ...input, workspaceId })
|
|
932
|
+
.where("id", "=", input.mutationIntentId)
|
|
933
|
+
.executeTakeFirst();
|
|
934
|
+
if (row === undefined) {
|
|
935
|
+
throw new ReviewerAvailabilityValidationError("Replacement mutation intent linkage is unavailable");
|
|
936
|
+
}
|
|
937
|
+
assertMutationIntentMatches(row, {
|
|
938
|
+
workspaceId,
|
|
939
|
+
provider: input.provider,
|
|
940
|
+
providerConnectionId: input.providerConnectionId,
|
|
941
|
+
absenceId: input.absenceId,
|
|
942
|
+
absenceRevision: input.absenceRevision,
|
|
943
|
+
decisionId: input.decisionId,
|
|
944
|
+
repositoryId: input.event.repositoryId,
|
|
945
|
+
changeRequestId: input.event.changeRequestId,
|
|
946
|
+
expectedHeadRevision: input.expectedHeadRevision,
|
|
947
|
+
unavailableActorId: input.unavailableActorId,
|
|
948
|
+
replacementActorId: input.outcome === "replaced"
|
|
949
|
+
? input.replacementActorId
|
|
950
|
+
: row.replacement_actor_id,
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
function toMutationIntent(row) {
|
|
954
|
+
if (!isNonBlank(row.id)) {
|
|
955
|
+
throw new ReviewerAvailabilityValidationError("Persisted reviewer mutation intent ID is malformed");
|
|
956
|
+
}
|
|
957
|
+
return {
|
|
958
|
+
id: row.id,
|
|
959
|
+
workspaceId: row.workspace_id,
|
|
960
|
+
provider: row.provider,
|
|
961
|
+
providerConnectionId: row.provider_connection_id,
|
|
962
|
+
absenceId: row.absence_id,
|
|
963
|
+
absenceRevision: row.absence_revision,
|
|
964
|
+
decisionId: row.decision_id,
|
|
965
|
+
repositoryId: row.repository_id,
|
|
966
|
+
changeRequestId: row.change_request_id,
|
|
967
|
+
expectedHeadRevision: row.expected_head_revision,
|
|
968
|
+
unavailableActorId: row.unavailable_actor_id,
|
|
969
|
+
replacementActorId: row.replacement_actor_id,
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
function isNonBlank(value) {
|
|
973
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
974
|
+
}
|