@kici-dev/compiler 0.1.16 → 0.1.17

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 (136) hide show
  1. package/dist/auth/headless-detect.js +1 -1
  2. package/dist/cli-banner.js +1 -1
  3. package/dist/cli.js +56 -19
  4. package/dist/commands/approve.js +1 -1
  5. package/dist/commands/compile.js +5 -3
  6. package/dist/commands/diagnostics.d.ts +7 -0
  7. package/dist/commands/diagnostics.js +58 -0
  8. package/dist/commands/docs.js +1 -1
  9. package/dist/commands/drain-worker.js +1 -1
  10. package/dist/commands/endpoints.js +1 -1
  11. package/dist/commands/fixture.js +1 -1
  12. package/dist/commands/held-run-client.js +1 -1
  13. package/dist/commands/held-run-resolve.js +1 -1
  14. package/dist/commands/hook.js +1 -1
  15. package/dist/commands/index.d.ts +16 -4
  16. package/dist/commands/index.js +10 -4
  17. package/dist/commands/init.js +2 -2
  18. package/dist/commands/login.d.ts +0 -2
  19. package/dist/commands/login.js +1 -3
  20. package/dist/commands/logout.js +1 -1
  21. package/dist/commands/orchestrators.d.ts +25 -0
  22. package/dist/commands/orchestrators.js +115 -0
  23. package/dist/commands/org.js +1 -1
  24. package/dist/commands/reject.js +1 -1
  25. package/dist/commands/run.d.ts +2 -9
  26. package/dist/commands/run.js +256 -315
  27. package/dist/commands/runs/cancel.d.ts +6 -0
  28. package/dist/commands/runs/cancel.js +32 -0
  29. package/dist/commands/runs/list.d.ts +6 -0
  30. package/dist/commands/runs/list.js +49 -0
  31. package/dist/commands/runs/logs.d.ts +7 -0
  32. package/dist/commands/runs/logs.js +67 -0
  33. package/dist/commands/runs/rerun.d.ts +5 -0
  34. package/dist/commands/runs/rerun.js +21 -0
  35. package/dist/commands/runs/show.d.ts +5 -0
  36. package/dist/commands/runs/show.js +65 -0
  37. package/dist/commands/secrets-list.d.ts +7 -9
  38. package/dist/commands/secrets-list.js +40 -64
  39. package/dist/commands/test.d.ts +4 -0
  40. package/dist/commands/test.js +1 -1
  41. package/dist/commands/types.d.ts +3 -5
  42. package/dist/commands/types.js +13 -37
  43. package/dist/commands/verify-attestation.d.ts +12 -0
  44. package/dist/commands/verify-attestation.js +69 -0
  45. package/dist/commands/watch.js +1 -1
  46. package/dist/commands/workflows.js +14 -34
  47. package/dist/errors/capability-gap.js +1 -1
  48. package/dist/errors/formatter.js +1 -1
  49. package/dist/errors/index.js +1 -1
  50. package/dist/execution/executor.js +1 -1
  51. package/dist/execution/index.js +1 -1
  52. package/dist/execution/sdk-alias.js +1 -1
  53. package/dist/execution/ts-loader.js +1 -1
  54. package/dist/fixtures/compiler.js +1 -1
  55. package/dist/fixtures/defaults/index.js +1 -1
  56. package/dist/format.js +1 -1
  57. package/dist/generators/secrets-dts.js +2 -2
  58. package/dist/hooks/detector.js +1 -1
  59. package/dist/hooks/index.js +1 -1
  60. package/dist/hooks/installer.js +1 -1
  61. package/dist/hooks/templates.js +1 -1
  62. package/dist/index.js +1 -1
  63. package/dist/llm-context/llms-full.txt +1047 -132
  64. package/dist/llm-context/llms.txt +4 -1
  65. package/dist/local-executor/dag-scheduler.js +1 -1
  66. package/dist/local-executor/index.js +1 -1
  67. package/dist/local-executor/job-runner.js +37 -16
  68. package/dist/local-executor/materializer.js +1 -1
  69. package/dist/local-executor/output-streamer.js +1 -1
  70. package/dist/local-executor/payload-generator.js +1 -1
  71. package/dist/local-executor/picker.js +1 -1
  72. package/dist/local-executor/secret-loader.js +1 -1
  73. package/dist/local-executor/to-event-payload.js +1 -1
  74. package/dist/local-executor/types.js +1 -1
  75. package/dist/local-executor/workflow-lock.js +0 -0
  76. package/dist/lockfile/generator.d.ts +7 -5
  77. package/dist/lockfile/generator.js +21 -18
  78. package/dist/lockfile/hash-files.js +1 -1
  79. package/dist/lockfile/hasher.js +1 -1
  80. package/dist/lockfile/index.js +1 -1
  81. package/dist/lockfile/purity-analyzer.js +1 -1
  82. package/dist/postinstall.js +1 -1
  83. package/dist/provenance-trust-root.d.ts +14 -0
  84. package/dist/provenance-trust-root.js +49 -0
  85. package/dist/remote/config.d.ts +6 -2
  86. package/dist/remote/config.js +7 -4
  87. package/dist/remote/dashboard-client.d.ts +92 -0
  88. package/dist/remote/dashboard-client.js +168 -0
  89. package/dist/remote/encryption.js +3 -4
  90. package/dist/remote/history.js +1 -1
  91. package/dist/remote/oauth.js +1 -1
  92. package/dist/remote/oidc-discovery.js +1 -1
  93. package/dist/remote/output/json.js +1 -1
  94. package/dist/remote/output/junit.js +1 -1
  95. package/dist/remote/output/streaming.js +1 -1
  96. package/dist/remote/output/summary.js +1 -1
  97. package/dist/remote/platform-client.d.ts +142 -0
  98. package/dist/remote/platform-client.js +187 -0
  99. package/dist/remote/prod-defaults.js +1 -1
  100. package/dist/remote/render.d.ts +11 -0
  101. package/dist/remote/render.js +48 -0
  102. package/dist/remote/secret-upload.js +1 -1
  103. package/dist/remote/uploader.d.ts +9 -0
  104. package/dist/remote/uploader.js +51 -4
  105. package/dist/templates/agents-md.d.ts +1 -1
  106. package/dist/templates/agents-md.js +8 -3
  107. package/dist/templates/index.js +1 -1
  108. package/dist/templates/package-json.js +4 -5
  109. package/dist/templates/tsconfig-json.js +1 -1
  110. package/dist/templates/workflows/hello-world.js +2 -2
  111. package/dist/templates/workflows/hello-world.ts +1 -1
  112. package/dist/templates/workflows/pr-checks.js +3 -3
  113. package/dist/templates/workflows/pr-checks.ts +2 -2
  114. package/dist/test-runner/dry-run.js +1 -1
  115. package/dist/test-runner/event-types.js +1 -1
  116. package/dist/test-runner/git-detector.js +1 -1
  117. package/dist/test-runner/index.js +1 -1
  118. package/dist/test-runner/job-executor.js +1 -1
  119. package/dist/test-runner/output-formatter.js +1 -1
  120. package/dist/test-runner/payload-builder.js +1 -1
  121. package/dist/test-runner/rule-evaluator.js +1 -1
  122. package/dist/test-runner/secrets-file.js +1 -1
  123. package/dist/test-runner/step-context.js +10 -6
  124. package/dist/types.d.ts +8 -5
  125. package/dist/types.js +2 -1
  126. package/dist/validation/index.js +1 -1
  127. package/dist/validation/validator.js +1 -1
  128. package/dist/workflows/hello-world.ts +1 -1
  129. package/dist/workflows/pr-checks.ts +2 -2
  130. package/package.json +5 -4
  131. package/sbom.spdx.json +68 -38
  132. package/dist/commands/cancel.js +0 -124
  133. package/dist/commands/status.js +0 -212
  134. package/dist/remote/client.js +0 -203
  135. package/dist/remote/observer.js +0 -174
  136. /package/dist/{chunk-gOLHoazu.js → chunk-BTugEXQM.js} +0 -0
