ghagga-db 3.3.0 → 3.5.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/dist/queries.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { and, asc, desc, eq, gt, inArray, or, sql } from 'drizzle-orm';
3
- import { DEFAULT_REPO_SETTINGS, githubUserMappings, installationSettings, installations, memoryObservations, memorySessions, repositories, reviews, } from './schema.js';
2
+ import { and, asc, desc, eq, gt, inArray, isNotNull, isNull, lt, or, sql, } from 'drizzle-orm';
3
+ import { DEFAULT_REPO_SETTINGS, explanationInvocations, githubUserMappings, installationSettings, installations, issueDrafts, memoryObservations, memorySessions, repositories, reviews, } from './schema.js';
4
4
  // ─── Installations ──────────────────────────────────────────────
5
5
  export async function upsertInstallation(db, data) {
6
6
  const existing = await db
@@ -106,13 +106,20 @@ export async function getInstallationById(db, installationId) {
106
106
  .limit(1);
107
107
  return row ?? null;
108
108
  }
109
+ function resolveEffectiveRepoSettings(settings) {
110
+ const selectedSettings = (settings ?? DEFAULT_REPO_SETTINGS);
111
+ return {
112
+ ...selectedSettings,
113
+ explanationsEnabled: selectedSettings.explanationsEnabled ?? false,
114
+ };
115
+ }
109
116
  export async function getEffectiveRepoSettings(db, repo) {
110
117
  if (!repo.useGlobalSettings) {
111
118
  return {
112
119
  providerChain: (repo.providerChain ?? []),
113
120
  aiReviewEnabled: repo.aiReviewEnabled,
114
121
  reviewMode: repo.reviewMode,
115
- settings: (repo.settings ?? DEFAULT_REPO_SETTINGS),
122
+ settings: resolveEffectiveRepoSettings(repo.settings),
116
123
  source: 'repo',
117
124
  };
118
125
  }
@@ -122,7 +129,7 @@ export async function getEffectiveRepoSettings(db, repo) {
122
129
  providerChain: (globalSettings.providerChain ?? []),
123
130
  aiReviewEnabled: globalSettings.aiReviewEnabled,
124
131
  reviewMode: globalSettings.reviewMode,
125
- settings: (globalSettings.settings ?? DEFAULT_REPO_SETTINGS),
132
+ settings: resolveEffectiveRepoSettings(globalSettings.settings),
126
133
  source: 'global',
127
134
  };
128
135
  }
@@ -131,7 +138,7 @@ export async function getEffectiveRepoSettings(db, repo) {
131
138
  providerChain: [],
132
139
  aiReviewEnabled: true,
133
140
  reviewMode: 'simple',
134
- settings: DEFAULT_REPO_SETTINGS,
141
+ settings: resolveEffectiveRepoSettings(DEFAULT_REPO_SETTINGS),
135
142
  source: 'global',
136
143
  };
137
144
  }
@@ -222,6 +229,212 @@ export async function saveReview(db, data) {
222
229
  // biome-ignore lint/style/noNonNullAssertion: drizzle .returning() always returns for insert/update
223
230
  return result;
224
231
  }
