@jarenjs/studio 0.83.3 → 0.85.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/README.md +21 -6
- package/contracts/data.contract.json +243 -0
- package/dist/types/component/data/actions.d.ts +234 -0
- package/dist/types/component/data/index.d.ts +9 -0
- package/dist/types/component/data/mount.d.ts +87 -0
- package/dist/types/component/data/project-widget.d.ts +27 -0
- package/dist/types/component/data/viewmodel.d.ts +85 -0
- package/dist/types/component/data/views.d.ts +148 -0
- package/dist/types/component/document.d.ts +82 -0
- package/dist/types/component/flow/actions.d.ts +708 -0
- package/dist/types/component/flow/index.d.ts +7 -0
- package/dist/types/component/flow/mount.d.ts +66 -0
- package/dist/types/component/flow/project-widget.d.ts +8 -0
- package/dist/types/component/flow/runtime.d.ts +57 -0
- package/dist/types/component/flow/views.d.ts +242 -0
- package/dist/types/component/host.d.ts +2 -2
- package/dist/types/component/index.d.ts +21 -15
- package/dist/types/component/mount.d.ts +51 -0
- package/dist/types/component/project-actions.d.ts +302 -0
- package/dist/types/component/project-controller.d.ts +81 -0
- package/dist/types/component/project-state.d.ts +1 -0
- package/dist/types/component/project.d.ts +327 -0
- package/dist/types/component/shared/host-widget.d.ts +24 -0
- package/dist/types/component/shared/memo.d.ts +12 -0
- package/dist/types/component/shared/nodes.d.ts +92 -0
- package/dist/types/component/shared/schema-options.d.ts +28 -0
- package/dist/types/component/shared/studio-kit.d.ts +78 -0
- package/dist/types/component/shared/ui.d.ts +136 -0
- package/dist/types/component/view.d.ts +6 -6
- package/dist/types/data/boot-stages.d.ts +97 -0
- package/dist/types/data/browser-worker.d.ts +22 -0
- package/dist/types/data/contract.d.ts +9 -0
- package/dist/types/data/editor.d.ts +81 -0
- package/dist/types/data/handlers.d.ts +130 -0
- package/dist/types/data/host.d.ts +8 -0
- package/dist/types/data/project-worker.d.ts +8 -0
- package/dist/types/data/runtime.d.ts +46 -0
- package/dist/types/data/state.d.ts +33 -0
- package/dist/types/data/storage.d.ts +26 -0
- package/dist/types/data/transport.d.ts +69 -0
- package/dist/types/flow-document.d.ts +19 -0
- package/dist/types/flow-editor.d.ts +88 -0
- package/docs/EDITORS.md +158 -0
- package/docs/PROJECT-FORMAT.md +1 -11
- package/package.json +32 -15
- package/src/component/data/actions.js +138 -0
- package/src/component/data/index.js +10 -0
- package/src/component/data/mount.js +63 -0
- package/src/component/data/project-widget.js +164 -0
- package/src/component/data/viewmodel.js +185 -0
- package/src/component/data/views.js +241 -0
- package/src/component/document.js +344 -0
- package/src/component/flow/actions.js +331 -0
- package/src/component/flow/index.js +8 -0
- package/src/component/flow/mount.js +53 -0
- package/src/component/flow/project-widget.js +44 -0
- package/src/component/flow/runtime.js +481 -0
- package/src/component/flow/views.js +196 -0
- package/src/component/host.js +2 -2
- package/src/component/index.js +19 -9
- package/src/component/mount.js +43 -0
- package/src/component/project-actions.js +189 -0
- package/src/component/project-controller.js +248 -0
- package/src/component/project-state.js +30 -0
- package/src/component/project.js +308 -0
- package/src/component/shared/host-widget.js +35 -0
- package/src/component/shared/memo.js +28 -0
- package/src/component/shared/nodes.js +94 -0
- package/src/component/shared/schema-options.js +30 -0
- package/src/component/shared/studio-kit.js +59 -0
- package/src/component/shared/ui.js +134 -0
- package/src/data/boot-stages.js +202 -0
- package/src/data/browser-worker.js +247 -0
- package/src/data/contract.js +7 -0
- package/src/data/editor.js +95 -0
- package/src/data/handlers.js +349 -0
- package/src/data/host.js +8 -0
- package/src/data/project-worker.js +26 -0
- package/src/data/runtime.js +462 -0
- package/src/data/state.js +46 -0
- package/src/data/storage.js +61 -0
- package/src/data/transport.js +215 -0
- package/src/flow-document.js +24 -0
- package/src/flow-editor.js +98 -0
- package/styles/data.css +53 -0
- package/styles/editor.css +150 -0
- package/styles/flow.css +99 -0
- package/styles/studio.css +1 -0
- package/dist/types/author.d.ts +0 -27
- package/src/author.js +0 -55
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import { validateDagDoc } from '../../flow-document.js';
|
|
2
|
+
//@ts-check
|
|
3
|
+
/**
|
|
4
|
+
* The Flow studio's JS boundary: the projection chain (flow document →
|
|
5
|
+
* mermaid text → decorated SVG vnode), the selection vocabulary, the
|
|
6
|
+
* inspector form models, and the run-mode runtime (a nested @jarenjs/app
|
|
7
|
+
* driven by fsmToApp's generated actions, and a compileDag runner with
|
|
8
|
+
* abort). The page itself stays a stylesheet; JavaScript enters here,
|
|
9
|
+
* at the derivation and registry boundaries only.
|
|
10
|
+
*
|
|
11
|
+
* One document, three faces: every gesture patches `/flow/doc`, and the
|
|
12
|
+
* diagram, the text pane and the inspector are all derived projections
|
|
13
|
+
* of it — they cannot disagree.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { compileFsm, compileDag, fsmToApp } from '@jarenjs/flow';
|
|
17
|
+
import { parseMermaid, toMermaid, diagramDocument, compileMermaid } from '@jarenjs/mermaid';
|
|
18
|
+
import { transformJson } from '@jarenjs/json/jslt';
|
|
19
|
+
import { pickAllowed } from '@jarenjs/core/array';
|
|
20
|
+
import { encodeJSONPointerSegment, decodeJSONPointerSegment } from '@jarenjs/json/pointer';
|
|
21
|
+
import { createApp } from '@jarenjs/app';
|
|
22
|
+
import { buildFormModel, buildFormViewModel } from '@jarenjs/forms';
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
import workflowToState from '@jarenjs/mermaid/stylesheets/workflow-to-state.jslt.json' with { type: 'json' };
|
|
26
|
+
import stateToWorkflow from '@jarenjs/mermaid/stylesheets/state-to-workflow.jslt.json' with { type: 'json' };
|
|
27
|
+
import dagToFlowchart from '@jarenjs/mermaid/stylesheets/dag-to-flowchart.jslt.json' with { type: 'json' };
|
|
28
|
+
import flowchartToDag from '@jarenjs/mermaid/stylesheets/flowchart-to-dag.jslt.json' with { type: 'json' };
|
|
29
|
+
|
|
30
|
+
import { memo1 } from '../shared/memo.js';
|
|
31
|
+
import { errorMessage } from '../shared/nodes.js';
|
|
32
|
+
import { createHostWidget } from '../shared/host-widget.js';
|
|
33
|
+
|
|
34
|
+
/** The phone panes, in switcher order. */
|
|
35
|
+
const FLOW_PANES = ['diagram', 'inspector', 'run'];
|
|
36
|
+
|
|
37
|
+
// ------------------------------------------------------------------
|
|
38
|
+
// The projection chain: document → mermaid text → decorated vnode
|
|
39
|
+
// ------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
/** Read a JSON Pointer from a document (the inspector's selection). */
|
|
42
|
+
export function memberAt(doc, pointer) {
|
|
43
|
+
if (pointer === '') return doc;
|
|
44
|
+
let value = doc;
|
|
45
|
+
for (const raw of pointer.split('/').slice(1)) {
|
|
46
|
+
const seg = decodeJSONPointerSegment(raw);
|
|
47
|
+
if (value === null || typeof value !== 'object') return undefined;
|
|
48
|
+
value = Array.isArray(value) ? value[Number(seg)] : value[seg];
|
|
49
|
+
}
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const fsmToText = memo1((doc) => {
|
|
54
|
+
const ast = transformJson(workflowToState, doc);
|
|
55
|
+
return toMermaid(diagramDocument('state', {}, ast, { hash: '0', direction: null, title: null }));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const dagToText = memo1((doc) => {
|
|
59
|
+
const ast = transformJson(dagToFlowchart, doc);
|
|
60
|
+
return toMermaid(diagramDocument('flowchart', {}, ast, { hash: '0', direction: 'TD', title: null }));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/** @param {'fsm'|'dag'} kind @param {any} doc @returns {string} */
|
|
64
|
+
export function flowText(kind, doc) {
|
|
65
|
+
return kind === 'fsm' ? fsmToText(doc) : dagToText(doc);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const compiledFor = memo1((text) => compileMermaid(text));
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The selection targets a click can produce, precomputed per document
|
|
72
|
+
* so the decorated vnode's bindings carry ready JSON Pointers.
|
|
73
|
+
* @param {'fsm'|'dag'} kind @param {any} doc
|
|
74
|
+
*/
|
|
75
|
+
const selectables = memo1((kind, doc) => {
|
|
76
|
+
/** @type {Record<string, { type: string, id: string, path: string }>} */
|
|
77
|
+
const nodes = {};
|
|
78
|
+
/** @type {Record<number, { type: string, index: number, path: string }>} */
|
|
79
|
+
const edges = {};
|
|
80
|
+
if (kind === 'fsm') {
|
|
81
|
+
doc.states.forEach((s, i) => {
|
|
82
|
+
const id = typeof s === 'string' ? s : s.id;
|
|
83
|
+
nodes[id] = { type: 'state', id, path: `/states/${i}` };
|
|
84
|
+
});
|
|
85
|
+
const offset = doc.initial !== null ? 1 : 0; // the [*] pseudo-edge
|
|
86
|
+
doc.transitions.forEach((t, i) => {
|
|
87
|
+
edges[i + offset] = { type: 'transition', index: i, path: `/transitions/${i}` };
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
for (const id of Object.keys(doc.nodes)) {
|
|
92
|
+
nodes[id] = { type: 'node', id, path: `/nodes/${encodeJSONPointerSegment(id)}` };
|
|
93
|
+
}
|
|
94
|
+
doc.edges.forEach((_e, i) => {
|
|
95
|
+
edges[i] = { type: 'edge', index: i, path: `/edges/${i}` };
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return { nodes, edges };
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Decorate the compiled SVG vnode: click bindings on every selectable
|
|
103
|
+
* group (by `data-id`/`data-edge`) plus the marker classes — selection,
|
|
104
|
+
* the armed connect source, the running machine's active state, the
|
|
105
|
+
* dag run's per-node status. Pure copy; the compiled vnode is shared
|
|
106
|
+
* and never mutated.
|
|
107
|
+
*/
|
|
108
|
+
function decorate(vnode, targets, marks) {
|
|
109
|
+
const walk = (node) => {
|
|
110
|
+
if (!Array.isArray(node) || typeof node[0] !== 'string') {
|
|
111
|
+
return Array.isArray(node) ? node.map(walk) : node;
|
|
112
|
+
}
|
|
113
|
+
const [tag, props, ...children] = node;
|
|
114
|
+
let nextProps = props;
|
|
115
|
+
if (props !== null && typeof props === 'object' && !Array.isArray(props)) {
|
|
116
|
+
const id = props['data-id'];
|
|
117
|
+
const edge = props['data-edge'];
|
|
118
|
+
if (typeof id === 'string' && targets.nodes[id] !== undefined) {
|
|
119
|
+
const target = targets.nodes[id];
|
|
120
|
+
let cls = `${props.class ?? ''} mm-pick`;
|
|
121
|
+
if (marks.selectedPath === target.path) cls += ' mm-selected';
|
|
122
|
+
if (marks.armed === id) cls += ' mm-armed';
|
|
123
|
+
if (marks.activeId === id) cls += ' mm-active';
|
|
124
|
+
const status = marks.nodeStatus?.[id];
|
|
125
|
+
if (status !== undefined) cls += ` mm-run-${status}`;
|
|
126
|
+
nextProps = {
|
|
127
|
+
...props, class: cls, role: 'button', tabindex: '0',
|
|
128
|
+
'aria-label': `${target.type} ${target.id ?? target.index}`,
|
|
129
|
+
on: { click: { action: 'flow/pick', with: target }, keydown: { action: 'flow/key-pick', with: target } },
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
else if (typeof edge === 'number' && targets.edges[edge] !== undefined) {
|
|
133
|
+
const target = targets.edges[edge];
|
|
134
|
+
let cls = `${props.class ?? ''} mm-pick`;
|
|
135
|
+
if (marks.selectedPath === target.path) cls += ' mm-selected';
|
|
136
|
+
// the transition just taken flows from the old state to the new
|
|
137
|
+
// one — matched by endpoints, so every parallel edge that could
|
|
138
|
+
// have fired lights up (a machine with 4 drinks → 4 glowing arcs)
|
|
139
|
+
if (marks.firedFrom != null
|
|
140
|
+
&& props['data-from'] === marks.firedFrom && props['data-to'] === marks.firedTo) {
|
|
141
|
+
cls += ' mm-fired';
|
|
142
|
+
}
|
|
143
|
+
nextProps = {
|
|
144
|
+
...props, class: cls, role: 'button', tabindex: '0',
|
|
145
|
+
'aria-label': `${target.type} ${target.id ?? target.index}`,
|
|
146
|
+
on: { click: { action: 'flow/pick', with: target }, keydown: { action: 'flow/key-pick', with: target } },
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return [tag, nextProps, ...children.map(walk)];
|
|
151
|
+
};
|
|
152
|
+
return walk(vnode);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ------------------------------------------------------------------
|
|
156
|
+
// Text round-trip honesty: which documents survive the text projection
|
|
157
|
+
// ------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The text pane is read-only when the document carries members the
|
|
161
|
+
* diagram text cannot represent — committing an edit would silently
|
|
162
|
+
* destroy them (APP-INTEGRATION honesty rule, MERMAID-FORMAT §5.1).
|
|
163
|
+
* @param {'fsm'|'dag'} kind @param {any} doc
|
|
164
|
+
* @returns {string|null} the human explanation, or null when editable
|
|
165
|
+
*/
|
|
166
|
+
export function textLossReason(kind, doc) {
|
|
167
|
+
if (kind === 'fsm') {
|
|
168
|
+
if (doc.states.some((s) => typeof s !== 'string')) {
|
|
169
|
+
return 'states carry entry/exit/final members, which diagram text cannot express';
|
|
170
|
+
}
|
|
171
|
+
if (doc.transitions.some((t) => t.guard !== undefined && t.guard !== null && typeof t.guard !== 'string')) {
|
|
172
|
+
return 'a structured guard would flatten to the […] placeholder';
|
|
173
|
+
}
|
|
174
|
+
if (doc.transitions.some((t) => Array.isArray(t.effects)
|
|
175
|
+
&& t.effects.some((e) => e.with !== undefined))) {
|
|
176
|
+
return 'an effect carries props, which the / label drops';
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
const stubOnly = Object.entries(doc.nodes).every(([id, n]) =>
|
|
181
|
+
(n.kind === 'task' && n.run === id && n.with === undefined)
|
|
182
|
+
|| n.kind === 'input' || n.kind === 'output');
|
|
183
|
+
if (!stubOnly) return 'query, stylesheet and task documents do not fit in diagram text';
|
|
184
|
+
if (doc.edges.some((e) => e.select !== undefined)) {
|
|
185
|
+
return 'edge selectors do not fit in diagram text';
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ------------------------------------------------------------------
|
|
191
|
+
// Inspector: forms-generated, zero hand-written field rendering
|
|
192
|
+
// ------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
const TRANSITION_MODEL = buildFormModel({
|
|
195
|
+
type: 'object',
|
|
196
|
+
properties: {
|
|
197
|
+
from: { type: 'string', title: 'From state' },
|
|
198
|
+
event: { type: ['string', 'null'], title: 'Event (empty = any)' },
|
|
199
|
+
guard: { title: 'Guard (query document)' },
|
|
200
|
+
to: { type: 'string', title: 'To state' },
|
|
201
|
+
effects: { title: 'Effects' },
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
const DAG_EDGE_MODEL = buildFormModel({
|
|
205
|
+
type: 'object',
|
|
206
|
+
properties: {
|
|
207
|
+
from: { type: 'string', title: 'From node' },
|
|
208
|
+
to: { type: 'string', title: 'To node' },
|
|
209
|
+
port: { type: 'string', title: 'Port' },
|
|
210
|
+
select: { title: 'Select (query document)' },
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
/** Whole-member JSON editor: string states, object states, dag nodes. */
|
|
214
|
+
const MEMBER_MODEL = buildFormModel({ title: 'Member (JSON)' });
|
|
215
|
+
|
|
216
|
+
const MODELS = {
|
|
217
|
+
transition: TRANSITION_MODEL,
|
|
218
|
+
edge: DAG_EDGE_MODEL,
|
|
219
|
+
state: MEMBER_MODEL,
|
|
220
|
+
node: MEMBER_MODEL,
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const inspectorForm = memo1((type, value) =>
|
|
224
|
+
buildFormViewModel(MODELS[type], value, { session: { idPrefix: 'flow-insp' } }));
|
|
225
|
+
|
|
226
|
+
// ------------------------------------------------------------------
|
|
227
|
+
// The page view model (called from app/viewmodel.js)
|
|
228
|
+
// ------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
const picker = memo1((templates) => ({ templates: templates.map(t => ({
|
|
231
|
+
name: t.name, title: t.title, lead: t.lead,
|
|
232
|
+
preview: JSON.stringify(t.doc, null, 1).slice(0, 220) + '…',
|
|
233
|
+
})) }));
|
|
234
|
+
const NO_TEMPLATES = [];
|
|
235
|
+
|
|
236
|
+
const fsmEvents = memo1((doc) => {
|
|
237
|
+
const names = [];
|
|
238
|
+
for (const t of doc.transitions) {
|
|
239
|
+
if (typeof t.event === 'string' && !names.includes(t.event)) names.push(t.event);
|
|
240
|
+
}
|
|
241
|
+
return names;
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const composeDiagram = memo1((kind, doc, selectedPath, armed, activeId, nodeStatus, firedFrom, firedTo) => {
|
|
245
|
+
const compiled = compiledFor(flowText(kind, doc));
|
|
246
|
+
return decorate(compiled.toVnode(), selectables(kind, doc), {
|
|
247
|
+
selectedPath, armed, activeId, nodeStatus, firedFrom, firedTo,
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* @param {any} flow - the `state.flow` slice
|
|
253
|
+
* @returns {any} the `$.ui.flow` node
|
|
254
|
+
*/
|
|
255
|
+
export function flowPageViewModel(flow, templates = NO_TEMPLATES) {
|
|
256
|
+
if (flow.doc === null) return { picker: picker(templates) };
|
|
257
|
+
const kind = flow.kind;
|
|
258
|
+
const run = flow.run;
|
|
259
|
+
const selection = flow.selection;
|
|
260
|
+
const lossReason = textLossReason(kind, flow.doc);
|
|
261
|
+
const value = selection === null ? undefined : memberAt(flow.doc, selection.path);
|
|
262
|
+
return {
|
|
263
|
+
live: {
|
|
264
|
+
kind,
|
|
265
|
+
tab: flow.tab,
|
|
266
|
+
diagram: composeDiagram(kind, flow.doc, selection?.path ?? null, flow.connect,
|
|
267
|
+
run !== null && kind === 'fsm' ? run.current : null,
|
|
268
|
+
run !== null && kind === 'dag' ? run.nodes : null,
|
|
269
|
+
// the transition just taken: old → new state, only on a real move
|
|
270
|
+
run !== null && kind === 'fsm' && run.prev != null && run.prev !== run.current ? run.prev : null,
|
|
271
|
+
run !== null && kind === 'fsm' ? run.current : null),
|
|
272
|
+
text: flowText(kind, flow.doc),
|
|
273
|
+
textReadOnly: lossReason !== null,
|
|
274
|
+
textLossReason: lossReason,
|
|
275
|
+
parseError: flow.parseError,
|
|
276
|
+
connectArmed: flow.connect !== null,
|
|
277
|
+
canConnect: selection !== null && (selection.type === 'state' || selection.type === 'node'),
|
|
278
|
+
canDelete: selection !== null,
|
|
279
|
+
canUndo: flow.history.past.length > 0,
|
|
280
|
+
canRedo: flow.history.future.length > 0,
|
|
281
|
+
isFsm: kind === 'fsm',
|
|
282
|
+
selection: selection === null || value === undefined ? null : {
|
|
283
|
+
...selection,
|
|
284
|
+
label: `${selection.type} ${selection.type === 'transition' || selection.type === 'edge'
|
|
285
|
+
? '#' + selection.index : selection.id}`,
|
|
286
|
+
},
|
|
287
|
+
inspector: selection === null || value === undefined ? null : {
|
|
288
|
+
form: inspectorForm(selection.type, value),
|
|
289
|
+
},
|
|
290
|
+
run: run === null ? null : {
|
|
291
|
+
...run,
|
|
292
|
+
revision: flow.revision,
|
|
293
|
+
logLines: run.log.map((entry) => JSON.stringify(entry)),
|
|
294
|
+
outputText: run.output === undefined || run.output === null
|
|
295
|
+
? null : JSON.stringify(run.output, null, 1),
|
|
296
|
+
},
|
|
297
|
+
events: kind === 'fsm' ? fsmEvents(flow.doc) : [],
|
|
298
|
+
dagInput: flow.dagInput,
|
|
299
|
+
// the phone pane (Diagram · Inspector · Run)
|
|
300
|
+
mobilePane: pickAllowed(flow.mobilePane, FLOW_PANES, 'diagram'),
|
|
301
|
+
mount: run !== null && kind === 'fsm'
|
|
302
|
+
? runMount(flow.doc, flow.revision, flow.runContext)
|
|
303
|
+
: null,
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const runMount = memo1((doc, revision, context) => ({ doc, revision, context }));
|
|
309
|
+
|
|
310
|
+
// ------------------------------------------------------------------
|
|
311
|
+
// The run-mode runtime: nested app widget + dag runner + parse effect
|
|
312
|
+
// ------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
/** Every effect run name a machine document can schedule. */
|
|
315
|
+
function runNames(doc) {
|
|
316
|
+
const names = new Set();
|
|
317
|
+
for (const s of doc.states) {
|
|
318
|
+
if (typeof s === 'object') {
|
|
319
|
+
for (const e of s.entry ?? []) names.add(e.run);
|
|
320
|
+
for (const e of s.exit ?? []) names.add(e.run);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
for (const t of doc.transitions) for (const e of t.effects ?? []) names.add(e.run);
|
|
324
|
+
return [...names];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// One shared normalizer (lib/nodes.js): coded errors compose their own
|
|
328
|
+
// `code: reason at path` message now, so the hand prefix this module
|
|
329
|
+
// used to add would print the code twice.
|
|
330
|
+
const message = errorMessage;
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* The Flow studio runtime: the nested-app host widget for machine runs
|
|
334
|
+
* and the effect registry (template loading, fail-closed text parsing,
|
|
335
|
+
* machine sends, dag runs with abort). One closure ref ties the
|
|
336
|
+
* running instances to their effects.
|
|
337
|
+
* @param {{ schedule?: any, template?: (name: string) => any, tasks?: Record<string, Function>, getFlow?: () => any, mintFromCount?: boolean }} [env]
|
|
338
|
+
*/
|
|
339
|
+
export function createFlowRuntime(env = {}) {
|
|
340
|
+
const ref = { app: null, controller: null, generation: 0, disposed: false };
|
|
341
|
+
|
|
342
|
+
const boot = (handle, props) => {
|
|
343
|
+
try {
|
|
344
|
+
const { slice, actions } = fsmToApp(props.doc);
|
|
345
|
+
/** @type {Record<string, any>} */
|
|
346
|
+
const effects = {
|
|
347
|
+
};
|
|
348
|
+
for (const name of runNames(props.doc)) {
|
|
349
|
+
effects[name] = (p) => handle.emit({
|
|
350
|
+
action: 'flow/run-log',
|
|
351
|
+
with: { run: name, props: p ?? null },
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
const app = createApp({
|
|
355
|
+
state: { fsm: slice, ...(props.context ?? {}) },
|
|
356
|
+
view: [{ match: '$', body: ['div', { class: 'flow-run-current' }, 'current: ', '$.fsm.current'] }],
|
|
357
|
+
actions,
|
|
358
|
+
}, {
|
|
359
|
+
node: handle.host,
|
|
360
|
+
document: handle.host.ownerDocument,
|
|
361
|
+
schedule: env.schedule,
|
|
362
|
+
effects,
|
|
363
|
+
onError: (err) => handle.emit({ action: 'flow/run-log', with: { run: 'error', props: message(err) } }),
|
|
364
|
+
});
|
|
365
|
+
app.observe((tx) => handle.emit({
|
|
366
|
+
action: 'flow/run-tx',
|
|
367
|
+
with: { action: tx.action, status: tx.status, current: app.getState().fsm.current },
|
|
368
|
+
}));
|
|
369
|
+
handle.app = app;
|
|
370
|
+
ref.app = app;
|
|
371
|
+
}
|
|
372
|
+
catch (err) {
|
|
373
|
+
handle.app = null;
|
|
374
|
+
handle.emit({ action: 'flow/run-log', with: { run: 'error', props: message(err) } });
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
const destroy = (handle) => {
|
|
378
|
+
try { handle.app?.destroy(); }
|
|
379
|
+
catch { /* teardown must never throw into the patcher */ }
|
|
380
|
+
if (ref.app === handle.app) ref.app = null;
|
|
381
|
+
handle.app = null;
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const widget = createHostWidget({ boot, destroy });
|
|
385
|
+
|
|
386
|
+
const effects = {
|
|
387
|
+
'flow-mint': (props, dispatch) => {
|
|
388
|
+
const doc = env.getFlow?.().doc;
|
|
389
|
+
if (doc == null) return;
|
|
390
|
+
const ids = props.kind === 'state'
|
|
391
|
+
? new Set((doc.states ?? []).map(s => typeof s === 'string' ? s : s.id))
|
|
392
|
+
: new Set(Object.keys(doc.nodes ?? {}));
|
|
393
|
+
const prefix = props.kind === 'state' ? 's' : 'n';
|
|
394
|
+
let n = env.mintFromCount === false ? 1 : ids.size + 1;
|
|
395
|
+
while (ids.has(prefix + n)) n++;
|
|
396
|
+
dispatch(props.kind === 'state' ? 'flow/state-minted' : 'flow/node-minted', prefix + n);
|
|
397
|
+
},
|
|
398
|
+
|
|
399
|
+
'flow-template': (props, dispatch) => {
|
|
400
|
+
const t = env.template?.(props.name);
|
|
401
|
+
if (t == null) return;
|
|
402
|
+
dispatch('flow/load', {
|
|
403
|
+
kind: t.kind, doc: t.doc,
|
|
404
|
+
runContext: t.runContext ?? null,
|
|
405
|
+
dagInput: t.runInput === undefined ? '' : JSON.stringify(t.runInput, null, 1),
|
|
406
|
+
});
|
|
407
|
+
},
|
|
408
|
+
// fail closed: a broken edit never replaces the document — the last
|
|
409
|
+
// good diagram stays live and the error strip says why
|
|
410
|
+
'flow-parse': (props, dispatch) => {
|
|
411
|
+
try {
|
|
412
|
+
const parsed = parseMermaid(props.text);
|
|
413
|
+
const expected = props.kind === 'fsm' ? 'state' : 'flowchart';
|
|
414
|
+
if (parsed.diagram !== expected) {
|
|
415
|
+
throw new Error(`expected a ${expected} diagram, got '${parsed.diagram}'`);
|
|
416
|
+
}
|
|
417
|
+
const doc = props.kind === 'fsm'
|
|
418
|
+
? transformJson(stateToWorkflow, parsed)
|
|
419
|
+
: transformJson(flowchartToDag, parsed);
|
|
420
|
+
// fsm: the projection always compiles (the superset contract).
|
|
421
|
+
// dag: a text-projected SKELETON is deliberately incomplete
|
|
422
|
+
// (all-task stubs, no output yet), so the gate is the published
|
|
423
|
+
// schema — compile problems surface at run, where they belong.
|
|
424
|
+
if (props.kind === 'fsm') compileFsm(doc);
|
|
425
|
+
else if (!validateDagDoc(doc)) throw new Error('the projected dag failed jaren-dag schema validation');
|
|
426
|
+
dispatch('flow/parsed', { doc });
|
|
427
|
+
}
|
|
428
|
+
catch (err) {
|
|
429
|
+
dispatch('flow/parse-error', { message: message(err) });
|
|
430
|
+
}
|
|
431
|
+
},
|
|
432
|
+
'flow-run-send': (props) => {
|
|
433
|
+
ref.app?.dispatch(`fsm/${props.event}`);
|
|
434
|
+
},
|
|
435
|
+
'flow-dag-run': (props, dispatch) => {
|
|
436
|
+
if (ref.disposed) return Promise.resolve({ ok: false, error: 'The Flow editor is disposed.' });
|
|
437
|
+
ref.controller?.abort();
|
|
438
|
+
const generation = ++ref.generation;
|
|
439
|
+
const send = (action, payload) => {
|
|
440
|
+
if (!ref.disposed && generation === ref.generation) dispatch(action, payload);
|
|
441
|
+
};
|
|
442
|
+
let input = null;
|
|
443
|
+
if (typeof props.inputText === 'string' && props.inputText.trim() !== '') {
|
|
444
|
+
try { input = JSON.parse(props.inputText); }
|
|
445
|
+
catch (err) {
|
|
446
|
+
const error = `the input is not JSON: ${message(err)}`;
|
|
447
|
+
send('flow/dag-fail', { message: error });
|
|
448
|
+
return Promise.resolve({ ok: false, error });
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
let dag;
|
|
452
|
+
try { dag = compileDag(props.doc, { tasks: env.tasks ?? {} }); }
|
|
453
|
+
catch (err) {
|
|
454
|
+
send('flow/dag-fail', { message: message(err) });
|
|
455
|
+
return Promise.resolve({ ok: false, error: message(err) });
|
|
456
|
+
}
|
|
457
|
+
const controller = new AbortController();
|
|
458
|
+
ref.controller = controller;
|
|
459
|
+
return dag.run(input, {
|
|
460
|
+
signal: controller.signal,
|
|
461
|
+
onNode: (rec) => send('flow/dag-node', rec),
|
|
462
|
+
}).then(
|
|
463
|
+
output => {
|
|
464
|
+
if (ref.disposed || generation !== ref.generation) return { ok: false, error: 'The run was superseded or disposed.' };
|
|
465
|
+
send('flow/dag-done', { output }); return { ok: true, output };
|
|
466
|
+
},
|
|
467
|
+
err => {
|
|
468
|
+
const error = err?.nodeId !== undefined ? `${message(err)} (node '${err.nodeId}')` : message(err);
|
|
469
|
+
send('flow/dag-fail', { message: error }); return { ok: false, error };
|
|
470
|
+
},
|
|
471
|
+
);
|
|
472
|
+
},
|
|
473
|
+
'flow-dag-abort': () => {
|
|
474
|
+
ref.controller?.abort();
|
|
475
|
+
},
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
const dispose = () => { ref.disposed = true; ref.generation++; ref.controller?.abort(); };
|
|
479
|
+
effects['flow-dag-run'].dispose = dispose;
|
|
480
|
+
return { widget, effects, dispose };
|
|
481
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* The Flow studio — mode 'flow', dispatched with `$.ui.flow` as the
|
|
4
|
+
* current node. One jaren-fsm or jaren-dag document, edited three ways
|
|
5
|
+
* that cannot disagree: clicking the diagram (every gesture an RFC 6902
|
|
6
|
+
* patch), the forms-generated inspector, and the mermaid text pane —
|
|
7
|
+
* plus a live run mode where the same document boots a nested
|
|
8
|
+
* @jarenjs/app (machines) or a compileDag run (dataflow).
|
|
9
|
+
*
|
|
10
|
+
* The stylesheet renders derived projections only; the document itself
|
|
11
|
+
* lives at `state.flow.doc` and every mutation goes through the
|
|
12
|
+
* actions' patches — no pane owns private truth.
|
|
13
|
+
*
|
|
14
|
+
* Below the breakpoint the three cards become one pane at a time behind
|
|
15
|
+
* the shared segmented switcher (Diagram · Inspector · Run); selecting
|
|
16
|
+
* a node jumps to the inspector and running jumps to the run pane, so a
|
|
17
|
+
* phone never leaves the user looking at the pane they just left.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { createFormView } from '@jarenjs/app';
|
|
21
|
+
import { paneSwitcher } from '../shared/studio-kit.js';
|
|
22
|
+
|
|
23
|
+
const paletteBtn = (label, action, opts = {}) =>
|
|
24
|
+
['button', {
|
|
25
|
+
type: 'button', class: 'btn small',
|
|
26
|
+
...(opts.disabledIf ? { disabled: { $if: [opts.disabledIf, true, false] } } : {}),
|
|
27
|
+
on: { click: action },
|
|
28
|
+
}, label];
|
|
29
|
+
|
|
30
|
+
const canvasCard =
|
|
31
|
+
['div', { key: 'flow-canvas-card', class: 'card pg-card flow-canvas-card' },
|
|
32
|
+
['div', { class: 'card-head' },
|
|
33
|
+
['h3', {}, { $if: ['$.isFsm', 'Machine', 'Dataflow'] }],
|
|
34
|
+
['div', { class: 'flow-palette' },
|
|
35
|
+
{ $if: ['$.isFsm',
|
|
36
|
+
paletteBtn('Add state', 'flow/add-state'),
|
|
37
|
+
paletteBtn('Add node', 'flow/add-node')] },
|
|
38
|
+
{ $if: ['$.connectArmed',
|
|
39
|
+
['button', { type: 'button', class: 'btn small flow-armed', on: { click: 'flow/connect-cancel' } },
|
|
40
|
+
'Click a target…'],
|
|
41
|
+
paletteBtn('Connect', 'flow/connect-arm', { disabledIf: { $not: '$.canConnect' } })] },
|
|
42
|
+
paletteBtn('Delete', 'flow/delete', { disabledIf: { $not: '$.canDelete' } }),
|
|
43
|
+
paletteBtn('Undo', 'flow/undo', { disabledIf: { $not: '$.canUndo' } }),
|
|
44
|
+
paletteBtn('Redo', 'flow/redo', { disabledIf: { $not: '$.canRedo' } }),
|
|
45
|
+
paletteBtn('New', 'flow/clear'),
|
|
46
|
+
],
|
|
47
|
+
],
|
|
48
|
+
['div', { class: 'flow-tabs' },
|
|
49
|
+
['button', {
|
|
50
|
+
type: 'button',
|
|
51
|
+
class: { $if: [{ $eq: ['$.tab', 'diagram'] }, 'btn small active', 'btn small'] },
|
|
52
|
+
on: { click: { action: 'flow/tab', with: 'diagram' } },
|
|
53
|
+
}, 'Diagram'],
|
|
54
|
+
['button', {
|
|
55
|
+
type: 'button',
|
|
56
|
+
class: { $if: [{ $eq: ['$.tab', 'text'] }, 'btn small active', 'btn small'] },
|
|
57
|
+
on: { click: { action: 'flow/tab', with: 'text' } },
|
|
58
|
+
}, 'Text'],
|
|
59
|
+
],
|
|
60
|
+
{ $if: [{ $eq: ['$.tab', 'diagram'] },
|
|
61
|
+
['div', { key: 'flow-canvas', class: 'flow-canvas' }, '$.diagram'],
|
|
62
|
+
['div', { key: 'flow-text' },
|
|
63
|
+
['textarea', {
|
|
64
|
+
class: 'editor flow-text',
|
|
65
|
+
rows: 14,
|
|
66
|
+
spellcheck: 'false',
|
|
67
|
+
value: '$.text',
|
|
68
|
+
readonly: { $if: ['$.textReadOnly', true, false] },
|
|
69
|
+
on: { change: 'flow/text' },
|
|
70
|
+
}],
|
|
71
|
+
{ $if: ['$.textReadOnly',
|
|
72
|
+
['p', { class: 'muted' },
|
|
73
|
+
'Read-only: ', '$.textLossReason',
|
|
74
|
+
' — edit through the diagram or the inspector instead.'],
|
|
75
|
+
['p', { class: 'muted' },
|
|
76
|
+
'Edits apply when the field loses focus — the text re-parses, projects back to the document, and a broken edit never replaces it.']] },
|
|
77
|
+
]] },
|
|
78
|
+
{ $if: ['$.parseError', ['p', { class: 'error-line' }, '$.parseError']] },
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
const inspectorCard =
|
|
82
|
+
['div', { key: 'flow-inspector-card', class: 'card pg-card flow-inspector' },
|
|
83
|
+
['div', { class: 'card-head' },
|
|
84
|
+
['h3', {}, 'Inspector'],
|
|
85
|
+
{ $if: ['$.selection', ['span', { class: 'muted' }, '$.selection.label']] },
|
|
86
|
+
],
|
|
87
|
+
{ $if: ['$.inspector',
|
|
88
|
+
['div', { key: 'flow-inspector-form' }, { $apply: '$.inspector.form' }],
|
|
89
|
+
['p', { class: 'muted' },
|
|
90
|
+
'Select a state, node or edge in the diagram to edit it here — every field writes straight into the document.']] },
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
const fsmRun =
|
|
94
|
+
['div', { key: 'flow-run-fsm' },
|
|
95
|
+
{ $if: ['$.run',
|
|
96
|
+
['div', {},
|
|
97
|
+
['div', { class: 'flow-run-head' },
|
|
98
|
+
['div', { class: 'flow-run-buttons' },
|
|
99
|
+
[{ $for: { e: '$.events[*]' },
|
|
100
|
+
$return: ['button', {
|
|
101
|
+
type: 'button', class: 'btn small',
|
|
102
|
+
on: { click: { action: 'flow/send', with: '$e' } },
|
|
103
|
+
}, '$e'] }],
|
|
104
|
+
],
|
|
105
|
+
['button', { type: 'button', class: 'btn small', on: { click: 'flow/stop' } }, 'Stop'],
|
|
106
|
+
],
|
|
107
|
+
['div', { class: 'flow-run-mount' },
|
|
108
|
+
['jaren-widget', { name: 'flow-doc', key: 'flow-run-app', props: '$.mount' }]],
|
|
109
|
+
['ul', { class: 'flow-log' }, [{ $for: { l: '$.run.logLines[*]' },
|
|
110
|
+
$return: ['li', { class: 'flow-log-line' }, '$l'] }]],
|
|
111
|
+
],
|
|
112
|
+
['div', {},
|
|
113
|
+
['p', { class: 'muted' },
|
|
114
|
+
'Boot the machine as a real nested @jarenjs/app: the generated per-event actions drive it, and the diagram highlights the current state.'],
|
|
115
|
+
['button', { type: 'button', class: 'btn', on: { click: 'flow/run' } }, 'Run machine'],
|
|
116
|
+
]] },
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
const dagRun =
|
|
120
|
+
['div', { key: 'flow-run-dag' },
|
|
121
|
+
['label', { class: 'muted' }, 'Run input (JSON)'],
|
|
122
|
+
// published per keystroke, not on blur: a run streams per-node updates,
|
|
123
|
+
// and each render reasserts this controlled textarea — with a stale
|
|
124
|
+
// value it would overwrite whatever was being typed for the next run
|
|
125
|
+
['textarea', {
|
|
126
|
+
class: 'editor flow-dag-input', rows: 4, spellcheck: 'false',
|
|
127
|
+
value: '$.dagInput', on: { input: 'flow/dag-input', change: 'flow/dag-input' },
|
|
128
|
+
}],
|
|
129
|
+
['div', { class: 'flow-run-head' },
|
|
130
|
+
['button', { type: 'button', class: 'btn', on: { click: 'flow/dag-run' } }, 'Run graph'],
|
|
131
|
+
{ $if: ['$.run.running',
|
|
132
|
+
['button', { type: 'button', class: 'btn small', on: { click: 'flow/dag-abort' } }, 'Abort']] },
|
|
133
|
+
],
|
|
134
|
+
{ $if: ['$.run',
|
|
135
|
+
['div', {},
|
|
136
|
+
{ $if: ['$.run.error', ['p', { class: 'error-line' }, '$.run.error']] },
|
|
137
|
+
{ $if: ['$.run.outputText',
|
|
138
|
+
['pre', { class: 'code-block flow-output' }, ['code', {}, '$.run.outputText']]] },
|
|
139
|
+
['ul', { class: 'flow-log' }, [{ $for: { l: '$.run.logLines[*]' },
|
|
140
|
+
$return: ['li', { class: 'flow-log-line' }, '$l'] }]],
|
|
141
|
+
]] },
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
const runCard =
|
|
145
|
+
['div', { key: 'flow-run-card', class: 'card pg-card flow-run' },
|
|
146
|
+
['div', { class: 'card-head' }, ['h3', {}, 'Run']],
|
|
147
|
+
{ $if: ['$.isFsm', fsmRun, dagRun] },
|
|
148
|
+
];
|
|
149
|
+
|
|
150
|
+
export const FLOW_RULES = [
|
|
151
|
+
{
|
|
152
|
+
match: '$.ui.flow', mode: 'flow',
|
|
153
|
+
body: ['div', { class: 'jaren-flow-editor' },
|
|
154
|
+
{ $apply: '$.picker' },
|
|
155
|
+
{ $apply: '$.live' },
|
|
156
|
+
],
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
match: '$.ui.flow.picker', mode: 'flow',
|
|
160
|
+
body: ['div', { key: 'flow-picker' },
|
|
161
|
+
['p', { class: 'page-lead' },
|
|
162
|
+
'Start from a seed — a complete, runnable document either way.'],
|
|
163
|
+
['div', { class: 'example-grid' }, [{ $apply: '$.templates[*]' }]],
|
|
164
|
+
],
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
match: '$.ui.flow.picker.templates[*]', mode: 'flow',
|
|
168
|
+
body: ['article', { class: 'card example-card' },
|
|
169
|
+
['div', { class: 'card-head' },
|
|
170
|
+
['h3', {}, '$.title'],
|
|
171
|
+
['button', {
|
|
172
|
+
type: 'button', class: 'btn small',
|
|
173
|
+
on: { click: { action: 'flow/template', with: '$.name' } },
|
|
174
|
+
}, 'Load'],
|
|
175
|
+
],
|
|
176
|
+
['p', { class: 'muted' }, '$.lead'],
|
|
177
|
+
['pre', { class: 'code-block clamp' }, ['code', {}, '$.preview']],
|
|
178
|
+
],
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
match: '$.ui.flow.live', mode: 'flow',
|
|
182
|
+
body: ['div', { key: 'flow-live', class: 'flow-grid', 'data-pane': '$.mobilePane' },
|
|
183
|
+
paneSwitcher({
|
|
184
|
+
class: 'flow-panebar', pane: '$.mobilePane', action: 'flow/pane',
|
|
185
|
+
panes: [['diagram', 'Diagram'], ['inspector', 'Inspector'], ['run', 'Run']],
|
|
186
|
+
}),
|
|
187
|
+
canvasCard,
|
|
188
|
+
['div', { key: 'flow-side', class: 'flow-side' }, inspectorCard, runCard],
|
|
189
|
+
],
|
|
190
|
+
},
|
|
191
|
+
];
|
|
192
|
+
|
|
193
|
+
export const FLOW_FORM_RULES = createFormView({ root: '$.ui.flow.live.inspector.form', actions: {
|
|
194
|
+
input: 'flow/f-input', check: 'flow/f-check', number: 'flow/f-number',
|
|
195
|
+
json: 'flow/f-json', add: 'flow/f-add', remove: 'flow/f-remove',
|
|
196
|
+
} }).map(rule => ({ ...rule, mode: 'flow' }));
|