@kici-dev/orchestrator 0.1.17 → 0.1.19

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 (42) hide show
  1. package/dist/agent/dispatcher.d.ts +9 -0
  2. package/dist/agent/host-roster-reaper.d.ts +42 -0
  3. package/dist/agent/host-roster.d.ts +110 -0
  4. package/dist/agent/registry.d.ts +65 -2
  5. package/dist/app.d.ts +3 -0
  6. package/dist/cli/commands/host.d.ts +13 -0
  7. package/dist/cli/commands/shared/versioned-upgrade.d.ts +23 -0
  8. package/dist/cli/service/compose.d.ts +2 -1
  9. package/dist/cli/service/index.d.ts +2 -2
  10. package/dist/cli/service/instance/manifest.d.ts +12 -0
  11. package/dist/cli/service/instance/resolve.d.ts +12 -2
  12. package/dist/cli/service/launchd.d.ts +4 -1
  13. package/dist/cli/service/platform-detect.d.ts +5 -0
  14. package/dist/cli/service/systemd.d.ts +2 -1
  15. package/dist/cli/service/types.d.ts +37 -0
  16. package/dist/cli/service/windows.d.ts +2 -1
  17. package/dist/cli.js +797 -202
  18. package/dist/cluster/coordinator.d.ts +5 -1
  19. package/dist/config/schema.d.ts +4 -0
  20. package/dist/config/types.d.ts +9 -0
  21. package/dist/config.d.ts +6 -0
  22. package/dist/db/migrations/039_host_roster.d.ts +19 -0
  23. package/dist/db/migrations/040_runsonall_pin.d.ts +4 -0
  24. package/dist/db/migrations/041_wave_gated.d.ts +4 -0
  25. package/dist/db/migrations/042_dispatch_queue_patterns.d.ts +4 -0
  26. package/dist/db/types.d.ts +61 -0
  27. package/dist/diagnostics/fleet-collector.d.ts +1 -1
  28. package/dist/environments/held-runs.d.ts +9 -0
  29. package/dist/lockfile-redos-guard.d.ts +19 -0
  30. package/dist/metrics/prometheus.d.ts +13 -0
  31. package/dist/metrics/scheduled-jobs.d.ts +2 -2
  32. package/dist/orchestrator-core.d.ts +46 -1
  33. package/dist/pipeline/dispatch-matched-workflow.d.ts +42 -1
  34. package/dist/pipeline/processor.d.ts +25 -0
  35. package/dist/pipeline/wave-scheduler.d.ts +60 -0
  36. package/dist/queue/job-queue.d.ts +61 -2
  37. package/dist/reporting/execution-tracker.d.ts +28 -0
  38. package/dist/server.js +52760 -51263
  39. package/dist/standalone.js +1953 -500
  40. package/installer-image-digests.json +3 -3
  41. package/package.json +22 -22
  42. package/sbom.spdx.json +3227 -6682
