@xdelivered/emberflow 0.2.0 → 0.4.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 (88) 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 +185 -14
  19. package/dist/server/runRegistry.d.ts +52 -1
  20. package/dist/server/runRegistry.js +170 -1
  21. package/dist/server/subflowRunner.d.ts +48 -1
  22. package/dist/server/subflowRunner.js +34 -7
  23. package/dist/server/updateCheck.d.ts +68 -0
  24. package/dist/server/updateCheck.js +142 -0
  25. package/dist/src/engine/executor.js +4 -3
  26. package/package.json +27 -6
  27. package/server/agentRoute.test.ts +20 -0
  28. package/server/agents/codexParse.test.ts +37 -0
  29. package/server/agents/codexParse.ts +34 -0
  30. package/server/agents/detect.test.ts +26 -7
  31. package/server/agents/detect.ts +82 -14
  32. package/server/agents/modelRejectionHint.ts +2 -1
  33. package/server/agents/prompt.test.ts +128 -0
  34. package/server/agents/prompt.ts +102 -3
  35. package/server/agents/runManager.test.ts +9 -0
  36. package/server/agents/runManager.ts +37 -7
  37. package/server/client.ts +10 -4
  38. package/server/index.ts +189 -13
  39. package/server/nodeRun.test.ts +189 -0
  40. package/server/runRegistry.ts +200 -3
  41. package/server/setupStatus.test.ts +3 -0
  42. package/server/stepDrill.test.ts +380 -0
  43. package/server/subflowRunner.ts +92 -11
  44. package/server/updateCheck.test.ts +209 -0
  45. package/server/updateCheck.ts +175 -0
  46. package/server/workflowTestRoute.test.ts +1 -1
  47. package/src/App.tsx +82 -2
  48. package/src/components/AgentConsole.tsx +7 -280
  49. package/src/components/AgentStream.test.tsx +88 -0
  50. package/src/components/AgentStream.tsx +303 -0
  51. package/src/components/CreateModal.tsx +43 -9
  52. package/src/components/Dock.tsx +1 -1
  53. package/src/components/EmptyState.test.tsx +49 -0
  54. package/src/components/EmptyState.tsx +61 -0
  55. package/src/components/EnvironmentDialog.tsx +4 -1
  56. package/src/components/EnvironmentPicker.tsx +8 -3
  57. package/src/components/EnvironmentsDialog.tsx +4 -1
  58. package/src/components/InfraPanel.tsx +30 -2
  59. package/src/components/InfrastructureDialog.test.tsx +97 -0
  60. package/src/components/InfrastructureDialog.tsx +111 -0
  61. package/src/components/RunbookView.tsx +70 -6
  62. package/src/components/SettingsDialog.tsx +4 -59
  63. package/src/components/Sidebar.tsx +6 -26
  64. package/src/components/StatusBar.tsx +227 -26
  65. package/src/components/Toolbar.tsx +28 -16
  66. package/src/components/UpdateChip.test.tsx +31 -0
  67. package/src/components/WelcomeDialog.test.tsx +384 -13
  68. package/src/components/WelcomeDialog.tsx +996 -177
  69. package/src/engine/executor.ts +4 -3
  70. package/src/lib/guidedQuestions.test.ts +224 -0
  71. package/src/lib/guidedQuestions.ts +181 -0
  72. package/src/lib/runbookModel.ts +3 -3
  73. package/src/lib/runbookProjection.test.ts +43 -0
  74. package/src/lib/runbookProjection.ts +26 -6
  75. package/src/store/agentClient.ts +27 -8
  76. package/src/store/apiTree.ts +12 -0
  77. package/src/store/builderStore.test.ts +441 -99
  78. package/src/store/builderStore.ts +383 -312
  79. package/src/store/serverRunner.ts +56 -5
  80. package/src/store/setupClient.ts +7 -2
  81. package/src/store/updateClient.ts +50 -0
  82. package/studio-dist/assets/index-CAIDjNhv.css +1 -0
  83. package/studio-dist/assets/index-CGwEx82J.js +65 -0
  84. package/studio-dist/index.html +2 -2
  85. package/templates/skills/emberflow-model-process/SKILL.md +82 -9
  86. package/templates/skills/emberflow-review-workflow/SKILL.md +37 -1
  87. package/studio-dist/assets/index-DNJwW-hM.css +0 -1
  88. package/studio-dist/assets/index-O26dKRjW.js +0 -65
