@hilbras/omninode 2.0.0-alpha.4 → 2.0.0-alpha.6

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.
package/dist/cli/index.js CHANGED
@@ -6,7 +6,7 @@ import { realpathSync } from "fs";
6
6
  import { Command } from "commander";
7
7
 
8
8
  // src/version.ts
9
- var OMNINODE_VERSION = "2.0.0-alpha.4";
9
+ var OMNINODE_VERSION = "2.0.0-alpha.6";
10
10
 
11
11
  // src/errors/index.ts
12
12
  var OmniNodeError = class extends Error {
@@ -363,35 +363,32 @@ function authHeaders(config, env = process.env) {
363
363
  }
364
364
 
365
365
  // src/providers/http.ts
366
+ var HttpResponseError = class extends Error {
367
+ constructor(message, status, cause) {
368
+ super(message, { cause });
369
+ this.status = status;
370
+ this.name = "HttpResponseError";
371
+ }
372
+ status;
373
+ };
366
374
  var DEFAULT_TIMEOUT_MS = 3e4;
367
375
  async function requestJson(url, options = {}) {
368
376
  const { method = "GET", headers = {}, body, timeoutMs = DEFAULT_TIMEOUT_MS } = options;
369
- let response;
370
- try {
371
- response = await fetch(url, {
372
- method,
373
- headers: {
374
- accept: "application/json",
375
- ...body !== void 0 ? { "content-type": "application/json" } : {},
376
- ...headers
377
- },
378
- ...body !== void 0 ? { body: JSON.stringify(body) } : {},
379
- signal: AbortSignal.timeout(timeoutMs)
380
- });
381
- } catch (error) {
382
- const reason = error instanceof Error && error.name === "TimeoutError" ? `timed out after ${timeoutMs}ms` : error instanceof Error ? error.message : String(error);
383
- throw new ProviderError("PROVIDER_UNAVAILABLE", `Request to ${url} failed: ${reason}.`, {
384
- cause: error
385
- });
386
- }
377
+ const response = await fetch(url, {
378
+ method,
379
+ headers: {
380
+ accept: "application/json",
381
+ ...body !== void 0 ? { "content-type": "application/json" } : {},
382
+ ...headers
383
+ },
384
+ ...body !== void 0 ? { body: JSON.stringify(body) } : {},
385
+ signal: AbortSignal.timeout(timeoutMs)
386
+ });
387
387
  let parsed;
388
388
  try {
389
389
  parsed = await response.json();
390
390
  } catch (error) {
391
- throw new ProviderError("PROVIDER_UNAVAILABLE", `Response from ${url} was not valid JSON.`, {
392
- cause: error,
393
- details: { status: response.status }
394
- });
391
+ throw new HttpResponseError(`Response from ${url} was not valid JSON.`, response.status, error);
395
392
  }
396
393
  return { status: response.status, body: parsed };
397
394
  }
@@ -452,23 +449,151 @@ var Logger = class _Logger {
452
449
  };
453
450
  var logger = new Logger();
454
451
 
452
+ // src/providers/errors.ts
453
+ function kindToCode(kind) {
454
+ switch (kind) {
455
+ case "AUTHENTICATION_ERROR":
456
+ return "PROVIDER_AUTH_FAILED";
457
+ case "MODEL_NOT_FOUND":
458
+ return "MODEL_NOT_FOUND";
459
+ default:
460
+ return "PROVIDER_UNAVAILABLE";
461
+ }
462
+ }
463
+ function isRetryable(kind) {
464
+ return kind === "RATE_LIMIT_ERROR" || kind === "SERVER_ERROR" || kind === "NETWORK_ERROR";
465
+ }
466
+ function kindFromStatus(status) {
467
+ if (status === 401 || status === 403) return "AUTHENTICATION_ERROR";
468
+ if (status === 429) return "RATE_LIMIT_ERROR";
469
+ if (status === 404) return "MODEL_NOT_FOUND";
470
+ if (status === 408 || status === 504) return "TIMEOUT";
471
+ if (status >= 500) return "SERVER_ERROR";
472
+ if (status >= 400) return "INVALID_REQUEST";
473
+ return "UNKNOWN_ERROR";
474
+ }
475
+ function extractMessage(body) {
476
+ if (body === null || typeof body !== "object") return void 0;
477
+ const root = body;
478
+ const error = root.error ?? root;
479
+ const message = error.message ?? error.detail ?? root.message;
480
+ return typeof message === "string" ? message : void 0;
481
+ }
482
+ function parseRetryAfter(value, now = Date.now()) {
483
+ if (!value) return void 0;
484
+ const seconds = Number(value);
485
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1e3);
486
+ const date = Date.parse(value);
487
+ return Number.isNaN(date) ? void 0 : Math.max(0, date - now);
488
+ }
489
+ function isTimeoutError(error) {
490
+ return error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError") || typeof error?.code === "string" && error.code === "ABORT_ERR";
491
+ }
492
+ function fallbackMessage(kind, context, status) {
493
+ const { provider, operation } = context;
494
+ switch (kind) {
495
+ case "AUTHENTICATION_ERROR":
496
+ return `Authentication with provider "${provider}" failed (HTTP ${status}). Check that the referenced API key is set and valid.`;
497
+ case "RATE_LIMIT_ERROR":
498
+ return `Provider "${provider}" rate-limited ${operation} (HTTP ${status}). Retry later.`;
499
+ case "MODEL_NOT_FOUND":
500
+ return `Provider "${provider}" does not expose the requested model (HTTP ${status}).`;
501
+ case "TIMEOUT":
502
+ return `Provider "${provider}" timed out during ${operation} (HTTP ${status}).`;
503
+ case "SERVER_ERROR":
504
+ return `Provider "${provider}" returned a server error during ${operation} (HTTP ${status}).`;
505
+ case "INVALID_REQUEST":
506
+ return `Provider "${provider}" rejected ${operation} as invalid (HTTP ${status}).`;
507
+ default:
508
+ return `Provider "${provider}" failed during ${operation} (HTTP ${status}).`;
509
+ }
510
+ }
511
+ function providerHttpError(context) {
512
+ const status = context.status ?? 0;
513
+ const kind = kindFromStatus(status);
514
+ const detail = extractMessage(context.body);
515
+ const retryAfterMs = context.retryAfterMs ?? parseRetryAfter(void 0);
516
+ return new ProviderError(
517
+ context.code ?? kindToCode(kind),
518
+ detail ?? fallbackMessage(kind, context, status),
519
+ {
520
+ cause: context.cause,
521
+ details: {
522
+ kind,
523
+ provider: context.provider,
524
+ operation: context.operation,
525
+ status,
526
+ retryable: isRetryable(kind),
527
+ ...retryAfterMs !== void 0 ? { retryAfterMs } : {},
528
+ ...detail ? { providerMessage: detail } : {}
529
+ }
530
+ }
531
+ );
532
+ }
533
+ function providerTransportError(context) {
534
+ const cause = context.cause;
535
+ const kind = isTimeoutError(cause) ? "TIMEOUT" : cause instanceof SyntaxError || cause?.name === "HttpResponseError" ? "UNKNOWN_ERROR" : "NETWORK_ERROR";
536
+ const message = context.cause instanceof Error ? context.cause.message : String(context.cause);
537
+ return new ProviderError("PROVIDER_UNAVAILABLE", `Provider request failed: ${message}`, {
538
+ cause: context.cause,
539
+ details: {
540
+ kind,
541
+ provider: context.provider,
542
+ operation: context.operation,
543
+ retryable: isRetryable(kind)
544
+ }
545
+ });
546
+ }
547
+
455
548
  // src/providers/openai-compatible/index.ts
