@kici-dev/compiler 0.6.1 → 0.7.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 (41) hide show
  1. package/dist/cli.js +1 -1
  2. package/dist/commands/compile.js +5 -1
  3. package/dist/commands/doctor.js +8 -2
  4. package/dist/commands/init.d.ts +9 -0
  5. package/dist/commands/init.js +77 -12
  6. package/dist/commands/preview.js +1 -1
  7. package/dist/commands/report/identity.d.ts +11 -0
  8. package/dist/commands/report/identity.js +7 -2
  9. package/dist/commands/run-routed.js +1 -0
  10. package/dist/commands/types.d.ts +6 -1
  11. package/dist/commands/types.js +2 -1
  12. package/dist/execution/executor.js +7 -1
  13. package/dist/llm-context/llms-architecture.txt +72 -86
  14. package/dist/llm-context/llms-cli-remote.txt +40 -7
  15. package/dist/llm-context/llms-cli.txt +67 -34
  16. package/dist/llm-context/llms-features-execution.txt +52 -6
  17. package/dist/llm-context/llms-features.txt +137 -6
  18. package/dist/llm-context/llms-full.txt +527 -174
  19. package/dist/llm-context/llms-getting-started.txt +3 -3
  20. package/dist/llm-context/llms-patterns.txt +81 -5
  21. package/dist/llm-context/llms-providers.txt +6 -2
  22. package/dist/llm-context/llms-sdk-runtime.txt +22 -18
  23. package/dist/llm-context/llms-sdk.txt +47 -7
  24. package/dist/llm-context/llms.txt +8 -8
  25. package/dist/local-plane/orchestrator-process.d.ts +0 -8
  26. package/dist/local-plane/orchestrator-process.js +3 -14
  27. package/dist/local-plane/plane-manager.js +2 -2
  28. package/dist/lockfile/generator.js +25 -9
  29. package/dist/lockfile/hasher.d.ts +5 -13
  30. package/dist/lockfile/hasher.js +1 -15
  31. package/dist/lockfile/workspace-siblings.d.ts +46 -0
  32. package/dist/lockfile/workspace-siblings.js +197 -0
  33. package/dist/templates/package-json.js +1 -1
  34. package/dist/test-runner/job-executor.js +1 -1
  35. package/dist/test-runner/rule-evaluator.js +1 -1
  36. package/dist/types.d.ts +6 -1
  37. package/package.json +7 -9
  38. package/sbom.spdx.json +123 -123
  39. package/dist/postinstall.d.ts +0 -9
  40. package/dist/postinstall.js +0 -62
  41. package/hack/postinstall.mjs +0 -105
package/dist/cli.js CHANGED
@@ -7,7 +7,7 @@ import { realpathSync } from "node:fs";
7
7
  import { Argument, Command } from "commander";
8
8
  import pc from "picocolors";
9
9
  //#region src/cli.ts
10
- const version = "0.6.1";
10
+ const version = "0.7.0";
11
11
  /**
12
12
  * Top-level commands that were removed, mapped to their current equivalent.
13
13
  * Consulted when the CLI hits an unknown command so the user gets a precise
@@ -13,6 +13,7 @@ import path from "node:path";
13
13
  import { existsSync } from "node:fs";
14
14
  import pc from "picocolors";
15
15
  import fs$1 from "node:fs/promises";
16
+ import { findDigestReproducibilityWarnings, loadKiciIgnoreRules } from "@kici-dev/core/kici-source-digest";
16
17
  import { logger, toErrorMessage } from "@kici-dev/core";
17
18
  import { PackageManager, detectPackageManagerSync } from "@kici-dev/core/package-manager";
18
19
  import { execSync } from "node:child_process";
@@ -80,6 +81,9 @@ async function compileCommand(options) {
80
81
  const lockJson = serializeLockFile(lockFile);
81
82
  const windowWarning = schemaWindowWarning(BREAKING_FLOOR, SCHEMA_VERSION);
82
83
  if (windowWarning) logger.warn(pc.yellow(windowWarning));
84
+ const ignoreRules = await loadKiciIgnoreRules(absoluteKiciDir);
85
+ for (const warning of ignoreRules.warnings) logger.warn(pc.yellow(warning));
86
+ for (const warning of await findDigestReproducibilityWarnings(absoluteKiciDir, ignoreRules)) logger.warn(pc.yellow(warning));
83
87
  if (!options.check) {
84
88
  await fs$1.writeFile(lockPath, lockJson, "utf-8");
85
89
  if (!options.quiet) logger.info(pc.green("✓") + ` Compiled workflows → .kici/kici.lock.json` + pc.dim(` (${workflowsWithSource.length} workflow${workflowsWithSource.length !== 1 ? "s" : ""})`));
@@ -91,7 +95,7 @@ async function compileCommand(options) {
91
95
  if (hasToken && hasEndpoint && config.activeOrgId) {
92
96
  const { typesCommand } = await import("./types.js");
93
97
  await typesCommand({
94
- kiciDir,
98
+ kiciDir: absoluteKiciDir,
95
99
  quiet: options.quiet
96
100
  });
97
101
  }
@@ -8,7 +8,7 @@ import path from "node:path";
8
8
  import pc from "picocolors";
9
9
  import fs from "node:fs/promises";
10
10
  import { deriveDiagnoseOverall, diagnoseExitCode, toErrorMessage } from "@kici-dev/core";
11
- import { matcherSatisfiedBy } from "@kici-dev/engine";
11
+ import { canonicalizeLabelSet, canonicalizeMatcher, matcherSatisfiedBy } from "@kici-dev/engine";
12
12
  import { execFile } from "node:child_process";
13
13
  import { promisify } from "node:util";
14
14
  //#region src/commands/doctor.ts
@@ -157,9 +157,15 @@ function checkLockFile(state) {
157
157
  * A label group satisfies a requirement only when it matches EVERY runsOn
158
158
  * matcher AND matches NONE of the excludeLabels — the same authority the
159
159
  * orchestrator's job queue applies (`runsOn.every` && `!exclude.some`).
160
+ *
161
+ * Both sides are folded first, and both arms of the check get the fold: the
162
+ * probe reports canonical agent labels while lock matchers carry whatever case
163
+ * the workflow author wrote, so comparing them raw would report a dispatchable
164
+ * job unreachable and would let an excluded label slip past on case alone.
160
165
  */
