@kici-dev/compiler 0.1.27 → 0.2.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 (153) hide show
  1. package/dist/auth/headless-detect.d.ts +14 -5
  2. package/dist/auth/headless-detect.js +50 -9
  3. package/dist/cli.js +120 -11
  4. package/dist/commands/compile.js +28 -4
  5. package/dist/commands/diagnostics.js +14 -2
  6. package/dist/commands/doctor.d.ts +64 -0
  7. package/dist/commands/doctor.js +413 -0
  8. package/dist/commands/endpoints.js +6 -0
  9. package/dist/commands/fixture.js +13 -0
  10. package/dist/commands/hook.js +3 -2
  11. package/dist/commands/index.d.ts +8 -0
  12. package/dist/commands/index.js +5 -1
  13. package/dist/commands/init-host-os.d.ts +34 -0
  14. package/dist/commands/init-host-os.js +44 -0
  15. package/dist/commands/init.d.ts +4 -0
  16. package/dist/commands/init.js +159 -36
  17. package/dist/commands/local.d.ts +28 -2
  18. package/dist/commands/local.js +74 -5
  19. package/dist/commands/login.js +1 -1
  20. package/dist/commands/notifications.d.ts +61 -0
  21. package/dist/commands/notifications.js +274 -0
  22. package/dist/commands/preview.js +8 -1
  23. package/dist/commands/run-routed.js +10 -1
  24. package/dist/commands/run.js +41 -12
  25. package/dist/commands/runs/artifacts/download.d.ts +24 -0
  26. package/dist/commands/runs/artifacts/download.js +154 -0
  27. package/dist/commands/runs/artifacts/list.d.ts +5 -0
  28. package/dist/commands/runs/artifacts/list.js +47 -0
  29. package/dist/commands/runs/cancel.js +2 -2
  30. package/dist/commands/runs/list.js +8 -2
  31. package/dist/commands/verify-attestation.d.ts +3 -1
  32. package/dist/commands/verify-attestation.js +18 -7
  33. package/dist/errors/formatter.d.ts +14 -1
  34. package/dist/errors/formatter.js +13 -3
  35. package/dist/errors/index.d.ts +2 -1
  36. package/dist/errors/index.js +3 -2
  37. package/dist/errors/source-location.d.ts +20 -0
  38. package/dist/errors/source-location.js +45 -0
  39. package/dist/execution/executor.js +25 -5
  40. package/dist/fixtures/defaults/index.js +2 -1
  41. package/dist/llm-context/llms-architecture.txt +48 -19
  42. package/dist/llm-context/llms-cli.txt +2461 -1582
  43. package/dist/llm-context/llms-features.txt +178 -106
  44. package/dist/llm-context/llms-full.txt +6124 -4423
  45. package/dist/llm-context/llms-getting-started.txt +306 -42
  46. package/dist/llm-context/llms-patterns.txt +13 -7
  47. package/dist/llm-context/llms-sdk-runtime.txt +2013 -0
  48. package/dist/llm-context/llms-sdk.txt +591 -2152
  49. package/dist/llm-context/llms.txt +30 -16
  50. package/dist/local-plane/orchestrator-process.d.ts +12 -7
  51. package/dist/local-plane/orchestrator-process.js +28 -14
  52. package/dist/local-plane/os-mismatch-hint.d.ts +16 -0
  53. package/dist/local-plane/os-mismatch-hint.js +34 -0
  54. package/dist/local-plane/plane-liveness.d.ts +68 -0
  55. package/dist/local-plane/plane-liveness.js +145 -0
  56. package/dist/local-plane/plane-manager.d.ts +60 -18
  57. package/dist/local-plane/plane-manager.js +210 -70
  58. package/dist/local-plane/plane-trigger.d.ts +13 -4
  59. package/dist/local-plane/plane-trigger.js +27 -14
  60. package/dist/local-plane/platform-attach.js +1 -5
  61. package/dist/local-plane/port-holder.d.ts +78 -0
  62. package/dist/local-plane/port-holder.js +227 -0
  63. package/dist/local-plane/postgres.d.ts +15 -3
  64. package/dist/local-plane/postgres.js +34 -12
  65. package/dist/local-plane/run-follow.d.ts +20 -0
  66. package/dist/local-plane/run-follow.js +51 -4
  67. package/dist/local-plane/scaler-config.d.ts +28 -14
  68. package/dist/local-plane/scaler-config.js +59 -41
  69. package/dist/local-plane/source-provider.js +4 -10
  70. package/dist/lockfile/generator.d.ts +14 -1
  71. package/dist/lockfile/generator.js +99 -33
  72. package/dist/lockfile/index.d.ts +3 -1
  73. package/dist/lockfile/index.js +3 -2
  74. package/dist/lockfile/purity-diagnostics.d.ts +31 -0
  75. package/dist/lockfile/purity-diagnostics.js +52 -0
  76. package/dist/postinstall.js +2 -1
  77. package/dist/remote/artifact-extract.d.ts +8 -0
  78. package/dist/remote/artifact-extract.js +58 -0
  79. package/dist/remote/config.d.ts +21 -3
  80. package/dist/remote/config.js +24 -5
  81. package/dist/remote/dashboard-client.d.ts +39 -2
  82. package/dist/remote/dashboard-client.js +47 -4
  83. package/dist/remote/fs-case.d.ts +21 -0
  84. package/dist/remote/fs-case.js +45 -0
  85. package/dist/remote/history.d.ts +9 -3
  86. package/dist/remote/history.js +79 -10
  87. package/dist/remote/local-repo-identity.d.ts +1 -1
  88. package/dist/remote/local-repo-identity.js +2 -1
  89. package/dist/remote/notifications-client.d.ts +149 -0
  90. package/dist/remote/notifications-client.js +103 -0
  91. package/dist/remote/oauth.d.ts +13 -0
  92. package/dist/remote/oauth.js +31 -5
  93. package/dist/remote/output/summary.js +76 -14
  94. package/dist/remote/render.js +30 -9
  95. package/dist/remote/uploader.js +5 -4
  96. package/dist/templates/agents-md.d.ts +1 -1
  97. package/dist/templates/agents-md.js +6 -6
  98. package/dist/templates/index.d.ts +1 -1
  99. package/dist/templates/index.js +2 -2
  100. package/dist/templates/package-json.d.ts +15 -0
  101. package/dist/templates/package-json.js +26 -5
  102. package/dist/test-runner/dry-run.d.ts +2 -1
  103. package/dist/test-runner/dry-run.js +18 -1
  104. package/dist/test-runner/event-types.d.ts +2 -0
  105. package/dist/test-runner/event-types.js +3 -0
  106. package/dist/test-runner/job-executor.d.ts +1 -0
  107. package/dist/test-runner/job-executor.js +81 -43
  108. package/dist/test-runner/output-formatter.d.ts +5 -2
  109. package/dist/test-runner/output-formatter.js +6 -4
  110. package/dist/test-runner/payload-builder.d.ts +7 -1
  111. package/dist/test-runner/payload-builder.js +11 -2
  112. package/dist/test-runner/rule-evaluator.d.ts +7 -3
  113. package/dist/test-runner/rule-evaluator.js +13 -12
  114. package/dist/test-runner/step-context.d.ts +23 -5
  115. package/dist/test-runner/step-context.js +28 -118
  116. package/dist/types.d.ts +21 -2
  117. package/dist/types.js +4 -2
  118. package/dist/validation/index.d.ts +2 -0
  119. package/dist/validation/index.js +2 -1
  120. package/dist/validation/typecheck.d.ts +28 -0
  121. package/dist/validation/typecheck.js +110 -0
  122. package/dist/validation/validator.d.ts +9 -4
  123. package/dist/validation/validator.js +38 -44
  124. package/package.json +12 -15
  125. package/sbom.spdx.json +1322 -1964
  126. package/dist/assets/api-TJJVHrjC.json +0 -118
  127. package/dist/assets/descriptor-BTtjzN9L.json +0 -1382
  128. package/dist/assets/package-BpQF9kR8.json +0 -74
  129. package/dist/assets/package-Ceo2h27X.json +0 -89
  130. package/dist/assets/source_context-D0atuL28.json +0 -20
  131. package/dist/assets/type-BFqO8SCZ.json +0 -202
  132. package/dist/commands/cancel.d.ts +0 -22
  133. package/dist/commands/detect-package-manager.d.ts +0 -42
  134. package/dist/commands/held-run-resolve.d.ts +0 -50
  135. package/dist/commands/status.d.ts +0 -34
  136. package/dist/commands/test.d.ts +0 -88
  137. package/dist/errors/codes.d.ts +0 -25
  138. package/dist/local-executor/dag-scheduler.d.ts +0 -44
  139. package/dist/local-executor/index.d.ts +0 -23
  140. package/dist/local-executor/job-runner.d.ts +0 -47
  141. package/dist/local-executor/materializer.d.ts +0 -44
  142. package/dist/local-executor/output-streamer.d.ts +0 -31
  143. package/dist/local-executor/payload-generator.d.ts +0 -16
  144. package/dist/local-executor/picker.d.ts +0 -33
  145. package/dist/local-executor/runs-on-display.d.ts +0 -9
  146. package/dist/local-executor/secret-loader.d.ts +0 -18
  147. package/dist/local-executor/to-event-payload.d.ts +0 -16
  148. package/dist/local-executor/types.d.ts +0 -93
  149. package/dist/local-executor/workflow-lock.d.ts +0 -82
  150. package/dist/package-F7UXSDHW.json +0 -74
  151. package/dist/remote/client.d.ts +0 -210
  152. package/dist/remote/observer.d.ts +0 -81
  153. package/dist/test-runner/summary.d.ts +0 -7