456
549
  var OpenAICompatibleProvider = class {
457
550
  config;
551
+ providerId;
552
+ authentication;
458
553
  log;
459
- constructor(config, log = logger) {
554
+ /** Discovered models, so getModel() answers without another round-trip. */
555
+ discovered;
556
+ env;
557
+ constructor(config, log = logger, env = process.env) {
460
558
  this.config = { ...config, baseUrl: normalizeBaseUrl(config.baseUrl) };
559
+ this.env = env;
560
+ this.providerId = config.providerId ?? config.name;
561
+ this.authentication = {
562
+ method: config.apiKeyEnvVar ? "bearer" : "none",
563
+ ...config.apiKeyEnvVar ? { envVar: config.apiKeyEnvVar } : {},
564
+ configured: config.apiKeyEnvVar ? Boolean(env[config.apiKeyEnvVar]) : true
565
+ };
461
566
  this.log = log.child({ provider: config.name });
462
567
  }
568
+ /** Authenticate + discover (roadmap §10, Model Discovery). */
569
+ async connect() {
570
+ authHeaders(this.config, this.env);
571
+ const models = await this.listModels();
572
+ this.discovered = models;
573
+ return models;
574
+ }
575
+ async getModel(modelId) {
576
+ const models = this.discovered ?? await this.connect();
577
+ return models.find((m) => m.id === modelId);
578
+ }
463
579
  async listModels() {
464
- const { status, body } = await requestJson(`${this.config.baseUrl}/models`, {
465
- headers: authHeaders(this.config)
466
- });
467
- if (status === 401 || status === 403) {
468
- throw authFailed(this.config, status);
580
+ let response;
581
+ try {
582
+ response = await requestJson(`${this.config.baseUrl}/models`, {
583
+ headers: authHeaders(this.config, this.env)
584
+ });
585
+ } catch (error) {
586
+ if (error instanceof ProviderError) throw error;
587
+ throw providerTransportError({ provider: this.config.name, operation: "listModels", cause: error });
469
588
  }
589
+ const { status, body } = response;
470
590
  if (status !== 200) {
471
- throw unavailable(this.config, `Model discovery returned HTTP ${status}.`, status);
591
+ throw providerHttpError({
592
+ provider: this.config.name,
593
+ operation: "listModels",
594
+ status,
595
+ body
596
+ });
472
597
  }
473
598
  const entries = extractModelEntries(body, this.config);
474
599
  const models = [];
@@ -478,22 +603,40 @@ var OpenAICompatibleProvider = class {
478
603
  skipped += 1;
479
604
  continue;
480
605
  }
481
- models.push({
482
- provider: this.config.name,
483
- id: entry2.id,
484
- displayName: entry2.id,
485
- status: "available",
486
- metadata: {
487
- ...typeof entry2.owned_by === "string" ? { ownedBy: entry2.owned_by } : {},
488
- ...typeof entry2.created === "number" ? { createdAt: entry2.created } : {}
489
- }
490
- });
606
+ models.push(this.toModelInfo(entry2));
491
607
  }
492
608
  if (skipped > 0) {
493
609
  this.log.warn(`Skipped ${skipped} model entr(y/ies) without a valid id.`);
494
610
  }
495
611
  return models;
496
612
  }
613
+ /** Maps a raw model entry onto the normalized metadata shape (§10). */
614
+ toModelInfo(entry2) {
615
+ const parameters = Array.isArray(entry2.supported_parameters) ? entry2.supported_parameters.filter((p) => typeof p === "string") : void 0;
616
+ const contextWindow = typeof entry2.context_length === "number" ? entry2.context_length : typeof entry2.context_window === "number" ? entry2.context_window : void 0;
617
+ const inputTypes = normalizeModalities(entry2.input_modalities);
618
+ const outputTypes = normalizeModalities(entry2.output_modalities);
619
+ return {
620
+ provider: this.config.name,
621
+ id: entry2.id,
622
+ displayName: entry2.id,
623
+ ...typeof entry2.name === "string" ? { name: entry2.name } : {},
624
+ status: "available",
625
+ ...contextWindow !== void 0 ? { contextWindow } : {},
626
+ ...inputTypes !== void 0 ? { inputTypes } : {},
627
+ ...outputTypes !== void 0 ? { outputTypes } : {},
628
+ ...parameters !== void 0 ? {
629
+ supportsTools: parameters.includes("tools") || parameters.includes("tool_choice"),
630
+ supportsStructuredOutput: parameters.includes("response_format"),
631
+ supportsStreaming: parameters.includes("stream")
632
+ } : {},
633
+ metadata: {
634
+ ...typeof entry2.owned_by === "string" ? { ownedBy: entry2.owned_by } : {},
635
+ ...typeof entry2.created === "number" ? { createdAt: entry2.created } : {},
636
+ ...parameters !== void 0 ? { supportedParameters: parameters } : {}
637
+ }
638
+ };
639
+ }
497
640
  async healthCheck() {
498
641
  const lastCheckedAt = (/* @__PURE__ */ new Date()).toISOString();
499
642
  try {
@@ -523,27 +666,34 @@ var OpenAICompatibleProvider = class {
523
666
  }
524
667
  }
525
668
  async chat(request) {
526
- const { status, body } = await requestJson(`${this.config.baseUrl}/chat/completions`, {
527
- method: "POST",
528
- headers: authHeaders(this.config),
529
- body: {
530
- model: request.model,
531
- messages: request.messages,
532
- ...request.temperature !== void 0 ? { temperature: request.temperature } : {},
533
- ...request.maxTokens !== void 0 ? { max_tokens: request.maxTokens } : {},
534
- ...request.stop !== void 0 ? { stop: request.stop } : {}
535
- }
536
- });
537
- if (status === 401 || status === 403) throw authFailed(this.config, status);
538
- if (status === 404) {
539
- throw new ProviderError(
540
- "MODEL_NOT_FOUND",
541
- `Model "${request.model}" or the chat endpoint was not found on provider "${this.config.name}" (HTTP 404).`,
542
- { details: { status } }
543
- );
669
+ let response;
670
+ try {
671
+ response = await requestJson(`${this.config.baseUrl}/chat/completions`, {
672
+ method: "POST",
673
+ headers: authHeaders(this.config, this.env),
674
+ body: {
675
+ model: request.model,
676
+ messages: request.messages,
677
+ ...request.temperature !== void 0 ? { temperature: request.temperature } : {},
678
+ ...request.maxTokens !== void 0 ? { max_tokens: request.maxTokens } : {},
679
+ ...request.stop !== void 0 ? { stop: request.stop } : {}
680
+ }
681
+ });
682
+ } catch (error) {
683
+ if (error instanceof ProviderError) throw error;
684
+ throw providerTransportError({ provider: this.config.name, operation: "chat", cause: error });
544
685
  }
686
+ const { status, body } = response;
545
687
  if (status !== 200) {
546
- throw unavailable(this.config, `Chat completion returned HTTP ${status}.`, status);
688
+ throw providerHttpError({
689
+ provider: this.config.name,
690
+ operation: "chat",
691
+ status,
692
+ body,
693
+ ...status === 404 ? {
694
+ code: "MODEL_NOT_FOUND"
695
+ } : {}
696
+ });
547
697
  }
548
698
  const payload = body;
549
699
  const choices = Array.isArray(payload.choices) ? payload.choices : [];
@@ -565,6 +715,13 @@ var OpenAICompatibleProvider = class {
565
715
  };
566
716
  }
567
717
  };
718
+ function normalizeModalities(value) {
719
+ const list = Array.isArray(value) ? value : typeof value === "string" ? [value] : void 0;
720
+ if (!list) return void 0;
721
+ const allowed = /* @__PURE__ */ new Set(["text", "image", "audio", "video", "embedding"]);
722
+ const modalities = list.filter((m) => typeof m === "string" && allowed.has(m));
723
+ return modalities.length > 0 ? modalities : void 0;
724
+ }
568
725
  function normalizeBaseUrl(baseUrl) {
569
726
  return baseUrl.replace(/\/+$/, "");
570
727
  }
@@ -579,14 +736,6 @@ function extractModelEntries(body, config) {
579
736
  { details: { endpoint: "models" } }
580
737
  );
581
738
  }
582
- function authFailed(config, status) {
583
- const envHint = config.apiKeyEnvVar ? ` Check that $${config.apiKeyEnvVar} is set and valid.` : " The provider requires authentication; configure api_key_env_var for it.";
584
- return new ProviderError(
585
- "PROVIDER_AUTH_FAILED",
586
- `Authentication with provider "${config.name}" failed (HTTP ${status}).${envHint}`,
587
- { details: { status } }
588
- );
589
- }
590
739
  function unavailable(config, message, status) {
591
740
  return new ProviderError("PROVIDER_UNAVAILABLE", `Provider "${config.name}": ${message}`, {
592
741
  details: { status }
@@ -603,7 +752,7 @@ var OmniHilbrasProvider = class extends OpenAICompatibleProvider {
603
752
  * Run the full connect flow. Throws on authentication or transport failure
604
753
  * (use healthCheck() for a non-throwing probe).
605
754
  */
606
- async connect(registry) {
755
+ async connectAndRegister(registry) {
607
756
  const apiKey = resolveApiKey(this.config);
608
757
  const discovered = await this.listModels();
609
758
  const seen = /* @__PURE__ */ new Set();
@@ -827,7 +976,7 @@ async function testProvider(name, options = {}) {
827
976
  if (options.connect && provider instanceof OmniHilbrasProvider) {
828
977
  const registry = new ModelRegistry();
829
978
  try {
830
- const result = await provider.connect(registry);
979
+ const result = await provider.connectAndRegister(registry);
831
980
  printProviderStatus(result.status);
832
981
  console.log(` registered: ${result.registered}`);
833
982
  } catch (error) {
@@ -1634,7 +1783,7 @@ function describeExit(code, signal) {
1634
1783
  return `Process exited with code ${code}.`;
1635
1784
  }
1636
1785
  function sanitizeChunk(chunk) {
1637
- return chunk.toString("utf8").replace(/\u0000/g, "");
1786
+ return [...chunk.toString("utf8")].filter((char) => char.charCodeAt(0) !== 0).join("");
1638
1787
  }
1639
1788
  function normalizeReport(payload, taskId, agentName, index) {
1640
1789
  if (payload === null || typeof payload !== "object") return void 0;
@@ -2068,6 +2217,11 @@ var TaskEngine = class {
2068
2217
  if (this.options.memory) await this.options.memory.recordTaskOutcome(current);
2069
2218
  return current;
2070
2219
  }
2220
+ const currentState = await this.options.store.get(current.id);
2221
+ if (currentState?.status === "cancelled") {
2222
+ current = currentState;
2223
+ break;
2224
+ }
2071
2225
  const result = {
2072
2226
  ...output.summary !== void 0 ? { summary: output.summary } : {},
2073
2227
  ...output.reports !== void 0 && output.reports.length > 0 ? { reports: output.reports } : {},
@@ -3193,9 +3347,23 @@ var PipelineEngine = class {
3193
3347
  }
3194
3348
  options;
3195
3349
  log;
3350
+ /** Runs executing in THIS process — cancellation can terminate them directly. */
3351
+ activeRuns = /* @__PURE__ */ new Map();
3196
3352
  get store() {
3197
3353
  return this.options.store;
3198
3354
  }
3355
+ /**
3356
+ * Saves the run while preserving fields that may be set by another process
3357
+ * (e.g. a cancellation request) — an in-memory snapshot must never clobber
3358
+ * externally requested cancellation (§9.2).
3359
+ */
3360
+ async persist(run2) {
3361
+ const current = await this.options.store.get(run2.id);
3362
+ await this.options.store.save({
3363
+ ...run2,
3364
+ ...current?.cancellationRequested === true ? { cancellationRequested: true, cancellationRequestedAt: current.cancellationRequestedAt } : {}
3365
+ });
3366
+ }
3199
3367
  /** Structural + semantic validation (§27 Phase 5 — pipeline parser). */
3200
3368
  validate(def) {
3201
3369
  const ids = /* @__PURE__ */ new Set();
@@ -3295,18 +3463,44 @@ var PipelineEngine = class {
3295
3463
  );
3296
3464
  }
3297
3465
  }
3466
+ if (step.model) {
3467
+ const separator = step.model.indexOf(":");
3468
+ if (separator <= 0 || separator === step.model.length - 1) {
3469
+ throw new PipelineError(
3470
+ "PIPELINE_INVALID",
3471
+ `Step "${step.id}" has an invalid model reference "${step.model}" \u2014 use "provider:model-id".`
3472
+ );
3473
+ }
3474
+ const providerName = step.model.slice(0, separator);
3475
+ if (!this.options.providers.some((p) => p.name === providerName)) {
3476
+ throw new PipelineError(
3477
+ "PIPELINE_INVALID",
3478
+ `Step "${step.id}" uses model "${step.model}" but provider "${providerName}" is not configured.`
3479
+ );
3480
+ }
3481
+ }
3298
3482
  }
3299
3483
  const now = (/* @__PURE__ */ new Date()).toISOString();
3300
3484
  const run2 = {
3301
3485
  id: `run-${Date.now().toString(36)}-${randomBytes5(3).toString("hex")}`,
3302
3486
  pipelineId: def.id,
3487
+ attempt: 1,
3303
3488
  objective,
3304
3489
  status: "running",
3305
3490
  startedAt: now,
3306
3491
  stepRuns: []
3307
3492
  };
3308
3493
  await this.options.store.save(run2);
3309
- this.log.info(`Running pipeline ${def.id} as ${run2.id}.`);
3494
+ const liveTaskIds = /* @__PURE__ */ new Set();
3495
+ this.activeRuns.set(run2.id, {
3496
+ taskIds: liveTaskIds,
3497
+ cancelTasks: async () => {
3498
+ for (const taskId of liveTaskIds) {
3499
+ await this.options.tasks.cancel(taskId).catch(() => void 0);
3500
+ }
3501
+ }
3502
+ });
3503
+ this.log.info(`Running pipeline ${def.id} as ${run2.id} (attempt ${run2.attempt}).`);
3310
3504
  await this.options.audit?.record({
3311
3505
  at: (/* @__PURE__ */ new Date()).toISOString(),
3312
3506
  action: "pipeline.run.started",
@@ -3318,7 +3512,22 @@ var PipelineEngine = class {
3318
3512
  const pending = new Set(def.steps.map((s) => s.id));
3319
3513
  const contextParts = [];
3320
3514
  const allReports = [];
3515
+ let cancelled = false;
3321
3516
  while (pending.size > 0) {
3517
+ const persisted = await this.options.store.get(run2.id);
3518
+ if (persisted?.cancellationRequested === true) {
3519
+ cancelled = true;
3520
+ run2.cancellationRequested = true;
3521
+ run2.cancellationRequestedAt = persisted.cancellationRequestedAt;
3522
+ await this.activeRuns.get(run2.id)?.cancelTasks();
3523
+ for (const step of def.steps.filter((s) => pending.has(s.id))) {
3524
+ const stepRun = { stepId: step.id, status: "cancelled" };
3525
+ run2.stepRuns.push(stepRun);
3526
+ options.onStep?.(stepRun);
3527
+ }
3528
+ pending.clear();
3529
+ break;
3530
+ }
3322
3531
  const ready = def.steps.filter(
3323
3532
  (step) => pending.has(step.id) && (deps.get(step.id) ?? []).every((d) => outcomes.has(d))
3324
3533
  );
@@ -3335,7 +3544,8 @@ var PipelineEngine = class {
3335
3544
  ...role ? { role } : {},
3336
3545
  combinedContext: contextParts.join("\n\n"),
3337
3546
  reports: [...allReports],
3338
- pipelineRunId: run2.id
3547
+ pipelineRunId: run2.id,
3548
+ runId: run2.id
3339
3549
  };
3340
3550
  return this.executeStep(step, ctx);
3341
3551
  })
@@ -3352,6 +3562,8 @@ ${contribution}`);
3352
3562
  }
3353
3563
  const stepRun = {
3354
3564
  stepId: step.id,
3565
+ attempt: outcome.attempt ?? 1,
3566
+ ...outcome.executionIds && outcome.executionIds.length > 0 ? { executionIds: outcome.executionIds } : {},
3355
3567
  status: outcome.status === "skipped" ? "cancelled" : outcome.status === "failed" ? "failed" : outcome.status === "partial" ? "partially_completed" : "completed",
3356
3568
  ...outcome.taskIds.length > 0 ? { taskIds: outcome.taskIds } : {},
3357
3569
  ...outcome.planId !== void 0 ? { planId: outcome.planId } : {},
@@ -3361,12 +3573,12 @@ ${contribution}`);
3361
3573
  options.onStep?.(stepRun);
3362
3574
  });
3363
3575
  run2.reports = [...allReports];
3364
- await this.options.store.save(run2);
3576
+ await this.persist(run2);
3365
3577
  }
3366
3578
  const allOutcomes = [...outcomes.values()];
3367
- const failed = allOutcomes.some((o) => o.status === "failed");
3368
- const partial = !failed && allOutcomes.some((o) => o.status === "partial");
3369
- const finalStatus = failed ? "failed" : partial ? "partial" : "completed";
3579
+ const failed = !cancelled && allOutcomes.some((o) => o.status === "failed");
3580
+ const partial = !cancelled && !failed && allOutcomes.some((o) => o.status === "partial");
3581
+ const finalStatus = cancelled ? "cancelled" : failed ? "failed" : partial ? "partial" : "completed";
3370
3582
  const planId = [...outcomes.values()].find((o) => o.planId !== void 0)?.planId;
3371
3583
  let resultSummary;
3372
3584
  for (let i = def.steps.length - 1; i >= 0; i -= 1) {
@@ -3394,16 +3606,19 @@ ${contribution}`);
3394
3606
  run2.reports = collected;
3395
3607
  }
3396
3608
  }
3609
+ const finishedAt = (/* @__PURE__ */ new Date()).toISOString();
3610
+ this.activeRuns.delete(run2.id);
3397
3611
  const finished = {
3398
3612
  ...run2,
3399
3613
  status: finalStatus,
3400
- finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
3614
+ finishedAt,
3615
+ attempts: [...run2.attempts ?? [], { attempt: run2.attempt, startedAt: run2.startedAt ?? finishedAt, finishedAt, status: finalStatus }],
3401
3616
  ...run2.reports !== void 0 && run2.reports.length > 0 ? { reports: run2.reports } : {},
3402
3617
  ...combinedReportId !== void 0 ? { combinedReportId } : {},
3403
3618
  ...planId !== void 0 ? { planId } : {},
3404
3619
  ...resultSummary !== void 0 ? { resultSummary } : {}
3405
3620
  };
3406
- await this.options.store.save(finished);
3621
+ await this.persist(finished);
3407
3622
  this.log.info(`Pipeline ${def.id} finished: ${finalStatus}.`);
3408
3623
  await this.options.audit?.record({
3409
3624
  at: (/* @__PURE__ */ new Date()).toISOString(),
@@ -3413,6 +3628,23 @@ ${contribution}`);
3413
3628
  });
3414
3629
  return finished;
3415
3630
  }
3631
+ /** First-class cancellation (§9.2): requests cancellation and terminates
3632
+ * tasks that are running in this process immediately. */
3633
+ async cancel(runId) {
3634
+ const run2 = await this.options.store.get(runId);
3635
+ if (!run2) {
3636
+ throw new PipelineError("PIPELINE_NOT_FOUND", `Pipeline run "${runId}" does not exist.`);
3637
+ }
3638
+ await this.options.store.save({
3639
+ ...run2,
3640
+ cancellationRequested: true,
3641
+ cancellationRequestedAt: (/* @__PURE__ */ new Date()).toISOString()
3642
+ });
3643
+ const active = this.activeRuns.get(runId);
3644
+ if (active) {
3645
+ await active.cancelTasks();
3646
+ }
3647
+ }
3416
3648
  async get(runId) {
3417
3649
  return this.options.store.get(runId);
3418
3650
  }
@@ -3436,9 +3668,16 @@ ${contribution}`);
3436
3668
  this.log.warn(`Step "${step.id}" failed \u2014 retry ${attempt}/${retries}.`);
3437
3669
  }
3438
3670
  last = await this.executeStepOnce(step, ctx);
3439
- if (last.status !== "failed") return last;
3671
+ if (last.status !== "failed") return { ...last, attempt: attempt + 1 };
3440
3672
  }
3441
- return last;
3673
+ return { ...last, attempt: retries + 1 };
3674
+ }
3675
+ /** Registers tasks created for a step as live, so cancellation can terminate them (§9.2). */
3676
+ trackLiveTasks(runId, taskIds) {
3677
+ if (!runId) return;
3678
+ const active = this.activeRuns.get(runId);
3679
+ if (!active) return;
3680
+ for (const id of taskIds) active.taskIds.add(id);
3442
3681
  }
3443
3682
  async executeStepOnce(step, ctx) {
3444
3683
  switch (step.kind) {
@@ -3471,7 +3710,9 @@ ${contribution}`);
3471
3710
  })
3472
3711
  )
3473
3712
  );
3713
+ this.trackLiveTasks(ctx.runId, created.map((t) => t.id));
3474
3714
  const results = await Promise.all(created.map((task) => this.options.tasks.run(task.id)));
3715
+ const executionIds = results.map((r) => r.executions?.at(-1)?.executionId).filter((id) => id !== void 0);
3475
3716
  const completedTasks = results.filter((r) => r.status === "completed");
3476
3717
  const failedTasks = results.filter((r) => r.status !== "completed");
3477
3718
  const lines = results.map((result, index) => {
@@ -3494,7 +3735,13 @@ ${contribution}`);
3494
3735
  reports
3495
3736
  };
3496
3737
  }
3497
- return { status: "completed", summary, taskIds: created.map((t) => t.id), reports };
3738
+ return {
3739
+ status: "completed",
3740
+ summary,
3741
+ taskIds: created.map((t) => t.id),
3742
+ ...executionIds.length > 0 ? { executionIds } : {},
3743
+ reports
3744
+ };
3498
3745
  }
3499
3746
  async runSingle(step, ctx, agentName) {
3500
3747
  const task = await this.options.tasks.create({
@@ -3503,6 +3750,7 @@ ${contribution}`);
3503
3750
  ...ctx.role ? { role: ctx.role.id } : {},
3504
3751
  ...ctx.combinedContext.length > 0 ? { context: { background: ctx.combinedContext } } : {}
3505
3752
  });