@@ -150,6 +150,15 @@ export declare class Dispatcher {
150
150
  * 4. If queue full: return 'rejected'.
151
151
  */
152
152
  dispatch(job: QueuedJobInput): Promise<DispatchResult>;
153
+ /**
154
+ * Dispatch a host-fanout pinned child. The job targets exactly
155
+ * `job.pinnedAgentId`: if that agent is locally connected, satisfies the
156
+ * runsOn/exclude/mandatory gate, and has capacity, dispatch immediately;
157
+ * otherwise queue it WITH the pin so the pin-aware drain delivers it when the
158
+ * agent frees up or (re)connects. A pinned job never falls through to a
159
+ * different agent.
160
+ */
161
+ private dispatchPinned;
153
162
  /**
154
163
  * Eagerly dispatch a specific bound job to a freshly-registered
155
164
  * scaler-managed agent.
@@ -0,0 +1,42 @@
1
+ import type { HostRosterStore } from './host-roster.js';
2
+ export interface HostRosterReaperOptions {
3
+ store: Pick<HostRosterStore, 'reapEphemeralPastTtl' | 'countStaticUnreachable'>;
4
+ ttlMs: number;
5
+ /** Grace window for the connected-but-stale case in `countStaticUnreachable`. */
6
+ graceMs: number;
7
+ scanIntervalMs: number;
8
+ /** Setter for the `kici_orch_declared_hosts_unreachable` gauge. */
9
+ setUnreachableGauge: (value: number) => void;
10
+ }
11
+ /**
12
+ * Leader-only host-roster reaper.
13
+ *
14
+ * Modeled on `EventRetryScanner`: started/stopped via the Raft leadership
15
+ * callbacks, a single timer cleared on `stop()`. Each leader tick it:
16
+ *
17
+ * - DELETEs `ephemeral` host_roster rows whose `last_seen` is older than the ttl
18
+ * (scaled-down autoscale agents — silent GC, no alarm); and
19
+ * - counts `static` (declared) hosts whose derived status is `unreachable` and
20
+ * publishes that count to the `kici_orch_declared_hosts_unreachable` gauge, so
21
+ * a Mimir ruler alert can page an operator when a declared box goes dark.
22
+ *
23
+ * `static` unreachability is read-derived by `deriveHostStatus` (no write here),
24
+ * so the reaper never deletes a static row. Followers never run the reaper, so
25
+ * the gauge series exists only on the leader (the alert uses `max by ()`).
26
+ */
27
+ export declare class HostRosterReaper {
28
+ private readonly store;
29
+ private readonly ttlMs;
30
+ private readonly graceMs;
31
+ private readonly scanIntervalMs;
32
+ private readonly setUnreachableGauge;
33
+ private timer;
34
+ private isLeader;
35
+ constructor(opts: HostRosterReaperOptions);
36
+ onBecomeLeader(): void;
37
+ onLoseLeadership(): void;
38
+ stop(): void;
39
+ /** One reap pass. Public for tests. */
40
+ tick(): Promise<void>;
41
+ }
42
+ //# sourceMappingURL=host-roster-reaper.d.ts.map
@@ -0,0 +1,110 @@
1
+ import { type Kysely } from 'kysely';
2
+ import { type LabelMatcher } from '@kici-dev/engine';
3
+ import type { Database, HostRosterRow } from '../db/types.js';
4
+ /**
5
+ * Derived (read-time) status of a roster host. Never a stored mutable column:
6
+ * status is computed from the shared `last_seen` + `connected_instance_id` so
7
+ * every cluster instance agrees regardless of which one holds the live WS.
8
+ */
9
+ export declare enum HostStatus {
10
+ /** Connected to some instance AND heartbeat fresh. */
11
+ ready = "ready",
12
+ /** Static host that is not currently live (declared, rebooting, or gone). */
13
+ unreachable = "unreachable",
14
+ /** Ephemeral host past ttl but not yet reaped (scaled down). */
15
+ stale = "stale"
16
+ }
17
+ /** The lifecycle class snapshot from the auth token's `agent_type`. */
18
+ export type LifecycleClass = 'static' | 'ephemeral';
19
+ /**
20
+ * A roster host that matched a `runsOnAll` predicate, with its derived status.
21
+ * The host-fanout resolver consumes this: `ready` hosts become pinned children;
22
+ * `unreachable` static hosts are subject to the `onUnreachable` policy; `stale`
23
+ * ephemeral hosts are skipped.
24
+ */
25
+ export interface MatchedHost {
26
+ agentId: string;
27
+ /** Hostname (falls back to agentId). */
28
+ host: string;
29
+ labels: string[];
30
+ lifecycleClass: LifecycleClass;
31
+ /** Which orchestrator holds the live WS (null = disconnected). For the cross-cluster pin. */
32
+ connectedInstanceId: string | null;
33
+ status: HostStatus;
34
+ platform: string | null;
35
+ arch: string | null;
36
+ }
37
+ export interface UpsertHostInput {
38
+ agentId: string;
39
+ tokenId: string | null;
40
+ lifecycleClass: LifecycleClass;
41
+ labels: string[];
42
+ hostname: string | null;
43
+ platform: string;
44
+ arch: string;
45
+ instanceId: string;
46
+ }
47
+ /** Minimal row shape `deriveHostStatus` reads (the store row or the CLI row). */
48
+ export interface HostStatusRow {
49
+ connected_instance_id: string | null;
50
+ lifecycle_class: string;
51
+ last_seen: Date | string;
52
+ }
53
+ /**
54
+ * The ONE status-derivation function — used by the store and the kici-admin
55
+ * host CLI. `ready` requires the host to be genuinely live (connected to some
56
+ * instance AND heartbeat fresh — the freshness check catches a crashed
57
+ * instance that never cleared `connected_instance_id`). It never returns
58
+ * `ready` for a not-currently-live host: a not-live `static` reads
59
+ * `unreachable` (the declared-but-absent alarm), a not-live `ephemeral` reads
60
+ * `stale` (scaled down, awaiting reap).
61
+ */
62
+ export declare function deriveHostStatus(row: HostStatusRow, nowMs: number, graceMs: number): HostStatus;
63
+ /**
64
+ * Durable, cluster-shared roster of every agent the cluster has ever enrolled.
65
+ *
66
+ * The in-memory `AgentRegistry` reconciles into this table on register
67
+ * (`upsert`) / unregister (`markDisconnected`), with a coarse heartbeat
68
+ * (`stampLastSeen`). All liveness writes are owner-guarded by `instanceId` so a
69
+ * stale disconnect from an old instance can never clobber a row a different
70
+ * instance now owns. The leader-only reaper deletes `ephemeral` rows past their
71
+ * ttl via `reapEphemeralPastTtl`; `static` rows persist and read `unreachable`.
72
+ */
73
+ export declare class HostRosterStore {
74
+ private readonly db;
75
+ constructor(db: Kysely<Database>);
76
+ /** Idempotent upsert on agent_id; stamps connected_instance_id + last_seen. */
77
+ upsert(input: UpsertHostInput): Promise<void>;
78
+ /** Clear liveness on disconnect — but only if THIS instance still owns it. */
79
+ markDisconnected(agentId: string, instanceId: string): Promise<void>;
80
+ /** Coarse heartbeat stamp — same owner-guard as markDisconnected. */
81
+ stampLastSeen(agentId: string, instanceId: string): Promise<void>;
82
+ /** Operator pre-declare of a static host before its agent dials in. */
83
+ declareStatic(input: {
84
+ agentId: string;
85
+ labels: string[];
86
+ hostname?: string;
87
+ }): Promise<void>;
88
+ get(agentId: string): Promise<HostRosterRow | null>;
89
+ listAll(): Promise<HostRosterRow[]>;
90
+ /**
91
+ * Resolve every roster host matching a `runsOnAll` predicate (OR-of-AND
92
+ * include groups, minus exclude labels), tagged with its derived status. This
93
+ * is the host-fanout resolver: it returns declared-but-absent static hosts
94
+ * (status `unreachable`) so the caller can apply `onUnreachable` — the live
95
+ * registry alone cannot name an expected-but-absent host.
96
+ */
97
+ findMatching(include: readonly (readonly LabelMatcher[])[], exclude: readonly LabelMatcher[], graceMs: number): Promise<MatchedHost[]>;
98
+ /**
99
+ * Count `static` (declared) hosts whose derived status is `unreachable` —
100
+ * the "declared-but-absent" alarm population. Reuses the single-source
101
+ * {@link deriveHostStatus} so the count never diverges from what
102
+ * `kici-admin host list` shows. A not-currently-connected static host reads
103
+ * `unreachable` regardless of grace (only the connected-but-stale case
104
+ * depends on `graceMs`).
105
+ */
106
+ countStaticUnreachable(graceMs: number): Promise<number>;
107
+ /** Delete ephemeral rows whose last_seen is older than ttl. Returns count. */
108
+ reapEphemeralPastTtl(ttlMs: number): Promise<number>;
109
+ }
110
+ //# sourceMappingURL=host-roster.d.ts.map
@@ -8,6 +8,7 @@
8
8
  *
