@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.
- 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 +208 -16
- package/dist/server/nodesPayload.d.ts +14 -1
- package/dist/server/nodesPayload.js +15 -2
- package/dist/server/projectMode.js +5 -2
- package/dist/server/runRegistry.d.ts +52 -1
- package/dist/server/runRegistry.js +170 -1
- package/dist/server/sourceNav.d.ts +77 -0
- package/dist/server/sourceNav.js +503 -0
- 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/dist/src/engine/registry.d.ts +27 -2
- package/dist/src/engine/registry.js +84 -2
- package/dist/src/nodes/index.d.ts +8 -2
- package/dist/src/nodes/index.js +7 -3
- package/dist/src/nodes/login.d.ts +3 -1
- package/dist/src/nodes/login.js +2 -2
- package/package.json +28 -7
- 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 +213 -15
- package/server/nodeRun.test.ts +189 -0
- package/server/nodesPayload.test.ts +38 -0
- package/server/nodesPayload.ts +28 -3
- package/server/projectMode.ts +5 -2
- package/server/runRegistry.ts +200 -3
- package/server/setupStatus.test.ts +3 -0
- package/server/sourceNav.test.ts +382 -0
- package/server/sourceNav.ts +644 -0
- package/server/sourceNavRoute.test.ts +163 -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/Inspector.tsx +9 -26
- package/src/components/RunbookView.tsx +70 -6
- package/src/components/SettingsDialog.tsx +4 -59
- package/src/components/Sidebar.tsx +6 -26
- package/src/components/SourceNavigator.test.tsx +226 -0
- package/src/components/SourceNavigator.tsx +520 -0
- 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/engine/registry.sourceRef.test.ts +112 -0
- package/src/engine/registry.ts +103 -2
- 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/nodes/index.ts +10 -3
- package/src/nodes/login.ts +5 -2
- 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 +385 -314
- package/src/store/nodeMeta.ts +10 -2
- package/src/store/serverRunner.ts +56 -5
- package/src/store/setupClient.ts +7 -2
- package/src/store/sourceNavClient.ts +48 -0
- package/src/store/updateClient.ts +50 -0
- package/studio-dist/assets/index-BbzppDpt.js +65 -0
- package/studio-dist/assets/index-CLf6blcA.css +1 -0
- package/studio-dist/index.html +2 -2
- package/templates/skills/emberflow-basics/SKILL.md +29 -1
- package/templates/skills/emberflow-model-process/SKILL.md +92 -9
- package/templates/skills/emberflow-new-workflow/SKILL.md +8 -1
- package/templates/skills/emberflow-review-workflow/SKILL.md +64 -1
- package/studio-dist/assets/index-DNJwW-hM.css +0 -1
- package/studio-dist/assets/index-O26dKRjW.js +0 -65
package/server/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { existsSync, watch } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
2
3
|
import { homedir } from 'node:os';
|
|
3
4
|
import { dirname, join, resolve } from 'node:path';
|
|
4
5
|
import { fileURLToPath } from 'node:url';
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
mergeMocks,
|
|
10
11
|
validateFlow,
|
|
11
12
|
verifyArtifact,
|
|
13
|
+
type LogLine,
|
|
12
14
|
type ScenarioDefinition,
|
|
13
15
|
type WorkflowDefinition,
|
|
14
16
|
} from '../src/engine';
|
|
@@ -30,10 +32,12 @@ import { loadInfrastructure } from './infrastructure';
|
|
|
30
32
|
import { configPathFor, loadProjectConfig } from './projectConfig';
|
|
31
33
|
import { buildApiStore, buildRegistries, requireProjectWhenExplicit } from './projectMode';
|
|
32
34
|
import { nodesPayload } from './nodesPayload';
|
|
35
|
+
import { getSourceFile } from './sourceNav';
|
|
33
36
|
import { openBrowser } from './openBrowser';
|
|
34
37
|
import { AgentRunManager, AgentStartError } from './agents/runManager';
|
|
38
|
+
import { isGitRepo } from './agents/gitScope';
|
|
35
39
|
import { isMountablePath } from './pathGuard';
|
|
36
|
-
import {
|
|
40
|
+
import { detectAgentsCached } from './agents/detect';
|
|
37
41
|
import type { AgentEvent } from './agents/types';
|
|
38
42
|
import type { AgentIntent } from './agents/prompt';
|
|
39
43
|
import { ExpressAdapter } from './runtime/expressAdapter';
|
|
@@ -44,6 +48,7 @@ import { isPathWithin } from './pathSafety';
|
|
|
44
48
|
import { attachCredential } from './authAttach';
|
|
45
49
|
import { redactSecrets } from './redact';
|
|
46
50
|
import { runScenarioSuiteFor, ScenarioTestUsageError } from './testRunner';
|
|
51
|
+
import { checkForUpdate, isLinkedInstall, ownVersion, runNpmInstall } from './updateCheck';
|
|
47
52
|
import { diagnoseOperation } from '../src/engine/diagnostics';
|
|
48
53
|
import { seedParamDefaults } from './normalizeFlow';
|
|
49
54
|
|
|
@@ -155,11 +160,32 @@ const agentRuns = new AgentRunManager(
|
|
|
155
160
|
project ? project.root : projectDir,
|
|
156
161
|
apiStore.dir,
|
|
157
162
|
apiStore.pathOf.bind(apiStore),
|
|
158
|
-
() => nodesPayload(validationRegistry).nodes.map(({ type, label, description }) => ({ type, label, description })),
|
|
163
|
+
() => nodesPayload(validationRegistry, project ? project.root : process.cwd()).nodes.map(({ type, label, description }) => ({ type, label, description })),
|
|
159
164
|
project?.language ?? 'typescript',
|
|
160
165
|
// Fresh per run: re-read emberflow/infrastructure.json so a scout that ran
|
|
161
166
|
// earlier this session primes later prompts. Malformed → null (agent guesses).
|
|
162
167
|
() => loadInfrastructure(project ? project.root : projectDir),
|
|
168
|
+
// Runner-verified setup snapshot for guided-setup prompts: the same facts
|
|
169
|
+
// /setup-status reports, computed fresh at run start, so the agent starts
|
|
170
|
+
// from KNOWN state instead of probing for it.
|
|
171
|
+
() => {
|
|
172
|
+
const root = project ? project.root : process.cwd();
|
|
173
|
+
let envs = environmentsFile;
|
|
174
|
+
try {
|
|
175
|
+
envs = loadEnvironments(root);
|
|
176
|
+
} catch {
|
|
177
|
+
/* keep last good */
|
|
178
|
+
}
|
|
179
|
+
const ops = apiStore.list();
|
|
180
|
+
return {
|
|
181
|
+
gitRepo: isGitRepo(root),
|
|
182
|
+
skillsInstalled: skillInstalled(root, 'claude') || skillInstalled(root, 'codex'),
|
|
183
|
+
environmentsConfigured: envs.configured,
|
|
184
|
+
infrastructurePresent: readInfrastructure() !== null,
|
|
185
|
+
opCount: ops.length,
|
|
186
|
+
onlyHello: ops.length === 1 && ops[0]?.id === 'default/hello',
|
|
187
|
+
};
|
|
188
|
+
},
|
|
163
189
|
);
|
|
164
190
|
|
|
165
191
|
// Hot-reload agent-authored nodes: watch the project's config and rebuild the
|
|
@@ -228,7 +254,28 @@ api.post('/serving', (req: Request, res: Response) => {
|
|
|
228
254
|
});
|
|
229
255
|
|
|
230
256
|
api.get('/nodes', (_req, res) => {
|
|
231
|
-
res.json(nodesPayload(validationRegistry));
|
|
257
|
+
res.json(nodesPayload(validationRegistry, project ? project.root : process.cwd()));
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// Whole-file source view + symbol table for the studio's source navigator.
|
|
261
|
+
// Works in project mode AND no-project mode (root = cwd) — path guards in
|
|
262
|
+
// sourceNav (isPathWithin, node_modules, secret basenames) bound what is
|
|
263
|
+
// servable either way. 400s stay generic: the requested path is never echoed.
|
|
264
|
+
api.get('/source-file', (req: Request, res: Response) => {
|
|
265
|
+
void (async () => {
|
|
266
|
+
const path = req.query.path;
|
|
267
|
+
if (typeof path !== 'string' || path.length === 0) {
|
|
268
|
+
res.status(400).json({ error: 'Query param path is required' });
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const root = project ? project.root : process.cwd();
|
|
272
|
+
const result = await getSourceFile(root, path);
|
|
273
|
+
if (!result.ok) {
|
|
274
|
+
res.status(result.status).json({ error: result.error });
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
res.json(result.payload);
|
|
278
|
+
})();
|
|
232
279
|
});
|
|
233
280
|
|
|
234
281
|
// Validate a flow against the runner's LIVE registry (built-ins + project
|
|
@@ -717,6 +764,8 @@ api.post('/runs', async (req: Request, res: Response) => {
|
|
|
717
764
|
input: effectiveInput,
|
|
718
765
|
mockRun,
|
|
719
766
|
mocks: effectiveMocks,
|
|
767
|
+
// Step-mode runs get drill-in subflow stepping (see RunRegistry.step).
|
|
768
|
+
stepped: mode === 'step',
|
|
720
769
|
}));
|
|
721
770
|
} catch (err) {
|
|
722
771
|
res.status(400).json({ error: err instanceof Error ? err.message : String(err) });
|
|
@@ -731,23 +780,101 @@ api.post('/runs', async (req: Request, res: Response) => {
|
|
|
731
780
|
res.status(201).json({ runId });
|
|
732
781
|
});
|
|
733
782
|
|
|
783
|
+
// Run a single node in isolation (studio's "Run node" affordance). Executes the
|
|
784
|
+
// node's implementation in-process against the resolved environment's
|
|
785
|
+
// secrets/vars, honouring safe mode via resolveRunSafety, and redacts the
|
|
786
|
+
// output through the same choke point runs use. Unknown type → 404. The studio
|
|
787
|
+
// bundles no implementations, so this is the ONLY path to a lone node run.
|
|
788
|
+
api.post('/node-run', async (req: Request, res: Response) => {
|
|
789
|
+
const { type, input, config, environment, safeMode, confirm } = req.body ?? {};
|
|
790
|
+
if (typeof type !== 'string' || type.length === 0) {
|
|
791
|
+
res.status(400).json({ error: 'Body must include a node "type"' });
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
if (input !== undefined && (typeof input !== 'object' || input === null || Array.isArray(input))) {
|
|
795
|
+
res.status(400).json({ error: 'input must be a plain object' });
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
if (config !== undefined && (typeof config !== 'object' || config === null || Array.isArray(config))) {
|
|
799
|
+
res.status(400).json({ error: 'config must be a plain object' });
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
if (environment !== undefined && typeof environment !== 'string') {
|
|
803
|
+
res.status(400).json({ error: 'environment must be a string' });
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
if (safeMode !== undefined && typeof safeMode !== 'boolean') {
|
|
807
|
+
res.status(400).json({ error: 'safeMode must be a boolean' });
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
if (!runs.executionRegistry.has(type)) {
|
|
811
|
+
res.status(404).json({ error: `Unknown node type: ${type}` });
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
const environmentName = environment ?? environmentsFile.defaultEnvironment;
|
|
815
|
+
const environmentDef = environmentsFile.environments[environmentName];
|
|
816
|
+
if (!environmentDef) {
|
|
817
|
+
res.status(400).json({ error: `Unknown environment: '${environmentName}'` });
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
const safety = resolveRunSafety(environmentName, environmentDef, { safeMode, confirm });
|
|
821
|
+
if (!safety.ok) {
|
|
822
|
+
res.status(400).json({ error: safety.error });
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const { implementation } = runs.executionRegistry.get(type);
|
|
827
|
+
const runId = `node-run-${randomUUID().slice(0, 8)}`;
|
|
828
|
+
const logs: LogLine[] = [];
|
|
829
|
+
const log = (level: LogLine['level'], message: string): void => {
|
|
830
|
+
logs.push({ timestamp: new Date().toISOString(), level, runId, message });
|
|
831
|
+
};
|
|
832
|
+
const nodeInput = (input ?? {}) as Record<string, unknown>;
|
|
833
|
+
|
|
834
|
+
let result: { output?: unknown; error?: string; logs: LogLine[] };
|
|
835
|
+
try {
|
|
836
|
+
const output = await implementation({
|
|
837
|
+
input: nodeInput,
|
|
838
|
+
config: (config ?? {}) as Record<string, unknown>,
|
|
839
|
+
secrets: environmentDef.secrets,
|
|
840
|
+
vars: environmentDef.vars,
|
|
841
|
+
environment: environmentName,
|
|
842
|
+
safeMode: safety.safeMode,
|
|
843
|
+
runInput: nodeInput,
|
|
844
|
+
log,
|
|
845
|
+
// A node run in isolation has no flow context to resolve child workflows.
|
|
846
|
+
runSubflow: async () => ({
|
|
847
|
+
status: 'failed',
|
|
848
|
+
error: 'Subflow nodes cannot be run in isolation — run the whole operation instead',
|
|
849
|
+
}),
|
|
850
|
+
});
|
|
851
|
+
result = { output, logs };
|
|
852
|
+
} catch (err) {
|
|
853
|
+
result = { error: err instanceof Error ? err.message : String(err), logs };
|
|
854
|
+
}
|
|
855
|
+
// Redact secret values from the output AND captured logs before responding.
|
|
856
|
+
res.json(redactSecrets(result, environmentDef.secrets));
|
|
857
|
+
});
|
|
858
|
+
|
|
734
859
|
api.post('/runs/:id/step', async (req, res) => {
|
|
735
|
-
|
|
736
|
-
|
|
860
|
+
// Drill-aware composite step: stepping INTO a Subflow reports
|
|
861
|
+
// `entered: { workflowId, nodeId }`, completing the child reports
|
|
862
|
+
// `exited: true` (see RunRegistry.step). Plain steps report just `done`.
|
|
863
|
+
const result = await runs.step(req.params.id);
|
|
864
|
+
if (!result) {
|
|
737
865
|
res.status(404).json({ error: `Unknown run: ${req.params.id}` });
|
|
738
866
|
return;
|
|
739
867
|
}
|
|
740
|
-
|
|
741
|
-
res.json({ done: !more });
|
|
868
|
+
res.json(result);
|
|
742
869
|
});
|
|
743
870
|
|
|
744
871
|
api.post('/runs/:id/cancel', (req, res) => {
|
|
745
|
-
|
|
746
|
-
|
|
872
|
+
// Drill-aware: a stepped run drilled into subflows also cancels every
|
|
873
|
+
// stacked child and unblocks the parent executions (see RunRegistry.cancel).
|
|
874
|
+
if (!runs.cancel(req.params.id)) {
|
|
747
875
|
res.status(404).json({ error: `Unknown run: ${req.params.id}` });
|
|
748
876
|
return;
|
|
749
877
|
}
|
|
750
|
-
handle.cancel();
|
|
751
878
|
res.status(204).end();
|
|
752
879
|
});
|
|
753
880
|
|
|
@@ -773,7 +900,7 @@ api.get('/runs/:id/events', (req, res) => {
|
|
|
773
900
|
});
|
|
774
901
|
|
|
775
902
|
api.get('/agent/available', (_req: Request, res: Response) => {
|
|
776
|
-
res.json({ agents:
|
|
903
|
+
res.json({ agents: detectAgentsCached() });
|
|
777
904
|
});
|
|
778
905
|
|
|
779
906
|
// First-run onboarding aggregate for the Welcome checklist (studio's
|
|
@@ -796,7 +923,10 @@ api.get('/setup-status', (_req: Request, res: Response) => {
|
|
|
796
923
|
// A pristine project ships exactly the single `default/hello` example op.
|
|
797
924
|
const onlyHello = ops.length === 1 && ops[0]?.id === 'default/hello';
|
|
798
925
|
res.json({
|
|
799
|
-
agents:
|
|
926
|
+
agents: detectAgentsCached(),
|
|
927
|
+
// Agent features snapshot changes with git (server/agents/runManager.ts);
|
|
928
|
+
// the checklist gates them on this. Same notion gitScope's isGitRepo uses.
|
|
929
|
+
git: { repo: isGitRepo(root) },
|
|
800
930
|
environments: {
|
|
801
931
|
configured: envs.configured,
|
|
802
932
|
// The synthesized "local" fallback is "no environments yet" — report 0.
|
|
@@ -826,6 +956,53 @@ function skillInstalled(root: string, harness: 'claude' | 'codex'): boolean {
|
|
|
826
956
|
);
|
|
827
957
|
}
|
|
828
958
|
|
|
959
|
+
// Package update notifier: the studio asks once per page load; checkForUpdate
|
|
960
|
+
// caches registry hits for an hour and fails silent — an unreachable registry
|
|
961
|
+
// (or a pre-publish package) just reports "no update".
|
|
962
|
+
api.get('/update-status', async (_req: Request, res: Response) => {
|
|
963
|
+
const current = ownVersion() ?? '0.0.0';
|
|
964
|
+
const check = await checkForUpdate(current);
|
|
965
|
+
if (!check) {
|
|
966
|
+
res.json({ current, updateAvailable: false });
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
res.json(check);
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
// One-click updater: npm-installs the latest package into the consumer
|
|
973
|
+
// project. Single-flight — a second click while npm runs gets a 409, not a
|
|
974
|
+
// second npm process fighting over node_modules.
|
|
975
|
+
let updateInFlight = false;
|
|
976
|
+
api.post('/update', async (_req: Request, res: Response) => {
|
|
977
|
+
if (!project) {
|
|
978
|
+
res.status(400).json({ ok: false, error: 'not a consumer project — no project is loaded to update' });
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
if (isLinkedInstall(project.root)) {
|
|
982
|
+
res.status(400).json({
|
|
983
|
+
ok: false,
|
|
984
|
+
error:
|
|
985
|
+
'node_modules/@xdelivered/emberflow is a symlink (file:/npm-link dev setup) — refusing to npm install over it',
|
|
986
|
+
});
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
if (updateInFlight) {
|
|
990
|
+
res.status(409).json({ ok: false, error: 'update already running' });
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
updateInFlight = true;
|
|
994
|
+
try {
|
|
995
|
+
const result = await runNpmInstall(project.root);
|
|
996
|
+
if (result.ok) {
|
|
997
|
+
res.json({ ok: true, restartRequired: true });
|
|
998
|
+
} else {
|
|
999
|
+
res.status(500).json({ ok: false, error: result.error });
|
|
1000
|
+
}
|
|
1001
|
+
} finally {
|
|
1002
|
+
updateInFlight = false;
|
|
1003
|
+
}
|
|
1004
|
+
});
|
|
1005
|
+
|
|
829
1006
|
/** The /setup-status `infrastructure` field: presence + a shallow summary. */
|
|
830
1007
|
function infraStatus(): { present: boolean; scannedAt?: string; itemCount?: number } {
|
|
831
1008
|
const manifest = readInfrastructure();
|
|
@@ -855,11 +1032,12 @@ api.post('/agent', (req: Request, res: Response) => {
|
|
|
855
1032
|
intent.action !== 'setup-auth' &&
|
|
856
1033
|
intent.action !== 'setup-environments' &&
|
|
857
1034
|
intent.action !== 'scout-infrastructure' &&
|
|
1035
|
+
intent.action !== 'guided-setup' &&
|
|
858
1036
|
intent.action !== 'cover-operation' &&
|
|
859
1037
|
intent.action !== 'ask'
|
|
860
1038
|
) {
|
|
861
1039
|
res.status(400).json({
|
|
862
|
-
error: `Unsupported intent.action: ${intent.action}. Must be one of new-scenario, edit-node, edit-flow, new-operation, setup-auth, setup-environments, scout-infrastructure, cover-operation, ask.`,
|
|
1040
|
+
error: `Unsupported intent.action: ${intent.action}. Must be one of new-scenario, edit-node, edit-flow, new-operation, setup-auth, setup-environments, scout-infrastructure, guided-setup, cover-operation, ask.`,
|
|
863
1041
|
});
|
|
864
1042
|
return;
|
|
865
1043
|
}
|
|
@@ -873,7 +1051,11 @@ api.post('/agent', (req: Request, res: Response) => {
|
|
|
873
1051
|
res.status(400).json({ error: 'Body must include intent: { action: "setup-auth", environment, instruction }' });
|
|
874
1052
|
return;
|
|
875
1053
|
}
|
|
876
|
-
} else if (
|
|
1054
|
+
} else if (
|
|
1055
|
+
intent.action === 'setup-environments' ||
|
|
1056
|
+
intent.action === 'scout-infrastructure' ||
|
|
1057
|
+
intent.action === 'guided-setup'
|
|
1058
|
+
) {
|
|
877
1059
|
// instruction alone is required, already validated above; no flowId/environment needed.
|
|
878
1060
|
} else if (intent.action === 'ask') {
|
|
879
1061
|
if (intent.flowId !== undefined && typeof intent.flowId !== 'string') {
|
|
@@ -975,7 +1157,7 @@ const operationRunEnv = {
|
|
|
975
1157
|
// operation whose http.path collides with one of these would silently never
|
|
976
1158
|
// be reached (the internal route, registered first, always wins) — guarded
|
|
977
1159
|
// against below rather than left as a silent trap.
|
|
978
|
-
const RESERVED_ROOT_PATHS = ['/healthz', '/nodes', '/samples', '/environments', '/workflows', '/runs', '/agent', '/serving'];
|
|
1160
|
+
const RESERVED_ROOT_PATHS = ['/healthz', '/nodes', '/samples', '/environments', '/workflows', '/runs', '/node-run', '/agent', '/serving'];
|
|
979
1161
|
|
|
980
1162
|
function collidesWithReservedPath(path: string): boolean {
|
|
981
1163
|
return RESERVED_ROOT_PATHS.some((reserved) => path === reserved || path.startsWith(`${reserved}/`));
|
|
@@ -1097,6 +1279,22 @@ if (serveStudio) {
|
|
|
1097
1279
|
}
|
|
1098
1280
|
}
|
|
1099
1281
|
|
|
1282
|
+
// Agent CLIs and node executions must not outlive the runner: agents are
|
|
1283
|
+
// spawned detached (their own process group), so without this a killed runner
|
|
1284
|
+
// leaves an orphaned agent editing the project — and possibly writing files
|
|
1285
|
+
// long after the studio is gone (observed in the wild). Cancel everything,
|
|
1286
|
+
// then exit.
|
|
1287
|
+
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
|
1288
|
+
process.on(signal, () => {
|
|
1289
|
+
try {
|
|
1290
|
+
agentRuns.shutdown();
|
|
1291
|
+
runs.shutdown();
|
|
1292
|
+
} finally {
|
|
1293
|
+
process.exit(0);
|
|
1294
|
+
}
|
|
1295
|
+
});
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1100
1298
|
app.listen(PORT, HOST, () => {
|
|
1101
1299
|
console.log(`[emberflow-runner] listening on http://${HOST}:${PORT} — ${runs.nodeCount} nodes registered`);
|
|
1102
1300
|
if (serveStudio) {
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
2
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
3
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
// Verifies POST /node-run: the studio's "Run node" affordance runs a single
|
|
8
|
+
// node in-process on the runner (execution moved server-side; the studio bundles
|
|
9
|
+
// no node implementations). Boots the actual runner subprocess against a scratch
|
|
10
|
+
// project dir, mirroring runsAuth.test.ts / apiMount.test.ts.
|
|
11
|
+
|
|
12
|
+
let proc: ChildProcess;
|
|
13
|
+
const PORT = 8155;
|
|
14
|
+
const base = `http://127.0.0.1:${PORT}`;
|
|
15
|
+
let projectDir: string;
|
|
16
|
+
|
|
17
|
+
async function waitHealthy(url: string, tries = 40): Promise<void> {
|
|
18
|
+
for (let i = 0; i < tries; i++) {
|
|
19
|
+
try {
|
|
20
|
+
if ((await fetch(url)).ok) return;
|
|
21
|
+
} catch {
|
|
22
|
+
/* not up yet */
|
|
23
|
+
}
|
|
24
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
25
|
+
}
|
|
26
|
+
throw new Error('runner did not become healthy');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function bootRunner(port: number, env: Record<string, string | undefined>): ChildProcess {
|
|
30
|
+
return spawn('npx', ['tsx', 'server/index.ts'], {
|
|
31
|
+
env: { ...process.env, EMBERFLOW_RUNNER_PORT: String(port), ...env },
|
|
32
|
+
stdio: 'ignore',
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function nodeRun(body: unknown): Promise<{ status: number; json: any }> {
|
|
37
|
+
const res = await fetch(`${base}/api/node-run`, {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers: { 'Content-Type': 'application/json' },
|
|
40
|
+
body: JSON.stringify(body),
|
|
41
|
+
});
|
|
42
|
+
return { status: res.status, json: await res.json().catch(() => undefined) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Fixture secret value — looks like a real credential to redaction logic
|
|
46
|
+
// (>= 6 chars) but is clearly a test fixture, never a live secret.
|
|
47
|
+
const FIXTURE_SECRET = 'fixture-secret-123';
|
|
48
|
+
|
|
49
|
+
beforeAll(async () => {
|
|
50
|
+
projectDir = mkdtempSync(join(tmpdir(), 'noderun-'));
|
|
51
|
+
mkdirSync(join(projectDir, 'emberflow', 'apis', 'default'), { recursive: true });
|
|
52
|
+
writeFileSync(
|
|
53
|
+
join(projectDir, 'emberflow.config.mjs'),
|
|
54
|
+
`export default {
|
|
55
|
+
registerNodes(registry) {
|
|
56
|
+
// Embeds a secret in its output AND in a log line — exercises redaction
|
|
57
|
+
// across both response fields.
|
|
58
|
+
registry.register(
|
|
59
|
+
{ type: 'EchoSecret', label: 'Echo Secret', traceKind: 'pure', inputSchema: { fields: [] } },
|
|
60
|
+
async (ctx) => {
|
|
61
|
+
ctx.log('info', \`fetched key \${ctx.secrets.API_KEY}\`);
|
|
62
|
+
return { message: \`key is \${ctx.secrets.API_KEY}\` };
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
// Throws with the secret embedded in the error message — exercises
|
|
66
|
+
// redaction on the error path.
|
|
67
|
+
registry.register(
|
|
68
|
+
{ type: 'ThrowSecret', label: 'Throw Secret', traceKind: 'pure', inputSchema: { fields: [] } },
|
|
69
|
+
async (ctx) => {
|
|
70
|
+
throw new Error(\`boom \${ctx.secrets.API_KEY}\`);
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
// Echoes ctx.safeMode so tests can observe the resolved safety mode.
|
|
74
|
+
registry.register(
|
|
75
|
+
{ type: 'EchoSafeMode', label: 'Echo Safe Mode', traceKind: 'pure', inputSchema: { fields: [] } },
|
|
76
|
+
async (ctx) => ({ safeMode: ctx.safeMode }),
|
|
77
|
+
);
|
|
78
|
+
},
|
|
79
|
+
};\n`,
|
|
80
|
+
);
|
|
81
|
+
writeFileSync(
|
|
82
|
+
join(projectDir, 'emberflow.environments.json'),
|
|
83
|
+
JSON.stringify({
|
|
84
|
+
defaultEnvironment: 'local',
|
|
85
|
+
environments: {
|
|
86
|
+
local: { vars: {}, secrets: {} },
|
|
87
|
+
leaky: { vars: {}, secrets: { API_KEY: FIXTURE_SECRET } },
|
|
88
|
+
prod: { protected: true, vars: {}, secrets: {} },
|
|
89
|
+
},
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
proc = bootRunner(PORT, { EMBERFLOW_PROJECT: projectDir });
|
|
94
|
+
await waitHealthy(`${base}/healthz`);
|
|
95
|
+
}, 20_000);
|
|
96
|
+
|
|
97
|
+
afterAll(() => {
|
|
98
|
+
proc?.kill();
|
|
99
|
+
if (projectDir) rmSync(projectDir, { recursive: true, force: true });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('POST /node-run', () => {
|
|
103
|
+
it('runs a single built-in node in-process and returns its output + logs', async () => {
|
|
104
|
+
// CheckPlan is a pure built-in: input.user.plan -> { plan, features } + a log.
|
|
105
|
+
const { status, json } = await nodeRun({
|
|
106
|
+
type: 'CheckPlan',
|
|
107
|
+
input: { user: { plan: 'pro' } },
|
|
108
|
+
});
|
|
109
|
+
expect(status).toBe(200);
|
|
110
|
+
expect(json.output).toEqual({ plan: 'pro', features: ['sso', 'audit-log', 'priority-support'] });
|
|
111
|
+
expect(json.error).toBeUndefined();
|
|
112
|
+
expect(json.logs.some((l: { message: string }) => l.message.includes('pro'))).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('404s for an unknown node type', async () => {
|
|
116
|
+
const { status, json } = await nodeRun({ type: 'NoSuchNode', input: {} });
|
|
117
|
+
expect(status).toBe(404);
|
|
118
|
+
expect(json.error).toContain('Unknown node type');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('400s when the body omits a node type', async () => {
|
|
122
|
+
const { status } = await nodeRun({ input: {} });
|
|
123
|
+
expect(status).toBe(400);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('400s for an unknown environment', async () => {
|
|
127
|
+
const { status, json } = await nodeRun({
|
|
128
|
+
type: 'CheckPlan',
|
|
129
|
+
input: { user: {} },
|
|
130
|
+
environment: 'ghost',
|
|
131
|
+
});
|
|
132
|
+
expect(status).toBe(400);
|
|
133
|
+
expect(json.error).toContain('Unknown environment');
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('POST /node-run redacts secrets at the boundary', () => {
|
|
138
|
+
it('redacts a secret value embedded in the output and logs, never leaking the raw value', async () => {
|
|
139
|
+
const { status, json } = await nodeRun({ type: 'EchoSecret', input: {}, environment: 'leaky' });
|
|
140
|
+
expect(status).toBe(200);
|
|
141
|
+
expect(json.error).toBeUndefined();
|
|
142
|
+
|
|
143
|
+
expect(json.output.message).toContain('«secret:API_KEY»');
|
|
144
|
+
expect(json.output.message).not.toContain(FIXTURE_SECRET);
|
|
145
|
+
|
|
146
|
+
const logMessages = json.logs.map((l: { message: string }) => l.message).join('\n');
|
|
147
|
+
expect(logMessages).toContain('«secret:API_KEY»');
|
|
148
|
+
expect(logMessages).not.toContain(FIXTURE_SECRET);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('redacts a secret value embedded in a thrown error message', async () => {
|
|
152
|
+
const { status, json } = await nodeRun({ type: 'ThrowSecret', input: {}, environment: 'leaky' });
|
|
153
|
+
expect(status).toBe(200);
|
|
154
|
+
expect(json.output).toBeUndefined();
|
|
155
|
+
expect(json.error).toContain('«secret:API_KEY»');
|
|
156
|
+
expect(json.error).not.toContain(FIXTURE_SECRET);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('POST /node-run gates unsafe runs on protected environments', () => {
|
|
161
|
+
it('400s an explicit safeMode:false on a protected environment without a matching confirm', async () => {
|
|
162
|
+
const { status, json } = await nodeRun({
|
|
163
|
+
type: 'EchoSafeMode',
|
|
164
|
+
input: {},
|
|
165
|
+
environment: 'prod',
|
|
166
|
+
safeMode: false,
|
|
167
|
+
});
|
|
168
|
+
expect(status).toBe(400);
|
|
169
|
+
expect(json.error).toContain("unsafe run on protected environment 'prod' requires confirm");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('accepts safeMode:false on a protected environment with the matching confirm, and the node observes it', async () => {
|
|
173
|
+
const { status, json } = await nodeRun({
|
|
174
|
+
type: 'EchoSafeMode',
|
|
175
|
+
input: {},
|
|
176
|
+
environment: 'prod',
|
|
177
|
+
safeMode: false,
|
|
178
|
+
confirm: 'prod',
|
|
179
|
+
});
|
|
180
|
+
expect(status).toBe(200);
|
|
181
|
+
expect(json.output).toEqual({ safeMode: false });
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('defaults to safeMode:true on a protected environment when unspecified', async () => {
|
|
185
|
+
const { status, json } = await nodeRun({ type: 'EchoSafeMode', input: {}, environment: 'prod' });
|
|
186
|
+
expect(status).toBe(200);
|
|
187
|
+
expect(json.output).toEqual({ safeMode: true });
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -26,4 +26,42 @@ describe('nodesPayload', () => {
|
|
|
26
26
|
expect(round.nodes[0].type).toBe('A');
|
|
27
27
|
expect(typeof round.nodes[0].source).toBe('string');
|
|
28
28
|
});
|
|
29
|
+
|
|
30
|
+
it('adds a repo-relative sourceRef when the captured file is inside the project root', () => {
|
|
31
|
+
const r = new NodeRegistry();
|
|
32
|
+
r.register({ type: 'X', label: 'X' }, async () => ({}), {
|
|
33
|
+
sourceRef: { file: '/proj/nodes/logic.mjs', line: 12 },
|
|
34
|
+
});
|
|
35
|
+
const node = nodesPayload(r, '/proj').nodes[0];
|
|
36
|
+
expect(node.sourceRef).toEqual({ file: 'nodes/logic.mjs', line: 12 });
|
|
37
|
+
expect(node.builtin).toBeUndefined();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('flags builtin: true when the sourceRef points outside the project root', () => {
|
|
41
|
+
const r = new NodeRegistry();
|
|
42
|
+
r.register({ type: 'Y', label: 'Y' }, async () => ({}), {
|
|
43
|
+
sourceRef: { file: '/somewhere/else/pkg/node.ts', line: 3 },
|
|
44
|
+
});
|
|
45
|
+
const node = nodesPayload(r, '/proj').nodes[0];
|
|
46
|
+
expect(node.builtin).toBe(true);
|
|
47
|
+
expect(node.sourceRef).toBeUndefined();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('omits sourceRef and builtin entirely when nothing was captured', () => {
|
|
51
|
+
const r = new NodeRegistry();
|
|
52
|
+
r.register({ type: 'Z', label: 'Z' }, async () => ({}));
|
|
53
|
+
const node = nodesPayload(r, '/proj').nodes[0];
|
|
54
|
+
expect('sourceRef' in node).toBe(false);
|
|
55
|
+
expect('builtin' in node).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('preserves the source (toString) field alongside sourceRef', () => {
|
|
59
|
+
const r = new NodeRegistry();
|
|
60
|
+
r.register({ type: 'W', label: 'W' }, async () => ({ shout: true }), {
|
|
61
|
+
sourceRef: { file: '/proj/w.mjs', line: 1 },
|
|
62
|
+
});
|
|
63
|
+
const node = nodesPayload(r, '/proj').nodes[0];
|
|
64
|
+
expect(node.source).toContain('shout');
|
|
65
|
+
expect(node.sourceRef).toEqual({ file: 'w.mjs', line: 1 });
|
|
66
|
+
});
|
|
29
67
|
});
|
package/server/nodesPayload.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve } from 'node:path';
|
|
1
2
|
import type { NodeDefinition, NodeRegistry } from '../src/engine';
|
|
2
3
|
|
|
3
4
|
/**
|
|
@@ -6,12 +7,27 @@ import type { NodeDefinition, NodeRegistry } from '../src/engine';
|
|
|
6
7
|
* implementation's toString() so the Inspector can show a node's code without
|
|
7
8
|
* the browser bundling the implementation — the key to consumer nodes
|
|
8
9
|
* appearing in the palette without a per-project browser build.
|
|
10
|
+
*
|
|
11
|
+
* `sourceRef` (repo-relative file + line of the register() call) is present
|
|
12
|
+
* only when registration provenance was captured AND the file lives inside
|
|
13
|
+
* the project root — the studio uses it to open the real source via
|
|
14
|
+
* GET /source-file. A captured ref OUTSIDE the root (Emberflow's own
|
|
15
|
+
* built-ins registered from the package) is flagged `builtin: true` instead:
|
|
16
|
+
* those files are never served (node_modules exposure rule), so the
|
|
17
|
+
* Inspector keeps the toString() view for them.
|
|
9
18
|
*/
|
|
10
19
|
export interface NodeMetaPayload {
|
|
11
|
-
nodes: Array<
|
|
20
|
+
nodes: Array<
|
|
21
|
+
NodeDefinition & {
|
|
22
|
+
source?: string;
|
|
23
|
+
sourceRef?: { file: string; line?: number };
|
|
24
|
+
builtin?: boolean;
|
|
25
|
+
}
|
|
26
|
+
>;
|
|
12
27
|
}
|
|
13
28
|
|
|
14
|
-
export function nodesPayload(registry: NodeRegistry): NodeMetaPayload {
|
|
29
|
+
export function nodesPayload(registry: NodeRegistry, projectRoot?: string): NodeMetaPayload {
|
|
30
|
+
const root = resolve(projectRoot ?? process.cwd());
|
|
15
31
|
return {
|
|
16
32
|
nodes: registry.list().map((definition) => {
|
|
17
33
|
let source: string | undefined;
|
|
@@ -20,7 +36,16 @@ export function nodesPayload(registry: NodeRegistry): NodeMetaPayload {
|
|
|
20
36
|
} catch {
|
|
21
37
|
source = undefined;
|
|
22
38
|
}
|
|
23
|
-
|
|
39
|
+
const ref = registry.getSourceRef(definition.type);
|
|
40
|
+
if (!ref) return { ...definition, source };
|
|
41
|
+
const rel = relative(root, resolve(root, ref.file));
|
|
42
|
+
const inside = rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
|
|
43
|
+
if (!inside) return { ...definition, source, builtin: true };
|
|
44
|
+
return {
|
|
45
|
+
...definition,
|
|
46
|
+
source,
|
|
47
|
+
sourceRef: ref.line !== undefined ? { file: rel, line: ref.line } : { file: rel },
|
|
48
|
+
};
|
|
24
49
|
}),
|
|
25
50
|
};
|
|
26
51
|
}
|
package/server/projectMode.ts
CHANGED
|
@@ -49,8 +49,11 @@ export function buildRegistries(project: ProjectConfig | null): {
|
|
|
49
49
|
validation: NodeRegistry;
|
|
50
50
|
execution: NodeRegistry;
|
|
51
51
|
} {
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
// Server-side registries capture registration provenance (file:line of each
|
|
53
|
+
// register() call) so the studio can navigate to a node's real source.
|
|
54
|
+
// Browser registries never enable this — see createDefaultRegistry.
|
|
55
|
+
const validation = createDefaultRegistry(undefined, { captureSourceRefs: true });
|
|
56
|
+
const execution = createDefaultRegistry(undefined, { captureSourceRefs: true });
|
|
54
57
|
if (project?.registerNodes) {
|
|
55
58
|
project.registerNodes(validation);
|
|
56
59
|
project.registerNodes(execution);
|