@kici-dev/orchestrator 0.1.14 → 0.1.16

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 (92) hide show
  1. package/README.md +13 -1
  2. package/dist/__test-helpers__/mock-db.d.ts +2 -0
  3. package/dist/agent/dispatcher.d.ts +110 -6
  4. package/dist/agent/registry.d.ts +14 -0
  5. package/dist/app.d.ts +36 -0
  6. package/dist/approvals/apply-decision.d.ts +60 -0
  7. package/dist/approvals/approval-resolver.d.ts +66 -0
  8. package/dist/approvals/step-approval-bridge.d.ts +72 -0
  9. package/dist/approvals/team-membership-lookup.d.ts +13 -0
  10. package/dist/cache/agent-job-failed-error.d.ts +13 -0
  11. package/dist/cache/dispatch-cache-ref-tracker.d.ts +45 -0
  12. package/dist/cache/index.d.ts +2 -0
  13. package/dist/cache/user-cache.d.ts +116 -0
  14. package/dist/cancel/cancel-run.d.ts +56 -0
  15. package/dist/cli/api-client.d.ts +24 -0
  16. package/dist/cli/commands/environment.d.ts +1 -0
  17. package/dist/cli/commands/firecracker/index.d.ts +11 -0
  18. package/dist/cli/commands/firecracker/provision.d.ts +13 -0
  19. package/dist/cli/commands/firecracker/teardown.d.ts +3 -0
  20. package/dist/cli/commands/firecracker/verify.d.ts +3 -0
  21. package/dist/cli/commands/scaler.d.ts +18 -0
  22. package/dist/cli/kici-admin.d.ts +10 -1
  23. package/dist/cli/service/image-digests.d.ts +21 -0
  24. package/dist/cli.js +4148 -455
  25. package/dist/cluster/peer-client.d.ts +23 -1
  26. package/dist/cluster/peer-handler.d.ts +9 -1
  27. package/dist/cluster/peer-registry.d.ts +6 -0
  28. package/dist/config/schema.d.ts +4 -0
  29. package/dist/config.d.ts +13 -0
  30. package/dist/dashboard/handler.d.ts +92 -1
  31. package/dist/db/migrations/026_event_log_lockfile_corrupt.d.ts +11 -0
  32. package/dist/db/migrations/027_workflow_timeout.d.ts +20 -0
  33. package/dist/db/migrations/028_org_settings_user_cache.d.ts +4 -0
  34. package/dist/db/migrations/029_dispatch_queue_attempts.d.ts +16 -0
  35. package/dist/db/migrations/030_held_runs_env_set_null.d.ts +13 -0
  36. package/dist/db/migrations/031_dispatch_queue_ack_deadline.d.ts +19 -0
  37. package/dist/db/migrations/032_org_settings_dispatch_ack_timeout.d.ts +14 -0
  38. package/dist/db/migrations/033_org_settings_approval.d.ts +19 -0
  39. package/dist/db/migrations/034_held_runs_generalize.d.ts +24 -0
  40. package/dist/db/types.d.ts +89 -3
  41. package/dist/diagnostics/bundle-writer.d.ts +1 -17
  42. package/dist/diagnostics/checks/firecracker-network.d.ts +13 -0
  43. package/dist/diagnostics/checks/index.d.ts +2 -1
  44. package/dist/diagnostics/fleet-collector.d.ts +52 -0
  45. package/dist/diagnostics/fleet-constants.d.ts +8 -0
  46. package/dist/diagnostics/fleet-selection.d.ts +15 -0
  47. package/dist/diagnostics/fleet-topology.d.ts +47 -0
  48. package/dist/diagnostics/fleet-wiring.d.ts +60 -0
  49. package/dist/environments/environment-store.d.ts +14 -1
  50. package/dist/environments/held-runs.d.ts +69 -1
  51. package/dist/firecracker/host-network.d.ts +83 -0
  52. package/dist/firecracker/persist.d.ts +14 -0
  53. package/dist/index.d.ts +1 -0
  54. package/dist/index.js +380 -40
  55. package/dist/lockfile-cache.d.ts +1 -1
  56. package/dist/metrics/prometheus.d.ts +8 -0
  57. package/dist/orchestrator-core.d.ts +13 -2
  58. package/dist/pipeline/dispatch-matched-workflow.d.ts +9 -0
  59. package/dist/pipeline/inline-eval.d.ts +17 -2
  60. package/dist/pipeline/process-webhook.d.ts +19 -0
  61. package/dist/pipeline/processor.d.ts +28 -1
  62. package/dist/pipeline/test-pipeline.d.ts +10 -0
  63. package/dist/providers/github/lock-file.d.ts +1 -1
  64. package/dist/providers/internal/lock-file-fetcher.d.ts +3 -2
  65. package/dist/queue/job-queue.d.ts +53 -1
  66. package/dist/reporting/execution-tracker.d.ts +3 -1
  67. package/dist/routes/admin-environments.d.ts +1 -0
  68. package/dist/routes/fleet.d.ts +20 -0
  69. package/dist/scaler/bare-metal-backend.d.ts +1 -0
  70. package/dist/scaler/config.d.ts +4 -2
  71. package/dist/scaler/container-backend.d.ts +3 -2
  72. package/dist/scaler/disk-guard.d.ts +27 -0
  73. package/dist/scaler/firecracker-backend.d.ts +35 -0
  74. package/dist/scaler/manager.d.ts +8 -0
  75. package/dist/scaler/nftables.d.ts +29 -3
  76. package/dist/scaler/reap-orphans.d.ts +27 -0
  77. package/dist/scaler/types.d.ts +28 -1
  78. package/dist/server.js +6138 -1587
  79. package/dist/stale-detector/stale-run-detector.d.ts +18 -0
  80. package/dist/stale-detector/workflow-deadline-detector.d.ts +49 -0
  81. package/dist/standalone.js +18499 -14925
  82. package/dist/storage/filesystem.d.ts +14 -4
  83. package/dist/storage/s3.d.ts +19 -5
  84. package/dist/storage/types.d.ts +32 -5
  85. package/dist/worker/in-memory-job-queue.d.ts +40 -7
  86. package/dist/ws/agent-handler.d.ts +45 -0
  87. package/dist/ws/dashboard-env-handler.d.ts +36 -0
  88. package/dist/ws/fleet-agent-collector.d.ts +23 -0
  89. package/dist/ws/platform-client.d.ts +10 -1
  90. package/installer-image-digests.json +7 -0
  91. package/package.json +15 -11
  92. package/sbom.spdx.json +62 -57
