system-arbex-js (platform, system-utils)
system-arbex-js is a platform-only transform node template on system-utils. It runs sandboxed JavaScript (Node.js) configured on the node instance, reads upstream data through typed input readers, and writes a JSON arbex envelope as its node output.
Typical pipeline:
upstream → system-arbex-js → downstream (reads envelope JSON)Authoring types and examples live in the monorepo under core/api/Runner/Arbex/sdk/. External IDE corpora: tools/arbex/rag/.
Output envelope and node status
Every run produces:
| Field | Description |
|---|---|
output | JSON-serialisable return value from run(ctx), or error object |
console | Captured log / info / warn / error |
status | SUCCESS or ERROR |
metrics.executionTimeMs | Wall time |
configurationSnapshotBase64 | Base64 (UTF-8) of the full node configuration JSON at execution time (script, parent, limits). Lets UIs show which code produced a historical output after the live config is edited. |
When dataserviceOutput.persistence is dataservice-root, the stored file is the dataservice root plus a sibling $heat-arbex-run object with configurationSnapshotBase64, status, console, metrics, and profiler (envelope fields that would otherwise be dropped).
Runtime profiler (profiler)
Enabled by default. While run(ctx) executes, the Node host samples about every 50ms (configurable):
| Field | Source | Meaning |
|---|---|---|
profiler.samples[].heapUsedBytes | V8 | JS heap used |
profiler.samples[].rssBytes | Node | Process resident set |
profiler.samples[].cpuUserMicros / cpuSystemMicros | Node | CPU time in the interval |
profiler.samples[].location | Stack (best effort) | Top frame in arbex-user-script.js (line/column) when the event loop ticks |
profiler.hotspots[] | V8 CPU profile (end of run) | Per line/function/column with selfTimeUs, totalTimeUs (inclusive stack); up to 50 entries |
profiler.processSamples[] | C# host | Child process working set and CPU% (includes bridge IPC) |
Config (optional):
{
"profiler": {
"enabled": true,
"sampleIntervalMs": 50,
"maxSamples": 600
}
}Limits: Samples run on the Node event loop. Long synchronous loops block ticks, so line/heap samples may be sparse until the script awaits or finishes. Use hotspots for aggregate CPU line cost; use samples for memory and async-time line hints. The Arbex IDE can chart atMs vs heapUsedBytes and scrub location.line.
status | Node LastState |
|---|---|
SUCCESS | ProcessingSucceeded |
ERROR | ProcessingFailed |
Downstream nodes can inspect status even when the node is marked failed.
Host failures (no usable envelope from Node)
When the C# host cannot read or parse the child process envelope, status is ERROR and output includes structured diagnostics:
| Field | Description |
|---|---|
output.message | Short summary (for example Node process exited unexpectedly) |
output.reason | Machine-readable code: empty_stdout, deserialize_failed, no_envelope_line, timeout, memory_limit, host_not_found, … |
output.details | Full human-readable explanation |
output.exitCode | Node process exit code when applicable |
output.hostDiagnostics | rpcLineCount, stderrPreview, stdoutPreview, parse failure detail, … |
console[] | Same text as details, plus a separate stderr: entry when Node wrote stderr |
The system-utils runner also logs a Warning with the summary and sets the node status message to system-arbex-js failed: … (not only ERROR). Check the persisted output JSON or agentic-cli output <id> first; pod logs are a secondary hint.
Node configuration
| Property | Required | Default | Description |
|---|---|---|---|
script | yes | , | JS defining async function run(ctx) { ... } |
parent.nodeInstanceName | yes | , | Parent node graph name |
parent.mode | yes | , | latest or history |
parent.historyLimit | no | 60 | Max outputs listed (metadata only) |
parent.historyOrder | no | newestFirst | newestFirst or oldestFirst |
parent.readerHint | no | auto | auto | json | tabular | binary |
parent.tabularOptions | no | , | { delimiter, inputFormat } for CSV/TSV |
timeoutMs | no | 5000 | Kill Node child after this many ms |
maxScriptChars | no | 131072 | Max script size |
maxMemoryMb | no | 512 | C# watchdog kills Node before pod OOM (max 640; system-utils pod limit 768Mi) |
Example:
{
"script": "async function run(ctx) { return { ok: true }; }",
"parent": { "nodeInstanceName": "metrics-upstream", "mode": "latest" },
"timeoutMs": 10000,
"maxMemoryMb": 512
}run(ctx) context
Scripting API (context, heat.dataservice, heat.layout, pipelines): Arbex API reference.
| Property | Description |
|---|---|
apiVersion | "1.0" |
session | id, projectId, simulationName, template, configuration, nodes[] (no email/display name) |
session.nodes[] | { instanceId, instanceName, templateName, lastState, statusDetails } for every node in the session |
system | Operator triage helpers (on by default; see below) |
node | instanceId, instanceName, templateName, taskId, configuration |
parent | Resolved parent config block |
parents | { name, templateName, instanceId }[] |
inputs.latest | Single handle when mode: latest (or null) |
inputs.history | Iterable handles when mode: history |
Input handles
| Method | Returns |
|---|---|
openJsonReader() | { kind: 'json', root } |
openTabularReader(opts?) | { kind: 'tabular', headers, readRow(), [asyncIterator] } |
openBinaryReader() | { kind: 'binary', byteLength, base64, contentType } |
detectedKind hints: json | tabular | binary. Only one reader open per handle at a time.
HEAT_ARBEX symbols and heat helpers
HEAT_ARBEX.isJsonReader(r)/isTabularReader/isBinaryReader/isInputHandleheat.getPath(obj, dottedPath)heat.coerceNumber(v, fallback?)heat.parseIso8601(s)heat.jmespath.search(expr, data)(alsojmespath.searchglobal)heat.dataservice.createBuilder(options), build schema-valid$heat-dataservicepayloads for the Next dashboard (direct ingest)
Symbol keys: heat.arbex.reader.json, .reader.tabular, .reader.binary, .input.handle, etc.
system context (live cluster triage)
Platform operator use. On every system-arbex-js node, ctx.system is available by default with read-only helpers backed by the system-utils Kubernetes service account and in-process platform configuration. Set system.enabled: false to disable.
{
"script": "async function run(ctx) { ... }",
"parent": { "nodeInstanceName": "upstream", "mode": "latest" },
"system": {
"k8s": {
"namespaces": ["heat"],
"maxItems": 200
},
"configPrefixes": ["system."]
}
}| Property | Description |
|---|---|
system.enabled | Default true. Set false to omit ctx.system and block system.* bridge ops |
ctx.system.version | Always present when system context is on: platform build metadata (version, buildUtc, commitSuffix, …), same shape as GET /version |
system.k8s.namespaces | Namespace allowlist for list calls. When omitted, defaults to the in-cluster pod namespace |
system.k8s.maxItems | Cap per list operation (default 200, max 2000) |
system.configPrefixes | Allowlisted platform config prefixes for ctx.system.config.getPrefix. Required for config reads |
ctx.system.version
Structured platform build metadata from build-info/heat-platform-build.json (generated by deploy/build_all.sh). No bridge call; snapshot at run start.
const { version, commitSuffix, buildUtc } = ctx.system.version;
return { platformVersion: version, commit: commitSuffix, builtAt: buildUtc };ctx.system.k8s
Summaries use the same JSON shapes as cluster-report (not raw Kubernetes objects).
| Method | Description |
|---|---|
listPods({ namespace?, labelSelector? }) | Pod summaries |
listDeployments({ namespace? }) | Deployment summaries |
listStatefulSets({ namespace? }) | StatefulSet summaries |
listPersistentVolumeClaims({ namespace? }) | PVC summaries |
listIngresses({ namespace? }) | Ingress summaries |
listServices({ namespace? }) | Service summaries |
listNamespaces() | Namespace names and phases |
listNodes() | Cluster node summaries |
getPodMetrics() | Raw metrics.k8s.io pod metrics object |
namespace must appear in system.k8s.namespaces when that allowlist is configured.
Example:
async function run(ctx) {
if (!ctx.system) return { error: "system disabled (system.enabled: false)" };
const ns = ctx.system.k8sNamespaces[0];
const pods = await ctx.system.k8s.listPods({ namespace: ns });
const failing = pods.filter((p) => p.phase !== "Running");
return { failingCount: failing.length, failing };
}ctx.system.k8s.exec (opt-in)
Pod exec is disabled by default. Enable explicitly on the node instance:
"system": {
"k8s": {
"namespaces": ["heat"],
"exec": {
"enabled": true,
"allowClusterManager": true,
"podNamePrefixes": ["cluster-manager-"],
"timeoutMs": 30000,
"maxOutputChars": 65536
}
}
}| Method | Description |
|---|---|
execClusterManager({ namespace?, container?, command[] }) | Finds a Running app=cluster-manager pod and runs argv (one-shot stdout/stderr) |
exec({ namespace?, podName, container?, command[] }) | Exec into an allowlisted pod name prefix |
command must be an argv array (not a single shell string), for example ["/bin/sh", "-c", "curl -s localhost:3000/health"].
Returns { podName, namespace, container, stdout, stderr, exitCode }. Requires system-utils RBAC pods/exec and in-cluster execution.
ctx.system.config
When system.configPrefixes is set, ctx.system.config.getPrefix(prefix) returns { name, valueType, value, description }[]. Values containing secret or connectionstring in the key name are redacted (**REDACTED**). Only prefixes listed in node config are allowed.
Optional: native $heat-dataservice output
Scripts can emit a dashboard v2 payload without a separate heat-system-dataservice-envelope node. The builder supports every direct-ingest channel shape (not only time series):
shape | Builder method | Use case |
|---|---|---|
series | ds.series(spec, points) | Time-varying numeric or categorical playback |
value | ds.value(spec, payload) | KPIs, bar chart data, arbitrary JSON under data.value |
events | ds.events(spec, points) | Instantaneous boolean events |
timestamps | ds.timestamps(spec, points) | Labelled instants |
ranges | ds.ranges(spec, points) | Phases / intervals |
Per-channel realm (optional) maps to realms[].name. metadata on the channel spec is passed through to widgets (units, channelType, and so on).
async function run(ctx) {
const ds = heat.dataservice.createBuilder({ defaultRealm: "default" });
ds.addGroup("ops", "Operations");
ds.value({ id: "kpis", name: "KPIs", groupId: "ops" }, { podCount: 4 });
ds.series({ id: "cpu", name: "CPU", groupId: "ops", realm: "run-a" }, [
{ timeMs: 1000, value: 1.2 },
]);
return ds.buildRoot(); // { $heat-dataservice, dashboard_users: [] }
}Node configuration: dataserviceOutput
| Property | Default | Description |
|---|---|---|
enabled | false | Validate script output when it contains $heat-dataservice |
persistence | arbex-envelope | arbex-envelope | dataservice-root | dataservice-in-envelope |
dashboardUsers | [] | Applied when the script omits dashboard_users |
layoutOutput.enabled | false | When true, validate suggestedLayoutConfiguration on the script return (v2 layout) |
layoutOutput.require | false | When true with layoutOutput.enabled, the script must include suggestedLayoutConfiguration |
dataservice-root: node output file is the root object (forheat-system-next-dimension/ Next dimension downstream).arbex-envelope/dataservice-in-envelope: file is the arbex envelope; validated dataservice lives inoutput.
C# reuses the same normalization as heat-system-dataservice-envelope. See dashboard v2 contract and Next data service (UI).
Example: sdk/examples/dataservice-multi-shape.js
Layout builder (heat.layout)
heat.layout builds v2 layoutConfiguration JSON for heat-system-next-dimension (Next dashboard grid). Canonical sources live in ui/lib/heat-layout-builder/ and are synced into the arbex node host before core-api image builds (deploy/sync-arbex-layout-lib.sh).
| API | Description |
|---|---|
heat.layout.createBuilder(options?) | Start a layout; options include version, realms, configuration |
.addRow(opts?) | Append a grid row |
.addComposableChart(spec) | Fluent ComposableChart column with slice helpers (addAreaSlice, addEventsLaneSlice, …) |
.addColumn(componentId, config) | Generic v2 column (BarChart, MapDisplay, …) with widget keys such as barChartItem |
.build() | Returns layout JSON matching the layout schema |
Emitting layout with data (experimental loop)
The platform already supports suggestedLayoutConfiguration on dataservice-root outputs (same key as system-tabular-to-dataservice with emitSuggestedLayout). Pair with heat-system-next-dimension layoutFromParentOutput: true so one arbex run drives both channels and grid layout.
- Node config:
{
"dataserviceOutput": {
"enabled": true,
"persistence": "dataservice-root",
"layoutOutput": { "enabled": true, "require": true }
}
}- Script return:
const layout = heat.layout.createBuilder({ version: "1.0.0", realms: ["default"] })
.addRow()
.addComposableChart({ name: "c", titleContent: "Chart", channels: ["demo-metric"], colspan: 12 })
.addAreaSlice({ order: 1, height: 220 })
.addSeries({ id: "s1", label: "Series", channel: "demo-metric" })
.endComposableChart();
return ds.buildRoot({ suggestedLayoutConfiguration: layout });- Dimension node:
layoutFromParentOutput: true and a minimal fallbacklayoutConfigurationstub (seesystem-tabular-dashboard-autodetect-next).
Static layoutConfiguration on the dimension node remains the preferred production path; layoutOutput is for iteration without editing the dimension config each time.
Example: sdk/examples/layout-composable-from-arbex.js
Sample session template
Shipped preset sample-arbex-composable-dashboard (Name: sample-arbex-composable-dashboard):
sample-input (input-node, any upload)
→ sample-arbex (system-arbex-js, dataserviceOutput.persistence: dataservice-root)
→ sample-dashboard (heat-system-next-dimension, layoutFromParentOutput)- Upload any file to
sample-input(JSON is parsed when detected; other types contribute descriptor metadata only). sample-arbexreads the latest parent output, writes channelinput-summary(shape: value), synthesizes randomserieson channelsynthetic-metric(seeded from session id), and returnssuggestedLayoutConfiguration(ComposableChart viaheat.layout).sample-dashboardopens a Next dimension usinglayoutFromParentOutput: true (fallbacklayoutConfigurationstub is empty rows).
Script source (synced with the template): sdk/examples/sample-arbex-composable-dashboard.js.
Cookbook
Scripts live in sdk/examples/:
- A: latest JSON + JMESPath
- B: latest tabular row aggregation
- C: history over many CSV uploads
- D: node config field extract
- E: binary metadata
- F: history with type guards
- G: native
$heat-dataservice(all channel shapes) - H:
sample-arbex-composable-dashboard(upload + synthetic series +heat.layout+layoutFromParentOutput) - I: v2 layout builder (
heat.layout) with ComposableChart slices
Use async function run(ctx) because readers are async over the C# bridge.
Editing outside HEAT
- Start with the Arbex API reference, then use types from the Arbex SDK (
heat-arbex.d.ts) and examples undertools/arbex/rag/. - Add
/// <reference path="heat-arbex.d.ts" />in your script when using an external IDE. - Paste into the node
scriptconfiguration after testing logic locally.
Scripts run in a sandboxed Node host inside the platform runner deployment.
Security and limits
- User script runs in a separate Node process with stripped env (no DB/S3 credentials).
vmcontext whitelist: norequire,process,fs, or network.- C# memory watchdog terminates the child and writes an
ERRORenvelope before Kubernetes OOM kills the pod. - Template authors with config edit rights are trusted; this is not a multi-tenant isolation boundary.
See also
Related platform nodes (future)
system-json-field-to-binary (planned): extract a dotted path from a JSON parent (for example output.csv from an arbex envelope) and register a binary node output for tabular or file consumers.
Changelog
apiVersion 1.0
- Initial
system-arbex-jswith Node host, bridge readers, envelope contract, defaultmaxMemoryMb512.