@kici-dev/shared 0.1.27 → 0.2.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/db-admin.js CHANGED
@@ -2,6 +2,7 @@ import "./rolldown-runtime-ClRpJifh.js";
2
2
  import { createPool } from "./db.js";
3
3
  import { createHash } from "node:crypto";
4
4
  import pg from "pg";
5
+ import { HoldType, unknownContributorHoldReason } from "@kici-dev/engine";
5
6
  //#region src/db-admin.ts
6
7
  /**
7
8
  * Admin/DB-operations helpers shared between `kici-admin` (orchestrator DB)
@@ -389,13 +390,19 @@ const ENV_POLICY_COLUMNS = /* @__PURE__ */ new Set([
389
390
  "allow_local_execution"
390
391
  ]);
391
392
  /**
392
- * Upsert an context row keyed by (org_id, name). Returns the env id and
393
+ * Upsert a context row keyed by (org_id, name). Returns the env id and
393
394
  * whether the row was newly inserted. `branchRestrictions` / `requiredReviewers`
394
395
  * are JSON-serialised server-side; pass them as plain arrays or objects.
396
+ *
397
+ * An omitted `holdExpirySeconds` is written as NULL rather than a literal
398
+ * window: the column carries no DDL default, so "never set" and "cleared" both
399
+ * land on NULL and resolve through the one `DEFAULT_HOLD_EXPIRY_SECONDS`
400
+ * fallback on read. Writing a literal here would give this path a second,
401
+ * longer default that no read-side code knows about.
395
402
  */
