@xdelivered/emberflow 0.2.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/bin/commands.ts +7 -3
  2. package/bin/init.test.ts +94 -0
  3. package/bin/init.ts +108 -2
  4. package/dist/bin/commands.d.ts +1 -0
  5. package/dist/bin/commands.js +6 -3
  6. package/dist/bin/init.d.ts +4 -0
  7. package/dist/bin/init.js +96 -2
  8. package/dist/server/agents/codexParse.js +31 -0
  9. package/dist/server/agents/detect.d.ts +18 -8
  10. package/dist/server/agents/detect.js +78 -13
  11. package/dist/server/agents/modelRejectionHint.js +1 -1
  12. package/dist/server/agents/prompt.d.ts +15 -1
  13. package/dist/server/agents/prompt.js +59 -37
  14. package/dist/server/agents/runManager.d.ts +23 -2
  15. package/dist/server/agents/runManager.js +36 -8
  16. package/dist/server/client.d.ts +1 -0
  17. package/dist/server/client.js +7 -2
  18. package/dist/server/index.js +208 -16
  19. package/dist/server/nodesPayload.d.ts +14 -1
  20. package/dist/server/nodesPayload.js +15 -2
  21. package/dist/server/projectMode.js +5 -2
  22. package/dist/server/runRegistry.d.ts +52 -1
  23. package/dist/server/runRegistry.js +170 -1
  24. package/dist/server/sourceNav.d.ts +77 -0
  25. package/dist/server/sourceNav.js +503 -0
  26. package/dist/server/subflowRunner.d.ts +48 -1
  27. package/dist/server/subflowRunner.js +34 -7
  28. package/dist/server/updateCheck.d.ts +68 -0
  29. package/dist/server/updateCheck.js +142 -0
  30. package/dist/src/engine/executor.js +4 -3
  31. package/dist/src/engine/registry.d.ts +27 -2
  32. package/dist/src/engine/registry.js +84 -2
  33. package/dist/src/nodes/index.d.ts +8 -2
  34. package/dist/src/nodes/index.js +7 -3
  35. package/dist/src/nodes/login.d.ts +3 -1
  36. package/dist/src/nodes/login.js +2 -2
  37. package/package.json +28 -7
  38. package/server/agentRoute.test.ts +20 -0
  39. package/server/agents/codexParse.test.ts +37 -0
  40. package/server/agents/codexParse.ts +34 -0
  41. package/server/agents/detect.test.ts +26 -7
  42. package/server/agents/detect.ts +82 -14
  43. package/server/agents/modelRejectionHint.ts +2 -1
  44. package/server/agents/prompt.test.ts +128 -0
  45. package/server/agents/prompt.ts +102 -3
  46. package/server/agents/runManager.test.ts +9 -0
  47. package/server/agents/runManager.ts +37 -7
  48. package/server/client.ts +10 -4
  49. package/server/index.ts +213 -15
  50. package/server/nodeRun.test.ts +189 -0
  51. package/server/nodesPayload.test.ts +38 -0
  52. package/server/nodesPayload.ts +28 -3
  53. package/server/projectMode.ts +5 -2
  54. package/server/runRegistry.ts +200 -3
  55. package/server/setupStatus.test.ts +3 -0
  56. package/server/sourceNav.test.ts +382 -0
  57. package/server/sourceNav.ts +644 -0
  58. package/server/sourceNavRoute.test.ts +163 -0
  59. package/server/stepDrill.test.ts +380 -0
  60. package/server/subflowRunner.ts +92 -11
  61. package/server/updateCheck.test.ts +209 -0
  62. package/server/updateCheck.ts +175 -0
  63. package/server/workflowTestRoute.test.ts +1 -1
  64. package/src/App.tsx +82 -2
  65. package/src/components/AgentConsole.tsx +7 -280
  66. package/src/components/AgentStream.test.tsx +88 -0
  67. package/src/components/AgentStream.tsx +303 -0
  68. package/src/components/CreateModal.tsx +43 -9
  69. package/src/components/Dock.tsx +1 -1
  70. package/src/components/EmptyState.test.tsx +49 -0
  71. package/src/components/EmptyState.tsx +61 -0
  72. package/src/components/EnvironmentDialog.tsx +4 -1
  73. package/src/components/EnvironmentPicker.tsx +8 -3
  74. package/src/components/EnvironmentsDialog.tsx +4 -1
  75. package/src/components/InfraPanel.tsx +30 -2
  76. package/src/components/InfrastructureDialog.test.tsx +97 -0
  77. package/src/components/InfrastructureDialog.tsx +111 -0
  78. package/src/components/Inspector.tsx +9 -26
  79. package/src/components/RunbookView.tsx +70 -6
  80. package/src/components/SettingsDialog.tsx +4 -59
  81. package/src/components/Sidebar.tsx +6 -26
  82. package/src/components/SourceNavigator.test.tsx +226 -0
  83. package/src/components/SourceNavigator.tsx +520 -0
  84. package/src/components/StatusBar.tsx +227 -26
  85. package/src/components/Toolbar.tsx +28 -16
  86. package/src/components/UpdateChip.test.tsx +31 -0
  87. package/src/components/WelcomeDialog.test.tsx +384 -13
  88. package/src/components/WelcomeDialog.tsx +996 -177
  89. package/src/engine/executor.ts +4 -3
  90. package/src/engine/registry.sourceRef.test.ts +112 -0
  91. package/src/engine/registry.ts +103 -2
  92. package/src/lib/guidedQuestions.test.ts +224 -0
  93. package/src/lib/guidedQuestions.ts +181 -0
  94. package/src/lib/runbookModel.ts +3 -3
  95. package/src/lib/runbookProjection.test.ts +43 -0
  96. package/src/lib/runbookProjection.ts +26 -6
  97. package/src/nodes/index.ts +10 -3
  98. package/src/nodes/login.ts +5 -2
  99. package/src/store/agentClient.ts +27 -8
  100. package/src/store/apiTree.ts +12 -0
  101. package/src/store/builderStore.test.ts +441 -99
  102. package/src/store/builderStore.ts +385 -314
  103. package/src/store/nodeMeta.ts +10 -2
  104. package/src/store/serverRunner.ts +56 -5
  105. package/src/store/setupClient.ts +7 -2
  106. package/src/store/sourceNavClient.ts +48 -0
  107. package/src/store/updateClient.ts +50 -0
  108. package/studio-dist/assets/index-BbzppDpt.js +65 -0
  109. package/studio-dist/assets/index-CLf6blcA.css +1 -0
  110. package/studio-dist/index.html +2 -2
  111. package/templates/skills/emberflow-basics/SKILL.md +29 -1
  112. package/templates/skills/emberflow-model-process/SKILL.md +92 -9
  113. package/templates/skills/emberflow-new-workflow/SKILL.md +8 -1
  114. package/templates/skills/emberflow-review-workflow/SKILL.md +64 -1
  115. package/studio-dist/assets/index-DNJwW-hM.css +0 -1
  116. package/studio-dist/assets/index-O26dKRjW.js +0 -65
