@sapiom/orchestration-core 0.1.1 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # @sapiom/orchestration-core
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 704c9ac: Make the local development loop (`run_local`) production-faithful and trustworthy for the dispatch/pause pattern (`agent.coding.launch` + `pauseUntilSignal`).
8
+
9
+ - Stub capability handles now survive JSON serialization, so a paused/resumed coding workflow runs end-to-end locally instead of failing with an opaque `'sandbox.toJSON' is not a method or field` error.
10
+ - The payload a paused step resumes with is delivered as plain JSON — the same shape production sends over the wire — so authors re-attach handles by name (`sandboxes.attach(...)`) locally exactly as they would in prod.
11
+ - `@sapiom/tools` exports `CodingResultPayload`: the shape a step resumed from `pauseUntilSignal(codingHandle, …)` receives, so resumed steps can be annotated instead of hand-rolling the type.
12
+ - Stubbing a handle-returning capability with plain JSON no longer strips the handle's instance methods (e.g. `repo.pushFromSandbox`), and `repositories.list` stubs are coerced and shape-checked.
13
+ - A dispatched `launch()` accepts the `agent.coding.launch` stub key as well as the shared `agent.coding.run` (ordered candidate resolution), so the stub key matching the call the author wrote takes effect.
14
+ - `run_local` now reports `unusedStubs` (a supplied key that matched no call) and `stubWarnings` (a key that matched but carried the wrong shape), surfacing stubs that silently didn't take effect; the MCP `run_local` also serializes its result defensively.
15
+ - New `coding-pause` scaffold template for the launch + pause + resume pattern, and AGENTS docs documenting the resume-input contract, list stub item shape, failure-branch stubbing, and step determinism under replay.
16
+
17
+ ### Patch Changes
18
+
19
+ - Updated dependencies [704c9ac]
20
+ - @sapiom/tools@0.3.0
21
+ - @sapiom/orchestration@0.1.7
22
+
3
23
  ## 0.1.1
4
24
 
5
25
  ### Patch Changes
@@ -1,8 +1,8 @@
1
- import { type NextStepDirective, type OrchestrationDefinition } from '@sapiom/orchestration';
2
- import { type StepDispatcher, type StepDispatchRequest, type WorkflowRunnerCore } from '@sapiom/orchestration-runtime';
3
- import type { StubFile } from './stubs.js';
1
+ import { type NextStepDirective, type OrchestrationDefinition } from "@sapiom/orchestration";
2
+ import { type StepDispatcher, type StepDispatchRequest, type WorkflowRunnerCore } from "@sapiom/orchestration-runtime";
3
+ import type { StubFile } from "./stubs.js";
4
4
  export interface LogEntry {
5
- level: 'info' | 'warn' | 'error' | 'debug';
5
+ level: "info" | "warn" | "error" | "debug";
6
6
  msg: string;
7
7
  meta?: Record<string, unknown>;
8
8
  }