396
403
  async function seedContextDirect(databaseUrl, opts) {
397
404
  if (opts.waitTimerSeconds != null && opts.waitTimerSeconds < 0) throw new Error(`context: waitTimerSeconds must be >= 0 (got ${opts.waitTimerSeconds})`);
398
- if (opts.holdExpirySeconds != null && opts.holdExpirySeconds < 0) throw new Error(`context: holdExpirySeconds must be >= 0 (got ${opts.holdExpirySeconds})`);
405
+ if (opts.holdExpirySeconds != null && opts.holdExpirySeconds < 1) throw new Error(`context: holdExpirySeconds must be >= 1 (got ${opts.holdExpirySeconds})`);
399
406
  const pool = new pg.Pool({
400
407
  connectionString: databaseUrl,
401
408
  max: 1
@@ -407,7 +414,7 @@ async function seedContextDirect(databaseUrl, opts) {
407
414
  (org_id, name, type, enabled, branch_restrictions, required_reviewers,
408
415
  wait_timer_seconds, hold_expiry_seconds, minimum_trust, glob_pattern)
409
416
  VALUES ($1, $2, COALESCE($3, 'fixed'), COALESCE($4, true), $5::jsonb, $6::jsonb,
410
- $7, COALESCE($8, 86400), $9, $10)
417
+ $7, $8, $9, $10)
411
418
  ON CONFLICT (org_id, name) DO UPDATE SET
412
419
  type = COALESCE(EXCLUDED.type, contexts.type),
413
420
  enabled = EXCLUDED.enabled,
@@ -440,7 +447,7 @@ async function seedContextDirect(databaseUrl, opts) {
440
447
  }
441
448
  }
442
449
  /**
443
- * Delete an context keyed by (org_id, name). Returns whether a row was
450
+ * Delete a context keyed by (org_id, name). Returns whether a row was
444
451
  * removed. The `context_bindings`, `context_variables`, and
445
452
  * `context_source_overrides` children all carry
446
453
  * `FOREIGN KEY (context_id) REFERENCES contexts(id) ON DELETE CASCADE`,
@@ -499,7 +506,7 @@ async function seedContextBindingDirect(databaseUrl, opts) {
499
506
  */
500
507
  async function setContextPolicyDirect(databaseUrl, opts) {
501
508
  if (opts.waitTimerSeconds != null && opts.waitTimerSeconds < 0) throw new Error(`context: waitTimerSeconds must be >= 0 (got ${opts.waitTimerSeconds})`);
502
- if (opts.holdExpirySeconds != null && opts.holdExpirySeconds < 0) throw new Error(`context: holdExpirySeconds must be >= 0 (got ${opts.holdExpirySeconds})`);
509
+ if (opts.holdExpirySeconds != null && opts.holdExpirySeconds < 1) throw new Error(`context: holdExpirySeconds must be >= 1 (got ${opts.holdExpirySeconds})`);
503
510
  const setClauses = [];
504
511
  const params = [];
505
512
  let idx = 1;
@@ -587,11 +594,16 @@ async function showContextDirect(databaseUrl, opts) {
587
594
  }
588
595
  }
589
596
  /**
590
- * Create (or update) an context template + its seed variables in one
597
+ * Create (or update) a context template + its seed variables in one
591
598
  * transaction. Templates are represented as contexts with `type='template'`
592
599
  * by convention. Returns `{ envId, variablesSet }`.
600
+ *
601
+ * An omitted `holdExpirySeconds` is written as NULL rather than a literal
602
+ * window, for the same reason as `seedContextDirect`: the column has no DDL
603
+ * default and every read resolves NULL through `DEFAULT_HOLD_EXPIRY_SECONDS`.
593
604
  */
594
605
  async function createContextTemplateDirect(databaseUrl, opts) {
606
+ if (opts.holdExpirySeconds != null && opts.holdExpirySeconds < 1) throw new Error(`context: holdExpirySeconds must be >= 1 (got ${opts.holdExpirySeconds})`);
595
607
  const pool = new pg.Pool({
596
608
  connectionString: databaseUrl,
597
609
  max: 1
@@ -602,7 +614,7 @@ async function createContextTemplateDirect(databaseUrl, opts) {
602
614
  const row = (await client.query(`INSERT INTO contexts
603
615
  (org_id, name, type, enabled, branch_restrictions, required_reviewers,
604
616
  wait_timer_seconds, hold_expiry_seconds, minimum_trust)
605
- VALUES ($1, $2, COALESCE($3, 'template'), true, $4::jsonb, $5::jsonb, $6, COALESCE($7, 86400), $8)
617
+ VALUES ($1, $2, COALESCE($3, 'template'), true, $4::jsonb, $5::jsonb, $6, $7, $8)
606
618
  ON CONFLICT (org_id, name) DO UPDATE SET
607
619
  type = COALESCE(EXCLUDED.type, contexts.type),
608
620
  branch_restrictions = EXCLUDED.branch_restrictions,
@@ -800,6 +812,38 @@ async function listExecutionRunsDirect(databaseUrl, opts = {}) {
800
812
  }
801
813
  }
802
814
  /**
815
+ * READ-ONLY: SELECT check_run_tracking rows for a commit. One row per
816
+ * `(provider, owner, repo, sha, check_name)`, ordered by check_name.
817
+ *
818
+ * Each column answers a different question. `check_run_id` is written once,
819
+ * when the check run is created in the `queued` state, so it answers "did we
820
+ * create it?". `terminal_sent_at` is stamped only after the provider accepts
821
+ * the terminal `completed` PATCH, so it answers "did we complete it?". Every
822
+ * write here is best-effort, so a null column is "no record", never proof of
823
+ * failure — see the per-field notes for what each one does and does not prove.
824
+ */
825
+ async function listCheckRunTrackingDirect(databaseUrl, opts) {
826
+ const pool = createPool(databaseUrl);
827
+ try {
828
+ const clauses = ["sha = $1"];
829
+ const params = [opts.sha];
830
+ if (opts.checkName !== void 0) {
831
+ clauses.push(`check_name = $2`);
832
+ params.push(opts.checkName);
833
+ }
834
+ const limit = Math.max(1, Math.min(1e3, opts.limit ?? 50));
835
+ return { rows: (await pool.query(`SELECT provider, owner, repo, sha, check_name,
836
+ check_run_id::text AS check_run_id,
837
+ build_creation_state, run_id, in_progress_sent_at, terminal_sent_at
838
+ FROM check_run_tracking
839
+ WHERE ${clauses.join(" AND ")}
840
+ ORDER BY check_name
841
+ LIMIT ${limit}`, params)).rows };
842
+ } finally {
843
+ await pool.end();
844
+ }
845
+ }
846
+ /**
803
847
  * READ-ONLY: fetch a single run by run_id AND its jobs. Throws if no run
804
848
  * matches the run_id. Jobs list may be empty for pending runs.
805
849
  */
@@ -1648,6 +1692,8 @@ async function seedUniversalGitSourceDirect(databaseUrl, opts) {
1648
1692
  await pool.end();
1649
1693
  }
1650
1694
  }
1695
+ /** repo_identifier used for the different-repo isolation hold. */
1696
+ const CI_SECURITY_OTHER_REPO = "other/repo";
1651
1697
  async function seedCiSecurityFixturesDirect(databaseUrl, opts) {
1652
1698
  const contextName = opts.contextName ?? "ci-security-env";
1653
1699
  const sourceName = opts.sourceName ?? "ci-security-dashboard-resolver";
@@ -1665,33 +1711,85 @@ async function seedCiSecurityFixturesDirect(databaseUrl, opts) {
1665
1711
  VALUES ($1, $2, \'fixed\', true)
1666
1712
  ON CONFLICT (org_id, name) DO UPDATE SET enabled = true
1667
1713
  RETURNING id`, [opts.orgId, contextName])).rows[0].id;
1714
+ async function seedPrHold(args) {
1715
+ await pool.query(`INSERT INTO execution_runs (
1716
+ run_id, workflow_name, provider, repo_identifier,
1717
+ ref, sha, delivery_id, status, trust_tier, lock_file_source,
1718
+ contributor_username, routing_key, pr_number
1719
+ ) VALUES ($1, \'e2e-security-wf\', \'internal\', $4, \'refs/heads/feature\',
1720
+ $5, $2, \'pending\', \'unknown\', \'base\', \'unknown-dev\', $3, $6)`, [
1721
+ args.runId,
1722
+ args.deliveryId,
1723
+ opts.runsRoutingKey,
1724
+ args.repoIdentifier,
1725
+ args.sha,
1726
+ args.prNumber
1727
+ ]);
1728
+ await pool.query(`INSERT INTO execution_jobs (job_id, run_id, job_name, status)
1729
+ VALUES ($1, $2, \'security-test-job\', \'pending\')`, [args.jobId, args.runId]);
1730
+ return (await pool.query(`INSERT INTO held_runs (org_id, run_id, job_id, context_id, hold_type, queue_type, reason, expires_at)
1731
+ VALUES ($1, $2, $3, $4, $5, \'security\', $6, NOW() + INTERVAL \'72 hours\')
1732
+ RETURNING id`, [
1733
+ opts.orgId,
1734
+ args.runId,
1735
+ args.jobId,
1736
+ args.contextId === void 0 ? envId : args.contextId,
1737
+ args.holdType ?? HoldType.enum.security,
1738
+ args.reason ?? unknownContributorHoldReason(contextName)
1739
+ ])).rows[0].id;
1740
+ }
1741
+ const heldRunId = await seedPrHold({
1742
+ runId: opts.unknownRunId,
1743
+ deliveryId: opts.unknownDeliveryId,
1744
+ jobId: opts.unknownJobId,
1745
+ repoIdentifier: ".",
1746
+ prNumber: 1,
1747
+ sha: "abc123"
1748
+ });
1749
+ const secondHeldRunId = await seedPrHold({
1750
+ runId: opts.secondPrRunId,
1751
+ deliveryId: opts.secondPrDeliveryId,
1752
+ jobId: opts.secondPrJobId,
1753
+ repoIdentifier: ".",
1754
+ prNumber: 2,
1755
+ sha: "bbb222"
1756
+ });
1757
+ const otherRepoHeldRunId = await seedPrHold({
1758
+ runId: opts.otherRepoRunId,
1759
+ deliveryId: opts.otherRepoDeliveryId,
1760
+ jobId: opts.otherRepoJobId,
1761
+ repoIdentifier: CI_SECURITY_OTHER_REPO,
1762
+ prNumber: 1,
1763
+ sha: "ccc333"
1764
+ });
1765
+ const wfModHeldRunId = await seedPrHold({
1766
+ runId: opts.wfModRunId,
1767
+ deliveryId: opts.wfModDeliveryId,
1768
+ jobId: opts.wfModJobId,
1769
+ repoIdentifier: ".",
1770
+ prNumber: 3,
1771
+ sha: "ddd444",
1772
+ holdType: HoldType.enum.security,
1773
+ reason: "workflow_modification",
1774
+ contextId: null
1775
+ });
1776
+ const forkPrHeldRunId = await seedPrHold({
1777
+ runId: opts.forkPrRunId,
1778
+ deliveryId: opts.forkPrDeliveryId,
1779
+ jobId: opts.forkPrJobId,
1780
+ repoIdentifier: ".",
1781
+ prNumber: 4,
1782
+ sha: "eee555",
1783
+ holdType: HoldType.enum.security,
1784
+ reason: "fork_pr",
1785
+ contextId: null
1786
+ });
1668
1787
  await pool.query(`INSERT INTO execution_runs (
1669
1788
  run_id, workflow_name, provider, repo_identifier,
1670
1789
  ref, sha, delivery_id, status, trust_tier, lock_file_source,
1671
- contributor_username, routing_key
1672
- ) VALUES ($1, \'e2e-security-wf\', \'internal\', \'.\', \'refs/heads/feature\',
1673
- \'abc123\', $2, \'pending\', \'unknown\', \'base\', \'unknown-dev\', $3)`, [
1674
- opts.unknownRunId,
1675
- opts.unknownDeliveryId,
1676
- opts.runsRoutingKey
1677
- ]);
1678
- await pool.query(`INSERT INTO execution_jobs (job_id, run_id, job_name, status)
1679
- VALUES ($1, $2, \'security-test-job\', \'pending\')`, [opts.unknownJobId, opts.unknownRunId]);
1680
- const heldRunId = (await pool.query(`INSERT INTO held_runs (org_id, run_id, job_id, context_id, hold_type, queue_type, reason, expires_at)
1681
- VALUES ($1, $2, $3, $4, \'unknown_contributor\', \'security\',
1682
- \'Unknown contributor requires approval\', NOW() + INTERVAL \'72 hours\')
1683
- RETURNING id`, [
1684
- opts.orgId,
1685
- opts.unknownRunId,
1686
- opts.unknownJobId,
1687
- envId
1688
- ])).rows[0].id;
1689
- await pool.query(`INSERT INTO execution_runs (
1690
- run_id, workflow_name, provider, repo_identifier,
1691
- ref, sha, delivery_id, status, trust_tier, lock_file_source,
1692
- contributor_username, routing_key
1790
+ contributor_username, routing_key, pr_number
1693
1791
  ) VALUES ($1, \'e2e-security-wf\', \'internal\', \'.\', \'refs/heads/feature\',
1694
- \'def456\', $2, \'running\', \'trusted\', \'head\', \'trusted-dev\', $3)`, [
1792
+ \'def456\', $2, \'running\', \'trusted\', \'head\', \'trusted-dev\', $3, 1)`, [
1695
1793
  opts.trustedRunId,
1696
1794
  opts.trustedDeliveryId,
1697
1795
  opts.runsRoutingKey
@@ -1699,31 +1797,43 @@ async function seedCiSecurityFixturesDirect(databaseUrl, opts) {
1699
1797
  await pool.query(`INSERT INTO execution_jobs (job_id, run_id, job_name, status)
1700
1798
  VALUES ($1, $2, \'security-test-job\', \'running\')`, [opts.trustedJobId, opts.trustedRunId]);
1701
1799
  return {
1800
+ contextName,
1702
1801
  envId,
1703
- heldRunId
1802
+ heldRunId,
1803
+ secondHeldRunId,
1804
+ otherRepoHeldRunId,
1805
+ wfModHeldRunId,
1806
+ forkPrHeldRunId
1704
1807
  };
1705
1808
  } finally {
1706
1809
  await pool.end();
1707
1810
  }
1708
1811
  }
1709
1812
  /**
1710
- * Poll `execution_runs` for at least one row matching `status` whose
1711
- * `started_at > since`. Used by cluster/job-reroute tests to gate on
1712
- * a workflow reaching the terminal state after a webhook trigger.
1813
+ * Poll `execution_runs` for the newest run started since `since` whose status
1814
+ * is in `statuses`, returning that status. Resolves `{ status: null }` if the
1815
+ * deadline passes before any run reaches a target status.
1816
+ *
1817
+ * Callers wanting "did the run finish?" pass the terminal status set and read
1818
+ * the landed status — a terminal failure is reported immediately rather than
1819
+ * indistinguishable from a timeout. Used by the cluster reroute tests to gate
1820
+ * on a workflow reaching a terminal state after a webhook trigger.
1713
1821
  */
1714
- async function waitForExecutionRunStatusSinceDirect(databaseUrl, opts) {
1715
- const timeoutMs = opts.timeoutMs ?? 12e4;
1822
+ async function waitForExecutionRunReachesStatusSinceDirect(databaseUrl, opts) {
1823
+ const timeoutMs = opts.timeoutMs ?? 24e4;
1716
1824
  const intervalMs = opts.intervalMs ?? 5e3;
1717
1825
  const deadline = Date.now() + timeoutMs;
1718
1826
  const pool = createPool(databaseUrl);
1719
1827
  try {
1720
1828
  while (Date.now() < deadline) {
1721
- if ((await pool.query(`SELECT 1 FROM execution_runs
1722
- WHERE started_at > $1 AND status = $2
1723
- LIMIT 1`, [opts.since, opts.status])).rows.length > 0) return { found: true };
1829
+ const result = await pool.query(`SELECT status FROM execution_runs
1830
+ WHERE started_at > $1 AND status = ANY($2)
1831
+ ORDER BY started_at DESC
1832
+ LIMIT 1`, [opts.since, [...opts.statuses]]);
1833
+ if (result.rows.length > 0) return { status: result.rows[0].status };
1724
1834
  await new Promise((r) => setTimeout(r, intervalMs));
1725
1835
  }
1726
- return { found: false };
1836
+ return { status: null };
1727
1837
  } finally {
1728
1838
  await pool.end();
1729
1839
  }
@@ -2269,11 +2379,15 @@ async function bumpRegistryVersionSimpleDirect(databaseUrl, opts = {}) {
2269
2379
  async function upsertCronLastFiredDirect(databaseUrl, opts) {
2270
2380
  const pool = createPool(databaseUrl);
2271
2381
  try {
2272
- await pool.query(`INSERT INTO cron_last_fired (registration_id, last_fired_at)
2273
- VALUES ($1, NOW() - ($2)::interval)
2274
- ON CONFLICT (registration_id) DO UPDATE SET
2382
+ await pool.query(`INSERT INTO cron_last_fired (registration_id, schedule_key, last_fired_at)
2383
+ VALUES ($1, $3, NOW() - ($2)::interval)
2384
+ ON CONFLICT (registration_id, schedule_key) DO UPDATE SET
2275
2385
  last_fired_at = NOW() - ($2)::interval,
2276
- updated_at = NOW()`, [opts.registrationId, opts.agoInterval]);
2386
+ updated_at = NOW()`, [
2387
+ opts.registrationId,
2388
+ opts.agoInterval,
2389
+ opts.scheduleKey
2390
+ ]);
2277
2391
  } finally {
2278
2392
  await pool.end();
2279
2393
  }
@@ -2297,7 +2411,7 @@ async function countCronLastFiredDirect(databaseUrl, opts) {
2297
2411
  async function insertCronLastFiredNowDirect(databaseUrl, opts) {
2298
2412
  const pool = createPool(databaseUrl);
2299
2413
  try {
2300
- await pool.query(`INSERT INTO cron_last_fired (registration_id, last_fired_at) VALUES ($1, NOW())`, [opts.registrationId]);
2414
+ await pool.query(`INSERT INTO cron_last_fired (registration_id, schedule_key, last_fired_at) VALUES ($1, $2, NOW())`, [opts.registrationId, opts.scheduleKey]);
2301
2415
  } finally {
2302
2416
  await pool.end();
2303
2417
  }
@@ -2754,6 +2868,6 @@ async function terminateIdleDbBackendsDirect(databaseUrl) {
2754
2868
  }
2755
2869
  }
2756
2870
  //#endregion
2757
- export { MIGRATION_HASH_TABLE, PROVIDER_HASH_KEY, REGISTERABLE_TRIGGER_TYPES, apiKeyExistsDirect, bumpRegistryVersionDirect, bumpRegistryVersionSimpleDirect, cleanupExecutionRowsDirect, clearDispatchQueueDirect, clearPeerCredentialsRevokedAtByIdsDirect, computeMigrationsHash, countActivePeerCredentialsByInstanceDirect, countCronLastFiredDirect, countHeldRunsByRunIdDirect, countWebhookSourcesByConnectionIdDirect, createContextTemplateDirect, createDbRole, createJoinTokenDirect, createReadOnlyDbUser, deleteContextDirect, deleteCronLastFiredDirect, deleteCrossRepoTrustDirect, deleteExecutionRunsByWorkflowNameDirect, deleteGenericWebhookSourcesByNameDirect, deleteJoinTokensByCreatedByDirect, deleteKiciEventsDirect, deleteOrgSettingsByCustomerIdDirect, deletePeerCredentialsByInstanceIdLikeDirect, deleteWorkflowRegistrationsDirect, describeTableColumnsDirect, dropAndCreateDatabase, dropDatabaseDirect, emitKiciEventDirect, ensureDatabase, ensureOrgOwnerMemberDirect, findAnyUserApiKeyIdDirect, getExecutionRunSecurityDirect, getGenericWebhookSourceByRoutingKeyDirect, getHeldRunByIdDirect, getPeerCredentialRevokedAtDirect, getRegistryVersionDirect, getWebhookSourceByRoutingKeyDirect, getWorkflowRegistrationByIdDirect, insertCronLastFiredNowDirect, insertCrossRepoTrustStrictDirect, insertKiciEventAtDirect, insertKiciEventRawDirect, insertPeerCredentialExpiredDirect, insertWorkflowRegistrationRawDirect, insertWorkflowRegistrationStrictDirect, isSchemaCurrent, isSchemaCurrentFromFilesDirect, latestExecutionRunByStatusDirect, listActiveGenericWebhookSourcesDirect, listActivePeerCredentialsExcludingDirect, listContextsDirect, listCrossRepoTrustBySourceRoutingKeyDirect, listExecutionJobsDirect, listExecutionRunsDirect, listHeldRunApprovalsDirect, listKiciEventsDirect, listQueueDirect, listRegistrationsByRoutingKeyDirect, listRegistrationsDirect, maskDatabaseUrl, paginateUnprocessedEventsKeysetDirect, parseDatabaseUrl, platformConnectionExistsDirect, pollKiciEventsDirect, prunePeerCredentialsDirect, purgeContextsDirect, purgeScopedSecretsDirect, purgeSecretBackendsDirect, purgeStaleExecutionDirect, purgeStaleSourcesDirect, readStoredMigrationContentHash, registerWorkflowManualDirect, resetRaftStateDirect, resolvePlatformWebhookSourceRoutingKeyDirect, restoreSoftDeletedGenericWebhookSourceDirect, seedApiKeyInlineDirect, seedCiSecurityFixturesDirect, seedContextBindingDirect, seedContextDirect, seedCrossRepoTrustDirect, seedGenericWebhookSourceDirect, seedSourcePrivateKeyDirect, seedSyntheticGithubSourceDirect, seedUniversalGitSourceDirect, seedWebhookSecretDirect, setContextPolicyDirect, setContextSecretDirect, showContextDirect, showExecutionRunDirect, showKiciEventDirect, showQueueEntryDirect, showRegistrationDirect, storeMigrationContentHash, storeMigrationContentHashInTableDirect, tableExistsDirect, terminateIdleDbBackendsDirect, updateGenericWebhookVerificationConfigDirect, updateOrgSettingsDeniedReposDirect, updateSourceRoutingKeyDirect, updateWorkflowRegistrationCommitShaDirect, upsertCronLastFiredDirect, upsertOrgSettingsGlobalWorkflowsDirect, verifyKiciEventNotifyDirect, waitForEventLogRowByDeliveryIdDirect, waitForExecutionRunStatusSinceDirect, waitForLatestExecutionJobStatusDirect, waitForPlatformEventLogDistinctRoutedDirect, waitForPlatformExecutionRunStatusDirect, waitForPlatformRegistrationsDirect, waitForPostgresDirect, waitForRegistrationsByRoutingKeyDirect, waitForRegistrationsUpdatedAtAdvanceDirect, waitForRunCompletionDirect };
2871
+ export { CI_SECURITY_OTHER_REPO, MIGRATION_HASH_TABLE, PROVIDER_HASH_KEY, REGISTERABLE_TRIGGER_TYPES, apiKeyExistsDirect, bumpRegistryVersionDirect, bumpRegistryVersionSimpleDirect, cleanupExecutionRowsDirect, clearDispatchQueueDirect, clearPeerCredentialsRevokedAtByIdsDirect, computeMigrationsHash, countActivePeerCredentialsByInstanceDirect, countCronLastFiredDirect, countHeldRunsByRunIdDirect, countWebhookSourcesByConnectionIdDirect, createContextTemplateDirect, createDbRole, createJoinTokenDirect, createReadOnlyDbUser, deleteContextDirect, deleteCronLastFiredDirect, deleteCrossRepoTrustDirect, deleteExecutionRunsByWorkflowNameDirect, deleteGenericWebhookSourcesByNameDirect, deleteJoinTokensByCreatedByDirect, deleteKiciEventsDirect, deleteOrgSettingsByCustomerIdDirect, deletePeerCredentialsByInstanceIdLikeDirect, deleteWorkflowRegistrationsDirect, describeTableColumnsDirect, dropAndCreateDatabase, dropDatabaseDirect, emitKiciEventDirect, ensureDatabase, ensureOrgOwnerMemberDirect, findAnyUserApiKeyIdDirect, getExecutionRunSecurityDirect, getGenericWebhookSourceByRoutingKeyDirect, getHeldRunByIdDirect, getPeerCredentialRevokedAtDirect, getRegistryVersionDirect, getWebhookSourceByRoutingKeyDirect, getWorkflowRegistrationByIdDirect, insertCronLastFiredNowDirect, insertCrossRepoTrustStrictDirect, insertKiciEventAtDirect, insertKiciEventRawDirect, insertPeerCredentialExpiredDirect, insertWorkflowRegistrationRawDirect, insertWorkflowRegistrationStrictDirect, isSchemaCurrent, isSchemaCurrentFromFilesDirect, latestExecutionRunByStatusDirect, listActiveGenericWebhookSourcesDirect, listActivePeerCredentialsExcludingDirect, listCheckRunTrackingDirect, listContextsDirect, listCrossRepoTrustBySourceRoutingKeyDirect, listExecutionJobsDirect, listExecutionRunsDirect, listHeldRunApprovalsDirect, listKiciEventsDirect, listQueueDirect, listRegistrationsByRoutingKeyDirect, listRegistrationsDirect, maskDatabaseUrl, paginateUnprocessedEventsKeysetDirect, parseDatabaseUrl, platformConnectionExistsDirect, pollKiciEventsDirect, prunePeerCredentialsDirect, purgeContextsDirect, purgeScopedSecretsDirect, purgeSecretBackendsDirect, purgeStaleExecutionDirect, purgeStaleSourcesDirect, readStoredMigrationContentHash, registerWorkflowManualDirect, resetRaftStateDirect, resolvePlatformWebhookSourceRoutingKeyDirect, restoreSoftDeletedGenericWebhookSourceDirect, seedApiKeyInlineDirect, seedCiSecurityFixturesDirect, seedContextBindingDirect, seedContextDirect, seedCrossRepoTrustDirect, seedGenericWebhookSourceDirect, seedSourcePrivateKeyDirect, seedSyntheticGithubSourceDirect, seedUniversalGitSourceDirect, seedWebhookSecretDirect, setContextPolicyDirect, setContextSecretDirect, showContextDirect, showExecutionRunDirect, showKiciEventDirect, showQueueEntryDirect, showRegistrationDirect, storeMigrationContentHash, storeMigrationContentHashInTableDirect, tableExistsDirect, terminateIdleDbBackendsDirect, updateGenericWebhookVerificationConfigDirect, updateOrgSettingsDeniedReposDirect, updateSourceRoutingKeyDirect, updateWorkflowRegistrationCommitShaDirect, upsertCronLastFiredDirect, upsertOrgSettingsGlobalWorkflowsDirect, verifyKiciEventNotifyDirect, waitForEventLogRowByDeliveryIdDirect, waitForExecutionRunReachesStatusSinceDirect, waitForLatestExecutionJobStatusDirect, waitForPlatformEventLogDistinctRoutedDirect, waitForPlatformExecutionRunStatusDirect, waitForPlatformRegistrationsDirect, waitForPostgresDirect, waitForRegistrationsByRoutingKeyDirect, waitForRegistrationsUpdatedAtAdvanceDirect, waitForRunCompletionDirect };
2758
2872
 
2759
2873
  //# sourceMappingURL=db-admin.js.map
package/dist/db.d.ts CHANGED
@@ -2,6 +2,15 @@ import pg from 'pg';
2
2
  import { Kysely } from 'kysely';
3
3
  /** Where a pg connection error surfaced. */
4
4
  export type PgPoolErrorSource = 'idle-pool' | 'client';
5
+ /**
6
+ * Outcome of a single pool acquire.
7
+ *
8
+ * `'timeout'` means the caller waited the pool's full `connectionTimeoutMillis`
9
+ * and was refused a connection — a load condition. A backend that cannot be
10
+ * reached rejects with a connection error instead and is deliberately NOT
11
+ * reported here; that is a different condition with a different owner.
12
+ */
13
+ export type PoolAcquireOutcome = 'ok' | 'timeout';
5
14
  export interface CreatePoolOptions {
6
15
  /** Extra pg.Pool config merged over the connection string (e.g. max, connectionTimeoutMillis). */
7
16
  config?: Omit<pg.PoolConfig, 'connectionString'>;
@@ -11,6 +20,23 @@ export interface CreatePoolOptions {
11
20
  * never replaces the log.
12
21
  */
13
22
  onError?: (err: Error, source: PgPoolErrorSource) => void;
23
+ /**
24
+ * Optional hook invoked once per `pool.connect()` acquire on this pool, with
25
+ * the outcome and how long the caller waited.
26
+ *
27
+ * Only the promise form of `connect` is instrumented, so an acquire made via
28
+ * `pool.query(...)` is NOT reported — pg implements `query` on top of the
29
+ * callback form of `connect`. The exclusion is symmetric (neither outcome is
30
+ * reported), so a ratio derived from this hook stays well-formed; but work
31
+ * whose acquires must be counted has to go through `pool.connect()`, as
32
+ * Kysely's PostgresDialect does.
33
+ *
34
+ * Opt-in: a pool created without it behaves exactly as before. A consumer
35
+ * that derives a load signal from acquire outcomes wires it on the pool whose
36
+ * saturation actually matters to it — one hook shared across unrelated pools
37
+ * would attribute one pool's exhaustion to another pool's traffic.
38
+ */
39
+ onAcquire?: (outcome: PoolAcquireOutcome, waitedMs: number) => void;
14
40
  }
15
41
  /**
16
42
  * Create PostgreSQL connection pool.
@@ -23,6 +49,14 @@ export interface CreatePoolOptions {
23
49
  * the next acquire. In-flight query failures still reject to their callers.
24
50
  */
25
51
  export declare function createPool(databaseUrl: string, options?: CreatePoolOptions): pg.Pool;
52
+ /**
53
+ * node-postgres rejects a pool-acquire timeout (the `connectionTimeoutMillis`
54
+ * window elapsed with no free connection) with this exact message. It is the
55
+ * only signal pg exposes to tell "pool busy (load)" apart from "backend
56
+ * unreachable". Centralized here + covered by one unit test so a pg-version
57
+ * bump that changes the string fails loudly in one place.
58
+ */
59
+ export declare function isPoolAcquireTimeout(err: unknown): boolean;
26
60
  /**
27
61
  * Create Kysely database instance (PostgreSQL only).
28
62
  *
package/dist/db.js CHANGED
@@ -38,9 +38,38 @@ function createPool(databaseUrl, options) {
38
38
  pool.on("connect", (client) => {
39
39
  client.on("error", (err) => handle(err, "client"));
40
40
  });
41
+ if (options?.onAcquire) {
42
+ const report = options.onAcquire;
43
+ const original = pool.connect.bind(pool);
44
+ pool.connect = function connect(cb) {
45
+ if (typeof cb === "function") return original(cb);
46
+ const startedAt = Date.now();
47
+ return original().then((client) => {
48
+ try {
49
+ report("ok", Date.now() - startedAt);
50
+ } catch {}
51
+ return client;
52
+ }, (err) => {
53
+ try {
54
+ if (isPoolAcquireTimeout(err)) report("timeout", Date.now() - startedAt);
55
+ } catch {}
56
+ throw err;
57
+ });
58
+ };
59
+ }
41
60
  return pool;
42
61
  }
43
62
  /**
63
+ * node-postgres rejects a pool-acquire timeout (the `connectionTimeoutMillis`
64
+ * window elapsed with no free connection) with this exact message. It is the
65
+ * only signal pg exposes to tell "pool busy (load)" apart from "backend
66
+ * unreachable". Centralized here + covered by one unit test so a pg-version
67
+ * bump that changes the string fails loudly in one place.
68
+ */
69
+ function isPoolAcquireTimeout(err) {
70
+ return err instanceof Error && err.message === "timeout exceeded when trying to connect";
71
+ }
72
+ /**
44
73
  * Create Kysely database instance (PostgreSQL only).
45
74
  *
46
75
  * Generic over the database type so each consumer can provide
@@ -50,6 +79,6 @@ function createDb(pool) {
50
79
  return new Kysely({ dialect: new PostgresDialect({ pool }) });
51
80
  }
52
81
  //#endregion
53
- export { createDb, createPool };
82
+ export { createDb, createPool, isPoolAcquireTimeout };
54
83
 
55
84
  //# sourceMappingURL=db.js.map
@@ -1,6 +1,6 @@
1
1
  import "../rolldown-runtime-ClRpJifh.js";
2
2
  import * as fs from "node:fs";
3
- import * as path from "node:path";
3
+ import * as path$1 from "node:path";
4
4
  //#region src/diagnostics/bundle-archive.ts
5
5
  /**
6
6
  * Shared debug-bundle archive primitives.
@@ -103,18 +103,18 @@ async function addLogsToArchive(archive, logDir, logWindowHours) {
103
103
  const cutoff = Date.now() - logWindowHours * 60 * 60 * 1e3;
104
104
  const entries = fs.readdirSync(logDir).filter((f) => {
105
105
  if (!f.endsWith(".log")) return false;
106
- return fs.statSync(path.join(logDir, f)).mtimeMs >= cutoff;
106
+ return fs.statSync(path$1.join(logDir, f)).mtimeMs >= cutoff;
107
107
  });
108
108
  entries.sort((a, b) => {
109
- const aStat = fs.statSync(path.join(logDir, a));
110
- return fs.statSync(path.join(logDir, b)).mtimeMs - aStat.mtimeMs;
109
+ const aStat = fs.statSync(path$1.join(logDir, a));
110
+ return fs.statSync(path$1.join(logDir, b)).mtimeMs - aStat.mtimeMs;
111
111
  });
112
112
  let totalBytes = 0;
113
113
  let totalLines = 0;
114
114
  let errors = 0;
115
115
  let warnings = 0;
116
116
  for (const entry of entries) {
117
- const filePath = path.join(logDir, entry);
117
+ const filePath = path$1.join(logDir, entry);
118
118
  const stat = fs.statSync(filePath);
119
119
  if (totalBytes + stat.size > 52428800) break;
120
120
  const content = fs.readFileSync(filePath, "utf-8");
@@ -71,6 +71,11 @@
71
71
  * dashboard's vite.config consults to disable
72
72
  * the dev proxy), HEADED (Playwright convention
73
73
  * for `--headed` runs).
74
+ * - Vitest runtime — VITEST_* (vitest's own names, which it reads
75
+ * after config resolution: VITEST_MAX_WORKERS
76
+ * carries the per-process worker ceiling
77
+ * `hack/lib/vitest-workers.ts` publishes, and
78
+ * VITEST_POOL_ID marks a pool worker).
74
79
  */
75
80
  export declare const OS_SDK_ALLOWLIST_REGEX: RegExp;
76
81
  /**
@@ -73,8 +73,13 @@ import "../rolldown-runtime-ClRpJifh.js";
73
73
  * dashboard's vite.config consults to disable
74
74
  * the dev proxy), HEADED (Playwright convention
75
75
  * for `--headed` runs).
76
+ * - Vitest runtime — VITEST_* (vitest's own names, which it reads
77
+ * after config resolution: VITEST_MAX_WORKERS
78
+ * carries the per-process worker ceiling
79
+ * `hack/lib/vitest-workers.ts` publishes, and
80
+ * VITEST_POOL_ID marks a pool worker).
76
81
  */
77
- const OS_SDK_ALLOWLIST_REGEX = /^(KICI_.*|NODE_ENV|HOME|PATH|TZ|LANG|TMPDIR|USER|USERNAME|SHELL|COMSPEC|PWD|OLDPWD|HOSTNAME|PROCESSOR_ARCHITECTURE|COLUMNS|LINES|TERM|COLORTERM|DISPLAY|WAYLAND_DISPLAY|LOCALAPPDATA|XDG_CACHE_HOME|XDG_CONFIG_HOME|XDG_DATA_HOME|XDG_RUNTIME_DIR|XDG_STATE_HOME|INIT_CWD|npm_.*|SSH_.*|CI|GITHUB_ACTIONS|GITHUB_ENV|GITHUB_OUTPUT|GITHUB_PATH|GITHUB_STEP_SUMMARY|GITLAB_CI|AWS_.*|REDIS_.*|OTEL_.*|STRIPE_.*|DOCKER_.*|GIT_.*|CONTAINER_HOST|container|PGHOST|PGPORT|PGUSER|PGPASSWORD|PGDATABASE|PGSERVICEFILE|PGSSLMODE|FORGEJO_URL|FORGEJO_CONTAINER|KEYCLOAK_.*|VITE_.*|PLAYWRIGHT|HEADED)$/;
82
+ const OS_SDK_ALLOWLIST_REGEX = /^(KICI_.*|NODE_ENV|HOME|PATH|TZ|LANG|TMPDIR|USER|USERNAME|SHELL|COMSPEC|PWD|OLDPWD|HOSTNAME|PROCESSOR_ARCHITECTURE|COLUMNS|LINES|TERM|COLORTERM|DISPLAY|WAYLAND_DISPLAY|LOCALAPPDATA|XDG_CACHE_HOME|XDG_CONFIG_HOME|XDG_DATA_HOME|XDG_RUNTIME_DIR|XDG_STATE_HOME|INIT_CWD|npm_.*|SSH_.*|CI|GITHUB_ACTIONS|GITHUB_ENV|GITHUB_OUTPUT|GITHUB_PATH|GITHUB_STEP_SUMMARY|GITLAB_CI|AWS_.*|REDIS_.*|OTEL_.*|STRIPE_.*|DOCKER_.*|GIT_.*|CONTAINER_HOST|container|PGHOST|PGPORT|PGUSER|PGPASSWORD|PGDATABASE|PGSERVICEFILE|PGSSLMODE|FORGEJO_URL|FORGEJO_CONTAINER|KEYCLOAK_.*|VITE_.*|VITEST_.*|PLAYWRIGHT|HEADED)$/;
78
83
  /**
79
84
  * Returns true when `name` is allowed under the KiCI env-var convention:
80
85
  * it matches the OS/SDK allowlist regex (which includes the `KICI_*`
@@ -66,8 +66,15 @@ export interface DefineEnvOptions<TShape extends z.ZodRawShape> {
66
66
  descriptions?: Record<string, string>;
67
67
  }
68
68
  export interface DefineEnvResult<T> {
69
- /** Parse `env` (defaults to `process.env`) into a typed config. */
70
- parse(env?: NodeJS.ProcessEnv): T;
69
+ /**
70
+ * Parse `env` (defaults to `process.env`) into a typed config.
71
+ *
72
+ * `parserOverride` is an optional per-call parser (e.g. a scope-narrowed
73
+ * `.superRefine`) that replaces the default parser for this parse only —
74
+ * used to validate the same env map under a looser/stricter cross-field
75
+ * rule set without duplicating the envMap.
76
+ */
77
+ parse(env?: NodeJS.ProcessEnv, parserOverride?: z.ZodType): T;
71
78
  /** Machine-readable field specs for docs generation. */
72
79
  describe(): EnvFieldSpec[];
73
80
  /** Flat list of every env var the schema reads (for the unknown-var scanner). */
@@ -101,6 +108,16 @@ export declare function defineEnv<TShape extends z.ZodRawShape>(opts: DefineEnvO
101
108
  * (on POSIX it's a shell-local var that doesn't leak).
102
109
  * - `KICI_DEV`: the dev-mode toggle itself — read by the scanner to flip
103
110
  * to warn-only, so it must not trip the scanner.
111
+ * - `KICI_BUILD_COUNTER_NO_COMMIT`: a build-tooling flag read only by
112
+ * `hack/lib/commit-build-counter.mjs` (skip the per-build `.build-counter`
113
+ * commit on a force-synced checkout, e.g. a remote E2E executor). Set in the
114
+ * ambient shell for `pnpm build`; the native orchestrator spawn inherits it.
115
+ * - `KICI_TEST_ISOLATION`: the test-isolation marker set at config-eval time
116
+ * by every vitest config in this repository (`hack/lib/vitest-isolation.ts`,
117
+ * enforced by `hack/check-vitest-isolation.ts`). It makes the CLI's
118
+ * `getConfigDir` refuse the developer machine's ambient `~/.kici` config, and
119
+ * it is inherited by every service a test spawns — same leak-by-inheritance
120
+ * shape as the `KICI_E2E_` prefix below.
104
121
  *
105
122
  * Keep this list small and well-justified. Every addition is a typo we can
106
123
  * no longer catch, so only list things that are (a) actually set in the
@@ -155,8 +155,8 @@ function describeFieldRecursive(shape, envMap, fieldPath, descriptions, out) {
155
155
  * const config = envDef.parse();
156
156
  */
157
157
  function defineEnv(opts) {
158
- const parserSchema = opts.parser ?? opts.schema;
159
- function parse(env = process.env) {
158
+ function parse(env = process.env, parserOverride) {
159
+ const parserSchema = parserOverride ?? opts.parser ?? opts.schema;
160
160
  const raw = readEnv(opts.envMap, env);
161
161
  const result = parserSchema.safeParse(raw);
162
162
  if (!result.success) {
@@ -227,12 +227,27 @@ function suggestClosest(name, candidates) {
227
227
  * (on POSIX it's a shell-local var that doesn't leak).
228
228
  * - `KICI_DEV`: the dev-mode toggle itself — read by the scanner to flip
229
229
  * to warn-only, so it must not trip the scanner.
230
+ * - `KICI_BUILD_COUNTER_NO_COMMIT`: a build-tooling flag read only by
231
+ * `hack/lib/commit-build-counter.mjs` (skip the per-build `.build-counter`
232
+ * commit on a force-synced checkout, e.g. a remote E2E executor). Set in the
233
+ * ambient shell for `pnpm build`; the native orchestrator spawn inherits it.
234
+ * - `KICI_TEST_ISOLATION`: the test-isolation marker set at config-eval time
235
+ * by every vitest config in this repository (`hack/lib/vitest-isolation.ts`,
236
+ * enforced by `hack/check-vitest-isolation.ts`). It makes the CLI's
237
+ * `getConfigDir` refuse the developer machine's ambient `~/.kici` config, and
238
+ * it is inherited by every service a test spawns — same leak-by-inheritance
239
+ * shape as the `KICI_E2E_` prefix below.
230
240
  *
231
241
  * Keep this list small and well-justified. Every addition is a typo we can
232
242
  * no longer catch, so only list things that are (a) actually set in the
233
243
  * wild by our own tooling and (b) could never be a config typo.
234
244
  */
235
- const RESERVED_NON_SCHEMA_KICI_VARS = ["KICI_CACHE", "KICI_DEV"];
245
+ const RESERVED_NON_SCHEMA_KICI_VARS = [
246
+ "KICI_CACHE",
247
+ "KICI_DEV",
248
+ "KICI_BUILD_COUNTER_NO_COMMIT",
249
+ "KICI_TEST_ISOLATION"
250
+ ];
236
251
  /**
237
252
  * `KICI_*` prefixes that are entirely outside the service-config namespace —
238
253
  * usually set by our own test / dev tooling and inherited into a child
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * Shared LoggerEnv schema.
3
3
  *
4
- * The logger (packages/shared/src/logger.ts) reads these env vars *before*
5
- * the per-service config loads, so we can't include them in the service
6
- * schemas the normal way. We still want them in `docs/operator/env-reference.md`
7
- * and in `validateUnknownKiciVars()`'s known-var set, so this schema documents
8
- * them in one place. Each service includes the keys here when computing its
9
- * "known KICI_* vars" list, and the docs generator emits a "Logger / shared"
10
- * section from this schema.
4
+ * The logger (packages/core/src/logger.ts) reads these env vars *before*
5
+ * the per-service config loads as does `kiciTmpBase()`
6
+ * (packages/core/src/tmp.ts) for the `KICI_TMPDIR` entry below so we can't
7
+ * include them in the service schemas the normal way. We still want them in
8
+ * `docs/operator/env-reference.md` and in `validateUnknownKiciVars()`'s
9
+ * known-var set, so this schema documents them in one place. Each service
10
+ * includes the keys here when computing its "known KICI_* vars" list, and the
11
+ * docs generator emits a "Logger / shared" section from this schema.
11
12
  *
12
13
  * IMPORTANT: do not change the runtime behaviour of `logger.ts` from this
13
14
  * schema — the schema is documentation + the unknown-var allowlist, not the
@@ -20,16 +21,17 @@ export declare const LoggerEnvSchema: z.ZodObject<{
20
21
  KICI_LOG_MAX_SIZE: z.ZodDefault<z.ZodString>;
21
22
  KICI_LOG_RETENTION_DAYS: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
22
23
  KICI_LOG_FORMAT: z.ZodDefault<z.ZodEnum<{
23
- json: "json";
24
24
  auto: "auto";
25
+ json: "json";
25
26
  plain: "plain";
26
27
  }>>;
27
28
  KICI_CLUSTER_INSTANCE_ID: z.ZodOptional<z.ZodString>;
28
29
  KICI_AGENT_ID: z.ZodOptional<z.ZodString>;
29
30
  KICI_PLATFORM_INSTANCE_ID: z.ZodOptional<z.ZodString>;
31
+ KICI_TMPDIR: z.ZodOptional<z.ZodString>;
30
32
  }, z.core.$strip>;
31
33
  /** All env vars the logger reads, for the unknown-KICI-var scanner. */
32
- export declare const LOGGER_ENV_VARS: readonly ["KICI_LOG_DIR", "KICI_LOG_MAX_SIZE", "KICI_LOG_RETENTION_DAYS", "KICI_LOG_FORMAT", "KICI_CLUSTER_INSTANCE_ID", "KICI_AGENT_ID", "KICI_PLATFORM_INSTANCE_ID"];
34
+ export declare const LOGGER_ENV_VARS: readonly ['KICI_LOG_DIR', 'KICI_LOG_MAX_SIZE', 'KICI_LOG_RETENTION_DAYS', 'KICI_LOG_FORMAT', 'KICI_CLUSTER_INSTANCE_ID', 'KICI_AGENT_ID', 'KICI_PLATFORM_INSTANCE_ID', 'KICI_TMPDIR'];
33
35
  /** Doc-friendly description map (consumed by the env-reference generator). */
34
36
  export declare const LOGGER_ENV_FIELD_SPECS: EnvFieldSpec[];
35
37
  //# sourceMappingURL=logger-env.d.ts.map