@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/dist/server/index.js
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';
|
|
@@ -17,8 +18,9 @@ import { buildApiStore, buildRegistries, requireProjectWhenExplicit } from './pr
|
|
|
17
18
|
import { nodesPayload } from './nodesPayload.js';
|
|
18
19
|
import { openBrowser } from './openBrowser.js';
|
|
19
20
|
import { AgentRunManager, AgentStartError } from './agents/runManager.js';
|
|
21
|
+
import { isGitRepo } from './agents/gitScope.js';
|
|
20
22
|
import { isMountablePath } from './pathGuard.js';
|
|
21
|
-
import {
|
|
23
|
+
import { detectAgentsCached } from './agents/detect.js';
|
|
22
24
|
import { ExpressAdapter } from './runtime/expressAdapter.js';
|
|
23
25
|
import { makeOperationHandler } from './httpOperations.js';
|
|
24
26
|
import { mockResponseFor } from './mockHandler.js';
|
|
@@ -27,6 +29,7 @@ import { isPathWithin } from './pathSafety.js';
|
|
|
27
29
|
import { attachCredential } from './authAttach.js';
|
|
28
30
|
import { redactSecrets } from './redact.js';
|
|
29
31
|
import { runScenarioSuiteFor, ScenarioTestUsageError } from './testRunner.js';
|
|
32
|
+
import { checkForUpdate, isLinkedInstall, ownVersion, runNpmInstall } from './updateCheck.js';
|
|
30
33
|
import { diagnoseOperation } from '../src/engine/diagnostics.js';
|
|
31
34
|
import { seedParamDefaults } from './normalizeFlow.js';
|
|
32
35
|
const PORT = Number(process.env.EMBERFLOW_RUNNER_PORT ?? 8092);
|
|
@@ -127,7 +130,29 @@ if (!project) {
|
|
|
127
130
|
const agentRuns = new AgentRunManager(project ? project.root : projectDir, apiStore.dir, apiStore.pathOf.bind(apiStore), () => nodesPayload(validationRegistry).nodes.map(({ type, label, description }) => ({ type, label, description })), project?.language ?? 'typescript',
|
|
128
131
|
// Fresh per run: re-read emberflow/infrastructure.json so a scout that ran
|
|
129
132
|
// earlier this session primes later prompts. Malformed → null (agent guesses).
|
|
130
|
-
() => loadInfrastructure(project ? project.root : projectDir)
|
|
133
|
+
() => loadInfrastructure(project ? project.root : projectDir),
|
|
134
|
+
// Runner-verified setup snapshot for guided-setup prompts: the same facts
|
|
135
|
+
// /setup-status reports, computed fresh at run start, so the agent starts
|
|
136
|
+
// from KNOWN state instead of probing for it.
|
|
137
|
+
() => {
|
|
138
|
+
const root = project ? project.root : process.cwd();
|
|
139
|
+
let envs = environmentsFile;
|
|
140
|
+
try {
|
|
141
|
+
envs = loadEnvironments(root);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
/* keep last good */
|
|
145
|
+
}
|
|
146
|
+
const ops = apiStore.list();
|
|
147
|
+
return {
|
|
148
|
+
gitRepo: isGitRepo(root),
|
|
149
|
+
skillsInstalled: skillInstalled(root, 'claude') || skillInstalled(root, 'codex'),
|
|
150
|
+
environmentsConfigured: envs.configured,
|
|
151
|
+
infrastructurePresent: readInfrastructure() !== null,
|
|
152
|
+
opCount: ops.length,
|
|
153
|
+
onlyHello: ops.length === 1 && ops[0]?.id === 'default/hello',
|
|
154
|
+
};
|
|
155
|
+
});
|
|
131
156
|
// Hot-reload agent-authored nodes: watch the project's config and rebuild the
|
|
132
157
|
// node registries IN-PROCESS (no process restart), so registering a node in
|
|
133
158
|
// `registerNodes` takes effect live. Critically, this survives an in-flight
|
|
@@ -660,6 +685,8 @@ api.post('/runs', async (req, res) => {
|
|
|
660
685
|
input: effectiveInput,
|
|
661
686
|
mockRun,
|
|
662
687
|
mocks: effectiveMocks,
|
|
688
|
+
// Step-mode runs get drill-in subflow stepping (see RunRegistry.step).
|
|
689
|
+
stepped: mode === 'step',
|
|
663
690
|
}));
|
|
664
691
|
}
|
|
665
692
|
catch (err) {
|
|
@@ -673,22 +700,98 @@ api.post('/runs', async (req, res) => {
|
|
|
673
700
|
}
|
|
674
701
|
res.status(201).json({ runId });
|
|
675
702
|
});
|
|
703
|
+
// Run a single node in isolation (studio's "Run node" affordance). Executes the
|
|
704
|
+
// node's implementation in-process against the resolved environment's
|
|
705
|
+
// secrets/vars, honouring safe mode via resolveRunSafety, and redacts the
|
|
706
|
+
// output through the same choke point runs use. Unknown type → 404. The studio
|
|
707
|
+
// bundles no implementations, so this is the ONLY path to a lone node run.
|
|
708
|
+
api.post('/node-run', async (req, res) => {
|
|
709
|
+
const { type, input, config, environment, safeMode, confirm } = req.body ?? {};
|
|
710
|
+
if (typeof type !== 'string' || type.length === 0) {
|
|
711
|
+
res.status(400).json({ error: 'Body must include a node "type"' });
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
if (input !== undefined && (typeof input !== 'object' || input === null || Array.isArray(input))) {
|
|
715
|
+
res.status(400).json({ error: 'input must be a plain object' });
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
if (config !== undefined && (typeof config !== 'object' || config === null || Array.isArray(config))) {
|
|
719
|
+
res.status(400).json({ error: 'config must be a plain object' });
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
if (environment !== undefined && typeof environment !== 'string') {
|
|
723
|
+
res.status(400).json({ error: 'environment must be a string' });
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
if (safeMode !== undefined && typeof safeMode !== 'boolean') {
|
|
727
|
+
res.status(400).json({ error: 'safeMode must be a boolean' });
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
if (!runs.executionRegistry.has(type)) {
|
|
731
|
+
res.status(404).json({ error: `Unknown node type: ${type}` });
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
const environmentName = environment ?? environmentsFile.defaultEnvironment;
|
|
735
|
+
const environmentDef = environmentsFile.environments[environmentName];
|
|
736
|
+
if (!environmentDef) {
|
|
737
|
+
res.status(400).json({ error: `Unknown environment: '${environmentName}'` });
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
const safety = resolveRunSafety(environmentName, environmentDef, { safeMode, confirm });
|
|
741
|
+
if (!safety.ok) {
|
|
742
|
+
res.status(400).json({ error: safety.error });
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
const { implementation } = runs.executionRegistry.get(type);
|
|
746
|
+
const runId = `node-run-${randomUUID().slice(0, 8)}`;
|
|
747
|
+
const logs = [];
|
|
748
|
+
const log = (level, message) => {
|
|
749
|
+
logs.push({ timestamp: new Date().toISOString(), level, runId, message });
|
|
750
|
+
};
|
|
751
|
+
const nodeInput = (input ?? {});
|
|
752
|
+
let result;
|
|
753
|
+
try {
|
|
754
|
+
const output = await implementation({
|
|
755
|
+
input: nodeInput,
|
|
756
|
+
config: (config ?? {}),
|
|
757
|
+
secrets: environmentDef.secrets,
|
|
758
|
+
vars: environmentDef.vars,
|
|
759
|
+
environment: environmentName,
|
|
760
|
+
safeMode: safety.safeMode,
|
|
761
|
+
runInput: nodeInput,
|
|
762
|
+
log,
|
|
763
|
+
// A node run in isolation has no flow context to resolve child workflows.
|
|
764
|
+
runSubflow: async () => ({
|
|
765
|
+
status: 'failed',
|
|
766
|
+
error: 'Subflow nodes cannot be run in isolation — run the whole operation instead',
|
|
767
|
+
}),
|
|
768
|
+
});
|
|
769
|
+
result = { output, logs };
|
|
770
|
+
}
|
|
771
|
+
catch (err) {
|
|
772
|
+
result = { error: err instanceof Error ? err.message : String(err), logs };
|
|
773
|
+
}
|
|
774
|
+
// Redact secret values from the output AND captured logs before responding.
|
|
775
|
+
res.json(redactSecrets(result, environmentDef.secrets));
|
|
776
|
+
});
|
|
676
777
|
api.post('/runs/:id/step', async (req, res) => {
|
|
677
|
-
|
|
678
|
-
|
|
778
|
+
// Drill-aware composite step: stepping INTO a Subflow reports
|
|
779
|
+
// `entered: { workflowId, nodeId }`, completing the child reports
|
|
780
|
+
// `exited: true` (see RunRegistry.step). Plain steps report just `done`.
|
|
781
|
+
const result = await runs.step(req.params.id);
|
|
782
|
+
if (!result) {
|
|
679
783
|
res.status(404).json({ error: `Unknown run: ${req.params.id}` });
|
|
680
784
|
return;
|
|
681
785
|
}
|
|
682
|
-
|
|
683
|
-
res.json({ done: !more });
|
|
786
|
+
res.json(result);
|
|
684
787
|
});
|
|
685
788
|
api.post('/runs/:id/cancel', (req, res) => {
|
|
686
|
-
|
|
687
|
-
|
|
789
|
+
// Drill-aware: a stepped run drilled into subflows also cancels every
|
|
790
|
+
// stacked child and unblocks the parent executions (see RunRegistry.cancel).
|
|
791
|
+
if (!runs.cancel(req.params.id)) {
|
|
688
792
|
res.status(404).json({ error: `Unknown run: ${req.params.id}` });
|
|
689
793
|
return;
|
|
690
794
|
}
|
|
691
|
-
handle.cancel();
|
|
692
795
|
res.status(204).end();
|
|
693
796
|
});
|
|
694
797
|
api.get('/runs/:id/events', (req, res) => {
|
|
@@ -709,7 +812,7 @@ api.get('/runs/:id/events', (req, res) => {
|
|
|
709
812
|
unsubscribe = runs.subscribe(runId, listener);
|
|
710
813
|
});
|
|
711
814
|
api.get('/agent/available', (_req, res) => {
|
|
712
|
-
res.json({ agents:
|
|
815
|
+
res.json({ agents: detectAgentsCached() });
|
|
713
816
|
});
|
|
714
817
|
// First-run onboarding aggregate for the Welcome checklist (studio's
|
|
715
818
|
// WelcomeDialog). Recomputed per request (cheap; mirrors /environments'
|
|
@@ -732,7 +835,10 @@ api.get('/setup-status', (_req, res) => {
|
|
|
732
835
|
// A pristine project ships exactly the single `default/hello` example op.
|
|
733
836
|
const onlyHello = ops.length === 1 && ops[0]?.id === 'default/hello';
|
|
734
837
|
res.json({
|
|
735
|
-
agents:
|
|
838
|
+
agents: detectAgentsCached(),
|
|
839
|
+
// Agent features snapshot changes with git (server/agents/runManager.ts);
|
|
840
|
+
// the checklist gates them on this. Same notion gitScope's isGitRepo uses.
|
|
841
|
+
git: { repo: isGitRepo(root) },
|
|
736
842
|
environments: {
|
|
737
843
|
configured: envs.configured,
|
|
738
844
|
// The synthesized "local" fallback is "no environments yet" — report 0.
|
|
@@ -758,6 +864,52 @@ api.get('/setup-status', (_req, res) => {
|
|
|
758
864
|
function skillInstalled(root, harness) {
|
|
759
865
|
return [root, homedir()].some((base) => existsSync(join(base, `.${harness}`, 'skills', 'emberflow-basics', 'SKILL.md')));
|
|
760
866
|
}
|
|
867
|
+
// Package update notifier: the studio asks once per page load; checkForUpdate
|
|
868
|
+
// caches registry hits for an hour and fails silent — an unreachable registry
|
|
869
|
+
// (or a pre-publish package) just reports "no update".
|
|
870
|
+
api.get('/update-status', async (_req, res) => {
|
|
871
|
+
const current = ownVersion() ?? '0.0.0';
|
|
872
|
+
const check = await checkForUpdate(current);
|
|
873
|
+
if (!check) {
|
|
874
|
+
res.json({ current, updateAvailable: false });
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
res.json(check);
|
|
878
|
+
});
|
|
879
|
+
// One-click updater: npm-installs the latest package into the consumer
|
|
880
|
+
// project. Single-flight — a second click while npm runs gets a 409, not a
|
|
881
|
+
// second npm process fighting over node_modules.
|
|
882
|
+
let updateInFlight = false;
|
|
883
|
+
api.post('/update', async (_req, res) => {
|
|
884
|
+
if (!project) {
|
|
885
|
+
res.status(400).json({ ok: false, error: 'not a consumer project — no project is loaded to update' });
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
if (isLinkedInstall(project.root)) {
|
|
889
|
+
res.status(400).json({
|
|
890
|
+
ok: false,
|
|
891
|
+
error: 'node_modules/@xdelivered/emberflow is a symlink (file:/npm-link dev setup) — refusing to npm install over it',
|
|
892
|
+
});
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
if (updateInFlight) {
|
|
896
|
+
res.status(409).json({ ok: false, error: 'update already running' });
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
updateInFlight = true;
|
|
900
|
+
try {
|
|
901
|
+
const result = await runNpmInstall(project.root);
|
|
902
|
+
if (result.ok) {
|
|
903
|
+
res.json({ ok: true, restartRequired: true });
|
|
904
|
+
}
|
|
905
|
+
else {
|
|
906
|
+
res.status(500).json({ ok: false, error: result.error });
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
finally {
|
|
910
|
+
updateInFlight = false;
|
|
911
|
+
}
|
|
912
|
+
});
|
|
761
913
|
/** The /setup-status `infrastructure` field: presence + a shallow summary. */
|
|
762
914
|
function infraStatus() {
|
|
763
915
|
const manifest = readInfrastructure();
|
|
@@ -786,10 +938,11 @@ api.post('/agent', (req, res) => {
|
|
|
786
938
|
intent.action !== 'setup-auth' &&
|
|
787
939
|
intent.action !== 'setup-environments' &&
|
|
788
940
|
intent.action !== 'scout-infrastructure' &&
|
|
941
|
+
intent.action !== 'guided-setup' &&
|
|
789
942
|
intent.action !== 'cover-operation' &&
|
|
790
943
|
intent.action !== 'ask') {
|
|
791
944
|
res.status(400).json({
|
|
792
|
-
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.`,
|
|
945
|
+
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.`,
|
|
793
946
|
});
|
|
794
947
|
return;
|
|
795
948
|
}
|
|
@@ -805,7 +958,9 @@ api.post('/agent', (req, res) => {
|
|
|
805
958
|
return;
|
|
806
959
|
}
|
|
807
960
|
}
|
|
808
|
-
else if (intent.action === 'setup-environments' ||
|
|
961
|
+
else if (intent.action === 'setup-environments' ||
|
|
962
|
+
intent.action === 'scout-infrastructure' ||
|
|
963
|
+
intent.action === 'guided-setup') {
|
|
809
964
|
// instruction alone is required, already validated above; no flowId/environment needed.
|
|
810
965
|
}
|
|
811
966
|
else if (intent.action === 'ask') {
|
|
@@ -899,7 +1054,7 @@ const operationRunEnv = {
|
|
|
899
1054
|
// operation whose http.path collides with one of these would silently never
|
|
900
1055
|
// be reached (the internal route, registered first, always wins) — guarded
|
|
901
1056
|
// against below rather than left as a silent trap.
|
|
902
|
-
const RESERVED_ROOT_PATHS = ['/healthz', '/nodes', '/samples', '/environments', '/workflows', '/runs', '/agent', '/serving'];
|
|
1057
|
+
const RESERVED_ROOT_PATHS = ['/healthz', '/nodes', '/samples', '/environments', '/workflows', '/runs', '/node-run', '/agent', '/serving'];
|
|
903
1058
|
function collidesWithReservedPath(path) {
|
|
904
1059
|
return RESERVED_ROOT_PATHS.some((reserved) => path === reserved || path.startsWith(`${reserved}/`));
|
|
905
1060
|
}
|
|
@@ -1007,6 +1162,22 @@ if (serveStudio) {
|
|
|
1007
1162
|
console.warn(`[runner] EMBERFLOW_SERVE_STUDIO=1 but no studio-dist found near ${here} — run \`npm run build:studio\``);
|
|
1008
1163
|
}
|
|
1009
1164
|
}
|
|
1165
|
+
// Agent CLIs and node executions must not outlive the runner: agents are
|
|
1166
|
+
// spawned detached (their own process group), so without this a killed runner
|
|
1167
|
+
// leaves an orphaned agent editing the project — and possibly writing files
|
|
1168
|
+
// long after the studio is gone (observed in the wild). Cancel everything,
|
|
1169
|
+
// then exit.
|
|
1170
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
1171
|
+
process.on(signal, () => {
|
|
1172
|
+
try {
|
|
1173
|
+
agentRuns.shutdown();
|
|
1174
|
+
runs.shutdown();
|
|
1175
|
+
}
|
|
1176
|
+
finally {
|
|
1177
|
+
process.exit(0);
|
|
1178
|
+
}
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1010
1181
|
app.listen(PORT, HOST, () => {
|
|
1011
1182
|
console.log(`[emberflow-runner] listening on http://${HOST}:${PORT} — ${runs.nodeCount} nodes registered`);
|
|
1012
1183
|
if (serveStudio) {
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import type { LogLine, NodeExecutionSample, NodeRunState, WorkflowDefinition, WorkflowRun } from '../src/engine/index.js';
|
|
2
2
|
import { InMemoryTraceSink, type FlowRun, type NodeRegistry } from '../src/engine/index.js';
|
|
3
3
|
/** SSE-shaped events, mirroring ExecutorEvents 1:1. */
|
|
4
|
-
export type RunEvent =
|
|
4
|
+
export type RunEvent =
|
|
5
|
+
/** `workflowId` is the flow the node belongs to: the root flow for the
|
|
6
|
+
* run's own nodes, the CHILD flow's id for a stepped subflow's nodes. */
|
|
7
|
+
{
|
|
5
8
|
type: 'nodeState';
|
|
9
|
+
workflowId: string;
|
|
6
10
|
nodeId: string;
|
|
7
11
|
state: NodeRunState;
|
|
8
12
|
} | {
|
|
@@ -18,6 +22,17 @@ export type RunEvent = {
|
|
|
18
22
|
};
|
|
19
23
|
};
|
|
20
24
|
type Listener = (event: RunEvent) => void;
|
|
25
|
+
/** Result of one composite step on a stepped run (see RunRegistry.step). */
|
|
26
|
+
export interface StepResult {
|
|
27
|
+
done: boolean;
|
|
28
|
+
/** Set when this step drove execution INTO a subflow child. */
|
|
29
|
+
entered?: {
|
|
30
|
+
workflowId: string;
|
|
31
|
+
nodeId: string;
|
|
32
|
+
};
|
|
33
|
+
/** Set when this step completed the deepest child and popped back out. */
|
|
34
|
+
exited?: true;
|
|
35
|
+
}
|
|
21
36
|
/** Manages live runs: buffers engine events per run and fans them out to SSE subscribers. */
|
|
22
37
|
export declare class RunRegistry {
|
|
23
38
|
private readonly loadFlow?;
|
|
@@ -62,10 +77,46 @@ export declare class RunRegistry {
|
|
|
62
77
|
* error-handler run — stamped onto this run's `finished` event as
|
|
63
78
|
* `errorHandler: { firedBy }`. Only meaningful alongside isErrorHandler. */
|
|
64
79
|
errorHandlerFiredBy?: string;
|
|
80
|
+
/** When true (a step-mode run), Subflow children become step-drivable:
|
|
81
|
+
* entering one pauses inside it instead of running it to completion in
|
|
82
|
+
* a single opaque step. Drive the run via `step(runId)`. */
|
|
83
|
+
stepped?: boolean;
|
|
65
84
|
}): {
|
|
66
85
|
runId: string;
|
|
67
86
|
handle: FlowRun;
|
|
68
87
|
};
|
|
88
|
+
/**
|
|
89
|
+
* One composite step of a run, drill-aware. For stepped runs a Subflow node
|
|
90
|
+
* is no longer one opaque step: entering it pauses inside the child
|
|
91
|
+
* (`entered`), subsequent steps drive the child's nodes, and completing the
|
|
92
|
+
* child pops back out (`exited`). Non-stepped runs (no drill state) fall
|
|
93
|
+
* back to a plain executor step. Unknown run → undefined.
|
|
94
|
+
*/
|
|
95
|
+
step(runId: string): Promise<StepResult | undefined>;
|
|
96
|
+
/**
|
|
97
|
+
* One composite step, drill-aware. Structured as a loop over levels: each
|
|
98
|
+
* pass drives the current deepest level (a pending stashed step, or a fresh
|
|
99
|
+
* executor step) while racing "a new child started". Popping a finished
|
|
100
|
+
* child continues the loop on its parent's stashed step WITH the race still
|
|
101
|
+
* armed — the parent's still-pending Subflow node execution may call
|
|
102
|
+
* runSubflow again (retry, or a custom node making sequential calls), and
|
|
103
|
+
* that re-entry must surface as `entered`, not deadlock the fold.
|
|
104
|
+
*/
|
|
105
|
+
private stepLevels;
|
|
106
|
+
/**
|
|
107
|
+
* Cancel a run, drill-aware. For a stepped run drilled into subflows this
|
|
108
|
+
* cancels every stacked child deepest-first and rejects the promise each
|
|
109
|
+
* parent's Subflow node execution is blocked on — those pending executions
|
|
110
|
+
* then unwind in the background exactly like an in-flight node after a
|
|
111
|
+
* plain root cancel today (recorded, but the run is already finished).
|
|
112
|
+
* step() after cancel reports { done: true } without executing anything.
|
|
113
|
+
* Returns false for an unknown run.
|
|
114
|
+
*/
|
|
115
|
+
/** Cancels every live (non-finished) run — the server's shutdown path, so
|
|
116
|
+
* in-flight executions (and their drilled subflow children) don't keep
|
|
117
|
+
* running headless after the process is asked to die. */
|
|
118
|
+
shutdown(): void;
|
|
119
|
+
cancel(runId: string): boolean;
|
|
69
120
|
/**
|
|
70
121
|
* Best-effort: fires `errorOperation` (resolved via the same `loadFlow`
|
|
71
122
|
* callback Subflow nodes use) as a new run when `failedRun` finished with
|
|
@@ -57,6 +57,10 @@ export class RunRegistry {
|
|
|
57
57
|
// the child via loadFlow, runs it to completion on the same registry
|
|
58
58
|
// sharing the parent's environment, and forwards its (prefixed) logs into
|
|
59
59
|
// this run's SSE stream via `onLog`.
|
|
60
|
+
// Stepped runs share ONE drill state across the root's runner and every
|
|
61
|
+
// nested child's runner (makeSubflowRunner threads `opts` through), so a
|
|
62
|
+
// grandchild pushes onto the same stack and nesting works.
|
|
63
|
+
const drillState = opts.stepped ? { stack: [] } : undefined;
|
|
60
64
|
const subflowRunner = makeSubflowRunner({
|
|
61
65
|
loadFlow: (id) => this.loadFlow?.(id),
|
|
62
66
|
registry: this.registry,
|
|
@@ -67,6 +71,19 @@ export class RunRegistry {
|
|
|
67
71
|
mockRun: opts.mockRun,
|
|
68
72
|
trace: this.sink,
|
|
69
73
|
onLog: (line) => emit({ type: 'log', line }),
|
|
74
|
+
drill: drillState,
|
|
75
|
+
// Stepped children stream node states into this run's SSE stream,
|
|
76
|
+
// tagged with the CHILD flow's id so the client can route them.
|
|
77
|
+
// After a drill-aware cancel the rejected parent executions unwind in
|
|
78
|
+
// the background (recording their Subflow nodes as failed) — child
|
|
79
|
+
// states from that unwinding must not reach subscribers post-finish.
|
|
80
|
+
onNodeState: drillState
|
|
81
|
+
? (workflowId, nodeId, state) => {
|
|
82
|
+
if (drillState.cancelled)
|
|
83
|
+
return;
|
|
84
|
+
emit({ type: 'nodeState', workflowId, nodeId, state });
|
|
85
|
+
}
|
|
86
|
+
: undefined,
|
|
70
87
|
}, [flow.id]);
|
|
71
88
|
const handle = startRun({
|
|
72
89
|
flow,
|
|
@@ -82,9 +99,14 @@ export class RunRegistry {
|
|
|
82
99
|
trace: this.sink,
|
|
83
100
|
subflowRunner,
|
|
84
101
|
events: {
|
|
85
|
-
onNodeStateChange: (nodeId, state) => emit({ type: 'nodeState', nodeId, state }),
|
|
102
|
+
onNodeStateChange: (nodeId, state) => emit({ type: 'nodeState', workflowId: flow.id, nodeId, state }),
|
|
86
103
|
onLog: (line) => emit({ type: 'log', line }),
|
|
87
104
|
onRunFinished: (run) => {
|
|
105
|
+
// Idempotent: after a drill-aware cancel the root executor's pending
|
|
106
|
+
// Subflow execution can fail in the background and re-finish the run
|
|
107
|
+
// — subscribers already saw the cancelled `finished` event.
|
|
108
|
+
if (entry.finished)
|
|
109
|
+
return;
|
|
88
110
|
emit({
|
|
89
111
|
type: 'finished',
|
|
90
112
|
run,
|
|
@@ -100,9 +122,156 @@ export class RunRegistry {
|
|
|
100
122
|
},
|
|
101
123
|
});
|
|
102
124
|
entry.handle = handle;
|
|
125
|
+
if (drillState)
|
|
126
|
+
entry.drill = { state: drillState };
|
|
103
127
|
this.runs.set(handle.run.id, entry);
|
|
104
128
|
return { runId: handle.run.id, handle };
|
|
105
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* One composite step of a run, drill-aware. For stepped runs a Subflow node
|
|
132
|
+
* is no longer one opaque step: entering it pauses inside the child
|
|
133
|
+
* (`entered`), subsequent steps drive the child's nodes, and completing the
|
|
134
|
+
* child pops back out (`exited`). Non-stepped runs (no drill state) fall
|
|
135
|
+
* back to a plain executor step. Unknown run → undefined.
|
|
136
|
+
*/
|
|
137
|
+
async step(runId) {
|
|
138
|
+
const entry = this.runs.get(runId);
|
|
139
|
+
if (!entry)
|
|
140
|
+
return undefined;
|
|
141
|
+
// Serialize per run: concurrent step() calls would each arm
|
|
142
|
+
// `state.onChildStarted` (last writer wins) and double-drive the
|
|
143
|
+
// executor, so the second caller waits for the first to settle.
|
|
144
|
+
const result = (entry.stepChain ?? Promise.resolve()).then(() => this.stepLevels(entry));
|
|
145
|
+
entry.stepChain = result.catch(() => undefined);
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* One composite step, drill-aware. Structured as a loop over levels: each
|
|
150
|
+
* pass drives the current deepest level (a pending stashed step, or a fresh
|
|
151
|
+
* executor step) while racing "a new child started". Popping a finished
|
|
152
|
+
* child continues the loop on its parent's stashed step WITH the race still
|
|
153
|
+
* armed — the parent's still-pending Subflow node execution may call
|
|
154
|
+
* runSubflow again (retry, or a custom node making sequential calls), and
|
|
155
|
+
* that re-entry must surface as `entered`, not deadlock the fold.
|
|
156
|
+
*/
|
|
157
|
+
async stepLevels(entry) {
|
|
158
|
+
const drill = entry.drill;
|
|
159
|
+
if (!drill) {
|
|
160
|
+
const more = await entry.handle.step();
|
|
161
|
+
return { done: !more };
|
|
162
|
+
}
|
|
163
|
+
const { state } = drill;
|
|
164
|
+
// Cancelled mid-drill: the stack was already unwound by cancel(); report
|
|
165
|
+
// done without driving anything.
|
|
166
|
+
if (state.cancelled)
|
|
167
|
+
return { done: true };
|
|
168
|
+
// Set once this step pops a child; carried onto whatever this step
|
|
169
|
+
// ultimately reports (including an `entered` for a re-run child).
|
|
170
|
+
let exited = false;
|
|
171
|
+
const withExit = (r) => (exited ? { ...r, exited: true } : r);
|
|
172
|
+
for (;;) {
|
|
173
|
+
// The level being stepped: the deepest drilled-in child, or the root run.
|
|
174
|
+
const level = state.stack.length > 0 ? state.stack[state.stack.length - 1] : undefined;
|
|
175
|
+
const handle = level ? level.handle : entry.handle;
|
|
176
|
+
const pendingStep = level ? level.pendingStep : drill.rootPendingStep;
|
|
177
|
+
const setPending = (p) => {
|
|
178
|
+
if (level)
|
|
179
|
+
level.pendingStep = p;
|
|
180
|
+
else
|
|
181
|
+
drill.rootPendingStep = p;
|
|
182
|
+
};
|
|
183
|
+
// A Subflow node's execution blocks inside runSubflow until its child
|
|
184
|
+
// completes, so the step promise alone can never report "entered a
|
|
185
|
+
// child" — arm the child-started signal BEFORE awaiting and race them.
|
|
186
|
+
const childStarted = new Promise((res) => {
|
|
187
|
+
state.onChildStarted = res;
|
|
188
|
+
});
|
|
189
|
+
const stepPromise = pendingStep ?? handle.step();
|
|
190
|
+
setPending(undefined);
|
|
191
|
+
let raced;
|
|
192
|
+
try {
|
|
193
|
+
raced = await Promise.race([
|
|
194
|
+
stepPromise.then((more) => ({ kind: 'stepped', more })),
|
|
195
|
+
childStarted.then((child) => ({ kind: 'entered', child })),
|
|
196
|
+
]);
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
state.onChildStarted = undefined;
|
|
200
|
+
if (!level)
|
|
201
|
+
throw err;
|
|
202
|
+
// A child level's step threw outright (not a recorded node failure —
|
|
203
|
+
// the executor catches those). Reject the parent's blocked runSubflow,
|
|
204
|
+
// which surfaces it as the Subflow node's error, then fold the parent
|
|
205
|
+
// (next loop pass, race re-armed).
|
|
206
|
+
state.stack.pop();
|
|
207
|
+
level.reject(err);
|
|
208
|
+
exited = true;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
state.onChildStarted = undefined;
|
|
212
|
+
if (raced.kind === 'entered') {
|
|
213
|
+
// The level's step stays pending for the whole child run — stash it;
|
|
214
|
+
// the step that later pops this child folds its boolean back in.
|
|
215
|
+
setPending(stepPromise);
|
|
216
|
+
return withExit({
|
|
217
|
+
done: false,
|
|
218
|
+
entered: { workflowId: raced.child.workflowId, nodeId: raced.child.viaNodeId },
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
if (raced.more)
|
|
222
|
+
return withExit({ done: false });
|
|
223
|
+
// This level has no more nodes: its run is complete.
|
|
224
|
+
if (!level)
|
|
225
|
+
return withExit({ done: true });
|
|
226
|
+
// Pop the finished child and hand its completed run (succeeded OR failed)
|
|
227
|
+
// to the parent's blocked runSubflow — the success/failure semantics live
|
|
228
|
+
// in subflowRunner, byte-identical to the non-stepped path. The parent's
|
|
229
|
+
// stashed step is folded in by the next loop pass, with the child-started
|
|
230
|
+
// race re-armed so a retrying Subflow node re-entering its child yields
|
|
231
|
+
// `exited` + `entered` instead of hanging.
|
|
232
|
+
state.stack.pop();
|
|
233
|
+
level.resolve(level.handle.run);
|
|
234
|
+
exited = true;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Cancel a run, drill-aware. For a stepped run drilled into subflows this
|
|
239
|
+
* cancels every stacked child deepest-first and rejects the promise each
|
|
240
|
+
* parent's Subflow node execution is blocked on — those pending executions
|
|
241
|
+
* then unwind in the background exactly like an in-flight node after a
|
|
242
|
+
* plain root cancel today (recorded, but the run is already finished).
|
|
243
|
+
* step() after cancel reports { done: true } without executing anything.
|
|
244
|
+
* Returns false for an unknown run.
|
|
245
|
+
*/
|
|
246
|
+
/** Cancels every live (non-finished) run — the server's shutdown path, so
|
|
247
|
+
* in-flight executions (and their drilled subflow children) don't keep
|
|
248
|
+
* running headless after the process is asked to die. */
|
|
249
|
+
shutdown() {
|
|
250
|
+
for (const [id, run] of this.runs) {
|
|
251
|
+
if (!run.finished)
|
|
252
|
+
this.cancel(id);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
cancel(runId) {
|
|
256
|
+
const entry = this.runs.get(runId);
|
|
257
|
+
if (!entry)
|
|
258
|
+
return false;
|
|
259
|
+
const drill = entry.drill;
|
|
260
|
+
if (drill) {
|
|
261
|
+
const { state } = drill;
|
|
262
|
+
state.cancelled = true;
|
|
263
|
+
state.onChildStarted = undefined;
|
|
264
|
+
while (state.stack.length > 0) {
|
|
265
|
+
const child = state.stack.pop();
|
|
266
|
+
child.pendingStep = undefined;
|
|
267
|
+
child.handle.cancel();
|
|
268
|
+
child.reject(new Error('run cancelled'));
|
|
269
|
+
}
|
|
270
|
+
drill.rootPendingStep = undefined;
|
|
271
|
+
}
|
|
272
|
+
entry.handle.cancel();
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
106
275
|
/**
|
|
107
276
|
* Best-effort: fires `errorOperation` (resolved via the same `loadFlow`
|
|
108
277
|
* callback Subflow nodes use) as a new run when `failedRun` finished with
|
|
@@ -1,6 +1,42 @@
|
|
|
1
|
-
import type { LogLine, NodeRegistry, StartRunOptions, TraceSink, WorkflowDefinition } from '../src/engine/index.js';
|
|
1
|
+
import type { FlowRun, LogLine, NodeRegistry, NodeRunState, StartRunOptions, TraceSink, WorkflowDefinition, WorkflowRun } from '../src/engine/index.js';
|
|
2
2
|
/** Max depth of nested Subflow calls (ancestry chain length) before a run fails. */
|
|
3
3
|
export declare const SUBFLOW_DEPTH_CAP = 8;
|
|
4
|
+
/**
|
|
5
|
+
* One live drilled-into child on a stepped run. Pushed by the subflow runner
|
|
6
|
+
* when a Subflow node starts its child; popped by the registry's composite
|
|
7
|
+
* step() when the child's own stepping completes. `resolve`/`reject` settle
|
|
8
|
+
* the promise the parent's Subflow node execution is blocked on.
|
|
9
|
+
*/
|
|
10
|
+
export interface DrillEntry {
|
|
11
|
+
handle: FlowRun;
|
|
12
|
+
/** The child flow's id (what the caller drilled into). */
|
|
13
|
+
workflowId: string;
|
|
14
|
+
/** The parent Subflow node that triggered the child. */
|
|
15
|
+
viaNodeId: string;
|
|
16
|
+
/** Hands the completed child run back to the blocked parent Subflow node. */
|
|
17
|
+
resolve: (run: WorkflowRun) => void;
|
|
18
|
+
reject: (err: unknown) => void;
|
|
19
|
+
/**
|
|
20
|
+
* This level's own executor.step() promise, left pending while a DEEPER
|
|
21
|
+
* child runs (a Subflow node's execution blocks on its child). Stashed by
|
|
22
|
+
* the registry when it observes `entered`; folded back in on exit.
|
|
23
|
+
*/
|
|
24
|
+
pendingStep?: Promise<boolean>;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Per-root-run drill state for stepped runs. The root's subflow runner and
|
|
28
|
+
* every nested child's runner share the SAME instance, so grandchildren push
|
|
29
|
+
* onto the same stack and nesting Just Works.
|
|
30
|
+
*/
|
|
31
|
+
export interface DrillState {
|
|
32
|
+
stack: DrillEntry[];
|
|
33
|
+
/** Armed by the registry's step() while it awaits a level's step, so a
|
|
34
|
+
* child starting can be raced against the (now blocked) step promise. */
|
|
35
|
+
onChildStarted?: (entry: DrillEntry) => void;
|
|
36
|
+
/** Set by RunRegistry.cancel(): unwinding parent executions must not start
|
|
37
|
+
* (or block on) new children — runSubflow fails fast instead. */
|
|
38
|
+
cancelled?: boolean;
|
|
39
|
+
}
|
|
4
40
|
export interface SubflowRunnerOptions {
|
|
5
41
|
/** Resolves a child workflow by id (server hosts this via apiStore.load). */
|
|
6
42
|
loadFlow: (id: string) => WorkflowDefinition | undefined;
|
|
@@ -19,6 +55,17 @@ export interface SubflowRunnerOptions {
|
|
|
19
55
|
* prefixed with the child flow's name and the caller node's id, exactly as
|
|
20
56
|
* the live runner streams them over SSE. Absent for headless runs. */
|
|
21
57
|
onLog?: (line: LogLine) => void;
|
|
58
|
+
/**
|
|
59
|
+
* Present only for stepped runs: children become step-drivable instead of
|
|
60
|
+
* running to completion. The runner pushes each started child onto
|
|
61
|
+
* `drill.stack` and blocks the Subflow node until the registry pops it.
|
|
62
|
+
*/
|
|
63
|
+
drill?: DrillState;
|
|
64
|
+
/** Child nodeState forwarder for stepped runs — receives the CHILD flow's
|
|
65
|
+
* id so the client can route states to the drilled-in view. Only wired
|
|
66
|
+
* when `drill` is present (non-stepped children keep today's behavior:
|
|
67
|
+
* their node states are discarded, only the output returns). */
|
|
68
|
+
onNodeState?: (workflowId: string, nodeId: string, state: NodeRunState) => void;
|
|
22
69
|
}
|
|
23
70
|
/**
|
|
24
71
|
* Shared Subflow-runner factory used by BOTH the live runner
|