9
9
  * No persistence needed -- agents re-register on reconnect.
10
10
  */
11
+ import { type LabelMatcher } from '@kici-dev/engine';
11
12
  import type { WsLike } from '@kici-dev/engine';
12
13
  export type { WsLike } from '@kici-dev/engine';
13
14
  /**
@@ -76,6 +77,13 @@ export interface AgentEntry {
76
77
  memoryUsedMb: number | null;
77
78
  memoryAvailableMb: number | null;
78
79
  uptimeSeconds: number | null;
80
+ /**
81
+ * The auth token's `agent_type` (`'static'` | `'ephemeral'`) snapshot, or
82
+ * `null` when auth mode is `none`. Drives the host roster's
83
+ * `lifecycle_class` — `static` rows persist + alarm on absence, `ephemeral`
84
+ * rows are GC'd past their ttl.
85
+ */
86
+ tokenAgentType: 'static' | 'ephemeral' | null;
79
87
  }
80
88
  /** Optional static metadata from the agent.register message. */
81
89
  interface AgentMetadata {
@@ -106,6 +114,36 @@ interface AgentMetadata {
106
114
  * scaler registration metadata.
107
115
  */
108
116
  scalerManaged?: boolean;
117
+ /**
118
+ * The auth token's `agent_type` (`'static'` | `'ephemeral'`), threaded from
119
+ * `agent-handler.ts` so the host roster can snapshot it as the row's
120
+ * `lifecycle_class`. `null` / undefined when auth mode is `none`.
121
+ */
122
+ tokenAgentType?: 'static' | 'ephemeral' | null;
123
+ }
124
+ /**
125
+ * Subset of `HostRosterStore` the registry reconciles into. Optional — when no
126
+ * store is injected (e.g. workers, which have no DB), the reconcile hooks are
127
+ * a no-op and the in-memory registry behaves exactly as before.
128
+ */
129
+ export interface RosterReconciler {
130
+ upsert(input: {
131
+ agentId: string;
132
+ tokenId: string | null;
133
+ lifecycleClass: 'static' | 'ephemeral';
134
+ labels: string[];
135
+ hostname: string | null;
136
+ platform: string;
137
+ arch: string;
138
+ instanceId: string;
139
+ }): Promise<void>;
140
+ markDisconnected(agentId: string, instanceId: string): Promise<void>;
141
+ stampLastSeen(agentId: string, instanceId: string): Promise<void>;
142
+ }
143
+ /** Optional dependencies that enable host-roster reconciliation. */
144
+ export interface AgentRegistryDeps {
145
+ rosterStore?: RosterReconciler;
146
+ instanceId?: string;
109
147
  }
110
148
  export declare class AgentRegistry {
111
149
  /** Primary: agentId -> AgentEntry */
@@ -143,6 +181,24 @@ export declare class AgentRegistry {
143
181
  * propagation path to in-flight WS connections.
144
182
  */
145
183
  private readonly tokenExpiryTimers;
184
+ /**
185
+ * Per-agent last roster `last_seen` stamp time (epoch ms). Throttles the
186
+ * coarse heartbeat write so we don't issue a DB write per agent per
187
+ * heartbeat — `grace`/`ttl` are minute-scale, so a throttled stamp is
188
+ * sufficient. Cleaned up alongside the agent in `removeFromIndexes`.
189
+ */
190
+ private readonly lastRosterStampAt;
191
+ /** Minimum interval between coarse roster `last_seen` stamps per agent. */
192
+ private readonly ROSTER_STAMP_THROTTLE_MS;
193
+ /** Optional host-roster reconciler + this instance's id (see AgentRegistryDeps). */
194
+ private readonly rosterStore?;
195
+ private readonly instanceId?;
196
+ /**
197
+ * The roster reconcile seam is optional: when no store is injected (workers
198
+ * with no DB, unit tests), every reconcile hook is a no-op and the in-memory
199
+ * registry behaves exactly as before.
200
+ */
201
+ constructor(deps?: AgentRegistryDeps);
146
202
  /**
147
203
  * Register an agent. If the agentId already exists, updates the existing
148
204
  * entry (agent reconnection scenario).
@@ -209,7 +265,14 @@ export declare class AgentRegistry {
209
265
  * @param requiredLabels - All labels the agent must have (intersection semantics).
210
266
  * @returns Array of matching available AgentEntry objects.
211
267
  */
212
- findAvailable(requiredLabels: string[], excludeLabels?: string[]): AgentEntry[];
268
+ findAvailable(requiredLabels: string[], requiredPatterns?: LabelMatcher[], excludeLabels?: string[], excludePatterns?: LabelMatcher[]): AgentEntry[];
269
+ /**
270
+ * Whether a specific agent satisfies the same label / exclude / mandatory
271
+ * gate `findAvailable` applies per-agent, ignoring capacity. Used by the
272
+ * host-fanout pin to verify the resolved agent still matches before pinning
273
+ * (guards against roster label drift between resolution and dispatch).
274
+ */
275
+ agentSatisfies(agent: AgentEntry, requiredLabels: string[], requiredPatterns?: LabelMatcher[], excludeLabels?: string[], excludePatterns?: LabelMatcher[]): boolean;
213
276
  /**
214
277
  * Check if ANY registered agent matches the required labels, regardless of capacity.
215
278
  * Used to decide whether to queue (agent exists but busy) vs reject (no agent at all).
@@ -221,7 +284,7 @@ export declare class AgentRegistry {
221
284
  * an off-gate gated agent as "matching but busy" and skip the peer
222
285
  * reroute path even though the local agent can never accept the job.
223
286
  */
224
- hasMatchingAgent(requiredLabels: string[], excludeLabels?: string[]): boolean;
287
+ hasMatchingAgent(requiredLabels: string[], requiredPatterns?: LabelMatcher[], excludeLabels?: string[], excludePatterns?: LabelMatcher[]): boolean;
225
288
  /**
226
289
  * Internal helper: intersect the per-label candidate sets to find agentIds
227
290
  * that satisfy `requiredLabels`. Returns `null` when any required label
package/dist/app.d.ts CHANGED
@@ -16,6 +16,7 @@ import type { Kysely } from 'kysely';
16
16
  import type pg from 'pg';
17
17
  import { type ColdStore } from '@kici-dev/shared';
18
18
  import type { AppConfig } from './config.js';
19
+ import type { HostRosterStore } from './agent/host-roster.js';
19
20
  import type { Database } from './db/types.js';
20
21
  import type { AgentRegistry } from './agent/registry.js';
21
22
  import type { Dispatcher } from './agent/dispatcher.js';
@@ -81,6 +82,8 @@ export interface AppDependencies {
81
82
  db: Kysely<Database>;
82
83
  pool: pg.Pool;
83
84
  registry: AgentRegistry;
85
+ /** Host roster store for runsOnAll fan-out resolution. */
86
+ hostRosterStore?: HostRosterStore;
84
87
  dispatcher: Dispatcher;
85
88
  jobQueue: JobQueue;
86
89
  dedup: DedupCache;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Host roster management commands for kici-admin (orchestrator data plane).
3
+ *
4
+ * host list List all roster hosts with derived status
5
+ * host get Show one roster host
6
+ * host declare Pre-declare a static host before its agent connects
7
+ *
8
+ * Reads/writes the orchestrator DB directly (via withDb), so this lives in
9
+ * kici-admin — never in the public `kici` or the Platform `kici-platform-admin`.
10
+ */
11
+ import type { Command } from 'commander';
12
+ export declare function registerHostCommands(program: Command): void;
13
+ //# sourceMappingURL=host.d.ts.map
@@ -99,6 +99,29 @@ export declare function resolveNpmSourceVersion(opts: {
99
99
  requested: string | undefined;
100
100
  running: string;
101
101
  }): string;
102
+ /** Verdict from {@link verifyNpmSourceLaunch}. */
103
+ export type NpmSourceLaunchVerdict = {
104
+ ok: true;
105
+ version: string;
106
+ manifestVersion: string | null;
107
+ } | {
108
+ ok: false;
109
+ reason: string;
110
+ };
111
+ /**
112
+ * Decide whether an npm-source upgrade may proceed, given the version the
113
+ * invoking CLI is (`invoked`) and the version the installed unit will actually
114
+ * launch (`launched`, or null when unresolvable — e.g. an opaque --binary
115
+ * install). On success `manifestVersion` is the value to persist (null = leave
116
+ * the manifest's kiciVersion unchanged because we couldn't verify it).
117
+ */
118
+ export declare function verifyNpmSourceLaunch(opts: {
119
+ component: UpgradeComponent;
120
+ invoked: string;
121
+ launched: string | null;
122
+ launchedPath: string | null;
123
+ force: boolean;
124
+ }): NpmSourceLaunchVerdict;
102
125
  export declare function performVersionedUpgrade(component: UpgradeComponent, opts: VersionedUpgradeOptions): Promise<void>;
103
126
  export {};
104
127
  //# sourceMappingURL=versioned-upgrade.d.ts.map
@@ -6,7 +6,7 @@
6
6
  * a container-based service management backend that works on any
7
7
  * platform with Docker or Podman installed.
8
8
  */
9
- import type { ServiceManager, ServiceConfig, ServiceStatus, LogOptions, DiscoveredInstance } from './types.js';
9
+ import type { ServiceManager, ServiceConfig, ServiceStatus, LogOptions, DiscoveredInstance, LaunchSpec } from './types.js';
10
10
  export declare class ComposeServiceManager implements ServiceManager {
11
11
  private runtime;
12
12
  /** Get the runtime binary (`podman` or `docker`), detecting it if not already done. */
@@ -22,5 +22,6 @@ export declare class ComposeServiceManager implements ServiceManager {
22
22
  logs(config: ServiceConfig, options: LogOptions): Promise<void>;
23
23
  isInstalled(config: ServiceConfig): Promise<boolean>;
24
24
  list(isUserLevel: boolean): Promise<DiscoveredInstance[]>;
25
+ readLaunchSpec(_config: ServiceConfig): Promise<LaunchSpec | null>;
25
26
  }
26
27
  //# sourceMappingURL=compose.d.ts.map
@@ -6,13 +6,13 @@
6
6
  * specified) platform. Uses dynamic imports to avoid loading all
7
7
  * platform-specific code on every platform.
8
8
  */
9
- export type { ServiceManager, ServiceConfig, ServiceStatus, ServiceState, ServicePlatform, RestartPolicy, LogOptions, } from './types.js';
9
+ export type { ServiceManager, ServiceConfig, LaunchSpec, ServiceStatus, ServiceState, ServicePlatform, RestartPolicy, LogOptions, } from './types.js';
10
10
  export { DEFAULT_RESTART_POLICY } from './types.js';
11
11
  export { detectPlatform, isRoot, kiciConfigRoot, getConfigDir, getLogDir, getCacheDir, } from './platform-detect.js';
12
12
  export { resolveUserLevel, type PrivilegeOpts } from './privilege.js';
13
13
  export type { Component, InstanceManifest, IndexEntry, ResolveOptions, ResolvedInstance, } from './instance/types.js';
14
14
  export { COMPONENTS, isComponent } from './instance/types.js';
15
- export { manifestFilename, manifestPath, readManifest, writeManifest, readKiciVersion, } from './instance/manifest.js';
15
+ export { manifestFilename, manifestPath, readManifest, writeManifest, readKiciVersion, resolveVersionFromLaunchSpec, } from './instance/manifest.js';
16
16
  export { indexPath, readIndex, writeIndex, appendIndexEntry, removeIndexEntry, } from './instance/index-file.js';
17
17
  export { listInstances, resolveInstance, formatRefusal } from './instance/resolve.js';
18
18
  export type { ListedInstance, ResolveArgs, ListInstancesArgs } from './instance/resolve.js';
@@ -4,6 +4,7 @@
4
4
  * every lifecycle command to reconstruct the ServiceConfig.
5
5
  */
6
6
  import type { Component, InstanceManifest } from './types.js';
7
+ import type { LaunchSpec } from '../types.js';
7
8
  /** Per-component manifest filename. */
8
9
  export declare function manifestFilename(component: Component): string;
9
10
  /** Resolve the manifest path inside an instance directory. */
@@ -27,4 +28,15 @@ export declare function writeManifest(instanceDir: string, manifest: InstanceMan
27
28
  * reliable source.
28
29
  */
29
30
  export declare function readKiciVersion(): string;
31
+ /**
32
+ * Resolve the `@kici-dev/<component>` package version that a {@link LaunchSpec}
33
+ * will actually execute, by locating the component's entry script among the
34
+ * spec's tokens and reading the version from the package.json that owns it.
35
+ *
36
+ * Returns null when no resolvable entry script is present (e.g. an opaque
37
+ * `--binary` install) or the owning package.json is missing / name-mismatched.
38
+ * This is what lets the npm-source upgrade refuse to report a version it can't
39
+ * stand behind.
40
+ */
41
+ export declare function resolveVersionFromLaunchSpec(spec: LaunchSpec, component: Component): string | null;
30
42
  //# sourceMappingURL=manifest.d.ts.map
@@ -27,8 +27,18 @@ export interface ListInstancesArgs {
27
27
  }
28
28
  /**
29
29
  * Reconcile <kiciRoot>/instances.json with the driver's native scan, then
30
- * rewrite the index dropping entries whose units no longer exist. Returns
31
- * the merged list filtered to the requested component + isUserLevel.
30
+ * rewrite the index so it mirrors the scanned instances that have a known
31
+ * instanceDir. Two self-heal directions happen here:
32
+ *
33
+ * - backward: drop index entries whose unit no longer exists.
34
+ * - forward: adopt an instanceDir the driver recovered from the unit marker
35
+ * when the index entry is missing or carries no dir — so a lost or emptied
36
+ * index rebuilds itself from the init system on the next read.
37
+ *
38
+ * The init system is therefore the source of truth for the name→folder mapping
39
+ * (via the X-KiCI-InstanceDir / KiCIInstanceDir / dev.kici.instance-dir / [KiCI-DIR]
40
+ * markers), and the index is a pure cache. Returns the merged list filtered to
41
+ * the requested component + isUserLevel.
32
42
  */
33
43
  export declare function listInstances(args: ListInstancesArgs): Promise<ListedInstance[]>;
34
44
  export interface ResolveArgs {
@@ -5,7 +5,7 @@
5
5
  * Supports both system-level (/Library/LaunchDaemons/) and
6
6
  * user-level (~/Library/LaunchAgents/) agents.
7
7
  */
8
- import type { ServiceConfig, ServiceManager, ServiceStatus, LogOptions, DiscoveredInstance } from './types.js';
8
+ import type { ServiceConfig, ServiceManager, ServiceStatus, LogOptions, DiscoveredInstance, LaunchSpec } from './types.js';
9
9
  export declare class LaunchdServiceManager implements ServiceManager {
10
10
  /** Build the launchd label for a service. */
11
11
  private label;
@@ -17,6 +17,8 @@ export declare class LaunchdServiceManager implements ServiceManager {
17
17
  private parseEnvFile;
18
18
  /** Escape XML special characters. */
19
19
  private escapeXml;
20
+ /** Inverse of {@link escapeXml} — decode the entities back to literals. */
21
+ private unescapeXml;
20
22
  /**
21
23
  * Generate a launchd plist XML string from a service config.
22
24
  * Visible for testing.
@@ -67,5 +69,6 @@ export declare class LaunchdServiceManager implements ServiceManager {
67
69
  logs(config: ServiceConfig, options: LogOptions): Promise<void>;
68
70
  isInstalled(config: ServiceConfig): Promise<boolean>;
69
71
  list(isUserLevel: boolean): Promise<DiscoveredInstance[]>;
72
+ readLaunchSpec(config: ServiceConfig): Promise<LaunchSpec | null>;
70
73
  }
71
74
  //# sourceMappingURL=launchd.d.ts.map
@@ -37,6 +37,11 @@ export declare function isRoot(): boolean;
37
37
  *
38
38
  * Used by the instance index (`<kiciRoot>/instances.json`) which lives
39
39
  * outside any per-instance subdir, and as the base for {@link getConfigDir}.
40
+ *
41
+ * `KICI_CONFIG_ROOT` overrides the platform default for both privilege scopes.
42
+ * It exists so a test harness (or a sandboxed run) can point the instance index
43
+ * at an isolated directory instead of sharing the host's `~/.config/kici/` —
44
+ * which otherwise lets concurrent installs clobber each other's index.
40
45
  */
41
46
  export declare function kiciConfigRoot(isUserLevel: boolean): string;
42
47
  /**
@@ -5,7 +5,7 @@
5
5
  * journalctl, and loginctl commands. Supports both system-level
6
6
  * (/etc/systemd/system/) and user-level (~/.config/systemd/user/) services.
7
7
  */
8
- import type { DiscoveredInstance, ServiceConfig, ServiceManager, ServiceStatus, LogOptions } from './types.js';
8
+ import type { DiscoveredInstance, ServiceConfig, ServiceManager, ServiceStatus, LogOptions, LaunchSpec } from './types.js';
9
9
  /**
10
10
  * Check whether linger is already enabled for a user.
11
11
  *
@@ -37,6 +37,7 @@ export declare class SystemdServiceManager implements ServiceManager {
37
37
  status(config: ServiceConfig): Promise<ServiceStatus>;
38
38
  logs(config: ServiceConfig, options: LogOptions): Promise<void>;
39
39
  isInstalled(config: ServiceConfig): Promise<boolean>;
40
+ readLaunchSpec(config: ServiceConfig): Promise<LaunchSpec | null>;
40
41
  list(isUserLevel: boolean): Promise<DiscoveredInstance[]>;
41
42
  }
42
43
  //# sourceMappingURL=systemd.d.ts.map
@@ -68,6 +68,28 @@ export interface ServiceConfig {
68
68
  * Optional during lifecycle ops that don't regenerate the unit; required on install.
69
69
  */
70
70
  component?: 'orchestrator' | 'agent';
71
+ /**
72
+ * The deploy folder that holds this instance's manifest. Embedded in the unit
73
+ * (X-KiCI-InstanceDir for systemd, KiCIInstanceDir for launchd,
74
+ * dev.kici.instance-dir for compose, a description suffix for Windows) so
75
+ * per-driver `list()` scans can recover the deploy folder straight from the
76
+ * init system — making the instance index a true rebuildable cache rather
77
+ * than the sole source of the name→folder mapping.
78
+ *
79
+ * Optional during lifecycle ops that don't regenerate the unit; set on install.
80
+ */
81
+ instanceDir?: string;
82
+ }
83
+ /**
84
+ * The launch command an installed init unit will actually execute, parsed
85
+ * back out of the unit/plist/service registration. `execPath` is the binary
86
+ * the init system runs (for a Node-launched server, the node binary); `args`
87
+ * are everything after it (for a Node-launched server, the resolved entry
88
+ * script path). Returned by {@link ServiceManager.readLaunchSpec}.
89
+ */
90
+ export interface LaunchSpec {
91
+ execPath: string;
92
+ args: string[];
71
93
  }
72
94
  /** Current state of a service. */
73
95
  export type ServiceState = 'running' | 'stopped' | 'failed' | 'unknown';
@@ -101,6 +123,12 @@ export interface DiscoveredInstance {
101
123
  isUserLevel: boolean;
102
124
  /** The component this unit belongs to, decoded from the unit's KiCI marker. */
103
125
  component: 'orchestrator' | 'agent';
126
+ /**
127
+ * The deploy folder recovered from the unit's KiCI instance-dir marker, when
128
+ * the unit carries one. Undefined for units installed before the marker
129
+ * existed (the index cache remains the fallback for those).
130
+ */
131
+ instanceDir?: string;
104
132
  }
105
133
  /**
106
134
  * Interface that all platform-specific service managers must implement.
@@ -134,5 +162,14 @@ export interface ServiceManager {
134
162
  * (systemd, launchd); ignored on Windows/compose.
135
163
  */
136
164
  list(isUserLevel: boolean): Promise<DiscoveredInstance[]>;
165
+ /**
166
+ * Read the launch command the installed unit will actually execute, parsed
167
+ * back out of the init system's own record (systemd ExecStart, launchd
168
+ * ProgramArguments, windows service binPath). Returns null when the unit is
169
+ * not installed, its launch spec can't be parsed, or the platform has no
170
+ * node-launched entry to resolve (compose). Used by the npm-source upgrade to
171
+ * verify the version that will run before reporting success.
172
+ */
173
+ readLaunchSpec(config: ServiceConfig): Promise<LaunchSpec | null>;
137
174
  }
138
175
  //# sourceMappingURL=types.d.ts.map
@@ -6,7 +6,7 @@
6
6
  * dependency on first use. Service configuration (auto-start, recovery)
7
7
  * is handled via sc.exe.
8
8
  */
9
- import type { ServiceManager, ServiceConfig, ServiceStatus, LogOptions, DiscoveredInstance } from './types.js';
9
+ import type { ServiceManager, ServiceConfig, ServiceStatus, LogOptions, DiscoveredInstance, LaunchSpec } from './types.js';
10
10
  export declare class WindowsServiceManager implements ServiceManager {
11
11
  install(config: ServiceConfig): Promise<void>;
12
12
  uninstall(config: ServiceConfig): Promise<void>;
@@ -17,5 +17,6 @@ export declare class WindowsServiceManager implements ServiceManager {
17
17
  logs(config: ServiceConfig, _options: LogOptions): Promise<void>;
18
18
  isInstalled(config: ServiceConfig): Promise<boolean>;
19
19
  list(isUserLevel: boolean): Promise<DiscoveredInstance[]>;
20
+ readLaunchSpec(config: ServiceConfig): Promise<LaunchSpec | null>;
20
21
  }
21
22
  //# sourceMappingURL=windows.d.ts.map