3753
+ this.trackLiveTasks(ctx.runId, [task.id]);
3506
3754
  const result = await this.options.tasks.run(task.id);
3507
3755
  if (result.status !== "completed") {
3508
3756
  return {
@@ -3753,6 +4001,24 @@ function registerPipelineCommands(program) {
3753
4001
  }
3754
4002
  await runPipelineAction(def.id, objective ?? previous.objective);
3755
4003
  });
4004
+ pipeline.command("cancel <run-id>").description("Request cancellation of a pipeline run (stops scheduling and cancels running tasks).").action(async (runId) => {
4005
+ const config = loadConfig();
4006
+ const engine = buildPipelineEngine(config);
4007
+ await engine.cancel(runId);
4008
+ console.log(`Cancellation requested for run ${runId}.`);
4009
+ console.log("A run executing in another process stops at its next check point.");
4010
+ });
4011
+ pipeline.command("validate <id>").description("Validate a pipeline definition before running it (\xA79.5).").action(async (id) => {
4012
+ const config = loadConfig();
4013
+ const def = findPipeline(config, id);
4014
+ const engine = buildPipelineEngine(config);
4015
+ engine.validate(def);
4016
+ console.log(`Pipeline "${def.id}" is valid (${def.steps.length} step(s)).`);
4017
+ for (const step of def.steps) {
4018
+ const deps = step.dependsOn?.join(", ") ?? "(previous step)";
4019
+ console.log(` ${step.id} kind=${step.kind} depends_on=${deps}`);
4020
+ }
4021
+ });
3756
4022
  pipeline.command("runs <id>").description("List recorded runs of a pipeline.").action(async (id) => {
3757
4023
  const config = loadConfig();
3758
4024
  const engine = buildPipelineEngine(config);
@@ -3762,7 +4028,8 @@ function registerPipelineCommands(program) {
3762
4028
  return;
3763
4029
  }
3764
4030
  for (const run2 of runs) {
3765
- console.log(`${run2.id} ${run2.status.padEnd(10)} ${run2.startedAt ?? "?"}`);
4031
+ const note = isInterrupted(run2) ? " (interrupted \u2014 process gone before finish)" : "";
4032
+ console.log(`${run2.id} ${run2.status.padEnd(10)} ${run2.startedAt ?? "?"}${note}`);
3766
4033
  }
3767
4034
  });