@@ -1,6 +1,8 @@
1
1
  import "../rolldown-runtime-ClRpJifh.js";
2
2
  import { planePaths, planePorts } from "./paths.js";
3
- import { orchestratorReady, spawnOrchestrator } from "./orchestrator-process.js";
3
+ import { awaitOrchestratorReady, resolveServerEntry, resolveStandaloneEntry, spawnOrchestratorProcess } from "./orchestrator-process.js";
4
+ import { isPortFree, processCommandLine, terminatePid } from "./port-holder.js";
5
+ import { classificationPid, classifyPlane, planeStateOf, reclaimPlanePort } from "./plane-liveness.js";
4
6
  import { startPlanePostgres, stopPlanePostgres } from "./postgres.js";
5
7
  import { writeScalerConfig } from "./scaler-config.js";
6
8
  import { derivePlatformWsUrl, mintOrchestratorKey, revokeOrchestratorKey } from "./platform-attach.js";
@@ -8,7 +10,19 @@ import path from "node:path";
8
10
  import fs from "node:fs";
9
11
  import { generateKeyPairSync, randomBytes } from "node:crypto";
10
12
  //#region src/local-plane/plane-manager.ts
11
- const kiciVersion = "0.1.27";
13
+ /**
14
+ * The current CLI build's identity — semver plus git build commit — read at
15
+ * call time from the Rolldown-injected build constants (`scripts/build-ts.mjs`).
16
+ * Read on each call rather than captured in a module const so unit tests can
17
+ * inject the constants via `globalThis`. Falls back to `0.0.0` / `unknown` when
18
+ * running from source (unbuilt tree / vitest), where the defines are absent.
19
+ */
20
+ function currentBuildIdentity() {
21
+ return {
22
+ version: "0.2.0",
23
+ buildCommit: "15d5e4447"
24
+ };
25
+ }
12
26
  /**
13
27
  * Read the plane's persisted Platform orchestrator token (mode 0600), or null
14
28
  * when the plane is independent/offline. Kept out of the stamp so the token
@@ -139,6 +153,28 @@ function writeStamp(stamp) {
139
153
  fs.writeFileSync(stampFile, JSON.stringify(stamp, null, 2));
140
154
  fs.writeFileSync(pidfile, String(stamp.orchestratorPid));
141
155
  }
156
+ /** Remove the plane's on-disk record (pidfile + stamp). */
157
+ function clearStamp() {
158
+ const { pidfile, stampFile } = planePaths();
159
+ fs.rmSync(pidfile, { force: true });
160
+ fs.rmSync(stampFile, { force: true });
161
+ }
162
+ /**
163
+ * Whether a running plane described by `existing` was booted from a different
164
+ * CLI build than the current one — a semver bump OR a git-commit change (the
165
+ * latter covers intermediate staging/E2E commits that share a semver). Returns
166
+ * false when there is no stamp, or when the current build has no concrete
167
+ * identity (`buildCommit === 'unknown'`, i.e. running from source / a test),
168
+ * so a source-context `planeUp` never reboots a healthy plane spuriously. An
169
+ * old stamp with no `buildCommit` field reads as `undefined` and therefore
170
+ * triggers a one-time reboot on the first upgrade past this feature.
171
+ */
172
+ function planeBuildIsStale(existing) {
173
+ if (!existing) return false;
174
+ const { version, buildCommit } = currentBuildIdentity();
175
+ if (buildCommit === "unknown") return false;
176
+ return existing.kiciVersion !== version || existing.buildCommit !== buildCommit;
177
+ }
142
178
  function orchestratorUrl(port) {
143
179
  return `http://127.0.0.1:${port}`;
144
180
  }
