@pleri/olam-cli 0.1.180 → 0.1.185
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/dist/agent-stream/agent-sdk-to-chunks.js +44 -30
- package/dist/ask/checkout.d.ts +19 -0
- package/dist/ask/checkout.d.ts.map +1 -0
- package/dist/ask/checkout.js +40 -0
- package/dist/ask/checkout.js.map +1 -0
- package/dist/ask/knowledge-pack-builder.d.ts +72 -0
- package/dist/ask/knowledge-pack-builder.d.ts.map +1 -0
- package/dist/ask/knowledge-pack-builder.js +91 -0
- package/dist/ask/knowledge-pack-builder.js.map +1 -0
- package/dist/ask/knowledge-pack.generated.d.ts +8 -0
- package/dist/ask/knowledge-pack.generated.d.ts.map +1 -0
- package/dist/ask/knowledge-pack.generated.js +1947 -0
- package/dist/ask/knowledge-pack.generated.js.map +1 -0
- package/dist/ask/one-shot.d.ts +21 -0
- package/dist/ask/one-shot.d.ts.map +1 -0
- package/dist/ask/one-shot.js +50 -0
- package/dist/ask/one-shot.js.map +1 -0
- package/dist/ask/repl.d.ts +30 -0
- package/dist/ask/repl.d.ts.map +1 -0
- package/dist/ask/repl.js +109 -0
- package/dist/ask/repl.js.map +1 -0
- package/dist/ask/sdk-client.d.ts +87 -0
- package/dist/ask/sdk-client.d.ts.map +1 -0
- package/dist/ask/sdk-client.js +118 -0
- package/dist/ask/sdk-client.js.map +1 -0
- package/dist/ask/system-prompt.d.ts +30 -0
- package/dist/ask/system-prompt.d.ts.map +1 -0
- package/dist/ask/system-prompt.js +31 -0
- package/dist/ask/system-prompt.js.map +1 -0
- package/dist/commands/ask.d.ts +27 -0
- package/dist/commands/ask.d.ts.map +1 -0
- package/dist/commands/ask.js +63 -0
- package/dist/commands/ask.js.map +1 -0
- package/dist/commands/auth-list-json.d.ts +53 -0
- package/dist/commands/auth-list-json.d.ts.map +1 -0
- package/dist/commands/auth-list-json.js +47 -0
- package/dist/commands/auth-list-json.js.map +1 -0
- package/dist/commands/auth.d.ts.map +1 -1
- package/dist/commands/auth.js +80 -19
- package/dist/commands/auth.js.map +1 -1
- package/dist/commands/config.d.ts.map +1 -1
- package/dist/commands/config.js +93 -0
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/destroy.d.ts +41 -0
- package/dist/commands/destroy.d.ts.map +1 -1
- package/dist/commands/destroy.js +81 -33
- package/dist/commands/destroy.js.map +1 -1
- package/dist/commands/dispatch-resolve.d.ts +54 -0
- package/dist/commands/dispatch-resolve.d.ts.map +1 -0
- package/dist/commands/dispatch-resolve.js +105 -0
- package/dist/commands/dispatch-resolve.js.map +1 -0
- package/dist/commands/dispatch.d.ts.map +1 -1
- package/dist/commands/dispatch.js +40 -9
- package/dist/commands/dispatch.js.map +1 -1
- package/dist/commands/doctor.js +11 -11
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/flywheel/k5-validate.d.ts +31 -0
- package/dist/commands/flywheel/k5-validate.d.ts.map +1 -1
- package/dist/commands/flywheel/k5-validate.js +80 -19
- package/dist/commands/flywheel/k5-validate.js.map +1 -1
- package/dist/commands/keys-list-json.d.ts +55 -0
- package/dist/commands/keys-list-json.d.ts.map +1 -0
- package/dist/commands/keys-list-json.js +54 -0
- package/dist/commands/keys-list-json.js.map +1 -0
- package/dist/commands/keys.d.ts.map +1 -1
- package/dist/commands/keys.js +6 -0
- package/dist/commands/keys.js.map +1 -1
- package/dist/commands/kg-classify.d.ts.map +1 -1
- package/dist/commands/kg-classify.js +20 -0
- package/dist/commands/kg-classify.js.map +1 -1
- package/dist/commands/kg-doctor.d.ts +67 -6
- package/dist/commands/kg-doctor.d.ts.map +1 -1
- package/dist/commands/kg-doctor.js +126 -46
- package/dist/commands/kg-doctor.js.map +1 -1
- package/dist/commands/lanes-list-json.d.ts +69 -0
- package/dist/commands/lanes-list-json.d.ts.map +1 -0
- package/dist/commands/lanes-list-json.js +42 -0
- package/dist/commands/lanes-list-json.js.map +1 -0
- package/dist/commands/lanes.d.ts.map +1 -1
- package/dist/commands/lanes.js +18 -7
- package/dist/commands/lanes.js.map +1 -1
- package/dist/commands/list.d.ts +27 -0
- package/dist/commands/list.d.ts.map +1 -1
- package/dist/commands/list.js +67 -19
- package/dist/commands/list.js.map +1 -1
- package/dist/commands/memory/status.d.ts +18 -0
- package/dist/commands/memory/status.d.ts.map +1 -1
- package/dist/commands/memory/status.js +38 -2
- package/dist/commands/memory/status.js.map +1 -1
- package/dist/commands/memory-service-container.d.ts +44 -0
- package/dist/commands/memory-service-container.d.ts.map +1 -1
- package/dist/commands/memory-service-container.js +49 -0
- package/dist/commands/memory-service-container.js.map +1 -1
- package/dist/commands/plans-list-json.d.ts +77 -0
- package/dist/commands/plans-list-json.d.ts.map +1 -0
- package/dist/commands/plans-list-json.js +61 -0
- package/dist/commands/plans-list-json.js.map +1 -0
- package/dist/commands/plans.d.ts.map +1 -1
- package/dist/commands/plans.js +10 -0
- package/dist/commands/plans.js.map +1 -1
- package/dist/commands/ps.d.ts +32 -0
- package/dist/commands/ps.d.ts.map +1 -1
- package/dist/commands/ps.js +34 -0
- package/dist/commands/ps.js.map +1 -1
- package/dist/commands/repos-list-json.d.ts +58 -0
- package/dist/commands/repos-list-json.d.ts.map +1 -0
- package/dist/commands/repos-list-json.js +45 -0
- package/dist/commands/repos-list-json.js.map +1 -0
- package/dist/commands/repos.d.ts +1 -1
- package/dist/commands/repos.d.ts.map +1 -1
- package/dist/commands/repos.js +12 -2
- package/dist/commands/repos.js.map +1 -1
- package/dist/commands/runbooks.d.ts +32 -0
- package/dist/commands/runbooks.d.ts.map +1 -1
- package/dist/commands/runbooks.js +79 -22
- package/dist/commands/runbooks.js.map +1 -1
- package/dist/commands/services.d.ts +47 -1
- package/dist/commands/services.d.ts.map +1 -1
- package/dist/commands/services.js +59 -33
- package/dist/commands/services.js.map +1 -1
- package/dist/commands/skills-source.d.ts.map +1 -1
- package/dist/commands/skills-source.js +77 -2
- package/dist/commands/skills-source.js.map +1 -1
- package/dist/commands/skills.d.ts +27 -0
- package/dist/commands/skills.d.ts.map +1 -1
- package/dist/commands/skills.js +17 -2
- package/dist/commands/skills.js.map +1 -1
- package/dist/commands/upgrade-history.d.ts +0 -2
- package/dist/commands/upgrade-history.d.ts.map +1 -1
- package/dist/commands/upgrade-history.js +0 -6
- package/dist/commands/upgrade-history.js.map +1 -1
- package/dist/commands/upgrade-lock.d.ts +0 -9
- package/dist/commands/upgrade-lock.d.ts.map +1 -1
- package/dist/commands/upgrade-lock.js +1 -1
- package/dist/commands/upgrade-lock.js.map +1 -1
- package/dist/commands/workspace-list-json.d.ts +73 -0
- package/dist/commands/workspace-list-json.d.ts.map +1 -0
- package/dist/commands/workspace-list-json.js +59 -0
- package/dist/commands/workspace-list-json.js.map +1 -0
- package/dist/commands/workspace.d.ts.map +1 -1
- package/dist/commands/workspace.js +7 -1
- package/dist/commands/workspace.js.map +1 -1
- package/dist/commands/world-snapshot.d.ts +13 -0
- package/dist/commands/world-snapshot.d.ts.map +1 -1
- package/dist/commands/world-snapshot.js +81 -1
- package/dist/commands/world-snapshot.js.map +1 -1
- package/dist/commands/yolo.d.ts +0 -4
- package/dist/commands/yolo.d.ts.map +1 -1
- package/dist/commands/yolo.js +2 -2
- package/dist/commands/yolo.js.map +1 -1
- package/dist/image-digests.json +8 -8
- package/dist/index.js +6097 -2563
- package/dist/index.js.map +1 -1
- package/dist/lib/anthropic-base-url-file.d.ts +37 -0
- package/dist/lib/anthropic-base-url-file.d.ts.map +1 -0
- package/dist/lib/anthropic-base-url-file.js +46 -0
- package/dist/lib/anthropic-base-url-file.js.map +1 -0
- package/dist/lib/auth-remote.d.ts +9 -0
- package/dist/lib/auth-remote.d.ts.map +1 -1
- package/dist/lib/auth-remote.js +19 -4
- package/dist/lib/auth-remote.js.map +1 -1
- package/dist/lib/cf-access-token.d.ts +32 -0
- package/dist/lib/cf-access-token.d.ts.map +1 -0
- package/dist/lib/cf-access-token.js +52 -0
- package/dist/lib/cf-access-token.js.map +1 -0
- package/dist/lib/config.d.ts +17 -3
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +28 -4
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/k8s-bootstrap.d.ts.map +1 -1
- package/dist/lib/k8s-bootstrap.js +13 -1
- package/dist/lib/k8s-bootstrap.js.map +1 -1
- package/dist/lib/k8s-secret-render.d.ts +2 -0
- package/dist/lib/k8s-secret-render.d.ts.map +1 -1
- package/dist/lib/k8s-secret-render.js +27 -0
- package/dist/lib/k8s-secret-render.js.map +1 -1
- package/dist/lib/kubectl-context.d.ts +49 -0
- package/dist/lib/kubectl-context.d.ts.map +1 -1
- package/dist/lib/kubectl-context.js +64 -2
- package/dist/lib/kubectl-context.js.map +1 -1
- package/dist/lib/peripheral-registry.d.ts +1 -1
- package/dist/lib/peripheral-registry.d.ts.map +1 -1
- package/dist/lib/peripheral-registry.js +13 -0
- package/dist/lib/peripheral-registry.js.map +1 -1
- package/dist/lib/upgrade-kubernetes.d.ts +13 -0
- package/dist/lib/upgrade-kubernetes.d.ts.map +1 -1
- package/dist/lib/upgrade-kubernetes.js +42 -9
- package/dist/lib/upgrade-kubernetes.js.map +1 -1
- package/dist/mcp-server.js +2624 -1041
- package/hermes-bundle/version.json +1 -1
- package/host-cp/k8s/manifests/30-configmap.yaml +11 -6
- package/host-cp/k8s/manifests/45-pvc.yaml +6 -2
- package/host-cp/k8s/manifests/50-deployment.yaml +15 -1
- package/host-cp/k8s/manifests/auth-service/50-deployment.yaml +1 -1
- package/host-cp/k8s/manifests/kg-service/50-deployment.yaml +1 -1
- package/host-cp/k8s/manifests/mcp-auth-service/50-deployment.yaml +1 -1
- package/host-cp/k8s/manifests/memory-service/50-deployment.yaml +1 -1
- package/host-cp/k8s/templates/chunks-postgres-secret-template.yaml +24 -0
- package/host-cp/k8s/templates/plan-chat-service-secret-template.yaml +35 -0
- package/host-cp/observability/trace-summary.mjs +267 -0
- package/host-cp/src/bootstrap-selective.mjs +30 -28
- package/host-cp/src/host-stream.mjs +52 -0
- package/host-cp/src/plan-chat-service.mjs +99 -74
- package/host-cp/src/redirect.mjs +7 -0
- package/host-cp/src/router.mjs +168 -0
- package/host-cp/src/serve-only-config.mjs +85 -0
- package/host-cp/src/server.mjs +482 -217
- package/host-cp/src/world-services.mjs +136 -0
- package/package.json +4 -2
|
@@ -24,12 +24,17 @@ data:
|
|
|
24
24
|
OLAM_WORLDS_DB: "/data/worlds.db"
|
|
25
25
|
OLAM_PLAN_DB_PATH: "/data/plan.db"
|
|
26
26
|
OLAM_PLAN_DIR: "/data/plan"
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
|
|
27
|
+
# Phase B Model B: bearer file is now sourced from the shared
|
|
28
|
+
# olam-plan-chat-secret Kubernetes Secret (mounted at /etc/olam-plan-chat/).
|
|
29
|
+
# Two readers, one source-of-truth — replaces the per-pod /data/plan-chat-secret
|
|
30
|
+
# file that couldn't be shared across pods on RWO PVCs. The plan-chat-service
|
|
31
|
+
# pod also mounts the SAME Secret at the SAME path so bearer comparisons
|
|
32
|
+
# work both ways.
|
|
33
|
+
OLAM_PLAN_CHAT_SECRET_PATH: "/etc/olam-plan-chat/secret"
|
|
34
|
+
# In-cluster plan-chat-service URL. Rewritten by upgrade-kubernetes.ts step 2.5
|
|
35
|
+
# (buildK8sDnsUrl) — the default below is a sane fallback for raw
|
|
36
|
+
# `kubectl apply -f` operators who skip the CLI wrapper.
|
|
37
|
+
PLAN_CHAT_SERVICE_URL: "http://olam-plan-chat-service.olam.svc.cluster.local:3200"
|
|
33
38
|
# NDJSON span sink + recovery ledger — route to the writable PVC mount at
|
|
34
39
|
# /data rather than the default ~/.olam/logs (which resolves to
|
|
35
40
|
# /home/node/.olam/logs and is not writable with readOnlyRootFilesystem: true).
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# PersistentVolumeClaim for olam-host-cp /data volume.
|
|
1
|
+
# PersistentVolumeClaim for olam-host-cp /data volume — k3d substrate default.
|
|
2
2
|
#
|
|
3
3
|
# Why PVC instead of hostPath:
|
|
4
4
|
# hostPath volumes on k3d nodes resolve to paths INSIDE the k3d node
|
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
# root-owned hostPath mounts even when fsGroup: 1000 is set.
|
|
10
10
|
#
|
|
11
11
|
# local-path StorageClass ships with k3d by default (rancher/local-path-provisioner).
|
|
12
|
-
# On non-k3d clusters, substitute with the appropriate StorageClass name
|
|
12
|
+
# On non-k3d clusters, substitute with the appropriate StorageClass name (D24,
|
|
13
|
+
# operator-editable). For managed clusters (GKE, EKS, AKS) use the GKE-variant
|
|
14
|
+
# manifest instead: packages/host-cp/k8s/manifests/gke/45-pvc.yaml (storageClassName:
|
|
15
|
+
# standard-rwo). See docs/architecture/peripheral-services-on-k3s.md Decision #3
|
|
16
|
+
# for the full per-cluster storageclass table.
|
|
13
17
|
apiVersion: v1
|
|
14
18
|
kind: PersistentVolumeClaim
|
|
15
19
|
metadata:
|
|
@@ -118,7 +118,7 @@ spec:
|
|
|
118
118
|
# k3d), started by `olam upgrade` Step 0.7 — not inside this Pod.
|
|
119
119
|
containers:
|
|
120
120
|
- name: olam-host-cp
|
|
121
|
-
image: ghcr.io/pleri/olam-host-cp@sha256:
|
|
121
|
+
image: ghcr.io/pleri/olam-host-cp@sha256:843bea5159a7b5ec792a7d0a9766c49eb0443180023526c0c21d56e32982fd4e
|
|
122
122
|
imagePullPolicy: IfNotPresent
|
|
123
123
|
securityContext:
|
|
124
124
|
runAsNonRoot: true
|
|
@@ -147,6 +147,13 @@ spec:
|
|
|
147
147
|
readOnly: true
|
|
148
148
|
- name: tmp
|
|
149
149
|
mountPath: /tmp
|
|
150
|
+
# Phase B Model B: shared olam-plan-chat-secret mounted read-only
|
|
151
|
+
# so renderSpaShell can inject window.__OLAM_PLAN_CHAT_BEARER__.
|
|
152
|
+
# Plan-chat-service mounts the SAME Secret at the SAME path so
|
|
153
|
+
# bearer compares match across pods.
|
|
154
|
+
- name: plan-chat-secret
|
|
155
|
+
mountPath: /etc/olam-plan-chat
|
|
156
|
+
readOnly: true
|
|
150
157
|
# docker-socket volumeMount REMOVED in olam-k3d-on-mac-substrate-
|
|
151
158
|
# decision Phase B B2. Docker access now goes via TCP to the
|
|
152
159
|
# docker-socket-proxy ExternalName Service in the olam namespace.
|
|
@@ -191,6 +198,13 @@ spec:
|
|
|
191
198
|
type: DirectoryOrCreate
|
|
192
199
|
- name: tmp
|
|
193
200
|
emptyDir: {}
|
|
201
|
+
- name: plan-chat-secret
|
|
202
|
+
secret:
|
|
203
|
+
secretName: olam-plan-chat-secret
|
|
204
|
+
defaultMode: 0400
|
|
205
|
+
items:
|
|
206
|
+
- key: PLAN_CHAT_SECRET
|
|
207
|
+
path: secret
|
|
194
208
|
# host-colima + docker-socket volumes REMOVED in olam-k3d-on-mac-
|
|
195
209
|
# substrate-decision Phase B B2 (2026-05-21). R3-A's two-volume
|
|
196
210
|
# hostPath approach is fully retracted: round-4 R4-W2-F demonstrated
|
|
@@ -70,7 +70,7 @@ spec:
|
|
|
70
70
|
mountPath: /data
|
|
71
71
|
containers:
|
|
72
72
|
- name: olam-auth-service
|
|
73
|
-
image: ghcr.io/pleri/olam-auth@sha256:
|
|
73
|
+
image: ghcr.io/pleri/olam-auth@sha256:59de0618b656c45ed75465aefab637bd3b50ef803ae8d802c828c26d97183328
|
|
74
74
|
imagePullPolicy: IfNotPresent
|
|
75
75
|
securityContext:
|
|
76
76
|
runAsNonRoot: true
|
|
@@ -61,7 +61,7 @@ spec:
|
|
|
61
61
|
mountPath: /data
|
|
62
62
|
containers:
|
|
63
63
|
- name: olam-kg-service
|
|
64
|
-
image: ghcr.io/pleri/olam-kg-service@sha256:
|
|
64
|
+
image: ghcr.io/pleri/olam-kg-service@sha256:b53af63d452bb04420a1f89b5834888a324ee3995f45c908fa9321ec76b84047
|
|
65
65
|
imagePullPolicy: IfNotPresent
|
|
66
66
|
securityContext:
|
|
67
67
|
runAsNonRoot: true
|
|
@@ -68,7 +68,7 @@ spec:
|
|
|
68
68
|
mountPath: /data
|
|
69
69
|
containers:
|
|
70
70
|
- name: olam-mcp-auth-service
|
|
71
|
-
image: ghcr.io/pleri/olam-mcp-auth@sha256:
|
|
71
|
+
image: ghcr.io/pleri/olam-mcp-auth@sha256:635c1518e45cc0a1b5859cfb412d5a6eb9ab389630553eb9cdb6c903d97d8d15
|
|
72
72
|
imagePullPolicy: IfNotPresent
|
|
73
73
|
securityContext:
|
|
74
74
|
runAsNonRoot: true
|
|
@@ -70,7 +70,7 @@ spec:
|
|
|
70
70
|
# bootstrap-placeholder comment + run `npm run refresh:manifest-digests`
|
|
71
71
|
# once ghcr.io/pleri/olam-memory-service has a real published digest.
|
|
72
72
|
# bootstrap-placeholder: pre-publish; refresh after first release
|
|
73
|
-
image: ghcr.io/pleri/olam-memory-service@sha256:
|
|
73
|
+
image: ghcr.io/pleri/olam-memory-service@sha256:efad791c760420e6bccc68120621aba97a2532da517eb3f7d0e0349f6a2f8a06
|
|
74
74
|
imagePullPolicy: IfNotPresent
|
|
75
75
|
securityContext:
|
|
76
76
|
runAsNonRoot: true
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Secret TEMPLATE for olam-chunks-postgres.
|
|
2
|
+
#
|
|
3
|
+
# Generates a random 64-char hex POSTGRES_PASSWORD on first apply (via
|
|
4
|
+
# k8s-secret-render.ts generate-if-missing). The Secret is consumed by:
|
|
5
|
+
# - chunks-postgres StatefulSet (envFrom → POSTGRES_PASSWORD)
|
|
6
|
+
# - chunks-electric Deployment (env: valueFrom.secretKeyRef)
|
|
7
|
+
# - plan-chat-service Deployment (env: valueFrom.secretKeyRef)
|
|
8
|
+
#
|
|
9
|
+
# All three resolve the SAME random value because the secret-renderer
|
|
10
|
+
# persists generated values in ~/.olam/k8s-secrets-state.json so reapply
|
|
11
|
+
# is idempotent (no rotation unless --rotate-secrets).
|
|
12
|
+
apiVersion: v1
|
|
13
|
+
kind: Secret
|
|
14
|
+
metadata:
|
|
15
|
+
name: olam-chunks-postgres-secret
|
|
16
|
+
namespace: olam
|
|
17
|
+
labels:
|
|
18
|
+
app: olam-chunks-postgres
|
|
19
|
+
olam.io/component: substrate
|
|
20
|
+
type: Opaque
|
|
21
|
+
stringData:
|
|
22
|
+
# Postgres superuser password. Generated by the CLI's secret-renderer on
|
|
23
|
+
# first apply (no host-side file to read; this is in-cluster-only state).
|
|
24
|
+
POSTGRES_PASSWORD: "REPLACE_ME_GENERATE_RANDOM_HEX"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Secret TEMPLATE for olam-plan-chat-secret.
|
|
2
|
+
#
|
|
3
|
+
# This file is a TEMPLATE — it MUST NOT be applied directly without substituting
|
|
4
|
+
# the placeholder values. The placeholders are intentionally invalid; a raw
|
|
5
|
+
# `kubectl apply` will result in auth failures rather than silently shipping
|
|
6
|
+
# fake credentials.
|
|
7
|
+
#
|
|
8
|
+
# Preferred substitution (keeps secrets out of git):
|
|
9
|
+
# kubectl create secret generic olam-plan-chat-secret -n olam \
|
|
10
|
+
# --from-literal=PLAN_CHAT_SECRET=$(cat ~/.olam/plan-chat-secret) \
|
|
11
|
+
# --dry-run=client -o yaml | kubectl apply -f -
|
|
12
|
+
#
|
|
13
|
+
# This template lives in packages/host-cp/k8s/templates/ (NOT manifests/)
|
|
14
|
+
# so that `kubectl apply -f manifests/plan-chat-service/` does NOT apply it —
|
|
15
|
+
# operators must explicitly handle Secret provisioning before applying manifests.
|
|
16
|
+
#
|
|
17
|
+
# Architecture: this Secret is mounted by BOTH the host-cp pod (so its
|
|
18
|
+
# renderSpaShell can inject window.__OLAM_PLAN_CHAT_BEARER__) AND the
|
|
19
|
+
# plan-chat-service pod (so its bearer-auth gate timing-safe-compares incoming
|
|
20
|
+
# Authorization: Bearer headers against the same value). One source-of-truth,
|
|
21
|
+
# two readers — replaces the previous "/data/plan-chat-secret in host-cp PVC"
|
|
22
|
+
# pattern that couldn't be shared across pods (RWO PVC).
|
|
23
|
+
apiVersion: v1
|
|
24
|
+
kind: Secret
|
|
25
|
+
metadata:
|
|
26
|
+
name: olam-plan-chat-secret
|
|
27
|
+
namespace: olam
|
|
28
|
+
labels:
|
|
29
|
+
olam.io/component: substrate
|
|
30
|
+
type: Opaque
|
|
31
|
+
stringData:
|
|
32
|
+
# Shared bearer secret for plan-chat-service's POST /v1/chunks and
|
|
33
|
+
# GET /v1/shape endpoints. host-cp injects this into window.__OLAM_PLAN_CHAT_BEARER__.
|
|
34
|
+
# Source: cat ~/.olam/plan-chat-secret
|
|
35
|
+
PLAN_CHAT_SECRET: "REPLACE_ME_FROM_HOME_DOTOLAM_PLAN_CHAT_SECRET"
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// Trace summary — operator triage digest over the NDJSON span trace.
|
|
2
|
+
//
|
|
3
|
+
// The NDJSON span sink (see `ndjson-span-sink.mjs`) writes one JSON line
|
|
4
|
+
// per span to ~/.olam/logs/host.trace.ndjson. Operators triage it today
|
|
5
|
+
// with hand-typed `jq` one-liners (README § Observability): "longest 5
|
|
6
|
+
// spans", "all failed spans", "failure-kind tally". This module codifies
|
|
7
|
+
// those recipes into ONE digest so the common questions get one answer
|
|
8
|
+
// without remembering jq incantations.
|
|
9
|
+
//
|
|
10
|
+
// Design:
|
|
11
|
+
// - `summarizeSpans(spans, opts)` is PURE — no I/O. Given an array of
|
|
12
|
+
// parsed span records (the exact shape the sink writes) it returns a
|
|
13
|
+
// digest object. This is the unit-testable core.
|
|
14
|
+
// - `parseTrace(ndjsonText)` turns raw file bytes into { spans, skipped }.
|
|
15
|
+
// Malformed lines (truncated tail line, partial write mid-rotation)
|
|
16
|
+
// are COUNTED, never thrown — triage tooling must survive a corrupt
|
|
17
|
+
// line, not die on it.
|
|
18
|
+
// - `summarizeTraceFile(path, opts)` is the thin file-reading wrapper.
|
|
19
|
+
// - `formatDigest(digest)` renders a human-readable report for the CLI.
|
|
20
|
+
//
|
|
21
|
+
// Read-only + additive: this module never writes the trace, never changes
|
|
22
|
+
// the line schema. It only READS fields the sink already emits
|
|
23
|
+
// (durationMs, exit._tag, exit.reason, name, attributes.failureKind).
|
|
24
|
+
|
|
25
|
+
import { readFile } from 'node:fs/promises';
|
|
26
|
+
|
|
27
|
+
const DEFAULT_TOP_N = 5;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parse NDJSON trace text into spans, tolerating malformed lines.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} text raw file contents
|
|
33
|
+
* @returns {{ spans: object[], skipped: number }}
|
|
34
|
+
*/
|
|
35
|
+
export function parseTrace(text) {
|
|
36
|
+
const spans = [];
|
|
37
|
+
let skipped = 0;
|
|
38
|
+
for (const line of String(text).split('\n')) {
|
|
39
|
+
const trimmed = line.trim();
|
|
40
|
+
if (trimmed === '') continue;
|
|
41
|
+
try {
|
|
42
|
+
spans.push(JSON.parse(trimmed));
|
|
43
|
+
} catch {
|
|
44
|
+
// Truncated tail line or a partial write straddling rotation — the
|
|
45
|
+
// append-only log can leave one half-line. Triage must not crash on
|
|
46
|
+
// it; count and move on.
|
|
47
|
+
skipped += 1;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return { spans, skipped };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isFailure(span) {
|
|
54
|
+
return span?.exit?._tag === 'Failure';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Compute a triage digest over parsed spans. Pure.
|
|
59
|
+
*
|
|
60
|
+
* @param {object[]} spans
|
|
61
|
+
* @param {{ topN?: number }} [opts]
|
|
62
|
+
* @returns {{
|
|
63
|
+
* totalSpans: number,
|
|
64
|
+
* failures: number,
|
|
65
|
+
* successes: number,
|
|
66
|
+
* failureRate: number,
|
|
67
|
+
* slowest: object[],
|
|
68
|
+
* recentFailures: object[],
|
|
69
|
+
* failureReasons: { reason: string, count: number }[],
|
|
70
|
+
* failureKinds: { kind: string, count: number }[],
|
|
71
|
+
* byName: { name: string, count: number, failures: number, meanMs: number|null, maxMs: number|null }[],
|
|
72
|
+
* }}
|
|
73
|
+
*/
|
|
74
|
+
export function summarizeSpans(spans, { topN = DEFAULT_TOP_N } = {}) {
|
|
75
|
+
const list = Array.isArray(spans) ? spans : [];
|
|
76
|
+
const totalSpans = list.length;
|
|
77
|
+
const failingSpans = list.filter(isFailure);
|
|
78
|
+
const failures = failingSpans.length;
|
|
79
|
+
const successes = totalSpans - failures;
|
|
80
|
+
const failureRate = totalSpans === 0 ? 0 : failures / totalSpans;
|
|
81
|
+
|
|
82
|
+
// Slowest spans by durationMs. Spans with a null duration (in-flight or
|
|
83
|
+
// missing endedAt) are excluded — they carry no comparable cost signal.
|
|
84
|
+
const timed = list.filter((s) => typeof s?.durationMs === 'number');
|
|
85
|
+
const slowest = [...timed]
|
|
86
|
+
.sort((a, b) => b.durationMs - a.durationMs)
|
|
87
|
+
.slice(0, topN)
|
|
88
|
+
.map(projectSpan);
|
|
89
|
+
|
|
90
|
+
// Recent failures — the trace is append-only, so the last failures in
|
|
91
|
+
// file order are the most recent. Take the tail.
|
|
92
|
+
const recentFailures = failingSpans.slice(-topN).reverse().map(projectSpan);
|
|
93
|
+
|
|
94
|
+
const failureReasons = tally(
|
|
95
|
+
failingSpans,
|
|
96
|
+
(s) => (s?.exit?.reason != null ? String(s.exit.reason) : '(no reason)'),
|
|
97
|
+
'reason',
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// failureKind is the world.lifecycle attribute the README already greps
|
|
101
|
+
// for; surface it as a first-class tally regardless of span name so
|
|
102
|
+
// recovery-relevant failures aggregate even when span names differ.
|
|
103
|
+
const failureKinds = tally(
|
|
104
|
+
list.filter((s) => s?.attributes?.failureKind != null),
|
|
105
|
+
(s) => String(s.attributes.failureKind),
|
|
106
|
+
'kind',
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const byName = aggregateByName(list);
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
totalSpans,
|
|
113
|
+
failures,
|
|
114
|
+
successes,
|
|
115
|
+
failureRate,
|
|
116
|
+
slowest,
|
|
117
|
+
recentFailures,
|
|
118
|
+
failureReasons,
|
|
119
|
+
failureKinds,
|
|
120
|
+
byName,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function projectSpan(s) {
|
|
125
|
+
return {
|
|
126
|
+
name: s?.name ?? null,
|
|
127
|
+
traceId: s?.traceId ?? null,
|
|
128
|
+
spanId: s?.spanId ?? null,
|
|
129
|
+
durationMs: typeof s?.durationMs === 'number' ? s.durationMs : null,
|
|
130
|
+
startedAt: typeof s?.startedAt === 'number' ? s.startedAt : null,
|
|
131
|
+
reason: s?.exit?.reason != null ? String(s.exit.reason) : null,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Group spans by a string key and count occurrences, labelling the key
|
|
136
|
+
// field per the caller (`reason` for failure reasons, `kind` for failure
|
|
137
|
+
// kinds). Sorted by count descending so the dominant cause leads.
|
|
138
|
+
function tally(spans, keyFn, label) {
|
|
139
|
+
const counts = new Map();
|
|
140
|
+
for (const s of spans) {
|
|
141
|
+
const key = keyFn(s);
|
|
142
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
143
|
+
}
|
|
144
|
+
const out = [];
|
|
145
|
+
for (const [k, count] of counts) out.push({ count, [label]: k });
|
|
146
|
+
return out.sort((a, b) => b.count - a.count);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Per-span-name aggregate: count, failure count, mean + max duration.
|
|
151
|
+
* Sorted by count descending so the busiest spans surface first.
|
|
152
|
+
*/
|
|
153
|
+
function aggregateByName(spans) {
|
|
154
|
+
const groups = new Map();
|
|
155
|
+
for (const s of spans) {
|
|
156
|
+
const name = s?.name != null ? String(s.name) : '(unnamed)';
|
|
157
|
+
let g = groups.get(name);
|
|
158
|
+
if (!g) {
|
|
159
|
+
g = { name, count: 0, failures: 0, durSum: 0, durCount: 0, maxMs: null };
|
|
160
|
+
groups.set(name, g);
|
|
161
|
+
}
|
|
162
|
+
g.count += 1;
|
|
163
|
+
if (isFailure(s)) g.failures += 1;
|
|
164
|
+
if (typeof s?.durationMs === 'number') {
|
|
165
|
+
g.durSum += s.durationMs;
|
|
166
|
+
g.durCount += 1;
|
|
167
|
+
g.maxMs = g.maxMs === null ? s.durationMs : Math.max(g.maxMs, s.durationMs);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return [...groups.values()]
|
|
171
|
+
.map((g) => ({
|
|
172
|
+
name: g.name,
|
|
173
|
+
count: g.count,
|
|
174
|
+
failures: g.failures,
|
|
175
|
+
meanMs: g.durCount === 0 ? null : g.durSum / g.durCount,
|
|
176
|
+
maxMs: g.maxMs,
|
|
177
|
+
}))
|
|
178
|
+
.sort((a, b) => b.count - a.count);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Read + summarize a trace file. Missing file → empty digest (an operator
|
|
183
|
+
* who hasn't generated any spans yet sees a clean zero-state, not a crash).
|
|
184
|
+
*
|
|
185
|
+
* @param {string} path
|
|
186
|
+
* @param {{ topN?: number }} [opts]
|
|
187
|
+
*/
|
|
188
|
+
export async function summarizeTraceFile(path, opts = {}) {
|
|
189
|
+
let text;
|
|
190
|
+
try {
|
|
191
|
+
text = await readFile(path, 'utf8');
|
|
192
|
+
} catch (err) {
|
|
193
|
+
if (err && err.code === 'ENOENT') {
|
|
194
|
+
return { ...summarizeSpans([], opts), skipped: 0, missing: true };
|
|
195
|
+
}
|
|
196
|
+
throw err;
|
|
197
|
+
}
|
|
198
|
+
const { spans, skipped } = parseTrace(text);
|
|
199
|
+
return { ...summarizeSpans(spans, opts), skipped, missing: false };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function fmtMs(ms) {
|
|
203
|
+
if (ms == null) return '—';
|
|
204
|
+
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`;
|
|
205
|
+
return `${Math.round(ms)}ms`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Render a digest as a human-readable, plain-text report for the CLI.
|
|
210
|
+
*
|
|
211
|
+
* @param {ReturnType<typeof summarizeSpans> & { skipped?: number, missing?: boolean, path?: string }} digest
|
|
212
|
+
* @returns {string}
|
|
213
|
+
*/
|
|
214
|
+
export function formatDigest(digest) {
|
|
215
|
+
const lines = [];
|
|
216
|
+
const path = digest.path ? ` (${digest.path})` : '';
|
|
217
|
+
lines.push(`Trace summary${path}`);
|
|
218
|
+
if (digest.missing) {
|
|
219
|
+
lines.push(' no trace file yet — nothing recorded.');
|
|
220
|
+
return lines.join('\n');
|
|
221
|
+
}
|
|
222
|
+
const pct = (digest.failureRate * 100).toFixed(1);
|
|
223
|
+
lines.push(
|
|
224
|
+
` ${digest.totalSpans} spans · ${digest.failures} failed (${pct}%) · ${digest.successes} ok` +
|
|
225
|
+
(digest.skipped ? ` · ${digest.skipped} malformed line(s) skipped` : ''),
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
if (digest.slowest.length) {
|
|
229
|
+
lines.push('');
|
|
230
|
+
lines.push(`Top ${digest.slowest.length} slowest:`);
|
|
231
|
+
for (const s of digest.slowest) {
|
|
232
|
+
lines.push(` ${fmtMs(s.durationMs).padStart(7)} ${s.name ?? '(unnamed)'}${s.traceId ? ` [${s.traceId}]` : ''}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (digest.recentFailures.length) {
|
|
237
|
+
lines.push('');
|
|
238
|
+
lines.push(`Recent failures (${digest.recentFailures.length}):`);
|
|
239
|
+
for (const f of digest.recentFailures) {
|
|
240
|
+
lines.push(` ${f.name ?? '(unnamed)'}: ${f.reason ?? '(no reason)'}${f.traceId ? ` [${f.traceId}]` : ''}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (digest.failureKinds.length) {
|
|
245
|
+
lines.push('');
|
|
246
|
+
lines.push('Failure kinds:');
|
|
247
|
+
for (const k of digest.failureKinds) lines.push(` ${String(k.count).padStart(4)} ${k.kind}`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (digest.failureReasons.length) {
|
|
251
|
+
lines.push('');
|
|
252
|
+
lines.push('Failure reasons:');
|
|
253
|
+
for (const r of digest.failureReasons) lines.push(` ${String(r.count).padStart(4)} ${r.reason}`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (digest.byName.length) {
|
|
257
|
+
lines.push('');
|
|
258
|
+
lines.push('By span name (count · failures · mean · max):');
|
|
259
|
+
for (const n of digest.byName) {
|
|
260
|
+
lines.push(
|
|
261
|
+
` ${String(n.count).padStart(5)} · ${String(n.failures).padStart(4)}f · ${fmtMs(n.meanMs).padStart(7)} · ${fmtMs(n.maxMs).padStart(7)} ${n.name}`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return lines.join('\n');
|
|
267
|
+
}
|
|
@@ -1,19 +1,32 @@
|
|
|
1
|
-
// bootstrap-selective.mjs — Phase D1 helper
|
|
1
|
+
// bootstrap-selective.mjs — Phase D1 helper, collapsed to a wildcard in
|
|
2
|
+
// Phase E5 (ATOMIC SERVING CUTOVER).
|
|
2
3
|
//
|
|
3
4
|
// Determines whether a SPA shell render path should SKIP the host-cp
|
|
4
5
|
// BOOTSTRAP_SCRIPT injection (cookie-bootstrap + fetch/EventSource
|
|
5
|
-
// rewrite shim) and instead let
|
|
6
|
-
//
|
|
6
|
+
// rewrite shim) and instead let the served SPA's own auth resolver +
|
|
7
|
+
// world-fetch shim handle auth.
|
|
7
8
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
9
|
+
// Phase E5: plan-chat-spa is now host-cp's SOLE served SPA. Its bundle
|
|
10
|
+
// re-homes the cookie-bootstrap + world-fetch-rewrite + 401-recover shim
|
|
11
|
+
// (packages/plan-chat-spa/src/lib/worldFetch.ts, installed at the top of
|
|
12
|
+
// src/main.tsx — Phase C). Therefore host-cp NEVER needs to inject
|
|
13
|
+
// BOOTSTRAP_SCRIPT anymore: every path is a "planning" (== SPA-owned)
|
|
14
|
+
// path. isPlanningPath() is collapsed to a wildcard accordingly.
|
|
10
15
|
//
|
|
11
|
-
//
|
|
16
|
+
// Reversal: set isPlanningPath to consult BOOTSTRAP_NOOP_PLANNING_PATHS
|
|
17
|
+
// again (restore the prefix-match body below) to re-narrow the no-op to
|
|
18
|
+
// the explicit planning prefixes; or, for full pre-D behaviour, also set
|
|
19
|
+
// BOOTSTRAP_NOOP_PLANNING_PATHS to []. The const is retained as the
|
|
20
|
+
// documented revert seam.
|
|
21
|
+
//
|
|
22
|
+
// Per K1 SCP-3 + phase-d-tasks.md D1 + phase-e-tasks.md E2.
|
|
12
23
|
|
|
13
24
|
/**
|
|
14
|
-
* Path prefixes that
|
|
15
|
-
*
|
|
16
|
-
*
|
|
25
|
+
* Path prefixes that WERE owned by plan-chat-spa under the Phase D
|
|
26
|
+
* selective no-op. Retained as the documented single-line revert seam:
|
|
27
|
+
* to re-narrow the bootstrap no-op back to only the planning surfaces,
|
|
28
|
+
* restore the prefix-match body in isPlanningPath() (see git history of
|
|
29
|
+
* this file at the Phase E5 commit) so it consults this array again.
|
|
17
30
|
*
|
|
18
31
|
* Format: include both the bare segment ("/plan") and the trailing-slash
|
|
19
32
|
* variant ("/plan/"). The trailing-slash form is the prefix-match
|
|
@@ -27,30 +40,19 @@ export const BOOTSTRAP_NOOP_PLANNING_PATHS = Object.freeze([
|
|
|
27
40
|
]);
|
|
28
41
|
|
|
29
42
|
/**
|
|
30
|
-
*
|
|
43
|
+
* Phase E5 wildcard: TRUE for every string path.
|
|
31
44
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* /plan/<sid>/<anything>
|
|
45
|
+
* host-cp now serves plan-chat-spa exclusively, whose bundle re-homes the
|
|
46
|
+
* cookie-bootstrap + world-fetch-rewrite shim (worldFetch.ts). No served
|
|
47
|
+
* path needs host-cp's BOOTSTRAP_SCRIPT injection anymore, so every path
|
|
48
|
+
* is treated as an SPA-owned ("planning") path and skips bootstrap.
|
|
37
49
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* /workspaces/plan (planning isn't the top segment)
|
|
41
|
-
* /plans (different segment)
|
|
42
|
-
* undefined / null (defensive)
|
|
50
|
+
* Returns false only for non-string input (defensive — a non-string
|
|
51
|
+
* pathname is never a real served path).
|
|
43
52
|
*
|
|
44
53
|
* @param {unknown} pathname
|
|
45
54
|
* @returns {boolean}
|
|
46
55
|
*/
|
|
47
56
|
export function isPlanningPath(pathname) {
|
|
48
|
-
|
|
49
|
-
for (const prefix of BOOTSTRAP_NOOP_PLANNING_PATHS) {
|
|
50
|
-
if (pathname === prefix) return true;
|
|
51
|
-
const bare = prefix.replace(/\/$/, '');
|
|
52
|
-
if (pathname === bare) return true;
|
|
53
|
-
if (prefix.endsWith('/') && pathname.startsWith(prefix)) return true;
|
|
54
|
-
}
|
|
55
|
-
return false;
|
|
57
|
+
return typeof pathname === 'string';
|
|
56
58
|
}
|
|
@@ -23,6 +23,13 @@
|
|
|
23
23
|
// in-memory buffer; overflow drops oldest events with an
|
|
24
24
|
// `:overflow` comment so consumers know they missed updates.
|
|
25
25
|
// - E4: per-event-type broadcast counter + sink count metric line.
|
|
26
|
+
// - E5: the metrics tick ALSO broadcasts a `stream.health` typed event
|
|
27
|
+
// carrying the same counters it logs, so any SPA tab can observe
|
|
28
|
+
// live stream health (sink count, per-event broadcast rates,
|
|
29
|
+
// overflow drops) without polling. Snapshot-cached like every
|
|
30
|
+
// other state event — reconnecting clients replay the last
|
|
31
|
+
// health payload immediately (first-paint parity). Opt out via
|
|
32
|
+
// `deps.healthEvents = false`.
|
|
26
33
|
//
|
|
27
34
|
// Pure module: no docker, no DB, no global clock except `setInterval`
|
|
28
35
|
// for the heartbeat/metrics timers (injectable in tests). Wiring those
|
|
@@ -43,7 +50,9 @@ import crypto from 'node:crypto';
|
|
|
43
50
|
* @property {number} [debounceMs.default] default trailing-edge ms (Phase E1)
|
|
44
51
|
* @property {number} [heartbeatMs] per-sink heartbeat interval (Phase E2)
|
|
45
52
|
* @property {number} [metricsMs] per-broadcaster metrics tick (Phase E4)
|
|
53
|
+
* @property {boolean} [healthEvents] broadcast `stream.health` on each metrics tick (Phase E5; default true)
|
|
46
54
|
* @property {number} [maxQueuedPerSink] bounded queue size (Phase E3)
|
|
55
|
+
* @property {() => number} [now] injectable clock for `stream.health.at` (tests)
|
|
47
56
|
* @property {(cb: () => void, ms: number) => any} [setTimer] injectable setInterval (tests)
|
|
48
57
|
* @property {(handle: any) => void} [clearTimer] injectable clearInterval (tests)
|
|
49
58
|
*/
|
|
@@ -66,6 +75,26 @@ import crypto from 'node:crypto';
|
|
|
66
75
|
* @property {number} overflows total `:overflow` drops since last reset
|
|
67
76
|
*/
|
|
68
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Payload wire-shape for the `stream.health` event (Phase E5). A
|
|
80
|
+
* point-in-time projection of the broadcaster's own observability
|
|
81
|
+
* counters, emitted on each metrics tick. `events` carries the
|
|
82
|
+
* per-event-type broadcast counts accrued during the just-elapsed
|
|
83
|
+
* interval (reset afterward), so consumers see a per-interval RATE
|
|
84
|
+
* rather than a monotonic total. `at` is the wall-clock emit time so a
|
|
85
|
+
* reconnecting client can tell how stale the replayed snapshot is.
|
|
86
|
+
*
|
|
87
|
+
* @typedef {object} StreamHealthPayload
|
|
88
|
+
* @property {Record<string, number>} events per-event broadcasts during the interval
|
|
89
|
+
* @property {number} sinks active-sink count at emit time
|
|
90
|
+
* @property {number} overflows `:overflow` drops during the interval
|
|
91
|
+
* @property {number} intervalMs the metrics-tick cadence that produced this payload
|
|
92
|
+
* @property {number} at Date.now() at emit time
|
|
93
|
+
*/
|
|
94
|
+
|
|
95
|
+
/** Event type emitted by the metrics tick (Phase E5). */
|
|
96
|
+
export const STREAM_HEALTH_EVENT = 'stream.health';
|
|
97
|
+
|
|
69
98
|
const DEFAULT_DEBOUNCE_MS = 100;
|
|
70
99
|
const DEFAULT_HEARTBEAT_MS = 25_000;
|
|
71
100
|
const DEFAULT_METRICS_MS = 60_000;
|
|
@@ -104,6 +133,8 @@ export function createHostStream(deps = {}) {
|
|
|
104
133
|
const defaultDebounceMs = deps.debounceMs?.default ?? DEFAULT_DEBOUNCE_MS;
|
|
105
134
|
const heartbeatMs = deps.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
|
|
106
135
|
const metricsMs = deps.metricsMs ?? DEFAULT_METRICS_MS;
|
|
136
|
+
const healthEvents = deps.healthEvents ?? true;
|
|
137
|
+
const now = deps.now ?? (() => Date.now());
|
|
107
138
|
const maxQueuedPerSink = deps.maxQueuedPerSink ?? DEFAULT_MAX_QUEUED;
|
|
108
139
|
const setTimer = deps.setTimer ?? ((cb, ms) => setInterval(cb, ms));
|
|
109
140
|
const clearTimer = deps.clearTimer ?? ((h) => clearInterval(h));
|
|
@@ -273,6 +304,27 @@ export function createHostStream(deps = {}) {
|
|
|
273
304
|
const events = {};
|
|
274
305
|
for (const [type, count] of eventCounters) events[type] = count;
|
|
275
306
|
log(`events=${JSON.stringify(events)} sinks=${sinks.size}${overflowCounter > 0 ? ` overflows=${overflowCounter}` : ''}`);
|
|
307
|
+
|
|
308
|
+
// Phase E5: broadcast the same counters as a typed `stream.health`
|
|
309
|
+
// event so SPA tabs can observe live stream health without polling.
|
|
310
|
+
// Built from the interval's counters BEFORE the reset below, so the
|
|
311
|
+
// payload is a per-interval rate. The broadcast itself bumps the
|
|
312
|
+
// `stream.health` counter, but the immediately-following reset wipes
|
|
313
|
+
// it — the next interval never double-counts this tick's own emit.
|
|
314
|
+
// Bypasses debounce (immediate path) since each tick is already
|
|
315
|
+
// rate-limited to the metrics cadence.
|
|
316
|
+
if (healthEvents) {
|
|
317
|
+
/** @type {StreamHealthPayload} */
|
|
318
|
+
const payload = {
|
|
319
|
+
events,
|
|
320
|
+
sinks: sinks.size,
|
|
321
|
+
overflows: overflowCounter,
|
|
322
|
+
intervalMs: metricsMs,
|
|
323
|
+
at: now(),
|
|
324
|
+
};
|
|
325
|
+
doBroadcast(STREAM_HEALTH_EVENT, payload);
|
|
326
|
+
}
|
|
327
|
+
|
|
276
328
|
eventCounters.clear();
|
|
277
329
|
overflowCounter = 0;
|
|
278
330
|
}
|