@@ -1,15 +1,11 @@
1
1
  import { create } from 'zustand';
2
- import { InMemoryTraceSink, runOutput, startRun } from '../engine';
2
+ import { InMemoryTraceSink } from '../engine/trace';
3
+ import { NodeRegistry } from '../engine/registry';
4
+ import type { FlowRun } from '../engine/executor';
3
5
  import type {
4
- FieldMapping, FlowRun, LogLine, NodeRegistry, ScenarioDefinition, StartRunOptions,
5
- SubflowResult, WorkflowDefinition, WorkflowNode, WorkflowRun,
6
- } from '../engine';
7
- import { createDefaultRegistry } from '../nodes';
8
- import { createLoginFlow } from '../flows/login-flow';
9
- import { createWeatherFlow } from '../flows/weather-flow';
10
- import { createAnomalyFlows } from '../flows/anomaly-flows';
11
- import { createCitySweepFlow } from '../flows/city-sweep-flow';
12
- import { createPradarFlows } from '../flows/pradar-flows';
6
+ FieldMapping, LogLine, ScenarioDefinition,
7
+ WorkflowDefinition, WorkflowNode, WorkflowRun,
8
+ } from '../engine/types';
13
9
  import { loadWorkspace, parseFlow, saveWorkspace, serializeFlow } from './persistence';
14
10
  import { fetchNodeMeta } from './nodeMeta';
