@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.
- package/bin/commands.ts +7 -3
- package/bin/init.test.ts +94 -0
- package/bin/init.ts +108 -2
- package/dist/bin/commands.d.ts +1 -0
- package/dist/bin/commands.js +6 -3
- package/dist/bin/init.d.ts +4 -0
- package/dist/bin/init.js +96 -2
- package/dist/server/agents/codexParse.js +31 -0
- package/dist/server/agents/detect.d.ts +18 -8
- package/dist/server/agents/detect.js +78 -13
- package/dist/server/agents/modelRejectionHint.js +1 -1
- package/dist/server/agents/prompt.d.ts +15 -1
- package/dist/server/agents/prompt.js +59 -37
- package/dist/server/agents/runManager.d.ts +23 -2
- package/dist/server/agents/runManager.js +36 -8
- package/dist/server/client.d.ts +1 -0
- package/dist/server/client.js +7 -2
- package/dist/server/index.js +185 -14
- package/dist/server/runRegistry.d.ts +52 -1
- package/dist/server/runRegistry.js +170 -1
- package/dist/server/subflowRunner.d.ts +48 -1
- package/dist/server/subflowRunner.js +34 -7
- package/dist/server/updateCheck.d.ts +68 -0
- package/dist/server/updateCheck.js +142 -0
- package/dist/src/engine/executor.js +4 -3
- package/package.json +27 -6
- package/server/agentRoute.test.ts +20 -0
- package/server/agents/codexParse.test.ts +37 -0
- package/server/agents/codexParse.ts +34 -0
- package/server/agents/detect.test.ts +26 -7
- package/server/agents/detect.ts +82 -14
- package/server/agents/modelRejectionHint.ts +2 -1
- package/server/agents/prompt.test.ts +128 -0
- package/server/agents/prompt.ts +102 -3
- package/server/agents/runManager.test.ts +9 -0
- package/server/agents/runManager.ts +37 -7
- package/server/client.ts +10 -4
- package/server/index.ts +189 -13
- package/server/nodeRun.test.ts +189 -0
- package/server/runRegistry.ts +200 -3
- package/server/setupStatus.test.ts +3 -0
- package/server/stepDrill.test.ts +380 -0
- package/server/subflowRunner.ts +92 -11
- package/server/updateCheck.test.ts +209 -0
- package/server/updateCheck.ts +175 -0
- package/server/workflowTestRoute.test.ts +1 -1
- package/src/App.tsx +82 -2
- package/src/components/AgentConsole.tsx +7 -280
- package/src/components/AgentStream.test.tsx +88 -0
- package/src/components/AgentStream.tsx +303 -0
- package/src/components/CreateModal.tsx +43 -9
- package/src/components/Dock.tsx +1 -1
- package/src/components/EmptyState.test.tsx +49 -0
- package/src/components/EmptyState.tsx +61 -0
- package/src/components/EnvironmentDialog.tsx +4 -1
- package/src/components/EnvironmentPicker.tsx +8 -3
- package/src/components/EnvironmentsDialog.tsx +4 -1
- package/src/components/InfraPanel.tsx +30 -2
- package/src/components/InfrastructureDialog.test.tsx +97 -0
- package/src/components/InfrastructureDialog.tsx +111 -0
- package/src/components/RunbookView.tsx +70 -6
- package/src/components/SettingsDialog.tsx +4 -59
- package/src/components/Sidebar.tsx +6 -26
- package/src/components/StatusBar.tsx +227 -26
- package/src/components/Toolbar.tsx +28 -16
- package/src/components/UpdateChip.test.tsx +31 -0
- package/src/components/WelcomeDialog.test.tsx +384 -13
- package/src/components/WelcomeDialog.tsx +996 -177
- package/src/engine/executor.ts +4 -3
- package/src/lib/guidedQuestions.test.ts +224 -0
- package/src/lib/guidedQuestions.ts +181 -0
- package/src/lib/runbookModel.ts +3 -3
- package/src/lib/runbookProjection.test.ts +43 -0
- package/src/lib/runbookProjection.ts +26 -6
- package/src/store/agentClient.ts +27 -8
- package/src/store/apiTree.ts +12 -0
- package/src/store/builderStore.test.ts +441 -99
- package/src/store/builderStore.ts +383 -312
- package/src/store/serverRunner.ts +56 -5
- package/src/store/setupClient.ts +7 -2
- package/src/store/updateClient.ts +50 -0
- package/studio-dist/assets/index-CAIDjNhv.css +1 -0
- package/studio-dist/assets/index-CGwEx82J.js +65 -0
- package/studio-dist/index.html +2 -2
- package/templates/skills/emberflow-model-process/SKILL.md +82 -9
- package/templates/skills/emberflow-review-workflow/SKILL.md +37 -1
- package/studio-dist/assets/index-DNJwW-hM.css +0 -1
- package/studio-dist/assets/index-O26dKRjW.js +0 -65
package/src/engine/executor.ts
CHANGED
|
@@ -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:
|
|
64
|
-
// need
|
|
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
|
|
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
|
+
}
|
package/src/lib/runbookModel.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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;
|
package/src/store/agentClient.ts
CHANGED
|
@@ -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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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')
|
|
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
|
};
|
package/src/store/apiTree.ts
CHANGED
|
@@ -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
|
+
}
|