@kici-dev/orchestrator 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/dist/__test-helpers__/mock-db-query.d.ts +4 -5
  2. package/dist/agent/agent-version.d.ts +5 -5
  3. package/dist/agent/dispatcher.d.ts +26 -0
  4. package/dist/agent/registry.d.ts +7 -9
  5. package/dist/agent-packaging/node-runtime-members.d.ts +14 -0
  6. package/dist/app.d.ts +3 -3
  7. package/dist/approvals/apply-decision.d.ts +1 -1
  8. package/dist/audit/access-log.d.ts +2 -3
  9. package/dist/cli/commands/access-log.d.ts +3 -3
  10. package/dist/cli/commands/agent-service/agent-env.d.ts +20 -0
  11. package/dist/cli/commands/agent-service/status.d.ts +3 -3
  12. package/dist/cli/commands/attestations.d.ts +1 -1
  13. package/dist/cli/commands/context.d.ts +2 -2
  14. package/dist/cli/commands/event-dlq.d.ts +5 -6
  15. package/dist/cli/commands/event-log.d.ts +2 -3
  16. package/dist/cli/commands/execution.d.ts +1 -3
  17. package/dist/cli/commands/orchestrator-service/status.d.ts +10 -3
  18. package/dist/cli/commands/queue.d.ts +1 -2
  19. package/dist/cli/commands/runs.d.ts +2 -3
  20. package/dist/cli/commands/service-health.d.ts +68 -0
  21. package/dist/cli/commands/shared/env-file-mode.d.ts +3 -2
  22. package/dist/cli/commands/shared/upgrade-safety.d.ts +11 -5
  23. package/dist/cli/commands/shared/versioned-upgrade.d.ts +119 -7
  24. package/dist/cli/commands/workflow.d.ts +3 -4
  25. package/dist/cli/open-browser.d.ts +1 -1
  26. package/dist/cli/service/entrypoint.d.ts +18 -0
  27. package/dist/cli/service/image-digests.d.ts +10 -0
  28. package/dist/cli/service/index.d.ts +4 -2
  29. package/dist/cli/service/instance/manifest.d.ts +39 -0
  30. package/dist/cli/service/launchd.d.ts +45 -10
  31. package/dist/cli/service/shutdown-grace.d.ts +35 -0
  32. package/dist/cli/service/types.d.ts +9 -0
  33. package/dist/cli/service/windows-acl.d.ts +40 -0
  34. package/dist/cli/service/windows-env-file.d.ts +45 -0
  35. package/dist/cli/service/windows.d.ts +13 -0
  36. package/dist/cli.js +1620 -551
  37. package/dist/cluster/cluster-identity.d.ts +4 -4
  38. package/dist/cluster/join-token.d.ts +2 -2
  39. package/dist/cluster/leader-gated-scheduler.d.ts +7 -1
  40. package/dist/cluster/peer-credentials.d.ts +1 -1
  41. package/dist/cluster/raft-state.d.ts +6 -0
  42. package/dist/cluster/raft.d.ts +16 -2
  43. package/dist/cold-store/load-access-log-range.d.ts +3 -3
  44. package/dist/cold-store/load-event-log-range.d.ts +1 -1
  45. package/dist/cold-store/load-secret-audit-log-range.d.ts +2 -2
  46. package/dist/cold-store/orchestrator-cold-store.d.ts +9 -11
  47. package/dist/cold-store/tables/access-log.d.ts +2 -2
  48. package/dist/cold-store/tables/event-log.d.ts +4 -10
  49. package/dist/cold-store/tables/execution-jobs.d.ts +1 -1
  50. package/dist/cold-store/tables/execution-runs.d.ts +4 -4
  51. package/dist/cold-store/tables/execution-steps.d.ts +1 -1
  52. package/dist/cold-store/tables/secret-audit-log.d.ts +3 -3
  53. package/dist/config.d.ts +3 -1
  54. package/dist/contexts/held-runs.d.ts +2 -2
  55. package/dist/dashboard/attestation-filters.d.ts +1 -1
  56. package/dist/dashboard/handler.d.ts +8 -8
  57. package/dist/db/deferred-indexes.d.ts +2 -2
  58. package/dist/db/migrations/005_cold_store_chunk_counter.d.ts +2 -4
  59. package/dist/db/migrations/006_runs_jobs_steps_archived_at.d.ts +4 -4
  60. package/dist/db/migrations/007_audit_logs_archived_at.d.ts +6 -9
  61. package/dist/db/migrations/008_event_log_archived_at.d.ts +4 -6
  62. package/dist/db/migrations/010_cold_store_chunks.d.ts +2 -2
  63. package/dist/db/migrations/012_peer_credentials_active_uniq.d.ts +2 -4
  64. package/dist/db/migrations/016_org_settings_allow_http_npm.d.ts +2 -2
  65. package/dist/db/migrations/045_host_properties.d.ts +1 -1
  66. package/dist/db/migrations/046_join_token_consumed_by_instance.d.ts +1 -1
  67. package/dist/db/migrations/048_host_reboot_pending.d.ts +1 -1
  68. package/dist/db/migrations/049_held_runs_payload.d.ts +1 -1
  69. package/dist/db/migrations/050_sources_slug.d.ts +1 -1
  70. package/dist/db/migrations/051_binding_host_pattern.d.ts +1 -1
  71. package/dist/db/migrations/052_host_reach_metadata.d.ts +1 -1
  72. package/dist/db/migrations/053_agent_token_single_use.d.ts +1 -1
  73. package/dist/db/migrations/054_local_working_tree.d.ts +1 -1
  74. package/dist/db/migrations/055_agent_token_mandatory_labels.d.ts +1 -1
  75. package/dist/db/migrations/056_execution_jobs_environments.d.ts +1 -1
  76. package/dist/db/migrations/064_execution_jobs_skipped_environments.d.ts +1 -1
  77. package/dist/db/types.d.ts +12 -13
  78. package/dist/events/types.d.ts +1 -1
  79. package/dist/fault-injection-types.d.ts +2 -2
  80. package/dist/firecracker/host-network.d.ts +21 -3
  81. package/dist/firecracker/net-interfaces.d.ts +1 -1
  82. package/dist/helpers/run-detached.d.ts +12 -0
  83. package/dist/index.js +50 -14
  84. package/dist/oidc/db-signer.d.ts +1 -1
  85. package/dist/pipeline/global-eval-round.d.ts +2 -2
  86. package/dist/pipeline/processor.d.ts +2 -2
  87. package/dist/provenance/retrier-mint.d.ts +1 -1
  88. package/dist/provenance/trust-root.d.ts +3 -3
  89. package/dist/providers/universal-git/clone-token.d.ts +9 -13
  90. package/dist/providers/universal-git/index.d.ts +1 -2
  91. package/dist/providers/universal-git/normalizer.d.ts +2 -3
  92. package/dist/providers/universal-git/ssh-auth.d.ts +4 -4
  93. package/dist/queue/cleanup.d.ts +2 -2
  94. package/dist/queue/job-queue.d.ts +6 -6
  95. package/dist/queue/scheduled-job.d.ts +5 -7
  96. package/dist/registration/extractor.d.ts +1 -1
  97. package/dist/registration/registration-index.d.ts +1 -1
  98. package/dist/reporting/execution-tracker.d.ts +3 -3
  99. package/dist/routes/admin-access-log.d.ts +1 -1
  100. package/dist/routes/admin-auth.d.ts +2 -2
  101. package/dist/routes/admin-event-log.d.ts +2 -2
  102. package/dist/routes/admin-runs.d.ts +3 -3
  103. package/dist/routes/admin-scheduled-jobs.d.ts +2 -3
  104. package/dist/routes/health.d.ts +9 -0
  105. package/dist/scaler/container-backend.d.ts +1 -1
  106. package/dist/scaler/firecracker-backend.d.ts +15 -10
  107. package/dist/scaler/firecracker-extra-hosts.d.ts +20 -0
  108. package/dist/scaler/machine-ledger.d.ts +3 -4
  109. package/dist/scaler/scaler-state-store.d.ts +1 -1
  110. package/dist/scaler/types.d.ts +4 -1
  111. package/dist/secrets/audit-logger.d.ts +2 -2
  112. package/dist/security/reduced-privilege-note.d.ts +2 -3
  113. package/dist/security/trust-policy-store.d.ts +3 -3
  114. package/dist/server.d.ts +2 -1
  115. package/dist/server.js +2736 -2211
  116. package/dist/stale-detector/stale-run-detector.d.ts +1 -1
  117. package/dist/standalone.d.ts +1 -1
  118. package/dist/standalone.js +2105 -1629
  119. package/dist/storage/filesystem.d.ts +1 -1
  120. package/dist/storage/index.d.ts +1 -1
  121. package/dist/storage/sign-url.d.ts +2 -1
  122. package/dist/storage/types.d.ts +2 -2
  123. package/dist/ws/agent-handler.d.ts +34 -3
  124. package/dist/ws/agent-heartbeat.d.ts +2 -3
  125. package/dist/ws/dashboard-context-handler.d.ts +1 -1
  126. package/dist/ws/platform-client.d.ts +44 -32
  127. package/dist/ws/server-options.d.ts +1 -1
  128. package/dist/ws/test-relay-handlers.d.ts +1 -1
  129. package/installer-image-digests.json +3 -3
  130. package/package.json +4 -4
  131. package/sbom.spdx.json +53 -53
package/dist/cli.js CHANGED
@@ -6,7 +6,7 @@ import path, { dirname, join, resolve } from "node:path";
6
6
  import * as fs$1 from "node:fs";
7
7
  import fs, { chmodSync, closeSync, constants, createReadStream, createWriteStream, existsSync, mkdirSync, mkdtempSync, openSync, promises, readFileSync, realpathSync, rmSync, statSync, unwatchFile, watchFile, writeFileSync } from "node:fs";
8
8
  import { Command, Option } from "commander";
9
- import { AgentPlatform, BaseColdStore, ChunkLru, addLogsToArchive, chunkObjectKey, clearDispatchQueueDirect, computeChunkId, computeMigrationsHash, createContextTemplateDirect, createDb, createDbRole, createLogger, createPool, createReadOnlyDbUser, createS3Client, decodeChunk, decrypt, deleteContextDirect, deriveKey, diagnoseExitCode, dropAndCreateDatabase, emitKiciEventDirect, encodeKeySegment, encrypt, ensureDatabase, formatBytes, formatUptime, isSchemaCurrent, kiciMkdtemp, listCheckRunTrackingDirect, listContextsDirect, listExecutionRunsDirect, listQueueDirect, listRegistrationsDirect, maskDatabaseUrl, parseDatabaseUrl, parseManifest, prunePeerCredentialsDirect, purgeContextsDirect, purgeScopedSecretsDirect, purgeSecretBackendsDirect, purgeStaleExecutionDirect, purgeStaleSourcesDirect, registerWorkflowManualDirect, resetRaftStateDirect, seedContextBindingDirect, seedContextDirect, serializeError, setContextPolicyDirect, setContextSecretDirect, sha256, showContextDirect, showExecutionRunDirect, showQueueEntryDirect, showRegistrationDirect, splitAgentPlatform, storeMigrationContentHash, tablePrefix, toErrorMessage } from "@kici-dev/shared";
9
+ import { AgentPlatform, BaseColdStore, ChunkLru, ReadinessStatus, addLogsToArchive, chunkObjectKey, clearDispatchQueueDirect, computeChunkId, computeMigrationsHash, createContextTemplateDirect, createDb, createDbRole, createLogger, createPool, createReadOnlyDbUser, createS3Client, decodeChunk, decrypt, deleteContextDirect, deriveKey, diagnoseExitCode, dropAndCreateDatabase, emitKiciEventDirect, encodeKeySegment, encrypt, ensureDatabase, formatBytes, formatUptime, isSchemaCurrent, kiciMkdtemp, listCheckRunTrackingDirect, listContextsDirect, listExecutionRunsDirect, listQueueDirect, listRegistrationsDirect, maskDatabaseUrl, parseDatabaseUrl, parseManifest, prunePeerCredentialsDirect, purgeContextsDirect, purgeScopedSecretsDirect, purgeSecretBackendsDirect, purgeStaleExecutionDirect, purgeStaleSourcesDirect, registerWorkflowManualDirect, resetRaftStateDirect, seedContextBindingDirect, seedContextDirect, serializeError, setContextPolicyDirect, setContextSecretDirect, sha256, showContextDirect, showExecutionRunDirect, showQueueEntryDirect, showRegistrationDirect, splitAgentPlatform, storeMigrationContentHash, tablePrefix, toErrorMessage } from "@kici-dev/shared";
10
10
  import { AccessLogSource, ApprovalDecision, CiTrustLevel, ContextType, ExecutionJobStatus, ForkPolicy, HoldType, KICI_AGENT_ENV_PREFIX, KNOWN_ROLES, MIN_APPROVAL_EXPIRY_SECONDS, MinimumTrustSchema, OrchestratorMode, PLATFORM_CONNECTED_MODES, PRIVILEGED_ROOT_LABEL, RuntimeFact, SECONDS_PER_HOUR, ScalerBackendType, ScalerEventType, TERMINAL_JOB_STATES, TERMINAL_RUN_STATES, WS_MAX_PAYLOAD_BYTES, accessLogWarmSqlCase, agentLabelOf, assertValidSecretKey, attestationVerifyStatusSchema, canonicalizeLabel, canonicalizeLabels, canonicalizeMatcher, derivePlatformTaints, flattenActor, getAccessLogColdDays, getSecretAuditLogColdDays, matcherSatisfiedBy, minAccessLogWarmDays, minSecretAuditLogWarmDays, parseHostPropertyAssignments, platformToTaints, resolveHeldRunId, resolveRoleLabels, runtimeLabel, scalerAgentLabels, scalerPlatformSchema, secretAuditLogWarmSqlCase, shouldRecordAccess, shouldRecordSecretResolve } from "@kici-dev/engine";
11
11
  import crypto$1, { createDecipheriv, createHash, createHmac, createPublicKey, generateKeyPairSync, hkdfSync, randomBytes, randomUUID } from "node:crypto";
12
12
  import { createInterface } from "node:readline";