@@ -146,57 +182,135 @@ function orchestratorUrl(port) {
146
182
  function planeLogPath() {
147
183
  return planePaths().logFile;
148
184
  }
149
- /** Read the plane's current status by probing `/ready` against the stamped port. */
185
+ /**
186
+ * Read the plane's current status from whatever holds the plane port.
187
+ *
188
+ * `running` means "serving": a live orchestrator whose `/ready` fails is
189
+ * reported as `unready`, with the failing checks, rather than as stopped —
190
+ * inferring "not running" from an unready probe hides a process that still owns
191
+ * the port.
192
+ */
150
193
  async function planeStatus() {
151
194
  const stamp = readStamp();
152
195
  const port = stamp?.port ?? planePorts().orchestrator;
153
- if (!await orchestratorReady(port)) return {
196
+ const classification = await classifyPlane(port, stamp);
197
+ const mode = stamp?.mode ?? "independent";
198
+ const attachment = readAttachment() ?? void 0;
199
+ if (classification.kind === "free") return {
154
200
  running: false,
155
- mode: stamp?.mode ?? "independent",
156
- attachment: readAttachment() ?? void 0
201
+ state: "stopped",
202
+ mode,
203
+ attachment
157
204
  };
158
- return {
205
+ if (classification.kind === "ours-ready") return {
159
206
  running: true,
160
- pid: stamp?.orchestratorPid,
207
+ state: "ready",
208
+ pid: classification.pid,
161
209
  port,
162
210
  pgKind: stamp?.pgKind,
163
211
  stampVersion: stamp?.stampVersion,
164
212
  url: orchestratorUrl(port),
165
213
  adminToken: planeAdminToken() ?? void 0,
166
- mode: stamp?.mode ?? "independent",
167
- attachment: readAttachment() ?? void 0
214
+ mode,
215
+ attachment
216
+ };
217
+ return {
218
+ running: false,
219
+ state: planeStateOf(classification),
220
+ pid: classificationPid(classification) ?? void 0,
221
+ port,
222
+ pgKind: stamp?.pgKind,
223
+ url: orchestratorUrl(port),
224
+ checks: classification.kind === "ours-unready" ? classification.checks : void 0,
225
+ mode,
226
+ attachment
168
227
  };
169
228
  }
170
229
  /**
171
- * Start (or reuse) the local dev plane. Idempotent: a healthy running plane
172
- * **whose stamped mode matches the requested mode** is returned as-is; a mode
173
- * change (independent↔hybrid) forces a teardown + reboot. Otherwise Postgres +
174
- * the orchestrator are booted and a fresh stamp + pidfile are written. When
230
+ * Tear the plane down ahead of a fresh boot, refusing to continue when the port
231
+ * was not released.
232
+ *
233
+ * Booting over a still-held port only re-fails later, as a readiness timeout
234
+ * that names the log instead of the survivor — so the teardown's own reason (the
235
+ * pid still holding the port) is raised here, while it is still the accurate
236
+ * one.
237
+ */
238
+ async function tearDownForBoot() {
239
+ const result = await planeDown();
240
+ if (result.stopped) return;
241
+ const holder = result.holderPid === void 0 ? "" : ` (pid ${result.holderPid})`;
242
+ throw new Error(result.reason ?? `port ${result.port} is still held after stopping the local dev plane${holder}`);
243
+ }
244
+ /**
245
+ * Ensure the plane port is either reusable or vacant before a boot.
246
+ *
247
+ * Returns the running plane when it may be reused as-is, or null when the caller
248
+ * must boot fresh. A plane no stamp accounts for is reclaimed rather than
249
+ * adopted: reuse would serve `kici run --local` from an orchestrator of unknown
250
+ * build, with an unknown scaler configuration, which is exactly the state a
251
+ * stamped plane's staleness check exists to prevent.
252
+ */
253
+ async function prepareForBoot(port, existing, requestedMode) {
254
+ const classification = await classifyPlane(port, existing);
255
+ switch (classification.kind) {
256
+ case "free": return null;
257
+ case "foreign-unknown": {
258
+ const reclaim = await reclaimPlanePort(port, classification);
259
+ throw new Error(reclaim.error ?? `port ${port} is held by an unrecognised process`);
260
+ }
261
+ case "foreign-kici": {
262
+ const reclaim = await reclaimPlanePort(port, classification);
263
+ if (!reclaim.freed) throw new Error(reclaim.error ?? `could not reclaim port ${port}`);
264
+ clearStamp();
265
+ return null;
266
+ }
267
+ case "ours-unready":
268
+ await tearDownForBoot();
269
+ return null;
270
+ case "ours-ready": {
271
+ const modeChanged = (existing?.mode ?? "independent") !== requestedMode;
272
+ if (planeBuildIsStale(existing) || modeChanged) {
273
+ await tearDownForBoot();
274
+ return null;
275
+ }
276
+ return {
277
+ running: true,
278
+ state: "ready",
279
+ pid: classification.pid,
280
+ port,
281
+ pgKind: existing?.pgKind,
282
+ stampVersion: existing?.stampVersion,
283
+ url: orchestratorUrl(port),
284
+ adminToken: planeAdminToken() ?? void 0,
285
+ mode: existing?.mode ?? "independent",
286
+ attachment: readAttachment() ?? void 0
287
+ };
288
+ }
289
+ }
290
+ }
291
+ /**
292
+ * Start (or reuse) the local dev plane. Idempotent: a healthy running plane this
293
+ * config dir stamped, **whose stamped mode matches the requested mode**, is
294
+ * returned as-is. Otherwise the port is reclaimed if it is another KiCI plane,
295
+ * and Postgres + the orchestrator are booted with a fresh stamp + pidfile. When
175
296
  * `attach` is present the orchestrator boots hybrid against the Platform relay;
176
- * otherwise it boots independent with the dev-signed identity.
297
+ * otherwise it boots independent with the dev-signed identity. Throws when the
298
+ * port is held by a process that is not a KiCI plane orchestrator.
177
299
  */
178
300
  async function planeUp(opts = {}) {
179
301
  const { orchestrator: port } = planePorts();
180
302
  const existing = readStamp();
181
303
  const requestedMode = opts.attach ? "hybrid" : "independent";
182
304
  if (existing && existing.stampVersion !== 3) {
183
- await planeDown();
305
+ await tearDownForBoot();
184
306
  fs.rmSync(planePaths().pgData, {
185
307
  recursive: true,
186
308
  force: true
187
309
  });
188
- } else if ((existing?.mode ?? "independent") !== requestedMode && await orchestratorReady(port)) await planeDown();
189
- else if (await orchestratorReady(port)) return {
190
- running: true,
191
- pid: existing?.orchestratorPid,
192
- port,
193
- pgKind: existing?.pgKind,
194
- stampVersion: existing?.stampVersion,
195
- url: orchestratorUrl(port),
196
- adminToken: planeAdminToken() ?? void 0,
197
- mode: existing?.mode ?? "independent",
198
- attachment: readAttachment() ?? void 0
199
- };
310
+ } else {
311
+ const reusable = await prepareForBoot(port, existing, requestedMode);
312
+ if (reusable) return reusable;
313
+ }
200
314
  const adminToken = readOrCreateAdminToken();
201
315
  const secretKey = readOrCreateSecretKey();
202
316
  const scalerConfigFile = writeScalerConfig(port);
@@ -210,7 +324,7 @@ async function planeUp(opts = {}) {
210
324
  orgId: opts.attach.orgId,
211
325
  keyId: opts.attach.keyId
212
326
  });
213
- orch = await spawnOrchestrator(pg.url, {
327
+ orch = spawnOrchestratorProcess(pg.url, {
214
328
  adminToken,
215
329
  secretKey,
216
330
  scalerConfigFile,
@@ -221,23 +335,33 @@ async function planeUp(opts = {}) {
221
335
  });
222
336
  } else {
223
337
  const devIdentityKey = readOrCreateDevIdentity();
224
- orch = await spawnOrchestrator(pg.url, {
338
+ orch = spawnOrchestratorProcess(pg.url, {
225
339
  adminToken,
226
340
  secretKey,
227
341
  scalerConfigFile,
228
342
  devIdentityKeyFile: devIdentityKey
229
343
  });
230
344
  }
345
+ const { version: stampVersionSemver, buildCommit: stampBuildCommit } = currentBuildIdentity();
231
346
  writeStamp({
232
347
  orchestratorPid: orch.pid,
233
348
  port: orch.port,
234
349
  pgKind: pg.kind,
235
- kiciVersion,
350
+ kiciVersion: stampVersionSemver,
351
+ buildCommit: stampBuildCommit,
236
352
  stampVersion: 3,
237
353
  mode: requestedMode
238
354
  });
355
+ try {
356
+ await awaitOrchestratorReady(orch.port);
357
+ } catch {
358
+ await terminatePid(orch.pid);
359
+ if (await isPortFree(orch.port)) clearStamp();
360
+ throw new Error(`local orchestrator did not become ready — see ${planeLogPath()}`);
361
+ }
239
362
  return {
240
363
  running: true,
364
+ state: "ready",
241
365
  pid: orch.pid,
242
366
  port: orch.port,
243
367
  pgKind: pg.kind,
@@ -249,49 +373,65 @@ async function planeUp(opts = {}) {
249
373
  };
250
374
  }
251
375
  /**
252
- * Stop the local dev plane. Teardown is reconstructed from the stamp (the
253
- * start-time Postgres handle does not survive a separate CLI invocation):
254
- * SIGTERM the orchestrator, stop Postgres by backend kind, then clear the
255
- * pidfile + stamp.
376
+ * Whether the stamped pid is still one of this build's plane orchestrators.
377
+ *
378
+ * The stamp outlives the process it names a crash, a `kill -9`, or a reboot
379
+ * all leave it behind — so once the operating system recycles that pid onto an
380
+ * unrelated process, signalling it blind would stop a stranger's program. Every
381
+ * other signal path in the plane is identity-verified (`reclaimPlanePort` only
382
+ * ever signals a pid confirmed to hold the port or to answer `/health` as a KiCI
383
+ * orchestrator); this keeps the stamp-driven path to the same standard by
384
+ * matching the live command line against the entry this build would launch.
385
+ *
386
+ * Returns false whenever identity cannot be established — an unreadable command
387
+ * line, an unresolvable entry, or a plane launched from a different install.
388
+ * That is deliberate: not signalling leaves a process running, while signalling
389
+ * the wrong pid kills one.
256
390
  */
257
- async function planeDown() {
258
- const stamp = readStamp();
259
- const { pidfile, stampFile } = planePaths();
260
- if (stamp) {
261
- try {
262
- process.kill(stamp.orchestratorPid, "SIGTERM");
263
- } catch {}
264
- await waitForProcessExit(stamp.orchestratorPid);
265
- await stopPlanePostgres(stamp.pgKind);
266
- }
267
- fs.rmSync(pidfile, { force: true });
268
- fs.rmSync(stampFile, { force: true });
391
+ async function stampedPidIsOurOrchestrator(pid) {
392
+ const cmdline = await processCommandLine(pid);
393
+ if (!cmdline) return false;
394
+ for (const resolveEntry of [resolveStandaloneEntry, resolveServerEntry]) try {
395
+ if (cmdline.includes(resolveEntry())) return true;
396
+ } catch {}
397
+ return false;
269
398
  }
270
399
  /**
271
- * Poll until process `pid` has exited (SIGTERM already sent), escalating to
272
- * SIGKILL if it outlives the grace period, so the caller can rely on the
273
- * listening socket being released once this resolves. `process.kill(pid, 0)`
274
- * throws `ESRCH` once the process is gone.
400
+ * Stop the local dev plane and confirm the port was released.
401
+ *
402
+ * Teardown is reconstructed from whatever holds the port, not only from the
403
+ * stamp: a plane this config dir never stamped still occupies the port and is
404
+ * still ours to stop. The pidfile and stamp are cleared only once the port is
405
+ * verified free, so a survivor never loses the pid that identifies it.
275
406
  */
276
- async function waitForProcessExit(pid, graceMs = 1e4) {
277
- const deadline = Date.now() + graceMs;
278
- let killed = false;
279
- for (;;) {
280
- try {
281
- process.kill(pid, 0);
282
- } catch {
283
- return;
284
- }
285
- if (!killed && Date.now() > deadline) {
286
- try {
287
- process.kill(pid, "SIGKILL");
288
- } catch {
289
- return;
290
- }
291
- killed = true;
292
- }
293
- await new Promise((r) => setTimeout(r, 100));
407
+ async function planeDown() {
408
+ const stamp = readStamp();
409
+ const port = stamp?.port ?? planePorts().orchestrator;
410
+ const classification = await classifyPlane(port, stamp);
411
+ const reclaim = await reclaimPlanePort(port, classification);
412
+ if (!reclaim.freed) return {
413
+ stopped: false,
414
+ port,
415
+ holderPid: reclaim.killedPid ?? classificationPid(classification) ?? void 0,
416
+ reason: reclaim.error
417
+ };
418
+ if (stamp && stamp.orchestratorPid !== reclaim.killedPid && await stampedPidIsOurOrchestrator(stamp.orchestratorPid)) {
419
+ await terminatePid(stamp.orchestratorPid);
420
+ if (!await isPortFree(port)) return {
421
+ stopped: false,
422
+ port,
423
+ holderPid: stamp.orchestratorPid,
424
+ reason: `port ${port} is still held after stopping pid ${stamp.orchestratorPid}`
425
+ };
294
426
  }
427
+ if (stamp) await stopPlanePostgres(stamp.pgKind);
428
+ else if (classification.kind !== "free") await stopPlanePostgres("embedded");
429
+ clearStamp();
430
+ return {
431
+ stopped: true,
432
+ port,
433
+ holderPid: reclaim.killedPid
434
+ };
295
435
  }
296
436
  /**
297
437
  * Attach the local dev plane to the hosted Platform: mint an org-scoped
@@ -335,6 +475,6 @@ async function detachPlane(opts = {}) {
335
475
  return planeUp();
336
476
  }
337
477
  //#endregion
338
- export { attachPlane, clearAttachment, clearPlatformToken, detachPlane, devIdentityKeyFile, devIdentityPublicJwkFile, planeAdminToken, planeDown, planeLogPath, planeStatus, planeUp, readAttachment, readOrCreateAdminToken, readOrCreateDevIdentity, readOrCreateSecretKey, readPlatformToken, waitForProcessExit, writeAttachment, writePlatformToken };
478
+ export { attachPlane, clearAttachment, clearPlatformToken, clearStamp, currentBuildIdentity, detachPlane, devIdentityKeyFile, devIdentityPublicJwkFile, planeAdminToken, planeBuildIsStale, planeDown, planeLogPath, planeStatus, planeUp, readAttachment, readOrCreateAdminToken, readOrCreateDevIdentity, readOrCreateSecretKey, readPlatformToken, writeAttachment, writePlatformToken };
339
479
 
340
480
  //# sourceMappingURL=plane-manager.js.map
@@ -45,8 +45,14 @@ export interface LocalTriggerRequest {
45
45
  * of this phase — no orchestrator export is added).
46
46
  */
47
47
  export declare function buildLocalTriggerRequest(input: LocalTriggerInput): LocalTriggerRequest;
48
- /** POST the trigger request to the plane. Returns the HTTP status. */
49
- export declare function sendLocalTrigger(planeUrl: string, req: LocalTriggerRequest): Promise<number>;
48
+ /** Structured result of posting a local trigger to the plane. */
49
+ export interface LocalTriggerResponse {
50
+ status: number;
51
+ /** Routing-key-scoped delivery id echoed by the plane, or null if absent. */
52
+ deliveryId: string | null;
53
+ }
54
+ /** POST the trigger request to the plane. Returns the status + delivery id. */
55
+ export declare function sendLocalTrigger(planeUrl: string, req: LocalTriggerRequest): Promise<LocalTriggerResponse>;
50
56
  export interface TriggerRunOptions {
51
57
  client?: RunDiscoveryClient;
52
58
  pollIntervalMs?: number;
@@ -55,8 +61,11 @@ export interface TriggerRunOptions {
55
61
  }
56
62
  /**
57
63
  * Trigger the run and resolve its runId. Sends the synthetic push, then polls
58
- * the admin runs list (`created_at > since`) for the newest run, resending the
59
- * webhook past a grace window to absorb source hot-reload latency.
64
+ * the admin runs list filtered by this webhook's routing-key-scoped delivery id
65
+ * (`?deliveryId=`), resending the webhook past a grace window to absorb source
66
+ * hot-reload latency. Correlating on the exact delivery id — rather than the
67
+ * newest run in a time window — guarantees the follower attaches to the run
68
+ * THIS invocation created, even when another local run lands concurrently.
60
69
  */
61
70
  export declare function triggerRun(planeUrl: string, adminToken: string, input: LocalTriggerInput, opts?: TriggerRunOptions): Promise<string>;
62
71
  //# sourceMappingURL=plane-trigger.d.ts.map
@@ -45,45 +45,58 @@ function buildLocalTriggerRequest(input) {
45
45
  body
46
46
  };
47
47
  }
48
- /** POST the trigger request to the plane. Returns the HTTP status. */
48
+ /** POST the trigger request to the plane. Returns the status + delivery id. */
49
49
  async function sendLocalTrigger(planeUrl, req) {
50
- return (await fetch(`${planeUrl.replace(/\/$/, "")}${req.path}`, {
50
+ const res = await fetch(`${planeUrl.replace(/\/$/, "")}${req.path}`, {
51
51
  method: "POST",
52
52
  headers: req.headers,
53
53
  body: req.body
54
- })).status;
54
+ });
55
+ let deliveryId = null;
56
+ try {
57
+ const parsed = await res.json();
58
+ if (typeof parsed.deliveryId === "string") deliveryId = parsed.deliveryId;
59
+ } catch {}
60
+ return {
61
+ status: res.status,
62
+ deliveryId
63
+ };
55
64
  }
56
65
  /**
57
66
  * Trigger the run and resolve its runId. Sends the synthetic push, then polls
58
- * the admin runs list (`created_at > since`) for the newest run, resending the
59
- * webhook past a grace window to absorb source hot-reload latency.
67
+ * the admin runs list filtered by this webhook's routing-key-scoped delivery id
68
+ * (`?deliveryId=`), resending the webhook past a grace window to absorb source
69
+ * hot-reload latency. Correlating on the exact delivery id — rather than the
70
+ * newest run in a time window — guarantees the follower attaches to the run
71
+ * THIS invocation created, even when another local run lands concurrently.
60
72
  */
61
73
  async function triggerRun(planeUrl, adminToken, input, opts = {}) {
62
74
  const client = opts.client ?? new AdminApiClient(planeUrl, adminToken);
63
75
  const pollIntervalMs = opts.pollIntervalMs ?? 750;
64
76
  const resendAfterMs = opts.resendAfterMs ?? 8e3;
65
77
  const timeoutMs = opts.timeoutMs ?? 6e4;
66
- const since = /* @__PURE__ */ new Date(Date.now() - 3e3);
67
78
  const req = buildLocalTriggerRequest(input);
68
- await sendLocalTrigger(planeUrl, req);
79
+ let deliveryId = (await sendLocalTrigger(planeUrl, req)).deliveryId;
69
80
  let lastSend = Date.now();
70
81
  const deadline = Date.now() + timeoutMs;
71
82
  while (Date.now() < deadline) {
72
- const runId = await findLatestRunSince(client, since);
73
- if (runId) return runId;
83
+ if (deliveryId) {
84
+ const runId = await findRunByDelivery(client, deliveryId);
85
+ if (runId) return runId;
86
+ }
74
87
  if (Date.now() - lastSend > resendAfterMs) {
75
- await sendLocalTrigger(planeUrl, req);
88
+ deliveryId ??= (await sendLocalTrigger(planeUrl, req)).deliveryId;
76
89
  lastSend = Date.now();
77
90
  }
78
91
  await sleep(pollIntervalMs);
79
92
  }
80
93
  throw new Error("offline run: no run appeared after triggering the local plane");
81
94
  }
82
- /** Return the newest run created after `since`, or null when none yet. */
83
- async function findLatestRunSince(client, since) {
95
+ /** Return the run created by this webhook delivery, or null when none yet. */
96
+ async function findRunByDelivery(client, deliveryId) {
84
97
  const qs = new URLSearchParams({
85
- since: since.toISOString(),
86
- limit: "5"
98
+ deliveryId,
99
+ limit: "1"
87
100
  });
88
101
  const { runs } = await client.get(`/api/v1/admin/runs?${qs}`);
89
102
  return runs[0]?.runId ?? null;
@@ -99,11 +99,7 @@ async function probePlatformReachable(apiBase, timeoutMs = 4e3) {
99
99
  }
100
100
  /** A short, non-sensitive label for the minted key name (the machine host). */
101
101
  function orgHostLabel() {
102
- try {
103
- return process.env.HOSTNAME || "kici-local";
104
- } catch {
105
- return "kici-local";
106
- }
102
+ return process.env.HOSTNAME || "kici-local";
107
103
  }
108
104
  //#endregion
109
105
  export { derivePlatformWsUrl, mintOrchestratorKey, probePlatformReachable, revokeOrchestratorKey };
@@ -0,0 +1,78 @@
1
+ /** Command runner, injectable so each platform branch is unit-testable anywhere. */
2
+ export type ExecFn = (cmd: string, args: string[]) => Promise<{
3
+ stdout: string;
4
+ exitCode: number;
5
+ }>;
6
+ /**
7
+ * Command line of process `pid`, or null when it cannot be determined.
8
+ *
9
+ * Linux exposes it as a NUL-separated `/proc` file; every other platform is
10
+ * asked through the same injectable `ExecFn` the port discovery above uses.
11
+ *
12
+ * **A null means "cannot tell", never "not that process."** Callers must treat
13
+ * it as an absence of evidence — the only safe reading when the answer decides
14
+ * whether to signal a pid.
15
+ */
16
+ export declare function processCommandLine(pid: number, opts?: {
17
+ exec?: ExecFn;
18
+ platform?: NodeJS.Platform;
19
+ readProc?: (path: string) => string;
20
+ }): Promise<string | null>;
21
+ /**
22
+ * Whether the port can be bound on loopback. Probes `127.0.0.1` because that is
23
+ * the address the CLI talks to; a listener bound to `0.0.0.0` still makes this
24
+ * bind fail with EADDRINUSE, so there is no false "free".
25
+ */
26
+ export declare function isPortFree(port: number): Promise<boolean>;
27
+ /**
28
+ * Parse `ss -lptnH` rows. The process name is deliberately NOT matched — a Node
29
+ * listener reports as `MainThread` — so only `pid=N` is extracted. A row for a
30
+ * socket this user does not own carries no `users:(…)` field at all and yields
31
+ * null, which is what sends the caller to the lsof fallback.
32
+ */
33
+ export declare function parseSsOutput(stdout: string, port: number): number | null;
34
+ /**
35
+ * Parse `lsof -Fpn` field output, which is a stateful stream: each `p<pid>` line
36
+ * precedes the `n<address>` lines belonging to that pid.
37
+ */
38
+ export declare function parseLsofOutput(stdout: string, port: number): number | null;
39
+ /** Parse `netstat -ano -p tcp`: LISTENING rows only, pid in the last column. */
40
+ export declare function parseNetstatOutput(stdout: string, port: number): number | null;
41
+ /**
42
+ * Pid of the process listening on `port`, or null when it cannot be determined.
43
+ * Each platform runs the UNFILTERED listing and matches the port here rather
44
+ * than passing a filter expression to the tool, whose syntax is the fragile
45
+ * part. On Linux a null parse falls through to lsof as well as a non-zero exit,
46
+ * because ss omits the owning-process field for sockets this user does not own.
47
+ */
48
+ export declare function findPortHolderPid(port: number, opts?: {
49
+ exec?: ExecFn;
50
+ platform?: NodeJS.Platform;
51
+ }): Promise<number | null>;
52
+ /** Signal sender, injectable so the escalation windows are unit-testable. */
53
+ export type KillFn = (pid: number, signal: NodeJS.Signals | 0) => void;
54
+ /**
55
+ * Poll until process `pid` has exited, sending SIGTERM first and escalating to
56
+ * SIGKILL past the grace window. `process.kill(pid, 0)` throws ESRCH once the
57
+ * process is gone. On Windows every signal terminates unconditionally, so the
58
+ * escalation collapses into a single hard kill there.
59
+ *
60
+ * Resolving does NOT prove the process died: a pid that survives SIGKILL (a
61
+ * zombie awaiting reaping, or a task wedged in uninterruptible I/O) is given
62
+ * `hardKillWaitMs` and then abandoned, because a teardown command that hangs
63
+ * forever is worse than one that reports the port is still held. Callers decide
64
+ * the outcome from the port itself — `waitForPortFree` — never from this
65
+ * resolving.
66
+ */
67
+ export declare function terminatePid(pid: number, graceMs?: number, opts?: {
68
+ hardKillWaitMs?: number;
69
+ kill?: KillFn;
70
+ }): Promise<void>;
71
+ /**
72
+ * Poll until the port is bindable. This, not `terminatePid` resolving, is what
73
+ * decides a teardown: `terminatePid` abandons a pid that survives SIGKILL, and
74
+ * even a process that did exit can leave its listening socket lingering a moment
75
+ * longer on some platforms.
76
+ */
77
+ export declare function waitForPortFree(port: number, timeoutMs?: number): Promise<boolean>;
78
+ //# sourceMappingURL=port-holder.d.ts.map