@reventlessdev/reventless-gwt 1.0.0-alpha.74
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/CHANGELOG.md +698 -0
- package/LICENSE +202 -0
- package/bin/reventless-dev.mjs +21 -0
- package/package.json +54 -0
- package/rescript.json +36 -0
- package/src/.gwtignore +10 -0
- package/src/Automation_GWT.res +207 -0
- package/src/Automation_GWT.res.mjs +160 -0
- package/src/Behavior_GWT.res +603 -0
- package/src/Behavior_GWT.res.mjs +426 -0
- package/src/Bind.res +29 -0
- package/src/Bind.res.mjs +22 -0
- package/src/BuildClassifier.res +137 -0
- package/src/BuildClassifier.res.mjs +136 -0
- package/src/Cancellation.res +31 -0
- package/src/Cancellation.res.mjs +45 -0
- package/src/ChildProcess.res +84 -0
- package/src/ChildProcess.res.mjs +92 -0
- package/src/Cli.res +671 -0
- package/src/Cli.res.mjs +754 -0
- package/src/Collector.res +201 -0
- package/src/Collector.res.mjs +215 -0
- package/src/ComponentMeta.res +104 -0
- package/src/ComponentMeta.res.mjs +120 -0
- package/src/ComponentScan.res +103 -0
- package/src/ComponentScan.res.mjs +105 -0
- package/src/Delegate_GWT.res +303 -0
- package/src/Delegate_GWT.res.mjs +372 -0
- package/src/Diff.res +91 -0
- package/src/Diff.res.mjs +98 -0
- package/src/Discovery.res +105 -0
- package/src/Discovery.res.mjs +103 -0
- package/src/DomainDeadCode.res +93 -0
- package/src/DomainDeadCode.res.mjs +69 -0
- package/src/DomainGraph.res +0 -0
- package/src/DomainGraph.res.mjs +0 -0
- package/src/EventMapping_GWT.res +29 -0
- package/src/EventMapping_GWT.res.mjs +44 -0
- package/src/Filter.res +36 -0
- package/src/Filter.res.mjs +40 -0
- package/src/Flow_GWT.res +664 -0
- package/src/Flow_GWT.res.mjs +604 -0
- package/src/FormatterHuman.res +138 -0
- package/src/FormatterHuman.res.mjs +122 -0
- package/src/FormatterJson.res +257 -0
- package/src/FormatterJson.res.mjs +320 -0
- package/src/FormatterJunit.res +93 -0
- package/src/FormatterJunit.res.mjs +90 -0
- package/src/FormatterTap.res +139 -0
- package/src/FormatterTap.res.mjs +149 -0
- package/src/FormatterVsCode.res +362 -0
- package/src/FormatterVsCode.res.mjs +564 -0
- package/src/Hint.res +119 -0
- package/src/Hint.res.mjs +124 -0
- package/src/InboundTranslation_GWT.res +83 -0
- package/src/InboundTranslation_GWT.res.mjs +94 -0
- package/src/JestBind.res +70 -0
- package/src/JestBind.res.mjs +63 -0
- package/src/Loader.res +24 -0
- package/src/Loader.res.mjs +23 -0
- package/src/LocalHost.res +135 -0
- package/src/LocalHost.res.mjs +127 -0
- package/src/Mapping_GWT.res +474 -0
- package/src/Mapping_GWT.res.mjs +322 -0
- package/src/MultiSourceProjection_GWT.res +316 -0
- package/src/MultiSourceProjection_GWT.res.mjs +354 -0
- package/src/OutboundTranslation_GWT.res +238 -0
- package/src/OutboundTranslation_GWT.res.mjs +186 -0
- package/src/Outcome.res +161 -0
- package/src/Outcome.res.mjs +140 -0
- package/src/PackageScan.res +80 -0
- package/src/PackageScan.res.mjs +81 -0
- package/src/PlatformRunner.res +160 -0
- package/src/PlatformRunner.res.mjs +155 -0
- package/src/PlatformScan.res +138 -0
- package/src/PlatformScan.res.mjs +131 -0
- package/src/ProcessManager.res +91 -0
- package/src/ProcessManager.res.mjs +80 -0
- package/src/Projection_GWT.res +312 -0
- package/src/Projection_GWT.res.mjs +332 -0
- package/src/Query_GWT.res +379 -0
- package/src/Query_GWT.res.mjs +367 -0
- package/src/RenderRescript.res +125 -0
- package/src/RenderRescript.res.mjs +141 -0
- package/src/RunnerTypes.res +68 -0
- package/src/RunnerTypes.res.mjs +60 -0
- package/src/SideEffect_GWT.res +157 -0
- package/src/SideEffect_GWT.res.mjs +113 -0
- package/src/StateChange/ExternalAddCategorySlice.res +25 -0
- package/src/StateChange/ExternalAddCategorySlice.res.mjs +34 -0
- package/src/StateChange/ExternalAddCategorySlice_Behavior.res +19 -0
- package/src/StateChange/ExternalAddCategorySlice_Behavior.res.mjs +46 -0
- package/src/StateChange/WithFixtures.res +22 -0
- package/src/StateChange/WithFixtures.res.mjs +31 -0
- package/src/StateChange/WithFixtures_Behavior.res +17 -0
- package/src/StateChange/WithFixtures_Behavior.res.mjs +37 -0
- package/src/StateChange/WithManualOpen.res +23 -0
- package/src/StateChange/WithManualOpen.res.mjs +31 -0
- package/src/StateChange/WithManualOpen_Behavior.res +17 -0
- package/src/StateChange/WithManualOpen_Behavior.res.mjs +37 -0
- package/src/StubRuntime.res +53 -0
- package/src/StubRuntime.res.mjs +54 -0
- package/src/TestFixtures.res +16 -0
- package/src/TestFixtures.res.mjs +34 -0
- package/src/Watch.res +92 -0
- package/src/Watch.res.mjs +83 -0
- package/src/WatcherProbe.res +48 -0
- package/src/WatcherProbe.res.mjs +46 -0
- package/tests/AutomationGwtTest.res +71 -0
- package/tests/AutomationGwtTest.res.mjs +189 -0
- package/tests/BuildClassifierTest.res +94 -0
- package/tests/BuildClassifierTest.res.mjs +132 -0
- package/tests/ComponentMetaTest.res +52 -0
- package/tests/ComponentMetaTest.res.mjs +57 -0
- package/tests/DelegateGwtTest.res +487 -0
- package/tests/DelegateGwtTest.res.mjs +891 -0
- package/tests/DiscoveryTest.res +42 -0
- package/tests/DiscoveryTest.res.mjs +43 -0
- package/tests/DomainDeadCodeTest.res +116 -0
- package/tests/DomainDeadCodeTest.res.mjs +155 -0
- package/tests/DomainGraphTest.res +365 -0
- package/tests/DomainGraphTest.res.mjs +413 -0
- package/tests/FlowAggregateGwtTest.res +169 -0
- package/tests/FlowAggregateGwtTest.res.mjs +269 -0
- package/tests/FlowCrossPluginGwtTest.res +340 -0
- package/tests/FlowCrossPluginGwtTest.res.mjs +798 -0
- package/tests/FlowGwtTest.res +222 -0
- package/tests/FlowGwtTest.res.mjs +432 -0
- package/tests/FormatterVsCodeMessageTest.res +41 -0
- package/tests/FormatterVsCodeMessageTest.res.mjs +42 -0
- package/tests/InboundTranslationGwtTest.res +34 -0
- package/tests/InboundTranslationGwtTest.res.mjs +98 -0
- package/tests/LocalHostIntegration.res +70 -0
- package/tests/LocalHostIntegration.res.mjs +64 -0
- package/tests/LocalHostTest.res +71 -0
- package/tests/LocalHostTest.res.mjs +63 -0
- package/tests/MappingGwtTest.res +348 -0
- package/tests/MappingGwtTest.res.mjs +592 -0
- package/tests/OutboundTranslationGwtTest.res +59 -0
- package/tests/OutboundTranslationGwtTest.res.mjs +138 -0
- package/tests/PlatformRunnerTest.res +66 -0
- package/tests/PlatformRunnerTest.res.mjs +85 -0
- package/tests/PlatformScanTest.res +61 -0
- package/tests/PlatformScanTest.res.mjs +56 -0
- package/tests/QueryGwtTest.res +161 -0
- package/tests/QueryGwtTest.res.mjs +314 -0
- package/tests/QueryResolverGwtTest.res +71 -0
- package/tests/QueryResolverGwtTest.res.mjs +136 -0
- package/tests/RunnerUnitTest.res +219 -0
- package/tests/RunnerUnitTest.res.mjs +244 -0
- package/tests/SideEffect/FakeOrderNotification.res +23 -0
- package/tests/SideEffect/FakeOrderNotification.res.mjs +36 -0
- package/tests/SideEffect/FakeOrderNotificationGwtTest.res +35 -0
- package/tests/SideEffect/FakeOrderNotificationGwtTest.res.mjs +73 -0
- package/tests/SideEffect/MockEmail.res +18 -0
- package/tests/SideEffect/MockEmail.res.mjs +47 -0
- package/tests/StateChange/ExternalAddCategorySlice_GWT.res +27 -0
- package/tests/StateChange/ExternalAddCategorySlice_GWT.res.mjs +70 -0
- package/tests/StateChange/WithFixtures_Fixtures.res +9 -0
- package/tests/StateChange/WithFixtures_Fixtures.res.mjs +20 -0
- package/tests/StateChange/WithFixtures_GWT.res +23 -0
- package/tests/StateChange/WithFixtures_GWT.res.mjs +56 -0
- package/tests/StateChange/WithManualOpen_Fixtures.res +7 -0
- package/tests/StateChange/WithManualOpen_Fixtures.res.mjs +20 -0
- package/tests/StateChange/WithManualOpen_GWT.res +17 -0
- package/tests/StateChange/WithManualOpen_GWT.res.mjs +56 -0
- package/tests/StateChangeSliceGwtTest.res +160 -0
- package/tests/StateChangeSliceGwtTest.res.mjs +280 -0
- package/tests/StateViewSliceGwtTest.res +64 -0
- package/tests/StateViewSliceGwtTest.res.mjs +161 -0
- package/tests/WatchDebounceTest.res +78 -0
- package/tests/WatchDebounceTest.res.mjs +97 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Local platform runner (features plan Phase 9). Spawns an app's reventless-local
|
|
2
|
+
// platform as a managed child with the LocalBus NDJSON event tap enabled, on
|
|
3
|
+
// per-session free ports, classifies its stdout into structured lifecycle/event
|
|
4
|
+
// callbacks, and tears the child down on cancellation. The CLI (`reventless-gwt
|
|
5
|
+
// platform`) wires the callbacks to FormatterVsCode; tests drive `classifyLine`
|
|
6
|
+
// directly.
|
|
7
|
+
//
|
|
8
|
+
// Channels (Spike A/B): commands in / state out go to the platform's GraphQL
|
|
9
|
+
// server (the runner just reports its endpoint); the live event stream comes from
|
|
10
|
+
// the tap on stdout — one sentinel-prefixed JSON line per published event.
|
|
11
|
+
|
|
12
|
+
let tapSentinel = "@@RVLESS_EVT@@ "
|
|
13
|
+
|
|
14
|
+
type lineClass =
|
|
15
|
+
| Domain(JSON.t) // a tap line; the parsed `{event:"domainEvent",…}` payload
|
|
16
|
+
| Ready // the Domain GraphQL server's "listening on …" line
|
|
17
|
+
| Log(string) // any other stdout/stderr line (the platform's human logs)
|
|
18
|
+
|
|
19
|
+
// Pure line classifier — the unit-tested core. ANSI escapes around bracketed
|
|
20
|
+
// component tags survive substring checks, so readiness is detected structurally.
|
|
21
|
+
// The human-log payload is passed through **with its ANSI intact**: the extension
|
|
22
|
+
// renders it in a VS Code Pseudoterminal (xterm), which interprets colour/bold so
|
|
23
|
+
// the runner log reads exactly as on the command line. (Plain non-terminal sinks
|
|
24
|
+
// that want stripped text — CloudWatch etc. — are the framework's concern; see
|
|
25
|
+
// docs/plans/logging-output-optimization.md.)
|
|
26
|
+
let classifyLine = (line: string): lineClass =>
|
|
27
|
+
if line->String.startsWith(tapSentinel) {
|
|
28
|
+
let jsonStr = line->String.slice(~start=tapSentinel->String.length, ~end=line->String.length)
|
|
29
|
+
switch JSON.parseOrThrow(jsonStr) {
|
|
30
|
+
| exception _ => Log(line)
|
|
31
|
+
| json => Domain(json)
|
|
32
|
+
}
|
|
33
|
+
} else if line->String.includes("GraphQL:Domain") && line->String.includes("listening on") {
|
|
34
|
+
Ready
|
|
35
|
+
} else {
|
|
36
|
+
Log(line)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type callbacks = {
|
|
40
|
+
onStart: (~package: string, ~dir: string, ~domainPort: int, ~platformPort: int) => unit,
|
|
41
|
+
onReady: (~domainEndpoint: string) => unit,
|
|
42
|
+
onDomainEvent: JSON.t => unit,
|
|
43
|
+
onLog: string => unit,
|
|
44
|
+
onStop: option<int> => unit,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Free-port allocation ────────────────────────────────────────────────────
|
|
48
|
+
// One platform child per app folder; default ports (4000/4001/3001/3002) collide
|
|
49
|
+
// with a developer's own `dev:full`, so the runner binds ephemeral ports and
|
|
50
|
+
// passes them to the child via the env overrides added in Phase 1.
|
|
51
|
+
type netServer
|
|
52
|
+
@module("node:net") external _createServer: unit => netServer = "createServer"
|
|
53
|
+
@send external _listen: (netServer, int, string, unit => unit) => unit = "listen"
|
|
54
|
+
@send external _address: netServer => {"port": int} = "address"
|
|
55
|
+
@send external _close: (netServer, unit => unit) => unit = "close"
|
|
56
|
+
|
|
57
|
+
let openEphemeral = (): promise<(netServer, int)> =>
|
|
58
|
+
Promise.make((resolve, _reject) => {
|
|
59
|
+
let s = _createServer()
|
|
60
|
+
s->_listen(0, "127.0.0.1", () => resolve((s, (s->_address)["port"])))
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// Hold all n open while collecting ports so they're guaranteed distinct, then
|
|
64
|
+
// release them just before the child binds.
|
|
65
|
+
let allocFreePorts = async (n: int): array<int> => {
|
|
66
|
+
let servers = []
|
|
67
|
+
let ports = []
|
|
68
|
+
for _i in 1 to n {
|
|
69
|
+
let (s, p) = await openEphemeral()
|
|
70
|
+
servers->Array.push(s)
|
|
71
|
+
ports->Array.push(p)
|
|
72
|
+
}
|
|
73
|
+
servers->Array.forEach(s => s->_close(() => ()))
|
|
74
|
+
ports
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The platform child's default ports (Platform.startServers): domain / platform /
|
|
78
|
+
// domain-MCP / platform-MCP. `~fixedPorts` pins to these instead of allocating
|
|
79
|
+
// ephemeral ones — the VS Code extension uses it when launching the host-shell UI,
|
|
80
|
+
// whose Vite proxy hardcodes localhost:4000/4001. The caller is responsible for
|
|
81
|
+
// ensuring they're free (it pre-checks and toasts on conflict).
|
|
82
|
+
let fixedPlatformPorts = [4000, 4001, 3001, 3002]
|
|
83
|
+
|
|
84
|
+
// Discover the platform package under `roots`, spawn it with the tap + per-session
|
|
85
|
+
// ports + in-memory backend, stream its events through `callbacks`, and stay alive
|
|
86
|
+
// until cancelled (the child is killed via its process group on SIGTERM/SIGINT).
|
|
87
|
+
let run = async (
|
|
88
|
+
~roots: array<string>,
|
|
89
|
+
~backend: string,
|
|
90
|
+
~fixedPorts: bool=false,
|
|
91
|
+
~callbacks: callbacks,
|
|
92
|
+
): int => {
|
|
93
|
+
let pkgs = await PlatformScan.scan(roots)
|
|
94
|
+
switch pkgs->Array.get(0) {
|
|
95
|
+
| None =>
|
|
96
|
+
callbacks.onLog(
|
|
97
|
+
"no reventless-local platform package (src/Main.res.mjs + @reventlessdev/reventless-local) found under: " ++
|
|
98
|
+
roots->Array.join(", "),
|
|
99
|
+
)
|
|
100
|
+
1
|
|
101
|
+
| Some(pkg) =>
|
|
102
|
+
let ports = fixedPorts ? fixedPlatformPorts : await allocFreePorts(4)
|
|
103
|
+
let dPort = ports->Array.getUnsafe(0)
|
|
104
|
+
let pPort = ports->Array.getUnsafe(1)
|
|
105
|
+
let dMcp = ports->Array.getUnsafe(2)
|
|
106
|
+
let pMcp = ports->Array.getUnsafe(3)
|
|
107
|
+
let env = Dict.fromArray([
|
|
108
|
+
("REVENTLESS_EVENT_TAP", "ndjson"),
|
|
109
|
+
("REVENTLESS_LOCAL_BACKEND", backend),
|
|
110
|
+
("REVENTLESS_DOMAIN_PORT", Int.toString(dPort)),
|
|
111
|
+
("REVENTLESS_PLATFORM_PORT", Int.toString(pPort)),
|
|
112
|
+
("REVENTLESS_DOMAIN_MCP_PORT", Int.toString(dMcp)),
|
|
113
|
+
("REVENTLESS_PLATFORM_MCP_PORT", Int.toString(pMcp)),
|
|
114
|
+
("NODE_OPTIONS", "--disable-warning=ExperimentalWarning"),
|
|
115
|
+
// The gwt bin defaults LOG_LEVEL=silent so its OWN stdout stays pure NDJSON;
|
|
116
|
+
// the platform child is a separate process whose stdout we parse, so it needs
|
|
117
|
+
// real logs — the "listening on" line drives readiness and the rest becomes
|
|
118
|
+
// platformLog. Override the inherited silent.
|
|
119
|
+
("LOG_LEVEL", "info"),
|
|
120
|
+
// The child's stdout is a pipe (we line-parse it), so the framework's sink
|
|
121
|
+
// detection would auto-pick JSON (non-TTY). But every consumer of `onLog`
|
|
122
|
+
// renders ANSI — the CLI forwards to the developer's terminal, the extension
|
|
123
|
+
// to an xterm Pseudoterminal — so force human text mode for bold/colour.
|
|
124
|
+
// (Tap event lines stay JSON regardless: they're sentinel-prefixed.)
|
|
125
|
+
("REVENTLESS_LOG_FORMAT", "text"),
|
|
126
|
+
])
|
|
127
|
+
|
|
128
|
+
callbacks.onStart(~package=pkg.name, ~dir=pkg.dir, ~domainPort=dPort, ~platformPort=pPort)
|
|
129
|
+
|
|
130
|
+
// Run the compiled entrypoint directly with `node`. `src/Main.res.mjs` is plain
|
|
131
|
+
// ESM, so it needs no tsx/loader, and spawning a single process (vs `pnpm run
|
|
132
|
+
// serve` → `pnpm run serve` → `npx tsx`) keeps the child's stdout piped straight
|
|
133
|
+
// to us — nested pnpm buffers a long-running grandchild's output, so the event
|
|
134
|
+
// tap never streams through it. cwd = the package dir so `PackageVersion.fromCwd`
|
|
135
|
+
// and relative paths resolve. We set the backend + tap + ports via env directly,
|
|
136
|
+
// so the package's own serve script (which would otherwise pick them) is bypassed.
|
|
137
|
+
let proc = ChildProcess.spawn("node", ["src/Main.res.mjs"], ~cwd=pkg.dir, ~env)
|
|
138
|
+
Cancellation.onCancel(() => ChildProcess.killTree(proc, "SIGTERM"))
|
|
139
|
+
|
|
140
|
+
let readyEmitted = ref(false)
|
|
141
|
+
let handle = line =>
|
|
142
|
+
switch classifyLine(line) {
|
|
143
|
+
| Domain(json) => callbacks.onDomainEvent(json)
|
|
144
|
+
| Ready =>
|
|
145
|
+
if !readyEmitted.contents {
|
|
146
|
+
readyEmitted := true
|
|
147
|
+
callbacks.onReady(~domainEndpoint="http://localhost:" ++ Int.toString(dPort) ++ "/graphql")
|
|
148
|
+
}
|
|
149
|
+
| Log(l) => callbacks.onLog(l)
|
|
150
|
+
}
|
|
151
|
+
ChildProcess.onStdoutLine(proc, handle)
|
|
152
|
+
ChildProcess.onStderrLine(proc, handle)
|
|
153
|
+
ChildProcess.onClose(proc, code => callbacks.onStop(code))
|
|
154
|
+
|
|
155
|
+
// Keep the process alive until cancelled — like runWatch, returning here would
|
|
156
|
+
// let the bin wrapper process.exit and tear down the child immediately.
|
|
157
|
+
await Promise.make((resolve, _reject) => Cancellation.onCancel(() => resolve()))
|
|
158
|
+
0
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Nodenet from "node:net";
|
|
4
|
+
import * as Cancellation$ReventlessGwt from "./Cancellation.res.mjs";
|
|
5
|
+
import * as ChildProcess$ReventlessGwt from "./ChildProcess.res.mjs";
|
|
6
|
+
import * as PlatformScan$ReventlessGwt from "./PlatformScan.res.mjs";
|
|
7
|
+
|
|
8
|
+
let tapSentinel = "@@RVLESS_EVT@@ ";
|
|
9
|
+
|
|
10
|
+
function classifyLine(line) {
|
|
11
|
+
if (!line.startsWith(tapSentinel)) {
|
|
12
|
+
if (line.includes("GraphQL:Domain") && line.includes("listening on")) {
|
|
13
|
+
return "Ready";
|
|
14
|
+
} else {
|
|
15
|
+
return {
|
|
16
|
+
TAG: "Log",
|
|
17
|
+
_0: line
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
let jsonStr = line.slice(tapSentinel.length, line.length);
|
|
22
|
+
let json;
|
|
23
|
+
try {
|
|
24
|
+
json = JSON.parse(jsonStr);
|
|
25
|
+
} catch (exn) {
|
|
26
|
+
return {
|
|
27
|
+
TAG: "Log",
|
|
28
|
+
_0: line
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
TAG: "Domain",
|
|
33
|
+
_0: json
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function openEphemeral() {
|
|
38
|
+
return new Promise((resolve, _reject) => {
|
|
39
|
+
let s = Nodenet.createServer();
|
|
40
|
+
s.listen(0, "127.0.0.1", () => resolve([
|
|
41
|
+
s,
|
|
42
|
+
s.address().port
|
|
43
|
+
]));
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function allocFreePorts(n) {
|
|
48
|
+
let servers = [];
|
|
49
|
+
let ports = [];
|
|
50
|
+
for (let _i = 1; _i <= n; ++_i) {
|
|
51
|
+
let match = await openEphemeral();
|
|
52
|
+
servers.push(match[0]);
|
|
53
|
+
ports.push(match[1]);
|
|
54
|
+
}
|
|
55
|
+
servers.forEach(s => {
|
|
56
|
+
s.close(() => {});
|
|
57
|
+
});
|
|
58
|
+
return ports;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let fixedPlatformPorts = [
|
|
62
|
+
4000,
|
|
63
|
+
4001,
|
|
64
|
+
3001,
|
|
65
|
+
3002
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
async function run(roots, backend, fixedPortsOpt, callbacks) {
|
|
69
|
+
let fixedPorts = fixedPortsOpt !== undefined ? fixedPortsOpt : false;
|
|
70
|
+
let pkgs = await PlatformScan$ReventlessGwt.scan(roots);
|
|
71
|
+
let pkg = pkgs[0];
|
|
72
|
+
if (pkg !== undefined) {
|
|
73
|
+
let ports = fixedPorts ? fixedPlatformPorts : await allocFreePorts(4);
|
|
74
|
+
let dPort = ports[0];
|
|
75
|
+
let pPort = ports[1];
|
|
76
|
+
let dMcp = ports[2];
|
|
77
|
+
let pMcp = ports[3];
|
|
78
|
+
let env = Object.fromEntries([
|
|
79
|
+
[
|
|
80
|
+
"REVENTLESS_EVENT_TAP",
|
|
81
|
+
"ndjson"
|
|
82
|
+
],
|
|
83
|
+
[
|
|
84
|
+
"REVENTLESS_LOCAL_BACKEND",
|
|
85
|
+
backend
|
|
86
|
+
],
|
|
87
|
+
[
|
|
88
|
+
"REVENTLESS_DOMAIN_PORT",
|
|
89
|
+
dPort.toString()
|
|
90
|
+
],
|
|
91
|
+
[
|
|
92
|
+
"REVENTLESS_PLATFORM_PORT",
|
|
93
|
+
pPort.toString()
|
|
94
|
+
],
|
|
95
|
+
[
|
|
96
|
+
"REVENTLESS_DOMAIN_MCP_PORT",
|
|
97
|
+
dMcp.toString()
|
|
98
|
+
],
|
|
99
|
+
[
|
|
100
|
+
"REVENTLESS_PLATFORM_MCP_PORT",
|
|
101
|
+
pMcp.toString()
|
|
102
|
+
],
|
|
103
|
+
[
|
|
104
|
+
"NODE_OPTIONS",
|
|
105
|
+
"--disable-warning=ExperimentalWarning"
|
|
106
|
+
],
|
|
107
|
+
[
|
|
108
|
+
"LOG_LEVEL",
|
|
109
|
+
"info"
|
|
110
|
+
],
|
|
111
|
+
[
|
|
112
|
+
"REVENTLESS_LOG_FORMAT",
|
|
113
|
+
"text"
|
|
114
|
+
]
|
|
115
|
+
]);
|
|
116
|
+
callbacks.onStart(pkg.name, pkg.dir, dPort, pPort);
|
|
117
|
+
let proc = ChildProcess$ReventlessGwt.spawn("node", ["src/Main.res.mjs"], pkg.dir, env);
|
|
118
|
+
Cancellation$ReventlessGwt.onCancel(() => ChildProcess$ReventlessGwt.killTree(proc, "SIGTERM"));
|
|
119
|
+
let readyEmitted = {
|
|
120
|
+
contents: false
|
|
121
|
+
};
|
|
122
|
+
let handle = line => {
|
|
123
|
+
let json = classifyLine(line);
|
|
124
|
+
if (typeof json !== "object") {
|
|
125
|
+
if (!readyEmitted.contents) {
|
|
126
|
+
readyEmitted.contents = true;
|
|
127
|
+
return callbacks.onReady("http://localhost:" + dPort.toString() + "/graphql");
|
|
128
|
+
} else {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
} else if (json.TAG === "Domain") {
|
|
132
|
+
return callbacks.onDomainEvent(json._0);
|
|
133
|
+
} else {
|
|
134
|
+
return callbacks.onLog(json._0);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
ChildProcess$ReventlessGwt.onStdoutLine(proc, handle);
|
|
138
|
+
ChildProcess$ReventlessGwt.onStderrLine(proc, handle);
|
|
139
|
+
ChildProcess$ReventlessGwt.onClose(proc, code => callbacks.onStop(code));
|
|
140
|
+
await new Promise((resolve, _reject) => Cancellation$ReventlessGwt.onCancel(() => resolve()));
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
callbacks.onLog("no reventless-local platform package (src/Main.res.mjs + @reventlessdev/reventless-local) found under: " + roots.join(", "));
|
|
144
|
+
return 1;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export {
|
|
148
|
+
tapSentinel,
|
|
149
|
+
classifyLine,
|
|
150
|
+
openEphemeral,
|
|
151
|
+
allocFreePorts,
|
|
152
|
+
fixedPlatformPorts,
|
|
153
|
+
run,
|
|
154
|
+
}
|
|
155
|
+
/* node:net Not a pure module */
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Discovers the local-platform package(s) the runner can launch (features plan
|
|
2
|
+
// Phase 9). A platform package is one that (a) depends on
|
|
3
|
+
// `@reventlessdev/reventless-local` and (b) carries a compiled run entrypoint at
|
|
4
|
+
// `src/Main.res.mjs` (what `tsx`/`node` execute). This mirrors PackageScan's
|
|
5
|
+
// role for the watch session, but keys off the run entrypoint rather than test
|
|
6
|
+
// files. The matching predicate is pure so it can be unit-tested without a real
|
|
7
|
+
// workspace tree.
|
|
8
|
+
|
|
9
|
+
type platformPkg = {
|
|
10
|
+
// Package name from package.json (may be "" if absent).
|
|
11
|
+
name: string,
|
|
12
|
+
// Absolute package directory (the one containing package.json + src/).
|
|
13
|
+
dir: string,
|
|
14
|
+
// Absolute path to the compiled run entrypoint (src/Main.res.mjs).
|
|
15
|
+
mainPath: string,
|
|
16
|
+
// The npm-script name to launch, preferring an in-memory variant: "serve:memory"
|
|
17
|
+
// if declared, else "serve". None ⇒ no serve script (runner falls back to running
|
|
18
|
+
// src/Main.res.mjs via tsx directly).
|
|
19
|
+
serveScript: option<string>,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type dirent = {
|
|
23
|
+
name: string,
|
|
24
|
+
@as("isDirectory") _isDirectory: unit => bool,
|
|
25
|
+
}
|
|
26
|
+
type readdirOpts = {withFileTypes: bool}
|
|
27
|
+
@module("node:fs/promises")
|
|
28
|
+
external _readdir: (string, readdirOpts) => promise<array<dirent>> = "readdir"
|
|
29
|
+
@module("node:fs") external _existsSync: string => bool = "existsSync"
|
|
30
|
+
@module("node:fs") external _readFileSync: (string, string) => string = "readFileSync"
|
|
31
|
+
@module("node:path") external join: (string, string) => string = "join"
|
|
32
|
+
|
|
33
|
+
let localPlatformDep = "@reventlessdev/reventless-local"
|
|
34
|
+
let ignoreNames = ["node_modules", ".git", "dist", "lib", ".history"]
|
|
35
|
+
|
|
36
|
+
// Pure predicate: given a package.json's text and whether src/Main.res.mjs exists,
|
|
37
|
+
// decide whether this is a launchable platform package and surface its name +
|
|
38
|
+
// serve script. Depends on reventless-local appearing in any dependency block.
|
|
39
|
+
let matchPlatform = (
|
|
40
|
+
~pkgJsonText: string,
|
|
41
|
+
~mainExists: bool,
|
|
42
|
+
): option<(string, option<string>)> => {
|
|
43
|
+
if !mainExists {
|
|
44
|
+
None
|
|
45
|
+
} else {
|
|
46
|
+
switch JSON.parseOrThrow(pkgJsonText) {
|
|
47
|
+
| exception _ => None
|
|
48
|
+
| json =>
|
|
49
|
+
switch json->JSON.Decode.object {
|
|
50
|
+
| None => None
|
|
51
|
+
| Some(obj) =>
|
|
52
|
+
let name = switch obj->Dict.get("name") {
|
|
53
|
+
| Some(String(n)) => n
|
|
54
|
+
| _ => ""
|
|
55
|
+
}
|
|
56
|
+
let depsHave = key =>
|
|
57
|
+
switch obj->Dict.get(key)->Option.flatMap(JSON.Decode.object) {
|
|
58
|
+
| Some(deps) => deps->Dict.get(localPlatformDep)->Option.isSome
|
|
59
|
+
| None => false
|
|
60
|
+
}
|
|
61
|
+
let dependsOnLocal =
|
|
62
|
+
depsHave("dependencies") ||
|
|
63
|
+
depsHave("devDependencies") ||
|
|
64
|
+
depsHave("optionalDependencies")
|
|
65
|
+
if dependsOnLocal {
|
|
66
|
+
let serveScript =
|
|
67
|
+
switch obj->Dict.get("scripts")->Option.flatMap(JSON.Decode.object) {
|
|
68
|
+
| Some(scripts) =>
|
|
69
|
+
if scripts->Dict.get("serve:memory")->Option.isSome {
|
|
70
|
+
Some("serve:memory")
|
|
71
|
+
} else if scripts->Dict.get("serve")->Option.isSome {
|
|
72
|
+
Some("serve")
|
|
73
|
+
} else {
|
|
74
|
+
None
|
|
75
|
+
}
|
|
76
|
+
| None => None
|
|
77
|
+
}
|
|
78
|
+
Some((name, serveScript))
|
|
79
|
+
} else {
|
|
80
|
+
None
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Inspect a single directory: is it a platform package?
|
|
88
|
+
let inspectDir = (dir: string): option<platformPkg> => {
|
|
89
|
+
let pkgJson = join(dir, "package.json")
|
|
90
|
+
let mainPath = join(join(dir, "src"), "Main.res.mjs")
|
|
91
|
+
if !_existsSync(pkgJson) {
|
|
92
|
+
None
|
|
93
|
+
} else {
|
|
94
|
+
let text = try _readFileSync(pkgJson, "utf8") catch {
|
|
95
|
+
| _ => ""
|
|
96
|
+
}
|
|
97
|
+
switch matchPlatform(~pkgJsonText=text, ~mainExists=_existsSync(mainPath)) {
|
|
98
|
+
| Some((name, serveScript)) => Some({name, dir, mainPath, serveScript})
|
|
99
|
+
| None => None
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Walk the roots, returning every platform package found (deduped by dir,
|
|
105
|
+
// declaration order preserved). Prunes node_modules/lib/.git like Discovery.
|
|
106
|
+
let rec walk = async (dir: string, acc: array<platformPkg>): array<platformPkg> => {
|
|
107
|
+
switch inspectDir(dir) {
|
|
108
|
+
| Some(pkg) => acc->Array.push(pkg) // a platform package — record, don't descend into its src
|
|
109
|
+
| None => ()
|
|
110
|
+
}
|
|
111
|
+
let entries = try await _readdir(dir, {withFileTypes: true}) catch {
|
|
112
|
+
| _ => []
|
|
113
|
+
}
|
|
114
|
+
for i in 0 to entries->Array.length - 1 {
|
|
115
|
+
let entry = entries->Array.getUnsafe(i)
|
|
116
|
+
if entry._isDirectory() && !Array.includes(ignoreNames, entry.name) {
|
|
117
|
+
let _ = await walk(join(dir, entry.name), acc)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
acc
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let scan = async (roots: array<string>): array<platformPkg> => {
|
|
124
|
+
let seen = Dict.make()
|
|
125
|
+
let out = []
|
|
126
|
+
for i in 0 to roots->Array.length - 1 {
|
|
127
|
+
let found = await walk(roots->Array.getUnsafe(i), [])
|
|
128
|
+
found->Array.forEach(pkg =>
|
|
129
|
+
switch seen->Dict.get(pkg.dir) {
|
|
130
|
+
| Some(_) => ()
|
|
131
|
+
| None =>
|
|
132
|
+
seen->Dict.set(pkg.dir, true)
|
|
133
|
+
out->Array.push(pkg)
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
out
|
|
138
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Nodefs from "node:fs";
|
|
4
|
+
import * as Nodepath from "node:path";
|
|
5
|
+
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
6
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
7
|
+
import * as Promises from "node:fs/promises";
|
|
8
|
+
|
|
9
|
+
let localPlatformDep = "@reventlessdev/reventless-local";
|
|
10
|
+
|
|
11
|
+
let ignoreNames = [
|
|
12
|
+
"node_modules",
|
|
13
|
+
".git",
|
|
14
|
+
"dist",
|
|
15
|
+
"lib",
|
|
16
|
+
".history"
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
function matchPlatform(pkgJsonText, mainExists) {
|
|
20
|
+
if (!mainExists) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
let json;
|
|
24
|
+
try {
|
|
25
|
+
json = JSON.parse(pkgJsonText);
|
|
26
|
+
} catch (exn) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
let obj = Stdlib_JSON.Decode.object(json);
|
|
30
|
+
if (obj === undefined) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
let match = obj["name"];
|
|
34
|
+
let name = typeof match === "string" ? match : "";
|
|
35
|
+
let depsHave = key => {
|
|
36
|
+
let deps = Stdlib_Option.flatMap(obj[key], Stdlib_JSON.Decode.object);
|
|
37
|
+
if (deps !== undefined) {
|
|
38
|
+
return Stdlib_Option.isSome(deps[localPlatformDep]);
|
|
39
|
+
} else {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
let dependsOnLocal = depsHave("dependencies") || depsHave("devDependencies") || depsHave("optionalDependencies");
|
|
44
|
+
if (!dependsOnLocal) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
let scripts = Stdlib_Option.flatMap(obj["scripts"], Stdlib_JSON.Decode.object);
|
|
48
|
+
let serveScript = scripts !== undefined ? (
|
|
49
|
+
Stdlib_Option.isSome(scripts["serve:memory"]) ? "serve:memory" : (
|
|
50
|
+
Stdlib_Option.isSome(scripts["serve"]) ? "serve" : undefined
|
|
51
|
+
)
|
|
52
|
+
) : undefined;
|
|
53
|
+
return [
|
|
54
|
+
name,
|
|
55
|
+
serveScript
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function inspectDir(dir) {
|
|
60
|
+
let pkgJson = Nodepath.join(dir, "package.json");
|
|
61
|
+
let mainPath = Nodepath.join(Nodepath.join(dir, "src"), "Main.res.mjs");
|
|
62
|
+
if (!Nodefs.existsSync(pkgJson)) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
let text;
|
|
66
|
+
try {
|
|
67
|
+
text = Nodefs.readFileSync(pkgJson, "utf8");
|
|
68
|
+
} catch (exn) {
|
|
69
|
+
text = "";
|
|
70
|
+
}
|
|
71
|
+
let match = matchPlatform(text, Nodefs.existsSync(mainPath));
|
|
72
|
+
if (match !== undefined) {
|
|
73
|
+
return {
|
|
74
|
+
name: match[0],
|
|
75
|
+
dir: dir,
|
|
76
|
+
mainPath: mainPath,
|
|
77
|
+
serveScript: match[1]
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function walk(dir, acc) {
|
|
83
|
+
let pkg = inspectDir(dir);
|
|
84
|
+
if (pkg !== undefined) {
|
|
85
|
+
acc.push(pkg);
|
|
86
|
+
}
|
|
87
|
+
let entries;
|
|
88
|
+
try {
|
|
89
|
+
entries = await Promises.readdir(dir, {
|
|
90
|
+
withFileTypes: true
|
|
91
|
+
});
|
|
92
|
+
} catch (exn) {
|
|
93
|
+
entries = [];
|
|
94
|
+
}
|
|
95
|
+
for (let i = 0, i_finish = entries.length; i < i_finish; ++i) {
|
|
96
|
+
let entry = entries[i];
|
|
97
|
+
if (entry.isDirectory() && !ignoreNames.includes(entry.name)) {
|
|
98
|
+
await walk(Nodepath.join(dir, entry.name), acc);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return acc;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function scan(roots) {
|
|
105
|
+
let seen = {};
|
|
106
|
+
let out = [];
|
|
107
|
+
for (let i = 0, i_finish = roots.length; i < i_finish; ++i) {
|
|
108
|
+
let found = await walk(roots[i], []);
|
|
109
|
+
found.forEach(pkg => {
|
|
110
|
+
let match = seen[pkg.dir];
|
|
111
|
+
if (match !== undefined) {
|
|
112
|
+
return;
|
|
113
|
+
} else {
|
|
114
|
+
seen[pkg.dir] = true;
|
|
115
|
+
out.push(pkg);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export {
|
|
124
|
+
localPlatformDep,
|
|
125
|
+
ignoreNames,
|
|
126
|
+
matchPlatform,
|
|
127
|
+
inspectDir,
|
|
128
|
+
walk,
|
|
129
|
+
scan,
|
|
130
|
+
}
|
|
131
|
+
/* node:fs Not a pure module */
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Owns the `rescript build -w` watchers a watch session spawns — one per
|
|
2
|
+
// package that owns tests (PackageScan), at most one per directory,
|
|
3
|
+
// reference-counted by `dir`. Registered with Cancellation so SIGINT/SIGTERM
|
|
4
|
+
// tears down every spawned watcher; never orphans a compiler.
|
|
5
|
+
|
|
6
|
+
type managed = {
|
|
7
|
+
dir: string,
|
|
8
|
+
proc: ChildProcess.t,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let running: Dict.t<managed> = Dict.make()
|
|
12
|
+
|
|
13
|
+
let isManaging = (dir: string): bool =>
|
|
14
|
+
switch running->Dict.get(dir) {
|
|
15
|
+
| Some(_) => true
|
|
16
|
+
| None => false
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Spawn `pnpm run start` (the package's `rescript build -w`) in `pkg.dir`,
|
|
20
|
+
// unless one is already managed there. `onStdout` receives each output line so
|
|
21
|
+
// the caller can classify compiler markers (Phase 5).
|
|
22
|
+
let spawnWatcher = (pkg: PackageScan.pkg, ~onStdout: (string, string) => unit): unit =>
|
|
23
|
+
switch running->Dict.get(pkg.dir) {
|
|
24
|
+
| Some(_) => ()
|
|
25
|
+
| None => {
|
|
26
|
+
let proc = ChildProcess.spawn("pnpm", ["run", "start"], ~cwd=pkg.dir)
|
|
27
|
+
ChildProcess.onStdoutLine(proc, line => onStdout(pkg.dir, line))
|
|
28
|
+
running->Dict.set(pkg.dir, {dir: pkg.dir, proc})
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Stop just the watcher managed for `dir` (if any), tearing down its process
|
|
33
|
+
// group. Returns true when one was running — i.e. the caller *owned* this
|
|
34
|
+
// package's build (vs. an adopted external watcher, which we can't stop).
|
|
35
|
+
let stop = (dir: string): bool =>
|
|
36
|
+
switch running->Dict.get(dir) {
|
|
37
|
+
| Some(m) => {
|
|
38
|
+
ChildProcess.killTree(m.proc, "SIGTERM")
|
|
39
|
+
running->Dict.delete(dir)
|
|
40
|
+
true
|
|
41
|
+
}
|
|
42
|
+
| None => false
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Force a full re-emit of `pkg` after a structural change — a module relocation
|
|
46
|
+
// that incremental compilation can't detect, since it keys invalidation on
|
|
47
|
+
// source + dependency-interface hashes, never on a dependency's filesystem
|
|
48
|
+
// location (see plan Phase 12). Drives a deterministic one-shot `rescript clean`
|
|
49
|
+
// → `rescript build` to completion (the `clean` is what forces every dependent
|
|
50
|
+
// `.res.mjs` to be re-emitted against the new layout; the `build` is essential
|
|
51
|
+
// even on the adopted path — an external watcher won't rebuild after a clean
|
|
52
|
+
// without a fresh source change, so a bare clean would strand the package with
|
|
53
|
+
// no outputs). If we *owned* the package's watcher we stop it first (no lock
|
|
54
|
+
// contention) and respawn it afterward to resume incremental watching; on the
|
|
55
|
+
// adopted path the developer's own watcher keeps running untouched. `onStdout`
|
|
56
|
+
// streams the clean/build output (compiler markers) to the caller; `onDone`
|
|
57
|
+
// fires once the build completes, gating the test re-run.
|
|
58
|
+
let cleanRebuild = (
|
|
59
|
+
pkg: PackageScan.pkg,
|
|
60
|
+
~onStdout: (string, string) => unit,
|
|
61
|
+
~onDone: unit => unit,
|
|
62
|
+
): unit => {
|
|
63
|
+
let wasManaged = stop(pkg.dir)
|
|
64
|
+
let step = (cmd: string, args: array<string>, next: unit => unit): unit => {
|
|
65
|
+
let proc = ChildProcess.spawn(cmd, args, ~cwd=pkg.dir)
|
|
66
|
+
ChildProcess.onStdoutLine(proc, line => onStdout(pkg.dir, line))
|
|
67
|
+
ChildProcess.onClose(proc, _ => next())
|
|
68
|
+
}
|
|
69
|
+
step("pnpm", ["exec", "rescript", "clean"], () =>
|
|
70
|
+
step("pnpm", ["exec", "rescript", "build"], () => {
|
|
71
|
+
if wasManaged {
|
|
72
|
+
spawnWatcher(pkg, ~onStdout)
|
|
73
|
+
}
|
|
74
|
+
onDone()
|
|
75
|
+
})
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// SIGTERM, not SIGINT: a POSIX shell sets background children to ignore SIGINT,
|
|
80
|
+
// so SIGINT can leak a watcher; SIGTERM tears down the whole group reliably
|
|
81
|
+
// (verified across foreground and background process-tree shapes).
|
|
82
|
+
let killAll = (): unit => {
|
|
83
|
+
running
|
|
84
|
+
->Dict.valuesToArray
|
|
85
|
+
->Array.forEach(m => ChildProcess.killTree(m.proc, "SIGTERM"))
|
|
86
|
+
running
|
|
87
|
+
->Dict.keysToArray
|
|
88
|
+
->Array.forEach(k => running->Dict.delete(k))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let managedCount = (): int => running->Dict.keysToArray->Array.length
|