3768
4035
  pipeline.command("status <run-id>").description("Show the recorded state of a pipeline run.").action(async (runId) => {
@@ -3780,6 +4047,13 @@ function registerPipelineCommands(program) {
3780
4047
  console.log(` status: ${run2.status}`);
3781
4048
  console.log(` started: ${run2.startedAt ?? "?"}`);
3782
4049
  if (run2.finishedAt) console.log(` finished: ${run2.finishedAt}`);
4050
+ console.log(` attempt: ${run2.attempt}`);
4051
+ if (run2.cancellationRequested) {
4052
+ console.log(` cancellation requested at ${run2.cancellationRequestedAt ?? "?"}`);
4053
+ }
4054
+ if (isInterrupted(run2)) {
4055
+ console.log(" note: this run looks interrupted (no finishedAt) \u2014 retry with `pipeline retry`");
4056
+ }
3783
4057
  if (run2.resultSummary) console.log(` result: ${run2.resultSummary}`);
3784
4058
  for (const stepRun of run2.stepRuns) {
3785
4059
  console.log(` [${stepRun.stepId}] ${stepRun.status}`);
@@ -3821,6 +4095,9 @@ async function runPipelineAction(id, objective) {
3821
4095
  function registerRunAlias(program) {
3822
4096
  program.command("run <id> [objective]").description("Alias of `omninode pipeline run` \u2014 execute a configured pipeline end to end.").action(async (id, objective) => runPipelineAction(id, objective));
3823
4097
  }
4098
+ function isInterrupted(run2) {
4099
+ return run2.status === "running" && run2.finishedAt === void 0;
4100
+ }
3824
4101
  function findPipeline(config, id) {
3825
4102
  const def = config.project.pipelines.find((p) => p.id === id);
3826
4103
  if (!def) {