@kici-dev/orchestrator 0.1.3 → 0.1.5

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 (66) hide show
  1. package/dist/agent/dispatcher.d.ts +26 -0
  2. package/dist/agent/ownership-tracker.d.ts +45 -3
  3. package/dist/audit/access-log.d.ts +7 -0
  4. package/dist/cli/api-client.d.ts +14 -0
  5. package/dist/cli/commands/cluster-name.d.ts +18 -0
  6. package/dist/cli/commands/db.d.ts +9 -6
  7. package/dist/cli/commands/org-settings.d.ts +2 -1
  8. package/dist/cli/commands/shared/secret-input.d.ts +46 -0
  9. package/dist/cli/commands/variable.d.ts +18 -0
  10. package/dist/cli/service/index.d.ts +1 -0
  11. package/dist/cli/service/launchd.d.ts +19 -0
  12. package/dist/cli/service/privilege.d.ts +21 -0
  13. package/dist/cli/wizard/orchestrator-wizard.d.ts +4 -2
  14. package/dist/cli.js +1056 -236
  15. package/dist/cluster/join-token.d.ts +21 -13
  16. package/dist/cluster/peer-client.d.ts +9 -0
  17. package/dist/config/cluster-id.d.ts +23 -0
  18. package/dist/config/cluster-name.d.ts +39 -0
  19. package/dist/config.d.ts +6 -0
  20. package/dist/dashboard/handler.d.ts +54 -2
  21. package/dist/db/migrations/019_generic_sources_change_notify.d.ts +25 -0
  22. package/dist/db/migrations/020_org_settings_dashboard_write_policy.d.ts +17 -0
  23. package/dist/db/migrations/021_check_run_tracking.d.ts +26 -0
  24. package/dist/db/migrations/022_scaler_manager_state.d.ts +29 -0
  25. package/dist/db/migrations/023_dispatch_queue_recovery_deadline.d.ts +26 -0
  26. package/dist/db/types.d.ts +137 -0
  27. package/dist/events/event-store.d.ts +9 -2
  28. package/dist/events/trust-store.d.ts +7 -0
  29. package/dist/events/types.d.ts +33 -0
  30. package/dist/metrics/scheduled-jobs.d.ts +26 -5
  31. package/dist/orchestrator-core.d.ts +7 -0
  32. package/dist/policy/dashboard-write-policy.d.ts +115 -0
  33. package/dist/queue/job-queue.d.ts +54 -1
  34. package/dist/reporting/check-run-tracking-store.d.ts +133 -0
  35. package/dist/reporting/commit-status.d.ts +87 -9
  36. package/dist/routes/admin-access-log.d.ts +1 -0
  37. package/dist/routes/admin-backends.d.ts +9 -1
  38. package/dist/routes/admin-cluster-name.d.ts +50 -0
  39. package/dist/routes/admin-db.d.ts +9 -1
  40. package/dist/routes/admin-environments.d.ts +10 -6
  41. package/dist/routes/admin-event-dlq.d.ts +1 -0
  42. package/dist/routes/admin-event-log.d.ts +1 -0
  43. package/dist/routes/admin-events.d.ts +19 -0
  44. package/dist/routes/admin-maintenance.d.ts +9 -1
  45. package/dist/routes/admin-org-settings.d.ts +10 -1
  46. package/dist/routes/admin-queue-execution.d.ts +1 -0
  47. package/dist/routes/admin-registrations.d.ts +1 -0
  48. package/dist/routes/admin-runs.d.ts +1 -0
  49. package/dist/routes/admin-scheduled-jobs.d.ts +1 -0
  50. package/dist/routes/admin-sources.d.ts +9 -1
  51. package/dist/routes/admin.d.ts +8 -0
  52. package/dist/scaler/manager.d.ts +38 -0
  53. package/dist/scaler/scaler-state-store.d.ts +102 -0
  54. package/dist/secrets/routing-key-scope.d.ts +43 -0
  55. package/dist/server.js +9846 -6884
  56. package/dist/stale-detector/stale-run-detector.d.ts +14 -2
  57. package/dist/standalone.js +6120 -3838
  58. package/dist/webhook/generic-sources-listener.d.ts +74 -0
  59. package/dist/webhook/register-source-bundle.d.ts +57 -0
  60. package/dist/ws/dashboard-backends-handler.d.ts +14 -0
  61. package/dist/ws/dashboard-env-handler.d.ts +7 -0
  62. package/dist/ws/dashboard-global-workflows-handler.d.ts +6 -0
  63. package/dist/ws/dashboard-registrations-handler.d.ts +7 -0
  64. package/dist/ws/platform-client.d.ts +30 -1
  65. package/package.json +3 -3
  66. 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,68 +3290,368 @@ 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
  }
