@yarkivaev/scada 2.3.115 → 2.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/README.md +158 -0
- package/charts/scada/templates/processes.yaml +22 -0
- package/charts/scada/values.schema.json +12 -0
- package/db/migrations/V0009__collector_task_state.sql +6 -0
- package/package.json +14 -5
- package/src/application/deploy/collectorProcesses.js +3 -2
- package/src/application/deploy/readDocument.js +4 -1
- package/src/application/plantServer.js +1 -1
- package/src/application/runtime/additionalAdapters.js +139 -0
- package/src/application/runtime/assignment.js +30 -0
- package/src/application/runtime/budgetedSink.js +17 -0
- package/src/application/runtime/collectorSignals.js +14 -0
- package/src/application/runtime/compositeLifecycle.js +49 -0
- package/src/application/runtime/createRegistry.js +20 -0
- package/src/application/runtime/deadline.js +8 -0
- package/src/application/runtime/index.js +10 -0
- package/src/application/runtime/manifest.js +38 -0
- package/src/application/runtime/pipelineAdapters.js +60 -0
- package/src/application/runtime/resolveSecrets.js +12 -0
- package/src/application/runtime/resourceClaims.js +21 -0
- package/src/application/runtime/runCollector.js +50 -0
- package/src/application/runtime/runtime.js +86 -0
- package/src/application/runtime/runtimeHealth.js +29 -0
- package/src/application/runtime/runtimeServices.js +15 -0
- package/src/application/runtime/stateDocument.js +22 -0
- package/src/application/runtime/streamPorts.js +42 -0
- package/src/application/runtime/streamTask.js +105 -0
- package/src/application/runtime/taskBudget.js +31 -0
- package/src/application/runtime/taskContext.js +18 -0
- package/src/application/runtime/taskQueue.js +21 -0
- package/src/application/runtime/taskSlot.js +46 -0
- package/src/application/runtime/taskState.js +73 -0
- package/src/application/siteCollection.js +14 -0
- package/src/application/siteServer.js +7 -6
- package/src/application/siteTelemetry.js +1 -1
- package/src/application/supervisorSink.js +9 -8
- package/src/application/sync/siteSyncBind.js +1 -1
- package/src/application/sync/siteSyncTargets.js +1 -1
- package/src/bin/collector.js +7 -0
- package/src/bin/modbus-to-mqtt.js +11 -9
- package/src/infrastructure/deploy/executeRelease.js +45 -3
- package/src/infrastructure/ingest/activity/activityTracking.js +15 -21
- package/src/infrastructure/ingest/activity/activityTransformer.js +16 -15
- package/src/infrastructure/ingest/durableConsume.js +2 -169
- package/src/infrastructure/ingest/ingestBatch.js +13 -0
- package/src/infrastructure/ingest/modbus/mx210Tcp.js +4 -4
- package/src/infrastructure/ingest/modbus/silentStreams.js +39 -16
- package/src/infrastructure/ingest/mqtt/metricsTransformer.js +4 -5
- package/src/infrastructure/ingest/mqtt/modbusMqtt.js +17 -19
- package/src/infrastructure/ingest/mqtt/modbusPublish.js +4 -0
- package/src/infrastructure/ingest/mqtt/mqttMetrics.js +17 -21
- package/src/infrastructure/ingest/mqtt/registerPublish.js +3 -3
- package/src/infrastructure/ingest/pipelines/alertPipeline.js +7 -6
- package/src/infrastructure/ingest/pipelines/decisionPipeline.js +7 -6
- package/src/infrastructure/ingest/pipelines/segmentPipeline.js +8 -17
- package/src/infrastructure/ingest/pipelines/stompOptions.js +7 -0
- package/src/infrastructure/ingest/silentOpenWatch.js +5 -42
- package/src/infrastructure/ingest/telemetry/amqpMetricsIngest.js +16 -28
- package/src/infrastructure/ingest/telemetry/amqpMqttRelay.js +17 -22
- package/src/infrastructure/ingest/telemetry/foldingSink.js +5 -2
- package/src/infrastructure/messaging/stomp/segmentRetags.js +1 -1
- package/src/infrastructure/messaging/stomp/userDecisions.js +1 -1
- package/src/infrastructure/opcua/certificates.js +11 -0
- package/src/infrastructure/opcua/config.js +38 -0
- package/src/infrastructure/opcua/delivery.js +38 -0
- package/src/infrastructure/opcua/endpoint.js +90 -0
- package/src/infrastructure/opcua/index.js +10 -0
- package/src/infrastructure/opcua/session.js +70 -0
- package/src/infrastructure/opcua/source.js +38 -0
- package/src/infrastructure/opcua/subscriptionHealth.js +28 -0
- package/src/infrastructure/operators/operatorsSync.js +5 -29
- package/src/infrastructure/persistence/clickhouse/streamHub.js +21 -15
- package/src/infrastructure/persistence/metricsSensor.js +9 -6
- package/src/infrastructure/persistence/runtime/taskStatePg.js +24 -0
- package/src/infrastructure/streams/README.md +291 -0
- package/src/infrastructure/streams/abortable.js +14 -0
- package/src/infrastructure/streams/amqpSink/publication.js +46 -0
- package/src/infrastructure/streams/amqpSink/session.js +96 -0
- package/src/infrastructure/streams/amqpSink.js +47 -0
- package/src/infrastructure/streams/amqpSource/session.js +127 -0
- package/src/infrastructure/streams/amqpSource.js +97 -0
- package/src/infrastructure/streams/brokerClose.js +19 -0
- package/src/infrastructure/streams/budgetAccept.js +6 -0
- package/src/infrastructure/streams/clickhouseSink.js +48 -0
- package/src/infrastructure/streams/deadline.js +1 -0
- package/src/infrastructure/streams/follow.js +41 -0
- package/src/infrastructure/streams/index.js +15 -0
- package/src/infrastructure/streams/lokiSource.js +88 -0
- package/src/infrastructure/streams/modbusConnection.js +173 -0
- package/src/infrastructure/streams/modbusRtuBusSource.js +124 -0
- package/src/infrastructure/streams/modbusSource.js +185 -0
- package/src/infrastructure/streams/mqttSink/session.js +51 -0
- package/src/infrastructure/streams/mqttSink.js +54 -0
- package/src/infrastructure/streams/mqttSource.js +66 -0
- package/src/infrastructure/streams/pollingSource.js +1 -0
- package/src/infrastructure/streams/positive.js +5 -0
- package/src/infrastructure/streams/postgresSink.js +124 -0
- package/src/infrastructure/streams/serialReceive.js +80 -0
- package/src/infrastructure/streams/serialSource.js +87 -0
- package/src/infrastructure/streams/sqliteSink.js +55 -0
- package/src/infrastructure/streams/stompBody.js +46 -0
- package/src/infrastructure/streams/stompSend.js +89 -0
- package/src/infrastructure/streams/stompSource.js +201 -0
- package/src/infrastructure/sync/operationSyncIngest.js +4 -16
package/README.md
CHANGED
|
@@ -269,6 +269,30 @@ Opaque operation `kind` strings and generic ports (`latestForMachine`, list/upse
|
|
|
269
269
|
| Export ports `Query` / `Stream` / `Sink` / `Job` | Export jobs + destination adapters |
|
|
270
270
|
| ClickHouse + PG adapters | Plant wiring (`sokolPlant` / `edgePlant`) |
|
|
271
271
|
|
|
272
|
+
### Stream transport ownership
|
|
273
|
+
|
|
274
|
+
`@yarkivaev/source-to-sink` owns reusable Node.js sources, sinks and transport
|
|
275
|
+
lifecycle: serial input, Modbus polling,
|
|
276
|
+
MQTT/STOMP messaging, AMQP consumption and confirmed publishing, and serialized
|
|
277
|
+
periodic ticks. SCADA composes these primitives with generic codecs, domain ports
|
|
278
|
+
and persistence adapters. Plant packages supply source mappings, payload formats,
|
|
279
|
+
queries, routing and plant-specific behavior.
|
|
280
|
+
|
|
281
|
+
The SCADA AMQP telemetry, MQTT relay and operation-sync pipelines use
|
|
282
|
+
`amqpSource`. It acknowledges after collector acceptance, requeues failures by
|
|
283
|
+
default, and avoids settling explicitly acknowledged or rejected deliveries twice.
|
|
284
|
+
Shutdown cancels consumption, drains accepted work and settlements, then closes
|
|
285
|
+
the channel and connection. Reconnect and restart wait for that drain.
|
|
286
|
+
`durableConsume` remains a compatibility facade over the shared AMQP session.
|
|
287
|
+
Collectors that batch records acknowledge acceptance into that collector; this is
|
|
288
|
+
not a guarantee that a later batch has already reached persistent storage.
|
|
289
|
+
|
|
290
|
+
`intervalSource` schedules operator-cache synchronization, stale-open-segment
|
|
291
|
+
sweeps, silent-stream reconciliation, PostgreSQL metric streams and the shared
|
|
292
|
+
ClickHouse stream hub. Polls do not overlap; metric streams await subscribers
|
|
293
|
+
before advancing their cursors. HTTP keepalive timers and domain coalescing
|
|
294
|
+
windows remain owned by their respective adapters and domain policies.
|
|
295
|
+
|
|
272
296
|
Depend on a pinned version (`"@yarkivaev/scada": "…#v2.3.46"`), not a local path, in downstream packages.
|
|
273
297
|
|
|
274
298
|
## CI/CD
|
|
@@ -327,3 +351,137 @@ The RabbitMQ definitions builder and management reconciler are exported as
|
|
|
327
351
|
`@yarkivaev/scada/rabbitmq/definitions` and `@yarkivaev/scada/rabbitmq/reconcile`.
|
|
328
352
|
The caller supplies the vhost, connection settings and ownership policy for
|
|
329
353
|
retired routes. Reconciliation retains stored queues.
|
|
354
|
+
|
|
355
|
+
### Collect multiple OPC UA endpoints
|
|
356
|
+
|
|
357
|
+
`@yarkivaev/scada/opcua` exports `opcuaConnections(connections, publish, options)`
|
|
358
|
+
and `validateConnections(connections)`. The same entry point exports
|
|
359
|
+
`opcuaSource(connections, collector, options)` for source-to-sink composition;
|
|
360
|
+
the collector accepts `{ channel, value }`. SCADA owns the OPC UA runtime and
|
|
361
|
+
its `node-opcua` dependency; source-to-sink contains no OPC UA implementation.
|
|
362
|
+
Offline deployment planners should import
|
|
363
|
+
`validateConnections` from `@yarkivaev/scada/opcua/config`; this entry point never
|
|
364
|
+
loads the network library or its startup diagnostics. Each connection has a unique `url`,
|
|
365
|
+
positive `publishMs` and `heartbeatMs`, and `channels` with explicit, unique
|
|
366
|
+
`nodeId` values. Additional channel metadata is opaque to this library.
|
|
367
|
+
|
|
368
|
+
```js
|
|
369
|
+
import opcuaConnections from '@yarkivaev/scada/opcua';
|
|
370
|
+
|
|
371
|
+
const collector = opcuaConnections([
|
|
372
|
+
{ url: 'opc.tcp://192.0.2.10:4840', publishMs: 100, heartbeatMs: 1000,
|
|
373
|
+
channels: [{ nodeId: 'ns=1;s=temperature', topic: 'line/temperature' }] }
|
|
374
|
+
], (channel, value) => publish(channel.topic, value));
|
|
375
|
+
await collector.start();
|
|
376
|
+
// On process shutdown:
|
|
377
|
+
await collector.stop();
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
Each endpoint owns its client, session and subscription. An unavailable endpoint
|
|
381
|
+
retries independently while others continue. Start schedules these workers; it
|
|
382
|
+
does not wait for every server to be available. Samples are delivered only with
|
|
383
|
+
Good quality, both on change and through serialized fresh heartbeat reads.
|
|
384
|
+
The caller owns value conversion, routing, timestamps and destination transport.
|
|
385
|
+
Options `retryMs`, `connectTimeoutMs` and `requestTimeoutMs` bound retry timing and
|
|
386
|
+
network requests; `onError(error, connection)` receives connection diagnostics.
|
|
387
|
+
Do not replicate a collector with the same sources to distribute load: assign
|
|
388
|
+
disjoint source sets to separate instances instead.
|
|
389
|
+
|
|
390
|
+
A release can replace old collectors using
|
|
391
|
+
`retirements: [{ workload: 'deployment/old-reader', selector: 'app=old-reader' }]`.
|
|
392
|
+
For apply/adopt, the executor validates the new manifests first, foreground-deletes
|
|
393
|
+
only these explicit Deployments, and verifies that no selected old pods remain
|
|
394
|
+
before applying the replacement. This trades a collection gap for no overlap.
|
|
395
|
+
The caller must bind each selector to its old Deployment; no namespace-wide prune
|
|
396
|
+
is performed. Render/check/diff do not delete anything. An optional process `image`
|
|
397
|
+
overrides the release image, so unrelated collectors can retain their pinned runtime.
|
|
398
|
+
|
|
399
|
+
## Universal collector runtime
|
|
400
|
+
|
|
401
|
+
`scada-collector manifest.json` (or `scada-collector -` for stdin) runs all
|
|
402
|
+
registered adapters inside one Node.js process. Every instance has the same
|
|
403
|
+
registry; assignments select its work. `COLLECTOR_MANIFEST` accepts inline JSON;
|
|
404
|
+
`COLLECTOR_MANIFEST_PATH` selects a file. SIGHUP reconciles the file, SIGTERM/SIGINT
|
|
405
|
+
perform bounded shutdown. No cluster scheduler, lease service or network control
|
|
406
|
+
API is included. HTTP request handlers and their process-local read models remain
|
|
407
|
+
owned by the HTTP application; use `COLLECTOR_MODE=external` to disable its
|
|
408
|
+
transferable background collection before assigning that work elsewhere.
|
|
409
|
+
|
|
410
|
+
```js
|
|
411
|
+
import { createRegistry, createRuntime } from '@yarkivaev/scada/runtime';
|
|
412
|
+
const runtime = createRuntime({ registry: createRegistry() });
|
|
413
|
+
await runtime.apply({ taskId: 'line-reader', generation: 1, specVersion: 1,
|
|
414
|
+
spec: { adapter: 'modbus', url: 'mqtt://broker', config: {
|
|
415
|
+
addresses: ['tcp://127.0.0.1:502'], streams: [], maps: [], interval: 5
|
|
416
|
+
} } });
|
|
417
|
+
await runtime.remove({ taskId: 'line-reader', generation: 2 });
|
|
418
|
+
await runtime.stop();
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
Adapters include `stream` (registered source/transform/sinks), `modbus`,
|
|
422
|
+
`activity`, `amqp-relay`, `amqp-metrics`, `mqtt-metrics`, `operation-sync`,
|
|
423
|
+
`amqp-pg-metrics`, `silent-streams` and `stomp-ingest`. Plant packages extend the
|
|
424
|
+
registry with domain-specific adapters; OPC UA belongs here, not in
|
|
425
|
+
source-to-sink. Constructors do not open connections. A task owns independent
|
|
426
|
+
connections and must close them in `stop()`. Factories are trusted code, not
|
|
427
|
+
arbitrary scripts supplied in manifests.
|
|
428
|
+
|
|
429
|
+
Assignments have stable task IDs, externally supplied integer generations and
|
|
430
|
+
`specVersion: 1`. An optional `specHash` is checked against canonical JSON.
|
|
431
|
+
Identical repeats are idempotent; conflicting/stale generations are rejected.
|
|
432
|
+
Replacement waits for the previous owner to stop. Removed IDs retain bounded
|
|
433
|
+
in-memory tombstones. Cleanup timeout quarantines resources and marks the
|
|
434
|
+
runtime fatal; it never authorizes a second owner. Generation and PostgreSQL CAS
|
|
435
|
+
are local/conflict safeguards, **not distributed fencing**. The external agent
|
|
436
|
+
must establish cross-node ownership and provide current assignments after restart.
|
|
437
|
+
|
|
438
|
+
Secrets can be references such as `{ "$secret": "AMQP_URL" }`, resolved by the
|
|
439
|
+
host immediately before adapter construction. Status excludes configuration and
|
|
440
|
+
exception messages. Optional `COLLECTOR_HEALTH_PORT` and
|
|
441
|
+
`COLLECTOR_HEALTH_HOST` (default `127.0.0.1`) expose read-only `/live`, `/ready`
|
|
442
|
+
and `/status`. Readiness represents admission, not the health of every remote
|
|
443
|
+
server. Fatal cleanup makes liveness fail. A blocked event loop cannot answer
|
|
444
|
+
an external probe; synchronous infinite loops/native crashes still affect the
|
|
445
|
+
entire process. No hard CPU or native-memory isolation is claimed.
|
|
446
|
+
|
|
447
|
+
The process defaults to 16 MiB / 16,384 buffered records, each task to 1 MiB /
|
|
448
|
+
1,024, with bounded control admission and start/stop deadlines. Adapter-owned
|
|
449
|
+
buffers and actual sink promises reserve capacity; driver/internal allocations
|
|
450
|
+
and transient JSON decoding are not a hard RSS limit. Configure capacities for
|
|
451
|
+
all simultaneously retained pipeline stages. MQTT and Loki collectors flush
|
|
452
|
+
individual accepted records because their source delivery is sequential;
|
|
453
|
+
AMQP pipelines retain bounded timed batching. Timeout is not proof that a remote
|
|
454
|
+
side effect did not happen.
|
|
455
|
+
|
|
456
|
+
`stream` requires explicit `delivery: "live" | "durable"`. Durable streams need
|
|
457
|
+
`stateVersion`, `compatibilityKey`, stable `eventId` and a shared state store.
|
|
458
|
+
Durable payloads and checkpoints use JSON; encode binary explicitly as strings.
|
|
459
|
+
They persist an outbox and each recipient acknowledgement before advancing the
|
|
460
|
+
checkpoint. MQTT requires QoS 1/2; AMQP sets persistent/mandatory publication and
|
|
461
|
+
checks returned messages. Broker confirmation does not prove downstream business
|
|
462
|
+
processing. An uncertain send can repeat with the same event ID; receivers must
|
|
463
|
+
deduplicate. Sources with a non-null checkpoint must implement `restore()`;
|
|
464
|
+
unsupported restoration fails closed. Live telemetry cannot replay downtime.
|
|
465
|
+
Loki fails a saturated page rather than silently advancing across truncated data.
|
|
466
|
+
|
|
467
|
+
The runner accepts `COLLECTOR_STATE_URL` for PostgreSQL. Apply
|
|
468
|
+
`db/migrations/V0009__collector_task_state.sql` explicitly and grant the service
|
|
469
|
+
role access before starting stateful assignments; the runner never runs schema
|
|
470
|
+
migrations or falls back to local disk. The storage must be reachable from every
|
|
471
|
+
eligible node. The built-in store rejects unsupported ownership tokens rather
|
|
472
|
+
than pretending to enforce an external fencing protocol.
|
|
473
|
+
|
|
474
|
+
The coordinated review branches require new source-to-sink/scada package releases
|
|
475
|
+
before dependency installation can reproduce all runtime tests. Stable release/pin
|
|
476
|
+
updates and deployment remain separate steps. Unit, transport,
|
|
477
|
+
state recovery and mixed-runtime tests do not constitute a 24-hour production
|
|
478
|
+
soak or prove cluster-wide exactly-once effects.
|
|
479
|
+
|
|
480
|
+
## Streaming package boundary
|
|
481
|
+
|
|
482
|
+
`@yarkivaev/source-to-sink` 2.x contains only protocol-independent composition
|
|
483
|
+
primitives with no runtime dependencies. Protocol/storage implementations and
|
|
484
|
+
their integration tests live in `src/infrastructure/streams` and are exported as
|
|
485
|
+
`@yarkivaev/scada/streams`. This includes AMQP, MQTT, STOMP, serial, Modbus, Loki,
|
|
486
|
+
PostgreSQL, ClickHouse and SQLite; OPC UA stays in `@yarkivaev/scada/opcua`.
|
|
487
|
+
Plant-specific transformations stay in the plant package.
|
|
@@ -58,6 +58,28 @@ spec:
|
|
|
58
58
|
image: {{ required "process image is required" (default $.Values.image .image) | quote }}
|
|
59
59
|
imagePullPolicy: IfNotPresent
|
|
60
60
|
command: {{ required "process command is required" .command | toJson }}
|
|
61
|
+
{{- with .health }}
|
|
62
|
+
ports:
|
|
63
|
+
- name: health
|
|
64
|
+
containerPort: {{ .port }}
|
|
65
|
+
protocol: TCP
|
|
66
|
+
livenessProbe:
|
|
67
|
+
httpGet:
|
|
68
|
+
path: {{ default "/live" .livenessPath | quote }}
|
|
69
|
+
port: health
|
|
70
|
+
initialDelaySeconds: {{ if hasKey . "initialDelaySeconds" }}{{ .initialDelaySeconds }}{{ else }}2{{ end }}
|
|
71
|
+
periodSeconds: {{ default 10 .periodSeconds }}
|
|
72
|
+
timeoutSeconds: {{ default 2 .timeoutSeconds }}
|
|
73
|
+
failureThreshold: {{ default 3 .failureThreshold }}
|
|
74
|
+
readinessProbe:
|
|
75
|
+
httpGet:
|
|
76
|
+
path: {{ default "/ready" .readinessPath | quote }}
|
|
77
|
+
port: health
|
|
78
|
+
initialDelaySeconds: {{ if hasKey . "initialDelaySeconds" }}{{ .initialDelaySeconds }}{{ else }}2{{ end }}
|
|
79
|
+
periodSeconds: {{ default 10 .periodSeconds }}
|
|
80
|
+
timeoutSeconds: {{ default 2 .timeoutSeconds }}
|
|
81
|
+
failureThreshold: {{ default 3 .failureThreshold }}
|
|
82
|
+
{{- end }}
|
|
61
83
|
{{- if .hostDevices }}
|
|
62
84
|
securityContext:
|
|
63
85
|
privileged: true
|
|
@@ -11,6 +11,18 @@
|
|
|
11
11
|
"name": { "type": "string", "pattern": "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$" },
|
|
12
12
|
"command": { "type": "array", "minItems": 1, "items": { "type": "string" } },
|
|
13
13
|
"env": { "type": "object", "additionalProperties": { "type": ["string", "number", "boolean"] } },
|
|
14
|
+
"health": {
|
|
15
|
+
"type": "object", "required": ["port"], "additionalProperties": false,
|
|
16
|
+
"properties": {
|
|
17
|
+
"port": { "type": "integer", "minimum": 1, "maximum": 65535 },
|
|
18
|
+
"livenessPath": { "type": "string", "pattern": "^/" },
|
|
19
|
+
"readinessPath": { "type": "string", "pattern": "^/" },
|
|
20
|
+
"initialDelaySeconds": { "type": "integer", "minimum": 0 },
|
|
21
|
+
"periodSeconds": { "type": "integer", "minimum": 1 },
|
|
22
|
+
"timeoutSeconds": { "type": "integer", "minimum": 1 },
|
|
23
|
+
"failureThreshold": { "type": "integer", "minimum": 1 }
|
|
24
|
+
}
|
|
25
|
+
},
|
|
14
26
|
"hostDevices": {
|
|
15
27
|
"type": "array", "uniqueItems": true,
|
|
16
28
|
"items": { "type": "string", "pattern": "^/dev/([a-zA-Z0-9_-][a-zA-Z0-9_.-]*/)*[a-zA-Z0-9_-][a-zA-Z0-9_.-]*$" }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yarkivaev/scada",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "SCADA domain objects, state persistence, and plant monitoring",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -15,11 +15,16 @@
|
|
|
15
15
|
"./startTestEdgeApi": "./src/infrastructure/http/edge/startTestEdgeApi.js",
|
|
16
16
|
"./package.json": "./package.json",
|
|
17
17
|
"./rabbitmq/definitions": "./src/infrastructure/messaging/rabbitmq/definitions.js",
|
|
18
|
-
"./rabbitmq/reconcile": "./src/infrastructure/messaging/rabbitmq/reconcile.js"
|
|
18
|
+
"./rabbitmq/reconcile": "./src/infrastructure/messaging/rabbitmq/reconcile.js",
|
|
19
|
+
"./opcua": "./src/infrastructure/opcua/index.js",
|
|
20
|
+
"./opcua/config": "./src/infrastructure/opcua/config.js",
|
|
21
|
+
"./runtime": "./src/application/runtime/index.js",
|
|
22
|
+
"./streams": "./src/infrastructure/streams/index.js"
|
|
19
23
|
},
|
|
20
24
|
"bin": {
|
|
21
25
|
"modbus-to-mqtt": "./src/bin/modbus-to-mqtt.js",
|
|
22
|
-
"supervisor-sink": "./src/bin/supervisor-sink.js"
|
|
26
|
+
"supervisor-sink": "./src/bin/supervisor-sink.js",
|
|
27
|
+
"scada-collector": "./src/bin/collector.js"
|
|
23
28
|
},
|
|
24
29
|
"scripts": {
|
|
25
30
|
"test": "mocha --exit 'test/**/test*.js'",
|
|
@@ -33,15 +38,19 @@
|
|
|
33
38
|
"@clickhouse/client": "^1.0.0",
|
|
34
39
|
"@modelcontextprotocol/sdk": "^1.24.3",
|
|
35
40
|
"@yarkivaev/simple-server": "1.0.2",
|
|
36
|
-
"@yarkivaev/source-to-sink": "
|
|
41
|
+
"@yarkivaev/source-to-sink": "2.0.0",
|
|
37
42
|
"amqplib": "^0.10.9",
|
|
43
|
+
"better-sqlite3": "^11.0.0",
|
|
44
|
+
"modbus-serial": "^8.0.0",
|
|
45
|
+
"mqtt": "^5.0.0",
|
|
46
|
+
"node-opcua": "2.179.0",
|
|
38
47
|
"pg": "^8.0.0",
|
|
48
|
+
"serialport": "^12.0.0",
|
|
39
49
|
"stompit": "^1.0.0",
|
|
40
50
|
"zod": "^4.6.7"
|
|
41
51
|
},
|
|
42
52
|
"devDependencies": {
|
|
43
53
|
"@eslint/js": "^9.39.2",
|
|
44
|
-
"better-sqlite3": "^11.0.0",
|
|
45
54
|
"c8": "^10.1.3",
|
|
46
55
|
"cassandra-driver": "^4.7.2",
|
|
47
56
|
"eslint": "^9.39.2",
|
|
@@ -23,7 +23,7 @@ function grouped(machines, describe) {
|
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* describe(row) returns a process descriptor with a name, command and optional
|
|
26
|
-
* env and
|
|
26
|
+
* env, resources and image. All sources describing the same process must agree.
|
|
27
27
|
* project(descriptor, rows) supplies adapter-specific env and optional hostDevices.
|
|
28
28
|
* A physical device may appear in many source rows but belongs to only one process.
|
|
29
29
|
*/
|
|
@@ -37,6 +37,7 @@ export default function collectorProcesses(machines, describe, project) {
|
|
|
37
37
|
devices.add(device);
|
|
38
38
|
});
|
|
39
39
|
return { name: descriptor.name, command: descriptor.command, env: { ...descriptor.env, ...settings.env },
|
|
40
|
-
hostDevices, resources: descriptor.resources
|
|
40
|
+
hostDevices, resources: descriptor.resources,
|
|
41
|
+
...(descriptor.image === undefined ? {} : { image: descriptor.image }) };
|
|
41
42
|
});
|
|
42
43
|
}
|
|
@@ -4,10 +4,13 @@ import applyIncludes from './applyIncludes.js';
|
|
|
4
4
|
* Parses one scalar token.
|
|
5
5
|
*
|
|
6
6
|
* @param {string} text - raw scalar
|
|
7
|
-
* @returns {string|number|boolean|null} scalar
|
|
7
|
+
* @returns {string|number|boolean|null|Array|object} scalar
|
|
8
8
|
*/
|
|
9
9
|
function scalar(text) {
|
|
10
10
|
const trimmed = text.trim();
|
|
11
|
+
if (trimmed === '[]' || trimmed === '{}') {
|
|
12
|
+
return trimmed === '[]' ? [] : {};
|
|
13
|
+
}
|
|
11
14
|
if (trimmed === 'true') {
|
|
12
15
|
return true;
|
|
13
16
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import http from 'http';
|
|
2
|
-
import { stompSource } from '
|
|
2
|
+
import { stompSource } from '../infrastructure/streams/index.js';
|
|
3
3
|
import plantApi from './plantApi.js';
|
|
4
4
|
import pgAlerts from '../infrastructure/persistence/pg/alerts.js';
|
|
5
5
|
import postgresPool from '../infrastructure/persistence/postgresPool.js';
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { clickhouseSink } from '../../infrastructure/streams/index.js';
|
|
2
|
+
import amqpMetricsIngest from '../../infrastructure/ingest/telemetry/amqpMetricsIngest.js';
|
|
3
|
+
import operationSyncIngest from '../../infrastructure/sync/operationSyncIngest.js';
|
|
4
|
+
import operationsFromPg from '../../infrastructure/persistence/pg/operations.js';
|
|
5
|
+
import { metricsSinkFromPool } from '../../infrastructure/persistence/pg/metrics.js';
|
|
6
|
+
import postgresPool from '../../infrastructure/persistence/postgresPool.js';
|
|
7
|
+
import foldedMetricsSink from '../foldedMetricsSink.js';
|
|
8
|
+
import silentStreams from '../../infrastructure/ingest/modbus/silentStreams.js';
|
|
9
|
+
import mx210Tcp from '../../infrastructure/ingest/modbus/mx210Tcp.js';
|
|
10
|
+
import segmentPipeline from '../../infrastructure/ingest/pipelines/segmentPipeline.js';
|
|
11
|
+
import decisionPipeline from '../../infrastructure/ingest/pipelines/decisionPipeline.js';
|
|
12
|
+
import alertPipeline from '../../infrastructure/ingest/pipelines/alertPipeline.js';
|
|
13
|
+
import resolveSecrets from './resolveSecrets.js';
|
|
14
|
+
|
|
15
|
+
function urlOptions(spec) {
|
|
16
|
+
const protocols = { amqp: ['amqp:', 'amqps:'], stomp: ['stomp:', 'stomps:'],
|
|
17
|
+
postgres: ['postgres:', 'postgresql:'], clickhouse: ['http:', 'https:'] };
|
|
18
|
+
Object.entries(protocols).forEach(([key, allowed]) => {
|
|
19
|
+
const value = spec[key];
|
|
20
|
+
if (value === undefined || value?.$secret) { return; }
|
|
21
|
+
if (typeof value !== 'string' || !allowed.includes(new URL(value).protocol)) { throw new TypeError(`Invalid ${key} URL`); }
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
function boundedOptions(spec) {
|
|
25
|
+
const maxRecords = Math.min(spec.options?.maxRecords ?? 1000, spec.limits?.maxRecords ?? 1024);
|
|
26
|
+
const maxBytes = Math.min(spec.options?.maxBytes ?? 1048576, spec.limits?.maxBytes ?? 1048576);
|
|
27
|
+
if (!Number.isSafeInteger(maxRecords) || maxRecords < 1 || !Number.isSafeInteger(maxBytes) || maxBytes < 1) {
|
|
28
|
+
throw new TypeError('Ingest limits must be positive safe integers');
|
|
29
|
+
}
|
|
30
|
+
return { ...spec.options, maxRecords, maxBytes, maxMessageBytes: maxBytes, maxPendingBytes: maxBytes, size: Math.min(spec.options?.size ?? 100, maxRecords),
|
|
31
|
+
prefetch: Math.min(spec.options?.prefetch ?? 32, maxRecords) };
|
|
32
|
+
}
|
|
33
|
+
function ownedSink(pipeline, sink) {
|
|
34
|
+
return { start() { return pipeline.start(); }, async stop() {
|
|
35
|
+
try { await pipeline.stop(); } finally { await sink.stop?.(); }
|
|
36
|
+
} };
|
|
37
|
+
}
|
|
38
|
+
function together(items) {
|
|
39
|
+
return {
|
|
40
|
+
start() { return Promise.all(items.map((item) => { return item.start(); })); },
|
|
41
|
+
async stop() {
|
|
42
|
+
const results = await Promise.allSettled(items.map((item) => { return item.stop(); }));
|
|
43
|
+
const failure = results.find((result) => { return result.status === 'rejected'; });
|
|
44
|
+
if (failure) { throw failure.reason; }
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function borrowed(pool) {
|
|
49
|
+
return { query(...args) { return pool.query(...args); }, connect() { return pool.connect(); },
|
|
50
|
+
end() { return Promise.resolve(); } };
|
|
51
|
+
}
|
|
52
|
+
function telemetry(spec, pool) {
|
|
53
|
+
const inner = spec.clickhouse ? clickhouseSink(spec.clickhouse, spec.table ?? 'scada.metrics') : metricsSinkFromPool(pool);
|
|
54
|
+
return foldedMetricsSink(inner, pool, spec.devices);
|
|
55
|
+
}
|
|
56
|
+
function metrics(spec, pool, context) {
|
|
57
|
+
const sink = telemetry(spec, pool);
|
|
58
|
+
return ownedSink(amqpMetricsIngest(spec.amqp, spec.queue, sink, { ...boundedOptions(spec), budget: context.budget }), sink);
|
|
59
|
+
}
|
|
60
|
+
function silent(spec, pool, context) {
|
|
61
|
+
const sink = telemetry(spec, pool);
|
|
62
|
+
const streams = silentStreams({ budget: spec.budget, interval: spec.interval,
|
|
63
|
+
sources: spec.sources.map((source) => { return mx210Tcp(source.name, source.host, source.port); }),
|
|
64
|
+
onError() { context.report?.({ state: 'degraded' }); } });
|
|
65
|
+
streams.bind(sink);
|
|
66
|
+
const ingest = amqpMetricsIngest(spec.amqp, spec.queue, sink, {
|
|
67
|
+
...boundedOptions(spec), budget: context.budget, onSeen(name) { streams.seen(name); }
|
|
68
|
+
});
|
|
69
|
+
return ownedSink(together([ingest, streams]), sink);
|
|
70
|
+
}
|
|
71
|
+
function stomp(spec, pool, context) {
|
|
72
|
+
const shared = borrowed(pool);
|
|
73
|
+
const config = { ...spec.config, ...boundedOptions({ ...spec, options: spec.config }), budget: context.budget };
|
|
74
|
+
return together([
|
|
75
|
+
segmentPipeline(spec.stomp, spec.postgres, shared, config),
|
|
76
|
+
decisionPipeline(spec.stomp, shared, config),
|
|
77
|
+
alertPipeline(spec.stomp, shared, config)
|
|
78
|
+
]);
|
|
79
|
+
}
|
|
80
|
+
function close(state) {
|
|
81
|
+
state.closing ??= Promise.resolve().then(async () => {
|
|
82
|
+
try { await state.pipeline?.stop(); } finally { await state.pool?.end(); }
|
|
83
|
+
});
|
|
84
|
+
return state.closing;
|
|
85
|
+
}
|
|
86
|
+
function task(factory, spec, context) {
|
|
87
|
+
const state = { pipeline: null, pool: null, stopped: false };
|
|
88
|
+
return {
|
|
89
|
+
async start() {
|
|
90
|
+
if (state.stopped) { throw new Error('Ingest task stopped'); }
|
|
91
|
+
context.signal.throwIfAborted();
|
|
92
|
+
const resolved = resolveSecrets(spec, context.secrets);
|
|
93
|
+
urlOptions(resolved);
|
|
94
|
+
state.pool = resolved.postgres ? postgresPool({ connectionString: resolved.postgres }) : null;
|
|
95
|
+
try {
|
|
96
|
+
state.pipeline = factory(resolved, state.pool, context);
|
|
97
|
+
await state.pipeline.start();
|
|
98
|
+
context.signal.throwIfAborted();
|
|
99
|
+
} catch (error) {
|
|
100
|
+
await close(state);
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
async stop() {
|
|
105
|
+
state.stopped = true;
|
|
106
|
+
await close(state);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function required(spec, keys) {
|
|
111
|
+
keys.forEach((key) => { if (!spec[key]) { throw new TypeError(`Missing ingest ${key}`); } });
|
|
112
|
+
}
|
|
113
|
+
function silentConfig(spec) {
|
|
114
|
+
if (!spec.clickhouse && !spec.postgres) { throw new TypeError('Missing silent metrics storage'); }
|
|
115
|
+
if (spec.devices && Object.keys(spec.devices).length && !spec.postgres) { throw new TypeError('Missing folding postgres'); }
|
|
116
|
+
if (![spec.budget, spec.interval].every((value) => { return Number.isFinite(value) && value > 0; })) {
|
|
117
|
+
throw new TypeError('Silent budget and interval must be positive');
|
|
118
|
+
}
|
|
119
|
+
if (!Array.isArray(spec.sources) || !spec.sources.length || spec.sources.some((source) => {
|
|
120
|
+
return !source.name || !source.host || !Number.isInteger(source.port) || source.port < 1 || source.port > 65535;
|
|
121
|
+
})) { throw new TypeError('Invalid silent source'); }
|
|
122
|
+
}
|
|
123
|
+
function descriptor(factory, keys, validate = () => { return undefined; }, resources = () => { return []; }) {
|
|
124
|
+
return { validate(spec) { required(spec, keys); urlOptions(spec); boundedOptions(spec); validate(spec); }, resources,
|
|
125
|
+
create(spec, context) { return task(factory, spec, context); } };
|
|
126
|
+
}
|
|
127
|
+
/** Portable consumers previously embedded in site HTTP processes; migrations remain external. */
|
|
128
|
+
export default function additionalAdapters() {
|
|
129
|
+
return new Map([
|
|
130
|
+
['operation-sync', descriptor((spec, pool, context) => {
|
|
131
|
+
return operationSyncIngest(spec.amqp, spec.queue, operationsFromPg(pool), { ...boundedOptions(spec), budget: context.budget });
|
|
132
|
+
}, ['amqp', 'queue', 'postgres'])],
|
|
133
|
+
['amqp-pg-metrics', descriptor(metrics, ['amqp', 'queue', 'postgres'])],
|
|
134
|
+
['silent-streams', descriptor(silent, ['amqp', 'queue', 'sources'], silentConfig, (spec) => {
|
|
135
|
+
return [...new Set(spec.sources.map((source) => { return `modbus-tcp:${source.host}:${source.port}`; }))];
|
|
136
|
+
})],
|
|
137
|
+
['stomp-ingest', descriptor(stomp, ['stomp', 'postgres', 'config'])]
|
|
138
|
+
]);
|
|
139
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
function canonical(value) {
|
|
4
|
+
if (Array.isArray(value)) { return value.map(canonical); }
|
|
5
|
+
if (value && typeof value === 'object') {
|
|
6
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => { return [key, canonical(value[key])]; }));
|
|
7
|
+
}
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
export function identity(input) {
|
|
11
|
+
if (!input || typeof input.taskId !== 'string' || !/^[\w./:-]{1,200}$/u.test(input.taskId)) {
|
|
12
|
+
throw new TypeError('Invalid taskId');
|
|
13
|
+
}
|
|
14
|
+
if (!Number.isSafeInteger(input.generation) || input.generation < 0) { throw new TypeError('Invalid generation'); }
|
|
15
|
+
}
|
|
16
|
+
export default function assignment(input, registry) {
|
|
17
|
+
identity(input);
|
|
18
|
+
if (input.specVersion !== 1) { throw new TypeError('Unsupported specVersion'); }
|
|
19
|
+
const json = JSON.stringify(input.spec);
|
|
20
|
+
if (!json || Buffer.byteLength(json) > 1048576) { throw new TypeError('Invalid or oversized task spec'); }
|
|
21
|
+
const spec = JSON.parse(json);
|
|
22
|
+
Object.entries(spec.limits ?? {}).forEach(([key, value]) => {
|
|
23
|
+
if (!Number.isSafeInteger(value) || value < 1) { throw new TypeError(`Invalid ${key}`); }
|
|
24
|
+
});
|
|
25
|
+
const adapter = registry.get(spec.adapter);
|
|
26
|
+
if (!adapter) { throw new TypeError('Unknown task adapter'); }
|
|
27
|
+
const hash = createHash('sha256').update(JSON.stringify(canonical(spec))).digest('hex');
|
|
28
|
+
if (input.specHash && input.specHash !== hash) { throw new TypeError('Invalid specHash'); }
|
|
29
|
+
return { ...input, spec, specHash: hash, adapter };
|
|
30
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Reservation follows the driver's actual promise, not an outer timeout race. */
|
|
2
|
+
export default function budgetedSink(sink, budget, countRecords = true) {
|
|
3
|
+
return {
|
|
4
|
+
start() { return sink.start?.(); },
|
|
5
|
+
stop() { return sink.stop?.(); },
|
|
6
|
+
write(records, options) {
|
|
7
|
+
try {
|
|
8
|
+
options?.signal?.throwIfAborted();
|
|
9
|
+
const release = budget.reserve({ bytes: Buffer.byteLength(JSON.stringify(records)), records: countRecords ? records.length : 0 });
|
|
10
|
+
return Promise.resolve().then(() => {
|
|
11
|
+
options?.signal?.throwIfAborted();
|
|
12
|
+
return sink.write(records, options);
|
|
13
|
+
}).finally(release);
|
|
14
|
+
} catch (error) { return Promise.reject(error); }
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Only the executable runner owns process-global signal handlers. */
|
|
2
|
+
export default function collectorSignals(collector) {
|
|
3
|
+
function report(error) { process.stderr.write(`collector: ${error.name}\n`); }
|
|
4
|
+
function stop() { void collector.stop().then(() => { process.exit(0); }, () => { process.exit(1); }); }
|
|
5
|
+
function reload() { void collector.reload().catch(report); }
|
|
6
|
+
process.once('SIGTERM', stop);
|
|
7
|
+
process.once('SIGINT', stop);
|
|
8
|
+
process.on('SIGHUP', reload);
|
|
9
|
+
return () => {
|
|
10
|
+
process.off('SIGTERM', stop);
|
|
11
|
+
process.off('SIGINT', stop);
|
|
12
|
+
process.off('SIGHUP', reload);
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
async function close(state) {
|
|
2
|
+
const failures = [];
|
|
3
|
+
for (const part of [...state.opened].reverse()) {
|
|
4
|
+
try {
|
|
5
|
+
// Reverse startup order; every owned resource receives a stop attempt.
|
|
6
|
+
// eslint-disable-next-line no-await-in-loop
|
|
7
|
+
await part.stop();
|
|
8
|
+
} catch (error) { failures.push(error); }
|
|
9
|
+
}
|
|
10
|
+
if (failures.length) { throw new AggregateError(failures, 'Composite cleanup failed'); }
|
|
11
|
+
state.opened = [];
|
|
12
|
+
}
|
|
13
|
+
function cleanup(state) {
|
|
14
|
+
state.closing ??= close(state);
|
|
15
|
+
return state.closing;
|
|
16
|
+
}
|
|
17
|
+
async function open(state, parts) {
|
|
18
|
+
try {
|
|
19
|
+
for (const part of parts) {
|
|
20
|
+
if (state.stopped) { throw new Error('Composite startup aborted'); }
|
|
21
|
+
state.opened.push(part);
|
|
22
|
+
// Register ownership before awaiting potentially cancellable startup.
|
|
23
|
+
// eslint-disable-next-line no-await-in-loop
|
|
24
|
+
await part.start();
|
|
25
|
+
if (state.stopped) { throw new Error('Composite startup aborted'); }
|
|
26
|
+
}
|
|
27
|
+
} catch (error) {
|
|
28
|
+
try { await cleanup(state); } catch (failure) { throw new AggregateError([error, failure], 'Start and cleanup failed'); }
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Stop attempts cancellation immediately, then waits for startup to settle. */
|
|
33
|
+
export default function compositeLifecycle({ inputs, outputs }) {
|
|
34
|
+
const state = { opened: [], starting: null, closing: null, stopping: null, stopped: false };
|
|
35
|
+
return {
|
|
36
|
+
start() {
|
|
37
|
+
if (state.stopped) { return Promise.reject(new Error('Composite stopped')); }
|
|
38
|
+
state.starting ??= open(state, [...outputs, ...inputs]);
|
|
39
|
+
return state.starting;
|
|
40
|
+
},
|
|
41
|
+
stop() {
|
|
42
|
+
state.stopped = true;
|
|
43
|
+
state.stopping ??= Promise.all([cleanup(state), Promise.resolve(state.starting).catch(() => {
|
|
44
|
+
// Startup failure remains visible to its own caller.
|
|
45
|
+
})]);
|
|
46
|
+
return state.stopping;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { resources, sinks, sources } from './streamPorts.js';
|
|
2
|
+
import pipelineAdapters from './pipelineAdapters.js';
|
|
3
|
+
import additionalAdapters from './additionalAdapters.js';
|
|
4
|
+
import streamTask from './streamTask.js';
|
|
5
|
+
|
|
6
|
+
function extend(base, additions = new Map()) {
|
|
7
|
+
additions.forEach((value, key) => {
|
|
8
|
+
if (base.has(key)) { throw new Error(`duplicate runtime adapter: ${key}`); }
|
|
9
|
+
base.set(key, value);
|
|
10
|
+
});
|
|
11
|
+
return base;
|
|
12
|
+
}
|
|
13
|
+
/** Same protocol capabilities on every executor; only registered extensions may run. */
|
|
14
|
+
export default function createRegistry(options = {}) {
|
|
15
|
+
const ports = { sources: extend(sources(), options.sources), sinks: extend(sinks(), options.sinks), transforms: options.transforms ?? new Map() };
|
|
16
|
+
const stream = streamTask(ports);
|
|
17
|
+
const registry = extend(pipelineAdapters(), additionalAdapters());
|
|
18
|
+
registry.set('stream', { ...stream, resources(spec) { return [...new Set([...resources(spec), ...(spec.resources ?? [])])]; } });
|
|
19
|
+
return extend(registry, options.adapters);
|
|
20
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Bounds waiting; callers must also abort/close resources and fence late results. */
|
|
2
|
+
export default function deadline(action, milliseconds) {
|
|
3
|
+
let timer;
|
|
4
|
+
const timeout = new Promise((resolve, reject) => {
|
|
5
|
+
timer = setTimeout(() => { reject(new Error('Task operation timed out')); }, milliseconds);
|
|
6
|
+
});
|
|
7
|
+
return Promise.race([Promise.resolve().then(action), timeout]).finally(() => { clearTimeout(timer); });
|
|
8
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { default as createRuntime } from './runtime.js';
|
|
2
|
+
export { default as taskBudget } from './taskBudget.js';
|
|
3
|
+
export { default as taskState } from './taskState.js';
|
|
4
|
+
export { default as streamTask } from './streamTask.js';
|
|
5
|
+
export { default as compositeLifecycle } from './compositeLifecycle.js';
|
|
6
|
+
export { default as runCollector } from './runCollector.js';
|
|
7
|
+
export { default as taskQueue } from './taskQueue.js';
|
|
8
|
+
export { default as createRegistry } from './createRegistry.js';
|
|
9
|
+
export { default as resolveSecrets } from './resolveSecrets.js';
|
|
10
|
+
export { default as taskStatePg } from '../../infrastructure/persistence/runtime/taskStatePg.js';
|