161
166
  function requirementSatisfiedBy(req, labels) {
162
- return req.runsOn.every((m) => matcherSatisfiedBy(m, labels)) && !req.exclude.some((m) => matcherSatisfiedBy(m, labels));
167
+ const canonical = canonicalizeLabelSet([...labels]);
168
+ return req.runsOn.every((m) => matcherSatisfiedBy(canonicalizeMatcher(m), canonical)) && !req.exclude.some((m) => matcherSatisfiedBy(canonicalizeMatcher(m), canonical));
163
169
  }
164
170
  function describeMatcher(m) {
165
171
  return m.kind === "regex" ? `/${m.source}/${m.flags}` : m.value;
@@ -48,4 +48,13 @@ export declare function initCommand(options?: InitOptions): Promise<boolean>;
48
48
  * @param kiciDir - The resolved `.kici` directory path.
49
49
  */
50
50
  export declare function writeKiciGitignore(kiciDir: string): Promise<void>;
51
+ /**
52
+ * Scaffold `.kici/.kiciignore` with the default exclusion set. Never overwrites
53
+ * an existing file — the customer may have tuned it — and never runs from
54
+ * `kici compile`: a compile that wrote into the tree it is hashing is the exact
55
+ * hazard the exclusion set exists to close.
56
+ *
57
+ * @param kiciDir - The resolved `.kici` directory path.
58
+ */
59
+ export declare function writeKiciIgnore(kiciDir: string): Promise<void>;
51
60
  //# sourceMappingURL=init.d.ts.map
@@ -11,12 +11,13 @@ import { rewriteRunsOnForHost, shouldOfferFirstRun } from "./init-host-os.js";
11
11
  import path from "node:path";
12
12
  import pc from "picocolors";
13
13
  import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
14
+ import { KICI_DIGEST_DEFAULT_EXCLUSIONS, KICI_IGNORE_FILENAME } from "@kici-dev/core/kici-source-digest";
14
15
  import { $ } from "zx";
15
16
  import { initZx, logger, toErrorMessage } from "@kici-dev/core";
16
17
  import { detectPackageManager, detectWorkspaceRoot, installBuildPolicyArgs, installCommand, parsePackageManager } from "@kici-dev/core/package-manager";
18
+ import os from "node:os";
17
19
  import { checkbox, confirm, select } from "@inquirer/prompts";
18
20
  import { isCiEnvironment } from "@kici-dev/core/ci-env";
19
- import os from "node:os";
20
21
  //#region src/commands/init.ts
21
22
  /**
22
23
  * kici init command
@@ -77,6 +78,7 @@ async function initCommand(options = {}) {
77
78
  await writeFile(kiciIgnorePath, kiciIgnoreTemplate, "utf-8");
78
79
  }
79
80
  await writeKiciGitignore(kiciDir);
81
+ await writeKiciIgnore(kiciDir);
80
82
  const devMode = await detectDevelopmentMode();
81
83
  const mode = await resolveScaffoldMode(options);
82
84
  const useVerdaccio = devMode;
@@ -230,8 +232,21 @@ async function resolvePackageManager(override, dir = process.cwd()) {
230
232
  }
231
233
  return detectPackageManager(dir);
232
234
  }
233
- /** The @kici-dev scope → local Verdaccio registry line written in dev mode. */
234
- const VERDACCIO_NPMRC = "@kici-dev:registry=http://verdaccio.local:4873\n";
235
+ /**
236
+ * The `@kici-dev` scope → local registry line written in dev mode, or `null`
237
+ * when no dev registry is configured.
238
+ *
239
+ * The registry URL comes from `KICI_DEV_REGISTRY` rather than a literal in this
240
+ * file. `init.ts` ships in the published `@kici-dev/compiler` source and
241
+ * bundle, so a hard-coded internal hostname is published to every customer who
242
+ * unpacks the tarball — a disclosure, not a reachable path (every write site is
243
+ * behind the dev-mode gate), but an avoidable one. With dev mode on and the
244
+ * variable unset there is nothing to point at, so no `.npmrc` is written.
245
+ */
246
+ function devRegistryNpmrc() {
247
+ const registry = process.env.KICI_DEV_REGISTRY?.trim();
248
+ return registry ? `@kici-dev:registry=${registry}\n` : null;
249
+ }
235
250
  /** Write .kici/tsconfig.json + the empty types/ dir (TypeScript mode only). */
236
251
  async function writeTsConfigAndTypesDir(kiciDir) {
237
252
  logger.info(pc.gray("Writing .kici/tsconfig.json"));
@@ -254,13 +269,15 @@ async function runInstall(pm, dir) {
254
269
  async function wireStandaloneDeps(kiciDir, devMode, options) {
255
270
  logger.info(pc.gray("Writing .kici/package.json"));
256
271
  await writeFile(path.join(kiciDir, "package.json"), generatePackageJson(devMode), "utf-8");
257
- if (devMode) {
258
- logger.info(pc.yellow("Pointing @kici-dev to local Verdaccio registry."));
259
- await writeFile(path.join(kiciDir, ".npmrc"), VERDACCIO_NPMRC, "utf-8");
272
+ const devNpmrc = devMode ? devRegistryNpmrc() : null;
273
+ if (devMode && !devNpmrc) logger.info(pc.yellow("Dev mode: KICI_DEV_REGISTRY is unset, writing no .npmrc."));
274
+ if (devNpmrc) {
275
+ logger.info(pc.yellow("Pointing @kici-dev to the dev registry."));
276
+ await writeFile(path.join(kiciDir, ".npmrc"), devNpmrc, "utf-8");
260
277
  const rootNpmrc = path.resolve(".npmrc");
261
278
  if (!await checkExists(rootNpmrc)) {
262
- logger.info(pc.gray("Writing .npmrc (Verdaccio scope)"));
263
- await writeFile(rootNpmrc, VERDACCIO_NPMRC, "utf-8");
279
+ logger.info(pc.gray("Writing .npmrc (dev registry scope)"));
280
+ await writeFile(rootNpmrc, devNpmrc, "utf-8");
264
281
  }
265
282
  }
266
283
  if (!options.mjs) {
@@ -276,11 +293,13 @@ async function wireStandaloneDeps(kiciDir, devMode, options) {
276
293
  */
277
294
  async function wireIntegrateDeps(kiciDir, workspaceRoot, devMode, options) {
278
295
  await addSdkToRootManifest(workspaceRoot, devMode);
279
- if (devMode) {
296
+ const devNpmrc = devMode ? devRegistryNpmrc() : null;
297
+ if (devMode && !devNpmrc) logger.info(pc.yellow("Dev mode: KICI_DEV_REGISTRY is unset, writing no .npmrc."));
298
+ if (devNpmrc) {
280
299
  const rootNpmrc = path.join(workspaceRoot, ".npmrc");
281
300
  if (!await checkExists(rootNpmrc)) {
282
- logger.info(pc.gray("Writing workspace-root .npmrc (Verdaccio scope)"));
283
- await writeFile(rootNpmrc, VERDACCIO_NPMRC, "utf-8");
301
+ logger.info(pc.gray("Writing workspace-root .npmrc (dev registry scope)"));
302
+ await writeFile(rootNpmrc, devNpmrc, "utf-8");
284
303
  }
285
304
  }
286
305
  if (!options.mjs) await writeTsConfigAndTypesDir(kiciDir);
@@ -389,6 +408,52 @@ async function writeKiciGitignore(kiciDir) {
389
408
  await writeFile(gitignorePath, KICI_GITIGNORE_TEMPLATE, "utf-8");
390
409
  }
391
410
  /**
411
+ * Build the `.kici/.kiciignore` scaffold: the default exclusion set, annotated.
412
+ *
413
+ * Rendered from `KICI_DIGEST_DEFAULT_EXCLUSIONS` rather than a hand-typed list,
414
+ * so a new default cannot ship with the seeded file silently short of it — the
415
+ * file replaces the defaults rather than merging with them, which makes a stale
416
+ * template an unstable hash rather than a cosmetic drift.
417
+ */
418
+ function buildKiciIgnoreTemplate() {
419
+ return [
420
+ "# Paths the workflow source digest does NOT cover.",
421
+ "#",
422
+ "# This file IS the exclusion list: it replaces the built-in defaults rather",
423
+ "# than adding to them. The entries below are those defaults. Removing one",
424
+ "# re-includes that path in the hash — and node_modules/, .npmrc and",
425
+ "# package-lock.json are rewritten during a run, so dropping one makes the",
426
+ "# hash change on every run and the drift gate reject it. `kici compile`",
427
+ "# warns when that happens.",
428
+ "#",
429
+ "# Patterns are gitignore-style, relative to .kici/. Unrelated to the",
430
+ "# repo-root .kiciignore, which selects what `kici run --remote` uploads.",
431
+ "#",
432
+ "# This file is itself hashed: which files define a workflow identity is",
433
+ "# part of that identity, so editing it forces a recompile.",
434
+ "",
435
+ ...KICI_DIGEST_DEFAULT_EXCLUSIONS,
436
+ ""
437
+ ].join("\n");
438
+ }
439
+ /**
440
+ * Scaffold `.kici/.kiciignore` with the default exclusion set. Never overwrites
441
+ * an existing file — the customer may have tuned it — and never runs from
442
+ * `kici compile`: a compile that wrote into the tree it is hashing is the exact
443
+ * hazard the exclusion set exists to close.
444
+ *
445
+ * @param kiciDir - The resolved `.kici` directory path.
446
+ */
447
+ async function writeKiciIgnore(kiciDir) {
448
+ const kiciIgnorePath = path.join(kiciDir, KICI_IGNORE_FILENAME);
449
+ if (await checkExists(kiciIgnorePath)) {
450
+ logger.info(pc.gray(`Skipping .kici/${KICI_IGNORE_FILENAME} (already exists)`));
451
+ return;
452
+ }
453
+ logger.info(pc.gray(`Writing .kici/${KICI_IGNORE_FILENAME}`));
454
+ await writeFile(kiciIgnorePath, buildKiciIgnoreTemplate(), "utf-8");
455
+ }
456
+ /**
392
457
  * Update .gitignore with .kici/ entries
393
458
  *
394
459
  * Safely appends to .gitignore, checking for existing entries to avoid duplicates.
@@ -560,6 +625,6 @@ async function offerHookInstallation(useVerdaccio) {
560
625
  } else logger.warn(pc.yellow(`Warning: ${result.message}`));
561
626
  }
562
627
  //#endregion
563
- export { initCommand, writeKiciGitignore };
628
+ export { initCommand, writeKiciGitignore, writeKiciIgnore };
564
629
 
565
630
  //# sourceMappingURL=init.js.map
@@ -9,10 +9,10 @@ import { loadSecretsFile } from "../test-runner/secrets-file.js";
9
9
  import path from "node:path";
10
10
  import pc from "picocolors";
11
11
  import { readFile } from "node:fs/promises";
12
- import { flattenStepInputs } from "@kici-dev/sdk";
13
12
  import { logger, toErrorMessage } from "@kici-dev/core";
14
13
  import { matchAllWorkflows } from "@kici-dev/engine";
15
14
  import { normalizeRunsOnToMatchers } from "@kici-dev/engine/labels/compile";
15
+ import { flattenStepInputs } from "@kici-dev/sdk/internal";
16
16
  //#region src/commands/preview.ts
17
17
  /**
18
18
  * Main preview command entry point.
@@ -13,6 +13,17 @@ export interface OrchestratorIdentity {
13
13
  version: string | null;
14
14
  mode: string | null;
15
15
  connected: boolean;
16
+ /**
17
+ * Where this orchestrator's config files live on its host. Recorded so a
18
+ * support bundle names the files whoever reads it should ask for. Paths
19
+ * only — the bundle never carries their contents. Each is null when the
20
+ * orchestrator did not report it.
21
+ */
22
+ configPaths: {
23
+ envFile: string | null;
24
+ scalerConfig: string | null;
25
+ composeFile: string | null;
26
+ };
16
27
  }
17
28
  export interface ReportIdentity {
18
29
  kiciCliVersion: string;
@@ -19,7 +19,7 @@ import { PROTOCOL_VERSION } from "@kici-dev/engine";
19
19
  */
20
20
  function collectIdentity(probe) {
21
21
  const identity = {
22
- kiciCliVersion: "0.6.1",
22
+ kiciCliVersion: "0.7.0",
23
23
  nodeVersion: process.version,
24
24
  platform: process.platform,
25
25
  arch: process.arch,
@@ -38,7 +38,12 @@ function collectIdentity(probe) {
38
38
  clusterName: o.clusterName ?? "(unnamed)",
39
39
  version: o.version ?? null,
40
40
  mode: o.mode ?? null,
41
- connected: o.connected
41
+ connected: o.connected,
42
+ configPaths: {
43
+ envFile: o.configPaths?.envFile ?? null,
44
+ scalerConfig: o.configPaths?.scalerConfig ?? null,
45
+ composeFile: o.configPaths?.composeFile ?? null
46
+ }
42
47
  }));
43
48
  if (probe.infra.latestVersion) identity.latestKnownVersion = probe.infra.latestVersion;
44
49
  return identity;
@@ -129,6 +129,7 @@ async function runRouted(options) {
129
129
  }
130
130
  const seeded = await ensureLocalSource(plane.url, plane.adminToken, {
131
131
  repoDir: workdir.dir,
132
+ ...resolved.kind === "attached" && { orgId: resolved.orgId },
132
133
  inPlace: Boolean(options.inPlace)
133
134
  });
134
135
  const seededSecrets = await seedLocalSecrets(plane.url, plane.adminToken, {
@@ -1,5 +1,10 @@
1
1
  export interface TypesOptions {
2
- /** Path to .kici directory (defaults to .kici) */
2
+ /**
3
+ * Path to the `.kici` directory (defaults to `.kici`). Resolved through
4
+ * `resolveKiciDir`, the same resolver every other command uses, so a
5
+ * relative value is interpreted against the project rather than against
6
+ * whatever directory the CLI happened to be invoked from.
7
+ */
3
8
  kiciDir?: string;
4
9
  /** Suppress the success line on stdout (so machine-readable output stays pure). */
5
10
  quiet?: boolean;
@@ -1,4 +1,5 @@
1
1
  import "../rolldown-runtime-ClRpJifh.js";
2
+ import { resolveKiciDir } from "../execution/executor.js";
2
3
  import { loadGlobalConfig } from "../remote/config.js";
3
4
  import { DashboardClient, DashboardClientError } from "../remote/dashboard-client.js";
4
5
  import { generateSecretsDts } from "../generators/secrets-dts.js";
@@ -44,7 +45,7 @@ async function fileExists(p) {
44
45
  * @returns true on success, false on error
45
46
  */
46
47
  async function typesCommand(options = {}) {
47
- const kiciDir = options.kiciDir ?? ".kici";
48
+ const kiciDir = resolveKiciDir(options.kiciDir);
48
49
  const typesDir = path.join(kiciDir, "types");
49
50
  const outputPath = path.join(typesDir, "secrets.d.ts");
50
51
  try {
@@ -6,6 +6,7 @@ import { pathToFileURL } from "node:url";
6
6
  import path from "node:path";
7
7
  import { existsSync } from "node:fs";
8
8
  import fs$1 from "node:fs/promises";
9
+ import { findKiciDir, hashKiciSourceTree } from "@kici-dev/core/kici-source-digest";
9
10
  //#region src/execution/executor.ts
10
11
  /**
11
12
  * Map a caught runtime error's stack to a `SourceLocation` in the entry file.
@@ -46,7 +47,12 @@ function locateInEntry(stack, entryPoint) {
46
47
  */
47
48
  async function loadModule(entryPoint, errorContext) {
48
49
  let hashBundleSource;
49
- try {
50
+ const kiciDir = findKiciDir(entryPoint);
51
+ if (kiciDir) {
52
+ const digest = await hashKiciSourceTree(kiciDir);
53
+ if (digest) hashBundleSource = digest;
54
+ }
55
+ if (hashBundleSource === void 0) try {
50
56
  hashBundleSource = await fs$1.readFile(entryPoint, "utf-8");
51
57
  } catch {}
52
58
  try {
@@ -29,7 +29,7 @@ interface LocalConfig {
29
29
 
30
30
  ### SharedConfig
31
31
 
32
- Shared settings stored in the PostgreSQL `config_versions` table. Defined once, shared across all instances:
32
+ Settings stored in the PostgreSQL `config_versions` table, written and read by the `/admin/config` routes and their `kici-admin config` commands:
33
33
 
34
34
  ```typescript
35
35
  interface SharedConfig {
@@ -73,11 +73,13 @@ interface AppConfig {
73
73
 
74
74
  ### How they merge
75
75
 
76
+ `resolveFullConfig()` takes a `LocalConfig` and an optional `SharedConfig` and merges them:
77
+
76
78
  ```
77
79
  defaults (getDefaults())
78
80
  |
79
81
  v
80
- SharedConfig (from DB) ──deepMerge──> merged layer 1+2
82
+ SharedConfig (argument) ──deepMerge──> merged layer 1+2
81
83
  |
82
84
  v
83
85
  LocalConfig (from YAML) ──deepMerge──> merged layer 1+2+3
@@ -94,29 +96,48 @@ appConfigSchema.safeParse() ──validate──> typed AppConfig
94
96
 
95
97
  The `deepMerge` function merges objects recursively, replaces arrays (does not merge item-by-item), and skips `undefined`/`null` source values (they do not override existing values).
96
98
 
99
+ **The `SharedConfig` argument is `null` in the shipped wiring.** `ConfigReloader` is the only non-test caller of `resolveFullConfig()`, and it is constructed with `sharedStore: null`. So the DB layer is skipped and the effective chain is defaults → YAML → env. The `config_versions` table is read by the `/admin/config` write and inspection routes, by `kici-admin rotate-key`, and by the cluster join flow — never by a running orchestrator's own config.
100
+
97
101
  ## Resolution chain
98
102
 
99
- ### Two-phase design
103
+ ### Startup
104
+
105
+ `server.ts` and `standalone.ts` both call `loadConfig()`, which parses `KICI_*` environment variables against the flat schema in `config.ts`. No YAML file and no database row participates:
100
106
 
101
107
  ```
102
- Phase 1 (local-only):
103
- YAML file + KICI_ env vars
104
- |
105
- v
106
- resolveLocalConfig() -> { databaseUrl, instanceId, port, mode }
107
- |
108
- v
109
- Connect to PostgreSQL
110
- |
111
- v
112
- Phase 2 (full merge):
113
- defaults -> DB -> YAML -> env
114
- |
115
- v
116
- resolveFullConfig() -> AppConfig
108
+ Process start
109
+ |
110
+ v
111
+ loadConfig() -> envDef.parse(process.env) -> AppConfig
112
+ |
113
+ v
114
+ Connect to PostgreSQL, run migrations
115
+ |
116
+ v
117
+ Start server (HTTP, WS, scaler, cluster)
117
118
  ```
118
119
 
119
- **Why two phases?** The database URL must come from local config (YAML or env var) because we need it to connect to PostgreSQL. But the shared config is stored in PostgreSQL. This circular dependency is broken by resolving local config first (Phase 1), connecting to the DB, then doing the full merge (Phase 2).
120
+ The database URL therefore has to be an environment variable: the orchestrator needs it to reach PostgreSQL, and the shared config lives in PostgreSQL.
121
+
122
+ ### Reload
123
+
124
+ `resolveLocalConfig()` and `resolveFullConfig()` run on the reload path, not at startup:
125
+
126
+ ```
127
+ SIGHUP / POST /admin/config/reload / kici-admin config reload
128
+ |
129
+ v
130
+ resolveLocalConfig() -> YAML file + KICI_ env overlay
131
+ |
132
+ v
133
+ resolveFullConfig(local, null) -> defaults -> YAML -> env -> AppConfig
134
+ |
135
+ v
136
+ Hold databaseUrl, port, instanceId and storage at their startup values
137
+ |
138
+ v
139
+ Atomic swap into ConfigReloader.currentConfig
140
+ ```
120
141
 
121
142
  ### Env var processing
122
143
 
@@ -128,43 +149,6 @@ Environment variables are processed in two stages:
128
149
 
129
150
  Type coercion is applied based on known field types: numeric fields are parsed as numbers, boolean fields are compared against `"true"`, all others remain strings.
130
151
 
131
- ## Two-phase bootstrap
132
-
133
- ```
134
- ┌─────────────────┐
135
- │ Process Start │
136
- └────────┬────────┘
137
-
138
-
139
- ┌─────────────────┐
140
- │ Load YAML + │ resolveLocalConfig()
141
- │ Env Overrides │ -> databaseUrl, instanceId, port, mode
142
- └────────┬────────┘
143
-
144
-
145
- ┌─────────────────┐
146
- │ Connect to │ PostgreSQL
147
- │ Database │ Run migrations
148
- └────────┬────────┘
149
-
150
-
151
- ┌─────────────────┐
152
- │ Load Shared │ SharedConfigStore.getLatest()
153
- │ Config from DB │ -> decrypt -> SharedConfig
154
- └────────┬────────┘
155
-
156
-
157
- ┌─────────────────┐
158
- │ Full Merge │ resolveFullConfig(local, db, env)
159
- │ + Validate │ -> AppConfig
160
- └────────┬────────┘
161
-
162
-
163
- ┌─────────────────┐
164
- │ Start Server │ HTTP, WS, scaler, cluster
165
- └─────────────────┘
166
- ```
167
-
168
152
  ## DB schema
169
153
 
170
154
  ### config_versions table
@@ -264,7 +248,7 @@ flowchart TD
264
248
  trigger --> execute["executeReload()<br/>boolean mutex"]
265
249
 
266
250
  execute --> resolveLocal["resolveLocalConfig()"]
267
- execute --> getLatest["getLatest (DB)"]
251
+ execute --> getLatest["getLatest (DB)<br/>skipped: sharedStore is null"]
268
252
  execute --> resolveFull["resolveFullConfig()<br/>(merge + validate)"]
269
253
 
270
254
  resolveLocal --> check["Check restart-required fields"]
@@ -283,26 +267,26 @@ flowchart TD
283
267
  - **Mutex:** Boolean flag prevents concurrent reloads. Second reload returns `{ success: false, errors: ["Reload already in progress"] }`.
284
268
  - **Debounce:** Rapid triggers (e.g., multiple SIGHUP signals) are collapsed into a single reload with a 500ms window.
285
269
  - **Validation before swap:** The new config must pass full schema validation. On failure, the old config is preserved and an error is logged.
286
- - **Restart-required detection:** Fields like `databaseUrl`, `port`, `instanceId` are compared. If changed, the old values are preserved in the applied config and a warning is logged.
270
+ - **Restart-required detection:** `databaseUrl`, `port`, `instanceId` and `storage` are compared. If changed, the old values are preserved in the applied config and a warning is logged.
287
271
  - **No crash on failure:** The orchestrator always keeps running with the old config if anything goes wrong during reload.
288
272
 
289
273
  ### Subsystem callbacks
290
274
 
291
275
  The `ConfigReloader` uses a dependency injection pattern with callbacks for subsystem re-initialization:
292
276
 
293
- | Callback | When Called | Purpose |
294
- | --------------------- | ----------------------------- | -------------------------------------------------------------------------------------------- |
295
- | `onProviderChange` | Provider config changed | Reserved callback (providers are now DB-managed via sources table; currently always a no-op) |
296
- | `onScalerReload` | Always on successful reload | Reload scaler YAML config |
297
- | `onPlatformReconnect` | Platform URL or token changed | Reconnect WS to Platform relay |
298
- | `onConfigApplied` | Always on successful reload | Atomic config reference swap, increment local config version |
277
+ | Callback | When Called | Purpose |
278
+ | --------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
279
+ | `onProviderChange` | Provider config changed | Reserved callback. Providers are DB-managed via the sources table, so the change detector always reports no change |
280
+ | `onScalerReload` | Always on successful reload | Reload scaler YAML config, from the path the process started with |
281
+ | `onPlatformReconnect` | Platform URL or token changed | Logs that the Platform connection settings changed. The connection is not re-established; `standalone.ts` registers no handler |
282
+ | `onConfigApplied` | Always on successful reload | Atomic config reference swap, increment local config version |
299
283
 
300
284
  ### Prometheus metrics
301
285
 
302
- | Metric | Type | Labels | Description |
303
- | ------------------------------- | ------- | ----------------------------------------------------------------------- | ------------------------------------- |
304
- | `kici_orch_config_reload_total` | Counter | `result` (attempted/success/failed), `source` (sighup/http/cluster/cli) | Config reload attempts and outcomes |
305
- | `kici_orch_config_version` | Gauge | -- | Current shared config version from DB |
286
+ | Metric | Type | Labels | Description |
287
+ | ------------------------------- | ------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
288
+ | `kici_orch_config_reload_total` | Counter | `result` (attempted/success/failed), `source` (sighup/http/cluster/cli) | Config reload attempts and outcomes |
289
+ | `kici_orch_config_version` | Gauge | -- | Shared config version from the DB. Set only when the reload path reads a version, so it carries no value today |
306
290
 
307
291
  ## Multi-Provider
308
292
 
@@ -334,40 +318,42 @@ Each source record contains its own `appId` and `privateKey` (stored as scoped s
334
318
 
335
319
  ### Heartbeat config version
336
320
 
337
- In clustered deployments, each orchestrator includes its current config version in Raft heartbeat metadata via the `configVersion` optional field on the `peerHeartbeatSchema`.
321
+ In clustered deployments, each orchestrator includes its config version in Raft heartbeat metadata via the `configVersion` optional field on the `peerHeartbeatSchema`.
322
+
323
+ **That number is a local reload counter, not a shared config version.** `onConfigApplied` increments it on every successful reload and publishes the new value to the peer registry. It counts how many times this instance has reloaded.
338
324
 
339
325
  When the `PeerRegistry` processes a heartbeat:
340
326
 
341
327
  1. Compare `localConfigVersion` with `peer.configVersion`
342
328
  2. If `peer.configVersion > localConfigVersion` AND both are > 0:
343
329
  - Invoke the `onConfigVersionBehind` callback
344
- - This triggers a config reload from the database
330
+ - This triggers a config reload, which re-reads the environment and the local YAML file
345
331
 
346
332
  ### Auto-remediation flow
347
333
 
348
334
  ```
349
- Orchestrator A (version 5) Orchestrator B (version 3)
335
+ Orchestrator A (reloaded 5x) Orchestrator B (reloaded 3x)
350
336
  │ │
351
337
  │──── heartbeat(configVersion=5) ────>│
352
338
  │ │
353
339
  │ compare: 5 > 3
354
- │ trigger reload from DB
340
+ │ trigger reload
355
341
  │ │
356
- │ resolveFullConfig()
357
- │ -> version 5
342
+ │ resolveFullConfig(local, null)
343
+ │ -> counter becomes 4
358
344
  │ │
359
- │<── heartbeat(configVersion=5) ──────│
345
+ │<── heartbeat(configVersion=4) ──────│
360
346
  │ │
361
- │ both at version 5 ✓ │
362
347
  ```
363
348
 
349
+ B converges on A's count only after it has reloaded as many times as A has. Each instance reads its own environment and its own YAML file, so the two agree on content only when those inputs agree.
350
+
364
351
  ### Guard conditions
365
352
 
366
353
  - Version comparison only triggers when **both** local and peer versions are > 0
367
354
  - This prevents false triggers from:
368
- - Legacy orchestrators that do not report `configVersion` (field is optional, defaults to 0)
369
- - Newly started orchestrators before their first config load
370
- - The `localConfigVersion` is a monotonically incrementing local counter (incremented on each successful reload)
355
+ - Orchestrators that do not report `configVersion` (field is optional, defaults to 0)
356
+ - Newly started orchestrators before their first reload
371
357
 
372
358
  ## See also
373
359
 
@@ -398,7 +384,7 @@ GitHub --> Platform Relay --> Orchestrator --> Agent
398
384
 
399
385
  1. **Provider sends webhook** to the Platform relay endpoint.
400
386
  2. **Platform routes the webhook** to the right orchestrator over WebSocket and forwards the body bytes verbatim. Platform never sees customer HMAC secrets — signature verification happens entirely on the orchestrator after reassembly.
401
- 3. **Orchestrator verifies signature** (HMAC-SHA256 against per-source webhook secret, with dual-secret rotation support).
387
+ 3. **Orchestrator admits the delivery**, then **verifies the signature** (HMAC-SHA256 against per-source webhook secret, with dual-secret rotation support). Admission runs first, on the routing key alone: when the ingest admission controller sheds, the orchestrator records an `event_log` breadcrumb with status `shed` and ACKs `shed_retry_later`, which the Platform answers as **429** with `Retry-After`. See [ingest admission shed](https://docs.kici.dev/architecture/webhooks/webhook-delivery/#ingest-admission-shed-step-3).
402
388
  4. **Orchestrator dedup check** against dual-layer `DedupCache` (in-memory set + `dedup_cache` DB table).
403
389
  5. **Orchestrator resolves provider** by looking up the provider bundle from the `ProviderRegistry` using `getByRoutingKey()` (exact match first, falls back to provider type prefix for backward compatibility). Skips processing if the provider is unknown.
404
390
  6. **Orchestrator normalizes** the webhook via the provider's `WebhookNormalizer` (extracts branch, event type, action, sender).
@@ -525,7 +511,7 @@ Build Job Dispatch --> Build Agent (kici:role:builder + matching kici:os:/kici:a
525
511
  | |-- npm ci in .kici/
526
512
  | |-- Pack .kici/ source (portable tar.gz, excludes node_modules)
527
513
  | |-- Pack .kici/node_modules (portable tar.gz)
528
- | |-- Upload source tarball to cache (source/{contentHash}.tar.gz)
514
+ | |-- Upload source tarball to cache (source/v2/{orgId}/{sourceTarDigest}.tar.gz)
529
515
  | |-- Upload deps tarball to cache (deps/{plat}-{arch}/{depsHash}.tar.gz)
530
516
  | |-- Upload deps companion .hash file
531
517
  | |-- Report success (cache.upload.complete × 2)
@@ -600,7 +586,7 @@ Dep cache misses alone do **not** trigger a build job. Deps are platform-specifi
600
586
 
601
587
  ### Cross-source / no-contentHash workflows
602
588
 
603
- - **Lock files without `contentHash`** (schema v1) skip the source cache entirely; agents compile from source. Regenerate lock files with `kici compile` to enable caching. The current lock file schema version is 39.
589
+ - **Lock files without `contentHash`** (schema v1) skip the source cache entirely; agents compile from source. Regenerate lock files with `kici compile` to enable caching. The current lock file schema version is 41.
604
590
  - **Cross-source / global-workflow dispatch** (a workflow registered against source A fired by a webhook on source B) bypasses both caches. The registration's lock file entry still carries `contentHash`, but the cross-source path always clone-and-installs — the eval temp dir doesn't ship `@kici-dev/sdk`. The execution agent still verifies `contentHash` against the cloned source for drift detection.
605
591
 
606
592
  ### Build deduplication
@@ -639,7 +625,7 @@ Both source and dep caches use `S3CacheStorage` as the sole backend. The `CacheS
639
625
 
640
626
  Cache keys reflect that source tarballs and deps have different platform characteristics:
641
627
 
642
- - **Source:** `source/{contentHash}.tar.gz` — platform-agnostic. Raw TypeScript source is identical regardless of CPU architecture, so one entry is shared across all platforms. `contentHash` is the per-workflow hash from the lock file (`SHA-256(COMPILE_SCHEMA_VERSION + ":" + rawSource [+ "\0" + assetDigest])`, where `COMPILE_SCHEMA_VERSION = 5` and line endings are normalized to LF so the hash agrees across platforms).
628
+ - **Source:** `source/v2/{orgId}/{sourceTarDigest}.tar.gz`, with a `source/v2/{orgId}/{contentHash}.hash` pointer — platform-agnostic, and scoped to the owning organization so two repositories with matching `.kici/` trees never share one object. Raw TypeScript source is identical regardless of CPU architecture, so one entry is shared across all platforms. `contentHash` is the per-workflow hash from the lock file (`SHA-256(COMPILE_SCHEMA_VERSION + ":" + rawSource [+ "\0" + assetDigest])`, where `COMPILE_SCHEMA_VERSION = 7` and line endings are normalized to LF so the hash agrees across platforms).
643
629
  - **Deps:** `deps/{platform}-{arch}/{depsHash}.tar.gz`, with a
644
630
  `deps/{platform}-{arch}/{lockfileHash}.hash` pointer holding that hash — the
645
631
  tarball is addressed by its own content, so two builds sharing a lock file
@@ -1370,14 +1356,14 @@ Shared business logic used by all three tiers. Single source of truth for cross-
1370
1356
  - Label utilities (platform label derivation, runsOn normalization, `kici:*` set-only reserved namespace, role labels)
1371
1357
  - Host inventory (the canonical queryable host-roster schema shared by the orchestrator's roster store, the agent-facing inventory API, and the SDK's `ctx.kici.inventory`)
1372
1358
  - Audit policy and retention (per-action access-log sampling, warm-retention windows for cold-store eligibility, federated activity row schema)
1373
- - Scaler backend type enum (`container`, `bare-metal`, `firecracker`, `kubernetes`, `event`) and the reserved `kici.` event-name prefix that keeps a user step from forging a system event
1359
+ - Scaler backend type enum (`container`, `bare-metal`, `firecracker`, `kubernetes`, `event`; the orchestrator config rejects `kubernetes`) and the reserved `kici.` event-name prefix that keeps a user step from forging a system event
1374
1360
  - Job resource vocabulary (the requests/limits shape the SDK accepts, the compiler validates and emits, the orchestrator uses for capacity math and kernel-side enforcement, and the dashboard displays)
1375
1361
  - Registration trigger type enum (registerable trigger discriminator)
1376
1362
  - Sandbox capability set (the Linux capability names a container sandbox may add or drop, shared by the SDK validator, the compiler, and the dispatch resolver)
1377
1363
  - Plan tier vocabulary (the hosted plan tiers and the purchasable subset, shared by the Platform and the browser dashboard)
1378
1364
  - Infrastructure alert vocabulary (the diagnostics alert types and severities the Platform mints and the dashboard and `kici` CLI render)
1379
1365
  - Metric catalog (the generated Prometheus metric inventory, its naming policy, and metric-kind compatibility checks)
1380
- - Bundler config (shared bundler configuration consumed by `e2e/helpers/service-deploy.ts`; the agent runtime uses the `@kici-dev/core/ts-loader-hook` to transform TypeScript on import, with no runtime bundler step)
1366
+ - Bundler config (the shared workflow-bundle configuration factory on the barrel; the agent runtime uses the `@kici-dev/core/ts-loader-hook` to transform TypeScript on import, so no runtime path bundles a workflow)
1381
1367
 
1382
1368
  > Source: `packages/engine/src/`
1383
1369
 
@@ -1397,7 +1383,7 @@ It also runs the **local dev plane** -- an on-demand, fully local execution stac
1397
1383
 
1398
1384
  ### `@kici-dev/core`
1399
1385
 
1400
- Light shared utilities with no server-side dependencies. It provides JSON-structured logging, error helpers, async-local-storage request context, and human-readable formatting (`formatBytes`/`formatDuration`/`formatUptime`). It also provides cryptographic helpers (`sha256`/`sha256File`/`deriveSharedSecret` plus symmetric encrypt/decrypt), retry-backoff computation, and the shared diagnostics-result contract. The rest of its surface ships as subpath entry points: the temp-directory allocator and its garbage collector, package-manager detection, CI-environment detection, and the idempotent-step runner (the check / confirm / apply primitive behind idempotent steps). Finally it supplies zx initialization (`initZx()`) and the TypeScript loader hook that transforms TypeScript on import. It is the dependency-light core that the SDK, compiler, and `kici` CLI consume directly so they stay free of heavier server-only dependencies. `@kici-dev/shared` re-exports it, so existing `@kici-dev/shared` import paths keep working.
1386
+ Light shared utilities with no server-side dependencies. It provides JSON-structured logging, error helpers, async-local-storage request context, and human-readable formatting (`formatBytes`/`formatDuration`/`formatUptime`). It also provides cryptographic helpers (`sha256`/`sha256File`/`deriveSharedSecret` plus symmetric encrypt/decrypt), retry-backoff computation, and the shared diagnostics-result contract. The rest of its surface ships as subpath entry points: the temp-directory allocator and its garbage collector, package-manager detection, CI-environment detection, and the idempotent-step runner (the check / confirm / apply primitive behind idempotent steps). One further subpath holds the `.kici/` source digest: the single content-hash definition the compiler writes into the lock file and the agent recomputes as its drift gate. Finally it supplies zx initialization (`initZx()`) and the TypeScript loader hook that transforms TypeScript on import. It is the dependency-light core that the SDK, compiler, and `kici` CLI consume directly so they stay free of heavier server-only dependencies. `@kici-dev/shared` re-exports it, so existing `@kici-dev/shared` import paths keep working.
1401
1387
 
1402
1388
  > Source: `packages/core/src/`
1403
1389