3296
+ async function down$6(db) {
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);
3298
+ }
3299
+ //#endregion
3300
+ //#region src/db/migrations/018_org_id_prefix_backfill.ts
3301
+ var _018_org_id_prefix_backfill_exports = /* @__PURE__ */ __exportAll({
3302
+ down: () => down$5,
3303
+ up: () => up$5
3304
+ });
3305
+ /**
3306
+ * Prefix every orchestrator-side tenant string with `org_` to align
3307
+ * with Platform's prefixed canonical org ID format (`org_<12-char>`).
3308
+ *
3309
+ * Migration 017 widened the column types but deliberately left rows
3310
+ * un-backfilled, reasoning that pre-migration runtime data could be
3311
+ * orphaned and regenerated on the next deploy. That works for
3312
+ * historical artefacts (execution_runs, event_log, access_log) but
3313
+ * NOT for tenant-state tables the orchestrator needs to recognise the
3314
+ * staging org at routing time (`sources`, `generic_webhook_sources`,
3315
+ * `workflow_registrations`, `org_settings`) and the env-config tables
3316
+ * referenced from runtime requests (`environments`,
3317
+ * `environment_bindings`, `environment_source_overrides`,
3318
+ * `environment_variables`).
3319
+ *
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
+ }
3192
3589
  async function down$1(db) {
3193
- for (const table of ORG_ID_TABLES$1) await sql.raw(`ALTER TABLE public.${table} ALTER COLUMN org_id TYPE varchar(12)`).execute(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);
3194
3593
  }
3195
3594
  //#endregion
3196
- //#region src/db/migrations/018_org_id_prefix_backfill.ts
3197
- var _018_org_id_prefix_backfill_exports = /* @__PURE__ */ __exportAll({
3595
+ //#region src/db/migrations/023_dispatch_queue_recovery_deadline.ts
3596
+ var _023_dispatch_queue_recovery_deadline_exports = /* @__PURE__ */ __exportAll({
3198
3597
  down: () => down,
3199
3598
  up: () => up
3200
3599
  });
3201
3600
  /**
3202
- * Prefix every orchestrator-side tenant string with `org_` to align
3203
- * with Platform's prefixed canonical org ID format (`org_<12-char>`).
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.
3204
3604
  *
3205
- * Migration 017 widened the column types but deliberately left rows
3206
- * un-backfilled, reasoning that pre-migration runtime data could be
3207
- * orphaned and regenerated on the next deploy. That works for
3208
- * historical artefacts (execution_runs, event_log, access_log) but
3209
- * NOT for tenant-state tables the orchestrator needs to recognise the
3210
- * staging org at routing time (`sources`, `generic_webhook_sources`,
3211
- * `workflow_registrations`, `org_settings`) and the env-config tables
3212
- * referenced from runtime requests (`environments`,
3213
- * `environment_bindings`, `environment_source_overrides`,
3214
- * `environment_variables`).
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.
3215
3611
  *
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.
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.
3222
3618
  *
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.
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
@@ -4844,7 +5324,7 @@ function registerBackendCommands(program, getClient) {
4844
5324
  try {
4845
5325
  scopeInfo = ` has ${(await getClient().getBackend(name)).scopeCount ?? 0} discovered scopes -- they will become unavailable.`;
4846
5326
  } catch {}
4847
- if (!await confirm$2(`Backend "${name}"${scopeInfo} Continue? [y/N] `)) {
5327
+ if (!await confirm$3(`Backend "${name}"${scopeInfo} Continue? [y/N] `)) {
4848
5328
  console.log("Aborted.");
4849
5329
  return;
4850
5330
  }
@@ -5034,7 +5514,7 @@ function printBackendSummary(b) {
5034
5514
  /**
5035
5515
  * Simple confirmation prompt.
5036
5516
  */
5037
- async function confirm$2(message) {
5517
+ async function confirm$3(message) {
5038
5518
  const rl = createInterface({
5039
5519
  input: process.stdin,
5040
5520
  output: process.stderr
@@ -5594,6 +6074,18 @@ function getCacheDir() {
5594
6074
  }
5595
6075
  var init_platform_detect = __esmMin((() => {}));
5596
6076
  //#endregion
6077
+ //#region src/cli/service/privilege.ts
6078
+ init_platform_detect();
6079
+ function resolveUserLevel(opts) {
6080
+ if (opts.system && opts.userLevel) throw new Error("`--system` and `--user-level` are mutually exclusive. Pick one (or neither for auto-detect).");
6081
+ if (opts.system) {
6082
+ if (!isRoot()) throw new Error("`--system` requires root privileges. Re-run under sudo, e.g. `sudo kici-admin orchestrator install --system ...`.");
6083
+ return false;
6084
+ }
6085
+ if (opts.userLevel) return true;
6086
+ return !isRoot();
6087
+ }
6088
+ //#endregion
5597
6089
  //#region src/cli/service/systemd.ts
5598
6090
  /**
5599
6091
  * systemd service manager implementation.
@@ -5885,26 +6377,79 @@ var init_launchd = __esmMin((() => {
5885
6377
  lines.push("");
5886
6378
  return lines.join("\n");
5887
6379
  }
6380
+ /**
6381
+ * Resolve the launchctl domain string for a service config.
6382
+ *
6383
+ * - User-level (LaunchAgent at `~/Library/LaunchAgents/…`) → `gui/<uid>`
6384
+ * - System-level (LaunchDaemon at `/Library/LaunchDaemons/…`) → `system`
6385
+ *
6386
+ * The legacy `launchctl load` / `unload` verbs implicitly inferred the
6387
+ * domain from the plist's filesystem location, but they fail silently on
6388
+ * headless macOS hosts where no GUI session exists (the `gui/<uid>` domain
6389
+ * is not available without a console login). The modern `bootstrap` /
6390
+ * `bootout` / `kickstart` / `kill` verbs take the domain explicitly, so a
6391
+ * system-level LaunchDaemon loads regardless of whether anyone is logged
6392
+ * in at the console — exactly what we need for headless deploy targets.
6393
+ */
6394
+ domain(config) {
6395
+ return config.isUserLevel ? `gui/${os.userInfo().uid}` : "system";
6396
+ }
6397
+ /** `<domain>/<label>` — the target string for kickstart/kill/print/enable. */
6398
+ domainTarget(config) {
6399
+ return `${this.domain(config)}/${this.label(config)}`;
6400
+ }
6401
+ /** Is the service currently loaded in its target domain? */
6402
+ isLoaded(config) {
6403
+ try {
6404
+ execFileSync("launchctl", ["print", this.domainTarget(config)], { stdio: "ignore" });
6405
+ return true;
6406
+ } catch {
6407
+ return false;
6408
+ }
6409
+ }
5888
6410
  async install(config) {
5889
6411
  const plistFile = this.plistPath(config);
5890
6412
  const plistContent = this.generatePlist(config);
5891
6413
  fs.mkdirSync(path.dirname(plistFile), { recursive: true });
5892
- fs.mkdirSync(this.logDir(config), { recursive: true });
6414
+ const logDirectory = this.logDir(config);
6415
+ fs.mkdirSync(logDirectory, { recursive: true });
6416
+ if (!config.isUserLevel && config.user) execFileSync("chown", [
6417
+ "-R",
6418
+ `${config.user}:staff`,
6419
+ logDirectory
6420
+ ], { stdio: "inherit" });
5893
6421
  fs.writeFileSync(plistFile, plistContent, "utf-8");
5894
- execFileSync("launchctl", ["load", plistFile], { stdio: "inherit" });
6422
+ if (this.isLoaded(config)) try {
6423
+ execFileSync("launchctl", ["bootout", this.domainTarget(config)], { stdio: "inherit" });
6424
+ } catch {}
6425
+ execFileSync("launchctl", [
6426
+ "bootstrap",
6427
+ this.domain(config),
6428
+ plistFile
6429
+ ], { stdio: "inherit" });
5895
6430
  }
5896
6431
  async uninstall(config) {
5897
6432
  const plistFile = this.plistPath(config);
5898
6433
  try {
5899
- execFileSync("launchctl", ["unload", plistFile], { stdio: "inherit" });
6434
+ execFileSync("launchctl", ["bootout", this.domainTarget(config)], { stdio: "inherit" });
6435
+ } catch {}
6436
+ try {
6437
+ fs.unlinkSync(plistFile);
5900
6438
  } catch {}
5901
- fs.unlinkSync(plistFile);
5902
6439
  }
5903
6440
  async start(config) {
5904
- execFileSync("launchctl", ["start", this.label(config)], { stdio: "inherit" });
6441
+ execFileSync("launchctl", [
6442
+ "kickstart",
6443
+ "-k",
6444
+ this.domainTarget(config)
6445
+ ], { stdio: "inherit" });
5905
6446
  }
5906
6447
  async stop(config) {
5907
- execFileSync("launchctl", ["stop", this.label(config)], { stdio: "inherit" });
6448
+ execFileSync("launchctl", [
6449
+ "kill",
6450
+ "TERM",
6451
+ this.domainTarget(config)
6452
+ ], { stdio: "inherit" });
5908
6453
  }
5909
6454
  async restart(config) {
5910
6455
  await this.stop(config);
@@ -6397,6 +6942,11 @@ function getRestartMode(config) {
6397
6942
  * Uses a manual YAML builder to avoid adding a YAML library dependency.
6398
6943
  * The generated file includes the service definition with env_file,
6399
6944
  * restart policy, and volume mounts.
6945
+ *
6946
+ * The `image:` line points at `quay.io/kici-dev/<service-name>:latest`.
6947
+ * Source of truth for the publish targets: packages/ci/src/publish-images.ts.
6948
+ * See .claude/rules/container-publish-registry.md — Quay is the sole
6949
+ * registry for public KiCI images we publish.
6400
6950
  */
6401
6951
  function generateComposeYaml(config) {
6402
6952
  const restartMode = getRestartMode(config);
@@ -6406,7 +6956,7 @@ function generateComposeYaml(config) {
6406
6956
  "",
6407
6957
  "services:",
6408
6958
  ` ${config.name}:`,
6409
- ` image: ghcr.io/kici-dev/kici:latest`,
6959
+ ` image: quay.io/kici-dev/${config.name}:latest`,
6410
6960
  ` container_name: ${config.name}`,
6411
6961
  ` restart: ${restartMode}`,
6412
6962
  " env_file:",
@@ -6604,8 +7154,9 @@ var orchestrator_wizard_exports = /* @__PURE__ */ __exportAll({ runOrchestratorW
6604
7154
  * 2. Database URL
6605
7155
  * 3. Port
6606
7156
  * 4. Secrets encryption key
6607
- * 5. Platform URL + token (if platform/hybrid mode)
6608
- * 6. Webhook secret (if hybrid/independent mode)
7157
+ * 5. Bootstrap admin token (for kici-admin authentication)
7158
+ * 6. Platform URL + token (if platform/hybrid mode)
7159
+ * 7. Webhook secret (if hybrid/independent mode)
6609
7160
  */
6610
7161
  async function runOrchestratorWizard() {
6611
7162
  console.log("");
@@ -6639,6 +7190,13 @@ async function runOrchestratorWizard() {
6639
7190
  let secretsKey;
6640
7191
  if (useGenerated) secretsKey = generatedKey;
6641
7192
  else secretsKey = await promptSecret("Enter custom secrets encryption key (64 hex chars):");
7193
+ console.log("");
7194
+ const generatedAdminToken = randomBytes(32).toString("hex");
7195
+ console.log(`Generated bootstrap admin token: ${generatedAdminToken}`);
7196
+ const useGeneratedAdminToken = await promptConfirm("Use this token?");
7197
+ let bootstrapAdminToken;
7198
+ if (useGeneratedAdminToken) bootstrapAdminToken = generatedAdminToken;
7199
+ else bootstrapAdminToken = await promptSecret("Enter custom bootstrap admin token:");
6642
7200
  let platformUrl;
6643
7201
  let platformToken;
6644
7202
  if (mode === "platform" || mode === "hybrid") {
@@ -6678,6 +7236,7 @@ async function runOrchestratorWizard() {
6678
7236
  console.log(` Mode: ${mode}`);
6679
7237
  console.log(` Database: ${databaseUrl.replace(/:[^@]*@/, ":***@")}`);
6680
7238
  console.log(` Port: ${port}`);
7239
+ console.log(` Admin token: ${bootstrapAdminToken.slice(0, 8)}…`);
6681
7240
  console.log(` Platform: ${platformUrl ?? "N/A"}`);
6682
7241
  if (source) console.log(` Source: ${source.provider}:${source.appId} (${source.name})`);
6683
7242
  console.log("");
@@ -6686,6 +7245,7 @@ async function runOrchestratorWizard() {
6686
7245
  databaseUrl,
6687
7246
  port,
6688
7247
  secretsKey,
7248
+ bootstrapAdminToken,
6689
7249
  platformUrl,
6690
7250
  platformToken,
6691
7251
  source
@@ -6724,18 +7284,18 @@ function startDevPostgres(containerName) {
6724
7284
  if (err instanceof Error && err.message.includes("already exists")) throw err;
6725
7285
  }
6726
7286
  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" });
7287
+ execSync(`${runtime} run -d --name ${containerName} -p ${port}:5432 -e POSTGRES_PASSWORD=${password} -e POSTGRES_DB=kici postgres:18-trixie`, { stdio: "inherit" });
6728
7288
  return `postgresql://postgres:${password}@localhost:${port}/kici`;
6729
7289
  }
6730
7290
  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) => {
7291
+ 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
7292
  try {
6733
7293
  if (opts.wizard && opts.envFile) {
6734
7294
  console.error("Error: Cannot use --wizard with --env-file");
6735
7295
  process.exit(1);
6736
7296
  }
6737
7297
  const platform = detectPlatform(opts.platform);
6738
- const userLevel = !isRoot();
7298
+ const userLevel = resolveUserLevel(opts);
6739
7299
  const serviceName = opts.name;
6740
7300
  console.log(`Platform: ${platform}`);
6741
7301
  console.log(`Privilege: ${userLevel ? "user" : "system"}`);
@@ -6755,9 +7315,10 @@ function registerOrchestratorInstall(orchestrator) {
6755
7315
  const wizardConfig = await runOrchestratorWizard();
6756
7316
  let envContent = "# KiCI orchestrator configuration (generated by setup wizard)\n";
6757
7317
  envContent += `KICI_MODE=${wizardConfig.mode}\n`;
6758
- envContent += `DATABASE_URL=${wizardConfig.databaseUrl}\n`;
7318
+ envContent += `KICI_DATABASE_URL=${wizardConfig.databaseUrl}\n`;
6759
7319
  envContent += `KICI_PORT=${wizardConfig.port}\n`;
6760
- envContent += `KICI_SECRETS_KEY=${wizardConfig.secretsKey}\n`;
7320
+ envContent += `KICI_SECRET_KEY=${wizardConfig.secretsKey}\n`;
7321
+ envContent += `KICI_BOOTSTRAP_ADMIN_TOKEN=${wizardConfig.bootstrapAdminToken}\n`;
6761
7322
  if (wizardConfig.platformUrl) envContent += `KICI_PLATFORM_URL=${wizardConfig.platformUrl}\n`;
6762
7323
  if (wizardConfig.platformToken) envContent += `KICI_PLATFORM_TOKEN=${wizardConfig.platformToken}\n`;
6763
7324
  fs.writeFileSync(envFilePath, envContent, "utf-8");
@@ -6772,12 +7333,12 @@ function registerOrchestratorInstall(orchestrator) {
6772
7333
  console.log(`Copied env file to ${envFilePath}`);
6773
7334
  } else if (!fs.existsSync(envFilePath)) {
6774
7335
  let envContent = `# KiCI orchestrator configuration\n# See docs for all available options\n`;
6775
- if (devDbUrl) envContent += `DATABASE_URL=${devDbUrl}\n`;
7336
+ if (devDbUrl) envContent += `KICI_DATABASE_URL=${devDbUrl}\n`;
6776
7337
  fs.writeFileSync(envFilePath, envContent, "utf-8");
6777
7338
  console.log(`Created env file at ${envFilePath}`);
6778
7339
  } else if (devDbUrl) {
6779
- fs.appendFileSync(envFilePath, `\nDATABASE_URL=${devDbUrl}\n`);
6780
- console.log(`Appended DATABASE_URL to ${envFilePath}`);
7340
+ fs.appendFileSync(envFilePath, `\nKICI_DATABASE_URL=${devDbUrl}\n`);
7341
+ console.log(`Appended KICI_DATABASE_URL to ${envFilePath}`);
6781
7342
  }
6782
7343
  const executablePath = opts.binary ? path.resolve(opts.binary) : process.argv[0];
6783
7344
  if (userLevel) try {
@@ -6796,6 +7357,7 @@ function registerOrchestratorInstall(orchestrator) {
6796
7357
  envFilePath,
6797
7358
  workingDirectory: configDir,
6798
7359
  isUserLevel: userLevel,
7360
+ user: opts.user,
6799
7361
  restartPolicy: DEFAULT_RESTART_POLICY
6800
7362
  };
6801
7363
  await (await createServiceManager(platform)).install(config);
@@ -6814,10 +7376,10 @@ function registerOrchestratorInstall(orchestrator) {
6814
7376
  //#endregion
6815
7377
  //#region src/cli/commands/orchestrator-service/uninstall.ts
6816
7378
  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) => {
7379
+ 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
7380
  try {
6819
7381
  const platform = detectPlatform(opts.platform);
6820
- const userLevel = !isRoot();
7382
+ const userLevel = resolveUserLevel(opts);
6821
7383
  const serviceName = opts.name;
6822
7384
  const configDir = getConfigDir(serviceName, userLevel);
6823
7385
  const logDir = getLogDir(serviceName, userLevel);
@@ -6857,10 +7419,10 @@ function registerOrchestratorUninstall(orchestrator) {
6857
7419
  //#endregion
6858
7420
  //#region src/cli/commands/orchestrator-service/start.ts
6859
7421
  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) => {
7422
+ 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
7423
  try {
6862
7424
  const platform = detectPlatform(opts.platform);
6863
- const userLevel = !isRoot();
7425
+ const userLevel = resolveUserLevel(opts);
6864
7426
  const serviceName = opts.name;
6865
7427
  const configDir = getConfigDir(serviceName, userLevel);
6866
7428
  const config = {
@@ -6890,10 +7452,10 @@ function registerOrchestratorStart(orchestrator) {
6890
7452
  //#endregion
6891
7453
  //#region src/cli/commands/orchestrator-service/stop.ts
6892
7454
  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) => {
7455
+ 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
7456
  try {
6895
7457
  const platform = detectPlatform(opts.platform);
6896
- const userLevel = !isRoot();
7458
+ const userLevel = resolveUserLevel(opts);
6897
7459
  const serviceName = opts.name;
6898
7460
  const configDir = getConfigDir(serviceName, userLevel);
6899
7461
  const config = {
@@ -6917,10 +7479,10 @@ function registerOrchestratorStop(orchestrator) {
6917
7479
  //#endregion
6918
7480
  //#region src/cli/commands/orchestrator-service/restart.ts
6919
7481
  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) => {
7482
+ 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
7483
  try {
6922
7484
  const platform = detectPlatform(opts.platform);
6923
- const userLevel = !isRoot();
7485
+ const userLevel = resolveUserLevel(opts);
6924
7486
  const serviceName = opts.name;
6925
7487
  const configDir = getConfigDir(serviceName, userLevel);
6926
7488
  const config = {
@@ -7106,7 +7668,7 @@ function registerLogsCommand(parent) {
7106
7668
  * - Optional cleanup of old versions
7107
7669
  */
7108
7670
  /** Prompt the user for confirmation (returns true if yes). */
7109
- async function confirm$1(message) {
7671
+ async function confirm$2(message) {
7110
7672
  const rl = createInterface({
7111
7673
  input: process.stdin,
7112
7674
  output: process.stdout
@@ -7322,7 +7884,7 @@ async function performVersionedUpgrade(component, opts) {
7322
7884
  console.log(` Install path: ${versionedDirPath}`);
7323
7885
  console.log(" The service will be stopped during upgrade.");
7324
7886
  console.log("");
7325
- if (!await confirm$1("Proceed with upgrade?")) {
7887
+ if (!await confirm$2("Proceed with upgrade?")) {
7326
7888
  console.log("Upgrade cancelled.");
7327
7889
  return;
7328
7890
  }
@@ -7404,7 +7966,7 @@ async function handleRollback(component, platform, installBase, config, manager,
7404
7966
  console.log(` Current version: ${currentVersion}`);
7405
7967
  console.log(` Rollback to: ${previousVersion}`);
7406
7968
  console.log("");
7407
- if (!await confirm$1("Proceed with rollback?")) {
7969
+ if (!await confirm$2("Proceed with rollback?")) {
7408
7970
  console.log("Rollback cancelled.");
7409
7971
  return;
7410
7972
  }
@@ -7968,30 +8530,39 @@ var JoinTokenManager = class {
7968
8530
  return token;
7969
8531
  }
7970
8532
  /**
7971
- * Validate a token: check hash exists in DB, not expired, not consumed.
7972
- * Returns routing info and derived keys on success.
8533
+ * Atomically validate and consume a token in one DB round-trip.
8534
+ *
8535
+ * Single UPDATE with `WHERE token_hash = ? AND consumed_at IS NULL AND
8536
+ * expires_at > NOW()` — only one caller can win the claim across a
8537
+ * shared-DB multi-coordinator mesh. The winner gets `{ routing, keys }`;
8538
+ * every other concurrent caller gets `TOKEN_ALREADY_USED_MESSAGE` and is
8539
+ * expected to fall into the idempotent recovery branch in
8540
+ * `peer-handler.ts` (which serialises credential issuance via the
8541
+ * `peer_credentials_active_uniq` partial unique index).
8542
+ *
8543
+ * On a 0-row claim, a follow-up SELECT disambiguates not-found / expired
8544
+ * / already-used so callers can branch on the specific reason — the
8545
+ * recovery path in peer-handler.ts keys on the "already used" string
8546
+ * specifically. The follow-up only fires on the unhappy path.
7973
8547
  */
7974
- async validateToken(token) {
8548
+ async validateAndConsumeToken(token, consumedBy) {
7975
8549
  const parsed = parseToken(token);
7976
8550
  const keys = deriveKeys(Buffer.from(parsed.secretHex, "hex"));
8551
+ const updateResult = await this.deps.db.updateTable("join_tokens").set({
8552
+ consumed_at: /* @__PURE__ */ new Date(),
8553
+ consumed_by: consumedBy
8554
+ }).where("token_hash", "=", keys.validationHash).where("consumed_at", "is", null).where("expires_at", ">", /* @__PURE__ */ new Date()).executeTakeFirst();
8555
+ if (Number(updateResult?.numUpdatedRows ?? 0n) > 0) {
8556
+ joinTokenLogger.info("Consumed join token", { consumedBy });
8557
+ return {
8558
+ routing: parsed.routing,
8559
+ keys
8560
+ };
8561
+ }
7977
8562
  const row = await this.deps.db.selectFrom("join_tokens").selectAll().where("token_hash", "=", keys.validationHash).executeTakeFirst();
7978
8563
  if (!row) throw new Error("Invalid join token");
7979
8564
  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 });
8565
+ throw new Error("Join token has expired. Generate a new token with: kici admin create-join-token");
7995
8566
  }
7996
8567
  };
7997
8568
  /**
@@ -8347,6 +8918,7 @@ function registerOrgSettingsCommands(program, getClient) {
8347
8918
  registerListMutators(gw, getClient, "allow", "allowedRepos");
8348
8919
  registerListMutators(gw, getClient, "deny", "deniedRepos");
8349
8920
  registerListMutators(gw, getClient, "elevate", "elevatedRepos");
8921
+ registerDashboardWritesCommands(orgSettings, getClient);
8350
8922
  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
8923
  const allow = value === "true" ? true : value === "false" ? false : void 0;
8352
8924
  if (allow === void 0) {
@@ -8432,6 +9004,173 @@ function label(prefix) {
8432
9004
  if (prefix === "deny") return "source-repo deny-list";
8433
9005
  return "elevated-access list";
8434
9006
  }
9007
+ function formatDashboardWrites(response, format, filter) {
9008
+ if (format === "json") return JSON.stringify(response, null, 2);
9009
+ const lines = [];
9010
+ lines.push(`Customer/org id: ${response.customerId}`);
9011
+ lines.push("");
9012
+ const byCategory = /* @__PURE__ */ new Map();
9013
+ for (const descriptor of DASHBOARD_WRITE_OPERATIONS) {
9014
+ if (filter?.category && descriptor.category !== filter.category) continue;
9015
+ if (filter?.sensitivity && descriptor.sensitivity !== filter.sensitivity) continue;
9016
+ const list = byCategory.get(descriptor.category) ?? [];
9017
+ list.push(descriptor);
9018
+ byCategory.set(descriptor.category, list);
9019
+ }
9020
+ for (const [category, descriptors] of byCategory) {
9021
+ lines.push(`${category.toUpperCase()}`);
9022
+ for (const descriptor of descriptors) {
9023
+ const state = response.effective[descriptor.name] ?? true ? "enabled " : "disabled";
9024
+ lines.push(` ${state} ${descriptor.name.padEnd(40)} (${descriptor.cliEquivalent})`);
9025
+ }
9026
+ lines.push("");
9027
+ }
9028
+ return lines.join("\n").trimEnd();
9029
+ }
9030
+ function parseOpFlag(value, previous) {
9031
+ const eq = value.indexOf("=");
9032
+ if (eq < 1 || eq === value.length - 1) {
9033
+ console.error(`Error: --op expects <operation>=<true|false>, got: ${value}`);
9034
+ process.exit(1);
9035
+ }
9036
+ const op = value.slice(0, eq);
9037
+ const bool = value.slice(eq + 1).toLowerCase();
9038
+ if (bool !== "true" && bool !== "false") {
9039
+ console.error(`Error: --op value must be "true" or "false", got: ${bool}`);
9040
+ process.exit(1);
9041
+ }
9042
+ if (!DASHBOARD_WRITE_OPERATIONS_BY_NAME.has(op)) {
9043
+ console.error(`Error: unknown operation "${op}". Run "kici-admin org-settings dashboard-writes show" to list valid operations.`);
9044
+ process.exit(1);
9045
+ }
9046
+ previous.push([op, bool === "true"]);
9047
+ return previous;
9048
+ }
9049
+ function registerDashboardWritesCommands(orgSettings, getClient) {
9050
+ const dw = orgSettings.command("dashboard-writes").description("Manage per-orch dashboard write policy (which Platform-routed dashboard.* writes the orch accepts)");
9051
+ 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) => {
9052
+ const customerId = resolveCustomerId(opts);
9053
+ try {
9054
+ const response = await getClient().get(`/api/v1/admin/org-settings/dashboard-writes?customerId=${encodeURIComponent(customerId)}`);
9055
+ const filter = parseFilters(opts);
9056
+ console.log(formatDashboardWrites(response, opts.format, filter));
9057
+ } catch (err) {
9058
+ console.error(`Error: ${toErrorMessage(err)}`);
9059
+ process.exit(1);
9060
+ }
9061
+ });
9062
+ 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) => {
9063
+ const customerId = resolveCustomerId(opts);
9064
+ try {
9065
+ const updates = collectUpdates(opts);
9066
+ if (Object.keys(updates).length === 0) {
9067
+ console.error("Error: no operations specified. Pass --op <name>=<bool> or --category/--sensitivity + --enabled.");
9068
+ process.exit(1);
9069
+ }
9070
+ printPlannedChange(updates, await getClient().get(`/api/v1/admin/org-settings/dashboard-writes?customerId=${encodeURIComponent(customerId)}`));
9071
+ const response = await getClient().patch(`/api/v1/admin/org-settings/dashboard-writes`, {
9072
+ customerId,
9073
+ updates
9074
+ });
9075
+ console.log(formatDashboardWrites(response, opts.format));
9076
+ } catch (err) {
9077
+ console.error(`Error: ${toErrorMessage(err)}`);
9078
+ process.exit(1);
9079
+ }
9080
+ });
9081
+ 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) => {
9082
+ const customerId = resolveCustomerId(opts);
9083
+ try {
9084
+ const response = await getClient().patch(`/api/v1/admin/org-settings/dashboard-writes`, {
9085
+ customerId,
9086
+ reset: true
9087
+ });
9088
+ console.log(formatDashboardWrites(response, opts.format));
9089
+ } catch (err) {
9090
+ console.error(`Error: ${toErrorMessage(err)}`);
9091
+ process.exit(1);
9092
+ }
9093
+ });
9094
+ }
9095
+ function parseFilters(opts) {
9096
+ if (!opts.category && !opts.sensitivity) return void 0;
9097
+ const filter = {};
9098
+ if (opts.category) {
9099
+ const parsed = DashboardWriteCategory.safeParse(opts.category);
9100
+ if (!parsed.success) throw new Error(`Unknown --category: ${opts.category}`);
9101
+ filter.category = parsed.data;
9102
+ }
9103
+ if (opts.sensitivity) {
9104
+ const parsed = DashboardWriteSensitivity.safeParse(opts.sensitivity);
9105
+ if (!parsed.success) throw new Error(`Unknown --sensitivity: ${opts.sensitivity}`);
9106
+ filter.sensitivity = parsed.data;
9107
+ }
9108
+ return filter;
9109
+ }
9110
+ function collectUpdates(opts) {
9111
+ const updates = {};
9112
+ for (const [op, value] of opts.op) updates[op] = value;
9113
+ if (Boolean(opts.category || opts.sensitivity)) {
9114
+ if (opts.enabled === void 0) throw new Error("--category / --sensitivity require --enabled <true|false>");
9115
+ const enabled = opts.enabled.toLowerCase();
9116
+ if (enabled !== "true" && enabled !== "false") throw new Error("--enabled must be \"true\" or \"false\"");
9117
+ const value = enabled === "true";
9118
+ const cat = opts.category ? DashboardWriteCategory.parse(opts.category) : void 0;
9119
+ const sens = opts.sensitivity ? DashboardWriteSensitivity.parse(opts.sensitivity) : void 0;
9120
+ for (const descriptor of DASHBOARD_WRITE_OPERATIONS) {
9121
+ if (cat && descriptor.category !== cat) continue;
9122
+ if (sens && descriptor.sensitivity !== sens) continue;
9123
+ updates[descriptor.name] = value;
9124
+ }
9125
+ }
9126
+ return updates;
9127
+ }
9128
+ function printPlannedChange(updates, before) {
9129
+ const lines = ["Planned changes:"];
9130
+ let any = false;
9131
+ for (const [op, next] of Object.entries(updates)) {
9132
+ const prior = before.effective[op] ?? true;
9133
+ if (prior === next) continue;
9134
+ any = true;
9135
+ lines.push(` ${op}: ${prior ? "enabled" : "disabled"} -> ${next ? "enabled" : "disabled"}`);
9136
+ }
9137
+ if (!any) lines.push(" (no effective change)");
9138
+ console.error(lines.join("\n"));
9139
+ }
9140
+ //#endregion
9141
+ //#region src/cli/commands/cluster-name.ts
9142
+ function registerClusterNameCommands(program, getClient) {
9143
+ const cn = program.command("cluster-name").description("Manage this orchestrator's cluster name (Platform-visible identifier)");
9144
+ cn.command("get").description("Print the current cluster name.").option("--format <format>", "Output format: json|table", "table").action(async (opts) => {
9145
+ try {
9146
+ const response = await getClient().get("/api/v1/admin/cluster-name");
9147
+ if (opts.format === "json") {
9148
+ console.log(JSON.stringify(response, null, 2));
9149
+ return;
9150
+ }
9151
+ console.log(`Cluster name: ${response.clusterName}`);
9152
+ console.log(`Looks auto-generated: ${response.looksAutoGenerated ? "yes" : "no"}`);
9153
+ } catch (err) {
9154
+ console.error(`Error: ${toErrorMessage(err)}`);
9155
+ process.exit(1);
9156
+ }
9157
+ });
9158
+ cn.command("set <name>").description(`Rename the cluster. ${CLUSTER_NAME_FORMAT_MESSAGE}.`).option("--format <format>", "Output format: json|table", "table").action(async (name, opts) => {
9159
+ try {
9160
+ const response = await getClient().put("/api/v1/admin/cluster-name", { name });
9161
+ if (opts.format === "json") {
9162
+ console.log(JSON.stringify(response, null, 2));
9163
+ return;
9164
+ }
9165
+ if (response.prior !== null && response.prior !== response.clusterName) console.log(`Renamed: ${response.prior} -> ${response.clusterName}`);
9166
+ else console.log(`Cluster name is already ${response.clusterName} (no change).`);
9167
+ if (response.reconnectRequired) console.log("Reconnect to Platform to publish the new name. Restart the orchestrator or run `kici-admin orchestrator-service restart`.");
9168
+ } catch (err) {
9169
+ console.error(`Error: ${toErrorMessage(err)}`);
9170
+ process.exit(1);
9171
+ }
9172
+ });
9173
+ }
8435
9174
  //#endregion
8436
9175
  //#region src/cli/commands/maintenance.ts
8437
9176
  /**
@@ -8742,6 +9481,85 @@ function registerEnvironmentCommands(program, getClient) {
8742
9481
  });
8743
9482
  }
8744
9483
  //#endregion
9484
+ //#region src/cli/commands/variable.ts
9485
+ function registerVariableCommands(program, getClient) {
9486
+ const vr = program.command("variable").description("Manage environment variables");
9487
+ 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) => {
9488
+ try {
9489
+ const { variables } = await getClient().listVariables(orgId, environment);
9490
+ if (variables.length === 0) {
9491
+ console.log("No variables in this environment.");
9492
+ return;
9493
+ }
9494
+ for (const v of variables) {
9495
+ const lockTag = v.locked ? " [locked]" : "";
9496
+ if (opts.values) console.log(` - ${v.key}=${v.value}${lockTag}`);
9497
+ else console.log(` - ${v.key}${lockTag}`);
9498
+ }
9499
+ } catch (err) {
9500
+ console.error(`Error: ${toErrorMessage(err)}`);
9501
+ process.exit(1);
9502
+ }
9503
+ });
9504
+ vr.command("get <orgId> <environment> <key>").description("Print the value of a single variable").action(async (orgId, environment, key) => {
9505
+ try {
9506
+ const { variables } = await getClient().listVariables(orgId, environment);
9507
+ const match = variables.find((v) => v.key === key);
9508
+ if (!match) {
9509
+ console.error(`Variable '${key}' not found in environment '${environment}'.`);
9510
+ process.exit(1);
9511
+ }
9512
+ console.log(match.value);
9513
+ } catch (err) {
9514
+ console.error(`Error: ${toErrorMessage(err)}`);
9515
+ process.exit(1);
9516
+ }
9517
+ });
9518
+ 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) => {
9519
+ try {
9520
+ const { value, source } = await resolveSecretInput(opts);
9521
+ if (opts.dryRun) {
9522
+ 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)})`);
9523
+ return;
9524
+ }
9525
+ await getClient().setVariable(orgId, environment, key, value, opts.locked);
9526
+ const lockTag = opts.locked ? " [locked]" : "";
9527
+ console.log(`Variable '${key}' set in environment '${environment}' for org ${orgId}${lockTag}.`);
9528
+ } catch (err) {
9529
+ console.error(`Error: ${toErrorMessage(err)}`);
9530
+ process.exit(1);
9531
+ }
9532
+ });
9533
+ vr.command("delete <orgId> <environment> <key>").description("Delete an environment variable").option("--yes", "Skip confirmation prompt").action(async (orgId, environment, key, opts) => {
9534
+ try {
9535
+ if (!opts.yes) {
9536
+ if (!await confirm$1(`Are you sure you want to delete variable '${key}' from environment '${environment}'?`)) {
9537
+ console.log("Aborted.");
9538
+ return;
9539
+ }
9540
+ }
9541
+ await getClient().deleteVariable(orgId, environment, key);
9542
+ console.log(`Variable '${key}' deleted from environment '${environment}' for org ${orgId}.`);
9543
+ } catch (err) {
9544
+ console.error(`Error: ${toErrorMessage(err)}`);
9545
+ process.exit(1);
9546
+ }
9547
+ });
9548
+ }
9549
+ async function confirm$1(message) {
9550
+ const { createInterface } = await import("node:readline");
9551
+ const rl = createInterface({
9552
+ input: process.stdin,
9553
+ output: process.stderr
9554
+ });
9555
+ return new Promise((resolve) => {
9556
+ rl.question(`${message} [y/N] `, (answer) => {
9557
+ rl.close();
9558
+ resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes");
9559
+ });
9560
+ });
9561
+ }
9562
+ //#endregion
8745
9563
  //#region src/cli/commands/queue.ts
8746
9564
  function resolveDirectDbUrl$3(explicit) {
8747
9565
  return explicit ?? process.env.KICI_DATABASE_URL ?? null;
@@ -11419,8 +12237,10 @@ registerOrchestratorServiceCommands(program);
11419
12237
  registerAgentServiceCommands(program);
11420
12238
  registerPeerCommands(program, getClient);
11421
12239
  registerOrgSettingsCommands(program, getClient);
12240
+ registerClusterNameCommands(program, getClient);
11422
12241
  registerMaintenanceCommands(program, getClient);
11423
12242
  registerEnvironmentCommands(program, getClient);
12243
+ registerVariableCommands(program, getClient);
11424
12244
  registerQueueCommands(program, getClient);
11425
12245
  registerExecutionCommands(program, getClient);
11426
12246
  registerRegistrationCommands(program, getClient);