@@ -1,15 +1,13 @@
1
- import "../chunk-gOLHoazu.js";
1
+ import "../chunk-BTugEXQM.js";
2
2
  import { resolveKiciDir } from "../execution/executor.js";
3
3
  import "../execution/index.js";
4
- import { AuthenticationError, ConnectionError, OrchestratorClient } from "../remote/client.js";
5
4
  import { loadGlobalConfig } from "../remote/config.js";
6
5
  import { RunHistory } from "../remote/history.js";
7
- import { ObserverClient } from "../remote/observer.js";
6
+ import { AccessDeniedError, AmbiguousClusterError, AuthenticationError, ConnectionError, NoClusterError, PlatformRunClient } from "../remote/platform-client.js";
8
7
  import { buildEncryptedSecrets } from "../remote/secret-upload.js";
9
8
  import { createOverlayTarball, getSizeWarning, uploadTarball } from "../remote/uploader.js";
10
9
  import { formatJsonResult } from "../remote/output/json.js";
11
10
  import { formatJunitResult } from "../remote/output/junit.js";
12
- import { StreamingFormatter } from "../remote/output/streaming.js";
13
11
  import { formatErrorHighlight, formatMultiFixtureSummary, formatSummary } from "../remote/output/summary.js";
14
12
  import { compileFixtures, filterFixtures } from "../fixtures/compiler.js";
15
13
  import path from "node:path";
@@ -17,6 +15,15 @@ import pc from "picocolors";
17
15
  import { readFile, writeFile } from "node:fs/promises";
18
16
  import { formatBytes, logger, toErrorMessage } from "@kici-dev/core";
19
17
  //#region src/commands/run.ts
18
+ /** Terminal run statuses returned by the Platform run-status snapshot. */
19
+ const TERMINAL_STATUSES = new Set([
20
+ "success",
21
+ "failed",
22
+ "cancelled",
23
+ "error"
24
+ ]);
25
+ /** Interval between status/log polls while a run is active. */
26
+ const POLL_INTERVAL_MS = 750;
20
27
  /**
21
28
  * Run a workflow locally using the local executor.
22
29
  * Thin wrapper that delegates to executeLocal from local-executor.
@@ -43,8 +50,7 @@ async function runLocalCommand(options) {
43
50
  }
44
51
  }
45
52
  /**
46
- * Run fixtures remotely against the orchestrator.
47
- * Migrated from test.ts — full remote execution functionality.
53
+ * Run fixtures remotely, routed through the Platform.
48
54
  */
