@kici-dev/orchestrator 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/agent/dispatcher.d.ts +22 -0
  2. package/dist/agent/registry.d.ts +31 -0
  3. package/dist/app.d.ts +6 -0
  4. package/dist/cache/cache-upload-settle.d.ts +75 -0
  5. package/dist/cache/pending-inits.d.ts +9 -0
  6. package/dist/cancel/cancel-run.d.ts +22 -4
  7. package/dist/cancel/peer-job-cancel.d.ts +50 -0
  8. package/dist/cancel/sweep-stuck-cancelling.d.ts +3 -1
  9. package/dist/cli/commands/local-hook.d.ts +3 -2
  10. package/dist/cli/commands/local-trigger.d.ts +7 -0
  11. package/dist/cli.js +983 -683
  12. package/dist/cluster/coordinator-credential.d.ts +45 -0
  13. package/dist/cluster/coordinator.d.ts +18 -2
  14. package/dist/cluster/index.d.ts +1 -0
  15. package/dist/cluster/instance-heartbeat.d.ts +6 -1
  16. package/dist/cluster/join-token.d.ts +2 -0
  17. package/dist/cluster/peer-auth-coordinator.d.ts +13 -0
  18. package/dist/cluster/peer-client.d.ts +4 -0
  19. package/dist/cluster/peer-credentials.d.ts +31 -0
  20. package/dist/config.d.ts +3 -0
  21. package/dist/db/migrations/152_org_settings_cache_upload_settle_timeout.d.ts +15 -0
  22. package/dist/db/migrations/153_execution_runs_cancel_force.d.ts +16 -0
  23. package/dist/db/types.d.ts +15 -0
  24. package/dist/index.js +192 -53
  25. package/dist/pipeline/changed-files.d.ts +51 -0
  26. package/dist/pipeline/cross-source-tally.d.ts +21 -0
  27. package/dist/pipeline/degraded-reason.d.ts +8 -0
  28. package/dist/pipeline/dispatch-matched-workflow.d.ts +10 -0
  29. package/dist/pipeline/global-eval-round.d.ts +3 -2
  30. package/dist/pipeline/processor.d.ts +9 -1
  31. package/dist/pipeline/rerun-inputs.d.ts +16 -7
  32. package/dist/providers/github/changed-files.d.ts +14 -4
  33. package/dist/providers/universal-git/changed-files.d.ts +6 -0
  34. package/dist/providers/universal-git/config.d.ts +4 -0
  35. package/dist/scaler/container-backend.d.ts +2 -0
  36. package/dist/scaler/container-runtime.d.ts +9 -0
  37. package/dist/scaler/manager.d.ts +26 -2
  38. package/dist/server.js +2710 -1486
  39. package/dist/stale-detector/workflow-deadline-detector.d.ts +7 -1
  40. package/dist/standalone.js +11527 -10302
  41. package/dist/storage/s3.d.ts +36 -0
  42. package/dist/ws/agent-handler.d.ts +24 -3
  43. package/dist/ws/agent-heartbeat.d.ts +2 -2
  44. package/installer-image-digests.json +3 -3
  45. package/package.json +5 -5
  46. package/sbom.spdx.json +53 -53
package/dist/index.js CHANGED
@@ -33,6 +33,7 @@ import { exec } from "node:child_process";
33
33
  * TTL is enforced lazily: expired items are deleted on access.
34
34
  * Pre-signed URLs are generated for getUrl() with 15-minute expiry.
35
35
  */
36
+ const logger$5 = createLogger({ prefix: "s3-cache" });
36
37
  /** Pre-signed download URL expiry in seconds (15 minutes) */
37
38
  const PRESIGNED_URL_EXPIRY_SECONDS = 900;
38
39
  /** Pre-signed upload URL expiry in seconds (30 minutes -- uploads take longer) */