15
11
  import {
@@ -22,6 +18,7 @@ import {
22
18
  loginEnvironment as loginEnvironmentOnServer,
23
19
  putWorkflow,
24
20
  runnerHealthy,
21
+ runNodeOnServer,
25
22
  setEnvironmentAuth as setEnvironmentAuthOnServer,
26
23
  setEnvironmentSecret as setEnvironmentSecretOnServer,
27
24
  setServingMode as setServingModeOnServer,
@@ -31,12 +28,13 @@ import {
31
28
  testWorkflow as testWorkflowOnServer,
32
29
  } from './serverRunner';
33
30
  import type {
34
- EnvAuth, ErrorHandlerTag, EnvironmentSummary, ScenarioTestReport, ServerRunOptions,
31
+ EnvAuth, ErrorHandlerTag, EnvironmentSummary, ScenarioTestReport, ServerRunOptions, StepResult,
35
32
  } from './serverRunner';
36
33
  import { cancelAgent, fetchAgentDiff, revertAgent, startAgent, streamAgent } from './agentClient';
37
34
  import type { AgentEvent, AgentIntent, AgentKind, StartAgentOptions } from './agentClient';
38
35
  import { fetchSetupStatus } from './setupClient';
39
36
  import type { SetupStatus } from './setupClient';
37
+ import { extractGuidedQuestions } from '../lib/guidedQuestions';
40
38
 
41
39
  export interface WorkflowSummary {
42
40
  id: string;
@@ -48,6 +46,14 @@ export interface WorkflowSummary {
48
46
  http?: { method: string; path: string };
49
47
  }
50
48
 
49
+ /** Configures the two entry points that share the create modal: "New API"
50
+ * (name + first operation) and "New operation" (optionally scoped to an API
51
+ * via `location`). Owned here so any surface — Sidebar, canvas empty state —
52
+ * opens the SAME modal (hosted once by CreateModalHost). */
53
+ export type CreateModalState =
54
+ | { mode: 'api'; initialGoal?: string }
55
+ | { mode: 'operation'; location?: string };
56
+
51
57
  /** Per-operation path/http metadata, keyed by id — sourced from the runner's
52
58
  * `/workflows` `operations` array (the store's `WorkflowDefinition`s don't
53
59
  * carry `path`, since path is a filesystem concept, not a flow field). */
@@ -65,6 +71,30 @@ function dropReport(
65
71
  }
66
72
 
67
73
 
74
+ /**
75
+ * One level of the stepped-run drill stack: entered when a step response
76
+ * reports `entered` (execution moved inside a Subflow node), popped on
77
+ * `exited`. The parent's flow/run/selection are stashed here so the canvas
78
+ * can show the child while the parent keeps receiving its own SSE states
79
+ * (routed into `savedRun` by workflowId), and be restored intact on exit.
80
+ */
81
+ export interface StepDrillEntry {
82
+ /** The child flow's id (the level the drill entered INTO). */
83
+ workflowId: string;
84
+ /** The PARENT's Subflow node id that execution entered through. */
85
+ viaNodeId: string;
86
+ /** The parent level's flow, restored on exit. */
87
+ savedFlow: WorkflowDefinition;
88
+ /** The parent level's run — still updated by routed nodeState events while drilled. */
89
+ savedRun: WorkflowRun | null;
90
+ savedSelectedNodeId: string | null;
91
+ /** True when the child flow wasn't found in the workspace: the view stayed
92
+ * on the parent, but the level is still pushed so the client stack depth
93
+ * mirrors the server's and the matching `exited` pops correctly. Popping a
94
+ * placeholder leaves flow/run/selection untouched (they never changed). */
95
+ placeholder?: true;
96
+ }
97
+
68
98
  /** A finished run plus builder-side context the engine doesn't track. */
69
99
  export type RunHistoryEntry = WorkflowRun & {
70
100
  scenarioName?: string;
@@ -116,12 +146,8 @@ interface BuilderState {
116
146
  activeRun: FlowRun | null;
117
147
  dockTab: 'logs' | 'output' | 'infra';
118
148
  setDockTab(tab: 'logs' | 'output' | 'infra'): void;
119
- /** 'auto' resolves to the runner when it's reachable, browser otherwise. */
120
- executionMode: 'auto' | 'browser' | 'server';
121
- setExecutionMode(mode: 'auto' | 'browser' | 'server'): void;
122
- /** Where the next run will actually execute. */
123
- effectiveMode(): 'browser' | 'server';
124
- /** null = not yet checked. */
149
+ /** null = not yet checked. Runs are always server-side; when the runner is
150
+ * offline the studio surfaces a calm offline state instead of executing. */
125
151
  runnerOnline: boolean | null;
126
152
  /** True when the reachable runner is serving EMBERFLOW_MOCK responses
127
153
  * (from /healthz's `mock` field) rather than live execution. False when
@@ -138,7 +164,7 @@ interface BuilderState {
138
164
  environments: EnvironmentSummary[];
139
165
  /** The runner's default environment name. */
140
166
  environmentsDefault: string;
141
- /** The environment the next run points at (persisted, like executionMode). */
167
+ /** The environment the next run points at (persisted). */
142
168
  selectedEnvironment: string;
143
169
  /** When on, mutation nodes dry-run their writes (persisted). */
144
170
  safeMode: boolean;
@@ -180,6 +206,18 @@ interface BuilderState {
180
206
  /** True while a stepped run is in progress (started via Step, not Run) — drives
181
207
  * the toolbar's step-over affordance. Cleared when the run finishes or resets. */
182
208
  stepMode: boolean;
209
+ /** Stepped-run subflow drill stack, deepest last. Non-empty while execution
210
+ * is inside one (or more, when nested) Subflow nodes and the canvas shows
211
+ * the drilled child. Empty otherwise. */
212
+ stepDrill: StepDrillEntry[];
213
+ /** View-only peek back up the drill stack: an index into `stepDrill` shows
214
+ * that ancestor level's stashed flow/run in the runbook WITHOUT popping the
215
+ * stack or touching the server; null shows the deepest (live) level.
216
+ * Reset to null whenever execution enters or exits a subflow. */
217
+ drillPeek: number | null;
218
+ /** Peek at ancestor level `index` of the drill stack (null = back to the
219
+ * deepest level). Out-of-range indexes clear the peek. */
220
+ peekDrill(index: number | null): void;
183
221
  sidebarOpen: boolean;
184
222
  toggleSidebar(): void;
185
223
  /** Whether the bottom dock panel is mounted (persisted). */
@@ -199,12 +237,19 @@ interface BuilderState {
199
237
  beginEnvironmentSetup(): void;
200
238
  /** Dispatch the infrastructure scout (scout-infrastructure intent): reads the
201
239
  * project's deps/config/ORM/env-refs and writes emberflow/infrastructure.json.
202
- * Shared by the Welcome checklist and the Dock's Infra tab empty state. */
203
- beginInfrastructureScout(): void;
240
+ * Shared by the Welcome checklist and the Dock's Infra tab empty state. An
241
+ * optional free-text `instruction` (from the Infrastructure modal's "Update
242
+ * with AI") amends the manifest; empty/omitted runs a full rescan. */
243
+ beginInfrastructureScout(instruction?: string): void;
204
244
  /** Whether the first-run Welcome/Setup checklist dialog is open. Auto-opened
205
245
  * on a fresh project (see WelcomeDialog); always reachable from the Toolbar. */
206
246
  welcomeOpen: boolean;
207
247
  setWelcomeOpen(open: boolean): void;
248
+ /** The New API / New operation modal's open state. Lifted to the store so
249
+ * surfaces outside the Sidebar (e.g. the canvas empty state) share the ONE
250
+ * modal instead of duplicating it; null = closed. Hosted by CreateModalHost. */
251
+ createModal: CreateModalState | null;
252
+ setCreateModal(state: CreateModalState | null): void;
208
253
  /** Whether the Settings dialog is open. Lifted to the store so the Welcome
209
254
  * checklist's "coding agent" row can deep-link into it. */
210
255
  settingsOpen: boolean;
@@ -295,12 +340,28 @@ interface BuilderState {
295
340
  instruction?: string;
296
341
  diff?: string;
297
342
  files?: string[];
343
+ /** True when this run is a `guided-setup` run OWNED by the WelcomeDialog's
344
+ * two-pane phase machine. It suppresses the right-hand AgentConsole
345
+ * auto-open (the dialog embeds the stream itself) and, since the run lives
346
+ * in this single slot, lets the dialog re-attach after being closed
347
+ * mid-run — the phase derives from this flag + `status`. */
348
+ guided?: boolean;
298
349
  } | null;
350
+ /** Prior guided-setup conversation (finished runs' events + the user's
351
+ * answers), preserved across continuation runs — each continuation REPLACES
352
+ * the agentRun slot, so without this the onboarding pane would wipe to
353
+ * "Thinking…" on every answer. Cleared on a fresh (no-notes) start. */
354
+ guidedTranscript: AgentEvent[];
299
355
  /** Persisted agent+model picked in Settings; used as runAgent's default opts. */
300
356
  agentChoice: AgentChoice;
301
357
  setAgentChoice(choice: AgentChoice): void;
302
358
  /** Start a coding-agent run and stream its events into `agentRun`. */
303
359
  runAgent(intent: AgentIntent, opts?: StartAgentOptions): Promise<void>;
360
+ /** Kick off (or continue) the WelcomeDialog's guided-setup run: one agent run
361
+ * that reads state, scouts/skips, installs skills, and interviews about
362
+ * environments. Marks the run `guided` so the dialog owns its stream and the
363
+ * right-hand console stays closed. */
364
+ beginGuidedSetup(instruction?: string): void;
304
365
  /**
305
366
  * The operation currently being scaffolded by the create flow. Its stub is
306
367
  * already selected; the canvas shows a "waiting for the agent" holding pattern
@@ -352,8 +413,9 @@ interface BuilderState {
352
413
  pinNodeOutput(nodeId: string, output: unknown): void;
353
414
  unpinNode(nodeId: string): void;
354
415
  /**
355
- * Execute one node directly against the given input (browser-side),
356
- * capturing logs and recording a trace sample. Does not touch run state.
416
+ * Execute one node in isolation against the given input on the runner (POST
417
+ * /node-run), capturing logs and recording a local trace sample. Does not
418
+ * touch run state. Requires the runner to be online.
357
419
  */
358
420
  runNodeIsolated(
359
421
  nodeId: string,
@@ -370,35 +432,11 @@ interface BuilderState {
370
432
  importFlow(json: string): void;
371
433
  }
372
434
 
373
- const registry = createDefaultRegistry();
374
-
375
- /** Max depth of nested Subflow calls (ancestry chain length) before a run fails. */
376
- export const SUBFLOW_DEPTH_CAP = 8;
377
-
378
- /**
379
- * A prefixed copy of a child-run log line, folded into the parent's stream:
380
- * label becomes "<ChildFlow> › <nodeLabel>" and the console id is namespaced
381
- * under the calling Subflow node so console clicks don't collide with parent
382
- * ids.
383
- */
384
- function prefixSubflowLog(line: LogLine, childName: string, callerNodeId: string): LogLine {
385
- return {
386
- ...line,
387
- nodeLabel: `${childName} › ${line.nodeLabel ?? ''}`,
388
- nodeId: line.nodeId ? `${callerNodeId}/${line.nodeId}` : callerNodeId,
389
- };
390
- }
391
-
392
- /** Build the cycle/depth error a subflow guard reports for a given ancestry. */
393
- function subflowGuardError(ancestry: string[], workflowId: string): string | null {
394
- if (ancestry.length >= SUBFLOW_DEPTH_CAP) {
395
- return `subflow depth cap (${SUBFLOW_DEPTH_CAP}) exceeded`;
396
- }
397
- if (ancestry.includes(workflowId)) {
398
- return `subflow cycle: ${[...ancestry, workflowId].join(' → ')}`;
399
- }
400
- return null;
401
- }
435
+ // The studio registry is definitions-only: it carries node metadata (labels,
436
+ // schemas, categories) synced from the runner via syncNodeMeta, never the node
437
+ // implementations. All execution happens on the runner the studio is a pure
438
+ // client. syncNodeMeta populates this from GET /api/nodes.
439
+ const registry = new NodeRegistry();
402
440
 
403
441
  function touched(flow: WorkflowDefinition): WorkflowDefinition {
404
442
  return { ...flow, updatedAt: new Date().toISOString() };
@@ -451,29 +489,15 @@ function emptyFlow(): WorkflowDefinition {
451
489
  };
452
490
  }
453
491
 
492
+ // The workspace comes from the runner (adopted on first health check via
493
+ // syncFromRunner). At boot we hydrate only from any locally-persisted workspace;
494
+ // with none, we open an empty flow. No example flows are seeded client-side —
495
+ // the studio bundles no node implementations, so it can't execute them anyway.
454
496
  const workspace = loadWorkspace();
455
- const exampleFlows = [
456
- createWeatherFlow(),
457
- createLoginFlow(),
458
- ...createAnomalyFlows(),
459
- createCitySweepFlow(),
460
- ...createPradarFlows(),
461
- ];
462
- // Saved flows win; example flows the workspace doesn't know yet are merged in.
463
- const exampleById = new Map(exampleFlows.map((f) => [f.id, f]));
464
- const savedFlows = (workspace?.flows ?? []).map((f) => {
465
- // Backfill the preferred environment onto older saved copies of built-in
466
- // flows that predate the field, without clobbering user edits.
467
- if (f.environment === undefined && exampleById.get(f.id)?.environment) {
468
- return { ...f, environment: exampleById.get(f.id)!.environment };
469
- }
470
- return f;
471
- });
472
- const savedIds = new Set(savedFlows.map((f) => f.id));
473
- const initialFlows = [...savedFlows, ...exampleFlows.filter((f) => !savedIds.has(f.id))];
497
+ const savedFlows = workspace?.flows ?? [];
474
498
  const initialFlow =
475
- initialFlows.find((f) => f.id === workspace?.activeId) ?? initialFlows[0];
476
- const initialShelf = initialFlows.filter((f) => f.id !== initialFlow.id);
499
+ savedFlows.find((f) => f.id === workspace?.activeId) ?? savedFlows[0] ?? emptyFlow();
500
+ const initialShelf = savedFlows.filter((f) => f.id !== initialFlow.id);
477
501
 
478
502
  const ENV_KEY = 'emberflow.environment';
479
503
  const SAFE_KEY = 'emberflow.safeMode';
@@ -551,10 +575,6 @@ let flowEnvSynced = false;
551
575
  let stepFollow = false;
552
576
 
553
577
  export const useBuilderStore = create<BuilderState>((set, get) => {
554
- const snapshotRun = (handle: FlowRun): void => {
555
- set({ run: { ...handle.run, nodeStates: { ...handle.run.nodeStates } } });
556
- };
557
-
558
578
  /** Whether the selected environment is protected (writes are expensive there). */
559
579
  const selectedEnvProtected = (): boolean => {
560
580
  const s = get();
@@ -594,126 +614,143 @@ export const useBuilderStore = create<BuilderState>((set, get) => {
594
614
  get().selectEnvironment(preferred);
595
615
  };
596
616
 
597
- // Selection follows the step cursor: pick the most recently completed node.
598
- const selectLatestCompleted = () => {
599
- const states = Object.entries(get().run?.nodeStates ?? {});
600
- let latest: string | null = null;
601
- let latestAt = '';
602
- for (const [id, st] of states) {
603
- if ((st.status === 'succeeded' || st.status === 'failed') && st.completedAt && st.completedAt >= latestAt) {
604
- latestAt = st.completedAt;
605
- latest = id;
606
- }
607
- }
608
- if (latest) set({ selectedNodeId: latest });
617
+ const reportRunError = (message: string): void => {
618
+ set((s) => ({
619
+ logs: [
620
+ ...s.logs,
621
+ { timestamp: new Date().toISOString(), level: 'error', runId: 'runner', message },
622
+ ],
623
+ }));
609
624
  };
610
625
 
611
626
  /**
612
- * Browser-side Subflow runner. Resolves the child workflow across the active
613
- * flow + shelf, runs it to completion on the same registry (browser env:
614
- * empty secrets/vars, honouring safe mode), forwards its logs into the parent
615
- * stream (prefixed), and returns its collected Result output. The ancestry
616
- * chain threaded through this closure, not global state enforces the
617
- * depth cap and catches A→B→A cycles.
627
+ * Execution stepped INTO a Subflow node: stash the current level and show
628
+ * the child flow with a fresh synthetic run (status running, no node states
629
+ * yet the child's first node executes on the NEXT step and lights up as
630
+ * its SSE states arrive). Logs are the root run's and keep streaming; the
631
+ * server run id and step mode are untouched. If the child flow isn't in the
632
+ * workspace (shouldn't happen for project flows), stay on the parent but
633
+ * push a placeholder level so stack depths stay aligned with the server.
618
634
  */
619
- const makeSubflowRunner = (
620
- ancestry: string[],
621
- ): NonNullable<StartRunOptions['subflowRunner']> => {
622
- return async (workflowId, input, callerNodeId): Promise<SubflowResult> => {
623
- const guard = subflowGuardError(ancestry, workflowId);
624
- if (guard) return { status: 'failed', error: guard };
625
- const s = get();
626
- const childFlow = [s.flow, ...s.shelf].find((f) => f.id === workflowId);
627
- if (!childFlow) return { status: 'failed', error: `Unknown workflow: ${workflowId}` };
628
- try {
629
- const handle = startRun({
630
- flow: childFlow,
631
- registry: s.registry,
632
- trace: s.trace,
633
- pins: pinsOf(childFlow),
634
- input,
635
- environment: 'browser',
636
- safeMode: s.safeMode,
637
- subflowRunner: makeSubflowRunner([...ancestry, workflowId]),
638
- events: {
639
- onLog: (line) =>
640
- set((st) => ({
641
- logs: [...st.logs, prefixSubflowLog(line, childFlow.name, callerNodeId)],
642
- })),
643
- },
644
- });
645
- const childRun = await handle.runToEnd();
646
- if (childRun.status !== 'succeeded') {
647
- return { status: 'failed', error: `subflow "${childFlow.name}" ${childRun.status}` };
648
- }
649
- return { status: 'succeeded', output: runOutput(childRun, childFlow) };
650
- } catch (err) {
651
- return { status: 'failed', error: err instanceof Error ? err.message : String(err) };
652
- }
635
+ const enterDrill = (entered: { workflowId: string; nodeId: string }): void => {
636
+ const s = get();
637
+ const child = s.shelf.find((f) => f.id === entered.workflowId);
638
+ if (!child || child.id === s.flow.id) {
639
+ // Unknown child — keep stepping the parent view rather than crash, but
640
+ // still push a PLACEHOLDER level: the server's drill stack grew, and
641
+ // the client's must stay depth-aligned so the matching `exited` pops
642
+ // the right level (and deeper enters keep routing correctly).
643
+ hydrating(() =>
644
+ set({
645
+ stepDrill: [
646
+ ...s.stepDrill,
647
+ {
648
+ workflowId: entered.workflowId,
649
+ viaNodeId: entered.nodeId,
650
+ savedFlow: s.flow,
651
+ savedRun: s.run,
652
+ savedSelectedNodeId: s.selectedNodeId,
653
+ placeholder: true,
654
+ },
655
+ ],
656
+ drillPeek: null,
657
+ }),
658
+ );
659
+ return;
660
+ }
661
+ const runId = s.activeServerRunId ?? s.run?.id;
662
+ if (!runId) return;
663
+ // Same id as the root run: child states stream on the root run's SSE, and
664
+ // the visible-run guard in onNodeState keys off this id.
665
+ const childRun: WorkflowRun = {
666
+ id: runId,
667
+ workflowId: child.id,
668
+ status: 'running',
669
+ startedAt: new Date().toISOString(),
670
+ nodeStates: {},
653
671
  };
654
- };
655
-
656
- const ensureRun = (
657
- input?: Record<string, unknown>,
658
- scenarioMocks?: Record<string, unknown>,
659
- ): FlowRun | null => {
660
- const existing = get().activeRun;
661
- if (existing && existing.run.status === 'running') return existing;
662
- const mockRun = get().runnerMock;
663
- set({ activeRunMock: mockRun });
664
- try {
665
- const handle = startRun({
666
- flow: get().flow,
667
- registry: get().registry,
668
- trace: get().trace,
669
- pins: pinsOf(get().flow),
670
- input,
671
- // Browser-engine runs honour Mock mode the same way the runner does:
672
- // op-level mocks under any scenario's own map, fail-loud at infra.
673
- ...(mockRun ? { mockRun: true, mocks: { ...get().flow.mocks, ...scenarioMocks } } : {}),
674
- // Browser execution has no env file: it runs as environment 'browser'
675
- // (empty secrets/vars) but still honours safe mode over HTTP.
676
- environment: 'browser',
677
- safeMode: get().safeMode,
678
- subflowRunner: makeSubflowRunner([get().flow.id]),
679
- events: {
680
- onNodeStateChange: () => snapshotRun(handle),
681
- onLog: (line) => set((s) => ({ logs: [...s.logs, line] })),
682
- onRunFinished: () => {
683
- snapshotRun(handle);
684
- get().recordRun({ ...handle.run, nodeStates: { ...handle.run.nodeStates } });
685
- set({ activeRun: null });
686
- revertUnsafeAfterRun();
687
- },
688
- },
689
- });
690
- set({ activeRun: handle, logs: [] });
691
- snapshotRun(handle);
692
- return handle;
693
- } catch (err) {
694
- const message = err instanceof Error ? err.message : String(err);
695
- set((s) => ({
696
- logs: [
697
- ...s.logs,
672
+ // hydrating: a drill swap is a view change, not an edit — it must not
673
+ // trigger the autosave that a `flow` reference change normally implies.
674
+ hydrating(() =>
675
+ set({
676
+ stepDrill: [
677
+ ...s.stepDrill,
698
678
  {
699
- timestamp: new Date().toISOString(),
700
- level: 'error',
701
- runId: 'validation',
702
- message,
679
+ workflowId: child.id,
680
+ viaNodeId: entered.nodeId,
681
+ savedFlow: s.flow,
682
+ savedRun: s.run,
683
+ savedSelectedNodeId: s.selectedNodeId,
703
684
  },
704
685
  ],
705
- }));
706
- return null;
686
+ drillPeek: null,
687
+ flow: child,
688
+ run: childRun,
689
+ selectedNodeId: null,
690
+ }),
691
+ );
692
+ };
693
+
694
+ /** The drilled child completed: pop one level and restore the parent's
695
+ * flow/run/selection (its run kept receiving its own routed SSE states —
696
+ * including the Subflow node's terminal state — while we were inside). */
697
+ const exitDrill = (): void => {
698
+ const s = get();
699
+ const entry = s.stepDrill[s.stepDrill.length - 1];
700
+ if (!entry) return;
701
+ if (entry.placeholder) {
702
+ // The view never left the parent for this level — just drop it. The
703
+ // live flow/run kept receiving the parent's states while "inside".
704
+ hydrating(() => set({ stepDrill: s.stepDrill.slice(0, -1), drillPeek: null }));
705
+ return;
707
706
  }
707
+ hydrating(() =>
708
+ set({
709
+ stepDrill: s.stepDrill.slice(0, -1),
710
+ drillPeek: null,
711
+ flow: entry.savedFlow,
712
+ run: entry.savedRun,
713
+ selectedNodeId: entry.savedSelectedNodeId,
714
+ }),
715
+ );
708
716
  };
709
717
 
710
- const reportRunError = (message: string): void => {
711
- set((s) => ({
712
- logs: [
713
- ...s.logs,
714
- { timestamp: new Date().toISOString(), level: 'error', runId: 'runner', message },
715
- ],
716
- }));
718
+ /** Unwind the whole drill stack back to the root level (run finish/reset/
719
+ * flow switch): restore the root's flow/run/selection and drop the stack. */
720
+ const drainDrill = (): void => {
721
+ const s = get();
722
+ if (s.stepDrill.length === 0) {
723
+ if (s.drillPeek !== null) set({ drillPeek: null });
724
+ return;
725
+ }
726
+ // All-placeholder stack: the view never left the root, and the live run
727
+ // (not the stashes) kept receiving its states — just drop the levels.
728
+ if (s.stepDrill.every((d) => d.placeholder)) {
729
+ hydrating(() => set({ stepDrill: [], drillPeek: null }));
730
+ return;
731
+ }
732
+ const root = s.stepDrill[0];
733
+ hydrating(() =>
734
+ set({
735
+ stepDrill: [],
736
+ drillPeek: null,
737
+ flow: root.savedFlow,
738
+ run: root.savedRun,
739
+ selectedNodeId: root.savedSelectedNodeId,
740
+ }),
741
+ );
742
+ };
743
+
744
+ /** Consume one step response: drive the drill stack from entered/exited
745
+ * markers and clear the live-run id when the run is done. `exited` can
746
+ * co-occur with `done` (child was the last node, or the child failed). */
747
+ const handleStepResult = (result: StepResult): void => {
748
+ // Order matters when both markers co-occur: a retrying Subflow node pops
749
+ // its failed child AND re-enters a fresh one in the same step — the pop
750
+ // must apply before the push, or the exit would pop the new level.
751
+ if (result.exited) exitDrill();
752
+ if (result.entered) enterDrill(result.entered);
753
+ if (result.done) set({ activeServerRunId: null });
717
754
  };
718
755
 
719
756
  /** Start a server-side run (if none live) and wire its SSE events into the store. */
@@ -742,18 +779,40 @@ export const useBuilderStore = create<BuilderState>((set, get) => {
742
779
  };
743
780
  set({ activeServerRunId: runId, run: initial, logs: [] });
744
781
  subscribeServerRun(runId, {
745
- onNodeState: (nodeId, state) => {
782
+ onNodeState: (workflowId, nodeId, state) => {
746
783
  set((s) => {
747
- if (!s.run || s.run.id !== runId) return {};
748
- const follow =
749
- stepFollow && (state.status === 'succeeded' || state.status === 'failed')
750
- ? { selectedNodeId: nodeId }
751
- : {};
752
- return { run: { ...s.run, nodeStates: { ...s.run.nodeStates, [nodeId]: state } }, ...follow };
784
+ const followed =
785
+ stepFollow && (state.status === 'succeeded' || state.status === 'failed');
786
+ // The level whose flow is loaded owns this event (deepest drilled
787
+ // level, or the root when not drilled) → apply to the live run.
788
+ if (s.flow.id === workflowId) {
789
+ if (!s.run || s.run.id !== runId) return {};
790
+ return {
791
+ run: { ...s.run, nodeStates: { ...s.run.nodeStates, [nodeId]: state } },
792
+ ...(followed ? { selectedNodeId: nodeId } : {}),
793
+ };
794
+ }
795
+ // A stashed drill level owns it (e.g. the parent's Subflow node
796
+ // updating while we're inside the child) → apply to its stashed
797
+ // run so nothing is lost when that level is restored or peeked.
798
+ const idx = s.stepDrill.findIndex((d) => d.savedFlow.id === workflowId);
799
+ if (idx === -1) return {}; // unknown workflowId — ignore
800
+ const entry = s.stepDrill[idx];
801
+ if (!entry.savedRun || entry.savedRun.id !== runId) return {};
802
+ const savedRun = {
803
+ ...entry.savedRun,
804
+ nodeStates: { ...entry.savedRun.nodeStates, [nodeId]: state },
805
+ };
806
+ return {
807
+ stepDrill: s.stepDrill.map((d, i) => (i === idx ? { ...d, savedRun } : d)),
808
+ };
753
809
  });
754
810
  },
755
811
  onLog: (line) => set((s) => ({ logs: [...s.logs, line] })),
756
812
  onFinished: (run, errorHandler) => {
813
+ // The final run is the ROOT run — unwind any remaining drill levels
814
+ // (cancel/failure can finish a run while drilled) before showing it.
815
+ drainDrill();
757
816
  set({ run });
758
817
  get().recordRun(run, errorHandler);
759
818
  set({ activeServerRunId: null });
@@ -789,6 +848,7 @@ export const useBuilderStore = create<BuilderState>((set, get) => {
789
848
  agentPanelOpen: false,
790
849
  agentEnvSetup: false,
791
850
  welcomeOpen: false,
851
+ createModal: null,
792
852
  settingsOpen: false,
793
853
  settingsFromWelcome: false,
794
854
  setupStatus: null,
@@ -803,22 +863,26 @@ export const useBuilderStore = create<BuilderState>((set, get) => {
803
863
  activeRunMock: false,
804
864
  scenarioTestPending: null,
805
865
  agentRun: null,
866
+ guidedTranscript: [],
806
867
  buildingOperationId: null,
807
868
  agentChoice: initialAgentChoice,
808
869
  setAgentChoice(choice) {
809
870
  set({ agentChoice: choice });
810
871
  persistAgentChoice(choice);
811
872
  },
812
- executionMode: (() => {
813
- const saved =
814
- typeof localStorage !== 'undefined' ? localStorage.getItem('emberflow.mode') : null;
815
- return saved === 'browser' || saved === 'server' ? saved : 'auto';
816
- })(),
817
873
  runnerOnline: null,
818
874
  runnerMock: false,
819
875
  workspaceSource: 'local',
820
876
  activeServerRunId: null,
821
877
  stepMode: false,
878
+ stepDrill: [],
879
+ drillPeek: null,
880
+ peekDrill(index) {
881
+ set((s) => {
882
+ if (index === null || s.stepDrill.length === 0) return { drillPeek: null };
883
+ return { drillPeek: index >= 0 && index < s.stepDrill.length ? index : null };
884
+ });
885
+ },
822
886
  environments: [],
823
887
  environmentsDefault: '',
824
888
  selectedEnvironment: initialSelectedEnvironment,
@@ -906,18 +970,6 @@ export const useBuilderStore = create<BuilderState>((set, get) => {
906
970
  return true;
907
971
  },
908
972
 
909
- setExecutionMode(mode) {
910
- set({ executionMode: mode });
911
- if (typeof localStorage !== 'undefined') localStorage.setItem('emberflow.mode', mode);
912
- if (mode !== 'browser') void get().checkRunner();
913
- },
914
-
915
- effectiveMode() {
916
- const s = get();
917
- if (s.executionMode === 'auto') return s.runnerOnline ? 'server' : 'browser';
918
- return s.executionMode;
919
- },
920
-
921
973
  async setServingMode(mode) {
922
974
  await setServingModeOnServer(mode);
923
975
  await get().checkRunner();
@@ -942,8 +994,8 @@ export const useBuilderStore = create<BuilderState>((set, get) => {
942
994
  if (meta.length === 0) return;
943
995
  const registry = get().registry;
944
996
  for (const m of meta) {
945
- const { source, ...definition } = m;
946
- registry.registerDefinition(definition, source);
997
+ const { source, sourceRef, builtin, ...definition } = m;
998
+ registry.registerDefinition(definition, source, { sourceRef, builtin });
947
999
  }
948
1000
  // Force palette/inspector consumers to re-read the registry: a new
949
1001
  // top-level reference (Zustand uses Object.is) sharing the same node data.
@@ -971,6 +1023,8 @@ export const useBuilderStore = create<BuilderState>((set, get) => {
971
1023
  logs: [],
972
1024
  activeRun: null,
973
1025
  selectedNodeId: null,
1026
+ stepDrill: [],
1027
+ drillPeek: null,
974
1028
  }),
975
1029
  );
976
1030
  },
@@ -1050,6 +1104,11 @@ export const useBuilderStore = create<BuilderState>((set, get) => {
1050
1104
 
1051
1105
  async runAgent(intent, opts) {
1052
1106
  const effectiveOpts = opts ?? get().agentChoice;
1107
+ // A guided-setup run is OWNED by the WelcomeDialog's embedded stream, so
1108
+ // it must NOT auto-open the right-hand AgentConsole panel. The `guided`
1109
+ // marker on the run slot drives both the suppression here and the dialog's
1110
+ // phase machine (which re-attaches to this same slot when reopened).
1111
+ const guided = intent.action === 'guided-setup';
1053
1112
  // If a build is in flight, remember the op being built + the op ids that
1054
1113
  // existed before the run. If the agent renames the op (the id changes),
1055
1114
  // syncFromRunner can't find the old id and falls back to flows[0], leaving
@@ -1067,18 +1126,22 @@ export const useBuilderStore = create<BuilderState>((set, get) => {
1067
1126
  id: '',
1068
1127
  events: [{ type: 'error', text: err instanceof Error ? err.message : String(err) }],
1069
1128
  status: 'error',
1129
+ guided,
1070
1130
  },
1071
1131
  // Open the panel so the failure is visible — without this, a start
1072
1132
  // error (e.g. no agent CLI on PATH) lands in a closed panel and the
1073
- // click appears to do nothing.
1074
- agentPanelOpen: true,
1133
+ // click appears to do nothing. A guided run's failure surfaces in the
1134
+ // WelcomeDialog's own pane, so it stays closed.
1135
+ ...(guided ? {} : { agentPanelOpen: true }),
1075
1136
  });
1076
1137
  return;
1077
1138
  }
1078
1139
 
1079
1140
  set({
1080
- agentRun: { id: agentRunId, events: [], status: 'running', instruction: intent.instruction },
1081
- agentPanelOpen: true,
1141
+ agentRun: { id: agentRunId, events: [], status: 'running', instruction: intent.instruction, guided },
1142
+ // The WelcomeDialog embeds the stream for guided runs — don't also pop
1143
+ // the right-hand console.
1144
+ ...(guided ? {} : { agentPanelOpen: true }),
1082
1145
  });
1083
1146
 
1084
1147
  // Live canvas: while the agent works, poll the runner (GET /workflows reads
@@ -1210,18 +1273,55 @@ export const useBuilderStore = create<BuilderState>((set, get) => {
1210
1273
  set({ agentPanelOpen: true, agentEnvSetup: true });
1211
1274
  },
1212
1275
 
1213
- beginInfrastructureScout() {
1276
+ beginInfrastructureScout(instruction) {
1277
+ // Defensive: callers wired as onClick handlers leak a MouseEvent as the
1278
+ // first arg — anything that isn't a string means "full rescan".
1279
+ const amendment = typeof instruction === 'string' ? instruction.trim() : '';
1214
1280
  void get().runAgent({
1215
1281
  action: 'scout-infrastructure',
1216
1282
  instruction:
1217
- "Scan this project's dependencies, config files, ORM schemas, env-var references and HTTP clients, and write emberflow/infrastructure.json describing the databases, APIs and providers it already uses.",
1283
+ amendment && amendment.length > 0
1284
+ ? amendment
1285
+ : "Scan this project's dependencies, config files, ORM schemas, env-var references and HTTP clients, and write emberflow/infrastructure.json describing the databases, APIs and providers it already uses.",
1218
1286
  });
1219
1287
  },
1220
1288
 
1289
+ beginGuidedSetup(instruction) {
1290
+ // onClick handlers leak a MouseEvent as the first arg — only a real string
1291
+ // is a continuation answer; anything else means "start with no notes".
1292
+ const notes = typeof instruction === 'string' ? instruction.trim() : '';
1293
+ const prior = get().agentRun;
1294
+ if (notes && prior?.guided) {
1295
+ // Continuation: the new run REPLACES the agentRun slot, so fold the
1296
+ // finished run's events plus the user's answer into the persistent
1297
+ // transcript — otherwise the pane wipes to "Thinking…" and the whole
1298
+ // conversation vanishes. Question blocks are stripped here (they were
1299
+ // answered; re-rendering them as raw fences would be noise).
1300
+ const stripped = prior.events.map((e) =>
1301
+ e.type === 'message' && e.text ? { ...e, text: extractGuidedQuestions(e.text).stripped } : e,
1302
+ );
1303
+ set((s) => ({
1304
+ guidedTranscript: [
1305
+ ...s.guidedTranscript,
1306
+ ...stripped,
1307
+ { type: 'message', text: `**You:** ${notes}` },
1308
+ ],
1309
+ }));
1310
+ } else if (!notes) {
1311
+ // Fresh start: a brand-new guided run begins a brand-new conversation.
1312
+ set({ guidedTranscript: [] });
1313
+ }
1314
+ void get().runAgent({ action: 'guided-setup', instruction: notes });
1315
+ },
1316
+
1221
1317
  setWelcomeOpen(open) {
1222
1318
  set({ welcomeOpen: open });
1223
1319
  },
1224
1320
 
1321
+ setCreateModal(state) {
1322
+ set({ createModal: state });
1323
+ },
1324
+
1225
1325
  setSettingsOpen(open) {
1226
1326
  set(open ? { settingsOpen: true } : { settingsOpen: false, settingsFromWelcome: false });
1227
1327
  },
@@ -1328,6 +1428,9 @@ export const useBuilderStore = create<BuilderState>((set, get) => {
1328
1428
  },
1329
1429
 
1330
1430
  switchWorkflow(id) {
1431
+ // Switching flows while drilled would strand the stashed parent — put
1432
+ // the root level back first so flow/shelf are consistent again.
1433
+ drainDrill();
1331
1434
  let switched = false;
1332
1435
  set((s) => {
1333
1436
  if (s.flow.id === id) return {};
@@ -1590,57 +1693,45 @@ export const useBuilderStore = create<BuilderState>((set, get) => {
1590
1693
  const s = get();
1591
1694
  const node = s.flow.nodes.find((n) => n.id === nodeId);
1592
1695
  if (!node) return { error: `Unknown node: ${nodeId}`, logs: [] };
1593
- if (!s.registry.has(node.type)) {
1594
- return { error: `Unknown node type: ${node.type}`, logs: [] };
1595
- }
1596
- const { implementation } = s.registry.get(node.type);
1597
- const logs: LogLine[] = [];
1598
- const runId = `isolated-${crypto.randomUUID().slice(0, 8)}`;
1599
- const log = (level: LogLine['level'], message: string): void => {
1600
- logs.push({
1601
- timestamp: new Date().toISOString(),
1602
- level,
1603
- runId,
1604
- nodeId: node.id,
1605
- nodeLabel: node.label,
1606
- message,
1607
- });
1608
- };
1696
+ // Isolated node runs execute on the runner (POST /node-run) against the
1697
+ // selected environment's secrets/vars, honouring safe mode the studio
1698
+ // bundles no implementations. Runner offline → surface the offline notice.
1609
1699
  const startedAt = new Date().toISOString();
1610
- const sample = (status: 'succeeded' | 'failed', output?: unknown) => {
1611
- s.trace.record({
1612
- id: crypto.randomUUID(),
1613
- workflowId: s.flow.id,
1614
- runId,
1615
- nodeId: node.id,
1616
- nodeType: node.type,
1617
- nodeLabel: node.label,
1618
- input,
1619
- output,
1620
- status,
1621
- startedAt,
1622
- completedAt: new Date().toISOString(),
1623
- });
1624
- };
1700
+ let result: { output?: unknown; error?: string; logs: LogLine[] };
1625
1701
  try {
1626
- const output = await implementation({
1702
+ result = await runNodeOnServer({
1703
+ type: node.type,
1627
1704
  input,
1628
- config: node.config,
1629
- secrets: {},
1630
- vars: {},
1631
- safeMode: false,
1632
- runInput: {},
1633
- log,
1634
- runSubflow: (workflowId, subInput) =>
1635
- makeSubflowRunner([s.flow.id])(workflowId, subInput, node.id),
1705
+ config: node.config ?? {},
1706
+ ...(s.selectedEnvironment ? { environment: s.selectedEnvironment } : {}),
1707
+ safeMode: s.safeMode,
1708
+ ...(selectedEnvProtected() && !s.safeMode ? { confirm: s.selectedEnvironment } : {}),
1636
1709
  });
1637
- sample('succeeded', output);
1638
- return { output, logs };
1639
1710
  } catch (err) {
1640
- const error = err instanceof Error ? err.message : String(err);
1641
- sample('failed');
1642
- return { error, logs };
1711
+ const message = err instanceof Error ? err.message : String(err);
1712
+ return {
1713
+ error: get().runnerOnline === false
1714
+ ? 'Runner offline — start it with `npx emberflow dev`, then run this node again.'
1715
+ : message,
1716
+ logs: [],
1717
+ };
1643
1718
  }
1719
+ // Record a local trace sample so the modal's "previous executions" list
1720
+ // and pin affordance keep working alongside runner-recorded samples.
1721
+ s.trace.record({
1722
+ id: crypto.randomUUID(),
1723
+ workflowId: s.flow.id,
1724
+ runId: `isolated-${crypto.randomUUID().slice(0, 8)}`,
1725
+ nodeId: node.id,
1726
+ nodeType: node.type,
1727
+ nodeLabel: node.label,
1728
+ input,
1729
+ output: result.output,
1730
+ status: result.error ? 'failed' : 'succeeded',
1731
+ startedAt,
1732
+ completedAt: new Date().toISOString(),
1733
+ });
1734
+ return { output: result.output, error: result.error, logs: result.logs ?? [] };
1644
1735
  },
1645
1736
 
1646
1737
  async testWorkflow(flowId, environment) {
@@ -1662,12 +1753,7 @@ export const useBuilderStore = create<BuilderState>((set, get) => {
1662
1753
  get().resetRun();
1663
1754
  set({ stepMode: false });
1664
1755
  set({ activeScenarioId: scenarioId });
1665
- if (get().effectiveMode() === 'server') {
1666
- await ensureServerRun('run', scenario.input, scenario.name);
1667
- return;
1668
- }
1669
- const handle = ensureRun(scenario.input, scenario.mocks);
1670
- if (handle) await handle.runToEnd();
1756
+ await ensureServerRun('run', scenario.input, scenario.name);
1671
1757
  },
1672
1758
 
1673
1759
  async stepScenario(scenarioId) {
@@ -1677,22 +1763,13 @@ export const useBuilderStore = create<BuilderState>((set, get) => {
1677
1763
  get().resetRun();
1678
1764
  set({ stepMode: true });
1679
1765
  set({ activeScenarioId: scenarioId });
1680
- if (get().effectiveMode() === 'server') {
1681
- const runId = await ensureServerRun('step', scenario.input, scenario.name);
1682
- if (runId) {
1683
- try {
1684
- const done = await stepServerRun(runId);
1685
- if (done) set({ activeServerRunId: null });
1686
- } catch (err) {
1687
- reportRunError(err instanceof Error ? err.message : String(err));
1688
- }
1766
+ const runId = await ensureServerRun('step', scenario.input, scenario.name);
1767
+ if (runId) {
1768
+ try {
1769
+ handleStepResult(await stepServerRun(runId));
1770
+ } catch (err) {
1771
+ reportRunError(err instanceof Error ? err.message : String(err));
1689
1772
  }
1690
- return;
1691
- }
1692
- const handle = ensureRun(scenario.input, scenario.mocks);
1693
- if (handle) {
1694
- await handle.step();
1695
- selectLatestCompleted();
1696
1773
  }
1697
1774
  },
1698
1775
 
@@ -1740,53 +1817,47 @@ export const useBuilderStore = create<BuilderState>((set, get) => {
1740
1817
  async stepRun() {
1741
1818
  // A plain step/run is not scenario-driven; only tag history for runs
1742
1819
  // started via runScenario.
1743
- if (!get().activeRun && !get().activeServerRunId) set({ activeScenarioId: null });
1820
+ if (!get().activeServerRunId) set({ activeScenarioId: null });
1744
1821
  stepFollow = true;
1745
1822
  set({ stepMode: true, runConsoleDismissedId: null });
1746
- if (get().effectiveMode() === 'server') {
1747
- const runId = await ensureServerRun('step');
1748
- if (runId) {
1749
- try {
1750
- const done = await stepServerRun(runId);
1751
- if (done) set({ activeServerRunId: null });
1752
- } catch (err) {
1753
- reportRunError(err instanceof Error ? err.message : String(err));
1754
- }
1823
+ const runId = await ensureServerRun('step');
1824
+ if (runId) {
1825
+ try {
1826
+ handleStepResult(await stepServerRun(runId));
1827
+ } catch (err) {
1828
+ reportRunError(err instanceof Error ? err.message : String(err));
1755
1829
  }
1756
- return;
1757
- }
1758
- const handle = ensureRun();
1759
- if (handle) {
1760
- await handle.step();
1761
- selectLatestCompleted();
1762
1830
  }
1763
1831
  },
1764
1832
 
1765
1833
  async runToEnd() {
1766
1834
  stepFollow = false;
1767
1835
  set({ stepMode: false, runConsoleDismissedId: null });
1768
- if (!get().activeRun && !get().activeServerRunId) set({ activeScenarioId: null });
1769
- if (get().effectiveMode() === 'server') {
1770
- const existing = get().activeServerRunId;
1771
- if (existing) {
1772
- // A stepped run is already live on the runner — walk it to the end.
1773
- try {
1774
- let done = false;
1775
- while (!done) done = await stepServerRun(existing);
1776
- set({ activeServerRunId: null });
1777
- } catch (err) {
1778
- reportRunError(err instanceof Error ? err.message : String(err));
1836
+ if (!get().activeServerRunId) set({ activeScenarioId: null });
1837
+ const existing = get().activeServerRunId;
1838
+ if (existing) {
1839
+ // A stepped run is already live on the runner — walk it to the end.
1840
+ // Drill markers still apply (entered/exited keep the view balanced;
1841
+ // handleStepResult clears activeServerRunId on done).
1842
+ try {
1843
+ let done = false;
1844
+ while (!done) {
1845
+ const result = await stepServerRun(existing);
1846
+ handleStepResult(result);
1847
+ done = result.done;
1779
1848
  }
1780
- return;
1849
+ } catch (err) {
1850
+ reportRunError(err instanceof Error ? err.message : String(err));
1781
1851
  }
1782
- await ensureServerRun('run');
1783
1852
  return;
1784
1853
  }
1785
- const handle = ensureRun();
1786
- if (handle) await handle.runToEnd();
1854
+ await ensureServerRun('run');
1787
1855
  },
1788
1856
 
1789
1857
  resetRun() {
1858
+ // Unwind any subflow drill first so the root flow is back on the canvas
1859
+ // before the run state clears.
1860
+ drainDrill();
1790
1861
  get().activeRun?.cancel();
1791
1862
  const serverRunId = get().activeServerRunId;
1792
1863
  if (serverRunId) void cancelServerRun(serverRunId);