@@ -60,11 +60,12 @@ function envRefName(value: unknown): string | undefined {
60
60
  function resolveEnvRef(name: string, vars: Record<string, string>): string {
61
61
  if (!(name in vars)) {
62
62
  // The most common cause is not a missing key but a run with NO environment
63
- // at all: the in-tab engine has no vars, so flows seeded with $env refs
64
- // need the server runner. Say so, instead of a bare "missing".
63
+ // at all: an environment-less run (e.g. a scenario test) has no vars, so
64
+ // flows seeded with $env refs need a real environment. Say so, instead of a
65
+ // bare "missing".
65
66
  const hint =
66
67
  Object.keys(vars).length === 0
67
- ? ' — this run has no environment variables (in-tab/browser runs never do; make sure the runner is online and the engine is set to Server or Auto)'
68
+ ? ' — this run has no environment variables; run it against an environment that defines them'
68
69
  : '';
69
70
  throw new Error(`Missing environment variable: ${name}${hint}`);
70
71
  }
@@ -0,0 +1,224 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ composeAnsweredSubset,
4
+ extractGuidedQuestions,
5
+ resolveGuidedAnswers,
6
+ } from './guidedQuestions';
7
+ import type { GuidedQuestion } from './guidedQuestions';
8
+
9
+ const block = (json: string) => '```emberflow-questions\n' + json + '\n```';
10
+
11
+ describe('extractGuidedQuestions', () => {
12
+ it('happy path: strips a trailing block and normalizes string options', () => {
13
+ const text =
14
+ 'Steps 1–4 are done.\n\n' +
15
+ block('{"questions":[{"id":"envs","text":"Which environments?","options":["dev + prod","dev + staging + prod"],"custom":true}]}');
16
+ const { stripped, questions } = extractGuidedQuestions(text);
17
+ expect(stripped).toBe('Steps 1–4 are done.');
18
+ expect(questions).toEqual([
19
+ {
20
+ id: 'envs',
21
+ text: 'Which environments?',
22
+ options: [{ label: 'dev + prod' }, { label: 'dev + staging + prod' }],
23
+ custom: true,
24
+ },
25
+ ]);
26
+ });
27
+
28
+ it('tolerates trailing whitespace/newlines after the closing fence', () => {
29
+ const text =
30
+ 'Done.\n' + block('{"questions":[{"id":"a","text":"Q?","options":["x"]}]}') + ' \n\n';
31
+ const { stripped, questions } = extractGuidedQuestions(text);
32
+ expect(stripped).toBe('Done.');
33
+ expect(questions).toHaveLength(1);
34
+ });
35
+
36
+ it('no block: returns the text unchanged with null questions', () => {
37
+ const text = 'Just prose, with `inline code` and a ```js\nfence\n``` in the middle. The end.';
38
+ expect(extractGuidedQuestions(text)).toEqual({ stripped: text, questions: null });
39
+ });
40
+
41
+ it('malformed JSON: leaves the text untouched so nothing is silently lost', () => {
42
+ const text = 'Prose.\n' + block('{"questions": [oops');
43
+ expect(extractGuidedQuestions(text)).toEqual({ stripped: text, questions: null });
44
+ });
45
+
46
+ it('invalid shape (questions not an array / bad entries): text untouched, null', () => {
47
+ for (const json of [
48
+ '{"questions":"nope"}',
49
+ '{}',
50
+ '{"questions":[]}',
51
+ '{"questions":[{"id":"a","options":["x"]}]}', // missing text
52
+ '{"questions":[{"id":"a","text":"Q?","options":[42]}]}', // bad option
53
+ '{"questions":[{"id":"a","text":"Q?","options":[]}]}', // unanswerable
54
+ ]) {
55
+ const text = 'Prose.\n' + block(json);
56
+ expect(extractGuidedQuestions(text)).toEqual({ stripped: text, questions: null });
57
+ }
58
+ });
59
+
60
+ it('block not at the end: ignored (contract puts it last)', () => {
61
+ const text =
62
+ block('{"questions":[{"id":"a","text":"Q?","options":["x"]}]}') + '\nTrailing prose after.';
63
+ expect(extractGuidedQuestions(text)).toEqual({ stripped: text, questions: null });
64
+ });
65
+
66
+ it('mixed string and object options, including a finish action', () => {
67
+ const text = block(
68
+ '{"questions":[{"id":"first-build","text":"What first?","options":["An op",{"label":"Just look around","action":"finish"}],"custom":true}]}',
69
+ );
70
+ const { stripped, questions } = extractGuidedQuestions(text);
71
+ expect(stripped).toBe('');
72
+ expect(questions).toEqual([
73
+ {
74
+ id: 'first-build',
75
+ text: 'What first?',
76
+ options: [{ label: 'An op' }, { label: 'Just look around', action: 'finish' }],
77
+ custom: true,
78
+ },
79
+ ]);
80
+ });
81
+
82
+ it('drops unknown option actions rather than forwarding them', () => {
83
+ const { questions } = extractGuidedQuestions(
84
+ block('{"questions":[{"id":"a","text":"Q?","options":[{"label":"x","action":"explode"}]}]}'),
85
+ );
86
+ expect(questions).toEqual([{ id: 'a', text: 'Q?', options: [{ label: 'x' }] }]);
87
+ });
88
+
89
+ it('keeps a submit action on an option', () => {
90
+ const { questions } = extractGuidedQuestions(
91
+ block('{"questions":[{"id":"a","text":"Q?","options":[{"label":"Go","action":"submit"}]}]}'),
92
+ );
93
+ expect(questions).toEqual([
94
+ { id: 'a', text: 'Q?', options: [{ label: 'Go', action: 'submit' }] },
95
+ ]);
96
+ });
97
+
98
+ it('keeps a non-empty why (trimmed); drops empty/whitespace/non-string ones', () => {
99
+ const { questions } = extractGuidedQuestions(
100
+ block('{"questions":[{"id":"a","text":"Q?","options":["x"],"why":" So mocks stay safe. "}]}'),
101
+ );
102
+ expect(questions).toEqual([
103
+ { id: 'a', text: 'Q?', options: [{ label: 'x' }], why: 'So mocks stay safe.' },
104
+ ]);
105
+
106
+ for (const why of ['""', '" "', '42', 'null', '["nope"]']) {
107
+ const { questions: qs } = extractGuidedQuestions(
108
+ block(`{"questions":[{"id":"a","text":"Q?","options":["x"],"why":${why}}]}`),
109
+ );
110
+ expect(qs).toEqual([{ id: 'a', text: 'Q?', options: [{ label: 'x' }] }]);
111
+ }
112
+ });
113
+ });
114
+
115
+ const QUESTIONS: GuidedQuestion[] = [
116
+ { id: 'envs', text: 'Which environments?', options: [{ label: 'dev + prod' }], custom: true },
117
+ {
118
+ id: 'first-build',
119
+ text: 'What do you want to build first?',
120
+ options: [{ label: 'Just look around', action: 'finish' }],
121
+ custom: true,
122
+ },
123
+ ];
124
+
125
+ describe('resolveGuidedAnswers', () => {
126
+ it('incomplete until every question has a pill or custom text', () => {
127
+ expect(resolveGuidedAnswers(QUESTIONS, {})).toEqual({ kind: 'incomplete' });
128
+ expect(
129
+ resolveGuidedAnswers(QUESTIONS, { envs: { option: { label: 'dev + prod' } } }),
130
+ ).toEqual({ kind: 'incomplete' });
131
+ // Whitespace-only custom text does not count as an answer.
132
+ expect(
133
+ resolveGuidedAnswers(QUESTIONS, {
134
+ envs: { option: { label: 'dev + prod' } },
135
+ 'first-build': { text: ' ' },
136
+ }),
137
+ ).toEqual({ kind: 'incomplete' });
138
+ });
139
+
140
+ it('composes one "<question>: <answer>" line per question', () => {
141
+ expect(
142
+ resolveGuidedAnswers(QUESTIONS, {
143
+ envs: { option: { label: 'dev + prod' } },
144
+ 'first-build': { text: ' a health-check op ' },
145
+ }),
146
+ ).toEqual({
147
+ kind: 'send',
148
+ text: 'Which environments?: dev + prod\nWhat do you want to build first?: a health-check op',
149
+ });
150
+ });
151
+
152
+ it('any picked finish option resolves to finish — nothing is sent', () => {
153
+ expect(
154
+ resolveGuidedAnswers(QUESTIONS, {
155
+ envs: { option: { label: 'dev + prod' } },
156
+ 'first-build': { option: { label: 'Just look around', action: 'finish' } },
157
+ }),
158
+ ).toEqual({ kind: 'finish' });
159
+ });
160
+
161
+ describe('first-build routing', () => {
162
+ const FIRST_BUILD_ONLY: GuidedQuestion[] = [QUESTIONS[1]];
163
+
164
+ it('sole first-build question answered with custom text routes to build', () => {
165
+ expect(
166
+ resolveGuidedAnswers(FIRST_BUILD_ONLY, {
167
+ 'first-build': { text: ' an invoicing API ' },
168
+ }),
169
+ ).toEqual({ kind: 'build', text: 'an invoicing API' });
170
+ });
171
+
172
+ it('sole first-build question answered with a plain pill still sends', () => {
173
+ const q: GuidedQuestion[] = [
174
+ { id: 'first-build', text: 'What first?', options: [{ label: 'An op' }], custom: true },
175
+ ];
176
+ expect(resolveGuidedAnswers(q, { 'first-build': { option: { label: 'An op' } } })).toEqual({
177
+ kind: 'send',
178
+ text: 'What first?: An op',
179
+ });
180
+ });
181
+
182
+ it('first-build custom text alongside OTHER answers keeps the composed send', () => {
183
+ expect(
184
+ resolveGuidedAnswers(QUESTIONS, {
185
+ envs: { option: { label: 'dev + prod' } },
186
+ 'first-build': { text: 'an invoicing API' },
187
+ }),
188
+ ).toEqual({
189
+ kind: 'send',
190
+ text: 'Which environments?: dev + prod\nWhat do you want to build first?: an invoicing API',
191
+ });
192
+ });
193
+
194
+ it('a sole non-first-build question with custom text still sends', () => {
195
+ expect(
196
+ resolveGuidedAnswers([QUESTIONS[0]], { envs: { text: 'dev only' } }),
197
+ ).toEqual({ kind: 'send', text: 'Which environments?: dev only' });
198
+ });
199
+ });
200
+ });
201
+
202
+ describe('composeAnsweredSubset', () => {
203
+ it('composes only the answered questions, skipping unanswered ones', () => {
204
+ expect(
205
+ composeAnsweredSubset(QUESTIONS, {
206
+ 'first-build': { option: { label: 'Skip ahead', action: 'submit' } },
207
+ }),
208
+ ).toBe('What do you want to build first?: Skip ahead');
209
+ });
210
+
211
+ it('includes pill and trimmed custom-text answers in question order', () => {
212
+ expect(
213
+ composeAnsweredSubset(QUESTIONS, {
214
+ 'first-build': { text: ' a health-check op ' },
215
+ envs: { option: { label: 'dev + prod' } },
216
+ }),
217
+ ).toBe('Which environments?: dev + prod\nWhat do you want to build first?: a health-check op');
218
+ });
219
+
220
+ it('whitespace-only custom text is not an answer; nothing answered composes empty', () => {
221
+ expect(composeAnsweredSubset(QUESTIONS, { envs: { text: ' ' } })).toBe('');
222
+ expect(composeAnsweredSubset(QUESTIONS, {})).toBe('');
223
+ });
224
+ });
@@ -0,0 +1,181 @@
1
+ /**
2
+ * The guided-setup agent ends messages that need the user's input with a fenced
3
+ * ```emberflow-questions block (see server/agents/prompt.ts, 'guided-setup'):
4
+ * JSON describing clickable questions the studio renders as a form instead of
5
+ * a raw code block. This module owns the contract's client side — extraction,
6
+ * defensive parsing/normalization, and turning the user's picks back into the
7
+ * plaintext continuation message the agent expects.
8
+ */
9
+
10
+ export interface GuidedQuestionOption {
11
+ label: string;
12
+ /** 'finish' ends onboarding client-side instead of sending a run; 'submit'
13
+ * records the answer and immediately sends whatever has been answered so
14
+ * far (a subset — unanswered questions are simply skipped). */
15
+ action?: 'finish' | 'submit';
16
+ }
17
+
18
+ export interface GuidedQuestion {
19
+ id: string;
20
+ text: string;
21
+ options: GuidedQuestionOption[];
22
+ /** Also offer a free-text field alongside the clickable options. */
23
+ custom?: boolean;
24
+ /** One-line rationale rendered under the question text. */
25
+ why?: string;
26
+ }
27
+
28
+ /** Trailing fenced block: ```emberflow-questions\n<json>\n``` and then nothing
29
+ * but whitespace. The prompt contract puts the block LAST; anything after the
30
+ * closing fence means it's not the interactive form. */
31
+ const TRAILING_BLOCK = /```emberflow-questions[ \t]*\n([\s\S]*?)\n?```\s*$/;
32
+
33
+ function normalizeOption(raw: unknown): GuidedQuestionOption | null {
34
+ if (typeof raw === 'string') return { label: raw };
35
+ if (raw && typeof raw === 'object' && typeof (raw as { label?: unknown }).label === 'string') {
36
+ const { label, action } = raw as { label: string; action?: unknown };
37
+ // Unknown actions are dropped (the option stays clickable, just inert).
38
+ return action === 'finish' || action === 'submit' ? { label, action } : { label };
39
+ }
40
+ return null;
41
+ }
42
+
43
+ function normalizeQuestion(raw: unknown): GuidedQuestion | null {
44
+ if (!raw || typeof raw !== 'object') return null;
45
+ const { id, text, options, custom, why } = raw as {
46
+ id?: unknown;
47
+ text?: unknown;
48
+ options?: unknown;
49
+ custom?: unknown;
50
+ why?: unknown;
51
+ };
52
+ if (typeof id !== 'string' || typeof text !== 'string' || !Array.isArray(options)) return null;
53
+ const normalized: GuidedQuestionOption[] = [];
54
+ for (const o of options) {
55
+ const opt = normalizeOption(o);
56
+ if (!opt) return null;
57
+ normalized.push(opt);
58
+ }
59
+ // A question with no options and no free-text field is unanswerable.
60
+ if (normalized.length === 0 && custom !== true) return null;
61
+ // `why` must be a non-empty string; anything else is dropped, not fatal.
62
+ const rationale = typeof why === 'string' && why.trim() !== '' ? why.trim() : undefined;
63
+ return {
64
+ id,
65
+ text,
66
+ options: normalized,
67
+ ...(custom === true ? { custom: true } : {}),
68
+ ...(rationale !== undefined ? { why: rationale } : {}),
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Find and strip a trailing `emberflow-questions` fenced block. Malformed JSON
74
+ * or an invalid shape returns the text UNTOUCHED with `questions: null` —
75
+ * nothing is silently lost; the stream just shows the raw block as prose.
76
+ */
77
+ export function extractGuidedQuestions(text: string): {
78
+ stripped: string;
79
+ questions: GuidedQuestion[] | null;
80
+ } {
81
+ const match = TRAILING_BLOCK.exec(text);
82
+ if (!match) return { stripped: text, questions: null };
83
+
84
+ let parsed: unknown;
85
+ try {
86
+ parsed = JSON.parse(match[1]);
87
+ } catch {
88
+ return { stripped: text, questions: null };
89
+ }
90
+ const rawQuestions = (parsed as { questions?: unknown } | null)?.questions;
91
+ if (!Array.isArray(rawQuestions) || rawQuestions.length === 0) {
92
+ return { stripped: text, questions: null };
93
+ }
94
+ const questions: GuidedQuestion[] = [];
95
+ for (const q of rawQuestions) {
96
+ const normalized = normalizeQuestion(q);
97
+ if (!normalized) return { stripped: text, questions: null };
98
+ questions.push(normalized);
99
+ }
100
+ return { stripped: text.slice(0, match.index).replace(/\s+$/, ''), questions };
101
+ }
102
+
103
+ /** One answer per question id: a picked option OR free text (never both). */
104
+ export type GuidedAnswers = Record<
105
+ string,
106
+ { option?: GuidedQuestionOption; text?: string } | undefined
107
+ >;
108
+
109
+ /** The agent's closing "what do you want to build first?" question — a custom
110
+ * answer to it (alone) routes into the real build flow, not a continuation. */
111
+ export const FIRST_BUILD_QUESTION_ID = 'first-build';
112
+
113
+ export type GuidedSubmit =
114
+ | { kind: 'incomplete' }
115
+ /** A selected option carried action 'finish' — end onboarding, send nothing. */
116
+ | { kind: 'finish' }
117
+ /** Composed plaintext continuation: one `<question>: <answer>` line each. */
118
+ | { kind: 'send'; text: string }
119
+ /** The ONLY answer is free text on the 'first-build' question — open the
120
+ * create-API flow pre-filled with the description instead of sending a run. */
121
+ | { kind: 'build'; text: string };
122
+
123
+ /**
124
+ * Resolve the form's submit: every question must be answered (pill or custom
125
+ * text); any picked 'finish' option wins over sending. When the form's sole
126
+ * question is 'first-build' and it was answered with custom text (not a pill),
127
+ * the submit routes to the build flow instead of a continuation run. Pure so
128
+ * the component test can exercise the submit path without DOM interactions.
129
+ */
130
+ export function resolveGuidedAnswers(
131
+ questions: GuidedQuestion[],
132
+ answers: GuidedAnswers,
133
+ ): GuidedSubmit {
134
+ const lines: string[] = [];
135
+ let finish = false;
136
+ for (const q of questions) {
137
+ const a = answers[q.id];
138
+ const custom = a?.text?.trim();
139
+ if (a?.option) {
140
+ if (a.option.action === 'finish') finish = true;
141
+ lines.push(`${q.text}: ${a.option.label}`);
142
+ } else if (custom) {
143
+ lines.push(`${q.text}: ${custom}`);
144
+ } else {
145
+ return { kind: 'incomplete' };
146
+ }
147
+ }
148
+ if (finish) return { kind: 'finish' };
149
+ // The only answered question is 'first-build' with a typed description —
150
+ // that's a build request, not an interview answer. Alongside OTHER answers
151
+ // it stays part of the composed continuation.
152
+ const soleBuildText =
153
+ questions.length === 1 && questions[0].id === FIRST_BUILD_QUESTION_ID
154
+ ? answers[questions[0].id]?.text?.trim()
155
+ : undefined;
156
+ if (soleBuildText && !answers[questions[0].id]?.option) {
157
+ return { kind: 'build', text: soleBuildText };
158
+ }
159
+ return { kind: 'send', text: lines.join('\n') };
160
+ }
161
+
162
+ /**
163
+ * Compose ONLY the answered questions into the same `<question>: <answer>`
164
+ * plaintext the full resolver sends — unanswered questions are skipped, not
165
+ * required. Powers a 'submit' option: clicking it records the answer and sends
166
+ * immediately with whatever the user has answered so far. Pure for the same
167
+ * reason as `resolveGuidedAnswers`.
168
+ */
169
+ export function composeAnsweredSubset(
170
+ questions: GuidedQuestion[],
171
+ answers: GuidedAnswers,
172
+ ): string {
173
+ const lines: string[] = [];
174
+ for (const q of questions) {
175
+ const a = answers[q.id];
176
+ const custom = a?.text?.trim();
177
+ if (a?.option) lines.push(`${q.text}: ${a.option.label}`);
178
+ else if (custom) lines.push(`${q.text}: ${custom}`);
179
+ }
180
+ return lines.join('\n');
181
+ }
@@ -1,6 +1,6 @@
1
- import type { WorkflowDefinition, WorkflowEdge } from '../engine';
2
- import { computeLoopRegions, topoSort, type LoopRegion } from '../engine';
3
- import type { NodeRegistry } from '../engine';
1
+ import type { WorkflowDefinition, WorkflowEdge } from '../engine/types';
2
+ import { computeLoopRegions, topoSort, type LoopRegion } from '../engine/validation';
3
+ import type { NodeRegistry } from '../engine/registry';
4
4
  import { firstSentence, simpleNodeDescription } from './registerLens';
5
5
 
6
6
  export interface RunbookStep {
@@ -209,6 +209,49 @@ describe('projectRunbook', () => {
209
209
  expect(proj.steps.get('onStep')!.tech).toBe(`#2 Http · 50ms · ERROR: ${'x'.repeat(80)}`);
210
210
  });
211
211
 
212
+ it('drilled view: outcome and receipt match caller-prefixed child log ids (`caller/nodeId`); undrilled does not', () => {
213
+ const doc = makeDoc();
214
+ const run = makeRun({
215
+ nodeStates: {
216
+ onStep: {
217
+ status: 'succeeded',
218
+ startedAt: '2026-01-01T00:00:00.000Z',
219
+ completedAt: '2026-01-01T00:00:00.100Z',
220
+ input: { a: 1 },
221
+ output: { x: 1 },
222
+ },
223
+ },
224
+ });
225
+ // How a drilled subflow child's logs arrive: nodeId prefixed with the
226
+ // caller chain (`sub/onStep`, nested per level).
227
+ const logs: LogLine[] = [
228
+ { timestamp: 't1', level: 'debug', runId: 'run-1', nodeId: 'sub/onStep', message: '#4 ▶ execute' },
229
+ { timestamp: 't2', level: 'info', runId: 'run-1', nodeId: 'sub/onStep', message: 'child outcome' },
230
+ ];
231
+
232
+ const drilled = projectRunbook(doc, run, logs, [], FLOW_ID, true);
233
+ expect(drilled.steps.get('onStep')!.outcome).toBe('child outcome');
234
+ expect(drilled.steps.get('onStep')!.tech).toBe('#4 Http · 100ms · in[a] → out[x]');
235
+
236
+ // Undrilled: suffix matching stays off so a parent node can never pick
237
+ // up a same-named child's prefixed lines.
238
+ const plain = projectRunbook(doc, run, logs, [], FLOW_ID);
239
+ expect(plain.steps.get('onStep')!.outcome).toBe('');
240
+ expect(plain.steps.get('onStep')!.tech).toBe('Http · 100ms · in[a] → out[x]');
241
+ });
242
+
243
+ it('drilled view: a plain (unprefixed) id must not suffix-match another node ending with the same segment', () => {
244
+ const doc = makeDoc();
245
+ const logs: LogLine[] = [
246
+ // `sub/onStep` ends with `/onStep` but NOT with `/Step` etc.; also an
247
+ // exact-id line for a different node must not bleed over.
248
+ { timestamp: 't1', level: 'info', runId: 'run-1', nodeId: 'cond', message: 'cond outcome' },
249
+ ];
250
+ const drilled = projectRunbook(doc, makeRun(), logs, [], FLOW_ID, true);
251
+ expect(drilled.steps.get('onStep')!.outcome).toBe('');
252
+ expect(drilled.steps.get('cond')!.outcome).toBe('cond outcome');
253
+ });
254
+
212
255
  it('tech line: no receipt yet means no #N prefix', () => {
213
256
  const doc = makeDoc();
214
257
  const run = makeRun({ nodeStates: { onStep: { status: 'idle' } } });
@@ -56,10 +56,24 @@ function fieldList(value: unknown, label: string): string {
56
56
  return `${label}[${shown.join(',')}]`;
57
57
  }
58
58
 
59
- function lastInfoOutcome(logs: LogLine[], nodeId: string): string {
59
+ /**
60
+ * Whether a log line belongs to the given node. A drilled-in subflow child's
61
+ * log lines carry nodeIds prefixed with the caller chain
62
+ * (`${callerNodeId}/${childNodeId}`, nested per level — see subflowRunner),
63
+ * while the drilled view's runbook uses the raw child ids, so when projecting
64
+ * a drilled child flow a suffix match after a slash also counts. Kept behind
65
+ * `drilled` so an undriled parent view can never match a child's prefixed
66
+ * line against a same-named parent node.
67
+ */
68
+ function lineIsFor(line: LogLine, nodeId: string, drilled: boolean): boolean {
69
+ if (line.nodeId === nodeId) return true;
70
+ return drilled && !!line.nodeId && line.nodeId.endsWith(`/${nodeId}`);
71
+ }
72
+
73
+ function lastInfoOutcome(logs: LogLine[], nodeId: string, drilled: boolean): string {
60
74
  for (let i = logs.length - 1; i >= 0; i--) {
61
75
  const line = logs[i];
62
- if (line.nodeId === nodeId && line.level === 'info') return line.message;
76
+ if (lineIsFor(line, nodeId, drilled) && line.level === 'info') return line.message;
63
77
  }
64
78
  return '';
65
79
  }
@@ -72,10 +86,10 @@ function lastInfoOutcome(logs: LogLine[], nodeId: string): string {
72
86
  * iteration); null when the node has no receipt yet (never executed).
73
87
  */
74
88
  const RECEIPT_RE = /^#(\d+) ▶ execute/;
75
- function receiptSeq(logs: LogLine[], nodeId: string): string | null {
89
+ function receiptSeq(logs: LogLine[], nodeId: string, drilled: boolean): string | null {
76
90
  for (let i = logs.length - 1; i >= 0; i--) {
77
91
  const line = logs[i];
78
- if (line.nodeId === nodeId && line.level === 'debug') {
92
+ if (lineIsFor(line, nodeId, drilled) && line.level === 'debug') {
79
93
  const match = RECEIPT_RE.exec(line.message);
80
94
  if (match) return match[1];
81
95
  }
@@ -152,6 +166,9 @@ export function projectRunbook(
152
166
  logs: LogLine[],
153
167
  history: RunHistoryEntry[],
154
168
  flowId: string,
169
+ /** True when projecting a drilled-in subflow child view: log lines then
170
+ * also match by `…/${nodeId}` suffix (child log ids are caller-prefixed). */
171
+ drilled = false,
155
172
  ): RunbookProjection {
156
173
  // ── steps ──
157
174
  const steps = new Map<string, StepProjection>();
@@ -160,10 +177,13 @@ export function projectRunbook(
160
177
  const status = statusOf(state);
161
178
  // A failed node's outcome line is its error, not the last info log (which
162
179
  // may be stale from a prior successful attempt or simply absent).
163
- const outcome = status === 'failed' ? state?.error || lastInfoOutcome(logs, nodeId) : lastInfoOutcome(logs, nodeId);
180
+ const outcome =
181
+ status === 'failed'
182
+ ? state?.error || lastInfoOutcome(logs, nodeId, drilled)
183
+ : lastInfoOutcome(logs, nodeId, drilled);
164
184
  const durationMs = durationOf(state);
165
185
  // `#N` receipt prefix once the node has executed; bare type name before that.
166
- const seq = receiptSeq(logs, nodeId);
186
+ const seq = receiptSeq(logs, nodeId, drilled);
167
187
  const prefix = seq ? `#${seq} ` : '';
168
188
  const ms = `${durationMs ?? 0}ms`;
169
189
  let tech: string;
@@ -40,6 +40,7 @@ export type AgentIntent =
40
40
  | { action: 'setup-auth'; environment: string; instruction: string }
41
41
  | { action: 'setup-environments'; instruction: string }
42
42
  | { action: 'scout-infrastructure'; instruction: string }
43
+ | { action: 'guided-setup'; instruction: string }
43
44
  | { action: 'cover-operation'; flowId: string; instruction: string }
44
45
  | { action: 'ask'; flowId?: string; instruction: string };
45
46
 
@@ -63,11 +64,18 @@ export async function fetchAvailableAgents(): Promise<DetectedAgent[]> {
63
64
 
64
65
  /** POST /agent — start an agent run, returns its id. */
65
66
  export async function startAgent(intent: AgentIntent, opts?: StartAgentOptions): Promise<string> {
66
- const response = await fetch(`${BASE}/agent`, {
67
- method: 'POST',
68
- headers: { 'content-type': 'application/json' },
69
- body: JSON.stringify({ intent, ...opts }),
70
- });
67
+ let response: Response;
68
+ try {
69
+ response = await fetch(`${BASE}/agent`, {
70
+ method: 'POST',
71
+ headers: { 'content-type': 'application/json' },
72
+ body: JSON.stringify({ intent, ...opts }),
73
+ });
74
+ } catch {
75
+ // fetch rejects with a bare "Failed to fetch" on network errors — say what
76
+ // that actually means here and what to do about it.
77
+ throw new Error('Could not reach the runner — check it is still running, then try again.');
78
+ }
71
79
  if (!response.ok) {
72
80
  const body = (await response.json().catch(() => ({}))) as { error?: string };
73
81
  throw new Error(body.error ?? `Failed to start agent (HTTP ${response.status})`);
@@ -84,16 +92,27 @@ export async function startAgent(intent: AgentIntent, opts?: StartAgentOptions):
84
92
  */
85
93
  export function streamAgent(agentRunId: string, onEvent: (event: AgentEvent) => void): () => void {
86
94
  const source = new EventSource(`${BASE}/agent/${agentRunId}/events`);
95
+ // Set once the server delivers the run's terminal event — from then on any
96
+ // connection-level error (the server closing the response right after) is
97
+ // expected, not a lost stream, so onerror must stay quiet.
98
+ let sawTerminal = false;
87
99
  const types: AgentEvent['type'][] = ['started', 'message', 'command', 'mcp', 'approval-request', 'done', 'error'];
88
100
  for (const type of types) {
89
101
  source.addEventListener(type, (event) => {
90
- const data = JSON.parse((event as MessageEvent).data) as AgentEvent;
102
+ // EventSource dispatches its CONNECTION errors as `error` events too —
103
+ // those carry no data, unlike the server's named `event: error` payloads.
104
+ const raw = (event as MessageEvent).data as string | undefined;
105
+ if (raw === undefined) return;
106
+ const data = JSON.parse(raw) as AgentEvent;
91
107
  onEvent(data);
92
- if (type === 'done' || type === 'error') source.close();
108
+ if (type === 'done' || type === 'error') {
109
+ sawTerminal = true;
110
+ source.close();
111
+ }
93
112
  });
94
113
  }
95
114
  source.onerror = () => {
96
- if (source.readyState === EventSource.CLOSED) {
115
+ if (!sawTerminal && source.readyState === EventSource.CLOSED) {
97
116
  onEvent({ type: 'error', text: 'Lost connection to the agent event stream' });
98
117
  }
99
118
  };
@@ -71,3 +71,15 @@ export function buildApiTree(ops: OpInput[]): ApiTreeNode[] {
71
71
  tree.sort((a, b) => a.name.localeCompare(b.name));
72
72
  return tree;
73
73
  }
74
+
75
+ /** Flatten an API tree into every API/folder path (e.g. 'default', 'billing', 'billing/charges'). */
76
+ export function flattenLocations(tree: ApiTreeNode[]): string[] {
77
+ const paths: string[] = [];
78
+ const walk = (node: ApiTreeNode, prefix: string) => {
79
+ const path = prefix ? `${prefix}/${node.name}` : node.name;
80
+ paths.push(path);
81
+ for (const folder of node.folders) walk(folder, path);
82
+ };
83
+ for (const api of tree) walk(api, '');
84
+ return paths;
85
+ }