@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.
Files changed (43) hide show
  1. package/LICENSE +105 -0
  2. package/dist/availability.d.ts +164 -0
  3. package/dist/availability.js +974 -0
  4. package/dist/database.d.ts +3 -0
  5. package/dist/database.js +9 -0
  6. package/dist/decisions.d.ts +107 -0
  7. package/dist/decisions.js +435 -0
  8. package/dist/deliveries.d.ts +30 -0
  9. package/dist/deliveries.js +82 -0
  10. package/dist/heartbeat.d.ts +11 -0
  11. package/dist/heartbeat.js +11 -0
  12. package/dist/index.d.ts +14 -0
  13. package/dist/index.js +13 -0
  14. package/dist/jobs.d.ts +86 -0
  15. package/dist/jobs.js +641 -0
  16. package/dist/kysely.d.ts +202 -0
  17. package/dist/kysely.js +1 -0
  18. package/dist/migrate.d.ts +1 -0
  19. package/dist/migrate.js +45 -0
  20. package/dist/operations.d.ts +77 -0
  21. package/dist/operations.js +256 -0
  22. package/dist/outbox.d.ts +61 -0
  23. package/dist/outbox.js +230 -0
  24. package/dist/provider-connections.d.ts +34 -0
  25. package/dist/provider-connections.js +302 -0
  26. package/dist/retention.d.ts +6 -0
  27. package/dist/retention.js +44 -0
  28. package/dist/routing-recovery.d.ts +55 -0
  29. package/dist/routing-recovery.js +175 -0
  30. package/dist/workspaces.d.ts +50 -0
  31. package/dist/workspaces.js +43 -0
  32. package/migrations/0001_initial.sql +79 -0
  33. package/migrations/0002_selected_reviewers.sql +10 -0
  34. package/migrations/0003_human_review_policy.sql +10 -0
  35. package/migrations/0004_semantic_routing_deduplication.sql +15 -0
  36. package/migrations/0005_reviewer_availability.sql +58 -0
  37. package/migrations/0005_workspace_scope.sql +198 -0
  38. package/migrations/0006_decision_outbox.sql +24 -0
  39. package/migrations/0007_workspace_reviewer_availability.sql +192 -0
  40. package/migrations/0008_reviewer_mutation_intents.sql +160 -0
  41. package/migrations/0009_provider_connection_revocations.sql +18 -0
  42. package/migrations/0010_provider_connection_preemptive_revocations.sql +12 -0
  43. 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
+ }