@@ -46,6 +47,11 @@ var S3CacheStorage = class {
46
47
  bucket;
47
48
  prefix;
48
49
  ttlMs;
50
+ /**
51
+ * False once the backend answered a conditional self-copy with
52
+ * `501 NotImplemented`. From then on `updateMeta` copies unconditionally.
53
+ */
54
+ conditionalCopySupported = true;
49
55
  constructor(options) {
50
56
  this.bucket = options.bucket;
51
57
  this.prefix = options.prefix;
@@ -77,6 +83,13 @@ var S3CacheStorage = class {
77
83
  * Returns null if the object doesn't exist (NoSuchKey/NotFound).
78
84
  */
79
85
  async readMeta(key) {
86
+ return (await this.readHead(key))?.meta ?? null;
87
+ }
88
+ /**
89
+ * Read an object's TTL metadata together with the ETag of the version it
90
+ * belongs to. Returns null if the object doesn't exist (NoSuchKey/NotFound).
91
+ */
92
+ async readHead(key) {
80
93
  try {
81
94
  const head = await this.client.send(new HeadObjectCommand({
82
95
  Bucket: this.bucket,
@@ -86,8 +99,11 @@ var S3CacheStorage = class {
86
99
  const lastAccessedAt = head.Metadata?.["last-accessed-at"] ?? "";
87
100
  if (!createdAt || !lastAccessedAt) return null;
88
101
  return {
89
- createdAt,
90
- lastAccessedAt
102
+ meta: {
103
+ createdAt,
104
+ lastAccessedAt
105
+ },
106
+ ...head.ETag && { etag: head.ETag }
91
107
  };
92
108
  } catch (err) {
93
109
  if (this.isNotFoundError(err)) return null;
@@ -97,8 +113,41 @@ var S3CacheStorage = class {
97
113
  /**
98
114
  * Update metadata on an S3 object by copying it to itself.
99
115
  * This is the standard S3 pattern for updating metadata without re-uploading data.
116
+ *
117
+ * The copy is conditional on `etag`, the version whose metadata the caller
118
+ * read. A self-copy is a whole-object write: without the condition, one that
119
+ * races a `put()` of the same key can land after it and write the older body
120
+ * back. Cache pointers are rewritten in place while builds publish them, so a
121
+ * touch-on-read racing a publish reverted the pointer to a tarball that no
122
+ * longer exists. `CopySourceIfMatch` pins the bytes copied to that version and
123
+ * `IfMatch` refuses the write unless the key still holds it.
124
+ *
125
+ * Returns false when the object was replaced since the read (`412`), or is
126
+ * being replaced right now (`409 ConditionalRequestConflict`): the newer
127
+ * write stamps metadata of its own, so there is nothing left to refresh.
128
+ *
129
+ * An S3-compatible backend that does not implement conditional copies
130
+ * answers `501 NotImplemented`. The copy then runs again without the
131
+ * condition, and later copies skip it, as on a backend that ignores the
132
+ * headers.
100
133
  */
101
- async updateMeta(key, meta) {
134
+ async updateMeta(key, meta, etag) {
135
+ const condition = this.conditionalCopySupported ? etag : void 0;
136
+ try {
137
+ await this.copyOntoItself(key, meta, condition);
138
+ return true;
139
+ } catch (err) {
140
+ if (!condition) throw err;
141
+ if (this.isPreconditionFailedError(err) || this.isConditionalConflictError(err)) return false;
142
+ if (!this.isNotImplementedError(err)) throw err;
143
+ this.conditionalCopySupported = false;
144
+ logger$5.warn("The object store does not implement conditional copies; refreshing cache metadata unconditionally", { bucket: this.bucket });
145
+ await this.copyOntoItself(key, meta, void 0);
146
+ return true;
147
+ }
148
+ }
149
+ /** Copy an object onto itself with new TTL metadata, conditional on `etag` when given. */
150
+ async copyOntoItself(key, meta, etag) {
102
151
  const objectKey = this.objectKey(key);
103
152
  await this.client.send(new CopyObjectCommand({
104
153
  Bucket: this.bucket,
@@ -108,9 +157,30 @@ var S3CacheStorage = class {
108
157
  Metadata: {
109
158
  "created-at": meta.createdAt,
110
159
  "last-accessed-at": meta.lastAccessedAt
160
+ },
161
+ ...etag && {
162
+ CopySourceIfMatch: etag,
163
+ IfMatch: etag
111
164
  }
112
165
  }));
113
166
  }
167
+ /** Check if an error is a failed conditional request (PreconditionFailed, 412). */
168
+ isPreconditionFailedError(err) {
169
+ return this.hasErrorShape(err, "PreconditionFailed", 412);
170
+ }
171
+ /** Check if an error is a conditional write that raced another write (ConditionalRequestConflict, 409). */
172
+ isConditionalConflictError(err) {
173
+ return this.hasErrorShape(err, "ConditionalRequestConflict", 409);
174
+ }
175
+ /** Check if an error is a request the backend does not implement (NotImplemented, 501). */
176
+ isNotImplementedError(err) {
177
+ return this.hasErrorShape(err, "NotImplemented", 501);
178
+ }
179
+ hasErrorShape(err, name, status) {
180
+ if (typeof err !== "object" || err === null) return false;
181
+ if (err.name === name) return true;
182
+ return err.$metadata?.httpStatusCode === status;
183
+ }
114
184
  /** Check if an error is a "not found" error (NoSuchKey, NotFound, 404). */
115
185
  isNotFoundError(err) {
116
186
  if (typeof err !== "object" || err === null) return false;
@@ -135,8 +205,9 @@ var S3CacheStorage = class {
135
205
  }).done();
136
206
  }
137
207
  async get(key, ttlMsOverride) {
138
- const meta = await this.readMeta(key);
139
- if (!meta) return null;
208
+ const head = await this.readHead(key);
209
+ if (!head) return null;
210
+ const { meta, etag } = head;
140
211
  if (this.isExpired(meta, ttlMsOverride)) {
141
212
  await this.deleteObject(key);
142
213
  return null;
@@ -155,7 +226,7 @@ var S3CacheStorage = class {
155
226
  }
156
227
  try {
157
228
  meta.lastAccessedAt = (/* @__PURE__ */ new Date()).toISOString();
158
- await this.updateMeta(key, meta);
229
+ await this.updateMeta(key, meta, etag);
159
230
  } catch {}
160
231
  return data;
161
232
  }
@@ -174,10 +245,10 @@ var S3CacheStorage = class {
174
245
  return existed;
175
246
  }
176
247
  async touch(key) {
177
- const meta = await this.readMeta(key);
178
- if (!meta) return;
179
- meta.lastAccessedAt = (/* @__PURE__ */ new Date()).toISOString();
180
- await this.updateMeta(key, meta);
248
+ const head = await this.readHead(key);
249
+ if (!head) return;
250
+ head.meta.lastAccessedAt = (/* @__PURE__ */ new Date()).toISOString();
251
+ await this.updateMeta(key, head.meta, head.etag);
181
252
  }
182
253
  async getUrl(key, ttlMsOverride) {
183
254
  const meta = await this.readMeta(key);
@@ -633,7 +704,7 @@ function createCacheStorage(config) {
633
704
  * Old objects are orphaned. The cache TTL is enforced lazily on access, so
634
705
  * nothing here removes them; `kici-admin cache purge-legacy` does.
635
706
  */
636
- const logger$3 = createLogger({ prefix: "source-cache" });
707
+ const logger$4 = createLogger({ prefix: "source-cache" });
637
708
  /**
638
709
  * Cache key for a source tarball, addressed by the tarball's OWN content hash:
639
710
  * `source/v2/{orgId}/{sourceTarDigest}.tar.gz`.
@@ -676,25 +747,25 @@ var SourceCache = class SourceCache {
676
747
  async has(orgId, contentHash) {
677
748
  const digest = await this.resolvePointer(orgId, contentHash);
678
749
  if (!digest) {
679
- logger$3.debug(`has(${contentHash}): false (no pointer)`, { orgId });
750
+ logger$4.debug(`has(${contentHash}): false (no pointer)`, { orgId });
680
751
  return false;
681
752
  }
682
753
  const exists = await this.storage.has(sourceTarballKey(orgId, digest));
683
- logger$3.debug(`has(${contentHash}): ${exists}`, { orgId });
754
+ logger$4.debug(`has(${contentHash}): ${exists}`, { orgId });
684
755
  return exists;
685
756
  }
686
757
  async get(orgId, contentHash) {
687
758
  const digest = await this.resolvePointer(orgId, contentHash);
688
759
  if (!digest) {
689
- logger$3.debug(`get(${contentHash}): miss (no pointer)`, { orgId });
760
+ logger$4.debug(`get(${contentHash}): miss (no pointer)`, { orgId });
690
761
  return null;
691
762
  }
692
763
  const key = sourceTarballKey(orgId, digest);
693
764
  const data = await this.storage.get(key);
694
765
  if (data) {
695
766
  await this.storage.touch(key);
696
- logger$3.debug(`get(${contentHash}): hit (${data.length} bytes)`, { orgId });
697
- } else logger$3.debug(`get(${contentHash}): miss (dangling pointer)`, { orgId });
767
+ logger$4.debug(`get(${contentHash}): hit (${data.length} bytes)`, { orgId });
768
+ } else logger$4.debug(`get(${contentHash}): miss (dangling pointer)`, { orgId });
698
769
  return data;
699
770
  }
700
771
  /**
@@ -710,18 +781,18 @@ var SourceCache = class SourceCache {
710
781
  const pointerKey = sourcePointerKey(orgId, contentHash);
711
782
  const digest = (await this.storage.get(pointerKey))?.toString("utf-8").trim() || null;
712
783
  if (!digest) {
713
- logger$3.debug(`getUrlAndDigest(${contentHash}): miss (no pointer)`, { orgId });
784
+ logger$4.debug(`getUrlAndDigest(${contentHash}): miss (no pointer)`, { orgId });
714
785
  return null;
715
786
  }
716
787
  const key = sourceTarballKey(orgId, digest);
717
788
  const url = await this.storage.getUrl(key);
718
789
  if (!url) {
719
- logger$3.debug(`getUrlAndDigest(${contentHash}): miss (dangling pointer)`, { orgId });
790
+ logger$4.debug(`getUrlAndDigest(${contentHash}): miss (dangling pointer)`, { orgId });
720
791
  return null;
721
792
  }
722
793
  await this.storage.touch(key);
723
794
  await this.storage.touch(pointerKey);
724
- logger$3.debug(`getUrlAndDigest(${contentHash}): hit`, { orgId });
795
+ logger$4.debug(`getUrlAndDigest(${contentHash}): hit`, { orgId });
725
796
  return {
726
797
  url,
727
798
  digest
@@ -747,7 +818,7 @@ var SourceCache = class SourceCache {
747
818
  */
748
819
  async publishPointer(orgId, contentHash, sourceTarDigest) {
749
820
  await this.storage.put(sourcePointerKey(orgId, contentHash), sourceTarDigest);
750
- logger$3.info(`publishPointer(${contentHash}) -> ${sourceTarDigest.slice(0, 12)}`, { orgId });
821
+ logger$4.info(`publishPointer(${contentHash}) -> ${sourceTarDigest.slice(0, 12)}`, { orgId });
751
822
  }
752
823
  /** Store a tarball and the pointer that names it. */
753
824
  async store(orgId, contentHash, tarball) {
@@ -755,7 +826,7 @@ var SourceCache = class SourceCache {
755
826
  const digest = SourceCache.computeDigest(data);
756
827
  await this.storage.put(sourceTarballKey(orgId, digest), data);
757
828
  await this.storage.put(sourcePointerKey(orgId, contentHash), digest);
758
- logger$3.info(`store(${contentHash}): stored (${data.length} bytes)`, {
829
+ logger$4.info(`store(${contentHash}): stored (${data.length} bytes)`, {
759
830
  orgId,
760
831
  digest: digest.slice(0, 12)
761
832
  });
@@ -769,7 +840,7 @@ var SourceCache = class SourceCache {
769
840
  const digest = await this.resolvePointer(orgId, contentHash);
770
841
  await this.storage.delete(sourcePointerKey(orgId, contentHash));
771
842
  const removed = digest ? await this.storage.delete(sourceTarballKey(orgId, digest)) : false;
772
- logger$3.info(`remove(${contentHash}): ${removed ? "removed" : "not found"}`, { orgId });
843
+ logger$4.info(`remove(${contentHash}): ${removed ? "removed" : "not found"}`, { orgId });
773
844
  return removed;
774
845
  }
775
846
  };
@@ -786,7 +857,7 @@ var SourceCache = class SourceCache {
786
857
  * Keys: `deps/{platform}-{arch}/{depsHash}.tar.gz` (immutable) and
787
858
  * `deps/{platform}-{arch}/{lockfileHash}.hash` (the pointer).
788
859
  */
789
- const logger$2 = createLogger({ prefix: "dep-cache" });
860
+ const logger$3 = createLogger({ prefix: "dep-cache" });
790
861
  /** Default max tarball size: 500MB */
791
862
  const DEFAULT_MAX_TARBALL_BYTES = 524288e3;
792
863
  /** Default dependency-cache entry TTL: 30 days. */
@@ -867,14 +938,14 @@ var DepCache = class DepCache {
867
938
  const ttlMsOverride = await this.resolveTtlMsOverride();
868
939
  const depsHash = await this.resolvePointer(lockfileHash, platform, arch, ttlMsOverride, siblingsDigest);
869
940
  if (!depsHash) {
870
- logger$2.debug(`has(${lockfileHash}): false (no pointer)`, {
941
+ logger$3.debug(`has(${lockfileHash}): false (no pointer)`, {
871
942
  platform,
872
943
  arch
873
944
  });
874
945
  return false;
875
946
  }
876
947
  const exists = await this.storage.has(depTarballKey(depsHash, platform, arch), ttlMsOverride);
877
- logger$2.debug(`has(${lockfileHash}): ${exists}`, {
948
+ logger$3.debug(`has(${lockfileHash}): ${exists}`, {
878
949
  platform,
879
950
  arch
880
951
  });
@@ -898,7 +969,7 @@ var DepCache = class DepCache {
898
969
  const pointerKey = depPointerKey(lockfileHash, platform, arch, siblingsDigest);
899
970
  const hash = (await this.storage.get(pointerKey, ttlMsOverride))?.toString("utf-8").trim() || void 0;
900
971
  if (!hash) {
901
- logger$2.debug(`getUrlAndHash(${lockfileHash}): miss (no pointer)`, {
972
+ logger$3.debug(`getUrlAndHash(${lockfileHash}): miss (no pointer)`, {
902
973
  platform,
903
974
  arch
904
975
  });
@@ -907,7 +978,7 @@ var DepCache = class DepCache {
907
978
  const key = depTarballKey(hash, platform, arch);
908
979
  const url = await this.storage.getUrl(key, ttlMsOverride);
909
980
  if (!url) {
910
- logger$2.debug(`getUrlAndHash(${lockfileHash}): miss (dangling pointer)`, {
981
+ logger$3.debug(`getUrlAndHash(${lockfileHash}): miss (dangling pointer)`, {
911
982
  platform,
912
983
  arch
913
984
  });
@@ -915,7 +986,7 @@ var DepCache = class DepCache {
915
986
  }
916
987
  await this.storage.touch(key);
917
988
  await this.storage.touch(pointerKey);
918
- logger$2.debug(`getUrlAndHash(${lockfileHash}): hit`, {
989
+ logger$3.debug(`getUrlAndHash(${lockfileHash}): hit`, {
919
990
  platform,
920
991
  arch,
921
992
  hasHash: true
@@ -939,7 +1010,7 @@ var DepCache = class DepCache {
939
1010
  */
940
1011
  async publishPointer(lockfileHash, platform, arch, depsHash, siblingsDigest) {
941
1012
  await this.storage.put(depPointerKey(lockfileHash, platform, arch, siblingsDigest), depsHash);
942
- logger$2.info(`publishPointer(${lockfileHash}) -> ${depsHash.slice(0, 12)}`, {
1013
+ logger$3.info(`publishPointer(${lockfileHash}) -> ${depsHash.slice(0, 12)}`, {
943
1014
  platform,
944
1015
  arch
945
1016
  });
@@ -954,7 +1025,7 @@ var DepCache = class DepCache {
954
1025
  const depsHash = DepCache.computeHash(tarballData);
955
1026
  await this.storage.put(depTarballKey(depsHash, platform, arch), tarballData);
956
1027
  await this.storage.put(depPointerKey(lockfileHash, platform, arch), depsHash);
957
- logger$2.info(`store: ${tarballData.length} bytes`, {
1028
+ logger$3.info(`store: ${tarballData.length} bytes`, {
958
1029
  lockfileHash,
959
1030
  platform,
960
1031
  arch,
@@ -973,7 +1044,7 @@ var DepCache = class DepCache {
973
1044
  const depsHash = await this.resolvePointer(lockfileHash, platform, arch, await this.resolveTtlMsOverride());
974
1045
  await this.storage.delete(depPointerKey(lockfileHash, platform, arch));
975
1046
  const removed = depsHash ? await this.storage.delete(depTarballKey(depsHash, platform, arch)) : false;
976
- logger$2.info(`remove(${lockfileHash}): ${removed ? "removed" : "not found"}`, {
1047
+ logger$3.info(`remove(${lockfileHash}): ${removed ? "removed" : "not found"}`, {
977
1048
  platform,
978
1049
  arch
979
1050
  });
@@ -1017,7 +1088,7 @@ function keyDiscriminator(exact) {
1017
1088
  * hash and size accounting outside the tarball's own (presigned, metadata-less)
1018
1089
  * upload.
1019
1090
  */
1020
- const logger$1 = createLogger({ prefix: "user-cache" });
1091
+ const logger$2 = createLogger({ prefix: "user-cache" });
1021
1092
  /**
1022
1093
  * Cluster-wide default quota: 5 GiB. Serves as the fallback when an org has no
1023
1094
  * per-org override in `org_settings.user_cache_quota_bytes`. The cluster-wide
@@ -1165,7 +1236,7 @@ var UserCache = class {
1165
1236
  try {
1166
1237
  limits = await this.orgLimitsReader(org);
1167
1238
  } catch (err) {
1168
- logger$1.warn("user-cache org-limits lookup failed — using cluster defaults", {
1239
+ logger$2.warn("user-cache org-limits lookup failed — using cluster defaults", {
1169
1240
  org,
1170
1241
  error: err instanceof Error ? err.message : String(err)
1171
1242
  });
@@ -1222,7 +1293,7 @@ var UserCache = class {
1222
1293
  async beginSave(ref) {
1223
1294
  const final = userCacheEntryKey(ref, ref.key);
1224
1295
  if (await this.storage.has(final)) {
1225
- logger$1.info("user-cache save skipped (immutable key exists)", { key: ref.key });
1296
+ logger$2.info("user-cache save skipped (immutable key exists)", { key: ref.key });
1226
1297
  return { skip: true };
1227
1298
  }
1228
1299
  const tempKey = `${writePrefix(ref)}${TEMP_UPLOAD_STEM}${randomUUID()}${TAR_SUFFIX}`;
@@ -1243,7 +1314,7 @@ var UserCache = class {
1243
1314
  await this.storage.initMeta(final);
1244
1315
  await this.storage.put(`${final}.hash`, ref.tarHash);
1245
1316
  await this.storage.put(`${final}.size`, String(ref.sizeBytes));
1246
- logger$1.info("user-cache entry committed", {
1317
+ logger$2.info("user-cache entry committed", {
1247
1318
  key: ref.key,
1248
1319
  sizeBytes: ref.sizeBytes
1249
1320
  });
@@ -1293,7 +1364,7 @@ var UserCache = class {
1293
1364
  await this.storage.delete(`${key}.hash`);
1294
1365
  await this.storage.delete(`${key}.size`);
1295
1366
  total -= size;
1296
- logger$1.info("user-cache eviction (org over quota)", {
1367
+ logger$2.info("user-cache eviction (org over quota)", {
1297
1368
  org: ref.org,
1298
1369
  key,
1299
1370
  freedBytes: size
@@ -1301,18 +1372,12 @@ var UserCache = class {
1301
1372
  }
1302
1373
  }
1303
1374
  };
1304
- //#endregion
1305
- //#region src/cluster/peer-credentials.ts
1306
1375
  /**
1307
- * Peer credential management for cluster authentication.
1308
- *
1309
- * Provides two persistence layers:
1310
- * 1. DB CRUD (PeerCredentialStore) — coordinator stores credentials for all peers
1311
- * 2. File I/O (readCredentialFile/writeCredentialFile) — workers persist their
1312
- * credential locally for reconnection after restart
1313
- *
1314
- * Credential files are written with 0600 permissions to protect the secret.
1376
+ * `metadata` key set when a coordinator retires the self-issued credential its
1377
+ * previous run left behind. An operator revoke never sets it, which is how a
1378
+ * retirement is told apart from a revoke.
1315
1379
  */
1380
+ const RETIRED_BY_INSTANCE_KEY = "retiredByInstance";
1316
1381
  /** Default credential expiry: 90 days. */
1317
1382
  const DEFAULT_EXPIRY_DAYS = 90;
1318
1383
  /**
@@ -1349,7 +1414,8 @@ var PeerCredentialStore = class {
1349
1414
  role: opts.role,
1350
1415
  routing_keys: sql`${sql.val(opts.routingKeys)}`,
1351
1416
  source_token_hash: opts.sourceTokenHash ?? null,
1352
- expires_at: expiresAt
1417
+ expires_at: expiresAt,
1418
+ ...opts.metadata && { metadata: sql`${JSON.stringify(opts.metadata)}::jsonb` }
1353
1419
  }).execute();
1354
1420
  return Number(revokeResult?.numUpdatedRows ?? 0n);
1355
1421
  });
@@ -1376,6 +1442,33 @@ var PeerCredentialStore = class {
1376
1442
  return row ? mapRow(row) : null;
1377
1443
  }
1378
1444
  /**
1445
+ * The credential row for `instanceId` that nobody revoked, expired or not.
1446
+ * `peer_credentials_active_uniq` allows at most one.
1447
+ */
1448
+ async findUnrevokedByInstanceId(instanceId) {
1449
+ const row = await this.db.selectFrom("peer_credentials").selectAll().where("instance_id", "=", instanceId).where("revoked_at", "is", null).executeTakeFirst();
1450
+ return row ? mapRow(row) : null;
1451
+ }
1452
+ /** The most recently revoked credential row for `instanceId`, or null. */
1453
+ async findLatestRevokedByInstanceId(instanceId) {
1454
+ const row = await this.db.selectFrom("peer_credentials").selectAll().where("instance_id", "=", instanceId).where("revoked_at", "is not", null).orderBy("revoked_at", "desc").executeTakeFirst();
1455
+ return row ? mapRow(row) : null;
1456
+ }
1457
+ /**
1458
+ * Revoke `instanceId`'s unrevoked, self-issued row whose hash is
1459
+ * `credentialHash`, recording who retired it. Every condition sits in the
1460
+ * WHERE clause, so a row that changed meanwhile is left alone.
1461
+ * Returns whether a row was retired.
1462
+ */
1463
+ async retireSelfIssued(opts) {
1464
+ const marker = JSON.stringify({ [RETIRED_BY_INSTANCE_KEY]: opts.retiredByInstance });
1465
+ const result = await this.db.updateTable("peer_credentials").set({
1466
+ revoked_at: /* @__PURE__ */ new Date(),
1467
+ metadata: sql`COALESCE(metadata, '{}'::jsonb) || ${marker}::jsonb`
1468
+ }).where("instance_id", "=", opts.instanceId).where("credential_hash", "=", opts.credentialHash).where("revoked_at", "is", null).where(sql`metadata->>'issuance'`, "=", "self").executeTakeFirst();
1469
+ return Number(result?.numUpdatedRows ?? 0n) > 0;
1470
+ }
1471
+ /**
1379
1472
  * Update the last_seen_at timestamp for a credential.
1380
1473
  * Optionally records which coordinator validated it.
1381
1474
  */
@@ -1420,7 +1513,8 @@ function mapRow(row) {
1420
1513
  lastSeenAt: row.last_seen_at ? new Date(row.last_seen_at) : null,
1421
1514
  lastValidatedBy: row.last_validated_by ?? null,
1422
1515
  expiresAt: new Date(row.expires_at),
1423
- revokedAt: row.revoked_at ? new Date(row.revoked_at) : null
1516
+ revokedAt: row.revoked_at ? new Date(row.revoked_at) : null,
1517
+ metadata: row.metadata ?? {}
1424
1518
  };
1425
1519
  }
1426
1520
  /**
@@ -1514,6 +1608,8 @@ function runDetached(logger, task, work, context = {}) {
1514
1608
  }
1515
1609
  Promise.resolve(outcome).catch(fail);
1516
1610
  }
1611
+ createLogger({ prefix: "instance-heartbeat" });
1612
+ createLogger({ prefix: "coordinator-credential" });
1517
1613
  //#endregion
1518
1614
  //#region src/cluster/peer-auth-coordinator.ts
1519
1615
  /**
@@ -1526,6 +1622,10 @@ function runDetached(logger, task, work, context = {}) {
1526
1622
  * rejection — a credential revocation cascade. This coordinator serializes all
1527
1623
  * file access through one in-process mutex so only one peer-client token-joins
1528
1624
  * per storm, and it never deletes a credential file a sibling has refreshed.
1625
+ *
1626
+ * A coordinator without a join token also wires a `selfIssue` callback. When a
1627
+ * decision finds no credential and no token, the callback issues the
1628
+ * coordinator its own credential, once per storm, under the same mutex.
1529
1629
  */
1530
1630
  const logger = createLogger({ prefix: "peer-auth-coordinator" });
1531
1631
  /** How long a waiting peer-client awaits an in-flight sibling token-join. */
@@ -1546,6 +1646,11 @@ var PeerAuthCoordinator = class {
1546
1646
  instanceId;
1547
1647
  joinToken;
1548
1648
  joinWaitTimeoutMs;
1649
+ selfIssue;
1650
+ /** The issuance in progress; concurrent sibling clients share it. */
1651
+ selfIssueInFlight = null;
1652
+ /** An operator revoked this coordinator's credential: never issue again in this process. */
1653
+ selfIssueRevoked = false;
1549
1654
  /** Promise-chain mutex tail; every file op awaits the prior one. */
1550
1655
  lock = Promise.resolve();
1551
1656
  /** Set while one peer-client is mid token-join; siblings await it. */
@@ -1555,6 +1660,7 @@ var PeerAuthCoordinator = class {
1555
1660
  this.instanceId = opts.instanceId;
1556
1661
  this.joinToken = opts.joinToken;
1557
1662
  this.joinWaitTimeoutMs = opts.joinWaitTimeoutMs ?? DEFAULT_JOIN_WAIT_TIMEOUT_MS;
1663
+ this.selfIssue = opts.selfIssue;
1558
1664
  }
1559
1665
  /** Run `fn` exclusively against the credential file. */
1560
1666
  async withLock(fn) {
@@ -1563,10 +1669,21 @@ var PeerAuthCoordinator = class {
1563
1669
  return run;
1564
1670
  }
1565
1671
  async readValidCredential() {
1566
- const cred = await readCredentialFile(this.credentialFile);
1672
+ let cred;
1673
+ try {
1674
+ cred = await readCredentialFile(this.credentialFile);
1675
+ } catch (err) {
1676
+ logger.warn("Ignoring unreadable peer credential file", {
1677
+ instanceId: this.instanceId,
1678
+ path: this.credentialFile,
1679
+ error: toErrorMessage(err)
1680
+ });
1681
+ return null;
1682
+ }
1567
1683
  return cred && cred.instanceId === this.instanceId ? cred : null;
1568
1684
  }
1569
1685
  async decideAuth() {
1686
+ let selfIssueTried = false;
1570
1687
  for (let i = 0; i < MAX_DECIDE_ITERATIONS; i++) {
1571
1688
  const decision = await this.withLock(async () => {
1572
1689
  const cred = await this.readValidCredential();
@@ -1584,10 +1701,19 @@ var PeerAuthCoordinator = class {
1584
1701
  complete: this.makeComplete(join)
1585
1702
  };
1586
1703
  }
1704
+ if (this.selfIssue && !selfIssueTried && !this.selfIssueRevoked) return "self-issue";
1587
1705
  return { mode: "no-auth" };
1588
1706
  });
1589
- if (decision !== "await-join") return decision;
1590
- await this.awaitInFlightJoin();
1707
+ if (decision === "await-join") {
1708
+ await this.awaitInFlightJoin();
1709
+ continue;
1710
+ }
1711
+ if (decision === "self-issue") {
1712
+ selfIssueTried = true;
1713
+ await this.runSelfIssue();
1714
+ continue;
1715
+ }
1716
+ return decision;
1591
1717
  }
1592
1718
  if (this.joinToken) {
1593
1719
  const join = deferred();
@@ -1600,6 +1726,18 @@ var PeerAuthCoordinator = class {
1600
1726
  }
1601
1727
  return { mode: "no-auth" };
1602
1728
  }
1729
+ /** Run the issuer under the mutex, once for every concurrent caller. */
1730
+ runSelfIssue() {
1731
+ const selfIssue = this.selfIssue;
1732
+ if (!selfIssue) return Promise.resolve("failed");
1733
+ this.selfIssueInFlight ??= this.withLock(selfIssue).then((outcome) => {
1734
+ if (outcome === "revoked") this.selfIssueRevoked = true;
1735
+ return outcome;
1736
+ }).finally(() => {
1737
+ this.selfIssueInFlight = null;
1738
+ });
1739
+ return this.selfIssueInFlight;
1740
+ }
1603
1741
  makeComplete(join) {
1604
1742
  return (issued) => {
1605
1743
  runDetached(logger, "Peer token-join completion", () => this.withLock(async () => {
@@ -1668,7 +1806,8 @@ var PeerAuthCoordinator = class {
1668
1806
  const joinTokenLogger = createLogger({ prefix: "join-token" });
1669
1807
  const TOKEN_PREFIX = "kici_join_v1";
1670
1808
  const DEFAULT_EXPIRY_MS = 36e5;
1671
- const TOKEN_ALREADY_USED_MESSAGE = "Join token has already been used. Generate a new token with: kici admin create-join-token";
1809
+ const TOKEN_ALREADY_USED_MESSAGE = "Join token has already been used. Create a new token with: kici-admin peer create-token";
1810
+ const TOKEN_EXPIRED_MESSAGE = "Join token has expired. Create a new token with: kici-admin peer create-token";
1672
1811
  var JoinTokenManager = class {
1673
1812
  deps;
1674
1813
  constructor(deps) {
@@ -1753,7 +1892,7 @@ var JoinTokenManager = class {
1753
1892
  }
1754
1893
  const row = await this.deps.db.selectFrom("join_tokens").selectAll().where("token_hash", "=", keys.validationHash).executeTakeFirst();
1755
1894
  if (!row) throw new Error("Invalid join token");
1756
- if (new Date(row.expires_at).getTime() <= Date.now()) throw new Error("Join token has expired. Generate a new token with: kici admin create-join-token");
1895
+ if (new Date(row.expires_at).getTime() <= Date.now()) throw new Error(TOKEN_EXPIRED_MESSAGE);
1757
1896
  if (row.consumed_at) {
1758
1897
  if (row.consumed_by_instance === peerInstanceId) {
1759
1898
  joinTokenLogger.info("Re-validated join token for returning instance", {
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The one changed-files resolver every dispatch path calls: same-source, the
3
+ * no-lock-file organization-wide pass, cross-source repo mode, and rerun.
4
+ *
5
+ * `unavailable` means the orchestrator holds no authoritative list; whether the
6
+ * agent can compute one is the range's question, answered by `resolveDiffRange`.
7
+ */
8
+ import { type ChangedFilesStatus, type DiffRange, type LockWorkflow, type SimulatedEvent, type WebhookNormalizer, type WorkflowDecision } from '@kici-dev/engine';
9
+ import type { ProviderBundle } from '../provider-registry.js';
10
+ export interface ResolvedChangedFiles {
11
+ files: string[];
12
+ status: ChangedFilesStatus;
13
+ range: DiffRange;
14
+ }
15
+ /**
16
+ * Resolve the files an event changed through a bundle's fetcher.
17
+ *
18
+ * - no workflow in `workflows` has a `paths` trigger → `skipped`;
19
+ * - a deleted branch → `fetched` + `[]`, whichever provider delivered it;
20
+ * - no fetcher, or a fetcher that throws → `unavailable`, never a failed delivery.
21
+ */
22
+ export declare function resolveEventChangedFiles(args: {
23
+ bundle: Pick<ProviderBundle, 'changedFilesFetcher'>;
24
+ credentials: unknown;
25
+ repoIdentifier: string;
26
+ eventName: string;
27
+ payload: unknown;
28
+ event: SimulatedEvent;
29
+ /** Omitted: always resolve — a re-evaluation has no lock file to consult. */
30
+ workflows?: readonly LockWorkflow[];
31
+ }): Promise<ResolvedChangedFiles>;
32
+ /**
33
+ * Stamp the repository's default branch on a normalized event, so the
34
+ * new-branch range (`resolveDiffRange`) and the agent that receives this event
35
+ * read the same value. `extractDefaultBranch` prefers the normalizer's hook,
36
+ * which covers GitLab's `project.default_branch`.
37
+ */
38
+ export declare function withDefaultBranch(event: SimulatedEvent, payload: unknown, normalizer: WebhookNormalizer): SimulatedEvent;
39
+ /** Copy a resolved list and its status onto the event the matcher reads. */
40
+ export declare function stampChangedFiles(event: SimulatedEvent, resolved: ResolvedChangedFiles): SimulatedEvent;
41
+ /**
42
+ * Downgrade a cross-source pull-request deferral to a conservative match.
43
+ *
44
+ * A cross-source job checks out the registration's commit, never the pull
45
+ * request's head, so no agent can diff the pull request for it. A path
46
+ * decision the orchestrator could not make therefore runs the workflow, as a
47
+ * range-less push does, instead of handing the agent a diff of the wrong
48
+ * commits. Push ranges diff explicit commits and keep their deferral.
49
+ */
50
+ export declare function withoutCrossSourcePrDeferral(decision: WorkflowDecision, event: SimulatedEvent): WorkflowDecision;
51
+ //# sourceMappingURL=changed-files.d.ts.map
@@ -0,0 +1,21 @@
1
+ /**
2
+ * A generic delivery can run both halves — cross-source for other sources'
3
+ * registrations, and the inbound source's own lock when its normalizer reads a
4
+ * repository. The delivery still gets one event-log row: the same-source path
5
+ * writes it, and this wrapper folds the cross-source half into it.
6
+ */
7
+ import type { ProcessingDeps } from './processor.js';
8
+ export interface CrossSourceTally {
9
+ candidatesConsidered: number;
10
+ jobsDispatched: number;
11
+ /** A cross-source candidate evaluated `paths` against an unavailable diff. */
12
+ degraded: boolean;
13
+ }
14
+ /**
15
+ * Wrap `deps.eventLog` so whichever row the same-source path records — corrupt
16
+ * lock, missing lock, skip or processed — also counts the cross-source half's
17
+ * dispatched jobs and carries its degraded reason. A reason the same-source
18
+ * path already set wins.
19
+ */
20
+ export declare function withCrossSourceTally(deps: ProcessingDeps, tally: CrossSourceTally): ProcessingDeps;
21
+ //# sourceMappingURL=cross-source-tally.d.ts.map
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Annotation written to a `processed` event-log row when path filters were
3
+ * evaluated against an unavailable changed-files diff. Reuses the existing
4
+ * free-text field so the outcome is never a silent `processed / matched 0` —
5
+ * no new event-log status enum value is added.
6
+ */
7
+ export declare const DEGRADED_CHANGED_FILES_REASON = "trigger evaluation degraded: changed files unavailable \u2014 path filters matched conservatively";
8
+ //# sourceMappingURL=degraded-reason.d.ts.map
@@ -704,6 +704,8 @@ export declare function materializeStaticJobsSafe(staticJobs: readonly LockJob[]
704
704
  expansionMap: Map<string, readonly string[]>;
705
705
  matrixFailures: RejectedJob[];
706
706
  }>;
707
+ /** The `paths` lists this workflow's matching left to the agent, or undefined. */
708
+ export declare function deferredPathsOf(ctx: Pick<WorkflowDispatchContext, 'decision'>): string[][] | undefined;
707
709
  /**
708
710
  * Scan a lock workflow's always-mode approval timeouts (workflow-level +
709
711
  * each static job) for a non-positive / non-finite `timeoutSeconds`. Returns the
@@ -879,6 +881,8 @@ export declare function catchUpNeedsGatedJobs(args: {
879
881
  }): Promise<void>;
880
882
  /** Why an init result must not lead to a dispatch. */
881
883
  export declare enum InitDispatchSuppression {
884
+ /** The agent's clone diff matched none of the deferred `paths`. */
885
+ Paths = "paths",
882
886
  /** The workflow's own `filter` decided the workflow does not apply. */
883
887
  Filter = "filter",
884
888
  /** The job is already rejected by a context rule, or held for approval. */
@@ -887,6 +891,11 @@ export declare enum InitDispatchSuppression {
887
891
  /**
888
892
  * Decide whether an arrived init result may dispatch its job.
889
893
  *
894
+ * `Paths` is checked first: a workflow whose deferred `paths` did not match
895
+ * never triggered, whatever its filter says. Only an explicit `false`
896
+ * suppresses — an agent that predates deferred paths reports no verdict, and
897
+ * that absence dispatches.
898
+ *
890
899
  * `Filter` requires the workflow to actually declare a filter as well as the
891
900
  * agent to have reported `false`: a buggy or rogue agent must not be able to
892
901
  * suppress a filter-less workflow by inventing the field, and an agent that
@@ -917,6 +926,7 @@ export declare enum InitDispatchSuppression {
917
926
  */
918
927
  export declare function initDispatchSuppression(workflow: Pick<LockWorkflow, 'hasFilter'>, initResult: {
919
928
  filterPassed?: boolean;
929
+ pathsPassed?: boolean;
920
930
  }, jobEnvData: Pick<JobEnvData, 'rejected' | 'held'>): InitDispatchSuppression | null;
921
931
  export interface GeneratedJobConfig {
922
932
  /**