232
+ // ─── Issue Drafts (Issue-Triage Agent) ──────────────────────────
233
+ /**
234
+ * Persist a triage DRAFT produced by the issue-analysis worker.
235
+ *
236
+ * The worker NEVER posts a GitHub comment — it only inserts a DRAFT row that a
237
+ * human later approves in the dashboard. The `issue_drafts` table enforces a
238
+ * partial-unique invariant: AT MOST ONE open DRAFT per (repository, issue)
239
+ * (uq_issue_drafts_open_draft, predicate `status = 'DRAFT'`).
240
+ *
241
+ * Conflict handling (DECISION): we `onConflictDoNothing` on that partial-unique
242
+ * index. If a DRAFT for this (repo, issue) is already open, the insert is a
243
+ * no-op and this returns `undefined` — the caller treats that as "a draft
244
+ * already exists, skip" rather than overwriting an in-flight human review or
245
+ * throwing. Re-triaging is allowed only after the prior draft is decided
246
+ * (APPROVED/REJECTED/POSTED rows fall outside the partial predicate).
247
+ *
248
+ * @returns the inserted row, or `undefined` when an open DRAFT already existed.
249
+ */
250
+ export async function saveIssueDraft(db, data) {
251
+ const [result] = await db
252
+ .insert(issueDrafts)
253
+ .values(data)
254
+ // Target the partial-unique "one open DRAFT per (repo, issue)" index. The
255
+ // WHERE mirrors the index predicate so Postgres matches the SAME partial
256
+ // index. On conflict: do nothing (skip) — returning() yields no row.
257
+ .onConflictDoNothing({
258
+ target: [issueDrafts.repositoryId, issueDrafts.issueNumber],
259
+ where: sql `${issueDrafts.status} = 'DRAFT'`,
260
+ })
261
+ .returning();
262
+ return result;
263
+ }
264
+ /**
265
+ * Fetch the currently-open DRAFT (if any) for a given (repository, issue).
266
+ *
267
+ * Mirrors the partial-unique invariant enforced by `uq_issue_drafts_open_draft`
268
+ * (predicate `status = 'DRAFT'`): at most one open DRAFT exists per (repo,
269
+ * issue), so this returns either that single row or `undefined`.
270
+ *
271
+ * The issue-analysis worker calls this BEFORE the expensive dedup/LLM stages so
272
+ * a retry (or a re-triage while a human is still reviewing the prior draft) can
273
+ * skip early instead of re-charging the LLM and then silently discarding the
274
+ * result at the onConflictDoNothing insert.
275
+ *
276
+ * @returns the open DRAFT row, or `undefined` when none is open.
277
+ */
278
+ export async function getOpenIssueDraft(db, repositoryId, issueNumber) {
279
+ const [row] = await db
280
+ .select()
281
+ .from(issueDrafts)
282
+ .where(and(eq(issueDrafts.repositoryId, repositoryId), eq(issueDrafts.issueNumber, issueNumber), eq(issueDrafts.status, 'DRAFT')))
283
+ .limit(1);
284
+ return row;
285
+ }
286
+ /**
287
+ * List issue drafts visible to a set of repositories, newest-first.
288
+ *
289
+ * SCOPING: the caller MUST pass `repositoryIds` already restricted to the
290
+ * authenticated user's repos (see the approval API — it resolves these from
291
+ * `user.installationIds`). This query NEVER widens scope on its own: an empty
292
+ * `repositoryIds` returns an empty list (no rows), so a user with no repos can
293
+ * never see another tenant's drafts. `status` is an optional additional filter.
294
+ */
295
+ export async function listIssueDrafts(db, repositoryIds, options) {
296
+ if (repositoryIds.length === 0)
297
+ return [];
298
+ const limit = Math.min(options?.limit ?? 50, 100);
299
+ const offset = options?.offset ?? 0;
300
+ const conditions = [inArray(issueDrafts.repositoryId, repositoryIds)];
301
+ if (options?.status) {
302
+ conditions.push(eq(issueDrafts.status, options.status));
303
+ }
304
+ return db
305
+ .select()
306
+ .from(issueDrafts)
307
+ .where(and(...conditions))
308
+ .orderBy(desc(issueDrafts.createdAt))
309
+ .limit(limit)
310
+ .offset(offset);
311
+ }
312
+ /**
313
+ * Fetch a single issue draft by its primary key. No scoping is applied here —
314
+ * the caller MUST verify the row's `repositoryId` belongs to the authenticated
315
+ * user BEFORE returning it or acting on it (the approval API does this).
316
+ */
317
+ export async function getIssueDraftById(db, id) {
318
+ const [row] = await db.select().from(issueDrafts).where(eq(issueDrafts.id, id)).limit(1);
319
+ return row;
320
+ }
321
+ /**
322
+ * Edit a draft's body (the human sanitizes/edits before posting). Only a row
323
+ * still in DRAFT status is editable — editing an APPROVED/POSTED/REJECTED row is
324
+ * a no-op (returns `undefined`), so a decided draft cannot be silently mutated.
325
+ */
326
+ export async function updateIssueDraftBody(db, id, body) {
327
+ const [row] = await db
328
+ .update(issueDrafts)
329
+ .set({ body, updatedAt: new Date() })
330
+ .where(and(eq(issueDrafts.id, id), eq(issueDrafts.status, 'DRAFT')))
331
+ .returning();
332
+ return row;
333
+ }
334
+ /**
335
+ * Atomically CLAIM a DRAFT for posting by flipping it to APPROVED.
336
+ *
337
+ * This is the POSTING LOCK that makes the GitHub post exactly-once. The WHERE
338
+ * clause pins `status = 'DRAFT'` and the UPDATE ... RETURNING is a single atomic
339
+ * compare-and-swap (CAS): of N concurrent approvers, EXACTLY ONE matches the row
340
+ * (DRAFT → APPROVED) and gets it back; every other caller matches ZERO rows and
341
+ * gets `undefined`. The winner — and only the winner — proceeds to postComment.
342
+ *
343
+ * APPROVED is therefore a TRANSIENT "posting in progress" state, NOT a terminal
344
+ * decision: it resolves forward to POSTED (markIssueDraftPosted) on a successful
345
+ * post, or back to DRAFT (releaseIssueDraftClaim) when the post fails so a human
346
+ * can retry. The caller MUST treat `undefined` as "another approver already
347
+ * claimed this — return 409 and do NOT post".
348
+ *
349
+ * Lifecycle: DRAFT → APPROVED (posting) → POSTED (or → DRAFT on post failure).
350
+ */
351
+ export async function claimIssueDraftForPosting(db, id) {
352
+ const [row] = await db
353
+ .update(issueDrafts)
354
+ // claimedAt stamps the posting lease (see findStaleApprovedDrafts): a reaper
355
+ // uses it to detect an APPROVED draft whose poster crashed before posting.
356
+ .set({ status: 'APPROVED', claimedAt: new Date(), updatedAt: new Date() })
357
+ .where(and(eq(issueDrafts.id, id), eq(issueDrafts.status, 'DRAFT')))
358
+ .returning();
359
+ return row;
360
+ }
361
+ /**
362
+ * RELEASE a posting claim by reverting APPROVED → DRAFT after a FAILED post.
363
+ *
364
+ * The CAS pins `status = 'APPROVED'` so this only ever un-does an in-flight
365
+ * posting claim (never a POSTED/REJECTED terminal row). After release the draft
366
+ * is editable/approvable again, so the human can retry. Returns the reverted row,
367
+ * or `undefined` when the row was no longer APPROVED (defensive — should not
368
+ * happen on the post-failure path since the claimer owns the APPROVED row).
369
+ */
370
+ export async function releaseIssueDraftClaim(db, id) {
371
+ const [row] = await db
372
+ .update(issueDrafts)
373
+ // Clear the posting lease so a subsequent re-claim gets a fresh claimedAt
374
+ // (a stale lease would otherwise make the re-claimed draft look orphaned).
375
+ .set({ status: 'DRAFT', claimedAt: null, updatedAt: new Date() })
376
+ .where(and(eq(issueDrafts.id, id), eq(issueDrafts.status, 'APPROVED')))
377
+ .returning();
378
+ return row;
379
+ }
380
+ /**
381
+ * Atomically transition an APPROVED (claimed-for-posting) draft to POSTED,
382
+ * recording the GitHub comment id.
383
+ *
384
+ * IDEMPOTENCY/LIFECYCLE: the WHERE clause pins `status = 'APPROVED'` — only the
385
+ * approver that WON the posting claim (claimIssueDraftForPosting) and actually
386
+ * posted the comment reaches this. A row that is not APPROVED matches ZERO rows
387
+ * and returns `undefined`. Combined with the claim CAS this guarantees the post
388
+ * is recorded exactly once. The caller MUST treat `undefined` as "already
389
+ * decided, do not pretend we re-posted".
390
+ */
391
+ export async function markIssueDraftPosted(db, id, postedCommentId) {
392
+ const [row] = await db
393
+ .update(issueDrafts)
394
+ .set({ status: 'POSTED', postedCommentId, updatedAt: new Date() })
395
+ .where(and(eq(issueDrafts.id, id), eq(issueDrafts.status, 'APPROVED')))
396
+ .returning();
397
+ return row;
398
+ }
399
+ /**
400
+ * Transition a DRAFT to REJECTED — no comment is ever posted. Same DRAFT-only
401
+ * guard as the post transition: a decided row is not re-rejected (returns
402
+ * `undefined`).
403
+ */
404
+ export async function rejectIssueDraft(db, id) {
405
+ const [row] = await db
406
+ .update(issueDrafts)
407
+ .set({ status: 'REJECTED', updatedAt: new Date() })
408
+ .where(and(eq(issueDrafts.id, id), eq(issueDrafts.status, 'DRAFT')))
409
+ .returning();
410
+ return row;
411
+ }
412
+ /**
413
+ * DETECT stuck/orphaned APPROVED drafts whose posting lease is older than
414
+ * `olderThan` — the reaper-detection primitive (RES-001).
415
+ *
416
+ * APPROVED is a TRANSIENT "posting in progress" state (see
417
+ * claimIssueDraftForPosting). If the poster process crashes AFTER claiming
418
+ * (DRAFT → APPROVED, stamping `claimedAt`) but BEFORE it reaches POSTED or
419
+ * releases back to DRAFT, the row is stuck in APPROVED forever with no signal.
420
+ * This query surfaces those rows: status = APPROVED AND claimedAt IS NOT NULL
421
+ * AND claimedAt < olderThan, oldest-lease-first, capped at 100.
422
+ *
423
+ * It ONLY DETECTS — it takes no recovery action. The caller (PR2's reaper
424
+ * worker) decides what to do with each returned row: release it back to DRAFT
425
+ * via `releaseIssueDraftClaim` so a human can retry, or alert an operator.
426
+ *
427
+ * Global (not tenant-scoped): the reaper is a system maintenance job over the
428
+ * whole table, not a user-facing listing.
429
+ */
430
+ export async function findStaleApprovedDrafts(db, olderThan) {
431
+ return db
432
+ .select()
433
+ .from(issueDrafts)
434
+ .where(and(eq(issueDrafts.status, 'APPROVED'), isNotNull(issueDrafts.claimedAt), lt(issueDrafts.claimedAt, olderThan)))
435
+ .orderBy(asc(issueDrafts.claimedAt))
436
+ .limit(100);
437
+ }
225
438
  export async function getReviewsByRepoId(db, repositoryId, options = {}) {
226
439
  const { limit = 50, offset = 0 } = options;
227
440
  return db
@@ -1081,4 +1294,886 @@ export async function clearEmptyMemorySessions(db, installationId, project) {
1081
1294
  .returning({ id: memorySessions.id });
1082
1295
  return { deletedCount: result.length };
1083
1296
  }
1297
+ const explanationIdentityTextFields = [
1298
+ 'forgeInstance',
1299
+ 'installationId',
1300
+ 'actorId',
1301
+ 'repositoryId',
1302
+ 'requestedHeadSha',
1303
+ 'sourceCommentId',
1304
+ ];
1305
+ /**
1306
+ * Validates only the immutable identity shape. Opaque forge values are preserved
1307
+ * byte-for-byte; especially, questionHash is never trimmed or case-folded.
1308
+ */
1309
+ export function isValidExplanationInvocationIdentity(identity) {
1310
+ if (typeof identity !== 'object' || identity === null)
1311
+ return false;
1312
+ const candidate = identity;
1313
+ const pullRequestNumber = candidate.pullRequestNumber;
1314
+ if (typeof pullRequestNumber !== 'number' ||
1315
+ !Number.isSafeInteger(pullRequestNumber) ||
1316
+ pullRequestNumber <= 0)
1317
+ return false;
1318
+ if (typeof candidate.questionHash !== 'string' || !/^[a-f0-9]{64}$/.test(candidate.questionHash))
1319
+ return false;
1320
+ return explanationIdentityTextFields.every((field) => typeof candidate[field] === 'string' && candidate[field].length > 0);
1321
+ }
1322
+ export function isValidExplanationInvocationRequest(request) {
1323
+ if (!isValidExplanationInvocationIdentity(request))
1324
+ return false;
1325
+ const candidate = request;
1326
+ return (typeof candidate.question === 'string' &&
1327
+ candidate.question.length > 0 &&
1328
+ createHash('sha256').update(candidate.question).digest('hex') === candidate.questionHash);
1329
+ }
1330
+ /**
1331
+ * Produces an unambiguous SHA-256 key from the complete immutable tuple.
1332
+ * JSON array encoding prevents delimiter ambiguity between opaque identifiers.
1333
+ */
1334
+ export function deriveExplanationInvocationKey(identity) {
1335
+ if (!isValidExplanationInvocationIdentity(identity))
1336
+ return null;
1337
+ return createHash('sha256')
1338
+ .update(JSON.stringify([
1339
+ 'explanation-invocation-v1',
1340
+ identity.forgeInstance,
1341
+ identity.installationId,
1342
+ identity.actorId,
1343
+ identity.repositoryId,
1344
+ identity.pullRequestNumber,
1345
+ identity.requestedHeadSha,
1346
+ identity.sourceCommentId,
1347
+ identity.questionHash,
1348
+ ]))
1349
+ .digest('hex');
1350
+ }
1351
+ function matchesExplanationIdentity(row, identity) {
1352
+ return (row.forgeInstance === identity.forgeInstance &&
1353
+ row.installationId === identity.installationId &&
1354
+ row.actorId === identity.actorId &&
1355
+ row.repositoryId === identity.repositoryId &&
1356
+ row.pullRequestNumber === identity.pullRequestNumber &&
1357
+ row.requestedHeadSha === identity.requestedHeadSha &&
1358
+ row.sourceCommentId === identity.sourceCommentId &&
1359
+ row.questionHash === identity.questionHash &&
1360
+ row.question === identity.question);
1361
+ }
1362
+ async function selectExplanationInvocationByKey(db, invocationKey) {
1363
+ const [row] = await db
1364
+ .select()
1365
+ .from(explanationInvocations)
1366
+ .where(eq(explanationInvocations.invocationKey, invocationKey))
1367
+ .limit(1);
1368
+ return row;
1369
+ }
1370
+ /**
1371
+ * Inserts the sole pending claim, then separately observes a conflict. The
1372
+ * conflict follow-up is mandatory: a missing row is unavailable, never permission
1373
+ * to dispatch. This function creates no reservation or lifecycle authority.
1374
+ */
1375
+ export async function registerExplanationInvocation(db, request) {
1376
+ if (!isValidExplanationInvocationRequest(request))
1377
+ return { status: 'invalid' };
1378
+ const invocationKey = deriveExplanationInvocationKey(request);
1379
+ if (invocationKey === null)
1380
+ return { status: 'invalid' };
1381
+ const [inserted] = await db
1382
+ .insert(explanationInvocations)
1383
+ .values({
1384
+ invocationKey,
1385
+ forgeInstance: request.forgeInstance,
1386
+ installationId: request.installationId,
1387
+ actorId: request.actorId,
1388
+ repositoryId: request.repositoryId,
1389
+ pullRequestNumber: request.pullRequestNumber,
1390
+ requestedHeadSha: request.requestedHeadSha,
1391
+ sourceCommentId: request.sourceCommentId,
1392
+ questionHash: request.questionHash,
1393
+ question: request.question,
1394
+ })
1395
+ .onConflictDoNothing({ target: explanationInvocations.invocationKey })
1396
+ .returning();
1397
+ if (inserted)
1398
+ return { status: 'registered', invocation: inserted };
1399
+ const observed = await selectExplanationInvocationByKey(db, invocationKey);
1400
+ if (!observed)
1401
+ return { status: 'unavailable' };
1402
+ if (!matchesExplanationIdentity(observed, request))
1403
+ return { status: 'mismatch' };
1404
+ return { status: 'duplicate', invocation: observed };
1405
+ }
1406
+ /**
1407
+ * Fetches an invocation only when the caller supplies its complete expected
1408
+ * identity. A derived-key collision or changed tuple is deliberately opaque.
1409
+ */
1410
+ export async function lookupExplanationInvocation(db, request) {
1411
+ if (!isValidExplanationInvocationRequest(request))
1412
+ return { status: 'invalid' };
1413
+ const invocationKey = deriveExplanationInvocationKey(request);
1414
+ if (invocationKey === null)
1415
+ return { status: 'invalid' };
1416
+ const observed = await selectExplanationInvocationByKey(db, invocationKey);
1417
+ if (!observed)
1418
+ return { status: 'not_found' };
1419
+ if (!matchesExplanationIdentity(observed, request))
1420
+ return { status: 'mismatch' };
1421
+ return { status: 'found', invocation: observed };
1422
+ }
1423
+ const MIN_EXPLANATION_DISPATCH_LEASE_MS = 1_000;
1424
+ const MAX_EXPLANATION_DISPATCH_LEASE_MS = 300_000;
1425
+ function isValidExplanationInvocationDispatchReservationRequest(value) {
1426
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
1427
+ return false;
1428
+ const request = value;
1429
+ return (hasOnlyFields(request, [
1430
+ 'forgeInstance',
1431
+ 'installationId',
1432
+ 'actorId',
1433
+ 'repositoryId',
1434
+ 'pullRequestNumber',
1435
+ 'requestedHeadSha',
1436
+ 'sourceCommentId',
1437
+ 'questionHash',
1438
+ 'question',
1439
+ 'leaseDurationMs',
1440
+ ]) &&
1441
+ isValidExplanationInvocationRequest(request) &&
1442
+ typeof request.leaseDurationMs === 'number' &&
1443
+ Number.isSafeInteger(request.leaseDurationMs) &&
1444
+ request.leaseDurationMs >= MIN_EXPLANATION_DISPATCH_LEASE_MS &&
1445
+ request.leaseDurationMs <= MAX_EXPLANATION_DISPATCH_LEASE_MS);
1446
+ }
1447
+ /** A returned reservation authorizes only post-commit external I/O; this function never commits a caller transaction. */
1448
+ export async function reserveExplanationInvocationDispatch(db, value) {
1449
+ if (!isValidExplanationInvocationDispatchReservationRequest(value))
1450
+ return { status: 'invalid' };
1451
+ const invocationKey = deriveExplanationInvocationKey(value);
1452
+ if (invocationKey === null)
1453
+ return { status: 'invalid' };
1454
+ const [reserved] = await db
1455
+ .update(explanationInvocations)
1456
+ .set({
1457
+ executionStatus: 'DISPATCH_RESERVED',
1458
+ executionFence: sql `gen_random_uuid()::text`,
1459
+ dispatchReservedAt: sql `clock_timestamp()`,
1460
+ dispatchLeaseExpiresAt: sql `clock_timestamp() + (${value.leaseDurationMs} * interval '1 millisecond')`,
1461
+ })
1462
+ .where(and(eq(explanationInvocations.invocationKey, invocationKey), eq(explanationInvocations.forgeInstance, value.forgeInstance), eq(explanationInvocations.installationId, value.installationId), eq(explanationInvocations.actorId, value.actorId), eq(explanationInvocations.repositoryId, value.repositoryId), eq(explanationInvocations.pullRequestNumber, value.pullRequestNumber), eq(explanationInvocations.requestedHeadSha, value.requestedHeadSha), eq(explanationInvocations.sourceCommentId, value.sourceCommentId), eq(explanationInvocations.questionHash, value.questionHash), eq(explanationInvocations.question, value.question), eq(explanationInvocations.executionStatus, 'PENDING'), isNull(explanationInvocations.executionFence), isNull(explanationInvocations.dispatchReservedAt), isNull(explanationInvocations.dispatchLeaseExpiresAt), isNull(explanationInvocations.outcomeStatus), isNull(explanationInvocations.outcomeAnswer), isNull(explanationInvocations.outcomePayload), isNull(explanationInvocations.outcomeCompletedAt)))
1463
+ .returning();
1464
+ if (reserved && isNonEmptyText(reserved.executionFence)) {
1465
+ return { status: 'reserved', invocation: reserved, executionFence: reserved.executionFence };
1466
+ }
1467
+ const [observed] = await db
1468
+ .select()
1469
+ .from(explanationInvocations)
1470
+ .where(and(eq(explanationInvocations.invocationKey, invocationKey), eq(explanationInvocations.question, value.question)))
1471
+ .limit(1);
1472
+ return observed && matchesExplanationIdentity(observed, value)
1473
+ ? { status: 'observed', invocation: observed }
1474
+ : { status: 'unavailable' };
1475
+ }
1476
+ function isValidExplanationInvocationExpiredDispatchRecoveryRequest(value) {
1477
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
1478
+ return false;
1479
+ const request = value;
1480
+ return (hasOnlyFields(request, [
1481
+ 'forgeInstance',
1482
+ 'installationId',
1483
+ 'actorId',
1484
+ 'repositoryId',
1485
+ 'pullRequestNumber',
1486
+ 'requestedHeadSha',
1487
+ 'sourceCommentId',
1488
+ 'questionHash',
1489
+ 'question',
1490
+ 'executionFence',
1491
+ ]) &&
1492
+ isValidExplanationInvocationRequest(request) &&
1493
+ isNonEmptyText(request.executionFence));
1494
+ }
1495
+ export async function recoverExpiredExplanationInvocationDispatch(db, value) {
1496
+ if (!isValidExplanationInvocationExpiredDispatchRecoveryRequest(value))
1497
+ return { status: 'invalid' };
1498
+ const invocationKey = deriveExplanationInvocationKey(value);
1499
+ if (invocationKey === null)
1500
+ return { status: 'invalid' };
1501
+ const conditions = [
1502
+ eq(explanationInvocations.invocationKey, invocationKey),
1503
+ eq(explanationInvocations.forgeInstance, value.forgeInstance),
1504
+ eq(explanationInvocations.installationId, value.installationId),
1505
+ eq(explanationInvocations.actorId, value.actorId),
1506
+ eq(explanationInvocations.repositoryId, value.repositoryId),
1507
+ eq(explanationInvocations.pullRequestNumber, value.pullRequestNumber),
1508
+ eq(explanationInvocations.requestedHeadSha, value.requestedHeadSha),
1509
+ eq(explanationInvocations.sourceCommentId, value.sourceCommentId),
1510
+ eq(explanationInvocations.questionHash, value.questionHash),
1511
+ eq(explanationInvocations.question, value.question),
1512
+ eq(explanationInvocations.executionStatus, 'DISPATCH_RESERVED'),
1513
+ eq(explanationInvocations.executionFence, value.executionFence),
1514
+ isNull(explanationInvocations.outcomeStatus),
1515
+ isNull(explanationInvocations.outcomeAnswer),
1516
+ isNull(explanationInvocations.outcomePayload),
1517
+ isNull(explanationInvocations.outcomeCompletedAt),
1518
+ ];
1519
+ const locked = db.$with('locked_expired_explanation_invocation').as(db
1520
+ .select({
1521
+ id: explanationInvocations.id,
1522
+ lease: explanationInvocations.dispatchLeaseExpiresAt,
1523
+ })
1524
+ .from(explanationInvocations)
1525
+ .where(and(...conditions))
1526
+ .for('update'));
1527
+ const outcome = {
1528
+ kind: 'AMBIGUOUS',
1529
+ reason: 'dispatch lease expired before durable completion',
1530
+ };
1531
+ const [recovered] = await db
1532
+ .with(locked)
1533
+ .update(explanationInvocations)
1534
+ .set({
1535
+ executionStatus: 'AMBIGUOUS',
1536
+ outcomeStatus: 'AMBIGUOUS',
1537
+ outcomeAnswer: null,
1538
+ outcomePayload: outcome,
1539
+ outcomeCompletedAt: sql `clock_timestamp()`,
1540
+ })
1541
+ .from(locked)
1542
+ .where(and(eq(explanationInvocations.id, locked.id), lt(locked.lease, sql `clock_timestamp()`)))
1543
+ .returning();
1544
+ return recovered ? { status: 'recovered', invocation: recovered } : { status: 'unavailable' };
1545
+ }
1546
+ // ─── Explanation Invocation Settlement ──────────────────────────
1547
+ export const EXPLANATION_TERMINAL_OUTCOME_KINDS = [
1548
+ 'ANSWERED',
1549
+ 'INVALID',
1550
+ 'UNAUTHORIZED',
1551
+ 'DISABLED',
1552
+ 'STALE',
1553
+ 'AI_UNAVAILABLE',
1554
+ 'AMBIGUOUS',
1555
+ ];
1556
+ const pendingTerminalOutcomeKinds = [
1557
+ 'INVALID',
1558
+ 'UNAUTHORIZED',
1559
+ 'DISABLED',
1560
+ 'STALE',
1561
+ 'AI_UNAVAILABLE',
1562
+ ];
1563
+ function isNonEmptyText(value) {
1564
+ return typeof value === 'string' && value.trim().length > 0;
1565
+ }
1566
+ function hasOnlyFields(value, fields) {
1567
+ return Object.keys(value).every((field) => fields.includes(field));
1568
+ }
1569
+ function isValidExplanationOutcomePayload(value) {
1570
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
1571
+ return false;
1572
+ const payload = value;
1573
+ if (!EXPLANATION_TERMINAL_OUTCOME_KINDS.includes(payload.kind)) {
1574
+ return false;
1575
+ }
1576
+ if (payload.kind === 'ANSWERED') {
1577
+ if (!hasOnlyFields(payload, ['kind', 'answer', 'metadata']) ||
1578
+ !isNonEmptyText(payload.answer)) {
1579
+ return false;
1580
+ }
1581
+ if (typeof payload.metadata !== 'object' ||
1582
+ payload.metadata === null ||
1583
+ Array.isArray(payload.metadata)) {
1584
+ return false;
1585
+ }
1586
+ const metadata = payload.metadata;
1587
+ return (hasOnlyFields(metadata, ['provider', 'model', 'tokensUsed']) &&
1588
+ isNonEmptyText(metadata.provider) &&
1589
+ isNonEmptyText(metadata.model) &&
1590
+ typeof metadata.tokensUsed === 'number' &&
1591
+ Number.isFinite(metadata.tokensUsed) &&
1592
+ Number.isSafeInteger(metadata.tokensUsed) &&
1593
+ metadata.tokensUsed >= 0);
1594
+ }
1595
+ return hasOnlyFields(payload, ['kind', 'reason']) && isNonEmptyText(payload.reason);
1596
+ }
1597
+ /** Validates the complete immutable settlement request before any database access. */
1598
+ export function validateExplanationInvocationSettlement(value) {
1599
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
1600
+ return null;
1601
+ const request = value;
1602
+ if (!hasOnlyFields(request, [
1603
+ 'forgeInstance',
1604
+ 'installationId',
1605
+ 'actorId',
1606
+ 'repositoryId',
1607
+ 'pullRequestNumber',
1608
+ 'requestedHeadSha',
1609
+ 'sourceCommentId',
1610
+ 'questionHash',
1611
+ 'question',
1612
+ 'expectedExecutionStatus',
1613
+ 'executionFence',
1614
+ 'outcome',
1615
+ ]) ||
1616
+ !isValidExplanationInvocationRequest(request) ||
1617
+ !isValidExplanationOutcomePayload(request.outcome) ||
1618
+ (request.expectedExecutionStatus !== 'PENDING' &&
1619
+ request.expectedExecutionStatus !== 'DISPATCH_RESERVED')) {
1620
+ return null;
1621
+ }
1622
+ const outcome = request.outcome;
1623
+ if (request.expectedExecutionStatus === 'PENDING') {
1624
+ if (request.executionFence !== undefined ||
1625
+ !pendingTerminalOutcomeKinds.some((kind) => kind === outcome.kind)) {
1626
+ return null;
1627
+ }
1628
+ }
1629
+ else if (!isNonEmptyText(request.executionFence) ||
1630
+ !['ANSWERED', 'AI_UNAVAILABLE', 'AMBIGUOUS'].includes(outcome.kind)) {
1631
+ return null;
1632
+ }
1633
+ return value;
1634
+ }
1635
+ function outcomeAnswer(payload) {
1636
+ return payload.kind === 'ANSWERED' ? payload.answer : null;
1637
+ }
1638
+ function isCompleteExplanationInvocationOutcome(row) {
1639
+ if (row.outcomeStatus !== row.executionStatus ||
1640
+ row.outcomeCompletedAt === null ||
1641
+ !isValidExplanationOutcomePayload(row.outcomePayload) ||
1642
+ row.outcomePayload.kind !== row.executionStatus) {
1643
+ return false;
1644
+ }
1645
+ return row.outcomeAnswer === outcomeAnswer(row.outcomePayload);
1646
+ }
1647
+ /**
1648
+ * Performs one fenced conditional update. A zero-row result is observation-only:
1649
+ * it grants no execution authority and never repairs legacy rows.
1650
+ */
1651
+ export async function settleExplanationInvocation(db, value) {
1652
+ const request = validateExplanationInvocationSettlement(value);
1653
+ if (!request)
1654
+ return { status: 'invalid' };
1655
+ const invocationKey = deriveExplanationInvocationKey(request);
1656
+ if (invocationKey === null)
1657
+ return { status: 'invalid' };
1658
+ const conditions = [
1659
+ eq(explanationInvocations.invocationKey, invocationKey),
1660
+ eq(explanationInvocations.forgeInstance, request.forgeInstance),
1661
+ eq(explanationInvocations.installationId, request.installationId),
1662
+ eq(explanationInvocations.actorId, request.actorId),
1663
+ eq(explanationInvocations.repositoryId, request.repositoryId),
1664
+ eq(explanationInvocations.pullRequestNumber, request.pullRequestNumber),
1665
+ eq(explanationInvocations.requestedHeadSha, request.requestedHeadSha),
1666
+ eq(explanationInvocations.sourceCommentId, request.sourceCommentId),
1667
+ eq(explanationInvocations.questionHash, request.questionHash),
1668
+ eq(explanationInvocations.question, request.question),
1669
+ eq(explanationInvocations.executionStatus, request.expectedExecutionStatus),
1670
+ sql `${explanationInvocations.outcomeStatus} IS NULL`,
1671
+ sql `${explanationInvocations.outcomeAnswer} IS NULL`,
1672
+ sql `${explanationInvocations.outcomePayload} IS NULL`,
1673
+ sql `${explanationInvocations.outcomeCompletedAt} IS NULL`,
1674
+ ];
1675
+ const postLockConditions = [];
1676
+ const executionFence = request.executionFence;
1677
+ if (request.expectedExecutionStatus === 'PENDING') {
1678
+ conditions.push(isNull(explanationInvocations.executionFence), isNull(explanationInvocations.dispatchReservedAt), isNull(explanationInvocations.dispatchLeaseExpiresAt));
1679
+ }
1680
+ else {
1681
+ if (executionFence === undefined)
1682
+ return { status: 'invalid' };
1683
+ conditions.push(eq(explanationInvocations.executionFence, executionFence));
1684
+ if (request.outcome.kind !== 'AMBIGUOUS') {
1685
+ postLockConditions.push(gt(explanationInvocations.dispatchLeaseExpiresAt, sql `clock_timestamp()`));
1686
+ }
1687
+ }
1688
+ const locked = db
1689
+ .select({
1690
+ id: explanationInvocations.id,
1691
+ dispatchLeaseExpiresAt: explanationInvocations.dispatchLeaseExpiresAt,
1692
+ })
1693
+ .from(explanationInvocations)
1694
+ .where(and(...conditions))
1695
+ .for('update')
1696
+ .as('locked_explanation_invocation');
1697
+ const [settled] = await db
1698
+ .update(explanationInvocations)
1699
+ .set({
1700
+ executionStatus: request.outcome.kind,
1701
+ outcomeStatus: request.outcome.kind,
1702
+ outcomeAnswer: outcomeAnswer(request.outcome),
1703
+ outcomePayload: request.outcome,
1704
+ outcomeCompletedAt: sql `now()`,
1705
+ })
1706
+ .from(locked)
1707
+ .where(and(eq(explanationInvocations.id, locked.id), ...postLockConditions))
1708
+ .returning();
1709
+ if (settled)
1710
+ return { status: 'settled', invocation: settled };
1711
+ const [observed] = await db
1712
+ .select()
1713
+ .from(explanationInvocations)
1714
+ .where(and(eq(explanationInvocations.invocationKey, invocationKey), eq(explanationInvocations.forgeInstance, request.forgeInstance), eq(explanationInvocations.installationId, request.installationId), eq(explanationInvocations.actorId, request.actorId), eq(explanationInvocations.repositoryId, request.repositoryId), eq(explanationInvocations.pullRequestNumber, request.pullRequestNumber), eq(explanationInvocations.requestedHeadSha, request.requestedHeadSha), eq(explanationInvocations.sourceCommentId, request.sourceCommentId), eq(explanationInvocations.questionHash, request.questionHash), eq(explanationInvocations.question, request.question), inArray(explanationInvocations.executionStatus, EXPLANATION_TERMINAL_OUTCOME_KINDS), isNotNull(explanationInvocations.outcomeStatus), isNotNull(explanationInvocations.outcomePayload), isNotNull(explanationInvocations.outcomeCompletedAt)))
1715
+ .limit(1);
1716
+ return observed && isCompleteExplanationInvocationOutcome(observed)
1717
+ ? { status: 'settled', invocation: observed }
1718
+ : { status: 'unavailable' };
1719
+ }
1720
+ /**
1721
+ * Bounded ingress recovery: convert a claim whose queue admission is unknown
1722
+ * into an explicit terminal ambiguity. The identity and PENDING CAS make this
1723
+ * safe to call repeatedly and prevent a later worker from dispatching it.
1724
+ */
1725
+ export async function recoverPendingExplanationInvocation(db, value) {
1726
+ if (!isValidExplanationInvocationRequest(value))
1727
+ return { status: 'invalid' };
1728
+ const invocationKey = deriveExplanationInvocationKey(value);
1729
+ if (invocationKey === null)
1730
+ return { status: 'invalid' };
1731
+ const outcome = {
1732
+ kind: 'AMBIGUOUS',
1733
+ reason: 'Explanation queue admission could not be confirmed.',
1734
+ };
1735
+ const [settled] = await db
1736
+ .update(explanationInvocations)
1737
+ .set({
1738
+ executionStatus: 'AMBIGUOUS',
1739
+ outcomeStatus: 'AMBIGUOUS',
1740
+ outcomeAnswer: null,
1741
+ outcomePayload: outcome,
1742
+ outcomeCompletedAt: sql `now()`,
1743
+ })
1744
+ .where(and(...explanationPublicationIdentityConditions(invocationKey, value), eq(explanationInvocations.executionStatus, 'PENDING'), isNull(explanationInvocations.executionFence), isNull(explanationInvocations.outcomeStatus), isNull(explanationInvocations.outcomePayload), isNull(explanationInvocations.outcomeCompletedAt)))
1745
+ .returning();
1746
+ return settled ? { status: 'settled', invocation: settled } : { status: 'unavailable' };
1747
+ }
1748
+ // ─── Explanation Publication CREATE ─────────────────────────────
1749
+ export const EXPLANATION_PUBLICATION_CHANNELS = {
1750
+ PROGRESS: 'progress',
1751
+ ANSWER: 'answer',
1752
+ };
1753
+ export const EXPLANATION_PUBLICATION_CREATE_OUTCOMES = {
1754
+ ACKNOWLEDGED: 'ACKNOWLEDGED',
1755
+ UNCERTAIN: 'UNCERTAIN',
1756
+ };
1757
+ const MAX_POSTGRES_INT32 = 2_147_483_647;
1758
+ /**
1759
+ * Reservation advances the stored version once and must leave room for the
1760
+ * subsequent fenced settlement to advance it again.
1761
+ */
1762
+ function isValidPublicationReservationVersion(value) {
1763
+ return (typeof value === 'number' &&
1764
+ Number.isSafeInteger(value) &&
1765
+ value >= 0 &&
1766
+ value < MAX_POSTGRES_INT32 - 1);
1767
+ }
1768
+ /** Settlement receives the reservation's current persisted version and advances it once. */
1769
+ function isValidPublicationSettlementVersion(value) {
1770
+ return (typeof value === 'number' &&
1771
+ Number.isSafeInteger(value) &&
1772
+ value > 0 &&
1773
+ value < MAX_POSTGRES_INT32);
1774
+ }
1775
+ function isValidPositiveSafeId(value) {
1776
+ return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
1777
+ }
1778
+ function isExplanationPublicationChannel(value) {
1779
+ return (value === EXPLANATION_PUBLICATION_CHANNELS.PROGRESS ||
1780
+ value === EXPLANATION_PUBLICATION_CHANNELS.ANSWER);
1781
+ }
1782
+ function isValidExplanationPublicationStaleRequest(value) {
1783
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
1784
+ return false;
1785
+ const request = value;
1786
+ return (hasOnlyFields(request, [
1787
+ 'forgeInstance',
1788
+ 'installationId',
1789
+ 'actorId',
1790
+ 'repositoryId',
1791
+ 'pullRequestNumber',
1792
+ 'requestedHeadSha',
1793
+ 'sourceCommentId',
1794
+ 'questionHash',
1795
+ 'question',
1796
+ 'channel',
1797
+ ]) &&
1798
+ isValidExplanationInvocationRequest(request) &&
1799
+ isExplanationPublicationChannel(request.channel));
1800
+ }
1801
+ function hasCompleteExplanationPublicationIdentity(request, isValidVersion) {
1802
+ return (hasOnlyFields(request, [
1803
+ 'forgeInstance',
1804
+ 'installationId',
1805
+ 'actorId',
1806
+ 'repositoryId',
1807
+ 'pullRequestNumber',
1808
+ 'requestedHeadSha',
1809
+ 'sourceCommentId',
1810
+ 'questionHash',
1811
+ 'question',
1812
+ 'channel',
1813
+ 'expectedPublicationVersion',
1814
+ 'expectedBotAuthorId',
1815
+ ]) &&
1816
+ isValidExplanationInvocationRequest(request) &&
1817
+ isExplanationPublicationChannel(request.channel) &&
1818
+ isValidVersion(request.expectedPublicationVersion) &&
1819
+ isValidPositiveSafeId(request.expectedBotAuthorId));
1820
+ }
1821
+ /** Validates the complete immutable identity and CREATE fence before database access. */
1822
+ export function validateExplanationPublicationCreateReservation(value) {
1823
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
1824
+ return null;
1825
+ return hasCompleteExplanationPublicationIdentity(value, isValidPublicationReservationVersion)
1826
+ ? value
1827
+ : null;
1828
+ }
1829
+ /** Validates fenced CREATE settlement input without accepting caller-controlled lifecycle fields. */
1830
+ export function validateExplanationPublicationCreateSettlement(value) {
1831
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
1832
+ return null;
1833
+ const request = value;
1834
+ if (!hasOnlyFields(request, [
1835
+ 'forgeInstance',
1836
+ 'installationId',
1837
+ 'actorId',
1838
+ 'repositoryId',
1839
+ 'pullRequestNumber',
1840
+ 'requestedHeadSha',
1841
+ 'sourceCommentId',
1842
+ 'questionHash',
1843
+ 'question',
1844
+ 'channel',
1845
+ 'expectedPublicationVersion',
1846
+ 'expectedBotAuthorId',
1847
+ 'publicationFence',
1848
+ 'outcome',
1849
+ 'commentId',
1850
+ ]) ||
1851
+ !isNonEmptyText(request.publicationFence) ||
1852
+ (request.outcome !== EXPLANATION_PUBLICATION_CREATE_OUTCOMES.ACKNOWLEDGED &&
1853
+ request.outcome !== EXPLANATION_PUBLICATION_CREATE_OUTCOMES.UNCERTAIN)) {
1854
+ return null;
1855
+ }
1856
+ const reservation = {
1857
+ forgeInstance: request.forgeInstance,
1858
+ installationId: request.installationId,
1859
+ actorId: request.actorId,
1860
+ repositoryId: request.repositoryId,
1861
+ pullRequestNumber: request.pullRequestNumber,
1862
+ requestedHeadSha: request.requestedHeadSha,
1863
+ sourceCommentId: request.sourceCommentId,
1864
+ questionHash: request.questionHash,
1865
+ question: request.question,
1866
+ channel: request.channel,
1867
+ expectedPublicationVersion: request.expectedPublicationVersion,
1868
+ expectedBotAuthorId: request.expectedBotAuthorId,
1869
+ };
1870
+ if (!hasCompleteExplanationPublicationIdentity(reservation, isValidPublicationSettlementVersion))
1871
+ return null;
1872
+ if (request.outcome === EXPLANATION_PUBLICATION_CREATE_OUTCOMES.ACKNOWLEDGED) {
1873
+ return isValidPositiveSafeId(request.commentId)
1874
+ ? value
1875
+ : null;
1876
+ }
1877
+ return request.commentId === undefined
1878
+ ? value
1879
+ : null;
1880
+ }
1881
+ function explanationPublicationIdentityConditions(invocationKey, request) {
1882
+ return [
1883
+ eq(explanationInvocations.invocationKey, invocationKey),
1884
+ eq(explanationInvocations.forgeInstance, request.forgeInstance),
1885
+ eq(explanationInvocations.installationId, request.installationId),
1886
+ eq(explanationInvocations.actorId, request.actorId),
1887
+ eq(explanationInvocations.repositoryId, request.repositoryId),
1888
+ eq(explanationInvocations.pullRequestNumber, request.pullRequestNumber),
1889
+ eq(explanationInvocations.requestedHeadSha, request.requestedHeadSha),
1890
+ eq(explanationInvocations.sourceCommentId, request.sourceCommentId),
1891
+ eq(explanationInvocations.questionHash, request.questionHash),
1892
+ eq(explanationInvocations.question, request.question),
1893
+ ];
1894
+ }
1895
+ async function observeExplanationPublication(db, invocationKey, request) {
1896
+ const [observed] = await db
1897
+ .select()
1898
+ .from(explanationInvocations)
1899
+ .where(and(...explanationPublicationIdentityConditions(invocationKey, request)))
1900
+ .limit(1);
1901
+ return observed;
1902
+ }
1903
+ /**
1904
+ * Records a superseded target without mutating its generation outcome, visible
1905
+ * comment identity, publication version, or the other publication channel.
1906
+ */
1907
+ export async function markExplanationPublicationStale(db, value) {
1908
+ if (!isValidExplanationPublicationStaleRequest(value)) {
1909
+ return { status: 'invalid' };
1910
+ }
1911
+ const staleRequest = value;
1912
+ const invocationKey = deriveExplanationInvocationKey(staleRequest);
1913
+ if (invocationKey === null)
1914
+ return { status: 'invalid' };
1915
+ const conditions = explanationPublicationIdentityConditions(invocationKey, staleRequest);
1916
+ const [transitioned] = staleRequest.channel === EXPLANATION_PUBLICATION_CHANNELS.PROGRESS
1917
+ ? await db
1918
+ .update(explanationInvocations)
1919
+ .set({ progressPublicationStatus: 'STALE' })
1920
+ .where(and(...conditions, inArray(explanationInvocations.progressPublicationStatus, [
1921
+ 'NOT_STARTED',
1922
+ 'PUBLISHED',
1923
+ ])))
1924
+ .returning()
1925
+ : await db
1926
+ .update(explanationInvocations)
1927
+ .set({ answerPublicationStatus: 'STALE' })
1928
+ .where(and(...conditions, inArray(explanationInvocations.answerPublicationStatus, ['NOT_STARTED', 'PUBLISHED'])))
1929
+ .returning();
1930
+ if (transitioned)
1931
+ return { status: 'stale', invocation: transitioned };
1932
+ const observed = await observeExplanationPublication(db, invocationKey, staleRequest);
1933
+ const status = staleRequest.channel === EXPLANATION_PUBLICATION_CHANNELS.PROGRESS
1934
+ ? observed?.progressPublicationStatus
1935
+ : observed?.answerPublicationStatus;
1936
+ return observed && status === 'STALE'
1937
+ ? { status: 'stale', invocation: observed }
1938
+ : { status: 'unavailable' };
1939
+ }
1940
+ /**
1941
+ * Persists the CREATE fence in one CAS before any caller can issue a comment POST.
1942
+ * The supplied version is pre-reservation; the returned row carries the current
1943
+ * reserved version that settlement must present exactly.
1944
+ */
1945
+ export async function reserveExplanationPublicationCreate(db, value) {
1946
+ const request = validateExplanationPublicationCreateReservation(value);
1947
+ if (!request)
1948
+ return { status: 'invalid' };
1949
+ const invocationKey = deriveExplanationInvocationKey(request);
1950
+ if (invocationKey === null)
1951
+ return { status: 'invalid' };
1952
+ const conditions = explanationPublicationIdentityConditions(invocationKey, request);
1953
+ const [reserved] = request.channel === EXPLANATION_PUBLICATION_CHANNELS.PROGRESS
1954
+ ? await db
1955
+ .update(explanationInvocations)
1956
+ .set({
1957
+ progressPublicationStatus: 'CREATE_STARTED',
1958
+ progressPublicationCreateStartedAt: sql `clock_timestamp()`,
1959
+ progressExpectedBotAuthorId: request.expectedBotAuthorId,
1960
+ progressPublicationFence: sql `gen_random_uuid()::text`,
1961
+ progressPublicationVersion: request.expectedPublicationVersion + 1,
1962
+ })
1963
+ .where(and(...conditions, eq(explanationInvocations.progressPublicationStatus, 'NOT_STARTED'), eq(explanationInvocations.progressPublicationVersion, request.expectedPublicationVersion), isNull(explanationInvocations.progressPublicationCreateStartedAt), isNull(explanationInvocations.progressCommentId), isNull(explanationInvocations.progressExpectedBotAuthorId), isNull(explanationInvocations.progressPublicationFence)))
1964
+ .returning()
1965
+ : await db
1966
+ .update(explanationInvocations)
1967
+ .set({
1968
+ answerPublicationStatus: 'CREATE_STARTED',
1969
+ answerPublicationCreateStartedAt: sql `clock_timestamp()`,
1970
+ answerExpectedBotAuthorId: request.expectedBotAuthorId,
1971
+ answerPublicationFence: sql `gen_random_uuid()::text`,
1972
+ answerPublicationVersion: request.expectedPublicationVersion + 1,
1973
+ })
1974
+ .where(and(...conditions, eq(explanationInvocations.answerPublicationStatus, 'NOT_STARTED'), eq(explanationInvocations.answerPublicationVersion, request.expectedPublicationVersion), isNull(explanationInvocations.answerPublicationCreateStartedAt), isNull(explanationInvocations.answerCommentId), isNull(explanationInvocations.answerExpectedBotAuthorId), isNull(explanationInvocations.answerPublicationFence)))
1975
+ .returning();
1976
+ const fence = request.channel === EXPLANATION_PUBLICATION_CHANNELS.PROGRESS
1977
+ ? reserved?.progressPublicationFence
1978
+ : reserved?.answerPublicationFence;
1979
+ if (reserved && isNonEmptyText(fence)) {
1980
+ return { status: 'reserved', invocation: reserved, publicationFence: fence };
1981
+ }
1982
+ const observed = await observeExplanationPublication(db, invocationKey, request);
1983
+ return observed ? { status: 'observed', invocation: observed } : { status: 'unavailable' };
1984
+ }
1985
+ /**
1986
+ * Consumes a persisted CREATE fence so a duplicate or late POST result cannot gain authority.
1987
+ * The supplied version is the current reserved version; settlement increments it once.
1988
+ */
1989
+ export async function settleExplanationPublicationCreate(db, value) {
1990
+ const request = validateExplanationPublicationCreateSettlement(value);
1991
+ if (!request)
1992
+ return { status: 'invalid' };
1993
+ const invocationKey = deriveExplanationInvocationKey(request);
1994
+ if (invocationKey === null)
1995
+ return { status: 'invalid' };
1996
+ const conditions = explanationPublicationIdentityConditions(invocationKey, request);
1997
+ const isAcknowledged = request.outcome === EXPLANATION_PUBLICATION_CREATE_OUTCOMES.ACKNOWLEDGED;
1998
+ const [settled] = request.channel === EXPLANATION_PUBLICATION_CHANNELS.PROGRESS
1999
+ ? await db
2000
+ .update(explanationInvocations)
2001
+ .set({
2002
+ progressPublicationStatus: isAcknowledged ? 'PUBLISHED' : 'AMBIGUOUS',
2003
+ progressCommentId: isAcknowledged ? request.commentId : null,
2004
+ progressPublicationFence: null,
2005
+ progressPublicationVersion: request.expectedPublicationVersion + 1,
2006
+ })
2007
+ .where(and(...conditions, eq(explanationInvocations.progressPublicationStatus, 'CREATE_STARTED'), eq(explanationInvocations.progressPublicationVersion, request.expectedPublicationVersion), eq(explanationInvocations.progressExpectedBotAuthorId, request.expectedBotAuthorId), eq(explanationInvocations.progressPublicationFence, request.publicationFence)))
2008
+ .returning()
2009
+ : await db
2010
+ .update(explanationInvocations)
2011
+ .set({
2012
+ answerPublicationStatus: isAcknowledged ? 'PUBLISHED' : 'AMBIGUOUS',
2013
+ answerCommentId: isAcknowledged ? request.commentId : null,
2014
+ answerPublicationFence: null,
2015
+ answerPublicationVersion: request.expectedPublicationVersion + 1,
2016
+ })
2017
+ .where(and(...conditions, eq(explanationInvocations.answerPublicationStatus, 'CREATE_STARTED'), eq(explanationInvocations.answerPublicationVersion, request.expectedPublicationVersion), eq(explanationInvocations.answerExpectedBotAuthorId, request.expectedBotAuthorId), eq(explanationInvocations.answerPublicationFence, request.publicationFence)))
2018
+ .returning();
2019
+ if (settled)
2020
+ return { status: 'settled', invocation: settled };
2021
+ const observed = await observeExplanationPublication(db, invocationKey, request);
2022
+ return observed ? { status: 'observed', invocation: observed } : { status: 'unavailable' };
2023
+ }
2024
+ /** Validates a PATCH reservation against an existing immutable publication comment. */
2025
+ export function validateExplanationPublicationPatchReservation(value) {
2026
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
2027
+ return null;
2028
+ const request = value;
2029
+ const reservation = {
2030
+ forgeInstance: request.forgeInstance,
2031
+ installationId: request.installationId,
2032
+ actorId: request.actorId,
2033
+ repositoryId: request.repositoryId,
2034
+ pullRequestNumber: request.pullRequestNumber,
2035
+ requestedHeadSha: request.requestedHeadSha,
2036
+ sourceCommentId: request.sourceCommentId,
2037
+ questionHash: request.questionHash,
2038
+ question: request.question,
2039
+ channel: request.channel,
2040
+ expectedPublicationVersion: request.expectedPublicationVersion,
2041
+ expectedBotAuthorId: request.expectedBotAuthorId,
2042
+ };
2043
+ return hasOnlyFields(request, [
2044
+ 'forgeInstance',
2045
+ 'installationId',
2046
+ 'actorId',
2047
+ 'repositoryId',
2048
+ 'pullRequestNumber',
2049
+ 'requestedHeadSha',
2050
+ 'sourceCommentId',
2051
+ 'questionHash',
2052
+ 'question',
2053
+ 'channel',
2054
+ 'expectedPublicationVersion',
2055
+ 'expectedBotAuthorId',
2056
+ 'commentId',
2057
+ ]) &&
2058
+ hasCompleteExplanationPublicationIdentity(reservation, isValidPublicationReservationVersion) &&
2059
+ isValidPositiveSafeId(request.commentId)
2060
+ ? value
2061
+ : null;
2062
+ }
2063
+ /** Validates fenced PATCH settlement input without accepting caller lifecycle fields. */
2064
+ export function validateExplanationPublicationPatchSettlement(value) {
2065
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
2066
+ return null;
2067
+ const request = value;
2068
+ if (!hasOnlyFields(request, [
2069
+ 'forgeInstance',
2070
+ 'installationId',
2071
+ 'actorId',
2072
+ 'repositoryId',
2073
+ 'pullRequestNumber',
2074
+ 'requestedHeadSha',
2075
+ 'sourceCommentId',
2076
+ 'questionHash',
2077
+ 'question',
2078
+ 'channel',
2079
+ 'expectedPublicationVersion',
2080
+ 'expectedBotAuthorId',
2081
+ 'commentId',
2082
+ 'publicationFence',
2083
+ 'outcome',
2084
+ ]) ||
2085
+ !isNonEmptyText(request.publicationFence) ||
2086
+ (request.outcome !== EXPLANATION_PUBLICATION_CREATE_OUTCOMES.ACKNOWLEDGED &&
2087
+ request.outcome !== EXPLANATION_PUBLICATION_CREATE_OUTCOMES.UNCERTAIN)) {
2088
+ return null;
2089
+ }
2090
+ const reservation = {
2091
+ forgeInstance: request.forgeInstance,
2092
+ installationId: request.installationId,
2093
+ actorId: request.actorId,
2094
+ repositoryId: request.repositoryId,
2095
+ pullRequestNumber: request.pullRequestNumber,
2096
+ requestedHeadSha: request.requestedHeadSha,
2097
+ sourceCommentId: request.sourceCommentId,
2098
+ questionHash: request.questionHash,
2099
+ question: request.question,
2100
+ channel: request.channel,
2101
+ expectedPublicationVersion: request.expectedPublicationVersion,
2102
+ expectedBotAuthorId: request.expectedBotAuthorId,
2103
+ };
2104
+ return hasCompleteExplanationPublicationIdentity(reservation, isValidPublicationSettlementVersion) && isValidPositiveSafeId(request.commentId)
2105
+ ? value
2106
+ : null;
2107
+ }
2108
+ /** Persists PATCH_STARTED before caller I/O without changing the CREATE timestamp or comment. */
2109
+ export async function reserveExplanationPublicationPatch(db, value) {
2110
+ const request = validateExplanationPublicationPatchReservation(value);
2111
+ if (!request)
2112
+ return { status: 'invalid' };
2113
+ const invocationKey = deriveExplanationInvocationKey(request);
2114
+ if (invocationKey === null)
2115
+ return { status: 'invalid' };
2116
+ const conditions = explanationPublicationIdentityConditions(invocationKey, request);
2117
+ const [reserved] = request.channel === EXPLANATION_PUBLICATION_CHANNELS.PROGRESS
2118
+ ? await db
2119
+ .update(explanationInvocations)
2120
+ .set({
2121
+ progressPublicationStatus: 'PATCH_STARTED',
2122
+ progressPublicationFence: sql `gen_random_uuid()::text`,
2123
+ progressPublicationVersion: request.expectedPublicationVersion + 1,
2124
+ })
2125
+ .where(and(...conditions, eq(explanationInvocations.progressPublicationStatus, 'PUBLISHED'), eq(explanationInvocations.progressPublicationVersion, request.expectedPublicationVersion), eq(explanationInvocations.progressExpectedBotAuthorId, request.expectedBotAuthorId), eq(explanationInvocations.progressCommentId, request.commentId), isNull(explanationInvocations.progressPublicationFence)))
2126
+ .returning()
2127
+ : await db
2128
+ .update(explanationInvocations)
2129
+ .set({
2130
+ answerPublicationStatus: 'PATCH_STARTED',
2131
+ answerPublicationFence: sql `gen_random_uuid()::text`,
2132
+ answerPublicationVersion: request.expectedPublicationVersion + 1,
2133
+ })
2134
+ .where(and(...conditions, eq(explanationInvocations.answerPublicationStatus, 'PUBLISHED'), eq(explanationInvocations.answerPublicationVersion, request.expectedPublicationVersion), eq(explanationInvocations.answerExpectedBotAuthorId, request.expectedBotAuthorId), eq(explanationInvocations.answerCommentId, request.commentId), isNull(explanationInvocations.answerPublicationFence)))
2135
+ .returning();
2136
+ const fence = request.channel === EXPLANATION_PUBLICATION_CHANNELS.PROGRESS
2137
+ ? reserved?.progressPublicationFence
2138
+ : reserved?.answerPublicationFence;
2139
+ if (reserved && isNonEmptyText(fence)) {
2140
+ return { status: 'reserved', invocation: reserved, publicationFence: fence };
2141
+ }
2142
+ const observed = await observeExplanationPublication(db, invocationKey, request);
2143
+ return observed ? { status: 'observed', invocation: observed } : { status: 'unavailable' };
2144
+ }
2145
+ /** Consumes a matching PATCH fence and preserves the existing comment identity. */
2146
+ export async function settleExplanationPublicationPatch(db, value) {
2147
+ const request = validateExplanationPublicationPatchSettlement(value);
2148
+ if (!request)
2149
+ return { status: 'invalid' };
2150
+ const invocationKey = deriveExplanationInvocationKey(request);
2151
+ if (invocationKey === null)
2152
+ return { status: 'invalid' };
2153
+ const conditions = explanationPublicationIdentityConditions(invocationKey, request);
2154
+ const isAcknowledged = request.outcome === EXPLANATION_PUBLICATION_CREATE_OUTCOMES.ACKNOWLEDGED;
2155
+ const [settled] = request.channel === EXPLANATION_PUBLICATION_CHANNELS.PROGRESS
2156
+ ? await db
2157
+ .update(explanationInvocations)
2158
+ .set({
2159
+ progressPublicationStatus: isAcknowledged ? 'PUBLISHED' : 'AMBIGUOUS',
2160
+ progressPublicationFence: null,
2161
+ progressPublicationVersion: request.expectedPublicationVersion + 1,
2162
+ })
2163
+ .where(and(...conditions, eq(explanationInvocations.progressPublicationStatus, 'PATCH_STARTED'), eq(explanationInvocations.progressPublicationVersion, request.expectedPublicationVersion), eq(explanationInvocations.progressExpectedBotAuthorId, request.expectedBotAuthorId), eq(explanationInvocations.progressCommentId, request.commentId), eq(explanationInvocations.progressPublicationFence, request.publicationFence)))
2164
+ .returning()
2165
+ : await db
2166
+ .update(explanationInvocations)
2167
+ .set({
2168
+ answerPublicationStatus: isAcknowledged ? 'PUBLISHED' : 'AMBIGUOUS',
2169
+ answerPublicationFence: null,
2170
+ answerPublicationVersion: request.expectedPublicationVersion + 1,
2171
+ })
2172
+ .where(and(...conditions, eq(explanationInvocations.answerPublicationStatus, 'PATCH_STARTED'), eq(explanationInvocations.answerPublicationVersion, request.expectedPublicationVersion), eq(explanationInvocations.answerExpectedBotAuthorId, request.expectedBotAuthorId), eq(explanationInvocations.answerCommentId, request.commentId), eq(explanationInvocations.answerPublicationFence, request.publicationFence)))
2173
+ .returning();
2174
+ if (settled)
2175
+ return { status: 'settled', invocation: settled };
2176
+ const observed = await observeExplanationPublication(db, invocationKey, request);
2177
+ return observed ? { status: 'observed', invocation: observed } : { status: 'unavailable' };
2178
+ }
1084
2179
  //# sourceMappingURL=queries.js.map