@kici-dev/orchestrator 0.1.3 → 0.1.6

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 (73) hide show
  1. package/dist/agent/dispatcher.d.ts +26 -0
  2. package/dist/agent/ownership-tracker.d.ts +45 -3
  3. package/dist/app.d.ts +9 -3
  4. package/dist/audit/access-log.d.ts +7 -0
  5. package/dist/cli/api-client.d.ts +14 -0
  6. package/dist/cli/commands/cluster-name.d.ts +18 -0
  7. package/dist/cli/commands/db.d.ts +9 -6
  8. package/dist/cli/commands/org-settings.d.ts +2 -1
  9. package/dist/cli/commands/shared/secret-input.d.ts +46 -0
  10. package/dist/cli/commands/variable.d.ts +18 -0
  11. package/dist/cli/service/index.d.ts +1 -0
  12. package/dist/cli/service/launchd.d.ts +19 -0
  13. package/dist/cli/service/privilege.d.ts +21 -0
  14. package/dist/cli/wizard/orchestrator-wizard.d.ts +4 -2
  15. package/dist/cli.js +1074 -225
  16. package/dist/cluster/coordinator.d.ts +3 -3
  17. package/dist/cluster/join-token.d.ts +21 -13
  18. package/dist/cluster/peer-client.d.ts +9 -0
  19. package/dist/config/cluster-id.d.ts +23 -0
  20. package/dist/config/cluster-name.d.ts +39 -0
  21. package/dist/config.d.ts +8 -0
  22. package/dist/dashboard/handler.d.ts +54 -2
  23. package/dist/db/migrations/019_generic_sources_change_notify.d.ts +25 -0
  24. package/dist/db/migrations/020_org_settings_dashboard_write_policy.d.ts +17 -0
  25. package/dist/db/migrations/021_check_run_tracking.d.ts +26 -0
  26. package/dist/db/migrations/022_scaler_manager_state.d.ts +29 -0
  27. package/dist/db/migrations/023_dispatch_queue_recovery_deadline.d.ts +26 -0
  28. package/dist/db/types.d.ts +137 -0
  29. package/dist/events/event-store.d.ts +9 -2
  30. package/dist/events/trust-store.d.ts +7 -0
  31. package/dist/events/types.d.ts +33 -0
  32. package/dist/metrics/scheduled-jobs.d.ts +26 -5
  33. package/dist/orchestrator-core.d.ts +16 -2
  34. package/dist/pipeline/processor.d.ts +2 -2
  35. package/dist/pipeline/rerun.d.ts +2 -2
  36. package/dist/pipeline/test-pipeline.d.ts +2 -2
  37. package/dist/policy/dashboard-write-policy.d.ts +115 -0
  38. package/dist/queue/job-queue.d.ts +54 -1
  39. package/dist/reporting/{commit-status.d.ts → check-run-reporter.d.ts} +94 -16
  40. package/dist/reporting/check-run-tracking-store.d.ts +133 -0
  41. package/dist/routes/admin-access-log.d.ts +1 -0
  42. package/dist/routes/admin-backends.d.ts +9 -1
  43. package/dist/routes/admin-cluster-name.d.ts +50 -0
  44. package/dist/routes/admin-db.d.ts +9 -1
  45. package/dist/routes/admin-environments.d.ts +10 -6
  46. package/dist/routes/admin-event-dlq.d.ts +1 -0
  47. package/dist/routes/admin-event-log.d.ts +1 -0
  48. package/dist/routes/admin-events.d.ts +19 -0
  49. package/dist/routes/admin-maintenance.d.ts +9 -1
  50. package/dist/routes/admin-org-settings.d.ts +10 -1
  51. package/dist/routes/admin-queue-execution.d.ts +1 -0
  52. package/dist/routes/admin-registrations.d.ts +1 -0
  53. package/dist/routes/admin-runs.d.ts +1 -0
  54. package/dist/routes/admin-scheduled-jobs.d.ts +1 -0
  55. package/dist/routes/admin-sources.d.ts +24 -1
  56. package/dist/routes/admin.d.ts +22 -0
  57. package/dist/scaler/manager.d.ts +38 -0
  58. package/dist/scaler/scaler-state-store.d.ts +102 -0
  59. package/dist/secrets/routing-key-scope.d.ts +43 -0
  60. package/dist/server.js +10098 -6961
  61. package/dist/sources/build-platform-sources.d.ts +26 -0
  62. package/dist/sources/source-manager.d.ts +14 -0
  63. package/dist/stale-detector/stale-run-detector.d.ts +17 -5
  64. package/dist/standalone.js +7525 -5172
  65. package/dist/webhook/generic-sources-listener.d.ts +89 -0
  66. package/dist/webhook/register-source-bundle.d.ts +57 -0
  67. package/dist/ws/dashboard-backends-handler.d.ts +14 -0
  68. package/dist/ws/dashboard-env-handler.d.ts +7 -0
  69. package/dist/ws/dashboard-global-workflows-handler.d.ts +6 -0
  70. package/dist/ws/dashboard-registrations-handler.d.ts +7 -0
  71. package/dist/ws/platform-client.d.ts +57 -2
  72. package/package.json +3 -3
  73. package/sbom.spdx.json +40 -35
package/dist/cli.js CHANGED
@@ -5,13 +5,16 @@ __cjs_dirname(__cjs_fileURLToPath(import.meta.url));
5
5
  import { createRequire } from "node:module";
6
6
  import { Command } from "commander";
7
7
  import { BaseColdStore, ChunkLru, chunkObjectKey, clearDispatchQueueDirect, computeChunkId, computeMigrationsHash, createDb, createDbRole, createEnvironmentTemplateDirect, createLogger, createPool, createReadOnlyDbUser, decodeChunk, dropAndCreateDatabase, emitKiciEventDirect, encodeKeySegment, ensureDatabase, formatBytes, formatUptime, isSchemaCurrent, listEnvironmentsDirect, listExecutionRunsDirect, listQueueDirect, listRegistrationsDirect, maskDatabaseUrl, parseDatabaseUrl, parseManifest, prunePeerCredentialsDirect, purgeScopedSecretsDirect, purgeSecretBackendsDirect, purgeStaleExecutionDirect, purgeStaleSourcesDirect, registerWorkflowManualDirect, resetRaftStateDirect, seedEnvironmentBindingDirect, seedEnvironmentDirect, setEnvironmentPolicyDirect, setEnvironmentSecretDirect, sha256, showEnvironmentDirect, showExecutionRunDirect, showQueueEntryDirect, showRegistrationDirect, storeMigrationContentHash, tablePrefix, toErrorMessage } from "@kici-dev/shared";
8
+ import * as fs$1 from "node:fs";
9
+ import fs, { createWriteStream, promises, readFileSync } from "node:fs";
10
+ import crypto, { createDecipheriv, createHash, hkdfSync, randomBytes, randomUUID } from "node:crypto";
8
11
  import { createInterface } from "node:readline";
9
12
  import { readFile, writeFile } from "node:fs/promises";
10
13
  import { stringify } from "yaml";
11
- import crypto, { createDecipheriv, hkdfSync, randomBytes, randomUUID } from "node:crypto";
12
14
  import { z } from "zod";
13
15
  import "node:os";
14
16
  import os from "node:os";
17
+ import { getDatabaseCollationDrift, refreshDatabaseCollationVersion, reindexDatabaseConcurrently } from "@kici-dev/shared/db-collation";
15
18
  import { sql } from "kysely";
16
19
  import { Migrator } from "kysely/migration";
17
20
  import "jsonpath-plus";
@@ -19,13 +22,13 @@ import { execFile, execFileSync, execSync, spawn } from "node:child_process";
19
22
  import { promisify } from "node:util";
20
23
  import * as path$1 from "node:path";
21
24
  import path from "node:path";
22
- import * as fs$1 from "node:fs";
23
- import fs, { createWriteStream, readFileSync } from "node:fs";
24
25
  import archiver from "archiver";
25
26
  import JSZip from "jszip";
26
27
  import { confirm, input, password, select } from "@inquirer/prompts";
27
28
  import { pipeline } from "node:stream/promises";
28
29
  import "pg";
30
+ import { DASHBOARD_WRITE_OPERATIONS, DASHBOARD_WRITE_OPERATIONS_BY_NAME, DashboardWriteCategory, DashboardWriteSensitivity } from "@kici-dev/engine/protocol/dashboard-write-operations";
31
+ import { CLUSTER_NAME_FORMAT_MESSAGE } from "@kici-dev/engine/protocol/cluster-name";
29
32
  import { TERMINAL_JOB_STATES, TERMINAL_RUN_STATES, WS_MAX_PAYLOAD_BYTES, accessLogWarmSqlCase, getAccessLogColdDays, getSecretAuditLogColdDays, minAccessLogWarmDays, minSecretAuditLogWarmDays, secretAuditLogWarmSqlCase } from "@kici-dev/engine";
30
33
  var __defProp = Object.defineProperty;
31
34
  var __esmMin = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
@@ -92,6 +95,12 @@ var AdminApiClient = class {
92
95
  return this.request("PATCH", path, body);
93
96
  }
94
97
  /**
98
+ * Public PUT request returning parsed JSON.
99
+ */
100
+ async put(path, body) {
101
+ return this.request("PUT", path, body);
102
+ }
103
+ /**
95
104
  * Public DELETE request returning parsed JSON.
96
105
  */
97
106
  async delete(path) {
@@ -125,6 +134,21 @@ var AdminApiClient = class {
125
134
  async deleteSecret(orgId, scope, key) {
126
135
  return this.request("DELETE", `/api/v1/admin/secrets/${encodeURIComponent(orgId)}/${encodeURIComponent(scope)}/${encodeURIComponent(key)}`);
127
136
  }
137
+ async listVariables(orgId, environment) {
138
+ const params = new URLSearchParams({ orgId });
139
+ return this.request("GET", `/api/v1/admin/environments/${encodeURIComponent(environment)}/variables?${params}`);
140
+ }
141
+ async setVariable(orgId, environment, key, value, locked) {
142
+ const params = new URLSearchParams({ orgId });
143
+ return this.request("PUT", `/api/v1/admin/environments/${encodeURIComponent(environment)}/variables/${encodeURIComponent(key)}?${params}`, {
144
+ value,
145
+ locked
146
+ });
147
+ }
148
+ async deleteVariable(orgId, environment, key) {
149
+ const params = new URLSearchParams({ orgId });
150
+ return this.request("DELETE", `/api/v1/admin/environments/${encodeURIComponent(environment)}/variables/${encodeURIComponent(key)}?${params}`);
151
+ }
128
152
  async rotateKey() {
129
153
  return this.request("POST", "/api/v1/admin/rotate-key");
130
154
  }
@@ -384,6 +408,140 @@ var AdminApiClient = class {
384
408
  }
385
409
  };
386
410
  //#endregion
411
+ //#region src/cli/commands/shared/secret-input.ts
412
+ /**
413
+ * Shared input-mode helper for `kici-admin secret set` and
414
+ * `kici-admin variable set`.
415
+ *
416
+ * Resolves a value from exactly one of:
417
+ * --value <plaintext> — argv literal (warned; goes into shell history)
418
+ * --prompt — interactive no-echo prompt (TTY only)
419
+ * --from-stdin — pipe (full stdin until EOF)
420
+ * --from-file <path> — file contents (--trim default-on)
421
+ * --from-env <VAR> — env var contents
422
+ *
423
+ * Default-mode resolution when no flag is provided:
424
+ * TTY -> --prompt
425
+ * non-TTY -> --from-stdin
426
+ *
427
+ * Optional checks:
428
+ * --confirm-fingerprint <hex> — SHA-256 fingerprint guard against
429
+ * paste corruption.
430
+ *
431
+ * The helper does NOT print to stdout (it returns the value). It DOES
432
+ * write to stderr for the `--value` warning, the prompt label, and any
433
+ * "empty value" warning. Stdout is reserved for the caller's confirmation
434
+ * output so the caller's `--dry-run` summary stays clean.
435
+ */
436
+ const INPUT_MODE_FLAGS = [
437
+ "value",
438
+ "fromStdin",
439
+ "fromFile",
440
+ "fromEnv",
441
+ "prompt"
442
+ ];
443
+ const FINGERPRINT_RE = /^[0-9a-f]{64}$/i;
444
+ async function resolveSecretInput(opts, stderr = process.stderr, stdin = process.stdin) {
445
+ const explicit = INPUT_MODE_FLAGS.filter((flag) => {
446
+ const v = opts[flag];
447
+ return v !== void 0 && v !== false;
448
+ });
449
+ if (explicit.length > 1) throw new Error(`Ambiguous input mode: pick exactly one of --value, --from-stdin, --from-file, --from-env, --prompt (got: ${explicit.map((f) => "--" + f.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase())).join(", ")}).`);
450
+ let result;
451
+ if (opts.value !== void 0) {
452
+ stderr.write("warning: --value puts the secret in shell history. Prefer --prompt, --from-stdin, --from-env, or --from-file.\n");
453
+ result = {
454
+ value: opts.value,
455
+ source: "value"
456
+ };
457
+ } else if (opts.fromEnv !== void 0) {
458
+ const envValue = process.env[opts.fromEnv];
459
+ if (envValue === void 0) throw new Error(`--from-env ${opts.fromEnv}: environment variable is not set.`);
460
+ result = {
461
+ value: envValue,
462
+ source: "env"
463
+ };
464
+ } else if (opts.fromFile !== void 0) {
465
+ const raw = await promises.readFile(opts.fromFile, "utf8");
466
+ result = {
467
+ value: opts.trim === false ? raw : raw.replace(/\r?\n$/, ""),
468
+ source: "file"
469
+ };
470
+ } else if (opts.prompt) {
471
+ if (!stdin.isTTY) throw new Error("--prompt requires a TTY; use --from-stdin to read piped input.");
472
+ result = {
473
+ value: await promptSecret$1(stderr, stdin),
474
+ source: "prompt"
475
+ };
476
+ } else if (opts.fromStdin) {
477
+ if (stdin.isTTY) throw new Error("--from-stdin requires piped input (no TTY); pipe the value in or use --prompt to enter interactively.");
478
+ result = {
479
+ value: await readAllStdin(stdin),
480
+ source: "stdin"
481
+ };
482
+ } else if (stdin.isTTY) result = {
483
+ value: await promptSecret$1(stderr, stdin),
484
+ source: "prompt"
485
+ };
486
+ else result = {
487
+ value: await readAllStdin(stdin),
488
+ source: "stdin"
489
+ };
490
+ if (result.value.length === 0) stderr.write("warning: value is empty.\n");
491
+ if (opts.confirmFingerprint !== void 0) {
492
+ if (!FINGERPRINT_RE.test(opts.confirmFingerprint)) throw new Error("--confirm-fingerprint must be a SHA-256 hex string (64 lowercase hex characters).");
493
+ const actual = createHash("sha256").update(result.value, "utf8").digest("hex");
494
+ if (actual !== opts.confirmFingerprint.toLowerCase()) throw new Error(`--confirm-fingerprint mismatch: expected ${opts.confirmFingerprint.toLowerCase()}, got ${actual}. Value not written.`);
495
+ }
496
+ return result;
497
+ }
498
+ async function readAllStdin(stdin) {
499
+ return new Promise((resolve, reject) => {
500
+ const chunks = [];
501
+ stdin.on("data", (chunk) => {
502
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk);
503
+ });
504
+ stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "")));
505
+ stdin.on("error", reject);
506
+ });
507
+ }
508
+ async function promptSecret$1(stderr, stdin) {
509
+ const { createInterface } = await import("node:readline");
510
+ const rl = createInterface({
511
+ input: stdin,
512
+ output: stderr
513
+ });
514
+ if (stdin.setRawMode) stdin.setRawMode(true);
515
+ return new Promise((resolve) => {
516
+ stderr.write("Enter value: ");
517
+ let value = "";
518
+ function onData(chunk) {
519
+ const str = chunk.toString("utf8");
520
+ for (const char of str) if (char === "\n" || char === "\r") {
521
+ stdin.removeListener("data", onData);
522
+ if (stdin.setRawMode) stdin.setRawMode(false);
523
+ stderr.write("\n");
524
+ rl.close();
525
+ resolve(value);
526
+ return;
527
+ } else if (char === "" || char === "\b") value = value.slice(0, -1);
528
+ else if (char === "") {
529
+ stderr.write("\n");
530
+ process.exit(1);
531
+ } else value += char;
532
+ }
533
+ stdin.on("data", onData);
534
+ });
535
+ }
536
+ /**
537
+ * Compute the canonical SHA-256 fingerprint for a value. Helper for
538
+ * callers (tests, --dry-run output) that want to display the same hex
539
+ * the user would pass to `--confirm-fingerprint`.
540
+ */
541
+ function fingerprintValue(value) {
542
+ return createHash("sha256").update(value, "utf8").digest("hex");
543
+ }
544
+ //#endregion
387
545
  //#region src/cli/commands/secret.ts
