arkgate 4.8.7 → 4.8.9
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 +92 -3
- package/README.md +41 -6
- package/bin/ark-check-runtime.mjs +22 -0
- package/bin/ark-dashboard.mjs +423 -0
- package/bin/ark-layer-match.mjs +25 -10
- package/bin/ark.mjs +51 -3
- package/bin/lib/agent-homes.mjs +1 -1
- package/bin/lib/analysis-engine.mjs +8 -8
- package/bin/lib/ark-order-sensors.mjs +103 -3
- package/bin/lib/config-extras.mjs +1 -0
- package/bin/lib/contract-smells.mjs +12 -6
- package/bin/lib/doctor-human.mjs +32 -7
- package/bin/lib/doctor-next-actions.mjs +21 -2
- package/bin/lib/gate-files.mjs +108 -22
- package/bin/lib/managed-upgrade.mjs +9 -1
- package/bin/lib/upgrade-command.mjs +17 -4
- package/dist/{configTypes-0eHpocR3.d.ts → configTypes-j7so8B4O.d.ts} +12 -0
- package/dist/{diagnosticCatalog-wDAH08gH.d.ts → diagnosticCatalog-BrkOiwCk.d.ts} +3 -3
- package/dist/eslint/index.cjs +5 -5
- package/dist/eslint/index.d.ts +6 -4
- package/dist/eslint/index.js +5 -5
- package/dist/index.cjs +30 -30
- package/dist/index.d.ts +13 -4
- package/dist/index.js +31 -31
- package/dist/nestjs/index.cjs +5 -5
- package/dist/nestjs/index.d.ts +3 -3
- package/dist/nestjs/index.js +5 -5
- package/dist/order/index.cjs +1 -1
- package/dist/order/index.d.ts +6 -2
- package/dist/order/index.js +1 -1
- package/dist/runtime/index.cjs +15 -15
- package/dist/runtime/index.d.ts +6 -6
- package/dist/runtime/index.js +15 -15
- package/dist/{types-BK47clMl.d.ts → types-Djbs3KjE.d.ts} +1 -1
- package/dist/{types-CwZ_oz1N.d.ts → types-tGhZUiGX.d.ts} +106 -2
- package/docs/README.md +13 -4
- package/docs/agent-guide.md +38 -2
- package/docs/ai-gates.md +13 -1
- package/docs/arkorder.md +41 -8
- package/docs/configuration.md +47 -12
- package/docs/develop.md +17 -8
- package/docs/enthusiast/README.md +13 -2
- package/docs/package-surface.md +20 -6
- package/docs/product-voice.md +25 -2
- package/docs/use.md +11 -3
- package/package.json +3 -1
- package/schemas/ark.config.schema.json +9 -0
- package/server.json +2 -2
- package/templates/agent-skills/README.md +1 -1
- package/templates/agent-skills/ark-adopt/SKILL.md +1 -0
- package/templates/agent-skills/ark-autopilot/SKILL.md +1 -1
- package/templates/agent-skills/ark-contract/SKILL.md +1 -1
- package/templates/agent-skills/ark-explore/SKILL.md +2 -2
- package/templates/agent-skills/ark-place/SKILL.md +1 -0
- package/templates/skills/ark-adopt.md +1 -0
- package/templates/skills/ark-autopilot.md +1 -1
- package/templates/skills/ark-contract.md +1 -1
- package/templates/skills/ark-explore.md +2 -2
- package/templates/skills/ark-place.md +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,95 @@
|
|
|
3
3
|
All notable changes to ArkGate (`arkgate`; formerly `ark-runtime-kernel`) are documented here or
|
|
4
4
|
in the immutable pre-2.0 archive linked below.
|
|
5
5
|
|
|
6
|
+
## 4.8.9 — 2026-09-01
|
|
7
|
+
|
|
8
|
+
**Patch** over **4.8.8**. Field diagnostic honesty: doctor default homedir, live
|
|
9
|
+
`peerIsolation` walls are not dead rules, skippable CI is `ci-not-fail-closed`
|
|
10
|
+
not `missing-gates`, blocked upgrade JSON carries `reasonCode` + `nextCommand`,
|
|
11
|
+
content identity vs raw `afterHash`, cross-layer `peerIsolation` docs, and
|
|
12
|
+
intent-reference checks share the import classifier. Amarilla ArkOrder:
|
|
13
|
+
`appliesTo`, watchlist vs `maxXiKeys`, `restore`, default clock, `hashOf`,
|
|
14
|
+
`.d.ts` honesty, packaged example URL, and ingest-payload honesty.
|
|
15
|
+
**No required config migration.** No `ark.config.json` schema bump. Does not close `K01` / `Z09`.
|
|
16
|
+
No new skill names.
|
|
17
|
+
|
|
18
|
+
**Status: prepared** (tree candidate; npm `latest` remains **4.8.8** until the
|
|
19
|
+
signed-tag provenance workflow succeeds).
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- Optional `arkOrder.appliesTo` globs (same engine as layers). `managedLayers`
|
|
23
|
+
still required; empty or absent `appliesTo` keeps today's all-files-in-layer
|
|
24
|
+
emit; a Persistence write outside the globs stays silent.
|
|
25
|
+
- `OrderPlane.restore(release)` installs a previously frozen `Release` (current
|
|
26
|
+
+ version). Hash remains the identity. Not durable. Does not close `K01`.
|
|
27
|
+
- Public `arkgate/order` exports `hashOf` (alias of `hashReleasePayload`) so a
|
|
28
|
+
stored `Release.hash` can be verified without calling `release()`.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
- `--doctor` no longer throws when `CLAUDE_HOME` / `GROK_HOME` / `ANTIGRAVITY_HOME`
|
|
32
|
+
are unset (`skillsDirFor` now defaults `os.homedir()`).
|
|
33
|
+
- `contract-dead-rule` skips a live `peerIsolation` wall (`allowed: false` on a
|
|
34
|
+
same-layer or cross-layer pair). A self-allow stays dead. The fix text never
|
|
35
|
+
tells an agent to delete that wall.
|
|
36
|
+
- `--strict-merge` / `--require-gates` still fail skippable CI (`if:` other than
|
|
37
|
+
`true` / `always()`), but as `ci-not-fail-closed` (names the workflow file).
|
|
38
|
+
`missing-gates` remains only for files that are actually absent.
|
|
39
|
+
- Blocked `ark upgrade --apply --json` includes `reasonCode: managed-consent-required`
|
|
40
|
+
and a digest-bound `nextCommand` already carrying `--accept-conflicts`. Apply stays
|
|
41
|
+
atomic.
|
|
42
|
+
- `LAYER_INTENT_REFERENCE_VIOLATION` uses the same `peerIsolation` classifier as
|
|
43
|
+
imports (`fromPath` + `sharedRoots`). Shared-root files do not inherit the
|
|
44
|
+
slice-wall message. Cross-slice file imports still deny.
|
|
45
|
+
- Default `createOrderPlane` clock is Kernel `Date.now()` (was `0` with no
|
|
46
|
+
warning). Domain stays clock-free. Tests that need determinism still inject.
|
|
47
|
+
|
|
48
|
+
### Changed
|
|
49
|
+
- Public managed-upgrade assets expose `beforeIdentity` / `afterIdentity`.
|
|
50
|
+
`afterHash` is the SHA-256 of the raw desired bytes; `state: current` is content
|
|
51
|
+
identity, so stamp-only `arkVersion` drift can be current with unequal raw hashes.
|
|
52
|
+
- Docs: a slice wall on a cross-layer edge is a `peerIsolation` rule on that
|
|
53
|
+
`from`/`to` pair (`allowed: false`). Domain files without a slice fail-closed
|
|
54
|
+
unless listed in `sharedRoots`. No new engine mode, key, or skill.
|
|
55
|
+
- `maxXiKeys` caps one `release()` / `assertXiKeyCap` only. `xiKeys` is a
|
|
56
|
+
repo-wide watchlist; eight named keys with `maxXiKeys` 7 is valid config.
|
|
57
|
+
Sensor `arkorder-too-many-params` fires on `release()` `keyCount`, not on
|
|
58
|
+
config list length.
|
|
59
|
+
- Public `dist/order` `.d.ts` no longer says declarations-only / no runtime.
|
|
60
|
+
- Packaged docs point the billing gallery at the GitHub tree
|
|
61
|
+
(`https://github.com/pedroknigge/arkgate/tree/main/examples/arkorder-billing`).
|
|
62
|
+
The folder is not in the npm tarball; `examples/` is not added to
|
|
63
|
+
`package.json` `files`.
|
|
64
|
+
- Ingest payload-dependent escalation stays deliberate: `classifyIngest` uses
|
|
65
|
+
`kind`; `ConstraintPack` forbids user predicates. "Second week failing a
|
|
66
|
+
goal" is a domain/projector kind, not a pack function.
|
|
67
|
+
|
|
68
|
+
## 4.8.8 — 2026-09-01
|
|
69
|
+
|
|
70
|
+
**Patch** over **4.8.7**. ArkRun gains an opt-in terminal dashboard and bounded,
|
|
71
|
+
payload-free inspector monitors. ArkOrder and first-contact docs gain honesty copy.
|
|
72
|
+
**No required config migration.** No `ark.config.json` schema bump. Does not close
|
|
73
|
+
`K01` / `Z09`.
|
|
74
|
+
|
|
75
|
+
**Status: published** (on npm `latest`; see `docs/releases/4.8.8.md`).
|
|
76
|
+
|
|
77
|
+
### Added
|
|
78
|
+
- Dual **`ark-dashboard` / `arkgate-dashboard`** bins render the loopback ArkRun
|
|
79
|
+
inspector with ANSI and polling. `ark dashboard` / `arkgate dashboard` dispatch
|
|
80
|
+
to the same executable; this is a view, never a gate verdict.
|
|
81
|
+
- ArkRun inspector snapshots report explicit store durability facts. Default
|
|
82
|
+
`InMemory*` stores remain visibly memory-only and never earn a durable green.
|
|
83
|
+
- Loopback-only **`GET /outbox`** and **`GET /workflows`** return sanitized queue
|
|
84
|
+
summaries and full counts. Samples are capped at 32, payloads are omitted, the
|
|
85
|
+
inspector still refuses production, and nothing here supplies durable storage.
|
|
86
|
+
|
|
87
|
+
### Changed
|
|
88
|
+
- Existing skills and docs now teach the elimination question before a modeller
|
|
89
|
+
names `xiKeys`: recomputable statuses such as `paid` are derived, not frozen.
|
|
90
|
+
This is guidance, not a runtime invariant or new sensor; the check remains
|
|
91
|
+
silent on semantic entailment.
|
|
92
|
+
- Eight canonical public openings now share the same four plain-English story
|
|
93
|
+
paragraphs. STAR remains the writing method, not a visible heading or labels.
|
|
94
|
+
|
|
6
95
|
## 4.8.7 — 2026-08-31
|
|
7
96
|
|
|
8
97
|
**Patch** over **4.8.6**. Phase **LD** — layer description projection. Optional
|
|
@@ -10,9 +99,9 @@ in the immutable pre-2.0 archive linked below.
|
|
|
10
99
|
product), not architecture jargon. Place, doctor, coverage, and the HTML report
|
|
11
100
|
show it when present. Caption-only edits do not change `policyHash`.
|
|
12
101
|
**No required config migration.** No `schemaVersion` bump. Does not close `K01` / `Z09`.
|
|
13
|
-
No new skill names.
|
|
102
|
+
No new skill names.
|
|
14
103
|
|
|
15
|
-
**Status:
|
|
104
|
+
**Status: published** (see `docs/releases/4.8.7.md`).
|
|
16
105
|
|
|
17
106
|
### Added
|
|
18
107
|
- Public name for existing `layers[].description`. Example:
|
|
@@ -35,7 +124,7 @@ not ξ identity. ingest returns a closed residual. Capacity is data. ArkRun
|
|
|
35
124
|
carries the decision tape. **No required config migration.** Does not close
|
|
36
125
|
`K01` / `Z09`. No `/ark-order` skill.
|
|
37
126
|
|
|
38
|
-
**Status: published** (
|
|
127
|
+
**Status: published** (see `docs/releases/4.8.6.md`).
|
|
39
128
|
|
|
40
129
|
### Added
|
|
41
130
|
- **`apply(ProposeResult)`** on `createOrderPlane`. Unvalved second freeze of a
|
package/README.md
CHANGED
|
@@ -7,6 +7,14 @@
|
|
|
7
7
|
Not an API Gateway. Not a folder linter. If the check is not required on the PR, the config
|
|
8
8
|
is just documentation.
|
|
9
9
|
|
|
10
|
+
AI can build fast—and make a mess just as fast.
|
|
11
|
+
|
|
12
|
+
Keep the product easy to understand, change, and trust.
|
|
13
|
+
|
|
14
|
+
ArkGate stops bad shortcuts. ArkRules protects how each part should behave. ArkRun keeps work moving. ArkOrder protects the few big choices that should not change by accident.
|
|
15
|
+
|
|
16
|
+
Safer changes, fewer surprises, and extra protection only when you choose it.
|
|
17
|
+
|
|
10
18
|
Works with Cursor, Claude, Codex, and Grok.
|
|
11
19
|
|
|
12
20
|
[](https://www.arkgate.online/)
|
|
@@ -29,7 +37,7 @@ Works with Cursor, Claude, Codex, and Grok.
|
|
|
29
37
|
|
|
30
38
|
</div>
|
|
31
39
|
|
|
32
|
-
> **ArkGate 4.8.
|
|
40
|
+
> **ArkGate 4.8.9** is prepared on this tree; npm `latest` remains **4.8.8**.
|
|
33
41
|
> Write. Check. Ship. Adopted = required GitHub
|
|
34
42
|
> status running `arkgate-check --strict-merge`, or an explicit `advisory-only` stance.
|
|
35
43
|
> Status is compact (`arkgate-check --doctor`; `--all` for Details). Optional **ArkRun**
|
|
@@ -37,7 +45,7 @@ Works with Cursor, Claude, Codex, and Grok.
|
|
|
37
45
|
> (`arkgate/order`) stops the agent rewriting the few slow product decisions as CRUD;
|
|
38
46
|
> later pattern change is `proposeRelease` then `apply`.
|
|
39
47
|
> `@arkgate/runtime` is deprecated.
|
|
40
|
-
> [4.8.6](docs/releases/4.8.6.md) · [4.8.5](docs/releases/4.8.5.md) · [4.8.4](docs/releases/4.8.4.md) · [4.8.3](docs/releases/4.8.3.md) · [4.8.2](docs/releases/4.8.2.md) · [4.8.1](docs/releases/4.8.1.md) · [4.8.0](docs/releases/4.8.0.md) · [Docs hub](docs/README.md) · [Voice](docs/product-voice.md)
|
|
48
|
+
> [4.8.9 prepared](docs/releases/4.8.9.md) · [4.8.8 published](docs/releases/4.8.8.md) · [4.8.7](docs/releases/4.8.7.md) · [4.8.6](docs/releases/4.8.6.md) · [4.8.5](docs/releases/4.8.5.md) · [4.8.4](docs/releases/4.8.4.md) · [4.8.3](docs/releases/4.8.3.md) · [4.8.2](docs/releases/4.8.2.md) · [4.8.1](docs/releases/4.8.1.md) · [4.8.0](docs/releases/4.8.0.md) · [Docs hub](docs/README.md) · [Voice](docs/product-voice.md)
|
|
41
49
|
|
|
42
50
|
---
|
|
43
51
|
|
|
@@ -245,6 +253,8 @@ npx arkgate-check --install-agent-gates --tools claude,cursor,codex,grok,antigra
|
|
|
245
253
|
# npx arkgate-check --install-agent-gates --skills-only --agent-homes --force
|
|
246
254
|
# optional: same 13 skills via Agent Skills ecosystem (no new names)
|
|
247
255
|
# npx skills add ./node_modules/arkgate/templates/agent-skills
|
|
256
|
+
# optional ArkRun: poll the loopback inspector (ANSI TUI — not a gate)
|
|
257
|
+
# npx ark-dashboard --url http://127.0.0.1:<port>/snapshot
|
|
248
258
|
```
|
|
249
259
|
|
|
250
260
|
More: [docs/develop.md](docs/develop.md) · skills install: [docs/agent-guide.md](docs/agent-guide.md#install-skills-ark-and-ecosystem) · enthusiast track: [docs/enthusiast/](docs/enthusiast/README.md)
|
|
@@ -261,6 +271,26 @@ for decoupling.
|
|
|
261
271
|
lives in memory and **dies on restart**. Fine for local. Not Postgres, not an
|
|
262
272
|
outbox, not Temporal. `@arkgate/runtime` is deprecated.
|
|
263
273
|
|
|
274
|
+
### Dev inspector and observability dashboard
|
|
275
|
+
|
|
276
|
+
Opt-in `startInspector()` binds **loopback only** (`127.0.0.1`), refuses
|
|
277
|
+
`NODE_ENV=production`, and serves JSON facts — not a TUI. Alongside
|
|
278
|
+
`GET /snapshot`, `GET /events` (SSE), and `GET /graph`, the inspector exposes
|
|
279
|
+
queue monitors:
|
|
280
|
+
|
|
281
|
+
| Path | Body (JSON) |
|
|
282
|
+
|------|-------------|
|
|
283
|
+
| `GET /outbox` | Pending/failed outbox **summaries** + counts (`available`, `pendingCount`, `failedCount`, `pending`, `failed`) — no event payloads |
|
|
284
|
+
| `GET /workflows` | Workflow **summaries** + counts (`available`, `total`, `runningCount`, …, `workflows`) |
|
|
285
|
+
|
|
286
|
+
Poll those facts from the dual bins **`ark-dashboard`** / **`arkgate-dashboard`**
|
|
287
|
+
(`bin/ark-dashboard.mjs`). ANSI + polling only (no React/Ink/Blessed). Point
|
|
288
|
+
`--url` / `-u` at the inspector snapshot (default
|
|
289
|
+
`http://127.0.0.1:3000/snapshot`); the dashboard also fetches sibling `/outbox`
|
|
290
|
+
and `/workflows`. `--interval` / `-i` is clamped to 200–60000 ms (default 2000).
|
|
291
|
+
Also available as `ark dashboard` / `arkgate dashboard` (passthrough to the same bin). Kernel stays
|
|
292
|
+
JSON-only; presentation stays in `bin/`.
|
|
293
|
+
|
|
264
294
|
### Durability stance
|
|
265
295
|
|
|
266
296
|
Default stores (`InMemoryEventBuffer`, `InMemoryAuditStore`, `InMemoryReadModelStore`,
|
|
@@ -282,6 +312,7 @@ because “what may be the plan” was never a rule.
|
|
|
282
312
|
|
|
283
313
|
**ArkOrder** (`arkgate/order`) is that rule. Off unless you add `arkOrder`.
|
|
284
314
|
Name the slow keys (`xiKeys`: plan, protocol, cost-code bound — not `projectId`).
|
|
315
|
+
A status you can recompute from data you already have is not a slow decision. Derive it. Do not freeze it.
|
|
285
316
|
Posting an invoice is absorbed. Changing plan is `proposeRelease` then `apply`.
|
|
286
317
|
`refreshSigma`; ingest residual `absorb | escalate_up | hold` + `reasonCode`;
|
|
287
318
|
capacity pack as data; in-memory `ReleaseStore`; `ingestTravelAction`; ArkRun
|
|
@@ -290,8 +321,10 @@ PATCHes those keys through Prisma is named. Same npm package.
|
|
|
290
321
|
In-memory. Not durable. Does not replace ArkRun.
|
|
291
322
|
**ArkOrder freezes the pattern through a valve. ArkRun is how the residual travels.**
|
|
292
323
|
|
|
293
|
-
|
|
294
|
-
|
|
324
|
+
The billing gallery (`plan` / `cycle` / `tenancy`) lives on GitHub, not in the
|
|
325
|
+
npm tarball:
|
|
326
|
+
[examples/arkorder-billing](https://github.com/pedroknigge/arkgate/tree/main/examples/arkorder-billing).
|
|
327
|
+
Rename the three keys. Compact starters leave it off. Details:
|
|
295
328
|
[ArkOrder](docs/arkorder.md) · [configuration](docs/configuration.md) ·
|
|
296
329
|
[package surface](docs/package-surface.md).
|
|
297
330
|
|
|
@@ -309,8 +342,10 @@ three keys. Compact starters leave it off. Details:
|
|
|
309
342
|
| Config · package surface · TS | [configuration](docs/configuration.md) · [package-surface](docs/package-surface.md) · [typescript-support](docs/typescript-support.md) |
|
|
310
343
|
| Brownfield | [docs/brownfield-adoption.md](docs/brownfield-adoption.md) |
|
|
311
344
|
| Security | [SECURITY.md](SECURITY.md) |
|
|
312
|
-
|
|
|
313
|
-
| Current published (4.8.
|
|
345
|
+
| Prepared (4.8.9; not published) | [docs/releases/4.8.9.md](docs/releases/4.8.9.md) · [CHANGELOG](CHANGELOG.md) |
|
|
346
|
+
| Current published (4.8.8 on npm `latest`) | [docs/releases/4.8.8.md](docs/releases/4.8.8.md) · [CHANGELOG](CHANGELOG.md) |
|
|
347
|
+
| Prior published (4.8.7) | [docs/releases/4.8.7.md](docs/releases/4.8.7.md) · [CHANGELOG](CHANGELOG.md) |
|
|
348
|
+
| Prior published (4.8.6) | [docs/releases/4.8.6.md](docs/releases/4.8.6.md) |
|
|
314
349
|
| Prior published (4.8.5) | [docs/releases/4.8.5.md](docs/releases/4.8.5.md) |
|
|
315
350
|
| Prior published (4.8.4) | [docs/releases/4.8.4.md](docs/releases/4.8.4.md) |
|
|
316
351
|
| Prior published (4.8.3) | [docs/releases/4.8.3.md](docs/releases/4.8.3.md) |
|
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
REQUIRED_GATE_FILES,
|
|
48
48
|
detectWritePathCapabilities,
|
|
49
49
|
} from './lib/agent-gates.mjs';
|
|
50
|
+
import { ciNotFailClosed } from './lib/gate-files.mjs';
|
|
50
51
|
import { syncBaselineIntoCheckSurfaces } from './lib/field-install.mjs';
|
|
51
52
|
import {
|
|
52
53
|
detectEnforcement,
|
|
@@ -1307,6 +1308,27 @@ async function main() {
|
|
|
1307
1308
|
process.exitCode = 1;
|
|
1308
1309
|
return;
|
|
1309
1310
|
}
|
|
1311
|
+
if (args.requireGates) {
|
|
1312
|
+
const ci = ciNotFailClosed(args.root);
|
|
1313
|
+
if (ci) {
|
|
1314
|
+
const payload = {
|
|
1315
|
+
ok: false,
|
|
1316
|
+
error: ci.error,
|
|
1317
|
+
message: ci.message,
|
|
1318
|
+
workflowFile: ci.workflowFile,
|
|
1319
|
+
nextAction: ci.nextAction,
|
|
1320
|
+
...(writeRequest?.host ? { writeHost: writeRequest.host } : {}),
|
|
1321
|
+
};
|
|
1322
|
+
if (args.json) {
|
|
1323
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
1324
|
+
} else {
|
|
1325
|
+
console.error(`${color.red('\u2716')} ${ci.error} ${ci.message}`);
|
|
1326
|
+
console.error(`Next: ${ci.nextAction}`);
|
|
1327
|
+
}
|
|
1328
|
+
process.exitCode = 1;
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1310
1332
|
// Gates present. This is a precondition, not a standalone report: stay quiet
|
|
1311
1333
|
// in --json mode so the architecture check below owns the single JSON output.
|
|
1312
1334
|
// When --require-gates is the only intent (no config/architecture run needed),
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_INTERVAL_MS = '2000';
|
|
5
|
+
const DEFAULT_SNAPSHOT_URL = 'http://127.0.0.1:3000/snapshot';
|
|
6
|
+
const DEFAULT_FETCH_TIMEOUT_MS = 5_000;
|
|
7
|
+
const MAX_FIELD_LEN = 120;
|
|
8
|
+
|
|
9
|
+
const args = parseArgs({
|
|
10
|
+
options: {
|
|
11
|
+
interval: {
|
|
12
|
+
type: 'string',
|
|
13
|
+
short: 'i',
|
|
14
|
+
default: DEFAULT_INTERVAL_MS
|
|
15
|
+
},
|
|
16
|
+
url: {
|
|
17
|
+
type: 'string',
|
|
18
|
+
short: 'u',
|
|
19
|
+
default: DEFAULT_SNAPSHOT_URL
|
|
20
|
+
},
|
|
21
|
+
timeout: {
|
|
22
|
+
type: 'string',
|
|
23
|
+
short: 't',
|
|
24
|
+
default: String(DEFAULT_FETCH_TIMEOUT_MS)
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
allowPositionals: true
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const parsedInterval = parseInt(args.values.interval, 10);
|
|
31
|
+
const interval =
|
|
32
|
+
Number.isFinite(parsedInterval) && parsedInterval >= 200 && parsedInterval <= 60_000
|
|
33
|
+
? parsedInterval
|
|
34
|
+
: Number(DEFAULT_INTERVAL_MS);
|
|
35
|
+
const targetUrl = args.values.url || DEFAULT_SNAPSHOT_URL;
|
|
36
|
+
const parsedTimeout = parseInt(args.values.timeout, 10);
|
|
37
|
+
const fetchTimeoutMs =
|
|
38
|
+
Number.isFinite(parsedTimeout) && parsedTimeout >= 200 && parsedTimeout <= 60_000
|
|
39
|
+
? parsedTimeout
|
|
40
|
+
: DEFAULT_FETCH_TIMEOUT_MS;
|
|
41
|
+
|
|
42
|
+
const RESET = '\x1b[0m';
|
|
43
|
+
const RED = '\x1b[31m';
|
|
44
|
+
const GREEN = '\x1b[32m';
|
|
45
|
+
const YELLOW = '\x1b[33m';
|
|
46
|
+
const MUTED = '\x1b[2m';
|
|
47
|
+
const SAMPLE_LIMIT = 8;
|
|
48
|
+
|
|
49
|
+
const DRIFT_FLOW_KEYS = [
|
|
50
|
+
'declaredButUnobserved',
|
|
51
|
+
'observedButUndeclared',
|
|
52
|
+
'unknownSources',
|
|
53
|
+
];
|
|
54
|
+
const DRIFT_ID_KEYS = [
|
|
55
|
+
'unregisteredObservedSources',
|
|
56
|
+
'unregisteredObservedIntents',
|
|
57
|
+
'registeredButNeverObserved',
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
/** Strip ANSI CSI / OSC / other C0+C1 terminal control sequences; bound length. */
|
|
61
|
+
function sanitizeField(value, maxLen = MAX_FIELD_LEN) {
|
|
62
|
+
let text = value === undefined || value === null ? '' : String(value);
|
|
63
|
+
// OSC: ESC ] … BEL or ESC ]
|
|
64
|
+
text = text.replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?/g, '');
|
|
65
|
+
// CSI / Fe sequences
|
|
66
|
+
text = text.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '');
|
|
67
|
+
// Remaining ESC + final byte / 8-bit C1
|
|
68
|
+
text = text.replace(/\u001b[@-Z\\-_]/g, '');
|
|
69
|
+
text = text.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, '');
|
|
70
|
+
if (text.length > maxLen) {
|
|
71
|
+
return `${text.slice(0, Math.max(0, maxLen - 1))}…`;
|
|
72
|
+
}
|
|
73
|
+
return text;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function siblingUrl(snapshotUrl, suffix) {
|
|
77
|
+
try {
|
|
78
|
+
const u = new URL(snapshotUrl);
|
|
79
|
+
let basePath = u.pathname;
|
|
80
|
+
if (basePath.endsWith('/snapshot')) {
|
|
81
|
+
basePath = basePath.slice(0, -'/snapshot'.length);
|
|
82
|
+
} else if (basePath.endsWith('/snapshot/')) {
|
|
83
|
+
basePath = basePath.slice(0, -'/snapshot/'.length);
|
|
84
|
+
}
|
|
85
|
+
u.pathname = `${basePath}${suffix}`;
|
|
86
|
+
return u.toString();
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @returns {{ ok: true, data: unknown } | { ok: false, kind: 'waiting' | 'failure', error?: string }}
|
|
94
|
+
*/
|
|
95
|
+
async function fetchJsonResult(url) {
|
|
96
|
+
if (!url) return { ok: false, kind: 'waiting' };
|
|
97
|
+
const controller = new AbortController();
|
|
98
|
+
const timer = setTimeout(() => controller.abort(), fetchTimeoutMs);
|
|
99
|
+
try {
|
|
100
|
+
const res = await fetch(url, { signal: controller.signal });
|
|
101
|
+
if (!res.ok) {
|
|
102
|
+
return { ok: false, kind: 'failure', error: `HTTP ${res.status}` };
|
|
103
|
+
}
|
|
104
|
+
const data = await res.json();
|
|
105
|
+
return { ok: true, data };
|
|
106
|
+
} catch (error) {
|
|
107
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
108
|
+
const aborted =
|
|
109
|
+
(error && typeof error === 'object' && error.name === 'AbortError') ||
|
|
110
|
+
/aborted|timeout/i.test(message);
|
|
111
|
+
return {
|
|
112
|
+
ok: false,
|
|
113
|
+
kind: 'failure',
|
|
114
|
+
error: sanitizeField(aborted ? `timeout after ${fetchTimeoutMs}ms` : message),
|
|
115
|
+
};
|
|
116
|
+
} finally {
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isObservabilityReady(obs) {
|
|
122
|
+
if (!obs || typeof obs !== 'object' || Array.isArray(obs)) return false;
|
|
123
|
+
if (Object.keys(obs).length === 0) return false;
|
|
124
|
+
return [...DRIFT_FLOW_KEYS, ...DRIFT_ID_KEYS].some((k) => Array.isArray(obs[k]));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function formatFlow(flow) {
|
|
128
|
+
if (!flow || typeof flow !== 'object') return sanitizeField(flow);
|
|
129
|
+
const from = sanitizeField(flow.from ?? '?');
|
|
130
|
+
const to = sanitizeField(flow.to ?? '?');
|
|
131
|
+
return `${from}→${to}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function listSamples(items, formatter) {
|
|
135
|
+
const sample = items.slice(0, SAMPLE_LIMIT);
|
|
136
|
+
for (const item of sample) {
|
|
137
|
+
console.log(` • ${formatter(item)}`);
|
|
138
|
+
}
|
|
139
|
+
if (items.length > SAMPLE_LIMIT) {
|
|
140
|
+
console.log(` … +${items.length - SAMPLE_LIMIT} more`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function renderDriftRadar(observability) {
|
|
145
|
+
console.log(`\n--- Drift Radar ---`);
|
|
146
|
+
if (!isObservabilityReady(observability)) {
|
|
147
|
+
console.log(`${MUTED}Waiting for observability…${RESET}`);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const flows = Object.fromEntries(
|
|
152
|
+
DRIFT_FLOW_KEYS.map((k) => [k, Array.isArray(observability[k]) ? observability[k] : []]),
|
|
153
|
+
);
|
|
154
|
+
const ids = Object.fromEntries(
|
|
155
|
+
DRIFT_ID_KEYS.map((k) => [k, Array.isArray(observability[k]) ? observability[k] : []]),
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const counts = {
|
|
159
|
+
declaredButUnobserved: flows.declaredButUnobserved.length,
|
|
160
|
+
observedButUndeclared: flows.observedButUndeclared.length,
|
|
161
|
+
unknownSources: flows.unknownSources.length,
|
|
162
|
+
unregisteredObservedSources: ids.unregisteredObservedSources.length,
|
|
163
|
+
unregisteredObservedIntents: ids.unregisteredObservedIntents.length,
|
|
164
|
+
registeredButNeverObserved: ids.registeredButNeverObserved.length,
|
|
165
|
+
};
|
|
166
|
+
const totalDrift = Object.values(counts).reduce((a, b) => a + b, 0);
|
|
167
|
+
|
|
168
|
+
if (observability.generatedAt) {
|
|
169
|
+
console.log(`Generated: ${sanitizeField(observability.generatedAt)}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
console.log(
|
|
173
|
+
`Counts: declaredButUnobserved=${counts.declaredButUnobserved} ` +
|
|
174
|
+
`observedButUndeclared=${counts.observedButUndeclared} ` +
|
|
175
|
+
`unknownSources=${counts.unknownSources} ` +
|
|
176
|
+
`unregisteredSources=${counts.unregisteredObservedSources} ` +
|
|
177
|
+
`unregisteredIntents=${counts.unregisteredObservedIntents} ` +
|
|
178
|
+
`neverObserved=${counts.registeredButNeverObserved}`,
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
if (totalDrift === 0) {
|
|
182
|
+
console.log(`${GREEN}No drift${RESET}`);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const critical =
|
|
187
|
+
counts.observedButUndeclared > 0 ||
|
|
188
|
+
counts.unknownSources > 0 ||
|
|
189
|
+
counts.unregisteredObservedSources > 0 ||
|
|
190
|
+
counts.unregisteredObservedIntents > 0;
|
|
191
|
+
const color = critical ? RED : YELLOW;
|
|
192
|
+
console.log(`${color}[DRIFT] ${totalDrift} issue(s) detected${RESET}`);
|
|
193
|
+
|
|
194
|
+
for (const key of DRIFT_FLOW_KEYS) {
|
|
195
|
+
const items = flows[key];
|
|
196
|
+
if (items.length === 0) continue;
|
|
197
|
+
console.log(` ${YELLOW}${key}${RESET} (${items.length}):`);
|
|
198
|
+
listSamples(items, formatFlow);
|
|
199
|
+
}
|
|
200
|
+
for (const key of DRIFT_ID_KEYS) {
|
|
201
|
+
const items = ids[key];
|
|
202
|
+
if (items.length === 0) continue;
|
|
203
|
+
console.log(` ${YELLOW}${key}${RESET} (${items.length}):`);
|
|
204
|
+
listSamples(items, (id) => sanitizeField(id));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function formatOutboxRow(row) {
|
|
209
|
+
const intent = row.intent ? ` ${sanitizeField(row.intent)}` : '';
|
|
210
|
+
const err = row.error ? ` err=${sanitizeField(row.error)}` : '';
|
|
211
|
+
return `${sanitizeField(row.status)} ${sanitizeField(row.id)}${intent} attempts=${row.attempts ?? 0}${err}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function formatWorkflowRow(row) {
|
|
215
|
+
const step = row.currentStep ? ` @${sanitizeField(row.currentStep)}` : '';
|
|
216
|
+
const err = row.error ? ` err=${sanitizeField(row.error)}` : '';
|
|
217
|
+
return `${sanitizeField(row.status)} ${sanitizeField(row.name)} (${sanitizeField(row.id)})${step}${err}`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function isOutboxReady(outbox) {
|
|
221
|
+
return Boolean(outbox && typeof outbox === 'object' && outbox.available === true);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function isWorkflowsReady(workflows) {
|
|
225
|
+
return Boolean(workflows && typeof workflows === 'object' && workflows.available === true);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function renderFetchStatus(label, result) {
|
|
229
|
+
if (result.ok) return;
|
|
230
|
+
if (result.kind === 'waiting') {
|
|
231
|
+
console.log(`${MUTED}${label}: Waiting…${RESET}`);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const detail = result.error ? ` (${result.error})` : '';
|
|
235
|
+
console.log(`${RED}${label}: Failure${detail}${RESET}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function renderQueuesAndWorkflows(outboxResult, workflowsResult, snapshotOutbox, snapshotWorkflows) {
|
|
239
|
+
console.log(`\n--- Queues & Workflows ---`);
|
|
240
|
+
|
|
241
|
+
const outbox = isOutboxReady(outboxResult.ok ? outboxResult.data : null)
|
|
242
|
+
? outboxResult.data
|
|
243
|
+
: isOutboxReady(snapshotOutbox)
|
|
244
|
+
? snapshotOutbox
|
|
245
|
+
: null;
|
|
246
|
+
const workflows = isWorkflowsReady(workflowsResult.ok ? workflowsResult.data : null)
|
|
247
|
+
? workflowsResult.data
|
|
248
|
+
: isWorkflowsReady(snapshotWorkflows)
|
|
249
|
+
? snapshotWorkflows
|
|
250
|
+
: null;
|
|
251
|
+
|
|
252
|
+
if (!outbox && !workflows) {
|
|
253
|
+
if (!outboxResult.ok && !workflowsResult.ok) {
|
|
254
|
+
renderFetchStatus('Outbox', outboxResult);
|
|
255
|
+
renderFetchStatus('Workflows', workflowsResult);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
console.log(`${MUTED}Waiting for queues & workflows…${RESET}`);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (!outbox) {
|
|
263
|
+
renderFetchStatus('Outbox', outboxResult.ok ? { ok: false, kind: 'waiting' } : outboxResult);
|
|
264
|
+
} else {
|
|
265
|
+
const pending = Array.isArray(outbox.pending) ? outbox.pending : [];
|
|
266
|
+
const failed = Array.isArray(outbox.failed) ? outbox.failed : [];
|
|
267
|
+
const pendingCount = outbox.pendingCount ?? pending.length;
|
|
268
|
+
const failedCount = outbox.failedCount ?? failed.length;
|
|
269
|
+
console.log(`Outbox: pending=${pendingCount} failed=${failedCount}`);
|
|
270
|
+
|
|
271
|
+
if (pendingCount === 0 && failedCount === 0) {
|
|
272
|
+
console.log(`${GREEN}Outbox empty${RESET}`);
|
|
273
|
+
} else {
|
|
274
|
+
if (failedCount > 0) {
|
|
275
|
+
console.log(`${RED}[OUTBOX] ${failedCount} failed${RESET}`);
|
|
276
|
+
listSamples(failed, (row) => `${RED}${formatOutboxRow(row)}${RESET}`);
|
|
277
|
+
}
|
|
278
|
+
if (pendingCount > 0) {
|
|
279
|
+
console.log(`${YELLOW}[OUTBOX] ${pendingCount} pending${RESET}`);
|
|
280
|
+
listSamples(pending, (row) => `${YELLOW}${formatOutboxRow(row)}${RESET}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (!workflows) {
|
|
286
|
+
renderFetchStatus(
|
|
287
|
+
'Workflows',
|
|
288
|
+
workflowsResult.ok ? { ok: false, kind: 'waiting' } : workflowsResult,
|
|
289
|
+
);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const rows = Array.isArray(workflows.workflows) ? workflows.workflows : [];
|
|
294
|
+
const running = workflows.runningCount ?? rows.filter((w) => w.status === 'running').length;
|
|
295
|
+
const compensating =
|
|
296
|
+
workflows.compensatingCount ?? rows.filter((w) => w.status === 'compensating').length;
|
|
297
|
+
const failedWf = workflows.failedCount ?? rows.filter((w) => w.status === 'failed').length;
|
|
298
|
+
const pendingWf =
|
|
299
|
+
workflows.pendingCount ??
|
|
300
|
+
rows.filter((w) => w.status === 'idle' || w.status === 'waiting').length;
|
|
301
|
+
console.log(
|
|
302
|
+
`Workflows: total=${workflows.total ?? rows.length} running=${running} ` +
|
|
303
|
+
`compensating=${compensating} failed=${failedWf} pending=${pendingWf}`,
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
if (failedWf === 0 && running === 0 && compensating === 0 && pendingWf === 0) {
|
|
307
|
+
console.log(`${GREEN}No active workflows${RESET}`);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (failedWf > 0) {
|
|
312
|
+
console.log(`${RED}[WORKFLOWS] ${failedWf} failed${RESET}`);
|
|
313
|
+
listSamples(
|
|
314
|
+
rows.filter((w) => w.status === 'failed'),
|
|
315
|
+
(row) => `${RED}${formatWorkflowRow(row)}${RESET}`,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
const yellowRows = rows.filter((w) =>
|
|
319
|
+
w.status === 'running' ||
|
|
320
|
+
w.status === 'compensating' ||
|
|
321
|
+
w.status === 'idle' ||
|
|
322
|
+
w.status === 'waiting',
|
|
323
|
+
);
|
|
324
|
+
if (yellowRows.length > 0) {
|
|
325
|
+
console.log(`${YELLOW}[WORKFLOWS] ${yellowRows.length} pending/running/compensating${RESET}`);
|
|
326
|
+
listSamples(yellowRows, (row) => `${YELLOW}${formatWorkflowRow(row)}${RESET}`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function durabilityStores(snapshot) {
|
|
331
|
+
const stores = snapshot?.hardening?.durability?.stores;
|
|
332
|
+
return Array.isArray(stores) ? stores : [];
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function renderHardening(snapshotResult) {
|
|
336
|
+
console.log(`--- Hardening Status ---`);
|
|
337
|
+
if (!snapshotResult.ok) {
|
|
338
|
+
if (snapshotResult.kind === 'waiting') {
|
|
339
|
+
console.log(`${YELLOW}Waiting for kernel...${RESET}`);
|
|
340
|
+
} else {
|
|
341
|
+
const detail = snapshotResult.error ? ` (${snapshotResult.error})` : '';
|
|
342
|
+
console.log(`${RED}Failure contacting kernel${detail}${RESET}`);
|
|
343
|
+
}
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const snapshot = snapshotResult.data;
|
|
348
|
+
const stores = durabilityStores(snapshot);
|
|
349
|
+
const memoryStores = stores.filter((s) => s && s.kind === 'memory');
|
|
350
|
+
const durableStores = stores.filter((s) => s && s.kind === 'durable');
|
|
351
|
+
|
|
352
|
+
if (stores.length === 0) {
|
|
353
|
+
// Missing durability facts must never read as green OK.
|
|
354
|
+
console.log(`${YELLOW}[WARNING] Store durability facts unavailable${RESET}`);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (memoryStores.length > 0) {
|
|
359
|
+
console.log(`${RED}[WARNING] Memory defaults in use!${RESET}`);
|
|
360
|
+
for (const store of memoryStores) {
|
|
361
|
+
const role = sanitizeField(store.role ?? 'store');
|
|
362
|
+
const id = sanitizeField(store.id ?? store.name ?? 'unknown');
|
|
363
|
+
console.log(` - ${YELLOW}${role}: ${id}${RESET}`);
|
|
364
|
+
}
|
|
365
|
+
if (durableStores.length > 0) {
|
|
366
|
+
for (const store of durableStores) {
|
|
367
|
+
const role = sanitizeField(store.role ?? 'store');
|
|
368
|
+
const id = sanitizeField(store.id ?? store.name ?? 'unknown');
|
|
369
|
+
console.log(` - ${MUTED}${role}: ${id} (durable)${RESET}`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
console.log(`${GREEN}[OK] Durable Stores Configured${RESET}`);
|
|
376
|
+
for (const store of durableStores) {
|
|
377
|
+
const role = sanitizeField(store.role ?? 'store');
|
|
378
|
+
const id = sanitizeField(store.id ?? store.name ?? 'unknown');
|
|
379
|
+
console.log(` - ${MUTED}${role}: ${id}${RESET}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function render() {
|
|
384
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
385
|
+
console.log(`ArkGate Observability Dashboard`);
|
|
386
|
+
console.log(`Time: ${sanitizeField(new Date().toISOString())}`);
|
|
387
|
+
console.log(`Endpoint: ${sanitizeField(targetUrl)}\n`);
|
|
388
|
+
|
|
389
|
+
const outboxUrl = siblingUrl(targetUrl, '/outbox');
|
|
390
|
+
const workflowsUrl = siblingUrl(targetUrl, '/workflows');
|
|
391
|
+
const [snapshotResult, outboxResult, workflowsResult] = await Promise.all([
|
|
392
|
+
fetchJsonResult(targetUrl),
|
|
393
|
+
fetchJsonResult(outboxUrl),
|
|
394
|
+
fetchJsonResult(workflowsUrl),
|
|
395
|
+
]);
|
|
396
|
+
|
|
397
|
+
renderHardening(snapshotResult);
|
|
398
|
+
|
|
399
|
+
const snapshot = snapshotResult.ok ? snapshotResult.data : null;
|
|
400
|
+
renderDriftRadar(snapshot?.observability);
|
|
401
|
+
renderQueuesAndWorkflows(
|
|
402
|
+
outboxResult,
|
|
403
|
+
workflowsResult,
|
|
404
|
+
snapshot?.outbox,
|
|
405
|
+
snapshot?.workflows,
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function startDashboard() {
|
|
410
|
+
console.log(
|
|
411
|
+
`Starting ArkGate Observability Dashboard (polling every ${interval}ms, fetch timeout ${fetchTimeoutMs}ms)`,
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
while (true) {
|
|
415
|
+
await render();
|
|
416
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
startDashboard().catch((err) => {
|
|
421
|
+
console.error(err);
|
|
422
|
+
process.exit(1);
|
|
423
|
+
});
|