package/dist/index.js CHANGED
@@ -1,14 +1,11 @@
1
- import { fileURLToPath as __cjs_fileURLToPath } from "node:url";
2
- import { dirname as __cjs_dirname } from "node:path";
3
- __cjs_dirname(__cjs_fileURLToPath(import.meta.url));
4
1
  import "node:module";
5
- import { CopyObjectCommand, DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
2
+ import { CopyObjectCommand, DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, PutObjectCommand } from "@aws-sdk/client-s3";
6
3
  import { Upload } from "@aws-sdk/lib-storage";
7
4
  import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
8
5
  import { createLogger, createS3Client, sha256 } from "@kici-dev/shared";
9
6
  import { promises } from "node:fs";
10
7
  import { dirname, join } from "node:path";
11
- import { createHmac, randomBytes } from "node:crypto";
8
+ import { createHmac, randomBytes, randomUUID } from "node:crypto";
12
9
  import "node:fs/promises";
13
10
  import { Kysely, PostgresDialect, sql } from "kysely";
14
11
  import pg from "pg";
@@ -36,6 +33,8 @@ var S3CacheStorage = class {
36
33
  client;
37
34
  /** Separate client for pre-signed URL generation (uses externalEndpoint if configured). */
38
35
  presignClient;
36
+ /** Separate client for the host CLI's pre-signed upload URL (uses uploadEndpoint if configured). */
37
+ uploadPresignClient;
39
38
  bucket;
40
39
  prefix;
41
40
  ttlMs;
@@ -49,15 +48,21 @@ var S3CacheStorage = class {
49
48
  endpoint: options.externalEndpoint
50
49
  });
51
50
  else this.presignClient = this.client;
51
+ if (options.uploadEndpoint) this.uploadPresignClient = createS3Client({
52
+ ...options,
53
+ endpoint: options.uploadEndpoint
54
+ });
55
+ else this.uploadPresignClient = this.client;
52
56
  }
53
57
  /** Build the full S3 object key from a cache key. */
54
58
  objectKey(key) {
55
59
  return `${this.prefix}${key}`;
56
60
  }
57
- /** Check if metadata indicates an expired item. */
58
- isExpired(meta) {
61
+ /** Check if metadata indicates an expired item, honoring a per-op TTL override. */
62
+ isExpired(meta, ttlMsOverride) {
63
+ const ttl = ttlMsOverride ?? this.ttlMs;
59
64
  const lastAccessed = new Date(meta.lastAccessedAt).getTime();
60
- return Date.now() - lastAccessed > this.ttlMs;
65
+ return Date.now() - lastAccessed > ttl;
61
66
  }
62
67
  /**
63
68
  * Read metadata from an S3 object's custom headers.
@@ -121,10 +126,10 @@ var S3CacheStorage = class {
121
126
  }
122
127
  }).done();
123
128
  }
124
- async get(key) {
129
+ async get(key, ttlMsOverride) {
125
130
  const meta = await this.readMeta(key);
126
131
  if (!meta) return null;
127
- if (this.isExpired(meta)) {
132
+ if (this.isExpired(meta, ttlMsOverride)) {
128
133
  await this.deleteObject(key);
129
134
  return null;
130
135
  }
@@ -146,10 +151,10 @@ var S3CacheStorage = class {
146
151
  } catch {}
147
152
  return data;
148
153
  }
149
- async has(key) {
154
+ async has(key, ttlMsOverride) {
150
155
  const meta = await this.readMeta(key);
151
156
  if (!meta) return false;
152
- if (this.isExpired(meta)) {
157
+ if (this.isExpired(meta, ttlMsOverride)) {
153
158
  await this.deleteObject(key);
154
159
  return false;
155
160
  }
@@ -166,10 +171,10 @@ var S3CacheStorage = class {
166
171
  meta.lastAccessedAt = (/* @__PURE__ */ new Date()).toISOString();
167
172
  await this.updateMeta(key, meta);
168
173
  }