@@ -10,7 +10,7 @@ export interface LocalStepTrace {
10
10
  step: string;
11
11
  attempt: number;
12
12
  input: unknown;
13
- status: 'succeeded' | 'threw';
13
+ status: "succeeded" | "threw";
14
14
  output?: unknown;
15
15
  directive?: NextStepDirective;
16
16
  error?: {
@@ -27,6 +27,8 @@ export declare class LocalStubDispatcher implements StepDispatcher {
27
27
  private maxAttemptsPerStep;
28
28
  private signals;
29
29
  readonly trace: LocalStepTrace[];
30
+ readonly usedKeysByStep: Map<string, Set<string>>;
31
+ readonly stubWarnings: Set<string>;
30
32
  constructor(definition: OrchestrationDefinition, stubs: StubFile);
31
33
  setCore(core: WorkflowRunnerCore): void;
32
34
  setMaxAttempts(max: number): void;
@@ -11,6 +11,8 @@ class LocalStubDispatcher {
11
11
  this.core = null;
12
12
  this.signals = new Map();
13
13
  this.trace = [];
14
+ this.usedKeysByStep = new Map();
15
+ this.stubWarnings = new Set();
14
16
  }
15
17
  setCore(core) {
16
18
  this.core = core;
@@ -23,7 +25,7 @@ class LocalStubDispatcher {
23
25
  }
24
26
  async dispatch(request) {
25
27
  if (!this.core)
26
- throw new Error('LocalStubDispatcher: setCore() was not called');
28
+ throw new Error("LocalStubDispatcher: setCore() was not called");
27
29
  const step = this.definition.steps[request.stepName];
28
30
  if (!step)
29
31
  throw new Error(`LocalStubDispatcher: no step '${request.stepName}' in the definition`);
@@ -33,7 +35,17 @@ class LocalStubDispatcher {
33
35
  const logs = [];
34
36
  const sharedStore = new orchestration_1.InMemoryContextStore(request.shared);
35
37
  const overrides = (this.stubs.steps[request.stepName] ?? {});
36
- const sapiom = (0, stub_1.createStubClient)({ overrides, signals: this.signals });
38
+ let usedKeys = this.usedKeysByStep.get(request.stepName);
39
+ if (!usedKeys) {
40
+ usedKeys = new Set();
41
+ this.usedKeysByStep.set(request.stepName, usedKeys);
42
+ }
43
+ const sapiom = (0, stub_1.createStubClient)({
44
+ overrides,
45
+ signals: this.signals,
46
+ usedKeys,
47
+ warnings: this.stubWarnings,
48
+ });
37
49
  const ctx = {
38
50
  executionId: request.executionId,
39
51
  workflowName: request.workflowName,
@@ -56,7 +68,7 @@ class LocalStubDispatcher {
56
68
  step: request.stepName,
57
69
  attempt: request.attempt,
58
70
  input: request.input,
59
- status: 'threw',
71
+ status: "threw",
60
72
  error: { name: e.name, message: e.message, stack: e.stack },
61
73
  logs,
62
74
  });
@@ -74,7 +86,7 @@ class LocalStubDispatcher {
74
86
  step: request.stepName,
75
87
  attempt: request.attempt,
76
88
  input: request.input,
77
- status: 'succeeded',
89
+ status: "succeeded",
78
90
  output,
79
91
  directive,
80
92
  logs,
@@ -94,22 +106,44 @@ function makeLogger(sink) {
94
106
  const at = (level) => (msg, meta) => {
95
107
  sink.push({ level, msg, ...(meta ? { meta } : {}) });
96
108
  };
97
- return { info: at('info'), warn: at('warn'), error: at('error'), debug: at('debug') };
109
+ return {
110
+ info: at("info"),
111
+ warn: at("warn"),
112
+ error: at("error"),
113
+ debug: at("debug"),
114
+ };
98
115
  }
99
116
  function splitDirective(d) {
100
117
  switch (d.kind) {
101
- case 'continue':
102
- return { output: d.input, wire: { kind: 'continue', stepName: d.stepName, input: d.input } };
103
- case 'terminate':
104
- return { output: d.output, wire: { kind: 'terminate', reason: d.reason } };
105
- case 'fail':
106
- return { output: d.output, wire: { kind: 'fail', reason: d.reason } };
107
- case 'pause_until_signal':
118
+ case "continue":
119
+ return {
120
+ output: d.input,
121
+ wire: { kind: "continue", stepName: d.stepName, input: d.input },
122
+ };
123
+ case "terminate":
108
124
  return {
109
125
  output: d.output,
110
- wire: { kind: 'pause_until_signal', signal: d.signal, timeoutMs: d.timeoutMs, resumeStep: d.resumeStep },
126
+ wire: { kind: "terminate", reason: d.reason },
127
+ };
128
+ case "fail":
129
+ return {
130
+ output: d.output,
131
+ wire: { kind: "fail", reason: d.reason },
132
+ };
133
+ case "pause_until_signal":
134
+ return {
135
+ output: d.output,
136
+ wire: {
137
+ kind: "pause_until_signal",
138
+ signal: d.signal,
139
+ timeoutMs: d.timeoutMs,
140
+ resumeStep: d.resumeStep,
141
+ },
142
+ };
143
+ case "retry":
144
+ return {
145
+ output: undefined,
146
+ wire: { kind: "retry", delayMs: d.delayMs, reason: d.reason },
111
147
  };
112
- case 'retry':
113
- return { output: undefined, wire: { kind: 'retry', delayMs: d.delayMs, reason: d.reason } };
114
148
  }
115
149
  }
@@ -1,6 +1,6 @@
1
- import type { OrchestrationDefinition, WorkflowManifest } from '@sapiom/orchestration';
2
- import { type LocalStepTrace } from './dispatcher.js';
3
- import { type StubFile } from './stubs.js';
1
+ import type { OrchestrationDefinition, WorkflowManifest } from "@sapiom/orchestration";
2
+ import { type LocalStepTrace } from "./dispatcher.js";
3
+ import { type StubFile } from "./stubs.js";
4
4
  export declare const STUBS_FILE: string;
5
5
  export interface RunLocalOptions {
6
6
  definition: OrchestrationDefinition;
@@ -9,13 +9,19 @@ export interface RunLocalOptions {
9
9
  stubs?: StubFile;
10
10
  maxAttemptsPerStep?: number;
11
11
  }
12
- export type LocalRunOutcome = 'completed' | 'failed' | 'paused' | 'running';
12
+ export type LocalRunOutcome = "completed" | "failed" | "paused" | "running";
13
+ export interface UnusedStub {
14
+ step: string;
15
+ key: string;
16
+ }
13
17
  export interface LocalRunResult {
14
18
  outcome: LocalRunOutcome;
15
19
  executionId: string;
16
20
  output?: unknown;
17
21
  error?: unknown;
18
22
  steps: LocalStepTrace[];
23
+ unusedStubs: UnusedStub[];
24
+ stubWarnings: string[];
19
25
  }
20
26
  export declare function runLocal(opts: RunLocalOptions): Promise<LocalRunResult>;
21
27
  export declare function runLocalFromDir(opts: {
@@ -13,18 +13,18 @@ const errors_js_1 = require("../errors.js");
13
13
  const dispatcher_js_1 = require("./dispatcher.js");
14
14
  const load_js_1 = require("./load.js");
15
15
  const stubs_js_1 = require("./stubs.js");
16
- exports.STUBS_FILE = node_path_1.default.join('.sapiom-dev', 'stubs.json');
16
+ exports.STUBS_FILE = node_path_1.default.join(".sapiom-dev", "stubs.json");
17
17
  function loadStubsFile(sourceDir) {
18
18
  const file = node_path_1.default.join(sourceDir, exports.STUBS_FILE);
19
19
  if (!(0, node_fs_1.existsSync)(file))
20
20
  return undefined;
21
21
  let raw;
22
22
  try {
23
- raw = JSON.parse((0, node_fs_1.readFileSync)(file, 'utf8'));
23
+ raw = JSON.parse((0, node_fs_1.readFileSync)(file, "utf8"));
24
24
  }
25
25
  catch (err) {
26
26
  throw new errors_js_1.OrchestrationError({
27
- code: 'STUBS_INVALID',
27
+ code: "STUBS_INVALID",
28
28
  message: `${exports.STUBS_FILE} is not valid JSON.`,
29
29
  hint: err instanceof Error ? err.message : String(err),
30
30
  });
@@ -39,7 +39,11 @@ async function runLocal(opts) {
39
39
  const dispatcher = new dispatcher_js_1.LocalStubDispatcher(opts.definition, stubs);
40
40
  const signals = new Map();
41
41
  dispatcher.setSignals(signals);
42
- const core = new orchestration_runtime_1.WorkflowRunnerCore({ store, dispatcher, observer: orchestration_runtime_1.NOOP_OBSERVER });
42
+ const core = new orchestration_runtime_1.WorkflowRunnerCore({
43
+ store,
44
+ dispatcher,
45
+ observer: orchestration_runtime_1.NOOP_OBSERVER,
46
+ });
43
47
  dispatcher.setCore(core);
44
48
  dispatcher.setMaxAttempts(max);
45
49
  const executionId = await core.createExecution(opts.definition.name, opts.definition.entry, opts.input, {
@@ -49,25 +53,43 @@ async function runLocal(opts) {
49
53
  while (guard++ < MAX_ADVANCES) {
50
54
  await core.advance(executionId, max);
51
55
  const row = await store.loadExecution(executionId);
52
- if (!row || row.status === 'completed' || row.status === 'failed' || row.status === 'cancelled')
56
+ if (!row ||
57
+ row.status === "completed" ||
58
+ row.status === "failed" ||
59
+ row.status === "cancelled")
53
60
  break;
54
- if (row.status === 'paused') {
55
- const payload = signals.get(row.pausedSignalCorrelationId ?? '') ?? {};
61
+ if (row.status === "paused") {
62
+ const payload = signals.get(row.pausedSignalCorrelationId ?? "") ?? {};
56
63
  await core.resetForResume(executionId, { fromStepInput: payload });
57
64
  }
58
65
  }
59
66
  const final = await store.loadExecution(executionId);
60
- const outcome = final?.status === 'cancelled' ? 'failed' : (final?.status ?? 'running');
67
+ const outcome = final?.status === "cancelled" ? "failed" : (final?.status ?? "running");
68
+ const unusedStubs = [];
69
+ for (const [step, used] of dispatcher.usedKeysByStep) {
70
+ for (const key of Object.keys(stubs.steps[step] ?? {})) {
71
+ if (!used.has(key))
72
+ unusedStubs.push({ step, key });
73
+ }
74
+ }
61
75
  return {
62
76
  outcome,
63
77
  executionId,
64
78
  output: final?.output,
65
79
  error: final?.error,
66
80
  steps: dispatcher.trace,
81
+ unusedStubs,
82
+ stubWarnings: [...dispatcher.stubWarnings],
67
83
  };
68
84
  }
69
85
  async function runLocalFromDir(opts) {
70
86
  const { definition, manifest } = await (0, load_js_1.loadDefinition)(opts.sourceDir);
71
87
  const stubs = opts.stubs ?? loadStubsFile(opts.sourceDir);
72
- return runLocal({ definition, manifest, input: opts.input, stubs, maxAttemptsPerStep: opts.maxAttemptsPerStep });
88
+ return runLocal({
89
+ definition,
90
+ manifest,
91
+ input: opts.input,
92
+ stubs,
93
+ maxAttemptsPerStep: opts.maxAttemptsPerStep,
94
+ });
73
95
  }
@@ -1,8 +1,8 @@
1
- import { type NextStepDirective, type OrchestrationDefinition } from '@sapiom/orchestration';
2
- import { type StepDispatcher, type StepDispatchRequest, type WorkflowRunnerCore } from '@sapiom/orchestration-runtime';
3
- import type { StubFile } from './stubs.js';
1
+ import { type NextStepDirective, type OrchestrationDefinition } from "@sapiom/orchestration";
2
+ import { type StepDispatcher, type StepDispatchRequest, type WorkflowRunnerCore } from "@sapiom/orchestration-runtime";
3
+ import type { StubFile } from "./stubs.js";
4
4
  export interface LogEntry {
5
- level: 'info' | 'warn' | 'error' | 'debug';
5
+ level: "info" | "warn" | "error" | "debug";
6
6
  msg: string;
7
7
  meta?: Record<string, unknown>;
8
8
  }
@@ -10,7 +10,7 @@ export interface LocalStepTrace {
10
10
  step: string;
11
11
  attempt: number;
12
12
  input: unknown;
13
- status: 'succeeded' | 'threw';
13
+ status: "succeeded" | "threw";
14
14
  output?: unknown;
15
15
  directive?: NextStepDirective;
16
16
  error?: {
@@ -27,6 +27,8 @@ export declare class LocalStubDispatcher implements StepDispatcher {
27
27
  private maxAttemptsPerStep;
28
28
  private signals;
29
29
  readonly trace: LocalStepTrace[];
30
+ readonly usedKeysByStep: Map<string, Set<string>>;
31
+ readonly stubWarnings: Set<string>;
30
32
  constructor(definition: OrchestrationDefinition, stubs: StubFile);
31
33
  setCore(core: WorkflowRunnerCore): void;
32
34
  setMaxAttempts(max: number): void;
@@ -1,6 +1,6 @@
1
- import { InMemoryContextStore, } from '@sapiom/orchestration';
2
- import { parseCorrelationId, STEP_COMPLETION_OUTCOME, } from '@sapiom/orchestration-runtime';
3
- import { createStubClient } from '@sapiom/tools/stub';
1
+ import { InMemoryContextStore, } from "@sapiom/orchestration";
2
+ import { parseCorrelationId, STEP_COMPLETION_OUTCOME, } from "@sapiom/orchestration-runtime";
3
+ import { createStubClient } from "@sapiom/tools/stub";
4
4
  export class LocalStubDispatcher {
5
5
  constructor(definition, stubs) {
6
6
  this.definition = definition;
@@ -8,6 +8,8 @@ export class LocalStubDispatcher {
8
8
  this.core = null;
9
9
  this.signals = new Map();
10
10
  this.trace = [];
11
+ this.usedKeysByStep = new Map();
12
+ this.stubWarnings = new Set();
11
13
  }
12
14
  setCore(core) {
13
15
  this.core = core;
@@ -20,7 +22,7 @@ export class LocalStubDispatcher {
20
22
  }
21
23
  async dispatch(request) {
22
24
  if (!this.core)
23
- throw new Error('LocalStubDispatcher: setCore() was not called');
25
+ throw new Error("LocalStubDispatcher: setCore() was not called");
24
26
  const step = this.definition.steps[request.stepName];
25
27
  if (!step)
26
28
  throw new Error(`LocalStubDispatcher: no step '${request.stepName}' in the definition`);
@@ -30,7 +32,17 @@ export class LocalStubDispatcher {
30
32
  const logs = [];
31
33
  const sharedStore = new InMemoryContextStore(request.shared);
32
34
  const overrides = (this.stubs.steps[request.stepName] ?? {});
33
- const sapiom = createStubClient({ overrides, signals: this.signals });
35
+ let usedKeys = this.usedKeysByStep.get(request.stepName);
36
+ if (!usedKeys) {
37
+ usedKeys = new Set();
38
+ this.usedKeysByStep.set(request.stepName, usedKeys);
39
+ }
40
+ const sapiom = createStubClient({
41
+ overrides,
42
+ signals: this.signals,
43
+ usedKeys,
44
+ warnings: this.stubWarnings,
45
+ });
34
46
  const ctx = {
35
47
  executionId: request.executionId,
36
48
  workflowName: request.workflowName,
@@ -53,7 +65,7 @@ export class LocalStubDispatcher {
53
65
  step: request.stepName,
54
66
  attempt: request.attempt,
55
67
  input: request.input,
56
- status: 'threw',
68
+ status: "threw",
57
69
  error: { name: e.name, message: e.message, stack: e.stack },
58
70
  logs,
59
71
  });
@@ -71,7 +83,7 @@ export class LocalStubDispatcher {
71
83
  step: request.stepName,
72
84
  attempt: request.attempt,
73
85
  input: request.input,
74
- status: 'succeeded',
86
+ status: "succeeded",
75
87
  output,
76
88
  directive,
77
89
  logs,
@@ -90,22 +102,44 @@ function makeLogger(sink) {
90
102
  const at = (level) => (msg, meta) => {
91
103
  sink.push({ level, msg, ...(meta ? { meta } : {}) });
92
104
  };
93
- return { info: at('info'), warn: at('warn'), error: at('error'), debug: at('debug') };
105
+ return {
106
+ info: at("info"),
107
+ warn: at("warn"),
108
+ error: at("error"),
109
+ debug: at("debug"),
110
+ };
94
111
  }
95
112
  function splitDirective(d) {
96
113
  switch (d.kind) {
97
- case 'continue':
98
- return { output: d.input, wire: { kind: 'continue', stepName: d.stepName, input: d.input } };
99
- case 'terminate':
100
- return { output: d.output, wire: { kind: 'terminate', reason: d.reason } };
101
- case 'fail':
102
- return { output: d.output, wire: { kind: 'fail', reason: d.reason } };
103
- case 'pause_until_signal':
114
+ case "continue":
115
+ return {
116
+ output: d.input,
117
+ wire: { kind: "continue", stepName: d.stepName, input: d.input },
118
+ };
119
+ case "terminate":
104
120
  return {
105
121
  output: d.output,
106
- wire: { kind: 'pause_until_signal', signal: d.signal, timeoutMs: d.timeoutMs, resumeStep: d.resumeStep },
122
+ wire: { kind: "terminate", reason: d.reason },
123
+ };
124
+ case "fail":
125
+ return {
126
+ output: d.output,
127
+ wire: { kind: "fail", reason: d.reason },
128
+ };
129
+ case "pause_until_signal":
130
+ return {
131
+ output: d.output,
132
+ wire: {
133
+ kind: "pause_until_signal",
134
+ signal: d.signal,
135
+ timeoutMs: d.timeoutMs,
136
+ resumeStep: d.resumeStep,
137
+ },
138
+ };
139
+ case "retry":
140
+ return {
141
+ output: undefined,
142
+ wire: { kind: "retry", delayMs: d.delayMs, reason: d.reason },
107
143
  };
108
- case 'retry':
109
- return { output: undefined, wire: { kind: 'retry', delayMs: d.delayMs, reason: d.reason } };
110
144
  }
111
145
  }
@@ -1,6 +1,6 @@
1
- import type { OrchestrationDefinition, WorkflowManifest } from '@sapiom/orchestration';
2
- import { type LocalStepTrace } from './dispatcher.js';
3
- import { type StubFile } from './stubs.js';
1
+ import type { OrchestrationDefinition, WorkflowManifest } from "@sapiom/orchestration";
2
+ import { type LocalStepTrace } from "./dispatcher.js";
3
+ import { type StubFile } from "./stubs.js";
4
4
  export declare const STUBS_FILE: string;
5
5
  export interface RunLocalOptions {
6
6
  definition: OrchestrationDefinition;
@@ -9,13 +9,19 @@ export interface RunLocalOptions {
9
9
  stubs?: StubFile;
10
10
  maxAttemptsPerStep?: number;
11
11
  }
12
- export type LocalRunOutcome = 'completed' | 'failed' | 'paused' | 'running';
12
+ export type LocalRunOutcome = "completed" | "failed" | "paused" | "running";
13
+ export interface UnusedStub {
14
+ step: string;
15
+ key: string;
16
+ }
13
17
  export interface LocalRunResult {
14
18
  outcome: LocalRunOutcome;
15
19
  executionId: string;
16
20
  output?: unknown;
17
21
  error?: unknown;
18
22
  steps: LocalStepTrace[];
23
+ unusedStubs: UnusedStub[];
24
+ stubWarnings: string[];
19
25
  }
20
26
  export declare function runLocal(opts: RunLocalOptions): Promise<LocalRunResult>;
21
27
  export declare function runLocalFromDir(opts: {
@@ -1,22 +1,22 @@
1
- import { existsSync, readFileSync } from 'node:fs';
2
- import path from 'node:path';
3
- import { DEFAULT_MAX_ATTEMPTS_PER_STEP, InMemoryExecutionStore, NOOP_OBSERVER, WorkflowRunnerCore, } from '@sapiom/orchestration-runtime';
4
- import { OrchestrationError } from '../errors.js';
5
- import { LocalStubDispatcher } from './dispatcher.js';
6
- import { loadDefinition } from './load.js';
7
- import { parseStubFile } from './stubs.js';
8
- export const STUBS_FILE = path.join('.sapiom-dev', 'stubs.json');
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { DEFAULT_MAX_ATTEMPTS_PER_STEP, InMemoryExecutionStore, NOOP_OBSERVER, WorkflowRunnerCore, } from "@sapiom/orchestration-runtime";
4
+ import { OrchestrationError } from "../errors.js";
5
+ import { LocalStubDispatcher } from "./dispatcher.js";
6
+ import { loadDefinition } from "./load.js";
7
+ import { parseStubFile } from "./stubs.js";
8
+ export const STUBS_FILE = path.join(".sapiom-dev", "stubs.json");
9
9
  function loadStubsFile(sourceDir) {
10
10
  const file = path.join(sourceDir, STUBS_FILE);
11
11
  if (!existsSync(file))
12
12
  return undefined;
13
13
  let raw;
14
14
  try {
15
- raw = JSON.parse(readFileSync(file, 'utf8'));
15
+ raw = JSON.parse(readFileSync(file, "utf8"));
16
16
  }
17
17
  catch (err) {
18
18
  throw new OrchestrationError({
19
- code: 'STUBS_INVALID',
19
+ code: "STUBS_INVALID",
20
20
  message: `${STUBS_FILE} is not valid JSON.`,
21
21
  hint: err instanceof Error ? err.message : String(err),
22
22
  });
@@ -31,7 +31,11 @@ export async function runLocal(opts) {
31
31
  const dispatcher = new LocalStubDispatcher(opts.definition, stubs);
32
32
  const signals = new Map();
33
33
  dispatcher.setSignals(signals);
34
- const core = new WorkflowRunnerCore({ store, dispatcher, observer: NOOP_OBSERVER });
34
+ const core = new WorkflowRunnerCore({
35
+ store,
36
+ dispatcher,
37
+ observer: NOOP_OBSERVER,
38
+ });
35
39
  dispatcher.setCore(core);
36
40
  dispatcher.setMaxAttempts(max);
37
41
  const executionId = await core.createExecution(opts.definition.name, opts.definition.entry, opts.input, {
@@ -41,25 +45,43 @@ export async function runLocal(opts) {
41
45
  while (guard++ < MAX_ADVANCES) {
42
46
  await core.advance(executionId, max);
43
47
  const row = await store.loadExecution(executionId);
44
- if (!row || row.status === 'completed' || row.status === 'failed' || row.status === 'cancelled')
48
+ if (!row ||
49
+ row.status === "completed" ||
50
+ row.status === "failed" ||
51
+ row.status === "cancelled")
45
52
  break;
46
- if (row.status === 'paused') {
47
- const payload = signals.get(row.pausedSignalCorrelationId ?? '') ?? {};
53
+ if (row.status === "paused") {
54
+ const payload = signals.get(row.pausedSignalCorrelationId ?? "") ?? {};
48
55
  await core.resetForResume(executionId, { fromStepInput: payload });
49
56
  }
50
57
  }
51
58
  const final = await store.loadExecution(executionId);
52
- const outcome = final?.status === 'cancelled' ? 'failed' : (final?.status ?? 'running');
59
+ const outcome = final?.status === "cancelled" ? "failed" : (final?.status ?? "running");
60
+ const unusedStubs = [];
61
+ for (const [step, used] of dispatcher.usedKeysByStep) {
62
+ for (const key of Object.keys(stubs.steps[step] ?? {})) {
63
+ if (!used.has(key))
64
+ unusedStubs.push({ step, key });
65
+ }
66
+ }
53
67
  return {
54
68
  outcome,
55
69
  executionId,
56
70
  output: final?.output,
57
71
  error: final?.error,
58
72
  steps: dispatcher.trace,
73
+ unusedStubs,
74
+ stubWarnings: [...dispatcher.stubWarnings],
59
75
  };
60
76
  }
61
77
  export async function runLocalFromDir(opts) {
62
78
  const { definition, manifest } = await loadDefinition(opts.sourceDir);
63
79
  const stubs = opts.stubs ?? loadStubsFile(opts.sourceDir);
64
- return runLocal({ definition, manifest, input: opts.input, stubs, maxAttemptsPerStep: opts.maxAttemptsPerStep });
80
+ return runLocal({
81
+ definition,
82
+ manifest,
83
+ input: opts.input,
84
+ stubs,
85
+ maxAttemptsPerStep: opts.maxAttemptsPerStep,
86
+ });
65
87
  }