388
546
  function resolveDirectDbUrl$9(explicit) {
389
547
  return explicit ?? process.env.KICI_DATABASE_URL ?? null;
@@ -416,7 +574,7 @@ function registerSecretCommands(program, getClient) {
416
574
  process.exit(1);
417
575
  }
418
576
  });
419
- sec.command("set [orgId] [scope] [key]").description("Set a secret value. Positional form: \"set <orgId> <scope> <key>\". Sugar form (environment scope): \"set --org <id> --environment <env> --key <k>\". Value is read from stdin if piped, from --value, or prompted interactively.").option("--value <value>", "Secret value (reads from stdin or prompts if omitted)").option("--org <orgId>", "Org ID (use with --environment + --key; mutually exclusive with positional form)").option("--environment <name>", "Environment scope — sugar for positional <scope>. Requires --org and --key.").option("--key <key>", "Secret key name (use with --org + --environment)").option("--from-stdin", "Read value from stdin (required when piping but no TTY)").option("--database-url <url>", "Direct-DB mode: write encrypted_value verbatim to scoped_secrets (offline; skips HTTP + encryption)").action(async (posOrgId, posScope, posKey, opts) => {
577
+ sec.command("set [orgId] [scope] [key]").description("Set a secret value. Positional form: \"set <orgId> <scope> <key>\". Sugar form (environment scope): \"set --org <id> --environment <env> --key <k>\". Value comes from one of: --prompt (default on TTY), --from-stdin (default on pipe), --from-file <path>, --from-env <VAR>, --value <plaintext> (discouraged).").option("--value <value>", "Secret value via argv (visible in shell history; prefer --prompt)").option("--org <orgId>", "Org ID (use with --environment + --key; mutually exclusive with positional form)").option("--environment <name>", "Environment scope — sugar for positional <scope>. Requires --org and --key.").option("--key <key>", "Secret key name (use with --org + --environment)").option("--prompt", "Interactive no-echo prompt (requires TTY)").option("--from-stdin", "Read value from piped stdin until EOF").option("--from-file <path>", "Read value from a file (trailing newline trimmed)").option("--from-env <var>", "Read value from a named environment variable").option("--no-trim", "When reading --from-file, keep the trailing newline (default: trim once)").option("--confirm-fingerprint <sha256hex>", "Refuse the write unless SHA-256(value) matches this 64-hex string").option("--dry-run", "Parse + validate the value, print fingerprint + length, do not write").option("--database-url <url>", "Direct-DB mode: write encrypted_value verbatim to scoped_secrets (offline; skips HTTP + encryption)").action(async (posOrgId, posScope, posKey, opts) => {
420
578
  try {
421
579
  const hasPositional = Boolean(posOrgId || posScope || posKey);
422
580
  const hasSugar = Boolean(opts.org || opts.environment || opts.key);
@@ -437,9 +595,11 @@ function registerSecretCommands(program, getClient) {
437
595
  scope = posScope;
438
596
  key = posKey;
439
597
  }
440
- let value = opts.value;
441
- if (opts.fromStdin) value = await readPipedStdin();
442
- else if (value === void 0) value = await readSecretValue();
598
+ const { value, source } = await resolveSecretInput(opts);
599
+ if (opts.dryRun) {
600
+ console.log(`[dry-run] would set secret '${key}' in scope '${scope}' for org ${orgId} (${value.length} chars, source=${source}, sha256=${fingerprintValue(value)})`);
601
+ return;
602
+ }
443
603
  const dbUrl = resolveDirectDbUrl$9(opts.databaseUrl);
444
604
  if (dbUrl) {
445
605
  await setEnvironmentSecretDirect(dbUrl, {
@@ -461,7 +621,7 @@ function registerSecretCommands(program, getClient) {
461
621
  sec.command("delete <orgId> <scope> <key>").description("Delete a secret").option("--yes", "Skip confirmation prompt").action(async (orgId, scope, key, opts) => {
462
622
  try {
463
623
  if (!opts.yes) {
464
- if (!await confirm$3(`Are you sure you want to delete secret '${key}' from scope '${scope}'?`)) {
624
+ if (!await confirm$4(`Are you sure you want to delete secret '${key}' from scope '${scope}'?`)) {
465
625
  console.log("Aborted.");
466
626
  return;
467
627
  }
@@ -474,63 +634,7 @@ function registerSecretCommands(program, getClient) {
474
634
  }
475
635
  });
476
636
  }
477
- /**
478
- * Read all of piped stdin as a string, trimmed. Throws if stdin is a TTY
479
- * (no pipe attached). Used by `--from-stdin` in scripted callers (E2E,
480
- * deploy scripts) that want deterministic piping semantics.
481
- */
482
- async function readPipedStdin() {
483
- if (process.stdin.isTTY) throw new Error("--from-stdin requires piped input (no TTY); pipe the value in or omit the flag to prompt.");
484
- return new Promise((resolve, reject) => {
485
- const chunks = [];
486
- process.stdin.on("data", (chunk) => chunks.push(chunk));
487
- process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString().trim()));
488
- process.stdin.on("error", reject);
489
- });
490
- }
491
- /**
492
- * Read a secret value from stdin (pipe) or interactive prompt.
493
- *
494
- * - If stdin is piped, reads the entire input as the value.
495
- * - If stdin is a TTY, prompts for the value with hidden input.
496
- */
497
- async function readSecretValue() {
498
- if (!process.stdin.isTTY) return new Promise((resolve, reject) => {
499
- const chunks = [];
500
- process.stdin.on("data", (chunk) => chunks.push(chunk));
501
- process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString().trim()));
502
- process.stdin.on("error", reject);
503
- });
504
- const { createInterface } = await import("node:readline");
505
- const rl = createInterface({
506
- input: process.stdin,
507
- output: process.stderr
508
- });
509
- if (process.stdin.setRawMode) process.stdin.setRawMode(true);
510
- return new Promise((resolve) => {
511
- process.stderr.write("Enter secret value: ");
512
- let value = "";
513
- process.stdin.on("data", function onData(chunk) {
514
- const str = chunk.toString();
515
- for (const char of str) if (char === "\n" || char === "\r") {
516
- process.stdin.removeListener("data", onData);
517
- if (process.stdin.setRawMode) process.stdin.setRawMode(false);
518
- process.stderr.write("\n");
519
- rl.close();
520
- resolve(value);
521
- return;
522
- } else if (char === "" || char === "\b") value = value.slice(0, -1);
523
- else if (char === "") {
524
- process.stderr.write("\n");
525
- process.exit(1);
526
- } else value += char;
527
- });
528
- });
529
- }
530
- /**
531
- * Simple confirmation prompt.
532
- */
533
- async function confirm$3(message) {
637
+ async function confirm$4(message) {
534
638
  const { createInterface } = await import("node:readline");
535
639
  const rl = createInterface({
536
640
  input: process.stdin,
@@ -610,7 +714,7 @@ function formatTokenTable(tokens) {
610
714
  }
611
715
  function registerTokenCommands(program, getClient) {
612
716
  const tok = program.command("token").description("Manage admin API tokens");
613
- tok.command("create <label>").description("Create a new admin API token").requiredOption("--role <role>", "Token role (owner, admin, auditor)").option("--routing-key <key>", "Optional routing key scope").action(async (label, opts) => {
717
+ tok.command("create <label>").description("Create a new admin API token").requiredOption("--role <role>", "Token role (owner, admin, auditor)").option("--routing-key <key>", "Restrict the token to a single source routing key (e.g. \"github:42\"). The token can only act on requests targeting that routing key. Without this, the token has full orchestrator access.").action(async (label, opts) => {
614
718
  try {
615
719
  const result = await getClient().createToken({
616
720
  label,
@@ -1412,8 +1516,8 @@ var init_client = __esmMin((() => {
1412
1516
  //#region src/db/migrations/001_initial.ts
1413
1517
  init_client();
1414
1518
  var _001_initial_exports = /* @__PURE__ */ __exportAll({
1415
- down: () => down$17,
1416
- up: () => up$17
1519
+ down: () => down$22,
1520
+ up: () => up$22
1417
1521
  });
1418
1522
  /**
1419
1523
  * Squashed initial migration -- creates the complete Orchestrator database schema.
@@ -2105,7 +2209,7 @@ const DDL_STATEMENTS = [
2105
2209
  `ALTER TABLE ONLY public.held_runs
2106
2210
  ADD CONSTRAINT held_runs_environment_id_fkey FOREIGN KEY (environment_id) REFERENCES public.environments(id);`
2107
2211
  ];
2108
- async function up$17(db) {
2212
+ async function up$22(db) {
2109
2213
  for (const stmt of DDL_STATEMENTS) await sql.raw(stmt).execute(db);
2110
2214
  await sql`
2111
2215
  INSERT INTO cluster_meta (key, value)
@@ -2127,7 +2231,7 @@ async function up$17(db) {
2127
2231
  * Rollback drops everything created above. Uses CASCADE on table drops to cut
2128
2232
  * through the FK graph without relying on exact topological order.
2129
2233
  */
2130
- async function down$17(db) {
2234
+ async function down$22(db) {
2131
2235
  for (const [trig, tbl] of [["source_secrets_change_trigger", "scoped_secrets"], ["sources_change_trigger", "sources"]]) await sql.raw(`DROP TRIGGER IF EXISTS ${trig} ON public.${tbl}`).execute(db);
2132
2236
  for (const table of [
2133
2237
  "workflow_registrations",
@@ -2174,8 +2278,8 @@ async function down$17(db) {
2174
2278
  //#endregion
2175
2279
  //#region src/db/migrations/002_config_versions_key_version.ts
2176
2280
  var _002_config_versions_key_version_exports = /* @__PURE__ */ __exportAll({
2177
- down: () => down$16,
2178
- up: () => up$16
2281
+ down: () => down$21,
2282
+ up: () => up$21
2179
2283
  });
2180
2284
  /**
2181
2285
  * Add key_version column to config_versions so that sensitive-field encryption
@@ -2189,13 +2293,13 @@ var _002_config_versions_key_version_exports = /* @__PURE__ */ __exportAll({
2189
2293
  * No index is needed: rotation does a full-table scan; reads are by
2190
2294
  * `version` primary key and never filter on `key_version`.
2191
2295
  */
2192
- async function up$16(db) {
2296
+ async function up$21(db) {
2193
2297
  await sql`
2194
2298
  ALTER TABLE public.config_versions
2195
2299
  ADD COLUMN key_version integer NOT NULL DEFAULT 1
2196
2300
  `.execute(db);
2197
2301
  }
2198
- async function down$16(db) {
2302
+ async function down$21(db) {
2199
2303
  await sql`
2200
2304
  ALTER TABLE public.config_versions
2201
2305
  DROP COLUMN key_version
@@ -2204,8 +2308,8 @@ async function down$16(db) {
2204
2308
  //#endregion
2205
2309
  //#region src/db/migrations/003_access_log.ts
2206
2310
  var _003_access_log_exports = /* @__PURE__ */ __exportAll({
2207
- down: () => down$15,
2208
- up: () => up$15
2311
+ down: () => down$20,
2312
+ up: () => up$20
2209
2313
  });
2210
2314
  /**
2211
2315
  * Access log: one row per read or orchestrator-admin mutation attributable
@@ -2229,7 +2333,7 @@ var _003_access_log_exports = /* @__PURE__ */ __exportAll({
2229
2333
  * Retention is TTL-based via expires_at; packages/orchestrator/src/queue/
2230
2334
  * cleanup.ts picks up the prune pass.
2231
2335
  */
2232
- async function up$15(db) {
2336
+ async function up$20(db) {
2233
2337
  await sql`
2234
2338
  CREATE TABLE public.access_log (
2235
2339
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -2267,28 +2371,28 @@ async function up$15(db) {
2267
2371
  ON public.access_log (actor_type, actor_id, created_at DESC)
2268
2372
  `.execute(db);
2269
2373
  }
2270
- async function down$15(db) {
2374
+ async function down$20(db) {
2271
2375
  await sql`DROP TABLE IF EXISTS public.access_log`.execute(db);
2272
2376
  }
2273
2377
  //#endregion
2274
2378
  //#region src/db/migrations/004_rename_bundle_to_source.ts
2275
2379
  var _004_rename_bundle_to_source_exports = /* @__PURE__ */ __exportAll({
2276
- down: () => down$14,
2277
- up: () => up$14
2380
+ down: () => down$19,
2381
+ up: () => up$19
2278
2382
  });
2279
- async function up$14(db) {
2383
+ async function up$19(db) {
2280
2384
  await db.schema.alterTable("dispatch_queue").renameColumn("bundle_url", "source_tar_url").execute();
2281
2385
  await db.schema.alterTable("dispatch_queue").renameColumn("bundle_hash", "source_tar_hash").execute();
2282
2386
  }
2283
- async function down$14(db) {
2387
+ async function down$19(db) {
2284
2388
  await db.schema.alterTable("dispatch_queue").renameColumn("source_tar_url", "bundle_url").execute();
2285
2389
  await db.schema.alterTable("dispatch_queue").renameColumn("source_tar_hash", "bundle_hash").execute();
2286
2390
  }
2287
2391
  //#endregion
2288
2392
  //#region src/db/migrations/005_cold_store_chunk_counter.ts
2289
2393
  var _005_cold_store_chunk_counter_exports = /* @__PURE__ */ __exportAll({
2290
- down: () => down$13,
2291
- up: () => up$13
2394
+ down: () => down$18,
2395
+ up: () => up$18
2292
2396
  });
2293
2397
  /**
2294
2398
  * Cold-store chunk counter table.
@@ -2305,7 +2409,7 @@ var _005_cold_store_chunk_counter_exports = /* @__PURE__ */ __exportAll({
2305
2409
  *
2306
2410
  * sections 5 and 8.
2307
2411
  */
2308
- async function up$13(db) {
2412
+ async function up$18(db) {
2309
2413
  await sql`
2310
2414
  CREATE TABLE public.cold_store_chunk_counts (
2311
2415
  db TEXT NOT NULL,
@@ -2323,14 +2427,14 @@ async function up$13(db) {
2323
2427
  ON public.cold_store_chunk_counts (db, table_name)
2324
2428
  `.execute(db);
2325
2429
  }
2326
- async function down$13(db) {
2430
+ async function down$18(db) {
2327
2431
  await sql`DROP TABLE IF EXISTS public.cold_store_chunk_counts`.execute(db);
2328
2432
  }
2329
2433
  //#endregion
2330
2434
  //#region src/db/migrations/006_runs_jobs_steps_archived_at.ts
2331
2435
  var _006_runs_jobs_steps_archived_at_exports = /* @__PURE__ */ __exportAll({
2332
- down: () => down$12,
2333
- up: () => up$12
2436
+ down: () => down$17,
2437
+ up: () => up$17
2334
2438
  });
2335
2439
  /**
2336
2440
  * `execution_runs` / `execution_jobs` / `execution_steps` cold-store
@@ -2366,7 +2470,7 @@ var _006_runs_jobs_steps_archived_at_exports = /* @__PURE__ */ __exportAll({
2366
2470
  * - `idx_execution_jobs_routing_key_created (routing_key, created_at)`
2367
2471
  * - `idx_execution_steps_routing_key_created (routing_key, created_at)`
2368
2472
  */
2369
- async function up$12(db) {
2473
+ async function up$17(db) {
2370
2474
  await sql`
2371
2475
  ALTER TABLE public.execution_runs
2372
2476
  ADD COLUMN archived_at TIMESTAMPTZ NULL,
@@ -2411,7 +2515,7 @@ async function up$12(db) {
2411
2515
  ON public.execution_steps (routing_key, created_at)
2412
2516
  `.execute(db);
2413
2517
  }
2414
- async function down$12(db) {
2518
+ async function down$17(db) {
2415
2519
  await sql`DROP INDEX IF EXISTS public.idx_execution_steps_routing_key_created`.execute(db);
2416
2520
  await sql`
2417
2521
  ALTER TABLE public.execution_steps
@@ -2436,8 +2540,8 @@ async function down$12(db) {
2436
2540
  //#endregion
2437
2541
  //#region src/db/migrations/007_audit_logs_archived_at.ts
2438
2542
  var _007_audit_logs_archived_at_exports = /* @__PURE__ */ __exportAll({
2439
- down: () => down$11,
2440
- up: () => up$11
2543
+ down: () => down$16,
2544
+ up: () => up$16
2441
2545
  });
2442
2546
  /**
2443
2547
  * `secret_audit_log` and `access_log` cold-store schema additions, plus
@@ -2477,7 +2581,7 @@ var _007_audit_logs_archived_at_exports = /* @__PURE__ */ __exportAll({
2477
2581
  * `down()` would not have meaningful retention bounds. Acceptable for
2478
2582
  * staging.
2479
2583
  */
2480
- async function up$11(db) {
2584
+ async function up$16(db) {
2481
2585
  await sql`
2482
2586
  ALTER TABLE public.secret_audit_log
2483
2587
  ADD COLUMN archived_at TIMESTAMPTZ NULL,
@@ -2498,7 +2602,7 @@ async function up$11(db) {
2498
2602
  DROP COLUMN IF EXISTS expires_at
2499
2603
  `.execute(db);
2500
2604
  }
2501
- async function down$11(db) {
2605
+ async function down$16(db) {
2502
2606
  await sql`
2503
2607
  ALTER TABLE public.access_log
2504
2608
  ADD COLUMN expires_at TIMESTAMPTZ NOT NULL DEFAULT (now() + INTERVAL '90 days')
@@ -2526,8 +2630,8 @@ async function down$11(db) {
2526
2630
  //#endregion
2527
2631
  //#region src/db/migrations/008_event_log_archived_at.ts
2528
2632
  var _008_event_log_archived_at_exports = /* @__PURE__ */ __exportAll({
2529
- down: () => down$10,
2530
- up: () => up$10
2633
+ down: () => down$15,
2634
+ up: () => up$15
2531
2635
  });
2532
2636
  /**
2533
2637
  * `event_log` cold-store schema additions plus removal of the
@@ -2565,7 +2669,7 @@ var _008_event_log_archived_at_exports = /* @__PURE__ */ __exportAll({
2565
2669
  * — best effort; rows inserted between `up()` and a hypothetical
2566
2670
  * `down()` would not have meaningful retention bounds.
2567
2671
  */
2568
- async function up$10(db) {
2672
+ async function up$15(db) {
2569
2673
  await sql`
2570
2674
  ALTER TABLE public.event_log
2571
2675
  ADD COLUMN archived_at TIMESTAMPTZ NULL,
@@ -2581,7 +2685,7 @@ async function up$10(db) {
2581
2685
  DROP COLUMN IF EXISTS expires_at
2582
2686
  `.execute(db);
2583
2687
  }
2584
- async function down$10(db) {
2688
+ async function down$15(db) {
2585
2689
  await sql`
2586
2690
  ALTER TABLE public.event_log
2587
2691
  ADD COLUMN expires_at TIMESTAMPTZ NOT NULL DEFAULT (now() + INTERVAL '30 days')
@@ -2600,8 +2704,8 @@ async function down$10(db) {
2600
2704
  //#endregion
2601
2705
  //#region src/db/migrations/009_access_log_trigram.ts
2602
2706
  var _009_access_log_trigram_exports = /* @__PURE__ */ __exportAll({
2603
- down: () => down$9,
2604
- up: () => up$9
2707
+ down: () => down$14,
2708
+ up: () => up$14
2605
2709
  });
2606
2710
  /**
2607
2711
  * Trigram (pg_trgm) index on access_log.error_message for the federated
@@ -2614,7 +2718,7 @@ var _009_access_log_trigram_exports = /* @__PURE__ */ __exportAll({
2614
2718
  * EXISTS` are both safe to re-run. No CONCURRENTLY because Kysely runs
2615
2719
  * migrations inside a transaction; the lock is brief on a sampled table.
2616
2720
  */
2617
- async function up$9(db) {
2721
+ async function up$14(db) {
2618
2722
  await sql`CREATE EXTENSION IF NOT EXISTS pg_trgm`.execute(db);
2619
2723
  await sql`
2620
2724
  CREATE INDEX IF NOT EXISTS access_log_error_message_trgm_idx
@@ -2623,14 +2727,14 @@ async function up$9(db) {
2623
2727
  WHERE error_message IS NOT NULL
2624
2728
  `.execute(db);
2625
2729
  }
2626
- async function down$9(db) {
2730
+ async function down$14(db) {
2627
2731
  await sql`DROP INDEX IF EXISTS public.access_log_error_message_trgm_idx`.execute(db);
2628
2732
  }
2629
2733
  //#endregion
2630
2734
  //#region src/db/migrations/010_cold_store_chunks.ts
2631
2735
  var _010_cold_store_chunks_exports = /* @__PURE__ */ __exportAll({
2632
- down: () => down$8,
2633
- up: () => up$8
2736
+ down: () => down$13,
2737
+ up: () => up$13
2634
2738
  });
2635
2739
  /**
2636
2740
  * Cold-store chunk index — Phase 2 (cold-store purge).
@@ -2662,7 +2766,7 @@ var _010_cold_store_chunks_exports = /* @__PURE__ */ __exportAll({
2662
2766
  * forever. Adapters that don't opt into per-bucket archival via
2663
2767
  * `coldTtlDays` don't insert here either.
2664
2768
  */
2665
- async function up$8(db) {
2769
+ async function up$13(db) {
2666
2770
  await sql`
2667
2771
  CREATE TABLE public.cold_store_chunks (
2668
2772
  db TEXT NOT NULL,
@@ -2689,14 +2793,14 @@ async function up$8(db) {
2689
2793
  ON public.cold_store_chunks (db, table_name, tenant_id, archived_at DESC)
2690
2794
  `.execute(db);
2691
2795
  }
2692
- async function down$8(db) {
2796
+ async function down$13(db) {
2693
2797
  await sql`DROP TABLE IF EXISTS public.cold_store_chunks`.execute(db);
2694
2798
  }
2695
2799
  //#endregion
2696
2800
  //#region src/db/migrations/011_drop_source_secrets_notify.ts
2697
2801
  var _011_drop_source_secrets_notify_exports = /* @__PURE__ */ __exportAll({
2698
- down: () => down$7,
2699
- up: () => up$7
2802
+ down: () => down$12,
2803
+ up: () => up$12
2700
2804
  });
2701
2805
  /**
2702
2806
  * Drop the `source_secrets_change_trigger` and the
@@ -2715,11 +2819,11 @@ var _011_drop_source_secrets_notify_exports = /* @__PURE__ */ __exportAll({
2715
2819
  * in `001_initial.ts`. They wake up no consumer until the `WebhookSecretManager`
2716
2820
  * is restored, so this migration is safe to roll back.
2717
2821
  */
2718
- async function up$7(db) {
2822
+ async function up$12(db) {
2719
2823
  await sql`DROP TRIGGER IF EXISTS source_secrets_change_trigger ON public.scoped_secrets`.execute(db);
2720
2824
  await sql`DROP FUNCTION IF EXISTS public.notify_source_secrets_change() CASCADE`.execute(db);
2721
2825
  }
2722
- async function down$7(db) {
2826
+ async function down$12(db) {
2723
2827
  await sql`
2724
2828
  CREATE OR REPLACE FUNCTION public.notify_source_secrets_change() RETURNS trigger
2725
2829
  LANGUAGE plpgsql
@@ -2758,8 +2862,8 @@ async function down$7(db) {
2758
2862
  //#endregion
2759
2863
  //#region src/db/migrations/012_peer_credentials_active_uniq.ts
2760
2864
  var _012_peer_credentials_active_uniq_exports = /* @__PURE__ */ __exportAll({
2761
- down: () => down$6,
2762
- up: () => up$6
2865
+ down: () => down$11,
2866
+ up: () => up$11
2763
2867
  });
2764
2868
  /**
2765
2869
  * Add a partial unique index on `peer_credentials (instance_id) WHERE
@@ -2785,7 +2889,7 @@ var _012_peer_credentials_active_uniq_exports = /* @__PURE__ */ __exportAll({
2785
2889
  * `down()` only drops the index; it does NOT undo the dedupe (there's no
2786
2890
  * safe way to recreate revoked rows, and the dedupe is monotonic).
2787
2891
  */
2788
- async function up$6(db) {
2892
+ async function up$11(db) {
2789
2893
  await sql`
2790
2894
  UPDATE public.peer_credentials
2791
2895
  SET revoked_at = NOW()
@@ -2803,14 +2907,14 @@ async function up$6(db) {
2803
2907
  WHERE revoked_at IS NULL
2804
2908
  `.execute(db);
2805
2909
  }
2806
- async function down$6(db) {
2910
+ async function down$11(db) {
2807
2911
  await sql`DROP INDEX IF EXISTS public.peer_credentials_active_uniq`.execute(db);
2808
2912
  }
2809
2913
  //#endregion
2810
2914
  //#region src/db/migrations/013_execution_log_bytes.ts
2811
2915
  var _013_execution_log_bytes_exports = /* @__PURE__ */ __exportAll({
2812
- down: () => down$5,
2813
- up: () => up$5
2916
+ down: () => down$10,
2917
+ up: () => up$10
2814
2918
  });
2815
2919
  /**
2816
2920
  * Add `log_bytes BIGINT NOT NULL DEFAULT 0` columns to `execution_runs` and
@@ -2829,7 +2933,7 @@ var _013_execution_log_bytes_exports = /* @__PURE__ */ __exportAll({
2829
2933
  *
2830
2934
  * Idempotent (`ADD COLUMN IF NOT EXISTS`).
2831
2935
  */
2832
- async function up$5(db) {
2936
+ async function up$10(db) {
2833
2937
  await sql`
2834
2938
  ALTER TABLE public.execution_runs
2835
2939
  ADD COLUMN IF NOT EXISTS log_bytes BIGINT NOT NULL DEFAULT 0
@@ -2839,15 +2943,15 @@ async function up$5(db) {
2839
2943
  ADD COLUMN IF NOT EXISTS log_bytes BIGINT NOT NULL DEFAULT 0
2840
2944
  `.execute(db);
2841
2945
  }
2842
- async function down$5(db) {
2946
+ async function down$10(db) {
2843
2947
  await sql`ALTER TABLE public.execution_runs DROP COLUMN IF EXISTS log_bytes`.execute(db);
2844
2948
  await sql`ALTER TABLE public.execution_jobs DROP COLUMN IF EXISTS log_bytes`.execute(db);
2845
2949
  }
2846
2950
  //#endregion
2847
2951
  //#region src/db/migrations/014_kici_events_lease_retry.ts
2848
2952
  var _014_kici_events_lease_retry_exports = /* @__PURE__ */ __exportAll({
2849
- down: () => down$4,
2850
- up: () => up$4
2953
+ down: () => down$9,
2954
+ up: () => up$9
2851
2955
  });
2852
2956
  /**
2853
2957
  * Add lease + retry + DLQ columns to `kici_events` so the EventRouter can
@@ -2881,7 +2985,7 @@ var _014_kici_events_lease_retry_exports = /* @__PURE__ */ __exportAll({
2881
2985
  *
2882
2986
  * Idempotent (`ADD COLUMN IF NOT EXISTS` + `CREATE INDEX IF NOT EXISTS`).
2883
2987
  */
2884
- async function up$4(db) {
2988
+ async function up$9(db) {
2885
2989
  await sql`
2886
2990
  ALTER TABLE public.kici_events
2887
2991
  ADD COLUMN IF NOT EXISTS claimed_at TIMESTAMPTZ,
@@ -2912,7 +3016,7 @@ async function up$4(db) {
2912
3016
  WHERE dlq_at IS NOT NULL
2913
3017
  `.execute(db);
2914
3018
  }
2915
- async function down$4(db) {
3019
+ async function down$9(db) {
2916
3020
  await sql`DROP INDEX IF EXISTS public.idx_kici_events_dlq`.execute(db);
2917
3021
  await sql`DROP INDEX IF EXISTS public.idx_kici_events_lease_expired`.execute(db);
2918
3022
  await sql`DROP INDEX IF EXISTS public.idx_kici_events_retry_due`.execute(db);
@@ -2930,8 +3034,8 @@ async function down$4(db) {
2930
3034
  //#endregion
2931
3035
  //#region src/db/migrations/015_org_settings_customer_scoped.ts
2932
3036
  var _015_org_settings_customer_scoped_exports = /* @__PURE__ */ __exportAll({
2933
- down: () => down$3,
2934
- up: () => up$3
3037
+ down: () => down$8,
3038
+ up: () => up$8
2935
3039
  });
2936
3040
  /**
2937
3041
  * Org-scope `org_settings` and qualify each glob entry by source.
@@ -2959,7 +3063,7 @@ var _015_org_settings_customer_scoped_exports = /* @__PURE__ */ __exportAll({
2959
3063
  * Idempotent: a re-run on an already-migrated DB sees `customer_id` exists
2960
3064
  * and the list columns are already jsonb, so it is a no-op.
2961
3065
  */
2962
- async function up$3(db) {
3066
+ async function up$8(db) {
2963
3067
  if ((await sql`
2964
3068
  SELECT EXISTS (
2965
3069
  SELECT 1 FROM information_schema.columns
@@ -3084,7 +3188,7 @@ async function up$3(db) {
3084
3188
  await sql`DROP TABLE _org_settings_merged`.execute(db);
3085
3189
  await sql`DROP TABLE _org_settings_stage`.execute(db);
3086
3190
  }
3087
- async function down$3(db) {
3191
+ async function down$8(db) {
3088
3192
  if (!(await sql`
3089
3193
  SELECT EXISTS (
3090
3194
  SELECT 1 FROM information_schema.columns
@@ -3109,8 +3213,8 @@ async function down$3(db) {
3109
3213
  //#endregion
3110
3214
  //#region src/db/migrations/016_org_settings_allow_http_npm.ts
3111
3215
  var _016_org_settings_allow_http_npm_exports = /* @__PURE__ */ __exportAll({
3112
- down: () => down$2,
3113
- up: () => up$2
3216
+ down: () => down$7,
3217
+ up: () => up$7
3114
3218
  });
3115
3219
  /**
3116
3220
  * Add `org_settings.allow_http_npm_registries boolean NOT NULL DEFAULT false`.
@@ -3123,7 +3227,7 @@ var _016_org_settings_allow_http_npm_exports = /* @__PURE__ */ __exportAll({
3123
3227
  *
3124
3228
  * Idempotent: a re-run on a DB that already has the column is a no-op.
3125
3229
  */
3126
- async function up$2(db) {
3230
+ async function up$7(db) {
3127
3231
  if ((await sql`
3128
3232
  SELECT EXISTS (
3129
3233
  SELECT 1 FROM information_schema.columns
@@ -3137,7 +3241,7 @@ async function up$2(db) {
3137
3241
  ADD COLUMN allow_http_npm_registries boolean NOT NULL DEFAULT false
3138
3242
  `.execute(db);
3139
3243
  }
3140
- async function down$2(db) {
3244
+ async function down$7(db) {
3141
3245
  await sql`
3142
3246
  ALTER TABLE public.org_settings DROP COLUMN IF EXISTS allow_http_npm_registries
3143
3247
  `.execute(db);
@@ -3145,8 +3249,8 @@ async function down$2(db) {
3145
3249
  //#endregion
3146
3250
  //#region src/db/migrations/017_org_id_widen.ts
3147
3251
  var _017_org_id_widen_exports = /* @__PURE__ */ __exportAll({
3148
- down: () => down$1,
3149
- up: () => up$1
3252
+ down: () => down$6,
3253
+ up: () => up$6
3150
3254
  });
3151
3255
  /**
3152
3256
  * Widen every orchestrator-side `org_id varchar(12)` column to
@@ -3186,17 +3290,17 @@ const ORG_ID_TABLES$1 = [
3186
3290
  "held_runs",
3187
3291
  "scoped_secrets"
3188
3292
  ];
3189
- async function up$1(db) {
3293
+ async function up$6(db) {
3190
3294
  for (const table of ORG_ID_TABLES$1) await sql.raw(`ALTER TABLE public.${table} ALTER COLUMN org_id TYPE varchar(16)`).execute(db);
3191
3295
  }
3192
- async function down$1(db) {
3296
+ async function down$6(db) {
3193
3297
  for (const table of ORG_ID_TABLES$1) await sql.raw(`ALTER TABLE public.${table} ALTER COLUMN org_id TYPE varchar(12)`).execute(db);
3194
3298
  }
3195
3299
  //#endregion
3196
3300
  //#region src/db/migrations/018_org_id_prefix_backfill.ts
3197
3301
  var _018_org_id_prefix_backfill_exports = /* @__PURE__ */ __exportAll({
3198
- down: () => down,
3199
- up: () => up
3302
+ down: () => down$5,
3303
+ up: () => up$5
3200
3304
  });
3201
3305
  /**
3202
3306
  * Prefix every orchestrator-side tenant string with `org_` to align
@@ -3213,41 +3317,341 @@ var _018_org_id_prefix_backfill_exports = /* @__PURE__ */ __exportAll({
3213
3317
  * `environment_bindings`, `environment_source_overrides`,
3214
3318
  * `environment_variables`).
3215
3319
  *
3216
- * This migration walks both column families — `org_id` and
3217
- * `customer_id` — and applies the same `'org_' || value` prefix to
3218
- * every row whose value is not the platform-admin sentinel
3219
- * `kici-admin`. After it runs, the orchestrator's `WHERE org_id = $1`
3220
- * / `WHERE customer_id = $1` filters match the prefixed canonical IDs
3221
- * that Platform now sends.
3320
+ * This migration walks both column families — `org_id` and
3321
+ * `customer_id` — and applies the same `'org_' || value` prefix to
3322
+ * every row whose value is not the platform-admin sentinel
3323
+ * `kici-admin`. After it runs, the orchestrator's `WHERE org_id = $1`
3324
+ * / `WHERE customer_id = $1` filters match the prefixed canonical IDs
3325
+ * that Platform now sends.
3326
+ *
3327
+ * Idempotency: the `WHERE NOT LIKE 'org\\_%'` guard makes the
3328
+ * migration a no-op on rows already prefixed (e.g. a re-run after a
3329
+ * fresh deploy populated some rows already), so applying it twice is
3330
+ * safe.
3331
+ */
3332
+ const ORG_ID_TABLES = [
3333
+ "access_log",
3334
+ "environment_bindings",
3335
+ "environment_source_overrides",
3336
+ "environment_variables",
3337
+ "environments",
3338
+ "event_log",
3339
+ "held_runs",
3340
+ "scoped_secrets"
3341
+ ];
3342
+ const CUSTOMER_ID_TABLES = [
3343
+ "sources",
3344
+ "generic_webhook_sources",
3345
+ "workflow_registrations",
3346
+ "org_settings"
3347
+ ];
3348
+ async function up$5(db) {
3349
+ for (const table of ORG_ID_TABLES) await sql.raw(`UPDATE public.${table} SET org_id = 'org_' || org_id WHERE org_id <> 'kici-admin' AND org_id NOT LIKE 'org\\_%' ESCAPE '\\'`).execute(db);
3350
+ for (const table of CUSTOMER_ID_TABLES) await sql.raw(`UPDATE public.${table} SET customer_id = 'org_' || customer_id WHERE customer_id <> 'kici-admin' AND customer_id NOT LIKE 'org\\_%' ESCAPE '\\'`).execute(db);
3351
+ }
3352
+ async function down$5(db) {
3353
+ for (const table of CUSTOMER_ID_TABLES) await sql.raw(`UPDATE public.${table} SET customer_id = substring(customer_id from 5) WHERE customer_id LIKE 'org\\_%' ESCAPE '\\'`).execute(db);
3354
+ for (const table of ORG_ID_TABLES) await sql.raw(`UPDATE public.${table} SET org_id = substring(org_id from 5) WHERE org_id LIKE 'org\\_%' ESCAPE '\\'`).execute(db);
3355
+ }
3356
+ //#endregion
3357
+ //#region src/db/migrations/019_generic_sources_change_notify.ts
3358
+ var _019_generic_sources_change_notify_exports = /* @__PURE__ */ __exportAll({
3359
+ down: () => down$4,
3360
+ up: () => up$4
3361
+ });
3362
+ /**
3363
+ * Add a Postgres trigger on `generic_webhook_sources` that emits
3364
+ * `pg_notify('generic_sources_change', routing_key)` on every INSERT,
3365
+ * UPDATE, and DELETE. Soft-deletes (UPDATE that sets `deleted_at`) ride
3366
+ * the same UPDATE path — the listener treats a row with `deleted_at IS
3367
+ * NOT NULL` (or a row that no longer exists for that routing_key) as a
3368
+ * de-registration signal.
3369
+ *
3370
+ * The consumer is `GenericSourcesChangeListener`
3371
+ * (`packages/orchestrator/src/webhook/generic-sources-listener.ts`),
3372
+ * which runs in every orchestrator peer, LISTENs on the channel, and
3373
+ * mutates the local `ProviderRegistry` in place
3374
+ * (`registerProviderBundleForSource` for INSERT/UPDATE; `unregister` for
3375
+ * DELETE / soft-delete). Without this round-trip, peers other than the
3376
+ * one that handled `kici-admin source add generic` would 404 incoming
3377
+ * webhooks until restart.
3378
+ *
3379
+ * Mirrors the existing GitHub-app `sources` trigger
3380
+ * (`notify_sources_change()` + `sources_change_trigger`, defined in
3381
+ * `001_initial.ts`).
3382
+ */
3383
+ async function up$4(db) {
3384
+ await sql`
3385
+ CREATE FUNCTION public.notify_generic_sources_change() RETURNS trigger
3386
+ LANGUAGE plpgsql
3387
+ AS $$
3388
+ BEGIN
3389
+ IF TG_OP = 'DELETE' THEN
3390
+ PERFORM pg_notify('generic_sources_change', OLD.routing_key);
3391
+ ELSE
3392
+ PERFORM pg_notify('generic_sources_change', NEW.routing_key);
3393
+ END IF;
3394
+ RETURN NULL;
3395
+ END;
3396
+ $$
3397
+ `.execute(db);
3398
+ await sql`
3399
+ CREATE TRIGGER generic_sources_change_trigger
3400
+ AFTER INSERT OR UPDATE OR DELETE ON public.generic_webhook_sources
3401
+ FOR EACH ROW EXECUTE FUNCTION public.notify_generic_sources_change()
3402
+ `.execute(db);
3403
+ }
3404
+ async function down$4(db) {
3405
+ await sql`DROP TRIGGER IF EXISTS generic_sources_change_trigger ON public.generic_webhook_sources`.execute(db);
3406
+ await sql`DROP FUNCTION IF EXISTS public.notify_generic_sources_change()`.execute(db);
3407
+ }
3408
+ //#endregion
3409
+ //#region src/db/migrations/020_org_settings_dashboard_write_policy.ts
3410
+ var _020_org_settings_dashboard_write_policy_exports = /* @__PURE__ */ __exportAll({
3411
+ down: () => down$3,
3412
+ up: () => up$3
3413
+ });
3414
+ /**
3415
+ * Add `org_settings.dashboard_write_policy jsonb NOT NULL DEFAULT '{}'`.
3416
+ *
3417
+ * Stores the per-customer policy that decides which Platform-routed
3418
+ * dashboard.* write operations the orchestrator accepts. An empty
3419
+ * JSONB object means everything is enabled (permissive default). To
3420
+ * disable an operation, set its key to `false`, e.g.
3421
+ * `{"secrets.set": false, "variables.set": false}`. Unknown / missing
3422
+ * keys are treated as `true` by the resolver in
3423
+ * `@kici-dev/engine/protocol/dashboard-write-operations`.
3424
+ *
3425
+ * Idempotent: a re-run on a DB that already has the column is a no-op.
3426
+ */
3427
+ async function up$3(db) {
3428
+ if ((await sql`
3429
+ SELECT EXISTS (
3430
+ SELECT 1 FROM information_schema.columns
3431
+ WHERE table_schema = 'public'
3432
+ AND table_name = 'org_settings'
3433
+ AND column_name = 'dashboard_write_policy'
3434
+ ) AS exists
3435
+ `.execute(db)).rows[0]?.exists) return;
3436
+ await sql`
3437
+ ALTER TABLE public.org_settings
3438
+ ADD COLUMN dashboard_write_policy jsonb NOT NULL DEFAULT '{}'::jsonb
3439
+ `.execute(db);
3440
+ }
3441
+ async function down$3(db) {
3442
+ await sql`
3443
+ ALTER TABLE public.org_settings DROP COLUMN IF EXISTS dashboard_write_policy
3444
+ `.execute(db);
3445
+ }
3446
+ //#endregion
3447
+ //#region src/db/migrations/021_check_run_tracking.ts
3448
+ var _021_check_run_tracking_exports = /* @__PURE__ */ __exportAll({
3449
+ down: () => down$2,
3450
+ up: () => up$2
3451
+ });
3452
+ /**
3453
+ * Add `check_run_tracking` table for HA-safe check-run state persistence.
3454
+ *
3455
+ * The `CommitStatusReporter` previously held all of its check-run bookkeeping
3456
+ * in per-process `Map`s. Under HA (Raft leader switch / coord crash) those
3457
+ * maps reset to empty on the replacement coord, leaving GitHub check runs
3458
+ * stuck in `queued` forever.
3459
+ *
3460
+ * Single composite-keyed table per (provider, owner, repo, sha, check_name):
3461
+ *
3462
+ * - `check_run_id` replaces in-memory `checkRunIds`.
3463
+ * - `build_creation_state` replaces `pendingBuildCreations` Promise map.
3464
+ * - `step_progress_json` replaces `stepProgress` array map.
3465
+ * - `in_progress_sent_at` replaces `inProgressSent` boolean map.
3466
+ * - `run_id` indexed; replaces `runIdToKeys` reverse map.
3467
+ *
3468
+ * Progress-timer debounce state (the `progressTimers` Map) is NOT persisted —
3469
+ * timers are recreated on demand when an update arrives and the row's
3470
+ * `updated_at` is older than the debounce window.
3471
+ *
3472
+ * Idempotent: a re-run on a DB that already has the table is a no-op.
3473
+ */
3474
+ async function up$2(db) {
3475
+ if ((await sql`
3476
+ SELECT EXISTS (
3477
+ SELECT 1 FROM information_schema.tables
3478
+ WHERE table_schema = 'public'
3479
+ AND table_name = 'check_run_tracking'
3480
+ ) AS exists
3481
+ `.execute(db)).rows[0]?.exists) return;
3482
+ await sql`
3483
+ CREATE TABLE public.check_run_tracking (
3484
+ provider TEXT NOT NULL,
3485
+ owner TEXT NOT NULL,
3486
+ repo TEXT NOT NULL,
3487
+ sha TEXT NOT NULL,
3488
+ check_name TEXT NOT NULL,
3489
+ check_run_id BIGINT,
3490
+ build_creation_state TEXT,
3491
+ step_progress_json JSONB NOT NULL DEFAULT '[]'::jsonb,
3492
+ in_progress_sent_at TIMESTAMPTZ,
3493
+ run_id TEXT,
3494
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
3495
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
3496
+ PRIMARY KEY (provider, owner, repo, sha, check_name)
3497
+ )
3498
+ `.execute(db);
3499
+ await sql`
3500
+ CREATE INDEX idx_check_run_tracking_run_id
3501
+ ON public.check_run_tracking (run_id)
3502
+ WHERE run_id IS NOT NULL
3503
+ `.execute(db);
3504
+ }
3505
+ async function down$2(db) {
3506
+ await sql`DROP TABLE IF EXISTS public.check_run_tracking`.execute(db);
3507
+ }
3508
+ //#endregion
3509
+ //#region src/db/migrations/022_scaler_manager_state.ts
3510
+ var _022_scaler_manager_state_exports = /* @__PURE__ */ __exportAll({
3511
+ down: () => down$1,
3512
+ up: () => up$1
3513
+ });
3514
+ /**
3515
+ * Add three tables persisting `ScalerManager` per-coord state:
3516
+ *
3517
+ * - `scaler_spawning_agents`: agents spawned via Docker/Podman/Firecracker
3518
+ * that have not yet registered via WS. Carries the `bound_job_id` so a
3519
+ * replacement coord still issues the eager-dispatch hop on register.
3520
+ *
3521
+ * - `scaler_agent_jobs`: agentId → (runId, jobId) correlation for
3522
+ * scaler-lifecycle event routing. Inserted on `correlateAgentToJob`,
3523
+ * deleted on disconnect / job completion.
3524
+ *
3525
+ * - `scaler_reservations`: outstanding resource reservations keyed by
3526
+ * agentId. Per-scaler / global usage counters are derived state —
3527
+ * recomputed on coord boot as `SUM(...) GROUP BY scaler_name` so the
3528
+ * cap-check critical section stays correct.
3529
+ *
3530
+ * Without these tables, a coord crash mid-spawn orphans the agent (eager
3531
+ * dispatch lost), strands the reservation (resource leak until backend
3532
+ * GC eventually disconnects the WS, minutes later), and loses every
3533
+ * scaler-lifecycle event emitted before correlation (execution-tracker
3534
+ * sees a hole in the run timeline).
3535
+ *
3536
+ * Idempotent: a re-run on a DB that already has any of these tables
3537
+ * leaves the existing one alone.
3538
+ */
3539
+ async function up$1(db) {
3540
+ const tableExists = async (name) => {
3541
+ return (await sql`
3542
+ SELECT EXISTS (
3543
+ SELECT 1 FROM information_schema.tables
3544
+ WHERE table_schema = 'public' AND table_name = ${name}
3545
+ ) AS exists
3546
+ `.execute(db)).rows[0]?.exists ?? false;
3547
+ };
3548
+ if (!await tableExists("scaler_spawning_agents")) {
3549
+ await sql`
3550
+ CREATE TABLE public.scaler_spawning_agents (
3551
+ agent_id TEXT PRIMARY KEY,
3552
+ scaler_name TEXT NOT NULL,
3553
+ label_set JSONB NOT NULL,
3554
+ run_id TEXT,
3555
+ job_id TEXT,
3556
+ bound_job_id TEXT,
3557
+ spawned_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
3558
+ )
3559
+ `.execute(db);
3560
+ await sql`
3561
+ CREATE INDEX idx_scaler_spawning_agents_spawned_at
3562
+ ON public.scaler_spawning_agents (spawned_at)
3563
+ `.execute(db);
3564
+ }
3565
+ if (!await tableExists("scaler_agent_jobs")) await sql`
3566
+ CREATE TABLE public.scaler_agent_jobs (
3567
+ agent_id TEXT PRIMARY KEY,
3568
+ run_id TEXT NOT NULL,
3569
+ job_id TEXT NOT NULL,
3570
+ correlated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
3571
+ )
3572
+ `.execute(db);
3573
+ if (!await tableExists("scaler_reservations")) {
3574
+ await sql`
3575
+ CREATE TABLE public.scaler_reservations (
3576
+ agent_id TEXT PRIMARY KEY,
3577
+ scaler_name TEXT NOT NULL,
3578
+ cpu_units DOUBLE PRECISION NOT NULL,
3579
+ mem_bytes BIGINT NOT NULL,
3580
+ reserved_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
3581
+ )
3582
+ `.execute(db);
3583
+ await sql`
3584
+ CREATE INDEX idx_scaler_reservations_scaler_name
3585
+ ON public.scaler_reservations (scaler_name)
3586
+ `.execute(db);
3587
+ }
3588
+ }
3589
+ async function down$1(db) {
3590
+ await sql`DROP TABLE IF EXISTS public.scaler_reservations`.execute(db);
3591
+ await sql`DROP TABLE IF EXISTS public.scaler_agent_jobs`.execute(db);
3592
+ await sql`DROP TABLE IF EXISTS public.scaler_spawning_agents`.execute(db);
3593
+ }
3594
+ //#endregion
3595
+ //#region src/db/migrations/023_dispatch_queue_recovery_deadline.ts
3596
+ var _023_dispatch_queue_recovery_deadline_exports = /* @__PURE__ */ __exportAll({
3597
+ down: () => down,
3598
+ up: () => up
3599
+ });
3600
+ /**
3601
+ * Add `dispatch_queue.recovery_deadline TIMESTAMPTZ` and
3602
+ * `dispatch_queue.recovery_agent_id TEXT` columns persisting per-job
3603
+ * recovery state for HA-safe agent-disconnect handling.
3604
+ *
3605
+ * `Dispatcher.recoveringJobs` previously held the timer handle, agent
3606
+ * ID, and deadline entirely in process memory. A coord crash between
3607
+ * starting the timer and it firing dropped the timer; the replacement
3608
+ * coord saw the row as `status='recovering'` but had no record of when
3609
+ * the deadline expired or which agent owned the job. Jobs lingered
3610
+ * forever.
3222
3611
  *
3223
- * Idempotency: the `WHERE NOT LIKE 'org\\_%'` guard makes the
3224
- * migration a no-op on rows already prefixed (e.g. a re-run after a
3225
- * fresh deploy populated some rows already), so applying it twice is
3226
- * safe.
3612
+ * The new columns make the deadline durable. A leader-gated sweep
3613
+ * (`Dispatcher.sweepExpiredRecoveries`) scans
3614
+ * `WHERE status='recovering' AND recovery_deadline < now()` and marks
3615
+ * each row failed; on coord boot, `Dispatcher.recoverState()`
3616
+ * hydrates the in-memory Map by re-creating timers from the persisted
3617
+ * deadlines.
3618
+ *
3619
+ * Idempotent: re-running on a DB that already has either column is a
3620
+ * no-op.
3227
3621
  */
3228
- const ORG_ID_TABLES = [
3229
- "access_log",
3230
- "environment_bindings",
3231
- "environment_source_overrides",
3232
- "environment_variables",
3233
- "environments",
3234
- "event_log",
3235
- "held_runs",
3236
- "scoped_secrets"
3237
- ];
3238
- const CUSTOMER_ID_TABLES = [
3239
- "sources",
3240
- "generic_webhook_sources",
3241
- "workflow_registrations",
3242
- "org_settings"
3243
- ];
3244
3622
  async function up(db) {
3245
- for (const table of ORG_ID_TABLES) await sql.raw(`UPDATE public.${table} SET org_id = 'org_' || org_id WHERE org_id <> 'kici-admin' AND org_id NOT LIKE 'org\\_%' ESCAPE '\\'`).execute(db);
3246
- for (const table of CUSTOMER_ID_TABLES) await sql.raw(`UPDATE public.${table} SET customer_id = 'org_' || customer_id WHERE customer_id <> 'kici-admin' AND customer_id NOT LIKE 'org\\_%' ESCAPE '\\'`).execute(db);
3623
+ const colExists = async (name) => {
3624
+ return (await sql`
3625
+ SELECT EXISTS (
3626
+ SELECT 1 FROM information_schema.columns
3627
+ WHERE table_schema = 'public'
3628
+ AND table_name = 'dispatch_queue'
3629
+ AND column_name = ${name}
3630
+ ) AS exists
3631
+ `.execute(db)).rows[0]?.exists ?? false;
3632
+ };
3633
+ if (!await colExists("recovery_deadline")) await sql`
3634
+ ALTER TABLE public.dispatch_queue
3635
+ ADD COLUMN recovery_deadline TIMESTAMPTZ
3636
+ `.execute(db);
3637
+ if (!await colExists("recovery_agent_id")) await sql`
3638
+ ALTER TABLE public.dispatch_queue
3639
+ ADD COLUMN recovery_agent_id TEXT
3640
+ `.execute(db);
3641
+ await sql`
3642
+ CREATE INDEX IF NOT EXISTS idx_dispatch_queue_recovery_deadline
3643
+ ON public.dispatch_queue (recovery_deadline)
3644
+ WHERE recovery_deadline IS NOT NULL
3645
+ `.execute(db);
3247
3646
  }
3248
3647
  async function down(db) {
3249
- for (const table of CUSTOMER_ID_TABLES) await sql.raw(`UPDATE public.${table} SET customer_id = substring(customer_id from 5) WHERE customer_id LIKE 'org\\_%' ESCAPE '\\'`).execute(db);
3250
- for (const table of ORG_ID_TABLES) await sql.raw(`UPDATE public.${table} SET org_id = substring(org_id from 5) WHERE org_id LIKE 'org\\_%' ESCAPE '\\'`).execute(db);
3648
+ await sql`DROP INDEX IF EXISTS public.idx_dispatch_queue_recovery_deadline`.execute(db);
3649
+ await sql`
3650
+ ALTER TABLE public.dispatch_queue DROP COLUMN IF EXISTS recovery_agent_id
3651
+ `.execute(db);
3652
+ await sql`
3653
+ ALTER TABLE public.dispatch_queue DROP COLUMN IF EXISTS recovery_deadline
3654
+ `.execute(db);
3251
3655
  }
3252
3656
  //#endregion
3253
3657
  //#region src/db/migration-provider.ts
@@ -3271,7 +3675,12 @@ function createMigrationProvider() {
3271
3675
  "015_org_settings_customer_scoped": _015_org_settings_customer_scoped_exports,
3272
3676
  "016_org_settings_allow_http_npm": _016_org_settings_allow_http_npm_exports,
3273
3677
  "017_org_id_widen": _017_org_id_widen_exports,
3274
- "018_org_id_prefix_backfill": _018_org_id_prefix_backfill_exports
3678
+ "018_org_id_prefix_backfill": _018_org_id_prefix_backfill_exports,
3679
+ "019_generic_sources_change_notify": _019_generic_sources_change_notify_exports,
3680
+ "020_org_settings_dashboard_write_policy": _020_org_settings_dashboard_write_policy_exports,
3681
+ "021_check_run_tracking": _021_check_run_tracking_exports,
3682
+ "022_scaler_manager_state": _022_scaler_manager_state_exports,
3683
+ "023_dispatch_queue_recovery_deadline": _023_dispatch_queue_recovery_deadline_exports
3275
3684
  };
3276
3685
  } };
3277
3686
  }
@@ -3321,12 +3730,15 @@ async function runMigrations(opts) {
3321
3730
  /**
3322
3731
  * Database management commands for kici-admin.
3323
3732
  *
3324
- * db migrate Run pending migrations (HTTP — orchestrator must be up)
3325
- * db fresh DROP + CREATE + migrate + store content hash (direct DB)
3326
- * db ensure <name> CREATE DATABASE IF NOT EXISTS (direct DB)
3327
- * db create-role CREATE ROLE LOGIN [CREATEDB] (direct DB)
3328
- * db create-readonly-user Read-only role + GRANT SELECT (direct DB)
3329
- * db check-schema Compare bundled migrations vs live schema (direct DB)
3733
+ * db migrate Run pending migrations (HTTP — orchestrator must be up)
3734
+ * db fresh DROP + CREATE + migrate + store content hash (direct DB)
3735
+ * db ensure <name> CREATE DATABASE IF NOT EXISTS (direct DB)
3736
+ * db create-role CREATE ROLE LOGIN [CREATEDB] (direct DB)
3737
+ * db create-readonly-user Read-only role + GRANT SELECT (direct DB)
3738
+ * db check-schema Compare bundled migrations vs live schema (direct DB)
3739
+ * db collation-check Check pg_database.datcollversion vs running libc (direct DB)
3740
+ * db reindex REINDEX DATABASE CONCURRENTLY (direct DB)
3741
+ * db refresh-collation-version ALTER DATABASE REFRESH COLLATION VERSION (direct DB)
3330
3742
  *
3331
3743
  * The direct-DB subcommands cannot go through HTTP because the target database
3332
3744
  * may not exist yet or is about to be dropped. They follow the same pattern
@@ -3410,15 +3822,19 @@ function registerDbCommands(program, getClient) {
3410
3822
  process.exit(1);
3411
3823
  }
3412
3824
  });
3413
- db.command("ensure <name>").description("CREATE DATABASE IF NOT EXISTS (idempotent)").option("--database-url <url>", "Admin DB URL (else KICI_DATABASE_URL / DATABASE_URL)").action(async (name, opts) => {
3825
+ db.command("ensure <name>").description("CREATE DATABASE IF NOT EXISTS (idempotent)").option("--database-url <url>", "Admin DB URL (else KICI_DATABASE_URL / DATABASE_URL)").option("--owner <role>", "DB owner role (default: URL user). Pass when the admin connection is privileged but the new DB should be owned by a separate non-privileged role.").option("--revoke-connect-public", "After ensure, REVOKE CONNECT ON DATABASE \"<name>\" FROM PUBLIC (recommended on shared clusters).").action(async (name, opts) => {
3414
3826
  try {
3415
3827
  const baseUrl = resolveDatabaseUrl$1(opts.databaseUrl);
3416
3828
  const url = new URL(baseUrl);
3417
3829
  url.pathname = `/${name}`;
3418
3830
  const targetUrl = url.toString();
3419
3831
  logInvocation(`ensure ${name}`, targetUrl);
3420
- const outcome = await ensureDatabase(targetUrl);
3421
- console.log(`db ensure: ${name} — ${outcome}`);
3832
+ const outcome = await ensureDatabase(targetUrl, {
3833
+ owner: opts.owner,
3834
+ revokeConnectFromPublic: !!opts.revokeConnectPublic
3835
+ });
3836
+ const suffix = (opts.owner ? ` (owner=${opts.owner})` : "") + (opts.revokeConnectPublic ? " [revoked CONNECT from PUBLIC]" : "");
3837
+ console.log(`db ensure: ${name} — ${outcome}${suffix}`);
3422
3838
  } catch (err) {
3423
3839
  console.error(`Error: ${toErrorMessage(err)}`);
3424
3840
  process.exit(1);
@@ -3471,6 +3887,70 @@ function registerDbCommands(program, getClient) {
3471
3887
  process.exit(1);
3472
3888
  }
3473
3889
  });
3890
+ db.command("collation-check").description("Compare pg_database.datcollversion against the running libc collation version. Exit 2 on drift.").option("--database-url <url>", "Target DB URL (else KICI_DATABASE_URL / DATABASE_URL)").option("--json", "Emit JSON instead of a human-readable line", false).action(async (opts) => {
3891
+ try {
3892
+ const url = resolveDatabaseUrl$1(opts.databaseUrl);
3893
+ const { dbName } = parseDatabaseUrl(url);
3894
+ const pool = createPool(url);
3895
+ try {
3896
+ const drift = await getDatabaseCollationDrift(pool, dbName);
3897
+ if (drift) {
3898
+ if (opts.json) process.stdout.write(JSON.stringify({
3899
+ clean: false,
3900
+ database: dbName,
3901
+ stamped: drift.stamped,
3902
+ actual: drift.actual
3903
+ }) + "\n");
3904
+ else console.log(`db collation-check: ${dbName} — drift: stamped=${drift.stamped} actual=${drift.actual}`);
3905
+ process.exit(2);
3906
+ }
3907
+ if (opts.json) process.stdout.write(JSON.stringify({
3908
+ clean: true,
3909
+ database: dbName
3910
+ }) + "\n");
3911
+ else console.log(`db collation-check: ${dbName} — clean`);
3912
+ } finally {
3913
+ await pool.end().catch(() => void 0);
3914
+ }
3915
+ } catch (err) {
3916
+ console.error(`Error: ${toErrorMessage(err)}`);
3917
+ process.exit(1);
3918
+ }
3919
+ });
3920
+ db.command("reindex").description("REINDEX DATABASE CONCURRENTLY <db>. Rebuilds every index under the running libc collation rules. Non-blocking but takes minutes + ~2× temp disk.").option("--database-url <url>", "Target DB URL (else KICI_DATABASE_URL / DATABASE_URL)").requiredOption("--confirm", "Explicit confirmation (destructive — long-running)").requiredOption("--reason <text>", "Reason (recorded in stderr banner)").action(async (opts) => {
3921
+ try {
3922
+ const url = resolveDatabaseUrl$1(opts.databaseUrl);
3923
+ const { dbName } = parseDatabaseUrl(url);
3924
+ logInvocation(`reindex ${dbName} (${opts.reason})`, url);
3925
+ const pool = createPool(url);
3926
+ try {
3927
+ await reindexDatabaseConcurrently(pool, dbName);
3928
+ console.log(`db reindex: ${dbName} — REINDEX DATABASE CONCURRENTLY completed`);
3929
+ } finally {
3930
+ await pool.end().catch(() => void 0);
3931
+ }
3932
+ } catch (err) {
3933
+ console.error(`Error: ${toErrorMessage(err)}`);
3934
+ process.exit(1);
3935
+ }
3936
+ });
3937
+ db.command("refresh-collation-version").description("ALTER DATABASE <db> REFRESH COLLATION VERSION. Metadata-only bump; pair with db reindex after a libc-base image rebuild.").option("--database-url <url>", "Target DB URL (else KICI_DATABASE_URL / DATABASE_URL)").requiredOption("--reason <text>", "Reason (recorded in stderr banner)").action(async (opts) => {
3938
+ try {
3939
+ const url = resolveDatabaseUrl$1(opts.databaseUrl);
3940
+ const { dbName } = parseDatabaseUrl(url);
3941
+ logInvocation(`refresh-collation-version ${dbName} (${opts.reason})`, url);
3942
+ const pool = createPool(url);
3943
+ try {
3944
+ await refreshDatabaseCollationVersion(pool, dbName);
3945
+ console.log(`db refresh-collation-version: ${dbName} — ALTER DATABASE REFRESH COLLATION VERSION completed`);
3946
+ } finally {
3947
+ await pool.end().catch(() => void 0);
3948
+ }
3949
+ } catch (err) {
3950
+ console.error(`Error: ${toErrorMessage(err)}`);
3951
+ process.exit(1);
3952
+ }
3953
+ });
3474
3954
  }
3475
3955
  //#endregion
3476
3956
  //#region src/providers/universal-git/config.ts
@@ -3734,6 +4214,26 @@ createLogger({ prefix: "universal-git:clone-token" });
3734
4214
  * --stdin Read from stdin
3735
4215
  */
3736
4216
  /**
4217
+ * Short reason shown in `(unavailable — <reason>)` when no webhook URL could be
4218
+ * resolved for an added source.
4219
+ */
4220
+ function webhookNoteReason(note) {
4221
+ switch (note) {
4222
+ case "github-ingress-platform-only": return "this orchestrator runs in independent mode";
4223
+ case "platform-no-public-url": return "the Platform has no public webhook URL configured";
4224
+ case "platform-unavailable": return "could not reach the Platform";
4225
+ default: return "webhook URL could not be determined";
4226
+ }
4227
+ }
4228
+ /** Actionable next-step hint paired with {@link webhookNoteReason}. */
4229
+ function webhookNoteHint(note) {
4230
+ switch (note) {
4231
+ case "github-ingress-platform-only": return "GitHub App ingress is served by the KiCI Platform; find the URL in the dashboard under Sources.";
4232
+ case "platform-unavailable": return "Retry once the orchestrator reconnects, or find the URL in the dashboard under Sources.";
4233
+ default: return "Find the webhook URL in the dashboard under Sources.";
4234
+ }
4235
+ }
4236
+ /**
3737
4237
  * Resolve a secret value from various input modes.
3738
4238
  * Returns undefined if no input mode is specified.
3739
4239
  */
@@ -3877,7 +4377,16 @@ function registerSourceCommands(program, getClient) {
3877
4377
  webhookSecret: opts.webhookSecret
3878
4378
  });
3879
4379
  if (opts.json) console.log(JSON.stringify(result, null, 2));
3880
- else console.log(`Source added: ${result.routingKey} (${result.name})`);
4380
+ else {
4381
+ console.log(`Source added: ${result.routingKey} (${result.name})`);
4382
+ if (result.webhookUrl) {
4383
+ console.log(`Webhook URL: ${result.webhookUrl}`);
4384
+ console.log(` ↳ Paste this into your GitHub App's "Webhook URL" field.`);
4385
+ } else {
4386
+ console.log(`Webhook URL: (unavailable — ${webhookNoteReason(result.webhookNote)})`);
4387
+ console.log(` ↳ ${webhookNoteHint(result.webhookNote)}`);
4388
+ }
4389
+ }
3881
4390
  } catch (err) {
3882
4391
  console.error(`Error: ${toErrorMessage(err)}`);
3883
4392
  process.exit(1);
@@ -4844,7 +5353,7 @@ function registerBackendCommands(program, getClient) {
4844
5353
  try {
4845
5354
  scopeInfo = ` has ${(await getClient().getBackend(name)).scopeCount ?? 0} discovered scopes -- they will become unavailable.`;
4846
5355
  } catch {}
4847
- if (!await confirm$2(`Backend "${name}"${scopeInfo} Continue? [y/N] `)) {
5356
+ if (!await confirm$3(`Backend "${name}"${scopeInfo} Continue? [y/N] `)) {
4848
5357
  console.log("Aborted.");
4849
5358
  return;
4850
5359
  }
@@ -5034,7 +5543,7 @@ function printBackendSummary(b) {
5034
5543
  /**
5035
5544
  * Simple confirmation prompt.
5036
5545
  */
5037
- async function confirm$2(message) {
5546
+ async function confirm$3(message) {
5038
5547
  const rl = createInterface({
5039
5548
  input: process.stdin,
5040
5549
  output: process.stderr
@@ -5594,6 +6103,18 @@ function getCacheDir() {
5594
6103
  }
5595
6104
  var init_platform_detect = __esmMin((() => {}));
5596
6105
  //#endregion
6106
+ //#region src/cli/service/privilege.ts
6107
+ init_platform_detect();
6108
+ function resolveUserLevel(opts) {
6109
+ if (opts.system && opts.userLevel) throw new Error("`--system` and `--user-level` are mutually exclusive. Pick one (or neither for auto-detect).");
6110
+ if (opts.system) {
6111
+ if (!isRoot()) throw new Error("`--system` requires root privileges. Re-run under sudo, e.g. `sudo kici-admin orchestrator install --system ...`.");
6112
+ return false;
6113
+ }
6114
+ if (opts.userLevel) return true;
6115
+ return !isRoot();
6116
+ }
6117
+ //#endregion
5597
6118
  //#region src/cli/service/systemd.ts
5598
6119
  /**
5599
6120
  * systemd service manager implementation.
@@ -5885,26 +6406,79 @@ var init_launchd = __esmMin((() => {
5885
6406
  lines.push("");
5886
6407
  return lines.join("\n");
5887
6408
  }
6409
+ /**
6410
+ * Resolve the launchctl domain string for a service config.
6411
+ *
6412
+ * - User-level (LaunchAgent at `~/Library/LaunchAgents/…`) → `gui/<uid>`
6413
+ * - System-level (LaunchDaemon at `/Library/LaunchDaemons/…`) → `system`
6414
+ *
6415
+ * The legacy `launchctl load` / `unload` verbs implicitly inferred the
6416
+ * domain from the plist's filesystem location, but they fail silently on
6417
+ * headless macOS hosts where no GUI session exists (the `gui/<uid>` domain
6418
+ * is not available without a console login). The modern `bootstrap` /
6419
+ * `bootout` / `kickstart` / `kill` verbs take the domain explicitly, so a
6420
+ * system-level LaunchDaemon loads regardless of whether anyone is logged
6421
+ * in at the console — exactly what we need for headless deploy targets.
6422
+ */
6423
+ domain(config) {
6424
+ return config.isUserLevel ? `gui/${os.userInfo().uid}` : "system";
6425
+ }
6426
+ /** `<domain>/<label>` — the target string for kickstart/kill/print/enable. */
6427
+ domainTarget(config) {
6428
+ return `${this.domain(config)}/${this.label(config)}`;
6429
+ }
6430
+ /** Is the service currently loaded in its target domain? */
6431
+ isLoaded(config) {
6432
+ try {
6433
+ execFileSync("launchctl", ["print", this.domainTarget(config)], { stdio: "ignore" });
6434
+ return true;
6435
+ } catch {
6436
+ return false;
6437
+ }
6438
+ }
5888
6439
  async install(config) {
5889
6440
  const plistFile = this.plistPath(config);
5890
6441
  const plistContent = this.generatePlist(config);
5891
6442
  fs.mkdirSync(path.dirname(plistFile), { recursive: true });
5892
- fs.mkdirSync(this.logDir(config), { recursive: true });
6443
+ const logDirectory = this.logDir(config);
6444
+ fs.mkdirSync(logDirectory, { recursive: true });
6445
+ if (!config.isUserLevel && config.user) execFileSync("chown", [
6446
+ "-R",
6447
+ `${config.user}:staff`,
6448
+ logDirectory
6449
+ ], { stdio: "inherit" });
5893
6450
  fs.writeFileSync(plistFile, plistContent, "utf-8");
5894
- execFileSync("launchctl", ["load", plistFile], { stdio: "inherit" });
6451
+ if (this.isLoaded(config)) try {
6452
+ execFileSync("launchctl", ["bootout", this.domainTarget(config)], { stdio: "inherit" });
6453
+ } catch {}
6454
+ execFileSync("launchctl", [
6455
+ "bootstrap",
6456
+ this.domain(config),
6457
+ plistFile
6458
+ ], { stdio: "inherit" });
5895
6459
  }
5896
6460
  async uninstall(config) {
5897
6461
  const plistFile = this.plistPath(config);
5898
6462
  try {
5899
- execFileSync("launchctl", ["unload", plistFile], { stdio: "inherit" });
6463
+ execFileSync("launchctl", ["bootout", this.domainTarget(config)], { stdio: "inherit" });
6464
+ } catch {}
6465
+ try {
6466
+ fs.unlinkSync(plistFile);
5900
6467
  } catch {}
5901
- fs.unlinkSync(plistFile);
5902
6468
  }
5903
6469
  async start(config) {
5904
- execFileSync("launchctl", ["start", this.label(config)], { stdio: "inherit" });
6470
+ execFileSync("launchctl", [
6471
+ "kickstart",
6472
+ "-k",
6473
+ this.domainTarget(config)
6474
+ ], { stdio: "inherit" });
5905
6475
  }
5906
6476
  async stop(config) {
5907
- execFileSync("launchctl", ["stop", this.label(config)], { stdio: "inherit" });
6477
+ execFileSync("launchctl", [
6478
+ "kill",
6479
+ "TERM",
6480
+ this.domainTarget(config)
6481
+ ], { stdio: "inherit" });
5908
6482
  }
5909
6483
  async restart(config) {
5910
6484
  await this.stop(config);
@@ -6397,6 +6971,11 @@ function getRestartMode(config) {
6397
6971
  * Uses a manual YAML builder to avoid adding a YAML library dependency.
6398
6972
  * The generated file includes the service definition with env_file,
6399
6973
  * restart policy, and volume mounts.
6974
+ *
6975
+ * The `image:` line points at `quay.io/kici-dev/<service-name>:latest`.
6976
+ * Source of truth for the publish targets: packages/ci/src/publish-images.ts.
6977
+ * See .claude/rules/container-publish-registry.md — Quay is the sole
6978
+ * registry for public KiCI images we publish.
6400
6979
  */
6401
6980
  function generateComposeYaml(config) {
6402
6981
  const restartMode = getRestartMode(config);
@@ -6406,7 +6985,7 @@ function generateComposeYaml(config) {
6406
6985
  "",
6407
6986
  "services:",
6408
6987
  ` ${config.name}:`,
6409
- ` image: ghcr.io/kici-dev/kici:latest`,
6988
+ ` image: quay.io/kici-dev/${config.name}:latest`,
6410
6989
  ` container_name: ${config.name}`,
6411
6990
  ` restart: ${restartMode}`,
6412
6991
  " env_file:",
@@ -6604,8 +7183,9 @@ var orchestrator_wizard_exports = /* @__PURE__ */ __exportAll({ runOrchestratorW
6604
7183
  * 2. Database URL
6605
7184
  * 3. Port
6606
7185
  * 4. Secrets encryption key
6607
- * 5. Platform URL + token (if platform/hybrid mode)
6608
- * 6. Webhook secret (if hybrid/independent mode)
7186
+ * 5. Bootstrap admin token (for kici-admin authentication)
7187
+ * 6. Platform URL + token (if platform/hybrid mode)
7188
+ * 7. Webhook secret (if hybrid/independent mode)
6609
7189
  */
6610
7190
  async function runOrchestratorWizard() {
6611
7191
  console.log("");
@@ -6639,6 +7219,13 @@ async function runOrchestratorWizard() {
6639
7219
  let secretsKey;
6640
7220
  if (useGenerated) secretsKey = generatedKey;
6641
7221
  else secretsKey = await promptSecret("Enter custom secrets encryption key (64 hex chars):");
7222
+ console.log("");
7223
+ const generatedAdminToken = randomBytes(32).toString("hex");
7224
+ console.log(`Generated bootstrap admin token: ${generatedAdminToken}`);
7225
+ const useGeneratedAdminToken = await promptConfirm("Use this token?");
7226
+ let bootstrapAdminToken;
7227
+ if (useGeneratedAdminToken) bootstrapAdminToken = generatedAdminToken;
7228
+ else bootstrapAdminToken = await promptSecret("Enter custom bootstrap admin token:");
6642
7229
  let platformUrl;
6643
7230
  let platformToken;
6644
7231
  if (mode === "platform" || mode === "hybrid") {
@@ -6678,6 +7265,7 @@ async function runOrchestratorWizard() {
6678
7265
  console.log(` Mode: ${mode}`);
6679
7266
  console.log(` Database: ${databaseUrl.replace(/:[^@]*@/, ":***@")}`);
6680
7267
  console.log(` Port: ${port}`);
7268
+ console.log(` Admin token: ${bootstrapAdminToken.slice(0, 8)}…`);
6681
7269
  console.log(` Platform: ${platformUrl ?? "N/A"}`);
6682
7270
  if (source) console.log(` Source: ${source.provider}:${source.appId} (${source.name})`);
6683
7271
  console.log("");
@@ -6686,6 +7274,7 @@ async function runOrchestratorWizard() {
6686
7274
  databaseUrl,
6687
7275
  port,
6688
7276
  secretsKey,
7277
+ bootstrapAdminToken,
6689
7278
  platformUrl,
6690
7279
  platformToken,
6691
7280
  source
@@ -6724,18 +7313,18 @@ function startDevPostgres(containerName) {
6724
7313
  if (err instanceof Error && err.message.includes("already exists")) throw err;
6725
7314
  }
6726
7315
  console.log(`Starting dev PostgreSQL container "${containerName}" on port ${port}...`);
6727
- execSync(`${runtime} run -d --name ${containerName} -p ${port}:5432 -e POSTGRES_PASSWORD=${password} -e POSTGRES_DB=kici postgres:18-alpine`, { stdio: "inherit" });
7316
+ execSync(`${runtime} run -d --name ${containerName} -p ${port}:5432 -e POSTGRES_PASSWORD=${password} -e POSTGRES_DB=kici postgres:18-trixie`, { stdio: "inherit" });
6728
7317
  return `postgresql://postgres:${password}@localhost:${port}/kici`;
6729
7318
  }
6730
7319
  function registerOrchestratorInstall(orchestrator) {
6731
- orchestrator.command("install").description("Install the orchestrator as a system service").option("--platform <type>", "Service platform (systemd, launchd, windows, compose)").option("--env-file <path>", "Path to existing env/config file to use").option("--binary <path>", "Path to orchestrator binary (default: current executable)").option("--dev", "Dev mode: spin up PostgreSQL container on port 15432").option("--wizard", "Interactive wizard for guided setup").option("--name <name>", "Service name", "kici-orchestrator").action(async (opts) => {
7320
+ orchestrator.command("install").description("Install the orchestrator as a system service").option("--platform <type>", "Service platform (systemd, launchd, windows, compose)").option("--env-file <path>", "Path to existing env/config file to use").option("--binary <path>", "Path to orchestrator binary (default: current executable)").option("--dev", "Dev mode: spin up PostgreSQL container on port 15432").option("--wizard", "Interactive wizard for guided setup").option("--name <name>", "Service name", "kici-orchestrator").option("--system", "Install as system-level service (requires root)").option("--user-level", "Install as user-level service (no root required)").option("--user <name>", "Run the service as the named user (system-level launchd only; sets UserName in plist so the daemon drops privileges)").action(async (opts) => {
6732
7321
  try {
6733
7322
  if (opts.wizard && opts.envFile) {
6734
7323
  console.error("Error: Cannot use --wizard with --env-file");
6735
7324
  process.exit(1);
6736
7325
  }
6737
7326
  const platform = detectPlatform(opts.platform);
6738
- const userLevel = !isRoot();
7327
+ const userLevel = resolveUserLevel(opts);
6739
7328
  const serviceName = opts.name;
6740
7329
  console.log(`Platform: ${platform}`);
6741
7330
  console.log(`Privilege: ${userLevel ? "user" : "system"}`);
@@ -6755,9 +7344,10 @@ function registerOrchestratorInstall(orchestrator) {
6755
7344
  const wizardConfig = await runOrchestratorWizard();
6756
7345
  let envContent = "# KiCI orchestrator configuration (generated by setup wizard)\n";
6757
7346
  envContent += `KICI_MODE=${wizardConfig.mode}\n`;
6758
- envContent += `DATABASE_URL=${wizardConfig.databaseUrl}\n`;
7347
+ envContent += `KICI_DATABASE_URL=${wizardConfig.databaseUrl}\n`;
6759
7348
  envContent += `KICI_PORT=${wizardConfig.port}\n`;
6760
- envContent += `KICI_SECRETS_KEY=${wizardConfig.secretsKey}\n`;
7349
+ envContent += `KICI_SECRET_KEY=${wizardConfig.secretsKey}\n`;
7350
+ envContent += `KICI_BOOTSTRAP_ADMIN_TOKEN=${wizardConfig.bootstrapAdminToken}\n`;
6761
7351
  if (wizardConfig.platformUrl) envContent += `KICI_PLATFORM_URL=${wizardConfig.platformUrl}\n`;
6762
7352
  if (wizardConfig.platformToken) envContent += `KICI_PLATFORM_TOKEN=${wizardConfig.platformToken}\n`;
6763
7353
  fs.writeFileSync(envFilePath, envContent, "utf-8");
@@ -6772,12 +7362,12 @@ function registerOrchestratorInstall(orchestrator) {
6772
7362
  console.log(`Copied env file to ${envFilePath}`);
6773
7363
  } else if (!fs.existsSync(envFilePath)) {
6774
7364
  let envContent = `# KiCI orchestrator configuration\n# See docs for all available options\n`;
6775
- if (devDbUrl) envContent += `DATABASE_URL=${devDbUrl}\n`;
7365
+ if (devDbUrl) envContent += `KICI_DATABASE_URL=${devDbUrl}\n`;
6776
7366
  fs.writeFileSync(envFilePath, envContent, "utf-8");
6777
7367
  console.log(`Created env file at ${envFilePath}`);
6778
7368
  } else if (devDbUrl) {
6779
- fs.appendFileSync(envFilePath, `\nDATABASE_URL=${devDbUrl}\n`);
6780
- console.log(`Appended DATABASE_URL to ${envFilePath}`);
7369
+ fs.appendFileSync(envFilePath, `\nKICI_DATABASE_URL=${devDbUrl}\n`);
7370
+ console.log(`Appended KICI_DATABASE_URL to ${envFilePath}`);
6781
7371
  }
6782
7372
  const executablePath = opts.binary ? path.resolve(opts.binary) : process.argv[0];
6783
7373
  if (userLevel) try {
@@ -6796,6 +7386,7 @@ function registerOrchestratorInstall(orchestrator) {
6796
7386
  envFilePath,
6797
7387
  workingDirectory: configDir,
6798
7388
  isUserLevel: userLevel,
7389
+ user: opts.user,
6799
7390
  restartPolicy: DEFAULT_RESTART_POLICY
6800
7391
  };
6801
7392
  await (await createServiceManager(platform)).install(config);
@@ -6814,10 +7405,10 @@ function registerOrchestratorInstall(orchestrator) {
6814
7405
  //#endregion
6815
7406
  //#region src/cli/commands/orchestrator-service/uninstall.ts
6816
7407
  function registerOrchestratorUninstall(orchestrator) {
6817
- orchestrator.command("uninstall").description("Remove the orchestrator service registration").option("--platform <type>", "Service platform (systemd, launchd, windows, compose)").option("--name <name>", "Service name", "kici-orchestrator").action(async (opts) => {
7408
+ orchestrator.command("uninstall").description("Remove the orchestrator service registration").option("--platform <type>", "Service platform (systemd, launchd, windows, compose)").option("--name <name>", "Service name", "kici-orchestrator").option("--system", "Operate against the system-level service (requires root)").option("--user-level", "Operate against the user-level service").action(async (opts) => {
6818
7409
  try {
6819
7410
  const platform = detectPlatform(opts.platform);
6820
- const userLevel = !isRoot();
7411
+ const userLevel = resolveUserLevel(opts);
6821
7412
  const serviceName = opts.name;
6822
7413
  const configDir = getConfigDir(serviceName, userLevel);
6823
7414
  const logDir = getLogDir(serviceName, userLevel);
@@ -6857,10 +7448,10 @@ function registerOrchestratorUninstall(orchestrator) {
6857
7448
  //#endregion
6858
7449
  //#region src/cli/commands/orchestrator-service/start.ts
6859
7450
  function registerOrchestratorStart(orchestrator) {
6860
- orchestrator.command("start").description("Start the orchestrator service").option("--platform <type>", "Service platform (systemd, launchd, windows, compose)").option("--name <name>", "Service name", "kici-orchestrator").action(async (opts) => {
7451
+ orchestrator.command("start").description("Start the orchestrator service").option("--platform <type>", "Service platform (systemd, launchd, windows, compose)").option("--name <name>", "Service name", "kici-orchestrator").option("--system", "Operate against the system-level service (requires root)").option("--user-level", "Operate against the user-level service").action(async (opts) => {
6861
7452
  try {
6862
7453
  const platform = detectPlatform(opts.platform);
6863
- const userLevel = !isRoot();
7454
+ const userLevel = resolveUserLevel(opts);
6864
7455
  const serviceName = opts.name;
6865
7456
  const configDir = getConfigDir(serviceName, userLevel);
6866
7457
  const config = {
@@ -6890,10 +7481,10 @@ function registerOrchestratorStart(orchestrator) {
6890
7481
  //#endregion
6891
7482
  //#region src/cli/commands/orchestrator-service/stop.ts
6892
7483
  function registerOrchestratorStop(orchestrator) {
6893
- orchestrator.command("stop").description("Stop the orchestrator service").option("--platform <type>", "Service platform (systemd, launchd, windows, compose)").option("--name <name>", "Service name", "kici-orchestrator").action(async (opts) => {
7484
+ orchestrator.command("stop").description("Stop the orchestrator service").option("--platform <type>", "Service platform (systemd, launchd, windows, compose)").option("--name <name>", "Service name", "kici-orchestrator").option("--system", "Operate against the system-level service (requires root)").option("--user-level", "Operate against the user-level service").action(async (opts) => {
6894
7485
  try {
6895
7486
  const platform = detectPlatform(opts.platform);
6896
- const userLevel = !isRoot();
7487
+ const userLevel = resolveUserLevel(opts);
6897
7488
  const serviceName = opts.name;
6898
7489
  const configDir = getConfigDir(serviceName, userLevel);
6899
7490
  const config = {
@@ -6917,10 +7508,10 @@ function registerOrchestratorStop(orchestrator) {
6917
7508
  //#endregion
6918
7509
  //#region src/cli/commands/orchestrator-service/restart.ts
6919
7510
  function registerOrchestratorRestart(orchestrator) {
6920
- orchestrator.command("restart").description("Restart the orchestrator service").option("--platform <type>", "Service platform (systemd, launchd, windows, compose)").option("--name <name>", "Service name", "kici-orchestrator").action(async (opts) => {
7511
+ orchestrator.command("restart").description("Restart the orchestrator service").option("--platform <type>", "Service platform (systemd, launchd, windows, compose)").option("--name <name>", "Service name", "kici-orchestrator").option("--system", "Operate against the system-level service (requires root)").option("--user-level", "Operate against the user-level service").action(async (opts) => {
6921
7512
  try {
6922
7513
  const platform = detectPlatform(opts.platform);
6923
- const userLevel = !isRoot();
7514
+ const userLevel = resolveUserLevel(opts);
6924
7515
  const serviceName = opts.name;
6925
7516
  const configDir = getConfigDir(serviceName, userLevel);
6926
7517
  const config = {
@@ -7106,7 +7697,7 @@ function registerLogsCommand(parent) {
7106
7697
  * - Optional cleanup of old versions
7107
7698
  */
7108
7699
  /** Prompt the user for confirmation (returns true if yes). */
7109
- async function confirm$1(message) {
7700
+ async function confirm$2(message) {
7110
7701
  const rl = createInterface({
7111
7702
  input: process.stdin,
7112
7703
  output: process.stdout
@@ -7322,7 +7913,7 @@ async function performVersionedUpgrade(component, opts) {
7322
7913
  console.log(` Install path: ${versionedDirPath}`);
7323
7914
  console.log(" The service will be stopped during upgrade.");
7324
7915
  console.log("");
7325
- if (!await confirm$1("Proceed with upgrade?")) {
7916
+ if (!await confirm$2("Proceed with upgrade?")) {
7326
7917
  console.log("Upgrade cancelled.");
7327
7918
  return;
7328
7919
  }
@@ -7404,7 +7995,7 @@ async function handleRollback(component, platform, installBase, config, manager,
7404
7995
  console.log(` Current version: ${currentVersion}`);
7405
7996
  console.log(` Rollback to: ${previousVersion}`);
7406
7997
  console.log("");
7407
- if (!await confirm$1("Proceed with rollback?")) {
7998
+ if (!await confirm$2("Proceed with rollback?")) {
7408
7999
  console.log("Rollback cancelled.");
7409
8000
  return;
7410
8001
  }
@@ -7968,30 +8559,39 @@ var JoinTokenManager = class {
7968
8559
  return token;
7969
8560
  }
7970
8561
  /**
7971
- * Validate a token: check hash exists in DB, not expired, not consumed.
7972
- * Returns routing info and derived keys on success.
8562
+ * Atomically validate and consume a token in one DB round-trip.
8563
+ *
8564
+ * Single UPDATE with `WHERE token_hash = ? AND consumed_at IS NULL AND
8565
+ * expires_at > NOW()` — only one caller can win the claim across a
8566
+ * shared-DB multi-coordinator mesh. The winner gets `{ routing, keys }`;
8567
+ * every other concurrent caller gets `TOKEN_ALREADY_USED_MESSAGE` and is
8568
+ * expected to fall into the idempotent recovery branch in
8569
+ * `peer-handler.ts` (which serialises credential issuance via the
8570
+ * `peer_credentials_active_uniq` partial unique index).
8571
+ *
8572
+ * On a 0-row claim, a follow-up SELECT disambiguates not-found / expired
8573
+ * / already-used so callers can branch on the specific reason — the
8574
+ * recovery path in peer-handler.ts keys on the "already used" string
8575
+ * specifically. The follow-up only fires on the unhappy path.
7973
8576
  */
7974
- async validateToken(token) {
8577
+ async validateAndConsumeToken(token, consumedBy) {
7975
8578
  const parsed = parseToken(token);
7976
8579
  const keys = deriveKeys(Buffer.from(parsed.secretHex, "hex"));
8580
+ const updateResult = await this.deps.db.updateTable("join_tokens").set({
8581
+ consumed_at: /* @__PURE__ */ new Date(),
8582
+ consumed_by: consumedBy
8583
+ }).where("token_hash", "=", keys.validationHash).where("consumed_at", "is", null).where("expires_at", ">", /* @__PURE__ */ new Date()).executeTakeFirst();
8584
+ if (Number(updateResult?.numUpdatedRows ?? 0n) > 0) {
8585
+ joinTokenLogger.info("Consumed join token", { consumedBy });
8586
+ return {
8587
+ routing: parsed.routing,
8588
+ keys
8589
+ };
8590
+ }
7977
8591
  const row = await this.deps.db.selectFrom("join_tokens").selectAll().where("token_hash", "=", keys.validationHash).executeTakeFirst();
7978
8592
  if (!row) throw new Error("Invalid join token");
7979
8593
  if (row.consumed_at) throw new Error(TOKEN_ALREADY_USED_MESSAGE);
7980
- if (new Date(row.expires_at) < /* @__PURE__ */ new Date()) throw new Error("Join token has expired. Generate a new token with: kici admin create-join-token");
7981
- return {
7982
- routing: parsed.routing,
7983
- keys
7984
- };
7985
- }
7986
- /**
7987
- * Mark token as consumed (one-time use).
7988
- */
7989
- async consumeToken(validationHash, consumedBy) {
7990
- await this.deps.db.updateTable("join_tokens").set({
7991
- consumed_at: /* @__PURE__ */ new Date(),
7992
- consumed_by: consumedBy
7993
- }).where("token_hash", "=", validationHash).execute();
7994
- joinTokenLogger.info("Consumed join token", { consumedBy });
8594
+ throw new Error("Join token has expired. Generate a new token with: kici admin create-join-token");
7995
8595
  }
7996
8596
  };
7997
8597
  /**
@@ -8347,6 +8947,7 @@ function registerOrgSettingsCommands(program, getClient) {
8347
8947
  registerListMutators(gw, getClient, "allow", "allowedRepos");
8348
8948
  registerListMutators(gw, getClient, "deny", "deniedRepos");
8349
8949
  registerListMutators(gw, getClient, "elevate", "elevatedRepos");
8950
+ registerDashboardWritesCommands(orgSettings, getClient);
8350
8951
  orgSettings.command("allow-http-npm <value>").description("Permit plain http:// npm registry URLs in workflow registries:. Default false; loopback / *.local are always allowed regardless.").option("--customer-id <id>", "Customer / org id (alias: --org)").option("--org <id>", "Alias for --customer-id").option("--format <format>", "Output format: json|table", "table").action(async (value, opts) => {
8351
8952
  const allow = value === "true" ? true : value === "false" ? false : void 0;
8352
8953
  if (allow === void 0) {
@@ -8432,6 +9033,173 @@ function label(prefix) {
8432
9033
  if (prefix === "deny") return "source-repo deny-list";
8433
9034
  return "elevated-access list";
8434
9035
  }
9036
+ function formatDashboardWrites(response, format, filter) {
9037
+ if (format === "json") return JSON.stringify(response, null, 2);
9038
+ const lines = [];
9039
+ lines.push(`Customer/org id: ${response.customerId}`);
9040
+ lines.push("");
9041
+ const byCategory = /* @__PURE__ */ new Map();
9042
+ for (const descriptor of DASHBOARD_WRITE_OPERATIONS) {
9043
+ if (filter?.category && descriptor.category !== filter.category) continue;
9044
+ if (filter?.sensitivity && descriptor.sensitivity !== filter.sensitivity) continue;
9045
+ const list = byCategory.get(descriptor.category) ?? [];
9046
+ list.push(descriptor);
9047
+ byCategory.set(descriptor.category, list);
9048
+ }
9049
+ for (const [category, descriptors] of byCategory) {
9050
+ lines.push(`${category.toUpperCase()}`);
9051
+ for (const descriptor of descriptors) {
9052
+ const state = response.effective[descriptor.name] ?? true ? "enabled " : "disabled";
9053
+ lines.push(` ${state} ${descriptor.name.padEnd(40)} (${descriptor.cliEquivalent})`);
9054
+ }
9055
+ lines.push("");
9056
+ }
9057
+ return lines.join("\n").trimEnd();
9058
+ }
9059
+ function parseOpFlag(value, previous) {
9060
+ const eq = value.indexOf("=");
9061
+ if (eq < 1 || eq === value.length - 1) {
9062
+ console.error(`Error: --op expects <operation>=<true|false>, got: ${value}`);
9063
+ process.exit(1);
9064
+ }
9065
+ const op = value.slice(0, eq);
9066
+ const bool = value.slice(eq + 1).toLowerCase();
9067
+ if (bool !== "true" && bool !== "false") {
9068
+ console.error(`Error: --op value must be "true" or "false", got: ${bool}`);
9069
+ process.exit(1);
9070
+ }
9071
+ if (!DASHBOARD_WRITE_OPERATIONS_BY_NAME.has(op)) {
9072
+ console.error(`Error: unknown operation "${op}". Run "kici-admin org-settings dashboard-writes show" to list valid operations.`);
9073
+ process.exit(1);
9074
+ }
9075
+ previous.push([op, bool === "true"]);
9076
+ return previous;
9077
+ }
9078
+ function registerDashboardWritesCommands(orgSettings, getClient) {
9079
+ const dw = orgSettings.command("dashboard-writes").description("Manage per-orch dashboard write policy (which Platform-routed dashboard.* writes the orch accepts)");
9080
+ dw.command("show").description("Print current dashboard-write policy. Empty = all enabled.").option("--customer-id <id>", "Customer / org id (alias: --org)").option("--org <id>", "Alias for --customer-id").option("--category <name>", "Filter to one category (Secrets|Variables|Environments|Bindings|\"Held runs\"|DLQ|Registrations|Topology)").option("--sensitivity <name>", "Filter to one sensitivity bucket (plaintext|authority|dispatch)").option("--format <format>", "Output format: json|table", "table").action(async (opts) => {
9081
+ const customerId = resolveCustomerId(opts);
9082
+ try {
9083
+ const response = await getClient().get(`/api/v1/admin/org-settings/dashboard-writes?customerId=${encodeURIComponent(customerId)}`);
9084
+ const filter = parseFilters(opts);
9085
+ console.log(formatDashboardWrites(response, opts.format, filter));
9086
+ } catch (err) {
9087
+ console.error(`Error: ${toErrorMessage(err)}`);
9088
+ process.exit(1);
9089
+ }
9090
+ });
9091
+ dw.command("set").description("Set one or more operations. Use --op <name>=<true|false> per operation. Sugar: --category or --sensitivity + --enabled <bool> expands to the matching operations.").option("--customer-id <id>", "Customer / org id (alias: --org)").option("--org <id>", "Alias for --customer-id").option("--op <op=bool>", "Single operation flip; repeatable (e.g. --op secrets.set=false --op variables.set=false)", parseOpFlag, []).option("--category <name>", "Apply --enabled to every operation in this category").option("--sensitivity <name>", "Apply --enabled to every operation in this sensitivity bucket").option("--enabled <bool>", "Pair with --category or --sensitivity to flip the whole group").option("--format <format>", "Output format: json|table", "table").action(async (opts) => {
9092
+ const customerId = resolveCustomerId(opts);
9093
+ try {
9094
+ const updates = collectUpdates(opts);
9095
+ if (Object.keys(updates).length === 0) {
9096
+ console.error("Error: no operations specified. Pass --op <name>=<bool> or --category/--sensitivity + --enabled.");
9097
+ process.exit(1);
9098
+ }
9099
+ printPlannedChange(updates, await getClient().get(`/api/v1/admin/org-settings/dashboard-writes?customerId=${encodeURIComponent(customerId)}`));
9100
+ const response = await getClient().patch(`/api/v1/admin/org-settings/dashboard-writes`, {
9101
+ customerId,
9102
+ updates
9103
+ });
9104
+ console.log(formatDashboardWrites(response, opts.format));
9105
+ } catch (err) {
9106
+ console.error(`Error: ${toErrorMessage(err)}`);
9107
+ process.exit(1);
9108
+ }
9109
+ });
9110
+ dw.command("reset").description("Reset all operations to enabled (permissive default).").option("--customer-id <id>", "Customer / org id (alias: --org)").option("--org <id>", "Alias for --customer-id").option("--format <format>", "Output format: json|table", "table").action(async (opts) => {
9111
+ const customerId = resolveCustomerId(opts);
9112
+ try {
9113
+ const response = await getClient().patch(`/api/v1/admin/org-settings/dashboard-writes`, {
9114
+ customerId,
9115
+ reset: true
9116
+ });
9117
+ console.log(formatDashboardWrites(response, opts.format));
9118
+ } catch (err) {
9119
+ console.error(`Error: ${toErrorMessage(err)}`);
9120
+ process.exit(1);
9121
+ }
9122
+ });
9123
+ }
9124
+ function parseFilters(opts) {
9125
+ if (!opts.category && !opts.sensitivity) return void 0;
9126
+ const filter = {};
9127
+ if (opts.category) {
9128
+ const parsed = DashboardWriteCategory.safeParse(opts.category);
9129
+ if (!parsed.success) throw new Error(`Unknown --category: ${opts.category}`);
9130
+ filter.category = parsed.data;
9131
+ }
9132
+ if (opts.sensitivity) {
9133
+ const parsed = DashboardWriteSensitivity.safeParse(opts.sensitivity);
9134
+ if (!parsed.success) throw new Error(`Unknown --sensitivity: ${opts.sensitivity}`);
9135
+ filter.sensitivity = parsed.data;
9136
+ }
9137
+ return filter;
9138
+ }
9139
+ function collectUpdates(opts) {
9140
+ const updates = {};
9141
+ for (const [op, value] of opts.op) updates[op] = value;
9142
+ if (Boolean(opts.category || opts.sensitivity)) {
9143
+ if (opts.enabled === void 0) throw new Error("--category / --sensitivity require --enabled <true|false>");
9144
+ const enabled = opts.enabled.toLowerCase();
9145
+ if (enabled !== "true" && enabled !== "false") throw new Error("--enabled must be \"true\" or \"false\"");
9146
+ const value = enabled === "true";
9147
+ const cat = opts.category ? DashboardWriteCategory.parse(opts.category) : void 0;
9148
+ const sens = opts.sensitivity ? DashboardWriteSensitivity.parse(opts.sensitivity) : void 0;
9149
+ for (const descriptor of DASHBOARD_WRITE_OPERATIONS) {
9150
+ if (cat && descriptor.category !== cat) continue;
9151
+ if (sens && descriptor.sensitivity !== sens) continue;
9152
+ updates[descriptor.name] = value;
9153
+ }
9154
+ }
9155
+ return updates;
9156
+ }
9157
+ function printPlannedChange(updates, before) {
9158
+ const lines = ["Planned changes:"];
9159
+ let any = false;
9160
+ for (const [op, next] of Object.entries(updates)) {
9161
+ const prior = before.effective[op] ?? true;
9162
+ if (prior === next) continue;
9163
+ any = true;
9164
+ lines.push(` ${op}: ${prior ? "enabled" : "disabled"} -> ${next ? "enabled" : "disabled"}`);
9165
+ }
9166
+ if (!any) lines.push(" (no effective change)");
9167
+ console.error(lines.join("\n"));
9168
+ }
9169
+ //#endregion
9170
+ //#region src/cli/commands/cluster-name.ts
9171
+ function registerClusterNameCommands(program, getClient) {
9172
+ const cn = program.command("cluster-name").description("Manage this orchestrator's cluster name (Platform-visible identifier)");
9173
+ cn.command("get").description("Print the current cluster name.").option("--format <format>", "Output format: json|table", "table").action(async (opts) => {
9174
+ try {
9175
+ const response = await getClient().get("/api/v1/admin/cluster-name");
9176
+ if (opts.format === "json") {
9177
+ console.log(JSON.stringify(response, null, 2));
9178
+ return;
9179
+ }
9180
+ console.log(`Cluster name: ${response.clusterName}`);
9181
+ console.log(`Looks auto-generated: ${response.looksAutoGenerated ? "yes" : "no"}`);
9182
+ } catch (err) {
9183
+ console.error(`Error: ${toErrorMessage(err)}`);
9184
+ process.exit(1);
9185
+ }
9186
+ });
9187
+ cn.command("set <name>").description(`Rename the cluster. ${CLUSTER_NAME_FORMAT_MESSAGE}.`).option("--format <format>", "Output format: json|table", "table").action(async (name, opts) => {
9188
+ try {
9189
+ const response = await getClient().put("/api/v1/admin/cluster-name", { name });
9190
+ if (opts.format === "json") {
9191
+ console.log(JSON.stringify(response, null, 2));
9192
+ return;
9193
+ }
9194
+ if (response.prior !== null && response.prior !== response.clusterName) console.log(`Renamed: ${response.prior} -> ${response.clusterName}`);
9195
+ else console.log(`Cluster name is already ${response.clusterName} (no change).`);
9196
+ if (response.reconnectRequired) console.log("Reconnect to Platform to publish the new name. Restart the orchestrator or run `kici-admin orchestrator-service restart`.");
9197
+ } catch (err) {
9198
+ console.error(`Error: ${toErrorMessage(err)}`);
9199
+ process.exit(1);
9200
+ }
9201
+ });
9202
+ }
8435
9203
  //#endregion
8436
9204
  //#region src/cli/commands/maintenance.ts
8437
9205
  /**
@@ -8742,6 +9510,85 @@ function registerEnvironmentCommands(program, getClient) {
8742
9510
  });
8743
9511
  }
8744
9512
  //#endregion
9513
+ //#region src/cli/commands/variable.ts
9514
+ function registerVariableCommands(program, getClient) {
9515
+ const vr = program.command("variable").description("Manage environment variables");
9516
+ vr.command("list <orgId> <environment>").description("List org-level variables in an environment").option("--values", "Print variable values inline (default: keys + locked flag only)").action(async (orgId, environment, opts) => {
9517
+ try {
9518
+ const { variables } = await getClient().listVariables(orgId, environment);
9519
+ if (variables.length === 0) {
9520
+ console.log("No variables in this environment.");
9521
+ return;
9522
+ }
9523
+ for (const v of variables) {
9524
+ const lockTag = v.locked ? " [locked]" : "";
9525
+ if (opts.values) console.log(` - ${v.key}=${v.value}${lockTag}`);
9526
+ else console.log(` - ${v.key}${lockTag}`);
9527
+ }
9528
+ } catch (err) {
9529
+ console.error(`Error: ${toErrorMessage(err)}`);
9530
+ process.exit(1);
9531
+ }
9532
+ });
9533
+ vr.command("get <orgId> <environment> <key>").description("Print the value of a single variable").action(async (orgId, environment, key) => {
9534
+ try {
9535
+ const { variables } = await getClient().listVariables(orgId, environment);
9536
+ const match = variables.find((v) => v.key === key);
9537
+ if (!match) {
9538
+ console.error(`Variable '${key}' not found in environment '${environment}'.`);
9539
+ process.exit(1);
9540
+ }
9541
+ console.log(match.value);
9542
+ } catch (err) {
9543
+ console.error(`Error: ${toErrorMessage(err)}`);
9544
+ process.exit(1);
9545
+ }
9546
+ });
9547
+ vr.command("set <orgId> <environment> <key>").description("Set an environment variable. Value comes from one of: --prompt (default on TTY), --from-stdin (default on pipe), --from-file <path>, --from-env <VAR>, --value <plaintext> (discouraged).").option("--value <value>", "Variable value via argv (visible in shell history)").option("--prompt", "Interactive no-echo prompt (requires TTY)").option("--from-stdin", "Read value from piped stdin until EOF").option("--from-file <path>", "Read value from a file (trailing newline trimmed)").option("--from-env <var>", "Read value from a named environment variable").option("--no-trim", "When reading --from-file, keep the trailing newline (default: trim once)").option("--locked", "Mark the variable as locked (source overrides cannot replace it)").option("--confirm-fingerprint <sha256hex>", "Refuse the write unless SHA-256(value) matches this 64-hex string").option("--dry-run", "Parse + validate the value, print fingerprint + length, do not write").action(async (orgId, environment, key, opts) => {
9548
+ try {
9549
+ const { value, source } = await resolveSecretInput(opts);
9550
+ if (opts.dryRun) {
9551
+ console.log(`[dry-run] would set variable '${key}' in environment '${environment}' for org ${orgId} (${value.length} chars, source=${source}, locked=${Boolean(opts.locked)}, sha256=${fingerprintValue(value)})`);
9552
+ return;
9553
+ }
9554
+ await getClient().setVariable(orgId, environment, key, value, opts.locked);
9555
+ const lockTag = opts.locked ? " [locked]" : "";
9556
+ console.log(`Variable '${key}' set in environment '${environment}' for org ${orgId}${lockTag}.`);
9557
+ } catch (err) {
9558
+ console.error(`Error: ${toErrorMessage(err)}`);
9559
+ process.exit(1);
9560
+ }
9561
+ });
9562
+ vr.command("delete <orgId> <environment> <key>").description("Delete an environment variable").option("--yes", "Skip confirmation prompt").action(async (orgId, environment, key, opts) => {
9563
+ try {
9564
+ if (!opts.yes) {
9565
+ if (!await confirm$1(`Are you sure you want to delete variable '${key}' from environment '${environment}'?`)) {
9566
+ console.log("Aborted.");
9567
+ return;
9568
+ }
9569
+ }
9570
+ await getClient().deleteVariable(orgId, environment, key);
9571
+ console.log(`Variable '${key}' deleted from environment '${environment}' for org ${orgId}.`);
9572
+ } catch (err) {
9573
+ console.error(`Error: ${toErrorMessage(err)}`);
9574
+ process.exit(1);
9575
+ }
9576
+ });
9577
+ }
9578
+ async function confirm$1(message) {
9579
+ const { createInterface } = await import("node:readline");
9580
+ const rl = createInterface({
9581
+ input: process.stdin,
9582
+ output: process.stderr
9583
+ });
9584
+ return new Promise((resolve) => {
9585
+ rl.question(`${message} [y/N] `, (answer) => {
9586
+ rl.close();
9587
+ resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes");
9588
+ });
9589
+ });
9590
+ }
9591
+ //#endregion
8745
9592
  //#region src/cli/commands/queue.ts
8746
9593
  function resolveDirectDbUrl$3(explicit) {
8747
9594
  return explicit ?? process.env.KICI_DATABASE_URL ?? null;
@@ -11419,8 +12266,10 @@ registerOrchestratorServiceCommands(program);
11419
12266
  registerAgentServiceCommands(program);
11420
12267
  registerPeerCommands(program, getClient);
11421
12268
  registerOrgSettingsCommands(program, getClient);
12269
+ registerClusterNameCommands(program, getClient);
11422
12270
  registerMaintenanceCommands(program, getClient);
11423
12271
  registerEnvironmentCommands(program, getClient);
12272
+ registerVariableCommands(program, getClient);
11424
12273
  registerQueueCommands(program, getClient);
11425
12274
  registerExecutionCommands(program, getClient);
11426
12275
  registerRegistrationCommands(program, getClient);