169
- async getUrl(key) {
174
+ async getUrl(key, ttlMsOverride) {
170
175
  const meta = await this.readMeta(key);
171
176
  if (!meta) return null;
172
- if (this.isExpired(meta)) {
177
+ if (this.isExpired(meta, ttlMsOverride)) {
173
178
  await this.deleteObject(key);
174
179
  return null;
175
180
  }
@@ -187,7 +192,7 @@ var S3CacheStorage = class {
187
192
  }
188
193
  async getInternalUploadUrl(key) {
189
194
  const objectKey = this.objectKey(key);
190
- return getSignedUrl(this.client, new PutObjectCommand({
195
+ return getSignedUrl(this.uploadPresignClient, new PutObjectCommand({
191
196
  Bucket: this.bucket,
192
197
  Key: objectKey
193
198
  }), { expiresIn: UPLOAD_URL_EXPIRY_SECONDS });
@@ -206,6 +211,46 @@ var S3CacheStorage = class {
206
211
  }
207
212
  }));
208
213
  }
214
+ async list(subPrefix) {
215
+ const fullPrefix = this.objectKey(subPrefix);
216
+ const items = [];
217
+ let token;
218
+ do {
219
+ const resp = await this.client.send(new ListObjectsV2Command({
220
+ Bucket: this.bucket,
221
+ Prefix: fullPrefix,
222
+ ContinuationToken: token
223
+ }));
224
+ for (const obj of resp.Contents ?? []) {
225
+ if (!obj.Key) continue;
226
+ items.push({
227
+ key: obj.Key.slice(this.prefix.length),
228
+ created: obj.LastModified ? obj.LastModified.getTime() : 0
229
+ });
230
+ }
231
+ token = resp.IsTruncated ? resp.NextContinuationToken : void 0;
232
+ } while (token);
233
+ items.sort((a, b) => b.created - a.created);
234
+ return items.map((i) => i.key);
235
+ }
236
+ async getMetadata(key) {
237
+ return this.readMeta(key);
238
+ }
239
+ async copy(srcKey, destKey) {
240
+ const srcObj = this.objectKey(srcKey);
241
+ const destObj = this.objectKey(destKey);
242
+ const now = (/* @__PURE__ */ new Date()).toISOString();
243
+ await this.client.send(new CopyObjectCommand({
244
+ Bucket: this.bucket,
245
+ Key: destObj,
246
+ CopySource: `${this.bucket}/${srcObj}`,
247
+ MetadataDirective: "REPLACE",
248
+ Metadata: {
249
+ "created-at": now,
250
+ "last-accessed-at": now
251
+ }
252
+ }));
253
+ }
209
254
  /** Delete an S3 object. Idempotent (doesn't error if missing). */
210
255
  async deleteObject(key) {
211
256
  try {
@@ -298,9 +343,10 @@ var FilesystemCacheStorage = class {
298
343
  metaPath(key) {
299
344
  return `${this.resolvePath(key)}.meta.json`;
300
345
  }
301
- isExpired(meta) {
346
+ isExpired(meta, ttlMsOverride) {
347
+ const ttl = ttlMsOverride ?? this.ttlMs;
302
348
  const lastAccessed = new Date(meta.lastAccessedAt).getTime();
303
- return Date.now() - lastAccessed > this.ttlMs;
349
+ return Date.now() - lastAccessed > ttl;
304
350
  }
305
351
  async readMeta(key) {
306
352
  try {
@@ -328,10 +374,10 @@ var FilesystemCacheStorage = class {
328
374
  lastAccessedAt: now
329
375
  });
330
376
  }
331
- async get(key) {
377
+ async get(key, ttlMsOverride) {
332
378
  const meta = await this.readMeta(key);
333
379
  if (!meta) return null;
334
- if (this.isExpired(meta)) {
380
+ if (this.isExpired(meta, ttlMsOverride)) {
335
381
  await this.deleteFiles(key);
336
382
  return null;
337
383
  }
@@ -348,10 +394,10 @@ var FilesystemCacheStorage = class {
348
394
  } catch {}
349
395
  return data;
350
396
  }
351
- async has(key) {
397
+ async has(key, ttlMsOverride) {
352
398
  const meta = await this.readMeta(key);
353
399
  if (!meta) return false;
354
- if (this.isExpired(meta)) {
400
+ if (this.isExpired(meta, ttlMsOverride)) {
355
401
  await this.deleteFiles(key);
356
402
  return false;
357
403
  }
@@ -368,10 +414,10 @@ var FilesystemCacheStorage = class {
368
414
  meta.lastAccessedAt = (/* @__PURE__ */ new Date()).toISOString();
369
415
  await this.writeMeta(key, meta);
370
416
  }
371
- async getUrl(key) {
417
+ async getUrl(key, ttlMsOverride) {
372
418
  const meta = await this.readMeta(key);
373
419
  if (!meta) return null;
374
- if (this.isExpired(meta)) {
420
+ if (this.isExpired(meta, ttlMsOverride)) {
375
421
  await this.deleteFiles(key);
376
422
  return null;
377
423
  }
@@ -390,6 +436,57 @@ var FilesystemCacheStorage = class {
390
436
  lastAccessedAt: now
391
437
  });
392
438
  }
439
+ async list(subPrefix) {
440
+ const root = this.resolvePath(subPrefix.replace(/\/$/, ""));
441
+ let entries;
442
+ try {
443
+ entries = await this.walkDataFiles(root, subPrefix.replace(/\/$/, ""));
444
+ } catch (err) {
445
+ if (isNotFoundFsError(err)) return [];
446
+ throw err;
447
+ }
448
+ entries.sort((a, b) => b.mtime - a.mtime);
449
+ return entries.map((e) => e.key);
450
+ }
451
+ async getMetadata(key) {
452
+ return this.readMeta(key);
453
+ }
454
+ async copy(srcKey, destKey) {
455
+ const srcPath = this.resolvePath(srcKey);
456
+ const destPath = this.resolvePath(destKey);
457
+ await promises.mkdir(dirname(destPath), { recursive: true });
458
+ await promises.copyFile(srcPath, destPath);
459
+ const now = (/* @__PURE__ */ new Date()).toISOString();
460
+ await this.writeMeta(destKey, {
461
+ createdAt: now,
462
+ lastAccessedAt: now
463
+ });
464
+ }
465
+ /**
466
+ * Recursively collect data files under `dir`, returning each as a cache key
467
+ * relative to `basePath` paired with its mtime. Metadata sidecars
468
+ * (`*.meta.json`) and the atomic-write temp files (`*.tmp-*`) are skipped so
469
+ * `list()` only surfaces real cache objects.
470
+ */
471
+ async walkDataFiles(dir, keyPrefix) {
472
+ const out = [];
473
+ const dirents = await promises.readdir(dir, { withFileTypes: true });
474
+ for (const dirent of dirents) {
475
+ const childPath = join(dir, dirent.name);
476
+ const childKey = keyPrefix ? `${keyPrefix}/${dirent.name}` : dirent.name;
477
+ if (dirent.isDirectory()) {
478
+ out.push(...await this.walkDataFiles(childPath, childKey));
479
+ continue;
480
+ }
481
+ if (dirent.name.endsWith(".meta.json") || /\.tmp-[0-9a-f]+$/.test(dirent.name)) continue;
482
+ const stat = await promises.stat(childPath);
483
+ out.push({
484
+ key: childKey,
485
+ mtime: stat.mtimeMs
486
+ });
487
+ }
488
+ return out;
489
+ }
393
490
  signedUrl(method, key) {
394
491
  const { token } = signToken(this.signingSecret, method, key);
395
492
  const encodedKey = key.split("/").map((seg) => encodeURIComponent(seg)).join("/");
@@ -432,6 +529,7 @@ function createCacheStorage(config) {
432
529
  region: config.region,
433
530
  endpoint: config.endpoint,
434
531
  externalEndpoint: config.externalEndpoint,
532
+ uploadEndpoint: config.uploadEndpoint,
435
533
  forcePathStyle: config.forcePathStyle
436
534
  });
437
535
  if (config.type === "filesystem") return new FilesystemCacheStorage({
@@ -460,7 +558,7 @@ function createCacheStorage(config) {
460
558
  * downloads the source tarball, extracts it, and imports the workflow entry
461
559
  * via the shared oxc-transform ESM loader hook.
462
560
  */
463
- const logger$1 = createLogger({ prefix: "source-cache" });
561
+ const logger$2 = createLogger({ prefix: "source-cache" });
464
562
  /** Cache key format: source/{contentHash}.tar.gz */
465
563
  function sourceKey(contentHash) {
466
564
  return `source/${contentHash}.tar.gz`;
@@ -473,7 +571,7 @@ var SourceCache = class {
473
571
  async has(contentHash) {
474
572
  const key = sourceKey(contentHash);
475
573
  const exists = await this.storage.has(key);
476
- logger$1.debug(`has(${contentHash}): ${exists}`);
574
+ logger$2.debug(`has(${contentHash}): ${exists}`);
477
575
  return exists;
478
576
  }
479
577
  async get(contentHash) {
@@ -481,8 +579,8 @@ var SourceCache = class {
481
579
  const data = await this.storage.get(key);
482
580
  if (data) {
483
581
  await this.storage.touch(key);
484
- logger$1.debug(`get(${contentHash}): hit (${data.length} bytes)`);
485
- } else logger$1.debug(`get(${contentHash}): miss`);
582
+ logger$2.debug(`get(${contentHash}): hit (${data.length} bytes)`);
583
+ } else logger$2.debug(`get(${contentHash}): miss`);
486
584
  return data;
487
585
  }
488
586
  async getUrl(contentHash) {
@@ -490,8 +588,8 @@ var SourceCache = class {
490
588
  const url = await this.storage.getUrl(key);
491
589
  if (url) {
492
590
  await this.storage.touch(key);
493
- logger$1.debug(`getUrl(${contentHash}): hit`);
494
- } else logger$1.debug(`getUrl(${contentHash}): miss`);
591
+ logger$2.debug(`getUrl(${contentHash}): hit`);
592
+ } else logger$2.debug(`getUrl(${contentHash}): miss`);
495
593
  return url;
496
594
  }
497
595
  async getUploadUrl(contentHash) {
@@ -502,12 +600,12 @@ var SourceCache = class {
502
600
  const key = sourceKey(contentHash);
503
601
  await this.storage.put(key, tarball);
504
602
  const size = typeof tarball === "string" ? Buffer.byteLength(tarball) : tarball.length;
505
- logger$1.info(`store(${contentHash}): stored (${size} bytes)`);
603
+ logger$2.info(`store(${contentHash}): stored (${size} bytes)`);
506
604
  }
507
605
  async remove(contentHash) {
508
606
  const key = sourceKey(contentHash);
509
607
  const removed = await this.storage.delete(key);
510
- logger$1.info(`remove(${contentHash}): ${removed ? "removed" : "not found"}`);
608
+ logger$2.info(`remove(${contentHash}): ${removed ? "removed" : "not found"}`);
511
609
  return removed;
512
610
  }
513
611
  };
@@ -522,7 +620,7 @@ var SourceCache = class {
522
620
  *
523
621
  * Cache key format: deps/{platform}-{arch}/{lockfileHash}.tar.gz
524
622
  */
525
- const logger = createLogger({ prefix: "dep-cache" });
623
+ const logger$1 = createLogger({ prefix: "dep-cache" });
526
624
  /** Default max tarball size: 500MB */
527
625
  const DEFAULT_MAX_TARBALL_BYTES = 524288e3;
528
626
  /** Build cache key for dependency tarball: deps/{platform}-{arch}/{lockfileHash}.tar.gz */
@@ -540,7 +638,7 @@ var DepCache = class {
540
638
  async has(lockfileHash, platform, arch) {
541
639
  const key = depKey(lockfileHash, platform, arch);
542
640
  const exists = await this.storage.has(key);
543
- logger.debug(`has(${lockfileHash}): ${exists}`, {
641
+ logger$1.debug(`has(${lockfileHash}): ${exists}`, {
544
642
  platform,
545
643
  arch
546
644
  });
@@ -555,11 +653,11 @@ var DepCache = class {
555
653
  const url = await this.storage.getUrl(key);
556
654
  if (url) {
557
655
  await this.storage.touch(key);
558
- logger.debug(`getUrl(${lockfileHash}): hit`, {
656
+ logger$1.debug(`getUrl(${lockfileHash}): hit`, {
559
657
  platform,
560
658
  arch
561
659
  });
562
- } else logger.debug(`getUrl(${lockfileHash}): miss`, {
660
+ } else logger$1.debug(`getUrl(${lockfileHash}): miss`, {
563
661
  platform,
564
662
  arch
565
663
  });
@@ -574,7 +672,7 @@ var DepCache = class {
574
672
  const key = depKey(lockfileHash, platform, arch);
575
673
  const url = await this.storage.getUrl(key);
576
674
  if (!url) {
577
- logger.debug(`getUrlAndHash(${lockfileHash}): miss`, {
675
+ logger$1.debug(`getUrlAndHash(${lockfileHash}): miss`, {
578
676
  platform,
579
677
  arch
580
678
  });
@@ -583,7 +681,7 @@ var DepCache = class {
583
681
  await this.storage.touch(key);
584
682
  const hashKey = `deps/${platform}-${arch}/${lockfileHash}.hash`;
585
683
  const hash = (await this.storage.get(hashKey))?.toString("utf-8") || void 0;
586
- logger.debug(`getUrlAndHash(${lockfileHash}): hit`, {
684
+ logger$1.debug(`getUrlAndHash(${lockfileHash}): hit`, {
587
685
  platform,
588
686
  arch,
589
687
  hasHash: !!hash
@@ -608,7 +706,7 @@ var DepCache = class {
608
706
  if (tarballData.length > this.maxTarballBytes) throw new Error(`Dep tarball exceeds max size: ${tarballData.length} bytes > ${this.maxTarballBytes} bytes limit`);
609
707
  const key = depKey(lockfileHash, platform, arch);
610
708
  await this.storage.put(key, tarballData);
611
- logger.info(`store: ${tarballData.length} bytes`, {
709
+ logger$1.info(`store: ${tarballData.length} bytes`, {
612
710
  lockfileHash,
613
711
  platform,
614
712
  arch
@@ -625,7 +723,7 @@ var DepCache = class {
625
723
  async remove(lockfileHash, platform, arch) {
626
724
  const key = depKey(lockfileHash, platform, arch);
627
725
  const removed = await this.storage.delete(key);
628
- logger.info(`remove(${lockfileHash}): ${removed ? "removed" : "not found"}`, {
726
+ logger$1.info(`remove(${lockfileHash}): ${removed ? "removed" : "not found"}`, {
629
727
  platform,
630
728
  arch
631
729
  });
@@ -633,6 +731,248 @@ var DepCache = class {
633
731
  }
634
732
  };
635
733
  //#endregion
734
+ //#region src/cache/user-cache.ts
735
+ /**
736
+ * User-facing cache layer wrapping CacheStorage.
737
+ *
738
+ * Namespacing: `cache/<orgId>/<repoId>/<refScope>/<key>.tar.gz`, where
739
+ * `refScope` is `shared` for trusted refs (org-shared, default-branch cache)
740
+ * or `iso/<runId>` for untrusted refs (per-run isolated scope). Restores from
741
+ * an untrusted ref read the shared scope as a fallback but writes can NEVER
742
+ * land in the shared scope — the GitHub Actions cache-isolation model. Keyed
743
+ * strictly per org so no tenant can read another tenant's cache.
744
+ *
745
+ * Saves are immutable (first save under an exact key wins) and atomic
746
+ * (upload to a `.tmp-<uuid>` key, then server-side copy to the final key and
747
+ * delete the temp), so a crashed save never leaves a corrupt final entry.
748
+ *
749
+ * Eviction: per-org byte quota plus the TTL the backing CacheStorage already
750
+ * enforces lazily on access. On a save that pushes the org over quota,
751
+ * least-recently-used entries (oldest `lastAccessedAt`, read from the storage
752
+ * backend's metadata) are evicted until under quota; each eviction is logged.
753
+ * The companion `.hash` / `.size` sidecar objects carry the integrity
754
+ * hash and size accounting outside the tarball's own (presigned, metadata-less)
755
+ * upload.
756
+ */
757
+ const logger = createLogger({ prefix: "user-cache" });
758
+ /**
759
+ * Cluster-wide default quota: 5 GiB. Serves as the fallback when an org has no
760
+ * per-org override in `org_settings.user_cache_quota_bytes`. The cluster-wide
761
+ * value is itself operator-configurable via KICI_USER_CACHE_QUOTA_BYTES.
762
+ */
763
+ const DEFAULT_USER_CACHE_QUOTA_BYTES = 5 * 1024 * 1024 * 1024;
764
+ /**
765
+ * Cluster-wide default entry TTL: 7 days. Fallback when an org has no per-org
766
+ * override in `org_settings.user_cache_ttl_ms`. The cluster-wide value is
767
+ * operator-configurable via KICI_USER_CACHE_TTL_MS.
768
+ */
769
+ const DEFAULT_USER_CACHE_TTL_MS = 10080 * 60 * 1e3;
770
+ /** Tarball suffix for committed cache entries. */
771
+ const TAR_SUFFIX = ".tar.gz";
772
+ var UserCache = class {
773
+ storage;
774
+ /** Cluster-wide default quota (the `KICI_USER_CACHE_QUOTA_BYTES` value). */
775
+ defaultQuotaBytes;
776
+ /** Cluster-wide default TTL (the `KICI_USER_CACHE_TTL_MS` value). */
777
+ defaultTtlMs;
778
+ /** Optional per-org override reader; absent = always use the cluster defaults. */
779
+ orgLimitsReader;
780
+ constructor(opts) {
781
+ this.storage = opts.storage;
782
+ this.defaultQuotaBytes = opts.quotaBytes ?? 5368709120;
783
+ this.defaultTtlMs = opts.ttlMs ?? 6048e5;
784
+ this.orgLimitsReader = opts.orgLimitsReader;
785
+ }
786
+ /**
787
+ * Resolve the effective quota + TTL for an org: the per-org override from
788
+ * `org_settings` when present, otherwise the cluster-wide default. A reader
789
+ * failure falls back to the defaults (logged) — the cache must never fail a
790
+ * restore/save because the settings lookup hiccupped.
791
+ */
792
+ async resolveLimits(org) {
793
+ if (!this.orgLimitsReader) return {
794
+ quotaBytes: this.defaultQuotaBytes,
795
+ ttlMs: this.defaultTtlMs
796
+ };
797
+ let limits = {};
798
+ try {
799
+ limits = await this.orgLimitsReader(org);
800
+ } catch (err) {
801
+ logger.warn("user-cache org-limits lookup failed — using cluster defaults", {
802
+ org,
803
+ error: err instanceof Error ? err.message : String(err)
804
+ });
805
+ }
806
+ return {
807
+ quotaBytes: limits.quotaBytes ?? this.defaultQuotaBytes,
808
+ ttlMs: limits.ttlMs ?? this.defaultTtlMs
809
+ };
810
+ }
811
+ /**
812
+ * Sanitize a path segment so a key can never escape its org/repo/scope
813
+ * namespace. Beyond stripping disallowed characters, a segment consisting
814
+ * only of dots (`.`, `..`, …) is replaced wholesale: such a segment is a
815
+ * dot-segment that HTTP/S3 path canonicalization collapses (`a/./b` → `a/b`,
816
+ * `a/../b` → `b`), which both corrupts the namespace and breaks the SigV4
817
+ * signature on a pre-signed PUT/GET. Repo identifiers like `.` (the internal
818
+ * provider's repo id) hit exactly this case, so the all-dots guard keeps the
819
+ * object key canonical and the namespace boundary intact.
820
+ */
821
+ seg(s) {
822
+ const cleaned = s.replace(/[^A-Za-z0-9._-]/g, "_");
823
+ return /^\.+$/.test(cleaned) ? `_${cleaned}` : cleaned;
824
+ }
825
+ /** Org-level prefix: the per-tenant isolation boundary and quota scope. */
826
+ orgPrefix(ref) {
827
+ return `cache/${this.seg(ref.org)}/`;
828
+ }
829
+ /** Org + repo prefix shared by every scope of a repo. */
830
+ repoPrefix(ref) {
831
+ return `${this.orgPrefix(ref)}${this.seg(ref.repo)}`;
832
+ }
833
+ /** Namespace prefix for the WRITE scope of a ref (shared OR per-run isolated). */
834
+ writePrefix(ref) {
835
+ const base = this.repoPrefix(ref);
836
+ if (ref.scope === "isolated") {
837
+ if (!ref.runId) throw new Error("isolated cache scope requires a runId");
838
+ return `${base}/iso/${this.seg(ref.runId)}/`;
839
+ }
840
+ return `${base}/shared/`;
841
+ }
842
+ /** Namespace prefixes the ref may READ, in priority order. Isolated reads its own run scope, then shared. */
843
+ readPrefixes(ref) {
844
+ const base = this.repoPrefix(ref);
845
+ if (ref.scope === "isolated") {
846
+ if (!ref.runId) throw new Error("isolated cache scope requires a runId");
847
+ return [`${base}/iso/${this.seg(ref.runId)}/`, `${base}/shared/`];
848
+ }
849
+ return [`${base}/shared/`];
850
+ }
851
+ finalKey(prefix, key) {
852
+ return `${prefix}${this.seg(key)}${TAR_SUFFIX}`;
853
+ }
854
+ /** Restore: try the exact key across read prefixes, then restoreKeys prefix scan (newest wins). */
855
+ async restore(ref) {
856
+ const { ttlMs } = await this.resolveLimits(ref.org);
857
+ const prefixes = this.readPrefixes(ref);
858
+ const exact = await this.restoreExact(ref, prefixes, ttlMs);
859
+ if (exact) return exact;
860
+ return await this.restoreByPrefix(ref, prefixes, ttlMs) ?? { hit: false };
861
+ }
862
+ /** Try the exact key in read-prefix priority order. */
863
+ async restoreExact(ref, prefixes, ttlMs) {
864
+ for (const prefix of prefixes) {
865
+ const key = this.finalKey(prefix, ref.key);
866
+ const url = await this.storage.getUrl(key, ttlMs);
867
+ if (url) {
868
+ await this.storage.touch(key);
869
+ return {
870
+ hit: true,
871
+ matchedKey: ref.key,
872
+ downloadUrl: url,
873
+ tarHash: await this.readHash(key)
874
+ };
875
+ }
876
+ }
877
+ return null;
878
+ }
879
+ /** restoreKeys prefix fallback (ordered); within a prefix, list() returns newest-first. */
880
+ async restoreByPrefix(ref, prefixes, ttlMs) {
881
+ for (const rk of ref.restoreKeys ?? []) for (const prefix of prefixes) {
882
+ const matches = (await this.storage.list(`${prefix}${this.seg(rk)}`)).filter((k) => k.endsWith(TAR_SUFFIX));
883
+ if (matches.length === 0) continue;
884
+ const winner = matches[0];
885
+ const url = await this.storage.getUrl(winner, ttlMs);
886
+ if (!url) continue;
887
+ await this.storage.touch(winner);
888
+ return {
889
+ hit: true,
890
+ matchedKey: winner.slice(prefix.length, -7),
891
+ downloadUrl: url,
892
+ tarHash: await this.readHash(winner)
893
+ };
894
+ }
895
+ return null;
896
+ }
897
+ /** Begin a save: presigned PUT to a temp key, or skip=true when the immutable key exists. */
898
+ async beginSave(ref) {
899
+ const prefix = this.writePrefix(ref);
900
+ const final = this.finalKey(prefix, ref.key);
901
+ if (await this.storage.has(final)) {
902
+ logger.info("user-cache save skipped (immutable key exists)", { key: ref.key });
903
+ return { skip: true };
904
+ }
905
+ const tempKey = `${prefix}.tmp-${randomUUID()}${TAR_SUFFIX}`;
906
+ return {
907
+ skip: false,
908
+ uploadUrl: await this.storage.getUploadUrl(tempKey),
909
+ tempKey
910
+ };
911
+ }
912
+ /** Commit a save: copy temp -> final, init metadata, store companion hash/size, delete temp, enforce quota. */
913
+ async commitSave(ref) {
914
+ const prefix = this.writePrefix(ref);
915
+ const final = this.finalKey(prefix, ref.key);
916
+ if (await this.storage.has(final)) return;
917
+ if (ref.tempKey) {
918
+ await this.storage.copy(ref.tempKey, final);
919
+ await this.storage.delete(ref.tempKey);
920
+ }
921
+ await this.storage.initMeta(final);
922
+ await this.storage.put(`${final}.hash`, ref.tarHash);
923
+ await this.storage.put(`${final}.size`, String(ref.sizeBytes));
924
+ logger.info("user-cache entry committed", {
925
+ key: ref.key,
926
+ sizeBytes: ref.sizeBytes
927
+ });
928
+ await this.enforceQuota(ref);
929
+ }
930
+ async readHash(key) {
931
+ return (await this.storage.get(`${key}.hash`))?.toString("utf-8") || void 0;
932
+ }
933
+ /** Evict least-recently-used entries for the org until total tarball size <= the per-org quota. */
934
+ async enforceQuota(ref) {
935
+ const { quotaBytes } = await this.resolveLimits(ref.org);
936
+ const orgPrefix = this.orgPrefix(ref);
937
+ const keys = (await this.storage.list(orgPrefix)).filter((k) => k.endsWith(TAR_SUFFIX));
938
+ const sized = [];
939
+ let total = 0;
940
+ for (const k of keys) {
941
+ const sizeData = await this.storage.get(`${k}.size`);
942
+ const size = sizeData ? Number(sizeData.toString("utf-8")) : 0;
943
+ total += size;
944
+ sized.push({
945
+ key: k,
946
+ size
947
+ });
948
+ }
949
+ if (total <= quotaBytes) return;
950
+ const candidates = [];
951
+ for (const e of sized) {
952
+ const meta = await this.storage.getMetadata(e.key);
953
+ const lastAccessed = meta ? new Date(meta.lastAccessedAt).getTime() : 0;
954
+ candidates.push({
955
+ key: e.key,
956
+ size: e.size,
957
+ lastAccessed
958
+ });
959
+ }
960
+ candidates.sort((a, b) => a.lastAccessed - b.lastAccessed);
961
+ for (const { key, size } of candidates) {
962
+ if (total <= quotaBytes) break;
963
+ await this.storage.delete(key);
964
+ await this.storage.delete(`${key}.hash`);
965
+ await this.storage.delete(`${key}.size`);
966
+ total -= size;
967
+ logger.info("user-cache eviction (org over quota)", {
968
+ org: ref.org,
969
+ key,
970
+ freedBytes: size
971
+ });
972
+ }
973
+ }
974
+ };
975
+ //#endregion
636
976
  //#region src/cluster/peer-credentials.ts
637
977
  /**
638
978
  * Peer credential management for cluster authentication.
@@ -767,6 +1107,6 @@ async function createPeerCredentialStoreFromUrl(databaseUrl, opts) {
767
1107
  };
768
1108
  }
769
1109
  //#endregion
770
- export { DepCache, PeerCredentialStore, S3CacheStorage, SourceCache, createCacheStorage, createPeerCredentialStoreFromUrl };
1110
+ export { DEFAULT_USER_CACHE_QUOTA_BYTES, DEFAULT_USER_CACHE_TTL_MS, DepCache, PeerCredentialStore, S3CacheStorage, SourceCache, UserCache, createCacheStorage, createPeerCredentialStoreFromUrl };
771
1111
 
772
1112
  //# sourceMappingURL=index.js.map
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * Replaces the old GitHub-specific LockFileCache in github/lockfile.ts.
9
9
  */
10
- import type { LockFileFetcher, LockFile } from '@kici-dev/engine';
10
+ import { type LockFileFetcher, type LockFile } from '@kici-dev/engine';
11
11
  export declare class LockFileCache {
12
12
  private readonly cache;
13
13
  private hits;
@@ -64,6 +64,14 @@ export declare const triggerMatchDurationSeconds: import("@opentelemetry/api").H
64
64
  * Total deduplication cache hits (duplicate webhooks rejected).
65
65
  */
66
66
  export declare const dedupHitsTotal: import("@opentelemetry/api").Counter<import("@opentelemetry/api").Attributes>;
67
+ /**
68
+ * pg connection errors absorbed by the pool error handlers instead of
69
+ * crashing the process (e.g. a database failover terminating idle pooled
70
+ * backends).
71
+ * Labels:
72
+ * - source: idle-pool | client
73
+ */
74
+ export declare const pgPoolClientErrorsTotal: import("@opentelemetry/api").Counter<import("@opentelemetry/api").Attributes>;
67
75
  /**
68
76
  * Total source cache hits (`.kici/` source tarball found for content hash).
69
77
  */
@@ -22,11 +22,11 @@ import { DedupCache } from './webhook/dedup.js';
22
22
  import { ObserverRegistry } from './ws/observer-registry.js';
23
23
  import { AgentMetricsAggregator } from './metrics/agent-metrics-aggregator.js';
24
24
  import { SourceLocationStore } from './app.js';
25
- import { type PeerHeartbeat } from '@kici-dev/engine';
25
+ import { type PeerHeartbeat, type PeerLogsCollectRequest, type PeerToPeerMessage } from '@kici-dev/engine';
26
26
  import { ScalerManager } from './scaler/index.js';
27
27
  import type { ScalerConfig } from './scaler/index.js';
28
28
  import type { CacheStorage } from './storage/index.js';
29
- import { SourceCache, BuildCoordinator, DepCache, PendingBuildTracker, PendingInitTracker, PendingDynamicTracker } from './cache/index.js';
29
+ import { SourceCache, BuildCoordinator, DepCache, UserCache, DispatchCacheRefTracker, PendingBuildTracker, PendingInitTracker, PendingDynamicTracker } from './cache/index.js';
30
30
  import { CheckRunReporter } from './reporting/check-run-reporter.js';
31
31
  import { StepLogBuffer } from './reporting/step-log-buffer.js';
32
32
  import { type LogStorage } from './reporting/log-storage.js';
@@ -70,6 +70,9 @@ export interface OrchestratorSubsystems {
70
70
  cacheStorage: CacheStorage | undefined;
71
71
  sourceCache: SourceCache | undefined;
72
72
  depCache: DepCache | undefined;
73
+ userCache: UserCache | undefined;
74
+ /** Server-side jobId -> user-cache-namespace store, written at dispatch, read by the agent-WS handler. */
75
+ dispatchCacheRefs: DispatchCacheRefTracker;
73
76
  buildCoordinator: BuildCoordinator | undefined;
74
77
  pendingBuilds: PendingBuildTracker | undefined;
75
78
  pendingInits: PendingInitTracker;
@@ -143,6 +146,14 @@ export interface OrchestratorSubsystems {
143
146
  * store. the metrics enforcement plan.
144
147
  */
145
148
  agentMetricsAggregator: AgentMetricsAggregator;
149
+ /**
150
+ * Peer-side fleet collect responder. On an inbound peer.logs.collect.request,
151
+ * assembles this node's subtree and streams it back. Wired into every outgoing
152
+ * PeerClient (the incoming peer-handler is wired directly in setupCluster).
153
+ * Armed after the fleet runtime is built, so calls before that resolve to a
154
+ * no-op.
155
+ */
156
+ fleetCollectResponder: (msg: PeerLogsCollectRequest, send: (out: PeerToPeerMessage) => boolean) => Promise<void>;
146
157
  }
147
158
  /**
148
159
  * Mode-specific hooks injected by each entry point.