49
55
  async function runRemoteCommand(fixture, options) {
50
56
  if (options.debug) {
@@ -116,33 +122,59 @@ function describeEvent(event) {
116
122
  return "custom";
117
123
  }
118
124
  /**
119
- * Run fixtures remotely against the orchestrator.
125
+ * Resolve the authenticated Platform client and the run target (org + cluster).
126
+ *
127
+ * Org resolution: `--org` → `config.activeOrgId` → error.
128
+ * Cluster resolution: `--orchestrator` → `config.defaultClusters[orgId]` →
129
+ * omit and let the Platform sole-select (or return a 422 the CLI surfaces).
120
130
  */
121
- async function runFixturesRemotely(fixtures, options) {
122
- const config = await loadGlobalConfig();
123
- if (!config.token) {
124
- logger.error(pc.red("Not authenticated. Run `kici login` to set up your API key."));
125
- return false;
131
+ function resolvePlatformContext(config, options) {
132
+ const token = config.pat;
133
+ if (!token) {
134
+ logger.error(pc.red("Not authenticated. Run `kici login` to authenticate."));
135
+ return null;
126
136
  }
127
- const endpoint = config.endpoint ?? config.platformEndpoint;
128
- if (!endpoint) {
129
- logger.error(pc.red("No orchestrator endpoint configured. Run `kici login --endpoint <url>` to set one."));
130
- return false;
137
+ if (!config.platformEndpoint) {
138
+ logger.error(pc.red("No Platform endpoint configured. Run `kici login` to set up your Platform."));
139
+ return null;
140
+ }
141
+ const orgId = options.org ?? config.activeOrgId;
142
+ if (!orgId) {
143
+ logger.error(pc.red("No target organization. Select one with `kici org use <org>` or pass `--org <id>`."));
144
+ return null;
131
145
  }
146
+ const orchestrator = options.orchestrator;
147
+ const defaultCluster = config.defaultClusters?.[orgId];
148
+ return {
149
+ client: new PlatformRunClient({
150
+ platformEndpoint: config.platformEndpoint,
151
+ token
152
+ }),
153
+ orgId,
154
+ target: {
155
+ orchestrator,
156
+ defaultCluster
157
+ }
158
+ };
159
+ }
160
+ /**
161
+ * Run fixtures remotely against the Platform.
162
+ */
163
+ async function runFixturesRemotely(fixtures, options) {
164
+ const config = await loadGlobalConfig();
165
+ const ctx = resolvePlatformContext(config, options);
166
+ if (!ctx) return false;
132
167
  if (options.json) options.quiet = true;
133
- const client = new OrchestratorClient({
134
- endpoint,
135
- token: config.token
136
- });
137
168
  const history = new RunHistory();
138
169
  await history.load();
139
170
  if (!options.quiet) {
140
- logger.info(pc.gray(`Orchestrator: ${endpoint}`));
171
+ logger.info(pc.gray(`Platform: ${config.platformEndpoint}`));
172
+ logger.info(pc.gray(`Organization: ${ctx.orgId}`));
141
173
  logger.info(pc.gray(`Fixtures: ${fixtures.length} to run\n`));
142
174
  }
143
175
  const results = [];
144
176
  if (options.parallel && fixtures.length > 1) {
145
- const promises = fixtures.map((f) => runSingleFixture(f, client, options, config, history));
177
+ const promises = fixtures.map((f) => runSingleFixture(f, ctx, options, config, history));
146
178
  const settled = await Promise.allSettled(promises);
147
179
  for (const s of settled) if (s.status === "fulfilled") results.push(s.value);
148
180
  else results.push({
@@ -152,10 +184,16 @@ async function runFixturesRemotely(fixtures, options) {
152
184
  reason: s.reason instanceof Error ? s.reason.message : String(s.reason)
153
185
  });
154
186
  } else for (const f of fixtures) {
155
- const result = await runSingleFixture(f, client, options, config, history);
187
+ const result = await runSingleFixture(f, ctx, options, config, history);
156
188
  results.push(result);
157
189
  if (result.status === "failed" || result.status === "error") break;
158
190
  }
191
+ return renderResults(results, options);
192
+ }
193
+ /**
194
+ * Render the aggregated fixture results in the requested format.
195
+ */
196
+ async function renderResults(results, options) {
159
197
  const runResults = results.filter((r) => r.status === "success" || r.status === "failed" || r.status === "cancelled").map((r) => ({
160
198
  fixtureId: r.fixtureId,
161
199
  runId: r.runId,
@@ -173,44 +211,69 @@ async function runFixturesRemotely(fixtures, options) {
173
211
  return results.every((r) => r.status === "accepted" || r.status === "success");
174
212
  }
175
213
  /**
176
- * Run a single fixture remotely.
214
+ * Prepare the overlay tarball + routing metadata for a run.
215
+ *
216
+ * The overlay always carries the full local working tree (the run executes the
217
+ * developer's local code, never a clone). The compiled lock is inlined so the
218
+ * orchestrator can match triggers under the `remote:<orgId>` anchor without a
219
+ * webhook provider to fetch it from a git host.
220
+ */
221
+ async function prepareOverlay(options) {
222
+ const kiciDir = resolveKiciDir(options.kiciDir);
223
+ const repoRoot = path.resolve(kiciDir, "..");
224
+ if (!options.quiet) logger.info(pc.gray("Creating overlay tarball..."));
225
+ const { tarballPath, summary, hasRemote } = await createOverlayTarball(repoRoot, { fullWorkingTree: true });
226
+ if (!options.quiet) logger.info(pc.gray("Running your local working tree (overlay includes .git, so git steps work)"));
227
+ const inlineLockFile = await readFile(path.join(kiciDir, "kici.lock.json"), "utf-8");
228
+ if (!options.quiet) {
229
+ logger.info(pc.gray(`${summary.fileCount} files changed, ${summary.newFiles} new, ${summary.deletedFiles} deleted (${formatBytes(summary.compressedSize)} compressed)`));
230
+ const sizeWarning = getSizeWarning(summary.compressedSize);
231
+ if (sizeWarning) logger.info(pc.yellow(sizeWarning));
232
+ }
233
+ return {
234
+ tarballPath,
235
+ summary,
236
+ hasRemote,
237
+ inlineLockFile,
238
+ kiciDir,
239
+ repoRoot
240
+ };
241
+ }
242
+ /**
243
+ * Init the upload (Platform) and PUT the encrypted tarball directly to the
244
+ * object store (data plane). Returns the upload id + the encryption keys.
177
245
  */
178
- async function runSingleFixture(fixture, client, options, config, history) {
246
+ async function initAndUpload(ctx, overlay, options) {
247
+ if (!options.quiet) logger.info(pc.gray("Initializing upload..."));
248
+ const upload = await ctx.client.initUpload(ctx.orgId, ctx.target, {
249
+ sha: overlay.summary.sha,
250
+ fileCount: overlay.summary.fileCount,
251
+ compressedSize: overlay.summary.compressedSize
252
+ });
253
+ if (!options.quiet) logger.info(pc.gray("Uploading overlay..."));
254
+ const uploadResult = await uploadTarball({
255
+ tarballPath: overlay.tarballPath,
256
+ signedUrl: upload.signedUrl,
257
+ orchestratorPublicKey: Buffer.from(upload.publicKey, "base64")
258
+ });
259
+ return {
260
+ uploadId: upload.uploadId,
261
+ publicKey: upload.publicKey,
262
+ cliPublicKey: uploadResult.cliPublicKey.toString("base64")
263
+ };
264
+ }
265
+ /**
266
+ * Run a single fixture remotely through the Platform.
267
+ */
268
+ async function runSingleFixture(fixture, ctx, options, config, history) {
179
269
  const opts = typeof fixture.fixture.options === "function" ? await fixture.fixture.options() : fixture.fixture.options;
180
270
  if (!options.quiet) logger.info(pc.cyan(`\n--- ${fixture.id} ---`));
181
271
  try {
182
- const kiciDir = resolveKiciDir(options.kiciDir);
183
- const repoRoot = path.resolve(kiciDir, "..");
184
- if (!options.quiet) logger.info(pc.gray("Creating overlay tarball..."));
185
- const { tarballPath, summary, hasRemote } = await createOverlayTarball(repoRoot);
186
- let routingKey = options.routingKey ?? config.routingKey ?? "default";
187
- if (!hasRemote) {
188
- routingKey = `local:${path.basename(repoRoot)}`;
189
- if (!options.quiet) logger.warn("No remote detected -- steps that use git commands will fail (no .git directory)");
190
- }
191
- let inlineLockFile;
192
- if (!hasRemote) inlineLockFile = await readFile(path.join(kiciDir, "kici.lock.json"), "utf-8");
193
- if (!options.quiet) {
194
- logger.info(pc.gray(`${summary.fileCount} files changed, ${summary.newFiles} new, ${summary.deletedFiles} deleted (${formatBytes(summary.compressedSize)} compressed)`));
195
- const sizeWarning = getSizeWarning(summary.compressedSize);
196
- if (sizeWarning) logger.info(pc.yellow(sizeWarning));
197
- }
198
- if (!options.quiet) logger.info(pc.gray("Initializing upload..."));
199
- const upload = await client.initUpload({
200
- routingKey,
201
- sha: summary.sha,
202
- fileCount: summary.fileCount,
203
- compressedSize: summary.compressedSize
204
- });
205
- if (!options.quiet) logger.info(pc.gray("Uploading overlay..."));
206
- const uploadResult = await uploadTarball({
207
- tarballPath,
208
- signedUrl: upload.signedUrl,
209
- orchestratorPublicKey: Buffer.from(upload.publicKey, "base64")
210
- });
272
+ const overlay = await prepareOverlay(options);
273
+ const uploaded = await initAndUpload(ctx, overlay, options);
211
274
  const event = buildEventFromFixture(opts);
212
- if (!hasRemote) {
213
- const repoName = path.basename(repoRoot);
275
+ {
276
+ const repoName = path.basename(overlay.repoRoot);
214
277
  const repo = event.payload.repository;
215
278
  event.payload.repository = {
216
279
  ...repo ?? {},
@@ -218,21 +281,20 @@ async function runSingleFixture(fixture, client, options, config, history) {
218
281
  };
219
282
  }
220
283
  if (!options.quiet) logger.info(pc.gray("Triggering test run..."));
221
- const encrypted = await buildEncryptedSecrets(kiciDir, options.envFlags, options.context, upload.publicKey);
222
- const triggerResult = await client.triggerTest({
284
+ const encrypted = await buildEncryptedSecrets(overlay.kiciDir, options.envFlags, options.context, uploaded.publicKey);
285
+ const triggerResult = await ctx.client.trigger(ctx.orgId, ctx.target, {
223
286
  fixtureId: fixture.id,
224
287
  event,
225
- routingKey,
226
- uploadId: upload.uploadId,
227
- cliPublicKey: uploadResult.cliPublicKey.toString("base64"),
288
+ uploadId: uploaded.uploadId,
289
+ cliPublicKey: uploaded.cliPublicKey,
228
290
  secrets: opts.secrets,
229
291
  ...encrypted && {
230
292
  encryptedSecrets: encrypted.encryptedSecrets,
231
293
  encryptedSecretsKey: encrypted.cliPublicKey
232
294
  },
233
295
  workflowName: opts.workflowName,
234
- inlineLockFile,
235
- fullRepo: !hasRemote || void 0
296
+ inlineLockFile: overlay.inlineLockFile,
297
+ fullRepo: true
236
298
  });
237
299
  if (triggerResult.status === "rejected") {
238
300
  if (!options.quiet) logger.info(pc.red(`Rejected: ${triggerResult.reason ?? "unknown reason"}`));
@@ -240,29 +302,23 @@ async function runSingleFixture(fixture, client, options, config, history) {
240
302
  fixtureId: fixture.id,
241
303
  runId: triggerResult.runId,
242
304
  status: "rejected",
243
- reason: triggerResult.reason,
244
- observeUrl: triggerResult.observeUrl
305
+ reason: triggerResult.reason
245
306
  };
246
307
  }
247
- if (!options.quiet) {
248
- logger.info(pc.green(`Run started: ${triggerResult.runId}`));
249
- logger.info(pc.gray(`Observe: ${triggerResult.observeUrl}`));
250
- }
251
- const historyEndpoint = config.endpoint ?? config.platformEndpoint ?? "";
308
+ if (!options.quiet) logger.info(pc.green(`Run started: ${triggerResult.runId}`));
252
309
  await history.addEntry({
253
310
  runId: triggerResult.runId,
254
311
  fixtureId: fixture.id,
255
312
  status: "running",
256
313
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
257
- endpoint: historyEndpoint
314
+ endpoint: config.platformEndpoint ?? ""
258
315
  });
259
316
  if (options.wait === false) return {
260
317
  fixtureId: fixture.id,
261
318
  runId: triggerResult.runId,
262
- status: "accepted",
263
- observeUrl: triggerResult.observeUrl
319
+ status: "accepted"
264
320
  };
265
- const result = await observeCompletion(fixture.id, triggerResult.runId, triggerResult.observeUrl, config.token, client, options);
321
+ const result = await pollRunToCompletion(ctx, triggerResult.runId, fixture.id, options);
266
322
  await history.updateEntry(triggerResult.runId, {
267
323
  status: result.status,
268
324
  completedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -271,283 +327,168 @@ async function runSingleFixture(fixture, client, options, config, history) {
271
327
  });
272
328
  return result;
273
329
  } catch (error) {
274
- if (error instanceof AuthenticationError) logger.error(pc.red("Authentication failed. Run `kici login` to re-authenticate."));
275
- else if (error instanceof ConnectionError) logger.error(pc.red(`Connection failed: ${error.message}`));
276
- return {
277
- fixtureId: fixture.id,
278
- runId: "",
279
- status: "error",
280
- reason: toErrorMessage(error)
281
- };
330
+ return handleRunError(fixture.id, error, options);
282
331
  }
283
332
  }
284
- /**
285
- * Build a SimulatedEvent-compatible object from fixture options.
286
- */
287
- function buildEventFromFixture(opts) {
288
- const eventObj = opts.event;
289
- const type = String(eventObj._type ?? "push");
290
- const action = eventObj.action ? String(eventObj.action) : void 0;
291
- const targetBranch = opts.branch ?? "main";
333
+ /** Map a run-path error to a CLI message + a result. */
334
+ function handleRunError(fixtureId, error, options) {
335
+ if (error instanceof AmbiguousClusterError) {
336
+ logger.error(pc.red(`Multiple orchestrators are connected. Pass --orchestrator <name>, one of: ${error.clusters.join(", ")}`));
337
+ logger.error(pc.gray("Or set a default with `kici orchestrators use <name>`."));
338
+ } else if (error instanceof NoClusterError) logger.error(pc.red("No orchestrator is connected for this organization."));
339
+ else if (error instanceof AuthenticationError) logger.error(pc.red("Authentication failed. Run `kici login` to re-authenticate."));
340
+ else if (error instanceof AccessDeniedError) logger.error(pc.red(`Access denied: ${error.message}`));
341
+ else if (error instanceof ConnectionError) logger.error(pc.red(`Connection failed: ${error.message}`));
342
+ else if (options.debug && error instanceof Error && error.stack) logger.error(pc.gray(error.stack));
292
343
  return {
293
- type,
294
- action,
295
- targetBranch,
296
- sourceBranch: type === "pr" ? opts.branch ?? "feature/test" : void 0,
297
- payload: {
298
- ref: `refs/heads/${targetBranch}`,
299
- repository: { full_name: opts.repo ?? "owner/repo" },
300
- ...opts.sha && { after: opts.sha },
301
- ...opts.pr && { number: opts.pr }
302
- }
344
+ fixtureId,
345
+ runId: "",
346
+ status: "error",
347
+ reason: toErrorMessage(error)
303
348
  };
304
349
  }
305
350
  /**
306
- * Observe run completion via WebSocket (primary path).
307
- * Falls back to HTTP polling if the WS connection fails.
351
+ * Poll the Platform for run completion, streaming log lines as they arrive.
352
+ *
353
+ * Advances a monotonic line-offset cursor (spec §13a): each `runLogs(cursor)`
354
+ * returns the next chunk + `nextCursor`; the run is done only when the status
355
+ * is terminal and the log stream has drained.
308
356
  */
309
- async function observeCompletion(fixtureId, runId, observeUrl, token, client, options) {
310
- const formatter = new StreamingFormatter();
311
- formatter.isQuiet = options.quiet ?? false;
312
- const lastLinesByJob = /* @__PURE__ */ new Map();
313
- const MAX_ERROR_LINES = 50;
314
- let completionResult = null;
315
- let fallbackToPolling = false;
316
- const observer = new ObserverClient({
317
- observeUrl,
318
- token,
319
- runId,
320
- onLog: (msg) => {
321
- for (const line of msg.lines) {
322
- const output = formatter.formatLogLine(msg.jobName, msg.stepName, line);
323
- if (output) process.stdout.write(output);
324
- }
325
- if (!lastLinesByJob.has(msg.jobName)) lastLinesByJob.set(msg.jobName, []);
326
- const buffer = lastLinesByJob.get(msg.jobName);
327
- buffer.push(...msg.lines);
328
- while (buffer.length > MAX_ERROR_LINES) buffer.shift();
329
- },
330
- onStep: (msg) => {
331
- const output = formatter.formatStepChange(msg.jobName, msg.stepName, msg.state, msg.durationMs);
332
- if (output) process.stdout.write(output);
333
- },
334
- onStatus: (msg) => {
335
- const output = formatter.formatStatusChange(msg.status, msg.jobName);
336
- if (output) process.stdout.write(output);
337
- },
338
- onComplete: (msg) => {
339
- completionResult = msg;
340
- },
341
- onError: (err) => {
342
- if (options.debug) logger.error(pc.gray(`Observer error: ${err.message}`));
343
- fallbackToPolling = true;
344
- }
345
- });
357
+ async function pollRunToCompletion(ctx, runId, fixtureId, options) {
358
+ const startTime = Date.now();
359
+ let cursor = 0;
360
+ const tailLines = [];
361
+ const MAX_TAIL = 50;
346
362
  let cancelled = false;
347
363
  const cancelHandler = async () => {
348
364
  if (cancelled) return;
349
365
  cancelled = true;
350
366
  if (!options.quiet) logger.info(pc.yellow("\nCancelling test run..."));
351
- observer.disconnect();
352
367
  try {
353
- const cancelResult = await client.cancelTest(runId);
354
- if (!options.quiet) logger.info(pc.gray(`Cancel status: ${cancelResult.status}`));
368
+ await ctx.client.cancel(ctx.orgId, runId, ctx.target);
355
369
  } catch {}
356
370
  };
357
371
  process.on("SIGINT", cancelHandler);
358
372
  try {
359
- try {
360
- await observer.connect();
361
- } catch {
362
- if (options.debug) logger.info(pc.gray("WebSocket connection failed, falling back to HTTP polling"));
363
- process.removeListener("SIGINT", cancelHandler);
364
- return await pollForCompletionFallback(fixtureId, runId, observeUrl, client, options);
365
- }
366
- formatter.startElapsedTimer();
367
- let complete;
368
- try {
369
- complete = await observer.waitForCompletion();
370
- } catch {
371
- if (cancelled) return {
372
- fixtureId,
373
- runId,
374
- status: "cancelled",
375
- observeUrl
376
- };
377
- observer.disconnect();
378
- process.removeListener("SIGINT", cancelHandler);
379
- if (options.debug) logger.info(pc.gray("Observer disconnected, falling back to HTTP polling"));
380
- return await pollForCompletionFallback(fixtureId, runId, observeUrl, client, options);
381
- }
382
- completionResult = complete;
383
- formatter.stopElapsedTimer();
384
- if (fallbackToPolling && !completionResult) {
385
- observer.disconnect();
386
- process.removeListener("SIGINT", cancelHandler);
387
- if (options.debug) logger.info(pc.gray("Observer errored, falling back to HTTP polling"));
388
- return await pollForCompletionFallback(fixtureId, runId, observeUrl, client, options);
389
- }
390
- const runResult = {
391
- fixtureId,
392
- runId,
393
- status: complete.status,
394
- totalDurationMs: complete.summary.totalDurationMs,
395
- jobs: complete.summary.jobs
396
- };
397
- if (!options.quiet) process.stdout.write("\n" + formatSummary(runResult) + "\n");
398
- if (complete.status === "failed") {
399
- for (const job of complete.summary.jobs) if (job.status === "failed") {
400
- const lines = lastLinesByJob.get(job.name);
401
- if (lines && lines.length > 0 && !options.quiet) process.stdout.write("\n" + formatErrorHighlight(job.name, lines) + "\n");
373
+ let lastStatus = null;
374
+ while (!cancelled) {
375
+ const logs = await ctx.client.runLogs(ctx.orgId, runId, cursor, ctx.target);
376
+ for (const line of logs.lines) {
377
+ if (!options.quiet) process.stdout.write(line + "\n");
378
+ tailLines.push(line);
379
+ while (tailLines.length > MAX_TAIL) tailLines.shift();
402
380
  }
381
+ cursor = logs.nextCursor;
382
+ lastStatus = await ctx.client.runStatus(ctx.orgId, runId, ctx.target);
383
+ if ((lastStatus.done || TERMINAL_STATUSES.has(lastStatus.status)) && logs.done) return finishRun(fixtureId, runId, lastStatus, startTime, tailLines, options);
384
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
403
385
  }
404
- observer.disconnect();
405
386
  return {
406
387
  fixtureId,
407
388
  runId,
408
- status: complete.status,
409
- observeUrl,
410
- durationMs: complete.summary.totalDurationMs,
411
- jobs: complete.summary.jobs
389
+ status: "cancelled",
390
+ durationMs: Date.now() - startTime,
391
+ jobs: jobsFromStatus(lastStatus)
412
392
  };
413
393
  } finally {
414
- formatter.stopElapsedTimer();
415
394
  process.removeListener("SIGINT", cancelHandler);
416
395
  }
417
396
  }
418
- /**
419
- * Poll orchestrator for run completion (HTTP fallback when WebSocket fails).
420
- */
421
- async function pollForCompletionFallback(fixtureId, runId, observeUrl, client, options) {
422
- const POLL_INTERVAL_MS = 2e3;
423
- const MAX_POLL_DURATION_MS = 1800 * 1e3;
424
- const startTime = Date.now();
425
- let cancelled = false;
426
- const cancelHandler = async () => {
427
- if (cancelled) return;
428
- cancelled = true;
429
- if (!options.quiet) logger.info(pc.yellow("\nCancelling test run..."));
430
- try {
431
- const cancelResult = await client.cancelTest(runId);
432
- if (!options.quiet) logger.info(pc.gray(`Cancel status: ${cancelResult.status}`));
433
- } catch {}
434
- };
435
- process.on("SIGINT", cancelHandler);
436
- try {
437
- while (!cancelled) {
438
- if (Date.now() - startTime > MAX_POLL_DURATION_MS) return {
439
- fixtureId,
440
- runId,
441
- status: "error",
442
- reason: "Polling timeout exceeded (30 minutes)",
443
- observeUrl
444
- };
445
- try {
446
- const status = await client.getRunStatus(runId);
447
- if (status.status === "success" || status.status === "failed" || status.status === "cancelled") {
448
- const durationMs = Date.now() - startTime;
449
- if (!options.quiet) {
450
- const statusColor = status.status === "success" ? pc.green : pc.red;
451
- logger.info(statusColor(`\nRun ${status.status} (${(durationMs / 1e3).toFixed(1)}s)`));
452
- if (status.summary?.jobs) for (const job of status.summary.jobs) {
453
- const jobColor = job.status === "success" ? pc.green : pc.red;
454
- const duration = job.durationMs ? ` (${(job.durationMs / 1e3).toFixed(1)}s)` : "";
455
- logger.info(` ${jobColor(job.status.padEnd(10))} ${job.name}${duration}`);
456
- }
457
- }
458
- return {
459
- fixtureId,
460
- runId,
461
- status: status.status,
462
- observeUrl,
463
- durationMs: Date.now() - startTime,
464
- jobs: status.summary?.jobs
465
- };
466
- }
467
- } catch {}
468
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
469
- }
470
- return {
397
+ /** Build the final result + render the summary table for a completed run. */
398
+ function finishRun(fixtureId, runId, status, startTime, tailLines, options) {
399
+ const durationMs = Date.now() - startTime;
400
+ const jobs = jobsFromStatus(status);
401
+ const finalStatus = status.status;
402
+ if (!options.quiet) {
403
+ const runResult = {
471
404
  fixtureId,
472
405
  runId,
473
- status: "cancelled",
474
- observeUrl,
475
- durationMs: Date.now() - startTime
406
+ status: finalStatus === "failed" ? "failed" : finalStatus === "cancelled" ? "cancelled" : "success",
407
+ totalDurationMs: durationMs,
408
+ jobs
476
409
  };
477
- } finally {
478
- process.removeListener("SIGINT", cancelHandler);
410
+ process.stdout.write("\n" + formatSummary(runResult) + "\n");
411
+ if (finalStatus === "failed" && tailLines.length > 0) {
412
+ const failedJob = status.jobs.find((j) => j.status === "failed");
413
+ process.stdout.write("\n" + formatErrorHighlight(failedJob?.jobName ?? "run", tailLines) + "\n");
414
+ }
479
415
  }
416
+ return {
417
+ fixtureId,
418
+ runId,
419
+ status: finalStatus,
420
+ durationMs,
421
+ jobs
422
+ };
423
+ }
424
+ /** Map a Platform run-status snapshot to the CLI's job summary shape. */
425
+ function jobsFromStatus(status) {
426
+ if (!status) return [];
427
+ return status.jobs.map((j) => ({
428
+ name: j.jobName,
429
+ status: j.status
430
+ }));
480
431
  }
481
432
  /**
482
- * Run a specific workflow directly (bypass trigger matching).
433
+ * Build a SimulatedEvent-compatible object from fixture options.
434
+ */
435
+ function buildEventFromFixture(opts) {
436
+ const eventObj = opts.event;
437
+ const type = String(eventObj._type ?? "push");
438
+ const action = eventObj.action ? String(eventObj.action) : void 0;
439
+ const targetBranch = opts.branch ?? "main";
440
+ return {
441
+ type,
442
+ action,
443
+ targetBranch,
444
+ sourceBranch: type === "pr" ? opts.branch ?? "feature/test" : void 0,
445
+ payload: {
446
+ ref: `refs/heads/${targetBranch}`,
447
+ repository: { full_name: opts.repo ?? "owner/repo" },
448
+ ...opts.sha && { after: opts.sha },
449
+ ...opts.pr && { number: opts.pr }
450
+ }
451
+ };
452
+ }
453
+ /**
454
+ * Run a specific workflow directly (bypass trigger matching), through the
455
+ * Platform.
483
456
  */
484
457
  async function runDirectWorkflow(workflowName, options) {
485
- const config = await loadGlobalConfig();
486
- if (!config.token) {
487
- logger.error(pc.red("Not authenticated. Run `kici login` to set up your API key."));
488
- return false;
489
- }
490
- const endpoint = config.endpoint ?? config.platformEndpoint;
491
- if (!endpoint) {
492
- logger.error(pc.red("No orchestrator endpoint configured. Run `kici login --endpoint <url>` to set one."));
493
- return false;
494
- }
495
- const client = new OrchestratorClient({
496
- endpoint,
497
- token: config.token
498
- });
458
+ const ctx = resolvePlatformContext(await loadGlobalConfig(), options);
459
+ if (!ctx) return false;
460
+ if (options.json) options.quiet = true;
499
461
  if (!options.quiet) logger.info(pc.gray(`Running workflow "${workflowName}" directly (bypassing triggers)`));
500
- const kiciDir = resolveKiciDir(options.kiciDir);
501
- const repoRoot = path.resolve(kiciDir, "..");
502
- const { tarballPath, summary, hasRemote } = await createOverlayTarball(repoRoot);
503
- let routingKey = options.routingKey ?? config.routingKey ?? "default";
504
- if (!hasRemote) {
505
- routingKey = `local:${path.basename(repoRoot)}`;
506
- logger.warn("No remote detected -- steps that use git commands will fail (no .git directory)");
507
- }
508
- let inlineLockFile;
509
- if (!hasRemote) inlineLockFile = await readFile(path.join(kiciDir, "kici.lock.json"), "utf-8");
510
- if (!options.quiet) logger.info(pc.gray(`${summary.fileCount} files changed, ${summary.newFiles} new, ${summary.deletedFiles} deleted (${formatBytes(summary.compressedSize)} compressed)`));
511
- const upload = await client.initUpload({
512
- routingKey,
513
- sha: summary.sha,
514
- fileCount: summary.fileCount,
515
- compressedSize: summary.compressedSize
516
- });
517
- const directUploadResult = await uploadTarball({
518
- tarballPath,
519
- signedUrl: upload.signedUrl,
520
- orchestratorPublicKey: Buffer.from(upload.publicKey, "base64")
521
- });
522
- const repoName = !hasRemote ? path.basename(repoRoot) : void 0;
523
- const payload = {};
524
- if (!hasRemote) payload.repository = { full_name: `local/${repoName}` };
525
- const encrypted = await buildEncryptedSecrets(kiciDir, options.envFlags, options.context, upload.publicKey);
526
- const result = await client.triggerTest({
527
- fixtureId: `direct:${workflowName}`,
528
- event: {
529
- type: "manual",
530
- targetBranch: "main",
531
- payload
532
- },
533
- routingKey,
534
- uploadId: upload.uploadId,
535
- cliPublicKey: directUploadResult.cliPublicKey.toString("base64"),
536
- ...encrypted && {
537
- encryptedSecrets: encrypted.encryptedSecrets,
538
- encryptedSecretsKey: encrypted.cliPublicKey
539
- },
540
- workflowName,
541
- inlineLockFile,
542
- fullRepo: !hasRemote || void 0
543
- });
544
- if (!options.quiet) {
545
- logger.info(pc.green(`Run started: ${result.runId}`));
546
- logger.info(pc.gray(`Observe: ${result.observeUrl}`));
462
+ await new RunHistory().load();
463
+ const fixtureId = `direct:${workflowName}`;
464
+ try {
465
+ const overlay = await prepareOverlay(options);
466
+ const uploaded = await initAndUpload(ctx, overlay, options);
467
+ const payload = { repository: { full_name: `local/${path.basename(overlay.repoRoot)}` } };
468
+ const encrypted = await buildEncryptedSecrets(overlay.kiciDir, options.envFlags, options.context, uploaded.publicKey);
469
+ const triggerResult = await ctx.client.trigger(ctx.orgId, ctx.target, {
470
+ fixtureId,
471
+ event: {
472
+ type: "manual",
473
+ targetBranch: "main",
474
+ payload
475
+ },
476
+ uploadId: uploaded.uploadId,
477
+ cliPublicKey: uploaded.cliPublicKey,
478
+ ...encrypted && {
479
+ encryptedSecrets: encrypted.encryptedSecrets,
480
+ encryptedSecretsKey: encrypted.cliPublicKey
481
+ },
482
+ workflowName,
483
+ inlineLockFile: overlay.inlineLockFile,
484
+ fullRepo: true
485
+ });
486
+ if (!options.quiet) logger.info(pc.green(`Run started: ${triggerResult.runId}`));
487
+ if (options.wait === false) return triggerResult.status === "accepted";
488
+ return (await pollRunToCompletion(ctx, triggerResult.runId, fixtureId, options)).status === "success";
489
+ } catch (error) {
490
+ return handleRunError(fixtureId, error, options).status === "success";
547
491
  }
548
- if (options.wait === false) return result.status === "accepted";
549
- const observeResult = await observeCompletion(`direct:${workflowName}`, result.runId, result.observeUrl, config.token, client, options);
550
- return observeResult.status === "success" || observeResult.status === "accepted";
551
492
  }
552
493
  /**
553
494
  * Display remote run results as a summary table.
@@ -568,6 +509,6 @@ function displayRemoteResults(results) {
568
509
  logger.info("");
569
510
  }
570
511
  //#endregion
571
- export { observeCompletion, runLocalCommand, runRemoteCommand };
512
+ export { runLocalCommand, runRemoteCommand };
572
513
 
573
514
  //# sourceMappingURL=run.js.map