@@ -14,17 +14,18 @@ import { sql } from "kysely";
14
14
  import { access, chmod, constants as constants$1, copyFile, link, mkdir, open, readFile, readdir, readlink, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
15
15
  import { parse } from "yaml";
16
16
  import { z } from "zod";
17
+ import { AGENT_DEFAULT_PORT, LOGGER_ENV_VARS, ORCHESTRATOR_DEFAULT_PORT, defineEnv, validateUnknownKiciVars } from "@kici-dev/shared/env";
17
18
  import os, { hostname, platform, tmpdir, uptime, default as os$1 } from "node:os";
18
19
  import { getDatabaseCollationDrift, refreshDatabaseCollationVersion, reindexDatabaseConcurrently } from "@kici-dev/shared/db-collation";
19
20
  import { Migrator } from "kysely/migration";
20
21
  import { exec, execFile, execFileSync, execSync, spawn, spawnSync } from "node:child_process";
21
22
  import { promisify } from "node:util";
23
+ import { SERVICE_ENV_FILE_VAR } from "@kici-dev/shared/service-env-file";
22
24
  import "jsonpath-plus";
23
25
  import { makeTempDir, withTempDir } from "@kici-dev/shared/tmp";
24
26
  import { Octokit } from "@octokit/rest";
25
27
  import { createAppAuth } from "@octokit/auth-app";
26
28
  import http, { createServer } from "node:http";
27
- import { LOGGER_ENV_VARS, defineEnv, validateUnknownKiciVars } from "@kici-dev/shared/env";
28
29
  import { CopyObjectCommand, DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, PutObjectCommand } from "@aws-sdk/client-s3";
29
30
  import { Upload } from "@aws-sdk/lib-storage";
30
31
  import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
@@ -43,6 +44,7 @@ import { CLI_INDEPENDENT_ONLY_OPERATIONS, DASHBOARD_WRITE_OPERATIONS, DASHBOARD_
43
44
  import { CLUSTER_NAME_FORMAT_MESSAGE } from "@kici-dev/engine/protocol/cluster-name";
44
45
  import { BASELINE_CHAIN, JOB_NETWORK_SUBNET, METADATA_RANGE, RFC1918_RANGES, addHostIsolationRules, addIsolationRules, deleteForwardRules, ensureKiciTable, listForwardRules, listIsolationRules, parseHostAccess, parseRuleHandles, readForwardChain, removeIsolationRules, validateNftablesAvailability } from "@kici-dev/shared/net";
45
46
  import { ImagePullPolicy, ensureRuntimeVolume, injectedAgentCommand, pullImageIfMissing, runtimeInjectBind } from "@kici-dev/shared/container-runtime";
47
+ import { isIP } from "node:net";
46
48
  import { StringDecoder } from "node:string_decoder";
47
49
  import { PassThrough } from "node:stream";
48
50
  import Docker from "dockerode";
@@ -1025,13 +1027,13 @@ var PgSecretStore = class PgSecretStore {
1025
1027
  //#endregion
1026
1028
  //#region src/cold-store/load-access-log-range.ts
1027
1029
  /**
1028
- * Read-through helper for `access_log` (Orchestrator) — Phase D.
1030
+ * Read-through helper for `access_log` (Orchestrator).
1029
1031
  *
1030
1032
  * Unlike `audit_log` and `secret_audit_log`, the `access_log` callers
1031
1033
  * (dashboard "Data access" tab, admin HTTP route, `kici-admin
1032
1034
  * access-log list` CLI) all use the same cursor-based pagination
1033
- * already implemented by `AccessLogWriter.query()`. Phase D's design
1034
- * §7 row 11 classifies access_log as "paginated/transparent" — there
1035
+ * already implemented by `AccessLogWriter.query()`. access_log is
1036
+ * "paginated/transparent" — there
1035
1037
  * is NO `--include-archived` flag. The helper merges cold rows
1036
1038
  * automatically when pagination crosses the warm cutoff or the caller
1037
1039
  * supplies a `from` timestamp older than warm.
@@ -1211,9 +1213,9 @@ var init_load_access_log_range = __esmMin((() => {
1211
1213
  //#endregion
1212
1214
  //#region src/cold-store/load-secret-audit-log-range.ts
1213
1215
  /**
1214
- * Read-through helper for `secret_audit_log` (Orchestrator) — Phase D.
1216
+ * Read-through helper for `secret_audit_log` (Orchestrator).
1215
1217
  *
1216
- * Mirrors the Phase B/C reader pattern. Wraps `AuditLogger.query()`'s
1218
+ * Mirrors the execution-table reader pattern. Wraps `AuditLogger.query()`'s
1217
1219
  * existing offset/limit shape; cold-store rows are fetched only when
1218
1220
  * `includeArchived === true`.
1219
1221
  *
@@ -2306,7 +2308,7 @@ var init_schema = __esmMin((() => {
2306
2308
  instanceId: z.string().default(() => randomUUID()),
2307
2309
  mode: OrchestratorMode.default("platform"),
2308
2310
  databaseUrl: z.string().default(""),
2309
- port: z.coerce.number().default(4e3),
2311
+ port: z.coerce.number().default(ORCHESTRATOR_DEFAULT_PORT),
2310
2312
  basePath: z.string().default("/"),
2311
2313
  tlsCertPath: z.string().optional(),
2312
2314
  scalerConfigPath: z.string().optional(),
@@ -3743,12 +3745,10 @@ var _005_cold_store_chunk_counter_exports = /* @__PURE__ */ __exportAll({
3743
3745
  * `kici-admin cold-store list-chunks` CLI can report totals without
3744
3746
  * issuing S3 LIST calls (expensive + rate-limited).
3745
3747
  *
3746
- * Phase A creates the table empty; the cold-store archiver
3747
- * (Phase B+) updates it transactionally with every successful chunk
3748
+ * The table starts empty; the cold-store archiver
3749
+ * updates it transactionally with every successful chunk
3748
3750
  * write via `INSERT ... ON CONFLICT (db, table_name, tenant_id) DO
3749
3751
  * UPDATE SET chunk_count = chunk_count + EXCLUDED.chunk_count, ...`.
3750
- *
3751
- * sections 5 and 8.
3752
3752
  */
3753
3753
  async function up$146(db) {
3754
3754
  await sql`
@@ -3781,12 +3781,12 @@ var _006_runs_jobs_steps_archived_at_exports = /* @__PURE__ */ __exportAll({
3781
3781
  * `execution_runs` / `execution_jobs` / `execution_steps` cold-store
3782
3782
  * schema additions.
3783
3783
  *
3784
- * Phase C of the cold-storage archival system. See
3784
+ * Part of the cold-storage archival system.
3785
3785
  *
3786
3786
  * Each table gets `archived_at TIMESTAMPTZ NULL` and
3787
3787
  * `archive_object_key TEXT NULL` (set inside the archive tx before the
3788
- * DELETE; survivors carry NULL — same pattern as Phase B's migration
3789
- * 004 for `run_events`).
3788
+ * DELETE; survivors carry NULL — the same pattern `run_events` carries
3789
+ * in the Platform's schema).
3790
3790
  *
3791
3791
  * Tenant column denormalization: the orchestrator side partitions by
3792
3792
  * `routing_key`, but only `execution_runs` carries the column today.
@@ -3795,7 +3795,7 @@ var _006_runs_jobs_steps_archived_at_exports = /* @__PURE__ */ __exportAll({
3795
3795
  * denormalize `routing_key` onto both tables and backfill from the
3796
3796
  * existing run rows. Backfill is one-shot inside this migration. New
3797
3797
  * inserts must populate the column going forward (enforced at the
3798
- * application layer in the same phase).
3798
+ * application layer).
3799
3799
  *
3800
3800
  * The denormalized column is left NULLable for safety: if an insert
3801
3801
  * site is missed in the rollout, the row still lands in PG; the
@@ -3888,13 +3888,12 @@ var _007_audit_logs_archived_at_exports = /* @__PURE__ */ __exportAll({
3888
3888
  * `secret_audit_log` and `access_log` cold-store schema additions, plus
3889
3889
  * the removal of `access_log`'s `expires_at`-based hard delete.
3890
3890
  *
3891
- * Phase D of the cold-storage archival system. See
3891
+ * Part of the cold-storage archival system.
3892
3892
  *
3893
3893
  * Both tables get the same `archived_at TIMESTAMPTZ NULL` /
3894
- * `archive_object_key TEXT NULL` pair that Phase B added to `run_events`
3895
- * (`packages/platform/src/db/migrations/004_run_events_archived_at.ts`)
3896
- * and Phase C added to `execution_runs` / `execution_jobs` /
3897
- * `execution_steps` (`006_runs_jobs_steps_archived_at.ts`). Set inside
3894
+ * `archive_object_key TEXT NULL` pair that `run_events` carries
3895
+ * (in the Platform's schema) and that `execution_runs` / `execution_jobs` /
3896
+ * `execution_steps` got in `006_runs_jobs_steps_archived_at.ts`. Set inside
3898
3897
  * the archive transaction before the DELETE; survivors carry NULL.
3899
3898
  *
3900
3899
  * `secret_audit_log` gets a composite
@@ -3908,7 +3907,7 @@ var _007_audit_logs_archived_at_exports = /* @__PURE__ */ __exportAll({
3908
3907
  * (range scans use either direction).
3909
3908
  *
3910
3909
  * Drops `access_log.expires_at` column and `access_log_expires_idx`.
3911
- * Until Phase D, `expires_at` powered a 90-day hard-delete sweep in
3910
+ * Before this migration, `expires_at` powered a 90-day hard-delete sweep in
3912
3911
  * `packages/orchestrator/src/audit/access-log.ts:cleanup` (called from
3913
3912
  * `packages/orchestrator/src/queue/cleanup.ts:runCleanup` step 5). With
3914
3913
  * cold-store, `access_log` rows older than 30 days are archived rather
@@ -3916,11 +3915,9 @@ var _007_audit_logs_archived_at_exports = /* @__PURE__ */ __exportAll({
3916
3915
  * column would mean either (a) double-evicting rows or (b) silently
3917
3916
  * mismatching the new contract. Drop it.
3918
3917
  *
3919
- * Per the project's "no backward compatibility (pre-release)" rule,
3920
3918
  * `down()` recreates `expires_at` with a default of `now() + 90 days`
3921
3919
  * — best effort; rows inserted between `up()` and a hypothetical
3922
- * `down()` would not have meaningful retention bounds. Acceptable for
3923
- * staging.
3920
+ * `down()` would not have meaningful retention bounds.
3924
3921
  */
3925
3922
  async function up$144(db) {
3926
3923
  await sql`
@@ -3978,13 +3975,11 @@ var _008_event_log_archived_at_exports = /* @__PURE__ */ __exportAll({
3978
3975
  * `event_log` cold-store schema additions plus removal of the
3979
3976
  * `expires_at`-based 30-day hard delete.
3980
3977
  *
3981
- * Phase E of the cold-storage archival system. See
3982
- * §10
3983
- * Phase E.
3978
+ * Part of the cold-storage archival system.
3984
3979
  *
3985
3980
  * Adds the same `archived_at TIMESTAMPTZ NULL` /
3986
- * `archive_object_key TEXT NULL` pair Phase D added on this side
3987
- * (`007_audit_logs_archived_at.ts`). Set inside the archive
3981
+ * `archive_object_key TEXT NULL` pair
3982
+ * `007_audit_logs_archived_at.ts` added on this side. Set inside the archive
3988
3983
  * transaction before the DELETE; survivors carry NULL.
3989
3984
  *
3990
3985
  * Adds `idx_event_log_routing_received` on `(routing_key, received_at)`
@@ -3995,7 +3990,7 @@ var _008_event_log_archived_at_exports = /* @__PURE__ */ __exportAll({
3995
3990
  * archiver scan.
3996
3991
  *
3997
3992
  * Drops `event_log.expires_at` and the matching
3998
- * `event_log_expires_at_idx` index. Until Phase E, `expires_at`
3993
+ * `event_log_expires_at_idx` index. Before this migration, `expires_at`
3999
3994
  * powered the 30-day hard-delete sweep in
4000
3995
  * `packages/orchestrator/src/webhook/event-log.ts:cleanup`, called
4001
3996
  * from `packages/orchestrator/src/queue/cleanup.ts:runCleanup` step 4
@@ -4078,7 +4073,7 @@ var _010_cold_store_chunks_exports = /* @__PURE__ */ __exportAll({
4078
4073
  up: () => up$141
4079
4074
  });
4080
4075
  /**
4081
- * Cold-store chunk index — Phase 2 (cold-store purge).
4076
+ * Cold-store chunk index (cold-store purge).
4082
4077
  *
4083
4078
  * One row per archived chunk that the GC sweep can later purge from S3.
4084
4079
  * Inserted inside the same transaction as `markArchivedAndDelete` so a
@@ -4103,7 +4098,7 @@ var _010_cold_store_chunks_exports = /* @__PURE__ */ __exportAll({
4103
4098
  * uses this to issue `DeleteObject` directly without recomputing the
4104
4099
  * key from the prefix.
4105
4100
  *
4106
- * Pre-Phase-2 chunks (v1 manifest) are NOT in this table — they live
4101
+ * V1-manifest chunks are NOT in this table — they live
4107
4102
  * forever. Adapters that don't opt into per-bucket archival via
4108
4103
  * `coldTtlDays` don't insert here either.
4109
4104
  */
@@ -4219,13 +4214,11 @@ var _012_peer_credentials_active_uniq_exports = /* @__PURE__ */ __exportAll({
4219
4214
  * nothing and both `INSERT`s succeeded, leaving 2+ ACTIVE rows for the same
4220
4215
  * `instance_id`. The peer's local credential file then ended up holding a
4221
4216
  * hash that a later concurrent `save()` had revoked, causing an endless
4222
- * "Peer HMAC proof invalid" loop. See
4223
- * for the
4224
- * full incident write-up (DB rows + log evidence from real staging deploys).
4217
+ * "Peer HMAC proof invalid" loop.
4225
4218
  *
4226
4219
  * The `up()` first dedupes any pre-existing duplicates (revoke all but the
4227
4220
  * newest active row per `instance_id`) — required because earlier migrations
4228
- * predate the constraint and existing staging DBs already have the dupes.
4221
+ * predate the constraint and existing databases may already hold the dupes.
4229
4222
  *
4230
4223
  * `down()` only drops the index; it does NOT undo the dedupe (there's no
4231
4224
  * safe way to recreate revoked rows, and the dedupe is monotonic).
@@ -4563,8 +4556,8 @@ var _016_org_settings_allow_http_npm_exports = /* @__PURE__ */ __exportAll({
4563
4556
  * Operators flip this on a per-org basis to permit `http://` (non-HTTPS)
4564
4557
  * registry URLs in workflow `registries:` declarations. By default only
4565
4558
  * `https://` registries are accepted, with an automatic exemption for
4566
- * loopback / `*.local` / private link-local hosts so dev fixtures
4567
- * (Verdaccio at `http://verdaccio.local:4873`) keep working.
4559
+ * loopback / `*.local` / private link-local hosts so a local registry
4560
+ * (for example `http://registry.local:4873`) keeps working.
4568
4561
  *
4569
4562
  * Idempotent: a re-run on a DB that already has the column is a no-op.
4570
4563
  */
@@ -4609,8 +4602,7 @@ var _017_org_id_widen_exports = /* @__PURE__ */ __exportAll({
4609
4602
  * They are orphans by design — once Platform's migration runs, the
4610
4603
  * dashboard queries the orchestrator API with the new prefixed IDs
4611
4604
  * and the orchestrator's `WHERE org_id = $1` filter naturally hides
4612
- * the old rows. Staging E2E regenerates fresh rows during the next
4613
- * test run.
4605
+ * the old rows. New activity writes fresh rows under the prefixed IDs.
4614
4606
  *
4615
4607
  * Tables widened: `access_log`, `environment_bindings`,
4616
4608
  * `environment_source_overrides`, `environment_variables`,
@@ -4652,7 +4644,7 @@ var _018_org_id_prefix_backfill_exports = /* @__PURE__ */ __exportAll({
4652
4644
  * orphaned and regenerated on the next deploy. That works for
4653
4645
  * historical artefacts (execution_runs, event_log, access_log) but
4654
4646
  * NOT for tenant-state tables the orchestrator needs to recognise the
4655
- * staging org at routing time (`sources`, `generic_webhook_sources`,
4647
+ * tenant's org at routing time (`sources`, `generic_webhook_sources`,
4656
4648
  * `workflow_registrations`, `org_settings`) and the env-config tables
4657
4649
  * referenced from runtime requests (`environments`,
4658
4650
  * `environment_bindings`, `environment_source_overrides`,
@@ -5907,7 +5899,7 @@ var _045_host_properties_exports = /* @__PURE__ */ __exportAll({
5907
5899
  * the separate queryable host-vars dimension.
5908
5900
  *
5909
5901
  * Idempotent (`ADD COLUMN IF NOT EXISTS`): re-running on a DB that already has
5910
- * the column is a no-op. Staging data is preserved (additive column).
5902
+ * the column is a no-op. Existing data is preserved (additive column).
5911
5903
  */
5912
5904
  async function up$106(db) {
5913
5905
  await sql`ALTER TABLE public.host_roster
@@ -5933,7 +5925,7 @@ var _046_join_token_consumed_by_instance_exports = /* @__PURE__ */ __exportAll({
5933
5925
  * join token already in its env — no operator action, no cluster redeploy.
5934
5926
  *
5935
5927
  * Idempotent (`ADD COLUMN IF NOT EXISTS`): re-running on a DB that already has
5936
- * the column is a no-op. Staging data is preserved (additive nullable column).
5928
+ * the column is a no-op. Existing data is preserved (additive nullable column).
5937
5929
  */
5938
5930
  async function up$105(db) {
5939
5931
  await sql`ALTER TABLE public.join_tokens
@@ -5959,8 +5951,7 @@ var _047_needs_run_on_exports = /* @__PURE__ */ __exportAll({
5959
5951
  * - Drop `if_failed`.
5960
5952
  *
5961
5953
  * Idempotent: column-exists guarded so re-running on a DB that already has
5962
- * `run_on` (and no `if_failed`) is a no-op. Existing rows are preserved
5963
- * (staging data is not dropped).
5954
+ * `run_on` (and no `if_failed`) is a no-op. Existing rows are preserved.
5964
5955
  */
5965
5956
  const ALL_TERMINAL_JSON = JSON.stringify([
5966
5957
  "success",
@@ -6032,7 +6023,7 @@ var _048_host_reboot_pending_exports = /* @__PURE__ */ __exportAll({
6032
6023
  * (3) clears on the next reconnect (down-then-up), releasing the held job. NULL
6033
6024
  * = no reboot pending.
6034
6025
  *
6035
- * Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so staging data is
6026
+ * Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so existing data is
6036
6027
  * preserved.
6037
6028
  */
6038
6029
  async function up$103(db) {
@@ -6055,7 +6046,7 @@ var _049_held_runs_payload_exports = /* @__PURE__ */ __exportAll({
6055
6046
  * the dashboard approval queue and the CLI render the computed diff the
6056
6047
  * operator approves. NULL for every non-drift hold.
6057
6048
  *
6058
- * Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so staging data is
6049
+ * Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so existing data is
6059
6050
  * preserved.
6060
6051
  */
6061
6052
  async function up$102(db) {
@@ -6081,7 +6072,7 @@ var _050_sources_slug_exports = /* @__PURE__ */ __exportAll({
6081
6072
  * hasn't populated it yet (manual `--app-id` flow whose initial fetch failed,
6082
6073
  * or a row created before the rollout).
6083
6074
  *
6084
- * Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so staging data is
6075
+ * Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so existing data is
6085
6076
  * preserved.
6086
6077
  */
6087
6078
  async function up$101(db) {
@@ -6109,7 +6100,7 @@ var _051_binding_host_pattern_exports = /* @__PURE__ */ __exportAll({
6109
6100
  * distinct per-host selectors.
6110
6101
  *
6111
6102
  * Idempotent: re-running on a DB that already has the column / index is a no-op.
6112
- * Additive — staging data is preserved.
6103
+ * Additive — existing data is preserved.
6113
6104
  */
6114
6105
  async function up$100(db) {
6115
6106
  await sql`ALTER TABLE public.environment_bindings
@@ -6145,7 +6136,7 @@ var _052_host_reach_metadata_exports = /* @__PURE__ */ __exportAll({
6145
6136
  *
6146
6137
  * All nullable: a host with no reach metadata simply cannot be bootstrapped and
6147
6138
  * behaves exactly as before. Idempotent (`ADD COLUMN IF NOT EXISTS`); additive,
6148
- * so staging data is preserved.
6139
+ * so existing data is preserved.
6149
6140
  */
6150
6141
  async function up$99(db) {
6151
6142
  await sql`ALTER TABLE public.host_roster
@@ -6177,7 +6168,7 @@ var _053_agent_token_single_use_exports = /* @__PURE__ */ __exportAll({
6177
6168
  * default for every existing static / ephemeral token, which stay reusable
6178
6169
  * until expiry).
6179
6170
  *
6180
- * Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so staging data is
6171
+ * Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so existing data is
6181
6172
  * preserved.
6182
6173
  */
6183
6174
  async function up$98(db) {
@@ -6202,7 +6193,7 @@ var _054_local_working_tree_exports = /* @__PURE__ */ __exportAll({
6202
6193
  * lock file). The dashboard renders a "Local machine" badge for these runs
6203
6194
  * and avoids building an external repository link from the repo identifier.
6204
6195
  *
6205
- * Idempotent (`ADD COLUMN IF NOT EXISTS`); additive with a default, so staging
6196
+ * Idempotent (`ADD COLUMN IF NOT EXISTS`); additive with a default, so existing
6206
6197
  * data is preserved.
6207
6198
  */
6208
6199
  async function up$97(db) {
@@ -6230,7 +6221,7 @@ var _055_agent_token_mandatory_labels_exports = /* @__PURE__ */ __exportAll({
6230
6221
  * agent accepts any job its advertised labels match), so every existing token
6231
6222
  * stays unconfined until re-minted.
6232
6223
  *
6233
- * Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so staging data is
6224
+ * Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so existing data is
6234
6225
  * preserved.
6235
6226
  */
6236
6227
  async function up$96(db) {
@@ -6257,7 +6248,7 @@ var _056_execution_jobs_environments_exports = /* @__PURE__ */ __exportAll({
6257
6248
  * fully-resolved list when a deferred-init agent eval resolves dynamic
6258
6249
  * elements. NULL = the job binds no environment.
6259
6250
  *
6260
- * Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so staging data is
6251
+ * Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so existing data is
6261
6252
  * preserved.
6262
6253
  */
6263
6254
  async function up$95(db) {
@@ -6568,7 +6559,7 @@ var _064_execution_jobs_skipped_environments_exports = /* @__PURE__ */ __exportA
6568
6559
  * - `execution_jobs.env_warning text NULL` — the user-visible warning naming the
6569
6560
  * skipped environments, surfaced on the dashboard run view. NULL = no warning.
6570
6561
  *
6571
- * Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so staging data is
6562
+ * Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so existing data is
6572
6563
  * preserved.
6573
6564
  */
6574
6565
  async function up$87(db) {
@@ -10951,11 +10942,10 @@ var AccessLogWriter = class {
10951
10942
  }).execute();
10952
10943
  }
10953
10944
  /**
10954
- * Phase D: delegates to `loadAccessLogRange` so cold-store rows are
10945
+ * Delegates to `loadAccessLogRange` so cold-store rows are
10955
10946
  * merged transparently when pagination crosses the warm cutoff. The
10956
10947
  * caller-visible `{items, nextCursor}` shape is preserved. NO
10957
- * --include-archived flag — access_log is "paginated/transparent"
10958
- * per design §7 row 11.
10948
+ * --include-archived flag — access_log is "paginated/transparent".
10959
10949
  */
10960
10950
  async query(filter) {
10961
10951
  const limit = Math.max(1, Math.min(filter.limit ?? 50, 200));
@@ -11758,7 +11748,7 @@ function uninstallBackupTimer(platform, config, io = defaultTimerIo) {
11758
11748
  }
11759
11749
  //#endregion
11760
11750
  //#region src/cli/service/types.ts
11761
- var DEFAULT_RESTART_POLICY;
11751
+ var DEFAULT_RESTART_POLICY, SERVICE_TEXT;
11762
11752
  var init_types$1 = __esmMin((() => {
11763
11753
  DEFAULT_RESTART_POLICY = {
11764
11754
  enabled: true,
@@ -11771,6 +11761,16 @@ var init_types$1 = __esmMin((() => {
11771
11761
  maxRetries: 5,
11772
11762
  windowSeconds: 300
11773
11763
  };
11764
+ SERVICE_TEXT = {
11765
+ orchestrator: {
11766
+ displayName: "KiCI Orchestrator",
11767
+ description: "KiCI CI/CD workflow orchestrator service"
11768
+ },
11769
+ agent: {
11770
+ displayName: "KiCI Agent",
11771
+ description: "KiCI CI/CD workflow execution agent service"
11772
+ }
11773
+ };
11774
11774
  }));
11775
11775
  //#endregion
11776
11776
  //#region src/cli/service/privilege.ts
@@ -11847,6 +11847,148 @@ var init_privilege = __esmMin((() => {
11847
11847
  init_platform_detect();
11848
11848
  }));
11849
11849
  //#endregion
11850
+ //#region src/cli/service/windows-acl.ts
11851
+ /**
11852
+ * Restricting a Windows service's env file to the service account and to
11853
+ * Administrators.
11854
+ *
11855
+ * Node maps a file mode onto Windows by toggling the read-only attribute and
11856
+ * changes no ACL, so ENV_FILE_MODE protects nothing there. The instance folder
11857
+ * under C:\ProgramData\kici\ inherits read access for BUILTIN\Users from
11858
+ * C:\ProgramData. Its ACL is replaced with full control for LocalSystem (the
11859
+ * account the shawl service runs as) and Administrators, inherited by
11860
+ * everything created in it: the env file, the temporary file a secure write
11861
+ * stages it through, and the service logs are unreadable to other accounts
11862
+ * from the moment they exist.
11863
+ *
11864
+ * Every call passes `/L` and none recurses, so icacls acts on the folder and on
11865
+ * the env file themselves and never on what a link there points at. A
11866
+ * recursive `/reset` or `/setowner` descends into the target of a junction or
11867
+ * of a symbolic link, even with `/L`, and rewrites the ACL of the files there.
11868
+ * The new folder ACL still reaches every entry that inherits from the folder.
11869
+ */
11870
+ /**
11871
+ * The icacls in the Windows system folder, which also holds the cmd.exe that
11872
+ * COMSPEC names. A bare `icacls` would also be looked up in the current
11873
+ * folder, and the install runs elevated.
11874
+ */
11875
+ function icaclsPath() {
11876
+ const comspec = process.env.COMSPEC;
11877
+ const systemDir = comspec && /(^|\\)cmd\.exe$/i.test(comspec) ? path.win32.dirname(comspec) : "C:\\Windows\\System32";
11878
+ return path.win32.join(systemDir, "icacls.exe");
11879
+ }
11880
+ /**
11881
+ * The icacls arguments that remove every inherited ACE from `dir` and grant
11882
+ * full control, inherited by files and folders, to LocalSystem and
11883
+ * Administrators. SIDs rather than names, so a localized Windows resolves them.
11884
+ */
11885
+ function restrictDirArgs(dir) {
11886
+ return [
11887
+ dir,
11888
+ "/inheritance:r",
11889
+ "/grant:r",
11890
+ `*${LOCAL_SYSTEM_SID}:(OI)(CI)F`,
11891
+ `*${ADMINISTRATORS_SID}:(OI)(CI)F`,
11892
+ "/L",
11893
+ "/Q"
11894
+ ];
11895
+ }
11896
+ /** The icacls arguments that reset `file` to inherit only from its folder. */
11897
+ function resetFileArgs(file) {
11898
+ return [
11899
+ file,
11900
+ "/reset",
11901
+ "/L",
11902
+ "/Q"
11903
+ ];
11904
+ }
11905
+ /**
11906
+ * Restrict the folder that holds `envFilePath`, then reset an existing env
11907
+ * file to inherit only from it, which also drops any explicit ACE on the file.
11908
+ * Run it before the first secret is written into the folder.
11909
+ */
11910
+ function restrictEnvFileAccess(envFilePath, run = runIcacls, exists = fs.existsSync) {
11911
+ const dir = path.win32.dirname(envFilePath);
11912
+ try {
11913
+ run(restrictDirArgs(dir));
11914
+ if (exists(envFilePath)) run(resetFileArgs(envFilePath));
11915
+ } catch (err) {
11916
+ throw new Error(`could not restrict access to ${dir}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
11917
+ }
11918
+ }
11919
+ var LOCAL_SYSTEM_SID, ADMINISTRATORS_SID, runIcacls;
11920
+ var init_windows_acl = __esmMin((() => {
11921
+ LOCAL_SYSTEM_SID = "S-1-5-18";
11922
+ ADMINISTRATORS_SID = "S-1-5-32-544";
11923
+ runIcacls = (args) => {
11924
+ execFileSync(icaclsPath(), args, { stdio: "pipe" });
11925
+ };
11926
+ }));
11927
+ //#endregion
11928
+ //#region src/cli/service/windows-env-file.ts
11929
+ /**
11930
+ * Whether a Windows service launch runs a release that reads KICI_ENV_FILE.
11931
+ *
11932
+ * A Windows service is registered with the path of its env file in
11933
+ * KICI_ENV_FILE, never with the values in the file: any local account reads the
11934
+ * command line of a service with `sc.exe qc`. A release from before
11935
+ * KICI_ENV_FILE ignores the path and starts without its configuration, and the
11936
+ * only registration that would run it puts the values back on the command line.
11937
+ * So every command that registers a Windows service, or switches one to another
11938
+ * release, refuses such a release before it changes anything.
11939
+ */
11940
+ /** Whether `file` holds `marker`, or undefined when it cannot be read. */
11941
+ function fileHolds(file, marker) {
11942
+ let content;
11943
+ try {
11944
+ content = fs.readFileSync(file);
11945
+ } catch {
11946
+ return;
11947
+ }
11948
+ return content.includes(marker);
11949
+ }
11950
+ /**
11951
+ * The file that names a launch's release: the npm entry among its arguments,
11952
+ * or else the executable itself.
11953
+ */
11954
+ function launchedRelease(launch) {
11955
+ return launch.args?.find((arg) => PACKAGE_ENTRY.test(arg)) ?? launch.executablePath;
11956
+ }
11957
+ /**
11958
+ * Whether the process a Windows service launch starts reads KICI_ENV_FILE.
11959
+ *
11960
+ * A KiCI package runs as `<dir>\<name>.cmd` beside its bundle
11961
+ * `<dir>\lib\<name>.cjs`, which names KICI_ENV_FILE when the release reads it.
11962
+ * An npm install runs node with the `@kici-dev/<component>/dist/<entry>.js`
11963
+ * entry, which then imports the env-file loader. Any other launch, such as a
11964
+ * batch file with no KiCI bundle beside it or a custom executable, is taken to
11965
+ * read it.
11966
+ */
11967
+ function launchReadsEnvFile(launch) {
11968
+ const { executablePath } = launch;
11969
+ if (/\.(cmd|bat)$/i.test(executablePath)) {
11970
+ const { dir, name } = path.win32.parse(executablePath);
11971
+ const sep = executablePath.includes("\\") ? "\\" : "/";
11972
+ return fileHolds(`${dir}${sep}lib${sep}${name}.cjs`, BUNDLE_MARKER) ?? true;
11973
+ }
11974
+ const entry = launchedRelease(launch);
11975
+ return PACKAGE_ENTRY.test(entry) ? fileHolds(entry, ENTRY_MARKER) ?? true : true;
11976
+ }
11977
+ /**
11978
+ * Why a Windows service may not run `release`, a release from before
11979
+ * KICI_ENV_FILE, and what the operator can do instead.
11980
+ */
11981
+ function envFileRefusal(release, envFilePath) {
11982
+ return `${release} predates KICI_ENV_FILE, so a Windows service can run it only with the values of ${envFilePath} on its command line, where any local account can read them with sc.exe qc. Use a release that reads KICI_ENV_FILE. To run that release anyway, uninstall the service and install it with the kici-admin of that release.`;
11983
+ }
11984
+ var ENV_FILE_LOADER_EXPORT, BUNDLE_MARKER, ENTRY_MARKER, PACKAGE_ENTRY;
11985
+ var init_windows_env_file = __esmMin((() => {
11986
+ ENV_FILE_LOADER_EXPORT = "./load-service-env-file";
11987
+ BUNDLE_MARKER = SERVICE_ENV_FILE_VAR;
11988
+ ENTRY_MARKER = "@kici-dev/shared/load-service-env-file";
11989
+ PACKAGE_ENTRY = /[/\\]@kici-dev[/\\](?:orchestrator|agent)[/\\]dist[/\\](?:server|standalone)\.js$/;
11990
+ }));
11991
+ //#endregion
11850
11992
  //#region src/cli/service/instance/types.ts
11851
11993
  /** Narrow an arbitrary string to a Component. */
11852
11994
  function isComponent(value) {
@@ -11857,6 +11999,81 @@ var init_types = __esmMin((() => {
11857
11999
  COMPONENTS = ["orchestrator", "agent"];
11858
12000
  }));
11859
12001
  //#endregion
12002
+ //#region src/cli/service/image-digests.ts
12003
+ /**
12004
+ * Resolves the customer installer's image reference from the digest record
12005
+ * shipped with this package (installer-image-digests.json at the package root).
12006
+ *
12007
+ * Emits a manifest-list-digest-pinned ref so a `docker`/`podman pull` verifies
12008
+ * the image hash — a registry that serves substituted bits fails the pull. When
12009
+ * the record is missing (a dev tree that never ran a release) or lacks the
12010
+ * image, falls back to the moving `:latest` tag and warns.
12011
+ *
12012
+ * The record resolves relative to this module's directory and works for both
12013
+ * layouts: the source tree (`src/cli/service/` → the JSON is three dirs up at
12014
+ * the package root) and the single-file bundle (`dist/cli.js` → one dir up).
12015
+ */
12016
+ /**
12017
+ * Climb from a starting directory looking for installer-image-digests.json.
12018
+ * Works for the source layout (src/cli/service → 3 up) and the single-file
12019
+ * bundle layout (dist → 1 up).
12020
+ */
12021
+ function findRecordFile(startDir) {
12022
+ let dir = startDir;
12023
+ for (let i = 0; i < 6; i++) {
12024
+ const candidate = path.join(dir, RECORD_FILENAME);
12025
+ if (existsSync(candidate)) return candidate;
12026
+ const parent = path.dirname(dir);
12027
+ if (parent === dir) break;
12028
+ dir = parent;
12029
+ }
12030
+ return null;
12031
+ }
12032
+ /** The published image a component runs, whatever the service is named. */
12033
+ function installerImageName(component) {
12034
+ return `kici-${component}`;
12035
+ }
12036
+ /**
12037
+ * The recorded release and digest of an installer image, or null when the
12038
+ * record is missing, unreadable, or does not hold the image.
12039
+ */
12040
+ function recordedImage(name, opts) {
12041
+ const file = opts.filePath ?? findRecordFile(import.meta.dirname);
12042
+ if (!file || !existsSync(file)) return null;
12043
+ try {
12044
+ const rec = JSON.parse(readFileSync(file, "utf8"));
12045
+ const digest = rec.images?.[name];
12046
+ return digest && rec.version ? {
12047
+ version: rec.version,
12048
+ digest
12049
+ } : null;
12050
+ } catch {
12051
+ return null;
12052
+ }
12053
+ }
12054
+ /**
12055
+ * The release {@link resolveImageRef} pins `name` at, or null when it falls
12056
+ * back to the moving `:latest` tag.
12057
+ */
12058
+ function pinnedImageVersion(name, opts = {}) {
12059
+ return recordedImage(name, opts)?.version ?? null;
12060
+ }
12061
+ /**
12062
+ * Resolve `quay.io/kici-dev/<name>:<version>@sha256:<digest>` for an installer
12063
+ * image. Falls back to `:latest` (with a warning) when no recorded digest exists.
12064
+ */
12065
+ function resolveImageRef(name, opts = {}) {
12066
+ const pinned = recordedImage(name, opts);
12067
+ if (pinned) return `${QUAY_PREFIX}/${name}:${pinned.version}@${pinned.digest}`;
12068
+ console.warn(`[kici] no recorded manifest-list digest for ${name}; pinning the mutable :latest tag. Reinstall from a released kici-admin to get a digest-pinned image.`);
12069
+ return `${QUAY_PREFIX}/${name}:latest`;
12070
+ }
12071
+ var QUAY_PREFIX, RECORD_FILENAME;
12072
+ var init_image_digests = __esmMin((() => {
12073
+ QUAY_PREFIX = "quay.io/kici-dev";
12074
+ RECORD_FILENAME = "installer-image-digests.json";
12075
+ }));
12076
+ //#endregion
11860
12077
  //#region src/cli/service/instance/manifest.ts
11861
12078
  /**
11862
12079
  * Instance manifest — the single source of truth for a folder-anchored
@@ -11947,6 +12164,90 @@ function resolveVersionFromLaunchSpec(spec, component) {
11947
12164
  }
11948
12165
  return null;
11949
12166
  }
12167
+ /**
12168
+ * The version of the launcher a service definition starts, or null when the
12169
+ * launch command does not show one.
12170
+ *
12171
+ * An npm-installed entry script names its version in its package.json
12172
+ * ({@link resolveVersionFromLaunchSpec}). A launcher under the instance's
12173
+ * install base resolves into the `<component>-<version>` folder an archive
12174
+ * upgrade extracted: through the `<component>` symlink on Linux and macOS, or
12175
+ * directly where Windows registers it. A `--binary` anywhere else shows none.
12176
+ */
12177
+ function resolveLauncherVersion(spec, component, installBase) {
12178
+ const fromPackage = resolveVersionFromLaunchSpec(spec, component);
12179
+ if (fromPackage) return fromPackage;
12180
+ let base;
12181
+ try {
12182
+ base = fs.realpathSync(installBase);
12183
+ } catch {
12184
+ return null;
12185
+ }
12186
+ const prefix = `${component}-`;
12187
+ for (const token of [spec.execPath, ...spec.args]) {
12188
+ if (!path.isAbsolute(token)) continue;
12189
+ let real;
12190
+ try {
12191
+ real = fs.realpathSync(token);
12192
+ } catch {
12193
+ continue;
12194
+ }
12195
+ const [folder = "", ...rest] = path.relative(base, real).split(path.sep);
12196
+ if (rest.length > 0 && folder.startsWith(prefix) && folder.length > prefix.length) return folder.slice(prefix.length);
12197
+ }
12198
+ return null;
12199
+ }
12200
+ /**
12201
+ * Write the manifest `install` built into `instanceDir`.
12202
+ *
12203
+ * A first install writes `fresh` as it is. A re-install of the same instance
12204
+ * (the folder holds a readable manifest of this component and name) updates
12205
+ * that manifest: it keeps what the instance recorded over time — `createdAt`,
12206
+ * the `migrationHeads` the rollback schema guard reads, and any field a newer
12207
+ * CLI wrote — and takes every other field from `fresh`. Its `kiciVersion` is
12208
+ * the version of the launcher `launch` starts; when the launcher shows none,
12209
+ * the recorded version stands, because the CLI that ran `install` need not
12210
+ * be the version the service runs. A compose service runs the image its
12211
+ * compose file pins, whatever `launch` names, so a compose re-install records
12212
+ * the release of that image (`opts.digestRecordPath` overrides the digest
12213
+ * record it reads, for tests).
12214
+ *
12215
+ * A manifest that cannot be read, or that names another instance, is
12216
+ * replaced, and the result carries a warning.
12217
+ */
12218
+ function writeInstallManifest(instanceDir, fresh, launch, opts = {}) {
12219
+ let existing;
12220
+ try {
12221
+ existing = readManifest(instanceDir, fresh.component);
12222
+ } catch (err) {
12223
+ return {
12224
+ file: writeManifest(instanceDir, fresh),
12225
+ manifest: fresh,
12226
+ warning: `${err.message}. install replaced it, so the migration heads it recorded, if any, are lost.`
12227
+ };
12228
+ }
12229
+ if (!existing) return {
12230
+ file: writeManifest(instanceDir, fresh),
12231
+ manifest: fresh
12232
+ };
12233
+ if (existing.name !== fresh.name) return {
12234
+ file: writeManifest(instanceDir, fresh),
12235
+ manifest: fresh,
12236
+ warning: `${manifestPath(instanceDir, fresh.component)} described the instance "${existing.name}". It now describes "${fresh.name}".`
12237
+ };
12238
+ const launcherVersion = fresh.platform === "compose" ? pinnedImageVersion(installerImageName(fresh.component), { filePath: opts.digestRecordPath }) : resolveLauncherVersion(launch, fresh.component, fresh.installBase);
12239
+ const manifest = {
12240
+ ...existing,
12241
+ ...fresh,
12242
+ createdAt: existing.createdAt,
12243
+ kiciVersion: launcherVersion ?? existing.kiciVersion
12244
+ };
12245
+ if (existing.migrationHeads) manifest.migrationHeads = existing.migrationHeads;
12246
+ return {
12247
+ file: writeManifest(instanceDir, manifest),
12248
+ manifest
12249
+ };
12250
+ }
11950
12251
  function resolveNpmInstallTarget(spec, component, opts) {
11951
12252
  const entryRe = new RegExp(`[/\\\\]@kici-dev[/\\\\]${component}[/\\\\]dist[/\\\\](?:server|standalone)\\.js$`);
11952
12253
  const entry = [spec.execPath, ...spec.args].find((a) => entryRe.test(a));
@@ -11982,6 +12283,7 @@ function firstGlobalPackage(entry) {
11982
12283
  }
11983
12284
  var REQUIRED_FIELDS;
11984
12285
  var init_manifest = __esmMin((() => {
12286
+ init_image_digests();
11985
12287
  REQUIRED_FIELDS = [
11986
12288
  "component",
11987
12289
  "name",
@@ -12549,6 +12851,43 @@ var init_systemd = __esmMin((() => {
12549
12851
  };
12550
12852
  }));
12551
12853
  //#endregion
12854
+ //#region src/cli/service/shutdown-grace.ts
12855
+ /**
12856
+ * Seconds an init system must wait after SIGTERM before it SIGKILLs the service.
12857
+ *
12858
+ * The runtime defaults are shorter than either component's own graceful-shutdown
12859
+ * budget: Compose waits 10s, launchd's default ExitTimeOut is 5s, and shawl's
12860
+ * default `--stop-timeout` on Windows is 3s. A teardown that
12861
+ * takes longer is killed partway through: the orchestrator never broadcasts
12862
+ * `peer.leaving` and never closes its agent sockets, and the agent never
12863
+ * finishes draining. That window is reached exactly when it matters most — the
12864
+ * orchestrator's scaler stops each managed agent with its own grace, so an
12865
+ * orchestrator with agents in flight routinely needs more than 10s.
12866
+ *
12867
+ * Each value is its component's force-exit budget plus headroom, so the
12868
+ * process always reaches its own timer first and the runtime's kill stays the
12869
+ * last resort it is meant to be:
12870
+ * - orchestrator: 30s (`setupGracefulShutdown` default) plus up to 2s of log
12871
+ * file flush -> 45s
12872
+ * - agent: 10s plus a ~1s abort delay in its `onForceExit` -> 20s
12873
+ *
12874
+ * An unrecognised component gets the larger value: over-waiting costs a few
12875
+ * seconds on shutdown, under-waiting corrupts it.
12876
+ */
12877
+ function shutdownGraceSeconds(config) {
12878
+ return (config.component ?? (config.name === "kici-agent" ? "agent" : "orchestrator")) === "agent" ? 20 : 45;
12879
+ }
12880
+ /**
12881
+ * Seconds a `stop` waits for the init system to report the service stopped.
12882
+ *
12883
+ * The init system kills the process once its grace elapses, so a service still
12884
+ * running after the grace plus this margin is hung, not slow.
12885
+ */
12886
+ function stopWaitSeconds(config) {
12887
+ return shutdownGraceSeconds(config) + 15;
12888
+ }
12889
+ var init_shutdown_grace = __esmMin((() => {}));
12890
+ //#endregion
12552
12891
  //#region src/cli/service/launchd.ts
12553
12892
  /**
12554
12893
  * launchd service manager implementation.
@@ -12571,14 +12910,19 @@ function stripLabelPrefix(label) {
12571
12910
  return label;
12572
12911
  }
12573
12912
  /** Async sleep used to pace launchd bootout/bootstrap reconciliation. */
12574
- function sleep(ms) {
12913
+ function sleep$1(ms) {
12575
12914
  return new Promise((resolve) => setTimeout(resolve, ms));
12576
12915
  }
12577
- var LABEL_PREFIX, KNOWN_LABEL_PREFIXES, SYSTEM_LOG_DIR, LaunchdServiceManager;
12916
+ var LABEL_PREFIX, KNOWN_LABEL_PREFIXES, SYSTEM_LOG_DIR, KICI_COMPONENT_MARKER, PRINT_NO_SUCH_SERVICE, PRINT_NO_SUCH_DOMAIN, FAILED_BOOTOUT_SETTLE_MS, LaunchdServiceManager;
12578
12917
  var init_launchd = __esmMin((() => {
12918
+ init_shutdown_grace();
12579
12919
  LABEL_PREFIX = "dev.kici";
12580
12920
  KNOWN_LABEL_PREFIXES = ["dev.kici.", "com.kici."];
12581
12921
  SYSTEM_LOG_DIR = "/var/log/kici";
12922
+ KICI_COMPONENT_MARKER = /<key>KiCIComponent<\/key>\s*<string>(orchestrator|agent)<\/string>/;
12923
+ PRINT_NO_SUCH_SERVICE = 113;
12924
+ PRINT_NO_SUCH_DOMAIN = 112;
12925
+ FAILED_BOOTOUT_SETTLE_MS = 5e3;
12582
12926
  LaunchdServiceManager = class {
12583
12927
  platform = "launchd";
12584
12928
  /** Build the launchd label for a service. */
@@ -12590,11 +12934,51 @@ var init_launchd = __esmMin((() => {
12590
12934
  if (config.isUserLevel) return path.join(os.homedir(), "Library", "Logs", "kici");
12591
12935
  return SYSTEM_LOG_DIR;
12592
12936
  }
12593
- /** Resolve the plist file path based on service level. */
12594
- plistPath(config) {
12595
- const filename = `${this.label(config)}.plist`;
12596
- if (config.isUserLevel) return path.join(os.homedir(), "Library", "LaunchAgents", filename);
12597
- return path.join("/Library", "LaunchDaemons", filename);
12937
+ /** The directory launchd loads this service level's plists from. */
12938
+ plistDir(isUserLevel) {
12939
+ return isUserLevel ? path.join(os.homedir(), "Library", "LaunchAgents") : path.join("/Library", "LaunchDaemons");
12940
+ }
12941
+ /** The job `install` writes: `dev.kici.<name>`. */
12942
+ installJob(config) {
12943
+ const label = this.label(config);
12944
+ return {
12945
+ label,
12946
+ plistPath: path.join(this.plistDir(config.isUserLevel), `${label}.plist`)
12947
+ };
12948
+ }
12949
+ /**
12950
+ * An installed job for this name under another label: a `com.kici.<name>`
12951
+ * plist, or a plist named after the service itself — each one `list()`
12952
+ * reports as this instance. Only a plist that carries the KiCI component
12953
+ * marker counts, and the job is addressed by the Label the plist declares.
12954
+ */
12955
+ otherLabelJob(config) {
12956
+ const prefixes = [...KNOWN_LABEL_PREFIXES.filter((p) => p !== `${LABEL_PREFIX}.`), ""];
12957
+ for (const prefix of prefixes) {
12958
+ const stem = `${prefix}${config.name}`;
12959
+ if (stripLabelPrefix(stem) !== config.name) continue;
12960
+ const plistPath = path.join(this.plistDir(config.isUserLevel), `${stem}.plist`);
12961
+ if (!fs.existsSync(plistPath)) continue;
12962
+ const content = fs.readFileSync(plistPath, "utf-8");
12963
+ if (!KICI_COMPONENT_MARKER.test(content)) continue;
12964
+ const label = content.match(/<key>Label<\/key>\s*<string>([^<]+)<\/string>/);
12965
+ return {
12966
+ label: label ? this.unescapeXml(label[1]) : stem,
12967
+ plistPath
12968
+ };
12969
+ }
12970
+ return null;
12971
+ }
12972
+ /**
12973
+ * The job a lifecycle command acts on: the `dev.kici.<name>` plist when it
12974
+ * is installed, otherwise a job this name was installed under with another
12975
+ * label, so every instance `list()` reports can be stopped, started and
12976
+ * removed. With neither installed, the `dev.kici.<name>` job.
12977
+ */
12978
+ resolveJob(config) {
12979
+ const job = this.installJob(config);
12980
+ if (fs.existsSync(job.plistPath)) return job;
12981
+ return this.otherLabelJob(config) ?? job;
12598
12982
  }
12599
12983
  /** Parse a .env file into key-value pairs. */
12600
12984
  parseEnvFile(envFilePath) {
@@ -12650,11 +13034,18 @@ var init_launchd = __esmMin((() => {
12650
13034
  lines.push(` <string>${this.escapeXml(config.workingDirectory)}</string>`);
12651
13035
  lines.push(" <key>RunAtLoad</key>");
12652
13036
  lines.push(" <true/>");
12653
- lines.push(" <key>KeepAlive</key>");
12654
- lines.push(" <true/>");
12655
- if (config.restartPolicy.enabled && config.restartPolicy.delays.length > 0) {
12656
- lines.push(" <key>ThrottleInterval</key>");
12657
- lines.push(` <integer>${config.restartPolicy.delays[0]}</integer>`);
13037
+ lines.push(" <key>ExitTimeOut</key>");
13038
+ lines.push(` <integer>${shutdownGraceSeconds(config)}</integer>`);
13039
+ if (config.restartPolicy.enabled) {
13040
+ lines.push(" <key>KeepAlive</key>");
13041
+ lines.push(" <dict>");
13042
+ lines.push(" <key>SuccessfulExit</key>");
13043
+ lines.push(" <false/>");
13044
+ lines.push(" </dict>");
13045
+ if (config.restartPolicy.delays.length > 0) {
13046
+ lines.push(" <key>ThrottleInterval</key>");
13047
+ lines.push(` <integer>${config.restartPolicy.delays[0]}</integer>`);
13048
+ }
12658
13049
  }
12659
13050
  lines.push(" <key>StandardOutPath</key>");
12660
13051
  lines.push(` <string>${this.escapeXml(path.join(logDirectory, `${config.name}.out.log`))}</string>`);
@@ -12690,30 +13081,48 @@ var init_launchd = __esmMin((() => {
12690
13081
  * domain from the plist's filesystem location, but they fail silently on
12691
13082
  * headless macOS hosts where no GUI session exists (the `gui/<uid>` domain
12692
13083
  * is not available without a console login). The modern `bootstrap` /
12693
- * `bootout` / `kickstart` / `kill` verbs take the domain explicitly, so a
13084
+ * `bootout` / `kickstart` / `print` verbs take the domain explicitly, so a
12694
13085
  * system-level LaunchDaemon loads regardless of whether anyone is logged
12695
13086
  * in at the console — exactly what we need for headless deploy targets.
12696
13087
  */
12697
13088
  domain(config) {
12698
13089
  return config.isUserLevel ? `gui/${os.userInfo().uid}` : "system";
12699
13090
  }
12700
- /** `<domain>/<label>` — the target string for kickstart/kill/print/enable. */
12701
- domainTarget(config) {
12702
- return `${this.domain(config)}/${this.label(config)}`;
13091
+ /** `<domain>/<label>` — the target string for bootout/kickstart/print. */
13092
+ domainTarget(config, job) {
13093
+ return `${this.domain(config)}/${job.label}`;
12703
13094
  }
12704
- /** Is the service currently loaded in its target domain? */
12705
- isLoaded(config) {
13095
+ /**
13096
+ * `launchctl print <domain>/<label>`: the job's description while it is
13097
+ * loaded, or null when launchd reports it not loaded. Unlike `launchctl
13098
+ * list`, which lists only the caller's own domain, this reads a system
13099
+ * daemon without root. Any other failure throws, so a job that could not be
13100
+ * inspected is never mistaken for a stopped one.
13101
+ */
13102
+ printJob(config, job) {
12706
13103
  try {
12707
- execFileSync("launchctl", ["print", this.domainTarget(config)], { stdio: "ignore" });
12708
- return true;
12709
- } catch {
12710
- return false;
13104
+ return execFileSync("launchctl", ["print", this.domainTarget(config, job)], {
13105
+ encoding: "utf-8",
13106
+ stdio: [
13107
+ "ignore",
13108
+ "pipe",
13109
+ "pipe"
13110
+ ]
13111
+ });
13112
+ } catch (err) {
13113
+ const status = err.status;
13114
+ if (status === PRINT_NO_SUCH_SERVICE || status === PRINT_NO_SUCH_DOMAIN) return null;
13115
+ throw err;
12711
13116
  }
12712
13117
  }
13118
+ /** Is the job currently loaded in its target domain? */
13119
+ isLoaded(config, job) {
13120
+ return this.printJob(config, job) !== null;
13121
+ }
12713
13122
  async install(config) {
12714
- const plistFile = this.plistPath(config);
13123
+ const job = this.installJob(config);
12715
13124
  const plistContent = this.generatePlist(config);
12716
- fs.mkdirSync(path.dirname(plistFile), { recursive: true });
13125
+ fs.mkdirSync(path.dirname(job.plistPath), { recursive: true });
12717
13126
  const logDirectory = this.logDir(config);
12718
13127
  fs.mkdirSync(logDirectory, { recursive: true });
12719
13128
  if (!config.isUserLevel && config.user) execFileSync("chown", [
@@ -12721,44 +13130,51 @@ var init_launchd = __esmMin((() => {
12721
13130
  `${config.user}:staff`,
12722
13131
  logDirectory
12723
13132
  ], { stdio: "inherit" });
12724
- fs.writeFileSync(plistFile, plistContent, "utf-8");
12725
- if (this.isLoaded(config)) {
12726
- try {
12727
- execFileSync("launchctl", ["bootout", this.domainTarget(config)], { stdio: "inherit" });
12728
- } catch {}
12729
- await this.waitUntilUnloaded(config);
13133
+ const otherJob = this.otherLabelJob(config);
13134
+ if (otherJob) {
13135
+ await this.unload(config, otherJob);
13136
+ fs.unlinkSync(otherJob.plistPath);
12730
13137
  }
12731
- await this.bootstrapWithRetry(config, plistFile);
13138
+ fs.writeFileSync(job.plistPath, plistContent, "utf-8");
13139
+ await this.unload(config, job);
13140
+ await this.bootstrapWithRetry(config, job, { replaceLoaded: true });
12732
13141
  }
12733
13142
  /**
12734
- * Poll until the service is no longer loaded in its target domain, or a
12735
- * short deadline elapses. `launchctl bootout` is asynchronous — it returns
12736
- * before launchd has finished releasing the service — so a bootstrap issued
13143
+ * Poll until the job is no longer loaded in its target domain, or the
13144
+ * deadline elapses. Resolves true once the job has left the domain,
13145
+ * false on timeout. `launchctl bootout` is asynchronous — it returns before
13146
+ * launchd has finished releasing the job — so a bootstrap issued
12737
13147
  * immediately afterward races the teardown and fails with EIO. Waiting for
12738
13148
  * the unload to complete closes that race for the common case; the residual
12739
13149
  * window is covered by bootstrapWithRetry.
12740
13150
  */
12741
- async waitUntilUnloaded(config) {
12742
- const deadline = Date.now() + 15e3;
12743
- while (this.isLoaded(config)) {
12744
- if (Date.now() >= deadline) return;
12745
- await sleep(500);
13151
+ async waitUntilUnloaded(config, job, timeoutMs) {
13152
+ const deadline = Date.now() + timeoutMs;
13153
+ while (this.isLoaded(config, job)) {
13154
+ if (Date.now() >= deadline) return false;
13155
+ await sleep$1(500);
12746
13156
  }
13157
+ return true;
12747
13158
  }
12748
13159
  /**
12749
13160
  * Bootstrap into the target domain, retrying on the transient EIO
12750
13161
  * ("5: Input/output error") launchd returns when a just-removed service has
12751
13162
  * not finished tearing down. A genuine, non-transient failure (bad plist,
12752
- * permission denied) is re-thrown on the first attempt. If a stale instance
12753
- * reappears between attempts, it is booted out before the next try.
13163
+ * permission denied) is re-thrown on the first attempt.
13164
+ *
13165
+ * launchd answers a bootstrap of a job that is already loaded with the same
13166
+ * EIO. With `replaceLoaded` (install, which just wrote a new plist) such a
13167
+ * job is booted out before the next try. Without it (start), a job that
13168
+ * another caller loaded meanwhile is the outcome start wanted: RunAtLoad has
13169
+ * started it, so the call succeeds and leaves it running.
12754
13170
  */
12755
- async bootstrapWithRetry(config, plistFile) {
13171
+ async bootstrapWithRetry(config, job, opts) {
12756
13172
  const attempts = 5;
12757
13173
  for (let attempt = 1; attempt <= attempts; attempt++) try {
12758
13174
  execFileSync("launchctl", [
12759
13175
  "bootstrap",
12760
13176
  this.domain(config),
12761
- plistFile
13177
+ job.plistPath
12762
13178
  ], { stdio: [
12763
13179
  "inherit",
12764
13180
  "inherit",
@@ -12766,66 +13182,92 @@ var init_launchd = __esmMin((() => {
12766
13182
  ] });
12767
13183
  return;
12768
13184
  } catch (err) {
13185
+ if (!opts.replaceLoaded && this.isLoaded(config, job)) return;
12769
13186
  const e = err;
12770
13187
  const stderr = (e.stderr ?? "").toString();
12771
13188
  if (!(e.status === 5 || /input\/output error|resource busy/i.test(stderr)) || attempt === attempts) {
12772
13189
  if (stderr) process.stderr.write(stderr);
12773
13190
  throw err;
12774
13191
  }
12775
- if (this.isLoaded(config)) try {
12776
- execFileSync("launchctl", ["bootout", this.domainTarget(config)], { stdio: "inherit" });
13192
+ if (opts.replaceLoaded && this.isLoaded(config, job)) try {
13193
+ execFileSync("launchctl", ["bootout", this.domainTarget(config, job)], { stdio: "inherit" });
12777
13194
  } catch {}
12778
- await sleep(1e3 * attempt);
13195
+ await sleep$1(1e3 * attempt);
12779
13196
  }
12780
13197
  }
12781
13198
  async uninstall(config) {
12782
- const plistFile = this.plistPath(config);
13199
+ const job = this.resolveJob(config);
12783
13200
  try {
12784
- execFileSync("launchctl", ["bootout", this.domainTarget(config)], { stdio: "inherit" });
13201
+ execFileSync("launchctl", ["bootout", this.domainTarget(config, job)], { stdio: [
13202
+ "inherit",
13203
+ "inherit",
13204
+ "pipe"
13205
+ ] });
12785
13206
  } catch {}
12786
13207
  try {
12787
- fs.unlinkSync(plistFile);
13208
+ fs.unlinkSync(job.plistPath);
12788
13209
  } catch {}
12789
13210
  }
12790
13211
  async start(config) {
12791
- execFileSync("launchctl", [
12792
- "kickstart",
12793
- "-k",
12794
- this.domainTarget(config)
12795
- ], { stdio: "inherit" });
13212
+ const job = this.resolveJob(config);
13213
+ if (!this.isLoaded(config, job)) {
13214
+ await this.bootstrapWithRetry(config, job, { replaceLoaded: false });
13215
+ return;
13216
+ }
13217
+ execFileSync("launchctl", ["kickstart", this.domainTarget(config, job)], { stdio: "inherit" });
12796
13218
  }
12797
13219
  async stop(config) {
12798
- execFileSync("launchctl", [
12799
- "kill",
12800
- "TERM",
12801
- this.domainTarget(config)
12802
- ], { stdio: "inherit" });
13220
+ const job = this.resolveJob(config);
13221
+ if (!this.isLoaded(config, job) && !fs.existsSync(job.plistPath)) throw new Error(`launchd job ${job.label} is not installed: no job is loaded and ${job.plistPath} does not exist.`);
13222
+ await this.unload(config, job);
13223
+ }
13224
+ /**
13225
+ * Boot the job out of its domain and wait until launchd has finished the
13226
+ * teardown. Does nothing when no job is loaded.
13227
+ */
13228
+ async unload(config, job) {
13229
+ if (!this.isLoaded(config, job)) return;
13230
+ let bootoutError;
13231
+ try {
13232
+ execFileSync("launchctl", ["bootout", this.domainTarget(config, job)], { stdio: [
13233
+ "inherit",
13234
+ "inherit",
13235
+ "pipe"
13236
+ ] });
13237
+ } catch (err) {
13238
+ bootoutError = err;
13239
+ }
13240
+ if (bootoutError !== void 0) {
13241
+ if (await this.waitUntilUnloaded(config, job, FAILED_BOOTOUT_SETTLE_MS)) return;
13242
+ const stderr = String(bootoutError.stderr ?? "");
13243
+ if (stderr) process.stderr.write(stderr);
13244
+ throw bootoutError;
13245
+ }
13246
+ const timeoutSeconds = stopWaitSeconds(config);
13247
+ if (!await this.waitUntilUnloaded(config, job, timeoutSeconds * 1e3)) throw new Error(`launchd job ${job.label} is still loaded ${timeoutSeconds}s after launchctl bootout. Its process may be hung — inspect it with \`launchctl print ${this.domainTarget(config, job)}\`.`);
12803
13248
  }
12804
13249
  async restart(config) {
12805
13250
  await this.stop(config);
12806
13251
  await this.start(config);
12807
13252
  }
12808
13253
  async status(config) {
12809
- const label = this.label(config);
13254
+ const job = this.resolveJob(config);
13255
+ let description;
12810
13256
  try {
12811
- const output = execFileSync("launchctl", ["list"], { encoding: "utf-8" });
12812
- for (const line of output.split("\n")) {
12813
- if (!line.includes(label)) continue;
12814
- const parts = line.trim().split(" ");
12815
- if (parts.length >= 3) {
12816
- const pidStr = parts[0];
12817
- const exitCode = parseInt(parts[1], 10);
12818
- const pid = pidStr !== "-" ? parseInt(pidStr, 10) : void 0;
12819
- if (pid && pid > 0) return {
12820
- state: "running",
12821
- pid
12822
- };
12823
- if (exitCode !== 0) return { state: "failed" };
12824
- return { state: "stopped" };
12825
- }
12826
- }
12827
- } catch {}
12828
- return { state: "unknown" };
13257
+ description = this.printJob(config, job);
13258
+ } catch {
13259
+ return { state: "unknown" };
13260
+ }
13261
+ if (description === null) return fs.existsSync(job.plistPath) ? { state: "stopped" } : { state: "unknown" };
13262
+ const pid = description.match(/^\tpid = (\d+)$/m);
13263
+ if (pid) return {
13264
+ state: "running",
13265
+ pid: parseInt(pid[1], 10)
13266
+ };
13267
+ const lastExit = description.match(/^\tlast exit code = (-?\d+)/m);
13268
+ if (lastExit && parseInt(lastExit[1], 10) !== 0) return { state: "failed" };
13269
+ if (/^\tlast terminating signal = /m.test(description)) return { state: "failed" };
13270
+ return { state: "stopped" };
12829
13271
  }
12830
13272
  async logs(config, options) {
12831
13273
  const logDirectory = this.logDir(config);
@@ -12843,10 +13285,10 @@ var init_launchd = __esmMin((() => {
12843
13285
  });
12844
13286
  }
12845
13287
  async isInstalled(config) {
12846
- return fs.existsSync(this.plistPath(config));
13288
+ return fs.existsSync(this.resolveJob(config).plistPath);
12847
13289
  }
12848
13290
  async list(isUserLevel) {
12849
- const baseDir = isUserLevel ? path.join(os.homedir(), "Library", "LaunchAgents") : "/Library/LaunchDaemons";
13291
+ const baseDir = this.plistDir(isUserLevel);
12850
13292
  if (!fs.existsSync(baseDir)) return [];
12851
13293
  const out = [];
12852
13294
  for (const entry of fs.readdirSync(baseDir)) {
@@ -12859,7 +13301,7 @@ var init_launchd = __esmMin((() => {
12859
13301
  if (err?.code === "ENOENT") continue;
12860
13302
  throw new Error(`could not read the launchd job at ${path.join(baseDir, entry)}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
12861
13303
  }
12862
- const match = content.match(/<key>KiCIComponent<\/key>\s*<string>(orchestrator|agent)<\/string>/);
13304
+ const match = content.match(KICI_COMPONENT_MARKER);
12863
13305
  if (!match) continue;
12864
13306
  const dirMatch = content.match(/<key>KiCIInstanceDir<\/key>\s*<string>([^<]+)<\/string>/);
12865
13307
  out.push({
@@ -12875,7 +13317,7 @@ var init_launchd = __esmMin((() => {
12875
13317
  async readLaunchSpec(config) {
12876
13318
  let content;
12877
13319
  try {
12878
- content = fs.readFileSync(this.plistPath(config), "utf-8");
13320
+ content = fs.readFileSync(this.resolveJob(config).plistPath, "utf-8");
12879
13321
  } catch {
12880
13322
  return null;
12881
13323
  }
@@ -13144,12 +13586,75 @@ function parseStateCode(output) {
13144
13586
  const match = output.match(/STATE\s*:\s*(\d+)/);
13145
13587
  return match ? parseInt(match[1], 10) : void 0;
13146
13588
  }
13147
- var SERVICE_REGISTRY_PROBE_TIMEOUT_MS, WindowsServiceManager;
13589
+ /** Async sleep used to pace the `sc.exe query` polls. */
13590
+ function sleep(ms) {
13591
+ return new Promise((resolve) => setTimeout(resolve, ms));
13592
+ }
13593
+ /** A `.cmd` or `.bat` file, which Windows runs through cmd.exe. */
13594
+ function isBatchFile(executablePath) {
13595
+ return /\.(cmd|bat)$/i.test(executablePath);
13596
+ }
13597
+ /**
13598
+ * The cmd.exe that COMSPEC names, as Node itself uses to run a shell on Windows.
13599
+ * A COMSPEC that names another shell is not used: the wrapper arguments are
13600
+ * cmd.exe's.
13601
+ */
13602
+ function cmdExePath() {
13603
+ const comspec = process.env.COMSPEC;
13604
+ return comspec && /(^|\\)cmd\.exe$/i.test(comspec) ? comspec : "C:\\Windows\\System32\\cmd.exe";
13605
+ }
13606
+ /**
13607
+ * Throw when cmd.exe would read part of `token` as syntax.
13608
+ *
13609
+ * shawl quotes a token on cmd.exe's command line only when it contains a space
13610
+ * or a tab. Inside quotes cmd.exe reads `&`, `|`, `<`, `>`, `(`, `)` and the
13611
+ * `,` `;` `=` delimiters as text; outside quotes it reads them as syntax. `%`
13612
+ * and `^` change even inside quotes (`call` expands `%` again and doubles `^`),
13613
+ * and a `"` ends the quoting.
13614
+ */
13615
+ function assertCmdSafe(token) {
13616
+ const found = (/[ \t]/.test(token) ? /["%^]/ : /["%^&|<>(),;=]/).exec(token);
13617
+ if (found) throw new Error(`cannot run "${token}" as a Windows service: it contains "${found[0]}", which cmd.exe reads as syntax when it runs the batch file. Install from a path without it.`);
13618
+ }
13619
+ /**
13620
+ * The command shawl registers for the service: the executable and its
13621
+ * arguments, with a batch file run through cmd.exe ({@link BATCH_WRAPPER_ARGS}).
13622
+ */
13623
+ function serviceCommand(config) {
13624
+ const command = [config.executablePath, ...config.args ?? []];
13625
+ if (!isBatchFile(config.executablePath)) return command;
13626
+ command.forEach(assertCmdSafe);
13627
+ return [
13628
+ cmdExePath(),
13629
+ ...BATCH_WRAPPER_ARGS,
13630
+ ...command,
13631
+ BATCH_STDIN
13632
+ ];
13633
+ }
13634
+ /** The launch command inside a {@link serviceCommand}: a batch file's cmd.exe wrapper removed. */
13635
+ function unwrapServiceCommand(tokens) {
13636
+ const [exe, ...rest] = tokens;
13637
+ return exe !== void 0 && /(^|\\)cmd\.exe$/i.test(exe) && BATCH_WRAPPER_ARGS.every((arg, i) => rest[i]?.toLowerCase() === arg) && rest.at(-1) === BATCH_STDIN && rest.length > BATCH_WRAPPER_ARGS.length + 1 ? rest.slice(BATCH_WRAPPER_ARGS.length, -1) : tokens;
13638
+ }
13639
+ var SERVICE_REGISTRY_PROBE_TIMEOUT_MS, ERROR_SERVICE_NOT_ACTIVE, ERROR_SERVICE_CANNOT_ACCEPT_CTRL, BATCH_WRAPPER_ARGS, BATCH_STDIN, WindowsServiceManager;
13148
13640
  var init_windows = __esmMin((() => {
13149
13641
  init_downloader();
13150
13642
  init_registry();
13151
13643
  init_platform_detect();
13644
+ init_shutdown_grace();
13645
+ init_windows_acl();
13646
+ init_windows_env_file();
13152
13647
  SERVICE_REGISTRY_PROBE_TIMEOUT_MS = 1e4;
13648
+ ERROR_SERVICE_NOT_ACTIVE = 1062;
13649
+ ERROR_SERVICE_CANNOT_ACCEPT_CTRL = 1061;
13650
+ BATCH_WRAPPER_ARGS = [
13651
+ "/d",
13652
+ "/e:on",
13653
+ "/v:off",
13654
+ "/c",
13655
+ "call"
13656
+ ];
13657
+ BATCH_STDIN = "<NUL";
13153
13658
  WindowsServiceManager = class {
13154
13659
  platform = "windows";
13155
13660
  /**
@@ -13191,13 +13696,24 @@ var init_windows = __esmMin((() => {
13191
13696
  return false;
13192
13697
  }
13193
13698
  }
13699
+ /** The service's STATE code from `sc.exe query`, or undefined when the query fails. */
13700
+ queryStateCode(name) {
13701
+ try {
13702
+ return parseStateCode(execSync(`sc.exe query ${name}`, { stdio: "pipe" }).toString());
13703
+ } catch {
13704
+ return;
13705
+ }
13706
+ }
13194
13707
  async install(config) {
13708
+ const command = serviceCommand(config);
13709
+ if (!launchReadsEnvFile(config)) throw new Error(`cannot register ${config.name}: ` + envFileRefusal(launchedRelease(config), config.envFilePath));
13710
+ const envDir = path.dirname(config.envFilePath);
13711
+ fs.mkdirSync(envDir, { recursive: true });
13712
+ if (!config.isUserLevel) restrictEnvFileAccess(config.envFilePath);
13195
13713
  if (this.serviceExists(config.name)) await this.uninstall(config);
13196
13714
  const depMeta = getDepMetadata("shawl");
13197
13715
  const depPath = await ensureDep(depMeta, getCacheDir());
13198
13716
  const shawlExe = path.join(depPath, depMeta.extractPath);
13199
- const envDir = path.dirname(config.envFilePath);
13200
- fs.mkdirSync(envDir, { recursive: true });
13201
13717
  const cwd = config.workingDirectory.replace(/\\+$/, "");
13202
13718
  const logDir = path.join(envDir, "logs");
13203
13719
  fs.mkdirSync(logDir, { recursive: true });
@@ -13209,23 +13725,12 @@ var init_windows = __esmMin((() => {
13209
13725
  "--cwd",
13210
13726
  `"${cwd}"`,
13211
13727
  "--log-dir",
13212
- `"${logDir.replace(/\\+$/, "")}"`
13728
+ `"${logDir.replace(/\\+$/, "")}"`,
13729
+ "--stop-timeout",
13730
+ String(shutdownGraceSeconds(config) * 1e3)
13213
13731
  ];
13214
- if (fs.existsSync(config.envFilePath)) {
13215
- const envContent = fs.readFileSync(config.envFilePath, "utf-8");
13216
- for (const line of envContent.split("\n")) {
13217
- const trimmed = line.trim();
13218
- if (trimmed && !trimmed.startsWith("#") && trimmed.includes("=")) {
13219
- const eqIdx = trimmed.indexOf("=");
13220
- if (trimmed.slice(0, eqIdx) === "PATH") {
13221
- const dirs = trimmed.slice(eqIdx + 1).split(";").filter(Boolean);
13222
- for (const dir of dirs) cmdParts.push("--path-prepend", `"${dir.replace(/\\+$/, "")}"`);
13223
- } else cmdParts.push("--env", `"${trimmed}"`);
13224
- }
13225
- }
13226
- }
13227
- cmdParts.push("--", `"${config.executablePath}"`);
13228
- for (const arg of config.args ?? []) cmdParts.push(`"${arg}"`);
13732
+ cmdParts.push("--env", `"${SERVICE_ENV_FILE_VAR}=${config.envFilePath}"`);
13733
+ cmdParts.push("--", ...command.map((token) => `"${token}"`));
13229
13734
  const cmd = cmdParts.join(" ");
13230
13735
  execSync(cmd, { stdio: "pipe" });
13231
13736
  const dirSuffix = config.component && config.instanceDir ? ` [KiCI-DIR:${config.instanceDir}]` : "";
@@ -13237,60 +13742,55 @@ var init_windows = __esmMin((() => {
13237
13742
  execSync(`sc.exe failure ${config.name} reset= ${resetSeconds} actions= ${actions}`, { stdio: "pipe" });
13238
13743
  }
13239
13744
  async uninstall(config) {
13745
+ const waitSeconds = stopWaitSeconds(config);
13240
13746
  try {
13241
13747
  execSync(`sc.exe stop ${config.name}`, { stdio: "pipe" });
13242
- await new Promise((r) => setTimeout(r, 3e3));
13243
13748
  } catch {}
13749
+ const stopDeadline = Date.now() + waitSeconds * 1e3;
13750
+ while (Date.now() < stopDeadline) {
13751
+ const stateCode = this.queryStateCode(config.name);
13752
+ if (stateCode === 1 || stateCode === void 0) break;
13753
+ await sleep(500);
13754
+ }
13244
13755
  execSync(`sc.exe delete ${config.name}`, { stdio: "pipe" });
13245
- const deadline = Date.now() + 3e4;
13246
- while (Date.now() < deadline) try {
13247
- execSync(`sc.exe query ${config.name}`, { stdio: "pipe" });
13248
- await new Promise((r) => setTimeout(r, 1e3));
13249
- } catch {
13250
- return;
13756
+ const deadline = Date.now() + waitSeconds * 1e3;
13757
+ while (Date.now() < deadline) {
13758
+ if (!this.serviceExists(config.name)) return;
13759
+ await sleep(1e3);
13251
13760
  }
13252
- throw new Error(`Service ${config.name} still exists after 30s. The process may be hung — check Task Manager and kill manually if needed.`);
13761
+ throw new Error(`Service ${config.name} still exists ${waitSeconds}s after sc.exe delete. The process may be hung — check Task Manager and kill manually if needed.`);
13253
13762
  }
13254
13763
  async start(config) {
13255
- const deadline = Date.now() + 3e4;
13764
+ const deadline = Date.now() + stopWaitSeconds(config) * 1e3;
13256
13765
  while (Date.now() < deadline) {
13257
- let stateCode;
13258
- try {
13259
- stateCode = parseStateCode(execSync(`sc.exe query ${config.name}`, { stdio: "pipe" }).toString());
13260
- } catch {
13261
- stateCode = void 0;
13262
- }
13766
+ const stateCode = this.queryStateCode(config.name);
13263
13767
  if (stateCode === 4) return;
13264
13768
  if (stateCode === 1) break;
13265
- await new Promise((r) => setTimeout(r, 500));
13769
+ await sleep(500);
13266
13770
  }
13267
13771
  execSync(`sc.exe start ${config.name}`, { stdio: "pipe" });
13268
13772
  }
13269
13773
  async stop(config) {
13270
- execSync(`sc.exe stop ${config.name}`, { stdio: "pipe" });
13271
- const deadline = Date.now() + 3e4;
13272
- while (Date.now() < deadline) {
13273
- let stateCode;
13274
- try {
13275
- stateCode = parseStateCode(execSync(`sc.exe query ${config.name}`, { stdio: "pipe" }).toString());
13276
- } catch {
13277
- stateCode = void 0;
13278
- }
13279
- if (stateCode === 1) return;
13280
- await new Promise((r) => setTimeout(r, 500));
13281
- }
13282
- throw new Error(`Service ${config.name} did not reach STOPPED state within 30s after sc.exe stop. A subsequent start would fail with error 1056 — the service may be hung.`);
13283
- }
13284
- async restart(config) {
13285
13774
  try {
13286
13775
  execSync(`sc.exe stop ${config.name}`, { stdio: "pipe" });
13287
- } catch {}
13288
- const deadline = Date.now() + 3e4;
13776
+ } catch (err) {
13777
+ const status = err?.status;
13778
+ if (status !== ERROR_SERVICE_NOT_ACTIVE && status !== ERROR_SERVICE_CANNOT_ACCEPT_CTRL) throw err;
13779
+ const stateCode = this.queryStateCode(config.name);
13780
+ if (stateCode === 1) return;
13781
+ if (status !== ERROR_SERVICE_CANNOT_ACCEPT_CTRL || stateCode !== 3) throw err;
13782
+ }
13783
+ const waitSeconds = stopWaitSeconds(config);
13784
+ const deadline = Date.now() + waitSeconds * 1e3;
13289
13785
  while (Date.now() < deadline) {
13290
- if (parseStateCode(execSync(`sc.exe query ${config.name}`, { stdio: "pipe" }).toString()) === 1) break;
13291
- await new Promise((r) => setTimeout(r, 500));
13786
+ if (this.queryStateCode(config.name) === 1) return;
13787
+ await sleep(500);
13292
13788
  }
13293
- execSync(`sc.exe start ${config.name}`, { stdio: "pipe" });
13789
+ throw new Error(`Service ${config.name} did not reach STOPPED state within ${waitSeconds}s after sc.exe stop. A subsequent start would fail with error 1056 — the service may be hung.`);
13790
+ }
13791
+ async restart(config) {
13792
+ await this.stop(config);
13793
+ await this.start(config);
13294
13794
  }
13295
13795
  async status(config) {
13296
13796
  try {
@@ -13359,18 +13859,38 @@ var init_windows = __esmMin((() => {
13359
13859
  }
13360
13860
  return out;
13361
13861
  }
13362
- async readLaunchSpec(config) {
13363
- let raw;
13862
+ /**
13863
+ * The command line a service is registered with.
13864
+ *
13865
+ * `sc.exe qc` answers most registrations, but it fails (error 1734) once the
13866
+ * line is longer than about 4000 characters, as an older CLI's registration
13867
+ * with every env-file value on it can be. It also prints in the console code
13868
+ * page, so a path outside ASCII, such as one under the profile of a user
13869
+ * named Jürgen, decodes to U+FFFD. In either case the registry value it reads
13870
+ * (`ImagePath`) is read through PowerShell, printed as UTF-8.
13871
+ */
13872
+ registeredCommandLine(name) {
13364
13873
  try {
13365
- raw = execSync(`sc.exe qc ${config.name}`, { stdio: "pipe" }).toString();
13874
+ const line = execSync(`sc.exe qc ${name}`, { stdio: "pipe" }).toString().match(/BINARY_PATH_NAME\s*:\s*(.+)/)?.[1]?.trim();
13875
+ if (line && !line.includes("�")) return line;
13876
+ } catch {}
13877
+ const key = `HKLM:\\SYSTEM\\CurrentControlSet\\Services\\${name.replace(/'/g, "''")}`;
13878
+ try {
13879
+ return execSync(`powershell -NoProfile -NonInteractive -Command "[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding \$false; (Get-ItemProperty -LiteralPath '${key}').ImagePath"`, { stdio: "pipe" }).toString("utf-8").replace(/^\uFEFF/, "").trim() || null;
13366
13880
  } catch {
13367
13881
  return null;
13368
13882
  }
13369
- const m = raw.match(/BINARY_PATH_NAME\s*:\s*(.+)/);
13370
- if (!m) return null;
13371
- const sep = m[1].indexOf(" -- ");
13883
+ }
13884
+ async readLaunchSpec(config) {
13885
+ const line = this.registeredCommandLine(config.name);
13886
+ if (line === null) return null;
13887
+ const parts = [...line.matchAll(/"([^"]*)"|(\S+)/g)].map((t) => ({
13888
+ text: t[1] ?? t[2],
13889
+ quoted: t[1] !== void 0
13890
+ }));
13891
+ const sep = parts.findIndex((p) => !p.quoted && p.text === "--");
13372
13892
  if (sep === -1) return null;
13373
- const tokens = [...m[1].slice(sep + 4).matchAll(/"([^"]*)"|(\S+)/g)].map((t) => t[1] ?? t[2]);
13893
+ const tokens = unwrapServiceCommand(parts.slice(sep + 1).map((p) => p.text));
13374
13894
  if (tokens.length === 0 || !tokens[0]) return null;
13375
13895
  return {
13376
13896
  execPath: tokens[0],
@@ -13395,56 +13915,6 @@ function composeFilePath(envFilePath, serviceName) {
13395
13915
  }
13396
13916
  var init_compose_path = __esmMin((() => {}));
13397
13917
  //#endregion
13398
- //#region src/cli/service/image-digests.ts
13399
- /**
13400
- * Resolves the customer installer's image reference from the digest record
13401
- * shipped with this package (installer-image-digests.json at the package root).
13402
- *
13403
- * Emits a manifest-list-digest-pinned ref so a `docker`/`podman pull` verifies
13404
- * the image hash — a registry that serves substituted bits fails the pull. When
13405
- * the record is missing (a dev tree that never ran a release) or lacks the
13406
- * image, falls back to the moving `:latest` tag and warns.
13407
- *
13408
- * The record resolves relative to this module's directory and works for both
13409
- * layouts: the source tree (`src/cli/service/` → the JSON is three dirs up at
13410
- * the package root) and the single-file bundle (`dist/cli.js` → one dir up).
13411
- */
13412
- /**
13413
- * Climb from a starting directory looking for installer-image-digests.json.
13414
- * Works for the source layout (src/cli/service → 3 up) and the single-file
13415
- * bundle layout (dist → 1 up).
13416
- */
13417
- function findRecordFile(startDir) {
13418
- let dir = startDir;
13419
- for (let i = 0; i < 6; i++) {
13420
- const candidate = path.join(dir, RECORD_FILENAME);
13421
- if (existsSync(candidate)) return candidate;
13422
- const parent = path.dirname(dir);
13423
- if (parent === dir) break;
13424
- dir = parent;
13425
- }
13426
- return null;
13427
- }
13428
- /**
13429
- * Resolve `quay.io/kici-dev/<name>:<version>@sha256:<digest>` for an installer
13430
- * image. Falls back to `:latest` (with a warning) when no recorded digest exists.
13431
- */
13432
- function resolveImageRef(name, opts = {}) {
13433
- const file = opts.filePath ?? findRecordFile(import.meta.dirname);
13434
- if (file && existsSync(file)) try {
13435
- const rec = JSON.parse(readFileSync(file, "utf8"));
13436
- const digest = rec.images?.[name];
13437
- if (digest && rec.version) return `${QUAY_PREFIX}/${name}:${rec.version}@${digest}`;
13438
- } catch {}
13439
- console.warn(`[kici] no recorded manifest-list digest for ${name}; pinning the mutable :latest tag. Reinstall from a released kici-admin to get a digest-pinned image.`);
13440
- return `${QUAY_PREFIX}/${name}:latest`;
13441
- }
13442
- var QUAY_PREFIX, RECORD_FILENAME;
13443
- var init_image_digests = __esmMin((() => {
13444
- QUAY_PREFIX = "quay.io/kici-dev";
13445
- RECORD_FILENAME = "installer-image-digests.json";
13446
- }));
13447
- //#endregion
13448
13918
  //#region src/cli/service/compose.ts
13449
13919
  /**
13450
13920
  * Docker/Podman Compose service manager.
@@ -13553,29 +14023,6 @@ function getRestartMode(config) {
13553
14023
  return "unless-stopped";
13554
14024
  }
13555
14025
  /**
13556
- * Seconds a runtime must wait after SIGTERM before it SIGKILLs the container.
13557
- *
13558
- * Compose defaults this to 10s, which is shorter than either component's own
13559
- * graceful-shutdown budget, so a teardown that takes longer than 10s is killed
13560
- * partway through: the orchestrator never broadcasts `peer.leaving` and never
13561
- * closes its agent sockets, and the agent never finishes draining. That window
13562
- * is reached exactly when it matters most — the orchestrator's scaler stops
13563
- * each managed agent container with its own grace, so an orchestrator with
13564
- * agents in flight routinely needs more than 10s.
13565
- *
13566
- * Each value is its component's force-exit budget plus headroom, so the
13567
- * process always reaches its own timer first and the runtime's kill stays the
13568
- * last resort it is meant to be:
13569
- * - orchestrator: 30s (`setupGracefulShutdown` default) -> 45s
13570
- * - agent: 10s plus a ~1s abort delay in its `onForceExit` -> 20s
13571
- *
13572
- * An unrecognised component gets the larger value: over-waiting costs a few
13573
- * seconds on shutdown, under-waiting corrupts it.
13574
- */
13575
- function stopGracePeriodSeconds(config) {
13576
- return (config.component ?? (config.name === "kici-agent" ? "agent" : "orchestrator")) === "agent" ? 20 : 45;
13577
- }
13578
- /**
13579
14026
  * Generate compose YAML content for a service.
13580
14027
  *
13581
14028
  * Uses a manual YAML builder to avoid adding a YAML library dependency.
@@ -13583,25 +14030,24 @@ function stopGracePeriodSeconds(config) {
13583
14030
  * restart policy, and volume mounts.
13584
14031
  *
13585
14032
  * The `image:` line is pinned by manifest-list digest via `resolveImageRef`
13586
- * (`quay.io/kici-dev/<service-name>:<version>@sha256:<digest>`), falling back
13587
- * to the mutable `:latest` tag only when no recorded digest ships with this
13588
- * package. Source of truth for the publish targets:
13589
- * packages/ci/src/publish-images.ts. See
13590
- * .claude/rules/container-publish-registry.md — Quay is the sole registry for
13591
- * public KiCI images we publish.
14033
+ * (`quay.io/kici-dev/kici-<component>:<version>@sha256:<digest>`, whatever the
14034
+ * service is named), falling back to the mutable `:latest` tag only when no
14035
+ * recorded digest ships with this package. Quay is the only registry the KiCI
14036
+ * images are published to.
13592
14037
  */
13593
14038
  function generateComposeYaml(config) {
13594
14039
  const restartMode = getRestartMode(config);
14040
+ const image = config.component ? installerImageName(config.component) : config.name;
13595
14041
  const lines = [
13596
14042
  "# Generated by KiCI service installer",
13597
14043
  `# Service: ${config.displayName}`,
13598
14044
  "",
13599
14045
  "services:",
13600
14046
  ` ${config.name}:`,
13601
- ` image: ${resolveImageRef(config.name)}`,
14047
+ ` image: ${resolveImageRef(image)}`,
13602
14048
  ` container_name: ${config.name}`,
13603
14049
  ` restart: ${restartMode}`,
13604
- ` stop_grace_period: ${stopGracePeriodSeconds(config)}s`,
14050
+ ` stop_grace_period: ${shutdownGraceSeconds(config)}s`,
13605
14051
  " env_file:",
13606
14052
  ` - ${config.envFilePath}`,
13607
14053
  " volumes:",
@@ -13619,6 +14065,7 @@ var RUNTIME_PROBE_TIMEOUT_MS, ComposeServiceManager;
13619
14065
  var init_compose = __esmMin((() => {
13620
14066
  init_compose_path();
13621
14067
  init_image_digests();
14068
+ init_shutdown_grace();
13622
14069
  RUNTIME_PROBE_TIMEOUT_MS = 1e4;
13623
14070
  ComposeServiceManager = class {
13624
14071
  platform = "compose";
@@ -13773,11 +14220,14 @@ var init_compose = __esmMin((() => {
13773
14220
  var service_exports = /* @__PURE__ */ __exportAll({
13774
14221
  COMPONENTS: () => COMPONENTS,
13775
14222
  DEFAULT_RESTART_POLICY: () => DEFAULT_RESTART_POLICY,
14223
+ ENV_FILE_LOADER_EXPORT: () => ENV_FILE_LOADER_EXPORT,
13776
14224
  InstanceNotFoundError: () => InstanceNotFoundError,
14225
+ SERVICE_TEXT: () => SERVICE_TEXT,
13777
14226
  appendIndexEntry: () => appendIndexEntry,
13778
14227
  candidatePlatforms: () => candidatePlatforms,
13779
14228
  createServiceManager: () => createServiceManager,
13780
14229
  detectPlatform: () => detectPlatform,
14230
+ envFileRefusal: () => envFileRefusal,
13781
14231
  formatRefusal: () => formatRefusal,
13782
14232
  getCacheDir: () => getCacheDir,
13783
14233
  getConfigDir: () => getConfigDir,
@@ -13786,6 +14236,8 @@ var service_exports = /* @__PURE__ */ __exportAll({
13786
14236
  isComponent: () => isComponent,
13787
14237
  isRoot: () => isRoot,
13788
14238
  kiciConfigRoot: () => kiciConfigRoot,
14239
+ launchReadsEnvFile: () => launchReadsEnvFile,
14240
+ launchedRelease: () => launchedRelease,
13789
14241
  listInstances: () => listInstances,
13790
14242
  manifestFilename: () => manifestFilename,
13791
14243
  manifestPath: () => manifestPath,
@@ -13795,10 +14247,13 @@ var service_exports = /* @__PURE__ */ __exportAll({
13795
14247
  removeIndexEntry: () => removeIndexEntry,
13796
14248
  resolveInstance: () => resolveInstance,
13797
14249
  resolveInstanceTarget: () => resolveInstanceTarget,
14250
+ resolveLauncherVersion: () => resolveLauncherVersion,
13798
14251
  resolveNpmInstallTarget: () => resolveNpmInstallTarget,
13799
14252
  resolveUserLevel: () => resolveUserLevel,
13800
14253
  resolveVersionFromLaunchSpec: () => resolveVersionFromLaunchSpec,
14254
+ restrictEnvFileAccess: () => restrictEnvFileAccess,
13801
14255
  writeIndex: () => writeIndex,
14256
+ writeInstallManifest: () => writeInstallManifest,
13802
14257
  writeManifest: () => writeManifest
13803
14258
  });
13804
14259
  /**
@@ -13836,6 +14291,8 @@ var init_service = __esmMin((() => {
13836
14291
  init_types$1();
13837
14292
  init_platform_detect();
13838
14293
  init_privilege();
14294
+ init_windows_acl();
14295
+ init_windows_env_file();
13839
14296
  init_types();
13840
14297
  init_manifest();
13841
14298
  init_index_file();
@@ -15187,7 +15644,7 @@ function startManifestLoopback(opts) {
15187
15644
  * blocked launcher) just falls back to copy-paste.
15188
15645
  *
15189
15646
  * `KICI_BROWSER_CMD` overrides the launcher: `none` suppresses it entirely
15190
- * (E2E / headless capture), any other value is run with `{url}` substituted —
15647
+ * (automated tests / headless capture), any other value is run with `{url}` substituted —
15191
15648
  * mirroring the `kici login` convention.
15192
15649
  */
15193
15650
  function openBrowserBestEffort(url) {
@@ -16267,10 +16724,9 @@ function registerRemoteSourceCommands(program) {
16267
16724
  * Workflow registration commands for kici-admin.
16268
16725
  *
16269
16726
  * Provides operator-facing read access to the workflow_registrations table
16270
- * via the orchestrator's admin HTTP API. This is the dogfooded replacement
16271
- * for "let me just psql the registration table" — every CLI subcommand here
16272
- * goes through AdminApiClient and the existing /api/v1/admin/registrations
16273
- * route.
16727
+ * via the orchestrator's admin HTTP API. Use it instead of querying the
16728
+ * registration table with psql: every CLI subcommand here goes through
16729
+ * AdminApiClient and the existing /api/v1/admin/registrations route.
16274
16730
  *
16275
16731
  * workflow list List registered workflows (table or JSON)
16276
16732
  *
@@ -16353,7 +16809,7 @@ function renderTable$3(headers, rows) {
16353
16809
  */
16354
16810
  function registerWorkflowCommands(program, getClient) {
16355
16811
  const wf = program.command("workflow").description("Inspect workflow registrations");
16356
- wf.command("list").description("List workflow registrations (dogfooded via /api/v1/admin/registrations)").option("--org <orgId>", "Filter by customer/org id (server param: customerId)").option("--routing-key <key>", "Filter by routing key, e.g. github:42").option("--repo <ownerRepo>", "Filter by repo identifier (owner/repo)").option("--trigger-type <type>", "Filter by trigger type, e.g. webhook, push, schedule").option("--event <eventName>", "Filter by webhook event name (scans lock_entry.triggers)").option("--json", "Emit raw JSON instead of a table").action(async (opts) => {
16812
+ wf.command("list").description("List workflow registrations (admin API: /api/v1/admin/registrations)").option("--org <orgId>", "Filter by customer/org id (server param: customerId)").option("--routing-key <key>", "Filter by routing key, e.g. github:42").option("--repo <ownerRepo>", "Filter by repo identifier (owner/repo)").option("--trigger-type <type>", "Filter by trigger type, e.g. webhook, push, schedule").option("--event <eventName>", "Filter by webhook event name (scans lock_entry.triggers)").option("--json", "Emit raw JSON instead of a table").action(async (opts) => {
16357
16813
  try {
16358
16814
  const path = `/api/v1/admin/registrations${buildQueryString({
16359
16815
  org: opts.org,
@@ -16477,7 +16933,7 @@ function emptyStepLogNote(stepIndex, logs) {
16477
16933
  */
16478
16934
  function registerRunsCommands(program, getClient) {
16479
16935
  const runs = program.command("runs").description("Inspect execution runs, jobs, and steps");
16480
- runs.command("list").description("List execution runs (dogfooded via /api/v1/admin/runs)").option("--status <statuses>", "Filter by run status. Accepts a single value or a comma-separated list (e.g. success,failed)").option("--workflow-name <name>", "Filter by workflow name").option("--repo <ownerRepo>", "Filter by repo identifier (owner/repo)").option("--since <iso8601>", "Only include runs with created_at strictly later than this ISO-8601 timestamp").option("--count", "Return only the count of matching runs, skipping the row listing").option("--limit <n>", "Max results (default 20, max 100)", "20").option("--offset <n>", "Skip first N results", "0").option("--json", "Emit raw JSON instead of a table").action(async (opts) => {
16936
+ runs.command("list").description("List execution runs (admin API: /api/v1/admin/runs)").option("--status <statuses>", "Filter by run status. Accepts a single value or a comma-separated list (e.g. success,failed)").option("--workflow-name <name>", "Filter by workflow name").option("--repo <ownerRepo>", "Filter by repo identifier (owner/repo)").option("--since <iso8601>", "Only include runs with created_at strictly later than this ISO-8601 timestamp").option("--count", "Return only the count of matching runs, skipping the row listing").option("--limit <n>", "Max results (default 20, max 100)", "20").option("--offset <n>", "Skip first N results", "0").option("--json", "Emit raw JSON instead of a table").action(async (opts) => {
16481
16937
  try {
16482
16938
  if (opts.count) {
16483
16939
  const data = await getClient().countRuns({
@@ -16680,7 +17136,7 @@ function registerRunsCommands(program, getClient) {
16680
17136
  process.exit(1);
16681
17137
  }
16682
17138
  });
16683
- runs.command("jobs <runId>").description("List jobs for a run (dogfooded via /api/v1/admin/runs/:runId/jobs)").option("--include-steps", "Embed step list inside each job (default false)").option("--json", "Emit raw JSON instead of a table").action(async (runId, opts) => {
17139
+ runs.command("jobs <runId>").description("List jobs for a run (admin API: /api/v1/admin/runs/:runId/jobs)").option("--include-steps", "Embed step list inside each job (default false)").option("--json", "Emit raw JSON instead of a table").action(async (runId, opts) => {
16684
17140
  try {
16685
17141
  const response = await getClient().getRunJobs(runId, { includeSteps: Boolean(opts.includeSteps) });
16686
17142
  if (opts.json) {
@@ -16734,7 +17190,7 @@ function registerRunsCommands(program, getClient) {
16734
17190
  process.exit(1);
16735
17191
  }
16736
17192
  });
16737
- runs.command("logs <runId>").description("Print a page of a step log (dogfooded via /api/v1/admin/runs/:runId/jobs/:jobId/steps/:i/logs)").requiredOption("--job <jobId>", "Job id (the dispatch_queue row id for an eval round)").option("--step <n>", "Step index (default 0; -1 is the job setup log)", "0").option("--limit <n>", "Max lines to return (default 500, server caps at 2000)", "500").option("--cursor <c>", "Line-offset cursor from a previous page").option("--json", "Emit raw JSON instead of plain lines").action(async (runId, opts) => {
17193
+ runs.command("logs <runId>").description("Print a page of a step log (admin API: /api/v1/admin/runs/:runId/jobs/:jobId/steps/:i/logs)").requiredOption("--job <jobId>", "Job id (the dispatch_queue row id for an eval round)").option("--step <n>", "Step index (default 0; -1 is the job setup log)", "0").option("--limit <n>", "Max lines to return (default 500, server caps at 2000)", "500").option("--cursor <c>", "Line-offset cursor from a previous page").option("--json", "Emit raw JSON instead of plain lines").action(async (runId, opts) => {
16738
17194
  try {
16739
17195
  const params = new URLSearchParams({ limit: String(opts.limit) });
16740
17196
  if (opts.cursor) params.set("cursor", String(opts.cursor));
@@ -16844,6 +17300,8 @@ function clusterSentinelKey(prefix) {
16844
17300
  * Provider configuration (GitHub Apps, etc.) is managed via the `sources` table
16845
17301
  * and PgSecretStore, not through config. See SourceStore and SourceManager.
16846
17302
  */
17303
+ /** How long the database pool waits for a connection when `KICI_DB_POOL_ACQUIRE_TIMEOUT_MS` is unset. */
17304
+ const DB_POOL_ACQUIRE_TIMEOUT_DEFAULT_MS = 5e3;
16847
17305
  /**
16848
17306
  * Default for `sealed_secrets_retry_backoff_ms`: how long a coordinator stops
16849
17307
  * offering a queued job it put back because it could not open its seal. The
@@ -16854,7 +17312,7 @@ const DEFAULT_SEALED_SECRETS_RETRY_BACKOFF_MS = 6e4;
16854
17312
  const PLATFORM_CONNECTED_MODES_TEXT = `${PLATFORM_CONNECTED_MODES.slice(0, -1).join(", ")}, or ${PLATFORM_CONNECTED_MODES[PLATFORM_CONNECTED_MODES.length - 1]}`;
16855
17313
  const baseSchema = z.object({
16856
17314
  mode: OrchestratorMode.default("platform"),
16857
- port: z.coerce.number().default(4e3),
17315
+ port: z.coerce.number().default(ORCHESTRATOR_DEFAULT_PORT),
16858
17316
  /**
16859
17317
  * Address the HTTP + WebSocket listener binds. Defaults to every interface,
16860
17318
  * matching the Platform's own `KICI_HOST`, because a real orchestrator
@@ -16931,7 +17389,7 @@ const baseSchema = z.object({
16931
17389
  orchestratorUrl: z.string().optional(),
16932
17390
  databaseUrl: z.string().default(""),
16933
17391
  dbPoolMax: z.coerce.number().int().positive().default(20),
16934
- dbPoolAcquireTimeoutMs: z.coerce.number().int().nonnegative().default(5e3),
17392
+ dbPoolAcquireTimeoutMs: z.coerce.number().int().nonnegative().default(DB_POOL_ACQUIRE_TIMEOUT_DEFAULT_MS),
16935
17393
  dbStatementTimeoutMs: z.coerce.number().int().nonnegative().default(3e4),
16936
17394
  lockfileCacheMax: z.coerce.number().default(500),
16937
17395
  lockfileCacheTtlMs: z.coerce.number().default(36e5),
@@ -17220,7 +17678,7 @@ const configSchema = baseSchema.superRefine((data, ctx) => {
17220
17678
  const packagingConfigSchema = baseSchema.superRefine(refineStorageShape);
17221
17679
  /**
17222
17680
  * Env-var definition for the orchestrator. Exported so the docs generator and
17223
- * the deploy-stg pre-validator can re-parse without going through process.env.
17681
+ * config pre-validation can re-parse without going through process.env.
17224
17682
  *
17225
17683
  * Note: passes the inner `baseSchema` (a ZodObject) so describe() can walk
17226
17684
  * `.shape`, but uses the outer `configSchema` (with .superRefine) as the parser
@@ -17468,8 +17926,8 @@ const COLD_STORE_ENV_VARS = [
17468
17926
  "KICI_COLD_STORE_EVENT_LOG_ENABLED"
17469
17927
  ];
17470
17928
  /**
17471
- * Deployment-identity env vars injected by the installer (and the staging
17472
- * deploy) so the orchestrator can report its own deployment shape in
17929
+ * Deployment-identity env vars injected by the installer (or other deploy
17930
+ * tooling) so the orchestrator can report its own deployment shape in
17473
17931
  * `source.register`. Read directly from `process.env` by the deployment reader,
17474
17932
  * not threaded through the AppConfig schema — registered here so the
17475
17933
  * unknown-KICI_* validator at boot doesn't reject them.
@@ -17847,7 +18305,8 @@ var S3CacheStorage = class {
17847
18305
  * Signature input: `${method}:${key}:${expiresUnixMs}` with HMAC-SHA256 keyed
17848
18306
  * by a per-orchestrator-process secret (random 32-byte hex at boot, persists
17849
18307
  * for the lifetime of the process). Tokens become invalid on orchestrator
17850
- * restart — fine for the filesystem backend's E2E / single-host use case.
18308
+ * restart — fine for the filesystem backend's single-host / test-sandbox use
18309
+ * case.
17851
18310
  */
17852
18311
  /** Default URL lifetime: 1 hour (matches S3 backend's PUT-URL lifetime). */
17853
18312
  const DEFAULT_SIGN_URL_TTL_MS = 36e5;
@@ -17883,7 +18342,7 @@ function signToken(secret, method, key, ttlMs = DEFAULT_SIGN_URL_TTL_MS) {
17883
18342
  * are replaced by HMAC-signed `http://<orchUrl>/api/v1/cache/blob/...` URLs
17884
18343
  * — see `sign-url.ts` for the token mechanics and `app.ts` for the route.
17885
18344
  *
17886
- * Intended for single-host deployments and E2E sandboxes where standing up
18345
+ * Intended for single-host deployments and test sandboxes where standing up
17887
18346
  * an S3-compatible service is overkill. Production should still use S3.
17888
18347
  *
17889
18348
  * Metadata is co-located as a sibling JSON file (`<key>.meta.json`) so the
@@ -18153,7 +18612,7 @@ function createCacheStorage(config) {
18153
18612
  //#region src/provenance/trust-root.ts
18154
18613
  /**
18155
18614
  * Derive the JWKS URI for a provenance OIDC issuer. Identical to the Platform's
18156
- * own derivation (`packages/platform/src/dashboard/routes/runs.ts`) so a bundle
18615
+ * own derivation so a bundle
18157
18616
  * verified client-side and at ingest resolve the same key set.
18158
18617
  */
18159
18618
  function deriveJwksUri(issuer) {
@@ -18662,7 +19121,7 @@ var OrchestratorSigningKeyRepo = class {
18662
19121
  * The in-memory private key is imported NON-EXTRACTABLE (`extractable: false`),
18663
19122
  * so once loaded the raw private material can never be read back out of the
18664
19123
  * process — the only export path is the public JWK. This is what makes the
18665
- * private key "non-exportable by design" (design spec § A / § H).
19124
+ * private key "non-exportable by design".
18666
19125
  */
18667
19126
  /** AAD binding the wrapped private JWK to its purpose (AES-GCM authenticated data). */
18668
19127
  const SIGNING_KEY_AAD = "orchestrator-signing-key";
@@ -18775,8 +19234,7 @@ var DbSigner = class DbSigner {
18775
19234
  //#region src/cli/commands/signing-key.ts
18776
19235
  /**
18777
19236
  * `kici-admin signing-key` — orchestrator-owned provenance signing key
18778
- * management (orchestrator DB plane; classified `kici-admin` per
18779
- * .claude/rules/platform-admin.md).
19237
+ * management (orchestrator DB plane, so it belongs to `kici-admin`).
18780
19238
  *
18781
19239
  * signing-key list List keys (kid / status / created_at).
18782
19240
  * Reads the database when a database URL
@@ -19317,6 +19775,41 @@ function encryptPrivateKey(privateKey, secretKey) {
19317
19775
  }
19318
19776
  createLogger({ prefix: "job-secret-seal" });
19319
19777
  runtimeLabel(RuntimeFact.enum.docker), runtimeLabel(RuntimeFact.enum.podman);
19778
+ //#endregion
19779
+ //#region src/helpers/run-detached.ts
19780
+ /**
19781
+ * Running work that nothing awaits.
19782
+ *
19783
+ * A promise that nobody awaits and nobody catches becomes an unhandled
19784
+ * rejection when it fails, and the orchestrator's shutdown hook treats an
19785
+ * unhandled rejection as fatal: one failed query in a timer tick or a message
19786
+ * handler would stop the whole process. `runDetached` runs such work and logs
19787
+ * its failure instead. ESLint's `no-floating-promises` (with `ignoreVoid:
19788
+ * false`) refuses a bare or `void`-ed promise anywhere in this package, so
19789
+ * detached work runs through here or carries its own `.catch`.
19790
+ */
19791
+ /**
19792
+ * Run `work` without waiting for it. A throw, or a rejection of the promise it
19793
+ * returns, is logged at error level as `<task> failed` with `context` and the
19794
+ * error message, and goes no further. `context` is read when the failure is
19795
+ * logged, so the work may fill it in while it runs.
19796
+ */
19797
+ function runDetached(logger, task, work, context = {}) {
19798
+ const fail = (err) => {
19799
+ logger.error(`${task} failed`, {
19800
+ ...context,
19801
+ error: toErrorMessage(err)
19802
+ });
19803
+ };
19804
+ let outcome;
19805
+ try {
19806
+ outcome = work();
19807
+ } catch (err) {
19808
+ fail(err);
19809
+ return;
19810
+ }
19811
+ Promise.resolve(outcome).catch(fail);
19812
+ }
19320
19813
  createLogger({ prefix: "instance-heartbeat" });
19321
19814
  createLogger({ prefix: "job-queue" });
19322
19815
  /**
@@ -19485,7 +19978,7 @@ function renderTable$1(headers, rows) {
19485
19978
  }
19486
19979
  function registerEventLogCommands(program, getClient) {
19487
19980
  const eventLog = program.command("event-log").description("Inspect the inbound webhook delivery log");
19488
- eventLog.command("list").description("List inbound webhook deliveries (dogfooded via /api/v1/admin/event-log)").option("--org <orgId>", "Filter by org/tenant ID").option("--routing-key <key>", "Filter by routing key (e.g. github:42)").option("--event <type>", "Filter by event type (e.g. push, pull_request)").option("--action <action>", "Filter by event action (e.g. opened, closed, synchronize for pull_request)").option("--status <s>", "Filter by outcome status (received|shed|processed|duplicate|lockfile_missing|lockfile_corrupt|failed)").option("--from <ts>", "ISO timestamp lower bound (inclusive)").option("--to <ts>", "ISO timestamp upper bound (exclusive)").option("--delivery-id <substr>", "Substring filter on delivery_id").option("--limit <n>", "Max results (default 50, max 200)", "50").option("--offset <n>", "Skip first N results", "0").option("--include-archived", "Merge cold-store archived rows into the result (requires --routing-key for cold scoping)").option("--json", "Emit raw JSON instead of a table").action(async (opts) => {
19981
+ eventLog.command("list").description("List inbound webhook deliveries (admin API: /api/v1/admin/event-log)").option("--org <orgId>", "Filter by org/tenant ID").option("--routing-key <key>", "Filter by routing key (e.g. github:42)").option("--event <type>", "Filter by event type (e.g. push, pull_request)").option("--action <action>", "Filter by event action (e.g. opened, closed, synchronize for pull_request)").option("--status <s>", "Filter by outcome status (received|shed|processed|duplicate|lockfile_missing|lockfile_corrupt|failed)").option("--from <ts>", "ISO timestamp lower bound (inclusive)").option("--to <ts>", "ISO timestamp upper bound (exclusive)").option("--delivery-id <substr>", "Substring filter on delivery_id").option("--limit <n>", "Max results (default 50, max 200)", "50").option("--offset <n>", "Skip first N results", "0").option("--include-archived", "Merge cold-store archived rows into the result (requires --routing-key for cold scoping)").option("--json", "Emit raw JSON instead of a table").action(async (opts) => {
19489
19982
  try {
19490
19983
  const response = await getClient().listEventLog({
19491
19984
  orgId: opts.org,
@@ -19599,7 +20092,7 @@ function renderTable(headers, rows) {
19599
20092
  }
19600
20093
  function registerAccessLogCommands(program, getClient) {
19601
20094
  const accessLog = program.command("access-log").description("Inspect the read / admin-mutation access log");
19602
- accessLog.command("list").description("List access-log rows (dogfooded via /api/v1/admin/access-log)").option("--org-id <orgId>", "Filter by org/tenant ID").option("--actor-type <t>", "Filter by actor type (user|api_key|service_account|platform_operator|system)").option("--actor-id <id>", "Filter by actor id (zsub, keyId, service_account id, ...)").option("--action <action>", "Filter by dotted action (e.g. run.detail.read, run.cancel)").option("--source <s>", "Filter by source (platform_proxy|admin_http|admin_cli)").option("--outcome <o>", "Filter by outcome (allowed|denied|error)").option("--target-type <t>", "Filter by target type (run|step|event_log|secret_scope|...)").option("--target-id <id>", "Filter by target id").option("--from <ts>", "ISO timestamp lower bound (inclusive)").option("--to <ts>", "ISO timestamp upper bound (exclusive)").option("--q <text>", "Filter by substring of error_message (trigram-indexed full-text search)").option("--agent-label <label>", "Filter by exact agent label").option("--agent-only", "Only agent-attributed rows").option("--limit <n>", "Max results (default 50, max 200)", "50").option("--cursor <c>", "Opaque cursor from a previous nextCursor").option("--json", "Emit raw JSON instead of a table").action(async (opts) => {
20095
+ accessLog.command("list").description("List access-log rows (admin API: /api/v1/admin/access-log)").option("--org-id <orgId>", "Filter by org/tenant ID").option("--actor-type <t>", "Filter by actor type (user|api_key|service_account|platform_operator|system)").option("--actor-id <id>", "Filter by actor id (zsub, keyId, service_account id, ...)").option("--action <action>", "Filter by dotted action (e.g. run.detail.read, run.cancel)").option("--source <s>", "Filter by source (platform_proxy|admin_http|admin_cli)").option("--outcome <o>", "Filter by outcome (allowed|denied|error)").option("--target-type <t>", "Filter by target type (run|step|event_log|secret_scope|...)").option("--target-id <id>", "Filter by target id").option("--from <ts>", "ISO timestamp lower bound (inclusive)").option("--to <ts>", "ISO timestamp upper bound (exclusive)").option("--q <text>", "Filter by substring of error_message (trigram-indexed full-text search)").option("--agent-label <label>", "Filter by exact agent label").option("--agent-only", "Only agent-attributed rows").option("--limit <n>", "Max results (default 50, max 200)", "50").option("--cursor <c>", "Opaque cursor from a previous nextCursor").option("--json", "Emit raw JSON instead of a table").action(async (opts) => {
19603
20096
  try {
19604
20097
  const response = await getClient().listAccessLog({
19605
20098
  orgId: opts.orgId,
@@ -20268,6 +20761,16 @@ function registerInspectBundleCommand(program) {
20268
20761
  //#endregion
20269
20762
  //#region src/cli/service/entrypoint.ts
20270
20763
  /**
20764
+ * Resolves the command a service unit should run.
20765
+ *
20766
+ * A `npm install -g kici-admin` install exposes only the `kici-admin` /
20767
+ * `kici-agent` CLI bins — the long-running orchestrator and agent servers are
20768
+ * separate module entry points (`dist/server.js` / `dist/standalone.js`) that
20769
+ * must be launched with `node <script>`. These helpers turn an install's
20770
+ * options into the `{ executablePath, args }` pair the platform service
20771
+ * managers write into the unit's run command.
20772
+ */
20773
+ /**
20271
20774
  * Pick the orchestrator server entry from an env file's `KICI_MODE`.
20272
20775
  * `independent` runs the standalone server; `platform` / `hybrid` / unset run
20273
20776
  * the Platform-connected server.
@@ -20292,6 +20795,34 @@ function resolveServiceExecutable(opts) {
20292
20795
  args: [opts.entryScript]
20293
20796
  };
20294
20797
  }
20798
+ /**
20799
+ * The path of a server entry script installed beside this CLI, for a service
20800
+ * that runs `node <script>`. `resolve` is the calling module's
20801
+ * `import.meta.resolve`, or undefined where the CLI has none: a kici-admin from a
20802
+ * standalone package is a single CommonJS bundle that carries no orchestrator
20803
+ * or agent server. Either way the error names `--binary`, the launcher the
20804
+ * service runs instead.
20805
+ */
20806
+ function resolveServerScript(specifier, component, resolve) {
20807
+ let cause = NO_RESOLVER;
20808
+ if (resolve) try {
20809
+ return fileURLToPath(resolve(specifier));
20810
+ } catch (err) {
20811
+ cause = err instanceof Error ? err.message : String(err);
20812
+ }
20813
+ throw new Error(missingServerMessage(component, specifier, cause));
20814
+ }
20815
+ /**
20816
+ * Refuse an install without `--binary` on a kici-admin that cannot resolve
20817
+ * installed packages, before the wizard runs or any file is written.
20818
+ */
20819
+ function assertServerResolvable(component, binary, resolve) {
20820
+ if (binary === void 0 && resolve === void 0) throw new Error(missingServerMessage(component, `@kici-dev/${component}`, NO_RESOLVER));
20821
+ }
20822
+ const NO_RESOLVER = "this kici-admin cannot resolve installed packages";
20823
+ function missingServerMessage(component, specifier, cause) {
20824
+ return `kici-admin cannot find the ${component} server (${specifier}): ${cause}. Pass --binary with the path of the ${component} launcher, such as the one in a standalone ${component} package.`;
20825
+ }
20295
20826
  //#endregion
20296
20827
  //#region src/cli/service/deploy-env.ts
20297
20828
  init_compose_path();
@@ -20370,6 +20901,11 @@ async function waitForQuiesce(poll, opts) {
20370
20901
  }
20371
20902
  }
20372
20903
  /**
20904
+ * What a version switch prints when it cannot read the live migration ledger,
20905
+ * so it could not compare the schema to the recorded head.
20906
+ */
20907
+ const SCHEMA_NOT_CHECKED = "The running service could not be reached, so the schema was not checked.";
20908
+ /**
20373
20909
  * The last applied migration reported by a running service.
20374
20910
  *
20375
20911
  * Returns null when nothing has been applied — a fresh database — which the
@@ -20389,17 +20925,18 @@ function migrationsAfter(rows, head) {
20389
20925
  * Decide whether the version pointer may move.
20390
20926
  *
20391
20927
  * Three outcomes, and the middle one is the compatibility shim: an instance
20392
- * installed before `migrationHeads` existed has no recorded head, and refusing
20393
- * there would break the documented `--rollback` on exactly the installs that
20394
- * predate the guard. So an unknown head warns loudly and falls through to the
20395
- * existing confirmation prompt; the map is populated at the next version
20396
- * change, arming the guard one upgrade later.
20928
+ * installed before `migrationHeads` existed, or one whose upgrade away from the
20929
+ * target ran without admin access, has no recorded head, and refusing there
20930
+ * would break the documented `--rollback` on exactly those installs. So an
20931
+ * unknown head warns loudly and falls through to the existing confirmation
20932
+ * prompt; the map is populated at the next version change with admin access,
20933
+ * arming the guard one upgrade later.
20397
20934
  */
20398
20935
  function schemaGuardVerdict(input) {
20399
20936
  const { targetVersion, recordedHead, liveRows } = input;
20400
20937
  if (!recordedHead) return {
20401
20938
  kind: "unknown-head",
20402
- message: `WARNING: no migration head is recorded for ${targetVersion}.\n This instance was installed before the schema guard existed, so it cannot be\n checked whether the database is ahead of that version. If it is, the older\n binary will refuse to boot with "corrupted migrations" and restart in a loop.\n Take a dump first ("kici-admin db backup"). If the switch does loop, start the\n older version with KICI_AUTO_MIGRATE=false — it boots only if every newer\n migration was purely additive.\n The head is recorded from this version change on, so the guard is armed for\n the next one.`
20939
+ message: `WARNING: no migration head is recorded for ${targetVersion}.\n Either this instance was installed before the schema guard existed, or the\n upgrade away from ${targetVersion} ran without admin access (KICI_ADMIN_TOKEN).\n So it cannot be checked whether the database is ahead of that version. If it\n is, the older binary will refuse to boot with "corrupted migrations" and\n restart in a loop.\n Take a dump first ("kici-admin db backup"). If the switch does loop, start the\n older version with KICI_AUTO_MIGRATE=false — it boots only if every newer\n migration was purely additive.\n A version change with admin access records the head, which arms the guard\n for the next one.`
20403
20940
  };
20404
20941
  const live = appliedHead(liveRows);
20405
20942
  if (live === null || live === recordedHead) return { kind: "ok" };
@@ -20486,8 +21023,7 @@ async function resolveUpgradeTarget(args) {
20486
21023
  return {
20487
21024
  config: {
20488
21025
  name: resolved.manifest.name,
20489
- displayName: `KiCI ${component}`,
20490
- description: `KiCI ${component} service`,
21026
+ ...SERVICE_TEXT[component],
20491
21027
  executablePath: "",
20492
21028
  envFilePath: resolved.manifest.envFilePath,
20493
21029
  workingDirectory: resolved.manifest.configDir,
@@ -20522,8 +21058,8 @@ async function downloadArchive(url, destPath) {
20522
21058
  * Install base for a KiCI component instance.
20523
21059
  *
20524
21060
  * Name-scoped so that two instances of the same component (e.g. an org's
20525
- * dogfood orchestrator and an E2E test orchestrator) own independent
20526
- * versioned trees and symlinks. Per-platform bases:
21061
+ * production orchestrator and a test orchestrator on the same host) own
21062
+ * independent versioned trees and symlinks. Per-platform bases:
20527
21063
  * - systemd / compose: /opt/kici/<name>/
20528
21064
  * - launchd: /usr/local/kici/<name>/
20529
21065
  * - windows: C:\Program Files\KiCI\<name>\
@@ -20575,10 +21111,73 @@ function checkPickFlagConflicts(opts) {
20575
21111
  if (conflicts.length === 0) return null;
20576
21112
  return `--pick cannot be combined with ${conflicts.join(", ")}. --pick activates an already-installed version; it never downloads or takes an explicit version.`;
20577
21113
  }
21114
+ /**
21115
+ * Point the Windows service at `launcherPath`. sc.exe has no "update binary
21116
+ * path", so `install` replaces the registration: it checks the new launch
21117
+ * command first, then removes the old service and waits until it is gone
21118
+ * before it creates the new one. A launch command it refuses leaves the old
21119
+ * registration in place.
21120
+ */
21121
+ async function reregisterWindowsService(manager, config, launcherPath) {
21122
+ config.executablePath = launcherPath;
21123
+ await manager.install(config);
21124
+ }
20578
21125
  /** Check if the platform is Windows. */
20579
21126
  function isWindows(platform) {
20580
21127
  return platform === "windows";
20581
21128
  }
21129
+ /**
21130
+ * Refuse to point a Windows service at a release from before KICI_ENV_FILE.
21131
+ * Every caller runs it before it stops the service, so a refused switch leaves
21132
+ * the service registered and running as it was.
21133
+ */
21134
+ function assertWindowsLaunchReadsEnvFile(args) {
21135
+ if (launchReadsEnvFile(args.launch)) return;
21136
+ throw new Error(`refusing to ${args.action}: ${envFileRefusal(args.release, args.config.envFilePath)} The service was not changed.`);
21137
+ }
21138
+ /**
21139
+ * Stop the service, register it again on Windows, then start it: the restart
21140
+ * every npm-source upgrade ends with.
21141
+ *
21142
+ * `npm install -g` replaces the package in place, so the launch command the
21143
+ * service runs (`spec`, read back from its registration) stays valid. On
21144
+ * Windows the registration around that command (the service wrapper's
21145
+ * arguments, the description, the recovery settings) is whatever the CLI that
21146
+ * installed it wrote, so `install` writes the current one, as the archive
21147
+ * upgrade does. A launch command that names a file that does not exist was
21148
+ * read back wrong, so it is not registered: the service restarts as it was,
21149
+ * with a warning. systemd and launchd keep their unit as it is.
21150
+ */
21151
+ async function restartOntoInstalledPackage(args) {
21152
+ const { manager, config, platform, spec } = args;
21153
+ const component = config.component ?? "orchestrator";
21154
+ const read = spec ? {
21155
+ executablePath: spec.execPath,
21156
+ args: spec.args
21157
+ } : null;
21158
+ const launch = read && fs.existsSync(read.executablePath) && fs.existsSync(launchedRelease(read)) ? read : null;
21159
+ if (isWindows(platform) && launch) assertWindowsLaunchReadsEnvFile({
21160
+ launch,
21161
+ release: launchedRelease(launch),
21162
+ action: `restart "${config.name}" onto the installed package`,
21163
+ config
21164
+ });
21165
+ if ((await manager.status(config)).state === "running") await manager.stop(config);
21166
+ if (isWindows(platform)) {
21167
+ if (launch) {
21168
+ try {
21169
+ await manager.install({
21170
+ ...config,
21171
+ ...launch
21172
+ });
21173
+ } catch (err) {
21174
+ throw new Error(`could not register the service again: ${toErrorMessage(err)}. Run \`kici-admin ${component} install\` for it.`, { cause: err });
21175
+ }
21176
+ console.log("Service registration rewritten from the current installer.");
21177
+ } else console.warn(`Warning: the service was not registered again, because its launch command could not be read, or names a file that does not exist. Run \`kici-admin ${component} install\` for it to apply the current registration.`);
21178
+ }
21179
+ await manager.start(config);
21180
+ }
20582
21181
  /** Get the launcher script name for a component. */
20583
21182
  function getLauncherName(component, platform) {
20584
21183
  const baseName = component === "orchestrator" ? "kici-orchestrator-standalone" : "kici-agent";
@@ -20599,6 +21198,45 @@ function extractArchive(archivePath, destDir) {
20599
21198
  return dirs[0];
20600
21199
  }
20601
21200
  /**
21201
+ * Extract a release archive into `tmpDir` and return the release folder in it.
21202
+ * On Windows a release from before KICI_ENV_FILE is refused here, before
21203
+ * anything is copied into the install base or the service is stopped.
21204
+ */
21205
+ function extractRelease(args) {
21206
+ const { archivePath, tmpDir, component, platform, version, config } = args;
21207
+ const extractDir = path.join(tmpDir, "extract");
21208
+ const srcDir = path.join(extractDir, extractArchive(archivePath, extractDir));
21209
+ if (isWindows(platform)) assertWindowsLaunchReadsEnvFile({
21210
+ launch: { executablePath: path.join(srcDir, getLauncherName(component, platform)) },
21211
+ release: `version ${version}`,
21212
+ action: `upgrade "${config.name}" to ${version}`,
21213
+ config
21214
+ });
21215
+ return srcDir;
21216
+ }
21217
+ /**
21218
+ * Put the release in `archivePath` at `versionedDirPath`. The release is
21219
+ * extracted and checked first ({@link extractRelease}); only then is a folder
21220
+ * already at `versionedDirPath` removed, which the caller allows with
21221
+ * `--force`, and the release copied in. A refused release leaves the install
21222
+ * base as it was.
21223
+ */
21224
+ function installRelease(args) {
21225
+ const { installBase, versionedDirPath, platform } = args;
21226
+ const srcDir = extractRelease(args);
21227
+ if (fs.existsSync(versionedDirPath)) {
21228
+ console.log(`Removing existing directory ${versionedDirPath} (--force)`);
21229
+ fs.rmSync(versionedDirPath, {
21230
+ recursive: true,
21231
+ force: true
21232
+ });
21233
+ }
21234
+ fs.mkdirSync(installBase, { recursive: true });
21235
+ if (isWindows(platform)) execSync(`xcopy "${srcDir}" "${versionedDirPath}" /E /I /Q /Y`, { stdio: "inherit" });
21236
+ else execSync(`cp -r "${srcDir}" "${versionedDirPath}"`, { stdio: "inherit" });
21237
+ console.log(`Extracted to ${versionedDirPath}`);
21238
+ }
21239
+ /**
20602
21240
  * List installed versions for a component by scanning the install base directory
20603
21241
  * for directories matching `{component}-{version}/`.
20604
21242
  */
@@ -20747,18 +21385,87 @@ function planNpmSourceUpgrade(opts) {
20747
21385
  version: requestedVersion
20748
21386
  };
20749
21387
  }
21388
+ /**
21389
+ * The cmd.exe command line for npm on Windows. npm is a batch file there, which
21390
+ * Node.js starts only through a shell, and Node.js joins the parts of a shell
21391
+ * command with spaces and quotes none of them. The standard Node.js install is
21392
+ * under `C:\Program Files`, so the npm path is quoted here. An argument with a
21393
+ * character outside those of a package name, a version or a flag is refused
21394
+ * rather than handed to cmd.exe.
21395
+ */
21396
+ function windowsShellCommand(cmd, args) {
21397
+ if (cmd.includes("\"")) throw new Error(`cannot run "${cmd}" through cmd.exe: the path holds a double quote`);
21398
+ for (const arg of args) if (!/^[A-Za-z0-9@/._+-]+$/.test(arg)) throw new Error(`cannot pass "${arg}" to npm: it holds a character cmd.exe reads as syntax`);
21399
+ return [`"${cmd}"`, ...args].join(" ");
21400
+ }
20750
21401
  const defaultSpawnRunner = (cmd, args, opts) => {
20751
- const r = spawnSync(cmd, args, {
21402
+ const env = opts?.env ?? process.env;
21403
+ const r = process.platform === "win32" ? spawnSync(windowsShellCommand(cmd, args), {
21404
+ encoding: "utf-8",
21405
+ shell: true,
21406
+ env
21407
+ }) : spawnSync(cmd, args, {
20752
21408
  encoding: "utf-8",
20753
- shell: process.platform === "win32",
20754
- env: opts?.env ?? process.env
21409
+ env
20755
21410
  });
20756
21411
  return {
20757
21412
  status: r.status,
21413
+ stdout: r.stdout ?? "",
20758
21414
  stderr: (r.stderr ?? "") + (r.error ? String(r.error) : "")
20759
21415
  };
20760
21416
  };
20761
21417
  /**
21418
+ * The environment npm runs in: the unit's pinned node first on PATH, because
21419
+ * npm derives its global prefix from the node that runs it.
21420
+ */
21421
+ function npmEnv(target) {
21422
+ const pinnedBinDir = path.dirname(target.nodeExecPath);
21423
+ return {
21424
+ ...process.env,
21425
+ PATH: `${pinnedBinDir}${path.delimiter}${process.env.PATH ?? ""}`
21426
+ };
21427
+ }
21428
+ /**
21429
+ * Whether `version` of the KiCI packages reads KICI_ENV_FILE, asked of the
21430
+ * registry the unit's npm installs from. A release that reads it publishes the
21431
+ * env-file loader in `@kici-dev/shared`, which every KiCI package of the same
21432
+ * version depends on.
21433
+ */
21434
+ function npmReleaseReadsEnvFile(target, version, run = defaultSpawnRunner) {
21435
+ const res = run(target.npmPath, [
21436
+ "view",
21437
+ `@kici-dev/shared@${version}`,
21438
+ "exports",
21439
+ "--json"
21440
+ ], { env: npmEnv(target) });
21441
+ if (res.status !== 0) return {
21442
+ ok: false,
21443
+ error: res.stderr.trim() || `npm view exited with ${res.status}`
21444
+ };
21445
+ const out = res.stdout.trim();
21446
+ if (!out) return {
21447
+ ok: true,
21448
+ reads: false
21449
+ };
21450
+ let exportsMap;
21451
+ try {
21452
+ exportsMap = JSON.parse(out);
21453
+ } catch {
21454
+ return {
21455
+ ok: false,
21456
+ error: `npm view printed no JSON: ${out.slice(0, 200)}`
21457
+ };
21458
+ }
21459
+ if (Array.isArray(exportsMap)) return {
21460
+ ok: false,
21461
+ error: `npm matched more than one version of @kici-dev/shared for ${version}; pass an exact --version`
21462
+ };
21463
+ return {
21464
+ ok: true,
21465
+ reads: typeof exportsMap === "object" && exportsMap !== null && "./load-service-env-file" in exportsMap
21466
+ };
21467
+ }
21468
+ /**
20762
21469
  * Install `<owningPackage>@<version>` globally under the unit's own pinned node,
20763
21470
  * so it lands in exactly the global prefix the unit's ExecStart resolves from.
20764
21471
  *
@@ -20773,16 +21480,11 @@ const defaultSpawnRunner = (cmd, args, opts) => {
20773
21480
  * `run` seam is injected in tests.
20774
21481
  */
20775
21482
  function installGlobalPackage(target, version, run = defaultSpawnRunner) {
20776
- const pinnedBinDir = path.dirname(target.nodeExecPath);
20777
- const env = {
20778
- ...process.env,
20779
- PATH: `${pinnedBinDir}${path.delimiter}${process.env.PATH ?? ""}`
20780
- };
20781
21483
  const res = run(target.npmPath, [
20782
21484
  "install",
20783
21485
  "-g",
20784
21486
  `${target.owningPackage}@${version}`
20785
- ], { env });
21487
+ ], { env: npmEnv(target) });
20786
21488
  return {
20787
21489
  ok: res.status === 0,
20788
21490
  stderr: res.stderr
@@ -20891,18 +21593,10 @@ async function performVersionedUpgrade(component, opts, hooks = {}) {
20891
21593
  const version = opts.version;
20892
21594
  const versionedDirName = `${component}-${version}`;
20893
21595
  const versionedDirPath = path.join(installBase, versionedDirName);
20894
- if (fs.existsSync(versionedDirPath)) {
20895
- if (opts.force) {
20896
- console.log(`Removing existing directory ${versionedDirPath} (--force)`);
20897
- fs.rmSync(versionedDirPath, {
20898
- recursive: true,
20899
- force: true
20900
- });
20901
- } else {
20902
- console.error(`Error: version directory already exists: ${versionedDirPath}`);
20903
- console.error("Use --force to overwrite.");
20904
- process.exit(1);
20905
- }
21596
+ if (fs.existsSync(versionedDirPath) && !opts.force) {
21597
+ console.error(`Error: version directory already exists: ${versionedDirPath}`);
21598
+ console.error("Use --force to overwrite.");
21599
+ process.exit(1);
20906
21600
  }
20907
21601
  if (!await manager.isInstalled(config)) {
20908
21602
  console.error(`Error: service "${config.name}" is not installed`);
@@ -20935,13 +21629,21 @@ async function performVersionedUpgrade(component, opts, hooks = {}) {
20935
21629
  }
20936
21630
  }
20937
21631
  console.log("Extracting archive...");
20938
- const extractDir = path.join(tmpDir, "extract");
20939
- const extractedDirName = extractArchive(archivePath, extractDir);
20940
- fs.mkdirSync(installBase, { recursive: true });
20941
- const srcDir = path.join(extractDir, extractedDirName);
20942
- if (isWindows(platform)) execSync(`xcopy "${srcDir}" "${versionedDirPath}" /E /I /Q /Y`, { stdio: "inherit" });
20943
- else execSync(`cp -r "${srcDir}" "${versionedDirPath}"`, { stdio: "inherit" });
20944
- console.log(`Extracted to ${versionedDirPath}`);
21632
+ try {
21633
+ installRelease({
21634
+ archivePath,
21635
+ tmpDir,
21636
+ installBase,
21637
+ versionedDirPath,
21638
+ component,
21639
+ platform,
21640
+ version,
21641
+ config
21642
+ });
21643
+ } catch (err) {
21644
+ await cleanupTmpDir();
21645
+ throw err;
21646
+ }
20945
21647
  const preStopHeads = await runPreStopSafety({
20946
21648
  component,
20947
21649
  opts,
@@ -20955,10 +21657,7 @@ async function performVersionedUpgrade(component, opts, hooks = {}) {
20955
21657
  }
20956
21658
  if (isWindows(platform)) {
20957
21659
  const launcherPath = path.join(versionedDirPath, getLauncherName(component, platform));
20958
- config.executablePath = launcherPath;
20959
- await manager.uninstall(config);
20960
- await new Promise((r) => setTimeout(r, 2e3));
20961
- await manager.install(config);
21660
+ await reregisterWindowsService(manager, config, launcherPath);
20962
21661
  writeCurrentVersion(installBase, component, version);
20963
21662
  console.log(`Service registration updated to ${launcherPath}`);
20964
21663
  } else {
@@ -21023,17 +21722,36 @@ async function performNpmSourceUpgrade(component, config, resolvedInstance, mana
21023
21722
  process.exit(1);
21024
21723
  }
21025
21724
  if (action.kind === "restart-only") {
21026
- await restartOnlyUpgrade(component, config, resolvedInstance, manager, action.verdict, opts);
21725
+ await restartOnlyUpgrade({
21726
+ component,
21727
+ config,
21728
+ resolvedInstance,
21729
+ manager,
21730
+ verdict: action.verdict,
21731
+ opts,
21732
+ platform,
21733
+ spec
21734
+ });
21027
21735
  return;
21028
21736
  }
21029
- await performSelfDrivingInstall(component, config, resolvedInstance, manager, action, opts);
21737
+ await performSelfDrivingInstall({
21738
+ component,
21739
+ config,
21740
+ resolvedInstance,
21741
+ manager,
21742
+ action,
21743
+ opts,
21744
+ platform,
21745
+ spec
21746
+ });
21030
21747
  }
21031
21748
  /**
21032
21749
  * Restart-only path: the operator pre-installed `@kici-dev/<pkg>@<version>` (or
21033
21750
  * kici-admin) themselves. Verify the unit will launch the invoking CLI's version,
21034
21751
  * restart onto it, and persist the manifest version.
21035
21752
  */
21036
- async function restartOnlyUpgrade(component, config, resolvedInstance, manager, verdict, opts) {
21753
+ async function restartOnlyUpgrade(args) {
21754
+ const { config, resolvedInstance, manager, verdict, opts, platform, spec } = args;
21037
21755
  if (!verdict.ok) {
21038
21756
  console.error(`Error: ${verdict.reason}`);
21039
21757
  process.exit(1);
@@ -21048,8 +21766,12 @@ async function restartOnlyUpgrade(component, config, resolvedInstance, manager,
21048
21766
  }
21049
21767
  }
21050
21768
  console.log("Restarting service onto the npm-installed package...");
21051
- if ((await manager.status(config)).state === "running") await manager.stop(config);
21052
- await manager.start(config);
21769
+ await restartOntoInstalledPackage({
21770
+ manager,
21771
+ config,
21772
+ platform,
21773
+ spec
21774
+ });
21053
21775
  if (verdict.manifestVersion !== null) writeManifest(resolvedInstance.instanceDir, {
21054
21776
  ...resolvedInstance.manifest,
21055
21777
  kiciVersion: verdict.manifestVersion
@@ -21062,8 +21784,15 @@ async function restartOnlyUpgrade(component, config, resolvedInstance, manager,
21062
21784
  * pinned node/npm, restart, and verify the unit now launches the requested
21063
21785
  * version.
21064
21786
  */
21065
- async function performSelfDrivingInstall(component, config, resolvedInstance, manager, action, opts) {
21787
+ async function performSelfDrivingInstall(args) {
21788
+ const { component, config, resolvedInstance, manager, action, opts, platform, spec } = args;
21066
21789
  const { target, version } = action;
21790
+ if (isWindows(platform)) {
21791
+ const release = `${target.owningPackage}@${version}`;
21792
+ const verdict = npmReleaseReadsEnvFile(target, version, args.run);
21793
+ if (!verdict.ok) throw new Error(`could not check whether ${release} reads KICI_ENV_FILE: ${verdict.error}. The service and the installed package were not changed.`);
21794
+ if (!verdict.reads) throw new Error(`refusing to install ${release} for "${config.name}": ${envFileRefusal(release, config.envFilePath)} The service and the installed package were not changed.`);
21795
+ }
21067
21796
  if (!opts.yes) {
21068
21797
  console.log(`This will install ${target.owningPackage}@${version} using ${target.npmPath}`);
21069
21798
  console.log(` (node runtime: ${target.nodeExecPath}), then restart "${config.name}".`);
@@ -21074,7 +21803,7 @@ async function performSelfDrivingInstall(component, config, resolvedInstance, ma
21074
21803
  }
21075
21804
  }
21076
21805
  console.log(`Installing ${target.owningPackage}@${version} under the unit's runtime...`);
21077
- const result = installGlobalPackage(target, version);
21806
+ const result = installGlobalPackage(target, version, args.run);
21078
21807
  if (!result.ok) {
21079
21808
  const hint = /EACCES|permission denied|EPERM/i.test(result.stderr) ? " The unit's global prefix is not writable — re-run with privileges matching the unit's runtime." : "";
21080
21809
  console.error(`Error: npm install failed for ${target.owningPackage}@${version}.${hint}`);
@@ -21082,8 +21811,12 @@ async function performSelfDrivingInstall(component, config, resolvedInstance, ma
21082
21811
  process.exit(1);
21083
21812
  }
21084
21813
  console.log("Restarting service onto the freshly-installed package...");
21085
- if ((await manager.status(config)).state === "running") await manager.stop(config);
21086
- await manager.start(config);
21814
+ await restartOntoInstalledPackage({
21815
+ manager,
21816
+ config,
21817
+ platform,
21818
+ spec
21819
+ });
21087
21820
  const newSpec = await manager.readLaunchSpec(config);
21088
21821
  const launched = newSpec ? resolveVersionFromLaunchSpec(newSpec, component) : null;
21089
21822
  if (launched !== version) {
@@ -21146,14 +21879,6 @@ async function handlePick(component, platform, installBase, config, manager, res
21146
21879
  console.log(`Switched: ${current ?? "(unknown)"} -> ${target}`);
21147
21880
  }
21148
21881
  /**
21149
- * Stop the service, switch the active version pointer to an already-installed
21150
- * versioned directory (atomic symlink on Unix; uninstall→install + version file
21151
- * on Windows), restart, and persist the new `kiciVersion` into the manifest.
21152
- *
21153
- * Shared by `--rollback` (switch to the previous version) and `--pick` (switch
21154
- * to any chosen installed version).
21155
- */
21156
- /**
21157
21882
  * Refuse a version switch whose target cannot boot against the current schema.
21158
21883
  *
21159
21884
  * `--rollback` and `--pick` both flip a version pointer, and the previous
@@ -21171,7 +21896,7 @@ async function assertSchemaAllowsSwitch(args) {
21171
21896
  const rows = await readLiveMigrationRows(hooks.migrationStatus);
21172
21897
  if (rows === null) {
21173
21898
  console.log("");
21174
- console.log(" !! The running service could not be reached, so the schema was not checked.");
21899
+ console.log(` !! ${SCHEMA_NOT_CHECKED}`);
21175
21900
  console.log("");
21176
21901
  return yes ? true : confirm$1("Proceed with the switch anyway?");
21177
21902
  }
@@ -21213,8 +21938,23 @@ async function assertSchemaAllowsSwitch(args) {
21213
21938
  }
21214
21939
  return true;
21215
21940
  }
21941
+ /**
21942
+ * Stop the service, switch the active version pointer to an already-installed
21943
+ * versioned directory (atomic symlink on Unix; re-registration + version file
21944
+ * on Windows), restart, and persist the new `kiciVersion` into the manifest.
21945
+ *
21946
+ * Shared by `--rollback` (switch to the previous version) and `--pick` (switch
21947
+ * to any chosen installed version).
21948
+ */
21216
21949
  async function switchToInstalledVersion(args) {
21217
21950
  const { component, platform, installBase, config, manager, resolvedInstance, targetVersion } = args;
21951
+ const launcherPath = path.join(installBase, `${component}-${targetVersion}`, getLauncherName(component, platform));
21952
+ if (isWindows(platform)) assertWindowsLaunchReadsEnvFile({
21953
+ launch: { executablePath: launcherPath },
21954
+ release: `version ${targetVersion}`,
21955
+ action: `switch "${config.name}" to ${targetVersion}`,
21956
+ config
21957
+ });
21218
21958
  if (!await assertSchemaAllowsSwitch({
21219
21959
  component,
21220
21960
  targetVersion,
@@ -21232,10 +21972,7 @@ async function switchToInstalledVersion(args) {
21232
21972
  console.log("Service stopped.");
21233
21973
  }
21234
21974
  if (isWindows(platform)) {
21235
- const launcherPath = path.join(installBase, `${component}-${targetVersion}`, getLauncherName(component, platform));
21236
- config.executablePath = launcherPath;
21237
- await manager.uninstall(config);
21238
- await manager.install(config);
21975
+ await reregisterWindowsService(manager, config, launcherPath);
21239
21976
  writeCurrentVersion(installBase, component, targetVersion);
21240
21977
  console.log(`Service registration updated to ${launcherPath}`);
21241
21978
  } else {
@@ -21898,6 +22635,8 @@ Token vocabulary:
21898
22635
  \`kici-admin join\` to add another orchestrator as a peer to an existing cluster.
21899
22636
  `).option("--platform <type>", "Force the service platform (systemd, launchd, windows, compose). Default: detected from the host").option("--mode <mode>", `Operating mode written to the env file (${OrchestratorMode.options.join(", ")}). Naming it with --env-file overwrites the copied file's own KICI_MODE; omitting it leaves that value alone`, DEFAULT_INSTALL_MODE).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", "Run the interactive setup wizard (default on an interactive terminal)").option("--no-wizard", "Skip the wizard and write a stub env file to edit by hand").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)").option("--instance-dir <path>", "Deploy folder; the instance manifest is written here (default: current working directory)").option("--force", "Overwrite an existing same-named foreign instance").action(async (opts, command) => {
21900
22637
  try {
22638
+ const resolveModule = typeof import.meta.resolve === "function" ? (specifier) => import.meta.resolve(specifier) : void 0;
22639
+ assertServerResolvable("orchestrator", opts.binary, resolveModule);
21901
22640
  if (opts.wizard === true && opts.envFile) {
21902
22641
  console.error("Error: Cannot use --wizard with --env-file");
21903
22642
  process.exit(1);
@@ -21930,7 +22669,7 @@ Token vocabulary:
21930
22669
  })).find((c) => c.name === serviceName);
21931
22670
  if (existing && existing.instanceDir !== instanceDir && !opts.force) {
21932
22671
  const at = existing.instanceDir ?? "(no manifest)";
21933
- console.error(`Error: an orchestrator instance "${serviceName}" is already installed at ${at}.\n\nUpgrading this service? Don't re-run install — installing again is for first-time\nsetup, not upgrades. For an npm-global install run \`npm install -g kici-admin@latest\`\nthen \`kici-admin orchestrator restart\`; for a versioned-directory install run\n\`kici-admin orchestrator upgrade\`.\n\nInstalling a second, separate instance? Pass a different --name or --instance-dir,\nor --force to take over this one.`);
22672
+ console.error(`Error: an orchestrator instance "${serviceName}" is already installed at ${at}.\n\nUpgrading this service? Don't re-run install — installing again is for first-time\nsetup, not upgrades. For an npm-global install run\n\`kici-admin orchestrator upgrade --version <version>\`; for a versioned-directory install\nrun \`kici-admin orchestrator upgrade --from <archive> --version <version>\`.\n\nInstalling a second, separate instance? Pass a different --name or --instance-dir,\nor --force to take over this one.`);
21934
22673
  process.exit(1);
21935
22674
  }
21936
22675
  const configDir = getConfigDir(serviceName, userLevel);
@@ -21938,6 +22677,7 @@ Token vocabulary:
21938
22677
  fs.mkdirSync(configDir, { recursive: true });
21939
22678
  fs.mkdirSync(logDir, { recursive: true });
21940
22679
  const envFilePath = path.join(configDir, `${serviceName}.env`);
22680
+ if (platform === "windows" && !userLevel) restrictEnvFileAccess(envFilePath);
21941
22681
  let devDbUrl;
21942
22682
  if (opts.dev) {
21943
22683
  devDbUrl = startDevPostgres(`${serviceName}-dev-pg`);
@@ -21971,7 +22711,7 @@ Token vocabulary:
21971
22711
  envFilePath
21972
22712
  });
21973
22713
  fs.writeFileSync(envFilePath, upsertDeployEnvLines(fs.readFileSync(envFilePath, "utf-8"), deployLines), "utf-8");
21974
- const entryScript = opts.binary ? void 0 : fileURLToPath(import.meta.resolve(`@kici-dev/orchestrator/${selectServerEntry(fs.readFileSync(envFilePath, "utf-8"))}`));
22714
+ const entryScript = opts.binary ? void 0 : resolveServerScript(`@kici-dev/orchestrator/${selectServerEntry(fs.readFileSync(envFilePath, "utf-8"))}`, "orchestrator", resolveModule);
21975
22715
  const { executablePath, args } = resolveServiceExecutable({
21976
22716
  binary: opts.binary ? path.resolve(opts.binary) : void 0,
21977
22717
  nodePath: process.execPath,
@@ -21987,8 +22727,7 @@ Token vocabulary:
21987
22727
  } catch {}
21988
22728
  const config = {
21989
22729
  name: serviceName,
21990
- displayName: "KiCI Orchestrator",
21991
- description: "KiCI CI/CD workflow orchestrator service",
22730
+ ...SERVICE_TEXT.orchestrator,
21992
22731
  executablePath,
21993
22732
  args,
21994
22733
  nodeBinDir: path.dirname(process.execPath),
@@ -22001,7 +22740,7 @@ Token vocabulary:
22001
22740
  restartPolicy: DEFAULT_RESTART_POLICY
22002
22741
  };
22003
22742
  await manager.install(config);
22004
- const manifestFile = writeManifest(instanceDir, {
22743
+ const { file: manifestFile, warning: manifestWarning } = writeInstallManifest(instanceDir, {
22005
22744
  component: "orchestrator",
22006
22745
  name: serviceName,
22007
22746
  platform,
@@ -22012,7 +22751,11 @@ Token vocabulary:
22012
22751
  installBase: getInstallBase(platform, serviceName),
22013
22752
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
22014
22753
  kiciVersion: readKiciVersion()
22754
+ }, {
22755
+ execPath: executablePath,
22756
+ args
22015
22757
  });
22758
+ if (manifestWarning) console.warn(`Warning: ${manifestWarning}`);
22016
22759
  try {
22017
22760
  appendIndexEntry(kiciRoot, {
22018
22761
  component: "orchestrator",
@@ -22229,23 +22972,135 @@ function registerOrchestratorRestart(orchestrator) {
22229
22972
  });
22230
22973
  }
22231
22974
  //#endregion
22232
- //#region src/cli/commands/orchestrator-service/status.ts
22233
- init_service();
22234
- init_compose_path();
22235
- /** Read the env file, or empty content when it is missing or unreadable. */
22236
- function readEnvContent(envFilePath) {
22975
+ //#region src/cli/commands/service-health.ts
22976
+ /**
22977
+ * Reads a locally installed KiCI service's health endpoints and formats what
22978
+ * they report. `kici-admin orchestrator status` and `kici-admin agent status`
22979
+ * both render through here, so the two sections agree on layout and on how a
22980
+ * missing field is handled.
22981
+ */
22982
+ /** How long a health request waits unless the caller passes its own timeout. */
22983
+ const DEFAULT_REQUEST_TIMEOUT_MS = 3e3;
22984
+ /** Hex digits of a bundle hash to show: the length the build prints in its `deps:` line. */
22985
+ const SHORT_HASH_LENGTH = 12;
22986
+ /** Bind addresses that mean "every interface", reached on `localhost`. */
22987
+ const WILDCARD_HOSTS = /* @__PURE__ */ new Set([
22988
+ "",
22989
+ "0.0.0.0",
22990
+ "::",
22991
+ "[::]"
22992
+ ]);
22993
+ /**
22994
+ * Read the env file, or empty content when it is missing or unreadable. A file
22995
+ * this account may not read is reported: the port and address the caller then
22996
+ * falls back to may not be the ones the service uses. On Windows only
22997
+ * LocalSystem and Administrators can read a service's env file.
22998
+ */
22999
+ function readEnvContent(envFilePath, warn = console.warn) {
22237
23000
  try {
22238
- return fs.existsSync(envFilePath) ? fs.readFileSync(envFilePath, "utf-8") : "";
22239
- } catch {
23001
+ return fs.readFileSync(envFilePath, "utf-8");
23002
+ } catch (err) {
23003
+ const code = err.code;
23004
+ if (code === "EACCES" || code === "EPERM") warn(`Warning: cannot read ${envFilePath} (${code}), so the default port and address are used. Run the command as an administrator, or as an account that can read the file.`);
22240
23005
  return "";
22241
23006
  }
22242
23007
  }
22243
- /** Read the port from the service's env file (path from the manifest). */
22244
- function readPortFromEnvFile$1(envFilePath) {
22245
- const value = readEnvValue(readEnvContent(envFilePath), "KICI_PORT");
22246
- const parsed = value === void 0 ? NaN : parseInt(value, 10);
22247
- return isNaN(parsed) ? 4e3 : parsed;
23008
+ /**
23009
+ * Where the service answers, read from its env file: `KICI_PORT` (or
23010
+ * `defaultPort`), `KICI_HOST` and `KICI_BASE_PATH`. A service bound to every
23011
+ * interface is reached on `localhost`; one bound to a single address is reached
23012
+ * there. The agent reads none of the last two, so its env file never sets them.
23013
+ */
23014
+ function readLocalEndpoint(envContent, defaultPort) {
23015
+ const portValue = readEnvValue(envContent, "KICI_PORT");
23016
+ const port = portValue === void 0 ? NaN : parseInt(portValue, 10);
23017
+ const bindHost = readEnvValue(envContent, "KICI_HOST")?.trim() ?? "";
23018
+ const trimmedPath = (readEnvValue(envContent, "KICI_BASE_PATH")?.trim() ?? "").replace(/^\/+|\/+$/g, "");
23019
+ return {
23020
+ host: WILDCARD_HOSTS.has(bindHost) ? "localhost" : bindHost,
23021
+ port: isNaN(port) ? defaultPort : port,
23022
+ pathPrefix: trimmedPath === "" ? "" : `/${trimmedPath}`
23023
+ };
23024
+ }
23025
+ /** The URL of `pathname` on `endpoint`, with an IPv6 host bracketed. */
23026
+ function localUrl(endpoint, pathname) {
23027
+ return `http://${endpoint.host.includes(":") && !endpoint.host.startsWith("[") ? `[${endpoint.host}]` : endpoint.host}:${endpoint.port}${endpoint.pathPrefix}${pathname}`;
23028
+ }
23029
+ /**
23030
+ * GET a JSON body from a service on this host. `acceptStatuses` names the
23031
+ * non-2xx codes whose body is still an answer: `/ready` returns its checks
23032
+ * with a 503 when one fails. Returns null when nothing answers in time.
23033
+ */
23034
+ async function fetchLocalJson(endpoint, pathname, opts = {}) {
23035
+ const controller = new AbortController();
23036
+ const timeout = setTimeout(() => controller.abort(), opts.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS);
23037
+ try {
23038
+ const res = await fetch(localUrl(endpoint, pathname), { signal: controller.signal });
23039
+ if (!res.ok && !(opts.acceptStatuses ?? []).includes(res.status)) return null;
23040
+ return await res.json();
23041
+ } catch {
23042
+ return null;
23043
+ } finally {
23044
+ clearTimeout(timeout);
23045
+ }
23046
+ }
23047
+ /**
23048
+ * A `/health` body with its deprecated `buildCommit` replaced by its version.
23049
+ * A service older than this CLI reports a commit ID from KiCI's private
23050
+ * repository there, which a reader cannot resolve; `--json` keeps the key, a
23051
+ * string as before, and carries the release version in it. A body that reports
23052
+ * no `buildCommit` comes back unchanged.
23053
+ */
23054
+ function hideBuildCommit(health) {
23055
+ if (!("buildCommit" in health)) return health;
23056
+ return {
23057
+ ...health,
23058
+ buildCommit: health.version ?? "unknown"
23059
+ };
22248
23060
  }
23061
+ /**
23062
+ * Rows for the build fingerprint and process uptime of a `/health` body. A
23063
+ * field the body lacks is skipped, so a service older than this CLI renders
23064
+ * the fields it does report. The version line never shows `buildCommit`
23065
+ * (see {@link hideBuildCommit}).
23066
+ */
23067
+ function buildInfoRows(health) {
23068
+ const rows = [];
23069
+ if (health.version) rows.push(["Version", health.buildDate ? `${health.version} (built ${health.buildDate})` : health.version]);
23070
+ if (health.sdkVersion) rows.push(["SDK", health.sdkBundleHash ? `${health.sdkVersion} (bundle ${health.sdkBundleHash.slice(0, SHORT_HASH_LENGTH)})` : health.sdkVersion]);
23071
+ if (typeof health.uptime === "number") rows.push(["Uptime", formatUptime(Math.floor(health.uptime))]);
23072
+ return rows;
23073
+ }
23074
+ /**
23075
+ * A status section: a blank separator line, the heading, then one line per
23076
+ * row with each value starting at `valueColumn`. A body with no field this CLI
23077
+ * recognises still gets a line saying so, so the heading never stands alone.
23078
+ */
23079
+ function formatHealthSection(heading, rows, valueColumn) {
23080
+ return [
23081
+ "",
23082
+ heading,
23083
+ ...(rows.length > 0 ? rows : [["Health", "answered, with no fields this CLI recognises"]]).map(([label, value]) => `${label}:`.padEnd(valueColumn) + value)
23084
+ ];
23085
+ }
23086
+ //#endregion
23087
+ //#region src/cli/commands/orchestrator-service/status.ts
23088
+ init_service();
23089
+ init_compose_path();
23090
+ /** Time `/ready` gets beyond the database-pool acquire timeout, for its query and its response. */
23091
+ const READINESS_MARGIN_MS = 3e3;
23092
+ /**
23093
+ * How long to wait for `/ready`. Its database check waits up to the pool's
23094
+ * acquire timeout before it reports `database: false`, so the wait covers that
23095
+ * timeout plus a margin. An acquire timeout of 0 lets the pool wait without
23096
+ * limit; the default then bounds the wait.
23097
+ */
23098
+ function readinessTimeoutMs(envContent) {
23099
+ const configured = Number(readEnvValue(envContent, "KICI_DB_POOL_ACQUIRE_TIMEOUT_MS"));
23100
+ return (Number.isFinite(configured) && configured > 0 ? configured : DB_POOL_ACQUIRE_TIMEOUT_DEFAULT_MS) + READINESS_MARGIN_MS;
23101
+ }
23102
+ /** Column the values of the orchestrator section start at. */
23103
+ const VALUE_COLUMN$1 = 12;
22249
23104
  /** The scaler config the env file names, by path first and directory second. */
22250
23105
  function readScalerConfig(envContent) {
22251
23106
  return readEnvValue(envContent, "KICI_SCALER_CONFIG_PATH") ?? readEnvValue(envContent, "KICI_SCALER_CONFIG_DIR");
@@ -22266,21 +23121,14 @@ function formatConfigPaths(args) {
22266
23121
  if (args.platform === "compose") lines.push(`Compose file: ${composeFilePath(args.envFilePath, args.serviceName)}`);
22267
23122
  return lines;
22268
23123
  }
22269
- /** Query orchestrator health API. */
22270
- async function queryHealth$1(port) {
22271
- try {
22272
- const controller = new AbortController();
22273
- const timeout = setTimeout(() => controller.abort(), 3e3);
22274
- const res = await fetch(`http://localhost:${port}/health`, { signal: controller.signal });
22275
- clearTimeout(timeout);
22276
- if (!res.ok) return null;
22277
- return await res.json();
22278
- } catch {
22279
- return null;
22280
- }
23124
+ /** `yes` when every readiness check passed, otherwise `no` and the failing checks. */
23125
+ function formatReadiness(readiness) {
23126
+ if (readiness.status === ReadinessStatus.Ready) return "yes";
23127
+ const failing = Object.entries(readiness.checks ?? {}).filter(([, passed]) => !passed).map(([name]) => name);
23128
+ return failing.length > 0 ? `no (failing: ${failing.join(", ")})` : "no";
22281
23129
  }
22282
23130
  /** Format the status output as a readable table. */
22283
- function formatStatus$1(serviceStatus, health, serviceName) {
23131
+ function formatStatus$1(serviceStatus, health, readiness, serviceName) {
22284
23132
  const lines = [];
22285
23133
  lines.push(`Service: ${serviceName}`);
22286
23134
  lines.push(`State: ${serviceStatus.state}`);
@@ -22288,18 +23136,11 @@ function formatStatus$1(serviceStatus, health, serviceName) {
22288
23136
  if (serviceStatus.uptime != null) lines.push(`Uptime: ${formatUptime(serviceStatus.uptime)}`);
22289
23137
  if (serviceStatus.startedAt) lines.push(`Started: ${serviceStatus.startedAt}`);
22290
23138
  if (health) {
22291
- lines.push("");
22292
- lines.push("--- KiCI orchestrator ---");
22293
- if (health.mode) lines.push(`Mode: ${health.mode}`);
22294
- if (health.port) lines.push(`Port: ${health.port}`);
22295
- if (health.database) lines.push(`Database: ${health.database}`);
22296
- if (health.platformRelay) lines.push(`Platform relay: ${health.platformRelay}`);
22297
- if (health.agents != null) lines.push(`Agents: ${health.agents}`);
22298
- if (health.scaler) {
22299
- const s = health.scaler;
22300
- lines.push(`Scaler: ${s.type ?? "none"} (warm: ${s.warm ?? 0}, max: ${s.max ?? 0})`);
22301
- }
22302
- if (health.jobs) lines.push(`Jobs: ${health.jobs.pending ?? 0} pending, ${health.jobs.running ?? 0} running`);
23139
+ const rows = [];
23140
+ if (health.status) rows.push(["Health", health.status]);
23141
+ rows.push(["Ready", readiness ? formatReadiness(readiness) : "unknown (/ready did not answer)"]);
23142
+ rows.push(...buildInfoRows(health));
23143
+ lines.push(...formatHealthSection("--- KiCI orchestrator ---", rows, VALUE_COLUMN$1));
22303
23144
  } else if (serviceStatus.state === "running") {
22304
23145
  lines.push("");
22305
23146
  lines.push("(Could not reach health API)");
@@ -22307,12 +23148,13 @@ function formatStatus$1(serviceStatus, health, serviceName) {
22307
23148
  return lines.join("\n");
22308
23149
  }
22309
23150
  /** Build JSON output combining service + health data. */
22310
- function buildJsonOutput$1(serviceStatus, health, serviceName, configPaths) {
23151
+ function buildJsonOutput$1(serviceStatus, health, readiness, serviceName, configPaths) {
22311
23152
  return {
22312
23153
  service: serviceName,
22313
23154
  ...serviceStatus,
22314
23155
  configPaths,
22315
- health: health ?? void 0
23156
+ health: health ? hideBuildCommit(health) : void 0,
23157
+ readiness: readiness ?? void 0
22316
23158
  };
22317
23159
  }
22318
23160
  function registerStatusCommand(parent) {
@@ -22351,14 +23193,21 @@ function registerStatusCommand(parent) {
22351
23193
  envContent
22352
23194
  });
22353
23195
  let health = null;
22354
- if (serviceStatus.state === "running") health = await queryHealth$1(readPortFromEnvFile$1(config.envFilePath));
23196
+ let readiness = null;
23197
+ if (serviceStatus.state === "running") {
23198
+ const endpoint = readLocalEndpoint(envContent, ORCHESTRATOR_DEFAULT_PORT);
23199
+ [health, readiness] = await Promise.all([fetchLocalJson(endpoint, "/health"), fetchLocalJson(endpoint, "/ready", {
23200
+ acceptStatuses: [503],
23201
+ timeoutMs: readinessTimeoutMs(envContent)
23202
+ })]);
23203
+ }
22355
23204
  const jsonConfigPaths = { envFile: config.envFilePath };
22356
23205
  const scalerPath = readScalerConfig(envContent);
22357
23206
  if (scalerPath) jsonConfigPaths.scalerConfig = scalerPath;
22358
23207
  if (installPlatform === "compose") jsonConfigPaths.composeFile = composeFilePath(config.envFilePath, config.name);
22359
- if (opts.json) console.log(JSON.stringify(buildJsonOutput$1(serviceStatus, health, config.name, jsonConfigPaths), null, 2));
23208
+ if (opts.json) console.log(JSON.stringify(buildJsonOutput$1(serviceStatus, health, readiness, config.name, jsonConfigPaths), null, 2));
22360
23209
  else {
22361
- console.log(formatStatus$1(serviceStatus, health, config.name));
23210
+ console.log(formatStatus$1(serviceStatus, health, readiness, config.name));
22362
23211
  console.log("");
22363
23212
  console.log(configPathLines.join("\n"));
22364
23213
  }
@@ -22525,6 +23374,26 @@ function resolveDatabaseUrl$2(envFilePath, readFile) {
22525
23374
  return url;
22526
23375
  }
22527
23376
  //#endregion
23377
+ //#region src/agent-packaging/node-runtime-members.ts
23378
+ /**
23379
+ * The members of an official Node.js Linux or macOS archive that a KiCI payload
23380
+ * vendors, relative to the archive's `node-v<version>-<os>-<arch>/` directory:
23381
+ * the `node` binary, the `npm` and `npx` launchers, and the bundled npm package.
23382
+ * With them in place, the agent's `resolveNpm()` finds
23383
+ * `<nodeDir>/../lib/node_modules/npm/bin/npm-cli.js` beside the vendored `node`,
23384
+ * so the builder role needs no npm on the host.
23385
+ *
23386
+ * This module has no imports so that repository tooling can read the list:
23387
+ * `scripts/lib/node-dist.mjs` (the standalone package builder) keeps its own
23388
+ * copy, and its test pins the two lists equal.
23389
+ */
23390
+ const NODE_RUNTIME_MEMBERS = [
23391
+ "bin/node",
23392
+ "bin/npm",
23393
+ "bin/npx",
23394
+ "lib/node_modules/npm"
23395
+ ];
23396
+ //#endregion
22528
23397
  //#region src/agent-packaging/node-binary.ts
22529
23398
  /**
22530
23399
  * Fetch a Node dist URL, retrying transient failures. The runtime download is a
@@ -22583,12 +23452,7 @@ async function downloadNodeBinary(opts) {
22583
23452
  const archivePath = path.join(prefixDir, filename);
22584
23453
  writeFileSync(archivePath, buf);
22585
23454
  const inner = `node-v${opts.version}-${nodeOs}-${nodeArch}`;
22586
- const members = [
22587
- `${inner}/bin/node`,
22588
- `${inner}/bin/npm`,
22589
- `${inner}/bin/npx`,
22590
- `${inner}/lib/node_modules/npm`
22591
- ];
23455
+ const members = NODE_RUNTIME_MEMBERS.map((member) => `${inner}/${member}`);
22592
23456
  const out = $.sync({
22593
23457
  nothrow: true,
22594
23458
  stdio: "pipe"
@@ -22807,7 +23671,7 @@ init_manifest();
22807
23671
  const ALL_PLATFORMS = AgentPlatform.options;
22808
23672
  /** The orchestrator's own version (single-version invariant) = the packaged agent version. */
22809
23673
  function resolveKiciVersion() {
22810
- return "0.11.0";
23674
+ return "0.12.0";
22811
23675
  }
22812
23676
  /** Parse the --platform value: default set | single | CSV | `all`. */
22813
23677
  function parsePlatforms(raw) {
@@ -22991,6 +23855,17 @@ function registerOrchestratorServiceCommands(program, getClient, tryGetClient) {
22991
23855
  registerOrchestratorDrain(orchestrator, getClient);
22992
23856
  }
22993
23857
  //#endregion
23858
+ //#region src/cli/commands/agent-service/agent-env.ts
23859
+ /** Render the env file, one `KEY=value` line per field that was given. */
23860
+ function renderAgentEnvFile(fields) {
23861
+ const lines = [fields.header];
23862
+ if (fields.orchestratorUrl) lines.push(`KICI_ORCHESTRATOR_URL=${fields.orchestratorUrl}`);
23863
+ if (fields.token) lines.push(`KICI_AGENT_TOKEN=${fields.token}`);
23864
+ if (fields.labels && fields.labels.length > 0) lines.push(`KICI_LABELS=${fields.labels.join(",")}`);
23865
+ if (fields.port !== void 0) lines.push(`KICI_PORT=${fields.port}`);
23866
+ return `${lines.join("\n")}\n`;
23867
+ }
23868
+ //#endregion
22994
23869
  //#region src/cli/wizard/agent-wizard.ts
22995
23870
  var agent_wizard_exports = /* @__PURE__ */ __exportAll({ runAgentWizard: () => runAgentWizard });
22996
23871
  /**
@@ -23011,13 +23886,13 @@ async function runAgentWizard() {
23011
23886
  const agentToken = await promptSecret("Agent authentication token:");
23012
23887
  console.log("");
23013
23888
  const labels = (await input({
23014
- message: "Agent labels (comma-separated, empty = accept all jobs):",
23889
+ message: "Agent labels (comma-separated; empty = only the kici:* labels every agent reports):",
23015
23890
  default: ""
23016
23891
  })).split(",").map((l) => l.trim()).filter(Boolean);
23017
23892
  console.log("");
23018
23893
  console.log("Configuration complete. Summary:");
23019
23894
  console.log(` Orchestrator: ${orchestratorUrl}`);
23020
- console.log(` Labels: ${labels.length > 0 ? labels.join(", ") : "(none - accepts all jobs)"}`);
23895
+ console.log(` Labels: ${labels.length > 0 ? labels.join(", ") : "(none; only the kici:* labels every agent reports)"}`);
23021
23896
  console.log("");
23022
23897
  return {
23023
23898
  orchestratorUrl,
@@ -23031,13 +23906,26 @@ var init_agent_wizard = __esmMin((() => {
23031
23906
  //#endregion
23032
23907
  //#region src/cli/commands/agent-service/install.ts
23033
23908
  init_service();
23909
+ /** `--port` as a TCP port number; exits with an error for anything else. */
23910
+ function parsePort(value) {
23911
+ if (value === void 0) return void 0;
23912
+ const port = /^\d+$/.test(value) ? Number(value) : NaN;
23913
+ if (!(port >= 1 && port <= 65535)) {
23914
+ console.error(`Error: --port must be a TCP port between 1 and 65535 (got "${value}")`);
23915
+ process.exit(1);
23916
+ }
23917
+ return port;
23918
+ }
23034
23919
  function registerAgentInstall(agent) {
23035
- agent.command("install").description("Install the agent as a system service").option("--platform <type>", "Force the service platform (systemd, launchd, windows, compose). Default: detected from the host").option("--env-file <path>", "Path to existing env/config file to use").option("--binary <path>", "Path to agent binary (default: current executable)").option("--name <name>", "Service name", "kici-agent").option("--orchestrator-url <url>", "URL of the orchestrator to connect to").option("--token <token>", "Agent authentication token").option("--labels <labels>", "Comma-separated agent labels for routing").option("--wizard", "Interactive wizard for guided setup").option("--system", "Install as system-level service (requires root)").option("--user-level", "Install as user-level service (no root required)").option("--instance-dir <path>", "Deploy folder; the instance manifest is written here (default: current working directory)").option("--force", "Overwrite an existing same-named foreign instance").action(async (opts) => {
23920
+ agent.command("install").description("Install the agent as a system service").option("--platform <type>", "Force the service platform (systemd, launchd, windows, compose). Default: detected from the host").option("--env-file <path>", "Path to existing env/config file to use").option("--binary <path>", "Path to agent binary (default: current executable)").option("--name <name>", "Service name", "kici-agent").option("--orchestrator-url <url>", "URL of the orchestrator to connect to").option("--token <token>", "Agent authentication token").option("--labels <labels>", "Comma-separated agent labels for routing").option("--port <port>", `HTTP port for the agent's health and metrics endpoints; each agent on one host needs its own (default: ${AGENT_DEFAULT_PORT})`).option("--wizard", "Interactive wizard for guided setup").option("--system", "Install as system-level service (requires root)").option("--user-level", "Install as user-level service (no root required)").option("--instance-dir <path>", "Deploy folder; the instance manifest is written here (default: current working directory)").option("--force", "Overwrite an existing same-named foreign instance").action(async (opts) => {
23036
23921
  try {
23922
+ const resolveModule = typeof import.meta.resolve === "function" ? (specifier) => import.meta.resolve(specifier) : void 0;
23923
+ assertServerResolvable("agent", opts.binary, resolveModule);
23037
23924
  if (opts.wizard && opts.envFile) {
23038
23925
  console.error("Error: Cannot use --wizard with --env-file");
23039
23926
  process.exit(1);
23040
23927
  }
23928
+ const port = parsePort(opts.port);
23041
23929
  const platform = detectPlatform(opts.platform);
23042
23930
  const userLevel = resolveUserLevel(opts);
23043
23931
  const serviceName = opts.name;
@@ -23063,14 +23951,17 @@ function registerAgentInstall(agent) {
23063
23951
  fs.mkdirSync(configDir, { recursive: true });
23064
23952
  fs.mkdirSync(logDir, { recursive: true });
23065
23953
  const envFilePath = path.join(configDir, `${serviceName}.env`);
23954
+ if (platform === "windows" && !userLevel) restrictEnvFileAccess(envFilePath);
23066
23955
  if (opts.wizard) {
23067
23956
  const { runAgentWizard } = await Promise.resolve().then(() => (init_agent_wizard(), agent_wizard_exports));
23068
23957
  const wizardConfig = await runAgentWizard();
23069
- let envContent = "# KiCI agent configuration (generated by setup wizard)\n";
23070
- envContent += `KICI_ORCHESTRATOR_URL=${wizardConfig.orchestratorUrl}\n`;
23071
- envContent += `KICI_AGENT_TOKEN=${wizardConfig.agentToken}\n`;
23072
- if (wizardConfig.labels.length > 0) envContent += `KICI_AGENT_LABELS=${wizardConfig.labels.join(",")}\n`;
23073
- writeFileSecurelySync(envFilePath, envContent, 384);
23958
+ writeFileSecurelySync(envFilePath, renderAgentEnvFile({
23959
+ header: "# KiCI agent configuration (generated by setup wizard)",
23960
+ orchestratorUrl: wizardConfig.orchestratorUrl,
23961
+ token: wizardConfig.agentToken,
23962
+ labels: wizardConfig.labels,
23963
+ port
23964
+ }), 384);
23074
23965
  console.log(`Wrote wizard configuration to ${envFilePath}`);
23075
23966
  } else if (opts.envFile) {
23076
23967
  const source = path.resolve(opts.envFile);
@@ -23081,15 +23972,17 @@ function registerAgentInstall(agent) {
23081
23972
  copyFileSecurelySync(source, envFilePath, 384);
23082
23973
  console.log(`Copied env file to ${envFilePath}`);
23083
23974
  } else if (!fs.existsSync(envFilePath)) {
23084
- let envContent = `# KiCI agent configuration\n`;
23085
- if (opts.orchestratorUrl) envContent += `KICI_ORCHESTRATOR_URL=${opts.orchestratorUrl}\n`;
23086
- if (opts.token) envContent += `KICI_AGENT_TOKEN=${opts.token}\n`;
23087
- if (opts.labels) envContent += `KICI_AGENT_LABELS=${opts.labels}\n`;
23088
- writeFileSecurelySync(envFilePath, envContent, 384);
23975
+ writeFileSecurelySync(envFilePath, renderAgentEnvFile({
23976
+ header: "# KiCI agent configuration",
23977
+ orchestratorUrl: opts.orchestratorUrl,
23978
+ token: opts.token,
23979
+ labels: opts.labels?.split(",").map((l) => l.trim()).filter(Boolean),
23980
+ port
23981
+ }), 384);
23089
23982
  console.log(`Created env file at ${envFilePath}`);
23090
23983
  }
23091
23984
  if (fs.existsSync(envFilePath)) fs.chmodSync(envFilePath, 384);
23092
- const entryScript = opts.binary ? void 0 : fileURLToPath(import.meta.resolve("@kici-dev/agent/server"));
23985
+ const entryScript = opts.binary ? void 0 : resolveServerScript("@kici-dev/agent/server", "agent", resolveModule);
23093
23986
  const { executablePath, args } = resolveServiceExecutable({
23094
23987
  binary: opts.binary ? path.resolve(opts.binary) : void 0,
23095
23988
  nodePath: process.execPath,
@@ -23097,8 +23990,7 @@ function registerAgentInstall(agent) {
23097
23990
  });
23098
23991
  const config = {
23099
23992
  name: serviceName,
23100
- displayName: "KiCI Agent",
23101
- description: "KiCI CI/CD workflow execution agent service",
23993
+ ...SERVICE_TEXT.agent,
23102
23994
  executablePath,
23103
23995
  args,
23104
23996
  nodeBinDir: path.dirname(process.execPath),
@@ -23110,7 +24002,7 @@ function registerAgentInstall(agent) {
23110
24002
  restartPolicy: DEFAULT_RESTART_POLICY
23111
24003
  };
23112
24004
  await manager.install(config);
23113
- const manifestFile = writeManifest(instanceDir, {
24005
+ const { file: manifestFile, warning: manifestWarning } = writeInstallManifest(instanceDir, {
23114
24006
  component: "agent",
23115
24007
  name: serviceName,
23116
24008
  platform,
@@ -23121,7 +24013,11 @@ function registerAgentInstall(agent) {
23121
24013
  installBase: getInstallBase(platform, serviceName),
23122
24014
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
23123
24015
  kiciVersion: readKiciVersion()
24016
+ }, {
24017
+ execPath: executablePath,
24018
+ args
23124
24019
  });
24020
+ if (manifestWarning) console.warn(`Warning: ${manifestWarning}`);
23125
24021
  try {
23126
24022
  appendIndexEntry(kiciRoot, {
23127
24023
  component: "agent",
@@ -23333,37 +24229,8 @@ function registerAgentRestart(agent) {
23333
24229
  //#endregion
23334
24230
  //#region src/cli/commands/agent-service/status.ts
23335
24231
  init_service();
23336
- /** Read the port from the agent's env file (path from the manifest). */
23337
- function readPortFromEnvFile(envFilePath) {
23338
- if (!fs.existsSync(envFilePath)) return 4001;
23339
- try {
23340
- const content = fs.readFileSync(envFilePath, "utf-8");
23341
- for (const line of content.split("\n")) {
23342
- const trimmed = line.trim();
23343
- if (trimmed.startsWith("#") || !trimmed.includes("=")) continue;
23344
- const [key, ...rest] = trimmed.split("=");
23345
- if (key?.trim() === "KICI_AGENT_PORT" || key?.trim() === "KICI_PORT") {
23346
- const val = rest.join("=").trim().replace(/^["']|["']$/g, "");
23347
- const parsed = parseInt(val, 10);
23348
- if (!isNaN(parsed)) return parsed;
23349
- }
23350
- }
23351
- } catch {}
23352
- return 4001;
23353
- }
23354
- /** Query agent health API. */
23355
- async function queryHealth(port) {
23356
- try {
23357
- const controller = new AbortController();
23358
- const timeout = setTimeout(() => controller.abort(), 3e3);
23359
- const res = await fetch(`http://localhost:${port}/health`, { signal: controller.signal });
23360
- clearTimeout(timeout);
23361
- if (!res.ok) return null;
23362
- return await res.json();
23363
- } catch {
23364
- return null;
23365
- }
23366
- }
24232
+ /** Column the values of the agent section start at. */
24233
+ const VALUE_COLUMN = 14;
23367
24234
  function formatStatus(serviceStatus, health, serviceName) {
23368
24235
  const lines = [];
23369
24236
  lines.push(`Service: ${serviceName}`);
@@ -23372,13 +24239,12 @@ function formatStatus(serviceStatus, health, serviceName) {
23372
24239
  if (serviceStatus.uptime != null) lines.push(`Uptime: ${formatUptime(serviceStatus.uptime)}`);
23373
24240
  if (serviceStatus.startedAt) lines.push(`Started: ${serviceStatus.startedAt}`);
23374
24241
  if (health) {
23375
- lines.push("");
23376
- lines.push("--- KiCI agent ---");
23377
- if (health.labels && health.labels.length > 0) lines.push(`Labels: ${health.labels.join(", ")}`);
23378
- else lines.push("Labels: (none - accepts all jobs)");
23379
- if (health.orchestratorConnection) lines.push(`Orchestrator: ${health.orchestratorConnection}`);
23380
- if (health.currentJob) lines.push(`Current job: ${health.currentJob.id ?? "unknown"} (${health.currentJob.workflow ?? ""})`);
23381
- else lines.push("Current job: idle");
24242
+ const rows = [];
24243
+ if (health.agentId) rows.push(["Agent ID", health.agentId]);
24244
+ if (typeof health.connected === "boolean") rows.push(["Orchestrator", health.connected ? "connected" : "disconnected"]);
24245
+ if (typeof health.activeJobs === "number") rows.push(["Active jobs", String(health.activeJobs)]);
24246
+ rows.push(...buildInfoRows(health));
24247
+ lines.push(...formatHealthSection("--- KiCI agent ---", rows, VALUE_COLUMN));
23382
24248
  } else if (serviceStatus.state === "running") {
23383
24249
  lines.push("");
23384
24250
  lines.push("(Could not reach agent health API)");
@@ -23389,7 +24255,7 @@ function buildJsonOutput(serviceStatus, health, serviceName) {
23389
24255
  return {
23390
24256
  service: serviceName,
23391
24257
  ...serviceStatus,
23392
- health: health ?? void 0
24258
+ health: health ? hideBuildCommit(health) : void 0
23393
24259
  };
23394
24260
  }
23395
24261
  function registerAgentStatusCommand(parent) {
@@ -23421,7 +24287,7 @@ function registerAgentStatusCommand(parent) {
23421
24287
  };
23422
24288
  const serviceStatus = await manager.status(config);
23423
24289
  let health = null;
23424
- if (serviceStatus.state === "running") health = await queryHealth(readPortFromEnvFile(config.envFilePath));
24290
+ if (serviceStatus.state === "running") health = await fetchLocalJson(readLocalEndpoint(readEnvContent(config.envFilePath), AGENT_DEFAULT_PORT), "/health");
23425
24291
  if (opts.json) console.log(JSON.stringify(buildJsonOutput(serviceStatus, health, config.name), null, 2));
23426
24292
  else console.log(formatStatus(serviceStatus, health, config.name));
23427
24293
  } catch (err) {
@@ -26258,7 +27124,7 @@ function registerClusterNameCommands(program, getClient) {
26258
27124
  * (`cluster_meta.cluster_id`) and the durable S3 sentinel
26259
27125
  * (`<prefix>/.kici-cluster-id`).
26260
27126
  *
26261
- * Used by `kici-admin cluster reconcile-identity` and the staging deploy's
27127
+ * Used by `kici-admin cluster reconcile-identity`, which a deploy can run as a
26262
27128
  * pre-orchestrator-start self-heal step. Talks DB + S3 directly (never the
26263
27129
  * orchestrator HTTP admin API) so it works while the orchestrator process is
26264
27130
  * down — which is exactly when a "Cluster identity mismatch" boot failure needs
@@ -26455,9 +27321,8 @@ function registerClusterCommands(program) {
26455
27321
  *
26456
27322
  * Direct-DB mode: activated when `--database-url` is passed (or
26457
27323
  * KICI_DATABASE_URL / DATABASE_URL is set). Opens its own pool and runs the
26458
- * SQL directly. Used by e2e/helpers/deploy.ts during warm-start cleanup,
26459
- * when the orchestrator is deliberately stopped so a stale scoped-secret
26460
- * can't crash it on boot.
27324
+ * SQL directly. Use it for cleanup while the orchestrator is deliberately
27325
+ * stopped, so a stale scoped-secret can't crash it on boot.
26461
27326
  */
26462
27327
  function resolveDirectDbUrl$5(explicit) {
26463
27328
  return explicit ?? process.env.KICI_DATABASE_URL ?? null;
@@ -27489,7 +28354,7 @@ function registerEventDlqCommands(program, getClient) {
27489
28354
  * Contract:
27490
28355
  * - tenant column: `routing_key` (denormalized in migration 006)
27491
28356
  * - partition column: `created_at`
27492
- * - warm TTL: 30 days (design §5 matrix row 9)
28357
+ * - warm TTL: 30 days
27493
28358
  * - eligibility: created_at < cutoff (steps are append-only and have no
27494
28359
  * terminal-status predicate — there is no UPDATE statement against
27495
28360
  * `execution_steps` in the orchestrator codebase, so age alone is
@@ -27647,7 +28512,7 @@ var init_execution_steps = __esmMin((() => {
27647
28512
  * Contract:
27648
28513
  * - tenant column: `routing_key` (denormalized in migration 006)
27649
28514
  * - partition column: `created_at`
27650
- * - warm TTL: 30 days (design §5 matrix row 8)
28515
+ * - warm TTL: 30 days
27651
28516
  * - eligibility: terminal job status AND no live steps remain for this
27652
28517
  * (run, job)
27653
28518
  *
@@ -27822,7 +28687,7 @@ var init_execution_jobs = __esmMin((() => {
27822
28687
  * Contract:
27823
28688
  * - tenant column: `routing_key`
27824
28689
  * - partition column: `created_at`
27825
- * - warm TTL: 30 days (design §5 matrix row 7)
28690
+ * - warm TTL: 30 days
27826
28691
  * - eligibility: terminal run status AND no live jobs reference this run
27827
28692
  *
27828
28693
  * Order: registered LAST in the orchestrator cold-store. Within one
@@ -27940,7 +28805,7 @@ var init_execution_runs = __esmMin((() => {
27940
28805
  return row.created_at;
27941
28806
  }
27942
28807
  /**
27943
- * Phase F — natural-key extractor used by `replayRow` so callers
28808
+ * Natural-key extractor used by `replayRow` so callers
27944
28809
  * (the rerun pipeline) can locate the chunk holding a given UUID
27945
28810
  * `run_id` without knowing the internal SERIAL `id` returned by
27946
28811
  * `rowId()`.
@@ -27949,10 +28814,10 @@ var init_execution_runs = __esmMin((() => {
27949
28814
  return row.run_id;
27950
28815
  }
27951
28816
  /**
27952
- * Phase F — replay a chunk's `execution_runs` rows back into
28817
+ * Replay a chunk's `execution_runs` rows back into
27953
28818
  * Orchestrator PG. Mirrors the Platform-side replayInsert; the
27954
28819
  * audit row goes to `access_log` (not `audit_log`) because the
27955
- * orchestrator's audit surface is access_log per design §8.
28820
+ * orchestrator's audit surface is access_log.
27956
28821
  *
27957
28822
  * Idempotent on duplicate replays via `ON CONFLICT (run_id) DO
27958
28823
  * NOTHING`. Decrements `cold_store_chunk_counts` (floored at 0).
@@ -28051,8 +28916,8 @@ var init_execution_runs = __esmMin((() => {
28051
28916
  * Contract:
28052
28917
  * - tenant column: `routing_key` (NULL → synthetic `__orchestrator__`)
28053
28918
  * - partition column: `timestamp`
28054
- * - warm TTL: 90 days (longer window than other audit tables — design
28055
- * §5 matrix row 10. Volume is low, forensic value is high)
28919
+ * - warm TTL: 90 days (longer window than other audit tables — volume is
28920
+ * low, forensic value is high)
28056
28921
  *
28057
28922
  * Synthetic tenant: rows without `routing_key` (e.g. orchestrator-level
28058
28923
  * key rotations done before any source is bound) collapse to a single
@@ -28185,7 +29050,7 @@ var init_secret_audit_log = __esmMin((() => {
28185
29050
  return row.timestamp;
28186
29051
  }
28187
29052
  /**
28188
- * Phase 2 — per-row cold-retention TTL. Sampled `resolve` /
29053
+ * Per-row cold-retention TTL. Sampled `resolve` /
28189
29054
  * `resolve_named` rows get 180d cold; mutations get `'forever'`. The
28190
29055
  * denied-outcome override promotes to 730d (forensic).
28191
29056
  *
@@ -28299,7 +29164,7 @@ var init_secret_audit_log = __esmMin((() => {
28299
29164
  * Contract:
28300
29165
  * - tenant column: `org_id` (NULL → synthetic `__orchestrator__`)
28301
29166
  * - partition column: `created_at`
28302
- * - warm TTL: 30 days (design §5 matrix row 11)
29167
+ * - warm TTL: 30 days
28303
29168
  *
28304
29169
  * Replaces the previous 90-day `expires_at`-based hard delete (removed
28305
29170
  * by migration 007). Rows older than 30 days now live in S3
@@ -28440,7 +29305,7 @@ var init_access_log = __esmMin((() => {
28440
29305
  return row.created_at;
28441
29306
  }
28442
29307
  /**
28443
- * Phase 2 — per-row cold-retention TTL. Mirrors the warm-side override
29308
+ * Per-row cold-retention TTL. Mirrors the warm-side override
28444
29309
  * layering: outcome=denied/error → 730d (forensic), platform_operator →
28445
29310
  * `'forever'` (compliance), otherwise per-action map. The framework
28446
29311
  * groups rows by `coldDaysToBucket(coldTtlDays(row))` at archive time.
@@ -28545,27 +29410,21 @@ var init_access_log = __esmMin((() => {
28545
29410
  //#endregion
28546
29411
  //#region src/cold-store/tables/event-log.ts
28547
29412
  /**
28548
- * `event_log` cold-store adapter (Orchestrator side) —.
28549
- *
28550
- * §2 row 6
28551
- * and §10.
29413
+ * `event_log` cold-store adapter (Orchestrator side).
28552
29414
  *
28553
29415
  * Contract:
28554
29416
  * - tenant column: `routing_key` (NOT NULL on this side — no
28555
29417
  * synthetic-tenant fallback like access_log / secret_audit_log)
28556
29418
  * - partition column: `received_at`
28557
- * - warm TTL: 30 days (replaces the previous 30-day hard delete in
28558
- * `packages/orchestrator/src/webhook/event-log.ts:cleanup` +
28559
- * `packages/orchestrator/src/queue/cleanup.ts:runCleanup` step 4)
29419
+ * - warm TTL: 30 days (rows are archived, not hard-deleted)
28560
29420
  *
28561
29421
  * Per-row payload retention: rows on this table carry a `payload_key`
28562
29422
  * pointing at a gzipped webhook body in object storage (LogStorage).
28563
29423
  * Cold-store packages the row metadata — including `payload_key` —
28564
29424
  * but does NOT touch the body the key points to. The body remains in
28565
29425
  * S3 indefinitely so the dashboard delivery-detail page resolves
28566
- * payload reads identically for hot and cold rows. The pre-Phase-E
28567
- * cleanup that deleted both the row and the payload blob in
28568
- * lock-step is retired.
29426
+ * payload reads identically for hot and cold rows. Nothing deletes the
29427
+ * row and the payload blob in lock-step.
28569
29428
  *
28570
29429
  * Recursive write: archiving event_log rows writes one access_log
28571
29430
  * audit row per chunk (the orchestrator's audit surface). The new
@@ -28707,17 +29566,15 @@ var init_event_log = __esmMin((() => {
28707
29566
  /**
28708
29567
  * Orchestrator-side cold-store.
28709
29568
  *
28710
- * Extends BaseColdStore with orchestrator-specific table adapters.
28711
- * Phase A registered no adapters; Phase C added execution_runs /
28712
- * execution_jobs / execution_steps. Phase D adds secret_audit_log
28713
- * and access_log.
29569
+ * Extends BaseColdStore with orchestrator-specific table adapters:
29570
+ * execution_runs / execution_jobs / execution_steps, secret_audit_log,
29571
+ * access_log and event_log.
28714
29572
  */
28715
29573
  /**
28716
29574
  * Build the orchestrator cold-store config from env vars. Reads
28717
- * `KICI_COLD_STORE_*` vars mirrored with the Platform side. YAML
28718
- * integration (per the design doc) lands in a follow-up commit that
28719
- * wires the `coldStore` section of the orchestrator config schema;
28720
- * Phase A ships the env-var path only.
29575
+ * `KICI_COLD_STORE_*` vars mirrored with the Platform side. The
29576
+ * orchestrator config schema has no `coldStore` section, so env vars are
29577
+ * the only input.
28721
29578
  */
28722
29579
  function readOrchestratorColdStoreConfig(env = process.env) {
28723
29580
  const enabled = env.KICI_COLD_STORE_ENABLED === "true";
@@ -28810,7 +29667,7 @@ var init_orchestrator_cold_store = __esmMin((() => {
28810
29667
  this.registerAdapter(new EventLogAdapter(deps.kdb, deps.instanceId, { overrides: deps.config.tables.event_log ?? deps.config.tables["event_log"] }));
28811
29668
  }
28812
29669
  /**
28813
- * Phase 2 — query the orchestrator's `cold_store_chunks` table for
29670
+ * Query the orchestrator's `cold_store_chunks` table for
28814
29671
  * chunks past their per-row cold-retention horizon. See the matching
28815
29672
  * Platform-side method for the full rationale.
28816
29673
  */
@@ -28843,7 +29700,7 @@ var init_orchestrator_cold_store = __esmMin((() => {
28843
29700
  }));
28844
29701
  }
28845
29702
  /**
28846
- * Phase 2 — per-chunk advisory lock keyed on
29703
+ * Per-chunk advisory lock keyed on
28847
29704
  * `hashtext('cold-store-purge|orchestrator|<table>|<chunkId>')`.
28848
29705
  */
28849
29706
  async withPurgeLock(args, fn) {
@@ -28866,8 +29723,7 @@ var init_orchestrator_cold_store = __esmMin((() => {
28866
29723
  /**
28867
29724
  * Implementations for `kici-admin cold-store` subcommands.
28868
29725
  *
28869
- * Mirrors the Platform-side implementation in
28870
- * `packages/platform/src/admin/cold-store-commands.ts` — same shape,
29726
+ * Mirrors the Platform-side implementation — same shape,
28871
29727
  * just builds an `OrchestratorColdStore` against the orchestrator
28872
29728
  * Postgres + S3 instead of a `PlatformColdStore`.
28873
29729
  *
@@ -29737,7 +30593,7 @@ function registerCacheCommands(program, deps = {
29737
30593
  *
29738
30594
  * A leaf module on purpose: it imports nothing, so an out-of-package consumer
29739
30595
  * can read the constant without pulling the orchestrator runtime graph in with
29740
- * it. The NetworkManager drift watchdog (`hack/lib/nm-watchdog.ts`) derives its
30596
+ * it. A NetworkManager drift watchdog derives its
29741
30597
  * expected-pattern set from here rather than re-typing the patterns — a second
29742
30598
  * copy desyncs silently the moment a pattern is added. `host-network.ts`
29743
30599
  * re-exports it and renders `NM_CONF_CONTENT` from it, so the drop-in and the
@@ -29805,9 +30661,9 @@ function bridgePrefix(bridgeName) {
29805
30661
  * serializes them.
29806
30662
  *
29807
30663
  * The command list NEVER deletes the table. It is a self-heal — `ensureHostReady`
29808
- * runs it whenever `verifyBridge` reports unhealthy, which happens on any of
29809
- * four conditions none of which knows how many VMs are live — so dropping the
29810
- * table would leave every running VM fail-open, with full RFC1918 and
30664
+ * runs it whenever `verifyBridge` reports unhealthy, and none of the
30665
+ * conditions it checks says how many VMs are live — so dropping the table
30666
+ * would leave every running VM fail-open, with full RFC1918 and
29811
30667
  * cloud-metadata reach, until it was destroyed. Instead the baseline lives in
29812
30668
  * its own regular chain (`baseline`, see {@link BASELINE_CHAIN}) that is flushed
29813
30669
  * and refilled, and the hooked `forward` chain keeps its per-VM rules
@@ -29937,10 +30793,9 @@ const NM_CONF_CONTENT = [
29937
30793
  "#",
29938
30794
  "# The `+=` append operator is mandatory: NM merges conf.d drop-ins last-wins",
29939
30795
  "# per key, so a bare `unmanaged-devices=` here would be silently clobbered by",
29940
- "# (or would silently clobber) any other drop-in's unmanaged-devices line — e.g.",
29941
- "# the wifi drop-in from @kici-dev/util-linux-management. `+=` accumulates",
29942
- "# across files so every drop-in survives. See",
29943
- "# .claude/rules/networkmanager-unmanaged.md.",
30796
+ "# (or would silently clobber) any other drop-in's unmanaged-devices line, such",
30797
+ "# as a wifi drop-in another tool installs. `+=` accumulates across files so",
30798
+ "# every drop-in survives.",
29944
30799
  "[keyfile]",
29945
30800
  ...FIRECRACKER_NET_INTERFACES.map((p) => `unmanaged-devices+=interface-name:${p}`),
29946
30801
  ""
@@ -30128,6 +30983,7 @@ async function verifyBridge(cfg, opts = {}) {
30128
30983
  } catch {
30129
30984
  misses.push(`could not read chain ip ${cfg.table} forward`);
30130
30985
  }
30986
+ const { natPresent, baselineRulesPresent } = await checkSubnetRules(cfg, runner, misses);
30131
30987
  try {
30132
30988
  const { stdout } = await runner({
30133
30989
  bin: "ip",
@@ -30146,7 +31002,7 @@ async function verifyBridge(cfg, opts = {}) {
30146
31002
  } catch {
30147
31003
  misses.push(`could not read enslaved links on ${cfg.bridgeName}`);
30148
31004
  }
30149
- const healthy = bridgeExists && bridgeUp && addrPresent && tablePresent && baselineChainPresent && tapIsolationPresent;
31005
+ const healthy = bridgeExists && bridgeUp && addrPresent && tablePresent && baselineChainPresent && natPresent && baselineRulesPresent && tapIsolationPresent;
30150
31006
  return {
30151
31007
  bridgeName: cfg.bridgeName,
30152
31008
  bridgeExists,
@@ -30154,6 +31010,8 @@ async function verifyBridge(cfg, opts = {}) {
30154
31010
  addrPresent,
30155
31011
  tablePresent,
30156
31012
  baselineChainPresent,
31013
+ natPresent,
31014
+ baselineRulesPresent,
30157
31015
  tapIsolationPresent,
30158
31016
  healthy,
30159
31017
  detail: healthy ? "healthy" : misses.join("; ")
@@ -30183,6 +31041,146 @@ function forwardEndsInBaselineJump(nftJson) {
30183
31041
  });
30184
31042
  }
30185
31043
  /**
31044
+ * Check the two chains that carry this bridge's subnet: the `postrouting` NAT
31045
+ * that gives its guests internet egress, and the `baseline` rules that isolate
31046
+ * them.
31047
+ *
31048
+ * The table, its chains and the tail jump can all be in place while neither
31049
+ * rule set serves this subnet: a second bridge provisioned into the same table
31050
+ * flushes both chains and refills them with its own subnet. Every guest on this
31051
+ * bridge then loses internet access — no name resolves — and a check that stops
31052
+ * at the chain layout still reads the host as healthy, so the self-heal never
31053
+ * runs.
31054
+ */
31055
+ async function checkSubnetRules(cfg, runner, misses) {
31056
+ let subnet;
31057
+ try {
31058
+ subnet = cidrToNetwork(cfg.bridgeCidr);
31059
+ } catch (err) {
31060
+ misses.push(`invalid bridge CIDR ${cfg.bridgeCidr}: ${toErrorMessage(err)}`);
31061
+ return {
31062
+ natPresent: false,
31063
+ baselineRulesPresent: false
31064
+ };
31065
+ }
31066
+ const listChain = async (chain) => {
31067
+ try {
31068
+ const { stdout } = await runner({
31069
+ bin: "nft",
31070
+ args: [
31071
+ "-j",
31072
+ "list",
31073
+ "chain",
31074
+ "ip",
31075
+ cfg.table,
31076
+ chain
31077
+ ]
31078
+ });
31079
+ return stdout;
31080
+ } catch {
31081
+ misses.push(`could not read chain ip ${cfg.table} ${chain}`);
31082
+ return null;
31083
+ }
31084
+ };
31085
+ const postrouting = await listChain("postrouting");
31086
+ const natPresent = postrouting !== null && postroutingMasqueradesSubnet(postrouting, subnet);
31087
+ if (postrouting !== null && !natPresent) misses.push(`ip ${cfg.table} postrouting does not masquerade ${subnet}`);
31088
+ const baseline = await listChain(BASELINE_CHAIN);
31089
+ const baselineRulesPresent = baseline !== null && baselineHoldsBridgeRules(baseline, cfg);
31090
+ if (baseline !== null && !baselineRulesPresent) misses.push(`ip ${cfg.table} ${BASELINE_CHAIN} does not hold the ${subnet} rules`);
31091
+ return {
31092
+ natPresent,
31093
+ baselineRulesPresent
31094
+ };
31095
+ }
31096
+ const RULE_VERDICTS = [
31097
+ "accept",
31098
+ "drop",
31099
+ "masquerade"
31100
+ ];
31101
+ /** An nft JSON address operand as `a.b.c.d` or `a.b.c.d/len`; null for any other operand. */
31102
+ function nftAddress(operand) {
31103
+ if (typeof operand === "string") return operand;
31104
+ const prefix = operand?.prefix;
31105
+ return typeof prefix?.addr === "string" && typeof prefix.len === "number" ? `${prefix.addr}/${prefix.len}` : null;
31106
+ }
31107
+ /** The rules of a `nft -j list chain` listing, in chain order; null when it does not parse. */
31108
+ function listedRuleShapes(nftJson) {
31109
+ let parsed;
31110
+ try {
31111
+ parsed = JSON.parse(nftJson);
31112
+ } catch {
31113
+ return null;
31114
+ }
31115
+ const entries = parsed?.nftables;
31116
+ if (!Array.isArray(entries)) return null;
31117
+ const shapes = [];
31118
+ for (const entry of entries) {
31119
+ const expr = entry.rule?.expr;
31120
+ if (!Array.isArray(expr)) continue;
31121
+ const shape = {
31122
+ saddr: null,
31123
+ daddr: null,
31124
+ verdict: null
31125
+ };
31126
+ for (const node of expr) {
31127
+ const match = node.match;
31128
+ const positive = match?.op === void 0 || match.op === "==";
31129
+ const payload = match && positive ? match.left?.payload : void 0;
31130
+ if (payload?.protocol === "ip" && payload.field === "saddr") shape.saddr = nftAddress(match.right);
31131
+ else if (payload?.protocol === "ip" && payload.field === "daddr") shape.daddr = nftAddress(match.right);
31132
+ shape.verdict = RULE_VERDICTS.find((v) => v in node) ?? shape.verdict;
31133
+ }
31134
+ shapes.push(shape);
31135
+ }
31136
+ return shapes;
31137
+ }
31138
+ /** The shape of one `nft add rule` token list the provisioner builds. */
31139
+ function commandRuleShape(tokens) {
31140
+ const shape = {
31141
+ saddr: null,
31142
+ daddr: null,
31143
+ verdict: null
31144
+ };
31145
+ for (let i = 0; i + 2 < tokens.length; i++) {
31146
+ if (tokens[i] !== "ip") continue;
31147
+ if (tokens[i + 1] === "saddr") shape.saddr = tokens[i + 2];
31148
+ if (tokens[i + 1] === "daddr") shape.daddr = tokens[i + 2];
31149
+ }
31150
+ shape.verdict = RULE_VERDICTS.find((v) => v === tokens.at(-1)) ?? null;
31151
+ return shape;
31152
+ }
31153
+ /** `<daddr> <verdict>` for every subnet-scoped accept or drop, in chain order (`*`: any daddr). */
31154
+ function subnetVerdicts(rules, subnet) {
31155
+ return rules.filter((r) => r.saddr === subnet && (r.verdict === "accept" || r.verdict === "drop")).map((r) => `${r.daddr ?? "*"} ${r.verdict}`);
31156
+ }
31157
+ /**
31158
+ * True when a `nft -j list chain … postrouting` listing masquerades traffic
31159
+ * whose source is `subnet` (the network CIDR, e.g. `10.0.0.0/24`).
31160
+ */
31161
+ function postroutingMasqueradesSubnet(nftJson, subnet) {
31162
+ return (listedRuleShapes(nftJson) ?? []).some((r) => r.saddr === subnet && r.verdict === "masquerade");
31163
+ }
31164
+ /**
31165
+ * True when a `nft -j list chain … baseline` listing holds this bridge's
31166
+ * subnet-scoped accepts and drops exactly as {@link buildBridgeCommands} writes
31167
+ * them, in the same order.
31168
+ *
31169
+ * Order is part of the check: the internet accept is terminal, so ahead of the
31170
+ * RFC1918 and metadata drops it would let the guests reach every private range.
31171
+ */
31172
+ function baselineHoldsBridgeRules(nftJson, cfg) {
31173
+ const listed = listedRuleShapes(nftJson);
31174
+ if (listed === null) return false;
31175
+ const subnet = cidrToNetwork(cfg.bridgeCidr);
31176
+ const want = subnetVerdicts(buildBridgeCommands({
31177
+ ...cfg,
31178
+ hostIface: cfg.hostIface ?? "egress"
31179
+ }).filter((c) => c.bin === "nft" && c.args[0] === "add" && c.args[1] === "rule" && c.args[3] === BASELINE_CHAIN).map((c) => commandRuleShape(c.args.slice(4))), subnet);
31180
+ const got = subnetVerdicts(listed, subnet);
31181
+ return want.length === got.length && want.every((v, i) => v === got[i]);
31182
+ }
31183
+ /**
30186
31184
  * Names of the kici-* TAPs enslaved to the bridge that are NOT port-isolated,
30187
31185
  * read from `ip -d -j link show master <bridge>`.
30188
31186
  *
@@ -30348,7 +31346,7 @@ function registerProvision(group) {
30348
31346
  //#endregion
30349
31347
  //#region src/cli/commands/firecracker/verify.ts
30350
31348
  function registerVerify(group) {
30351
- group.command("verify").description("Check a Firecracker host bridge is up with its addr + nft table").requiredOption("--bridge <name>", "bridge interface name").requiredOption("--cidr <cidr>", "gateway IP + prefix").option("--table <name>", "nft table name", "kici").option("--sudo", "wrap privileged commands with sudo -n").action(async (opts) => {
31349
+ group.command("verify").description("Check a Firecracker host bridge: its addr, nft table, and subnet NAT + isolation rules").requiredOption("--bridge <name>", "bridge interface name").requiredOption("--cidr <cidr>", "gateway IP + prefix").option("--table <name>", "nft table name", "kici").option("--sudo", "wrap privileged commands with sudo -n").action(async (opts) => {
30352
31350
  const cfg = optionsToConfig(opts);
30353
31351
  const h = await verifyBridge(cfg, { requireSudo: opts.sudo });
30354
31352
  if (h.healthy) {
@@ -30427,6 +31425,57 @@ function isNodeError(err) {
30427
31425
  return err instanceof Error && "code" in err;
30428
31426
  }
30429
31427
  z.enum(["hook", "sweep"]);
31428
+ //#endregion
31429
+ //#region src/scaler/firecracker-extra-hosts.ts
31430
+ /**
31431
+ * A Firecracker scaler's `extraHosts`: the `host:address` mappings the rootfs
31432
+ * `/init` appends to each guest's `/etc/hosts`.
31433
+ *
31434
+ * An entry is `host:address`, the form the container backend documents for the
31435
+ * same field; the container runtimes accept more (`host=address`, bracketed
31436
+ * IPv6), a Firecracker scaler does not. `host-gateway` stands for the bridge
31437
+ * gateway, the address a microVM reaches its host at. A guest carries no
31438
+ * mapping unless the scaler configures one.
31439
+ */
31440
+ /** The address keyword for the bridge gateway, as the container runtimes spell it. */
31441
+ const HOST_GATEWAY = "host-gateway";
31442
+ /**
31443
+ * An RFC 1123 hostname: dot-separated labels of letters, digits and inner
31444
+ * hyphens. Nothing else can reach the guest's `/etc/hosts`, so an entry can
31445
+ * never add a second line or a second name there.
31446
+ */
31447
+ const HOSTNAME = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$/;
31448
+ /**
31449
+ * Parse one `host:address` entry. The host ends at the first colon, so an
31450
+ * IPv6 address keeps its own colons. Throws on a malformed entry.
31451
+ */
31452
+ function parseExtraHost(entry) {
31453
+ const sep = entry.indexOf(":");
31454
+ const host = sep === -1 ? "" : entry.slice(0, sep);
31455
+ const address = sep === -1 ? "" : entry.slice(sep + 1);
31456
+ if (host === "" || address === "") throw new Error(`extraHosts entry "${entry}" must be host:address`);
31457
+ if (!HOSTNAME.test(host)) throw new Error(`extraHosts entry "${entry}": "${host}" is not a valid hostname`);
31458
+ if (address !== "host-gateway" && isIP(address) === 0) throw new Error(`extraHosts entry "${entry}": the address must be an IP address or ${HOST_GATEWAY}`);
31459
+ return {
31460
+ host,
31461
+ address
31462
+ };
31463
+ }
31464
+ /**
31465
+ * The MMDS value the rootfs `/init` reads: `host:address` pairs joined by
31466
+ * commas, with `host-gateway` replaced by `gateway`. `undefined` when the
31467
+ * scaler configures no mapping, so the guest gets none. Throws on a malformed
31468
+ * entry, and on `host-gateway` when `gateway` is not an IP address.
31469
+ */
31470
+ function renderGuestExtraHosts(entries, gateway) {
31471
+ if (!entries || entries.length === 0) return void 0;
31472
+ return entries.map((entry) => {
31473
+ const { host, address } = parseExtraHost(entry);
31474
+ if (address !== "host-gateway") return `${host}:${address}`;
31475
+ if (isIP(gateway) === 0) throw new Error(`extraHosts entry "${entry}": the bridge gateway "${gateway}" is not an IP address`);
31476
+ return `${host}:${gateway}`;
31477
+ }).join(",");
31478
+ }
30430
31479
  /**
30431
31480
  * Zod schema for a single resource spec (cpus + memory).
30432
31481
  *
@@ -30569,6 +31618,12 @@ const scalerEntrySchema = z.object({
30569
31618
  "auto"
30570
31619
  ]).default("auto"),
30571
31620
  orchestratorUrl: z.string().optional(),
31621
+ /**
31622
+ * Extra `host:address` name mappings for spawned agents. A container scaler
31623
+ * passes them to the runtime; a Firecracker scaler writes them into each
31624
+ * guest's `/etc/hosts`, with `host-gateway` resolved to the bridge gateway.
31625
+ * None by default.
31626
+ */
30572
31627
  extraHosts: z.array(z.string()).optional(),
30573
31628
  networkIsolation: z.boolean().default(true),
30574
31629
  warmPool: warmPoolSchema,
@@ -30714,6 +31769,17 @@ const scalerEntrySchema = z.object({
30714
31769
  message: `Firecracker scaler "${data.name}" requires a 'gid' field`,
30715
31770
  path: ["gid"]
30716
31771
  });
31772
+ (data.extraHosts ?? []).forEach((entry, i) => {
31773
+ try {
31774
+ parseExtraHost(entry);
31775
+ } catch (err) {
31776
+ ctx.addIssue({
31777
+ code: z.ZodIssueCode.custom,
31778
+ message: `Firecracker scaler "${data.name}" extraHosts[${i}]: ${toErrorMessage(err)}`,
31779
+ path: ["extraHosts", i]
31780
+ });
31781
+ }
31782
+ });
30717
31783
  data.labelSets.forEach((ls, i) => {
30718
31784
  if (!ls.rootfsPath) ctx.addIssue({
30719
31785
  code: z.ZodIssueCode.custom,
@@ -31579,19 +32645,12 @@ const POSIX_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
31579
32645
  * Interface-name pattern for per-VM TAP devices.
31580
32646
  * Matches what {@link generateTapName} produces — `kici-` plus the LAST 8
31581
32647
  * characters of the VM id, which for a scaler-minted id is 8 lowercase hex.
32648
+ * The orphan sweep considers only names of this shape, so the default bridge
32649
+ * (`kici-br0`) and any other operator interface outside it are never
32650
+ * candidates. The scaler's configured bridge is skipped by name as well, in
32651
+ * case an operator named it in this shape: deleting it kills every running VM.
31582
32652
  */
31583
32653
  const VM_TAP_PATTERN = /^kici-[0-9a-f]{8}$/;
31584
- /**
31585
- * Infrastructure interfaces that share the `kici-` prefix but must NEVER be
31586
- * deleted by the orphan sweep. Bridges (kici-br0/kici-br1) carry all VM
31587
- * traffic; kici-m01 is the staging metadata interface. Losing any of these
31588
- * kills all running VMs.
31589
- */
31590
- const PROTECTED_INTERFACES = [
31591
- "kici-br0",
31592
- "kici-br1",
31593
- "kici-m01"
31594
- ];
31595
32654
  /** Convert a dotted IPv4 netmask ('255.255.255.0') to a CIDR prefix length (24). */
31596
32655
  function netmaskToPrefix(netmask) {
31597
32656
  return netmask.split(".").map((o) => Number(o).toString(2).padStart(8, "0")).join("").split("1").length - 1;
@@ -31626,6 +32685,8 @@ var FirecrackerScalerBackend = class FirecrackerScalerBackend {
31626
32685
  bridgeName;
31627
32686
  cidr;
31628
32687
  gateway;
32688
+ /** The MMDS `kici-extra-hosts` value, or undefined when the scaler maps no host. */
32689
+ guestExtraHosts;
31629
32690
  /** Host services the orchestrator directed agents at, as `hostAccess` entries. */
31630
32691
  hostServices;
31631
32692
  netmask;
@@ -31660,6 +32721,7 @@ var FirecrackerScalerBackend = class FirecrackerScalerBackend {
31660
32721
  this.bridgeName = options.bridgeName;
31661
32722
  this.cidr = options.cidr;
31662
32723
  this.gateway = options.gateway;
32724
+ this.guestExtraHosts = renderGuestExtraHosts(options.extraHosts, options.gateway);
31663
32725
  this.hostServices = options.hostServices;
31664
32726
  this.netmask = options.netmask;
31665
32727
  this.table = options.table ?? "kici";
@@ -31754,7 +32816,7 @@ var FirecrackerScalerBackend = class FirecrackerScalerBackend {
31754
32816
  });
31755
32817
  let alloc;
31756
32818
  const onSpawnAbort = () => {
31757
- this.abandonSpawn(agentId, alloc, "spawn aborted (deadline or shutdown)");
32819
+ this.abandonSpawnDetached(agentId, alloc, "spawn aborted (deadline or shutdown)");
31758
32820
  };
31759
32821
  signal?.addEventListener("abort", onSpawnAbort, { once: true });
31760
32822
  try {
@@ -31893,8 +32955,8 @@ var FirecrackerScalerBackend = class FirecrackerScalerBackend {
31893
32955
  "kici-agent-id": agentId,
31894
32956
  "kici-labels": fullLabels.join(","),
31895
32957
  "kici-scaler-managed": "1",
31896
- "kici-gateway-ip": this.gateway,
31897
32958
  ...agentToken ? { "kici-agent-token": agentToken } : {},
32959
+ ...this.guestExtraHosts ? { "kici-extra-hosts": this.guestExtraHosts } : {},
31898
32960
  ...matchedLabelSet.backpressureMode ? { "kici-backpressure-mode": matchedLabelSet.backpressureMode } : {},
31899
32961
  ...Object.keys(acceptedEnv).length > 0 ? { "kici-env": acceptedEnv } : {}
31900
32962
  } } });
@@ -31930,6 +32992,13 @@ var FirecrackerScalerBackend = class FirecrackerScalerBackend {
31930
32992
  logger$2.warn(`Cleanup after abandoned spawn ${agentId} failed: ${toErrorMessage(err)}`);
31931
32993
  }
31932
32994
  }
32995
+ /**
32996
+ * {@link abandonSpawn} from a path that cannot await it (an abort listener, a
32997
+ * process exit handler). A failure is logged.
32998
+ */
32999
+ abandonSpawnDetached(agentId, alloc, reason) {
33000
+ runDetached(logger$2, "Abandoned spawn teardown", () => this.abandonSpawn(agentId, alloc, reason), { agentId });
33001
+ }
31933
33002
  getScalerContext(agentId) {
31934
33003
  const managed = this.agents.get(agentId);
31935
33004
  if (!managed) return void 0;
@@ -32307,7 +33376,7 @@ var FirecrackerScalerBackend = class FirecrackerScalerBackend {
32307
33376
  }
32308
33377
  for (const name of ifaceNames) {
32309
33378
  if (!VM_TAP_PATTERN.test(name)) continue;
32310
- if (PROTECTED_INTERFACES.includes(name)) continue;
33379
+ if (name === this.bridgeName) continue;
32311
33380
  if (allocatedTapsBefore.has(name)) continue;
32312
33381
  if (allocatedTapsAfter.has(name)) continue;
32313
33382
  if (trackedTapDevices.has(name)) continue;
@@ -32366,9 +33435,9 @@ var FirecrackerScalerBackend = class FirecrackerScalerBackend {
32366
33435
  /**
32367
33436
  * Default interval for periodic orphan sweeps (15 minutes).
32368
33437
  *
32369
- * Long-running orchestrators need in-process orphan sweeps because the
32370
- * external kici-leak-sweep timer skips TAP cleanup while a staging
32371
- * orchestrator is active (to avoid racing spawns). Without this, leaked
33438
+ * Long-running orchestrators need in-process orphan sweeps because a
33439
+ * host-level cleanup job skips TAP cleanup while an orchestrator is active
33440
+ * (to avoid racing spawns). Without this, leaked
32372
33441
  * TAPs accumulate until the orchestrator restarts — which can be weeks.
32373
33442
  */
32374
33443
  static ORPHAN_SWEEP_INTERVAL_MS = 9e5;
@@ -32765,8 +33834,8 @@ var FirecrackerScalerBackend = class FirecrackerScalerBackend {
32765
33834
  this.tailAbortControllers.set(agentId, abortController);
32766
33835
  const signal = abortController.signal;
32767
33836
  const output = process.stdout;
32768
- this.tailAndForward(serialLogPath, agentId, "firecracker-serial", signal, output);
32769
- this.tailAndForward(vmmLogPath, agentId, "firecracker-vmm", signal, output);
33837
+ runDetached(logger$2, "Serial console tail", () => this.tailAndForward(serialLogPath, agentId, "firecracker-serial", signal, output), { agentId });
33838
+ runDetached(logger$2, "VMM log tail", () => this.tailAndForward(vmmLogPath, agentId, "firecracker-vmm", signal, output), { agentId });
32770
33839
  child.on("exit", (code, sig) => {
32771
33840
  setTimeout(() => {
32772
33841
  abortController.abort();
@@ -32780,7 +33849,7 @@ var FirecrackerScalerBackend = class FirecrackerScalerBackend {
32780
33849
  signal: sig,
32781
33850
  failureType
32782
33851
  }), agentId, output, void 0, "firecracker-serial");
32783
- this.abandonSpawn(agentId, alloc, `jailer exited during boot (code ${code}${sig ? `, signal ${sig}` : ""}, ${failureType})`);
33852
+ this.abandonSpawnDetached(agentId, alloc, `jailer exited during boot (code ${code}${sig ? `, signal ${sig}` : ""}, ${failureType})`);
32784
33853
  }
32785
33854
  }, 500);
32786
33855
  });
@@ -33694,7 +34763,7 @@ function registerScalerCommands(program) {
33694
34763
  process.exit(1);
33695
34764
  }
33696
34765
  const scalerConfig = await loadScalerConfig(scalerConfigPath, scalerConfigDir);
33697
- const port = local.server?.port ?? Number(process.env.KICI_PORT ?? "4000");
34766
+ const port = local.server?.port ?? Number(process.env.KICI_PORT ?? ORCHESTRATOR_DEFAULT_PORT);
33698
34767
  const basePath = local.server?.basePath ?? process.env.KICI_BASE_PATH ?? "/";
33699
34768
  if ((opts.force ? false : await isOrchestratorHealthy(port, basePath)) && !opts.force) {
33700
34769
  const msg = "orchestrator is up and reaps orphans itself; pass --force to reap anyway";
@@ -33995,7 +35064,7 @@ Token vocabulary:
33995
35064
  * where no bundler substituted the constant — the same shape `server.ts` and
33996
35065
  * `standalone.ts` use for `ORCHESTRATOR_VERSION`.
33997
35066
  */
33998
- const CLI_VERSION = "0.11.0";
35067
+ const CLI_VERSION = "0.12.0";
33999
35068
  /**
34000
35069
  * Build the kici-admin Commander program with every command group registered.
34001
35070
  * Exported so the surface registry can walk the real command tree without