@cohortapp/agent-sdk 2.17.0 → 2.18.4
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/.claude/settings.json +18 -0
- package/.env.example +18 -5
- package/README.md +1 -0
- package/bin/maestro.mjs +62 -0
- package/docs/guides/billing-console-keys.md +60 -0
- package/docs/guides/front-door-session.md +54 -9
- package/docs/guides/mac-mini.md +20 -25
- package/docs/guides/setup-wizard.md +1 -1
- package/docs/runbooks/fleet-rollout.md +156 -0
- package/docs/runbooks/mac-mini-bootstrap.md +12 -14
- package/lib/action-executor.js +19 -3
- package/lib/budget-guard.mjs +279 -3
- package/lib/channels/base-adapter.mjs +3 -1
- package/lib/channels/contract.mjs +2 -1
- package/lib/channels/inbox-item.mjs +8 -0
- package/lib/claude-bin.mjs +5 -6
- package/lib/cli/doctor-checks.mjs +141 -10
- package/lib/cli/global-setup-extras.mjs +5 -1
- package/lib/cli/inbox.mjs +100 -15
- package/lib/cli/seat-auth.mjs +463 -0
- package/lib/cli/session.mjs +80 -12
- package/lib/collective/capture.mjs +8 -6
- package/lib/collective/global-config.mjs +63 -1
- package/lib/collective/presence.mjs +142 -5
- package/lib/comms/send-gate.mjs +559 -1
- package/lib/diagnostics/alerts.mjs +49 -0
- package/lib/diagnostics/cadence-output-freshness.mjs +288 -0
- package/lib/engine/agents/definitions.mjs +343 -0
- package/lib/engine/agents/persist.mjs +275 -0
- package/lib/engine/agents/runtime.mjs +748 -0
- package/lib/engine/agents/usage.mjs +95 -0
- package/lib/engine/auth-status.mjs +139 -0
- package/lib/engine/budget.mjs +194 -0
- package/lib/engine/cli.mjs +1204 -0
- package/lib/engine/commands/index.mjs +269 -0
- package/lib/engine/context/budget.mjs +219 -0
- package/lib/engine/context/cache.mjs +125 -0
- package/lib/engine/context/child-env.mjs +215 -0
- package/lib/engine/context/compaction.mjs +342 -0
- package/lib/engine/context/images.mjs +90 -0
- package/lib/engine/context/instructions.mjs +327 -0
- package/lib/engine/context/lazy-instructions.mjs +169 -0
- package/lib/engine/context/manager.mjs +182 -0
- package/lib/engine/context/real-path.mjs +91 -0
- package/lib/engine/context/secret-values.mjs +163 -0
- package/lib/engine/context/settings.mjs +274 -0
- package/lib/engine/context/stream-input.mjs +159 -0
- package/lib/engine/guard.mjs +152 -0
- package/lib/engine/hooks.mjs +713 -0
- package/lib/engine/loop.mjs +560 -0
- package/lib/engine/mcp/client.mjs +254 -0
- package/lib/engine/mcp/config.mjs +301 -0
- package/lib/engine/mcp/http.mjs +201 -0
- package/lib/engine/mcp/index.mjs +146 -0
- package/lib/engine/mcp/jsonrpc.mjs +147 -0
- package/lib/engine/mcp/naming.mjs +66 -0
- package/lib/engine/mcp/resources.mjs +89 -0
- package/lib/engine/mcp/results.mjs +133 -0
- package/lib/engine/mcp/stdio.mjs +137 -0
- package/lib/engine/mcp/supervisor.mjs +116 -0
- package/lib/engine/messages.mjs +104 -0
- package/lib/engine/output/json.mjs +164 -0
- package/lib/engine/output/stream-json.mjs +266 -0
- package/lib/engine/permissions.mjs +845 -0
- package/lib/engine/process-identity.mjs +164 -0
- package/lib/engine/process-tree.mjs +551 -0
- package/lib/engine/prompt.mjs +60 -0
- package/lib/engine/session/store.mjs +299 -0
- package/lib/engine/session-runtime/args.mjs +97 -0
- package/lib/engine/session-runtime/host.mjs +143 -0
- package/lib/engine/session-runtime/inbox.mjs +122 -0
- package/lib/engine/session-runtime/notifications.mjs +129 -0
- package/lib/engine/session-runtime/registry.mjs +328 -0
- package/lib/engine/session-runtime/runner.mjs +344 -0
- package/lib/engine/session-runtime/socket.mjs +212 -0
- package/lib/engine/session-runtime/wakeup.mjs +115 -0
- package/lib/engine/skills/index.mjs +321 -0
- package/lib/engine/tools/bash-background.mjs +533 -0
- package/lib/engine/tools/bash.mjs +216 -0
- package/lib/engine/tools/edit.mjs +97 -0
- package/lib/engine/tools/glob.mjs +81 -0
- package/lib/engine/tools/grep.mjs +224 -0
- package/lib/engine/tools/index.mjs +84 -0
- package/lib/engine/tools/list-agents.mjs +32 -0
- package/lib/engine/tools/ls.mjs +127 -0
- package/lib/engine/tools/monitor.mjs +82 -0
- package/lib/engine/tools/notebook-edit.mjs +218 -0
- package/lib/engine/tools/read.mjs +103 -0
- package/lib/engine/tools/schedule-wakeup.mjs +45 -0
- package/lib/engine/tools/schema.mjs +144 -0
- package/lib/engine/tools/send-message.mjs +77 -0
- package/lib/engine/tools/session.mjs +70 -0
- package/lib/engine/tools/todo.mjs +144 -0
- package/lib/engine/tools/toolsearch.mjs +217 -0
- package/lib/engine/tools/walk.mjs +193 -0
- package/lib/engine/tools/web-switch.mjs +31 -0
- package/lib/engine/tools/webfetch-html.mjs +387 -0
- package/lib/engine/tools/webfetch-net.mjs +340 -0
- package/lib/engine/tools/webfetch.mjs +198 -0
- package/lib/engine/tools/websearch.mjs +91 -0
- package/lib/engine/tools/workflow.mjs +95 -0
- package/lib/engine/tools/write.mjs +76 -0
- package/lib/engine/tui/line-editor.mjs +137 -0
- package/lib/engine/tui/render.mjs +86 -0
- package/lib/engine/tui/tui.mjs +274 -0
- package/lib/engine/wire/anthropic-messages.mjs +263 -0
- package/lib/engine/wire/effort.mjs +36 -0
- package/lib/engine/wire/errors.mjs +496 -0
- package/lib/engine/wire/http.mjs +441 -0
- package/lib/engine/wire/index.mjs +76 -0
- package/lib/engine/wire/openai-chat.mjs +332 -0
- package/lib/engine/wire/prompt-cache.mjs +79 -0
- package/lib/engine/wire/search.mjs +140 -0
- package/lib/engine/wire/sse.mjs +114 -0
- package/lib/engine/wire/stall.mjs +349 -0
- package/lib/engine/wire/token-provider.mjs +175 -0
- package/lib/engine/wire/usage.mjs +192 -0
- package/lib/engine/workflow/host.mjs +524 -0
- package/lib/engine/workflow/journal.mjs +188 -0
- package/lib/engine/workflow/json-schema.mjs +171 -0
- package/lib/engine/workflow/meta.mjs +329 -0
- package/lib/engine/workflow/notifications.mjs +52 -0
- package/lib/engine/workflow/runtime.mjs +447 -0
- package/lib/engine/workflow/sandbox.mjs +534 -0
- package/lib/engine/workflow/worker.mjs +141 -0
- package/lib/engine/workflow/worktree.mjs +74 -0
- package/lib/execution/disposition.mjs +1 -1
- package/lib/execution/intake.mjs +10 -0
- package/lib/execution/surface-policy.mjs +15 -0
- package/lib/learning/curator.mjs +8 -6
- package/lib/learning/reflect.mjs +8 -6
- package/lib/model-router/catalog/cohort.yaml +137 -0
- package/lib/model-router/catalog.mjs +118 -1
- package/lib/model-router/failover.mjs +67 -16
- package/lib/model-router/llm-task.mjs +39 -3
- package/lib/model-router/resolve.mjs +89 -3
- package/lib/model-router/spawn.mjs +46 -47
- package/lib/model-router/taxonomy.mjs +126 -4
- package/lib/org/cost-sync.mjs +141 -11
- package/lib/org/inbound/broadcast.mjs +289 -0
- package/lib/org/inbound/collective.mjs +375 -0
- package/lib/org/inbound/directedness.mjs +96 -8
- package/lib/org/inbound/facts.mjs +78 -2
- package/lib/org/inbound/project.mjs +22 -0
- package/lib/org/inbound/surfaces.mjs +14 -0
- package/lib/org/llm-token.mjs +879 -0
- package/lib/org/mesh.mjs +61 -0
- package/lib/org/messaging.mjs +3 -1
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +15 -0
- package/lib/org/quota.mjs +520 -0
- package/lib/org/tool-surface.mjs +104 -16
- package/lib/org/ui-parity.mjs +16 -1
- package/lib/org/work-ledger.mjs +37 -6
- package/lib/rate-guard.mjs +114 -1
- package/lib/resource-governor.mjs +41 -6
- package/lib/runtime/adapter.mjs +823 -0
- package/lib/runtime/child-env.mjs +191 -0
- package/lib/runtime/legacy-shell-guard.mjs +97 -0
- package/lib/runtime/seat-engine.mjs +162 -0
- package/lib/session/ask-ledger.mjs +271 -0
- package/lib/session/current-work.mjs +676 -0
- package/lib/session/feed-core.mjs +40 -3
- package/lib/session/launch-args.mjs +56 -4
- package/lib/session/status-summary.mjs +26 -9
- package/lib/session/upgrade-notice.mjs +42 -0
- package/lib/setup/claude-probe.mjs +117 -13
- package/lib/setup/enrich.mjs +13 -10
- package/lib/setup/sections/model.mjs +39 -13
- package/lib/telemetry/collect.mjs +208 -9
- package/lib/upgrade/ignored-drift.mjs +105 -0
- package/lib/voice/post-call-brief.mjs +30 -17
- package/package.json +13 -3
- package/plugins/maestro-skills/skills/board-work.md +5 -0
- package/plugins/maestro-skills/skills/inbound-triage.md +56 -15
- package/plugins/maestro-skills/skills/main-session.md +18 -7
- package/scripts/ci/check-tarball-fidelity.mjs +126 -2
- package/scripts/ci/run-tests.mjs +47 -19
- package/scripts/cohort-llm/api-key-helper.mjs +92 -0
- package/scripts/collective/hook-runner.mjs +29 -2
- package/scripts/continuous-monitor.sh +13 -0
- package/scripts/cost/track-claude-usage.mjs +15 -0
- package/scripts/daemon/agent-daemon.mjs +408 -20
- package/scripts/daemon/assurance.mjs +48 -12
- package/scripts/daemon/cadence-consumer.mjs +218 -68
- package/scripts/daemon/cadence-handlers.mjs +73 -4
- package/scripts/daemon/classifier.mjs +75 -26
- package/scripts/daemon/context-compiler.mjs +51 -37
- package/scripts/daemon/deliver.mjs +30 -1
- package/scripts/daemon/dispatcher.mjs +595 -149
- package/scripts/daemon/health.mjs +14 -1
- package/scripts/daemon/maestro-daemon.mjs +11 -0
- package/scripts/daemon/prompt-builder.mjs +24 -0
- package/scripts/daemon/responder.mjs +246 -79
- package/scripts/daemon/sdk-version.mjs +98 -16
- package/scripts/eval/probe-gateway.mjs +635 -0
- package/scripts/eval/replay/extract.mjs +270 -0
- package/scripts/eval/replay/grade.mjs +260 -0
- package/scripts/eval/replay/lib/config.mjs +50 -0
- package/scripts/eval/replay/lib/effects.mjs +65 -0
- package/scripts/eval/replay/lib/fixture.mjs +188 -0
- package/scripts/eval/replay/lib/judge.mjs +72 -0
- package/scripts/eval/replay/lib/redact.mjs +136 -0
- package/scripts/eval/replay/lib/sandbox.mjs +170 -0
- package/scripts/eval/replay/lib/schema-check.mjs +63 -0
- package/scripts/eval/replay/lib/transcript.mjs +76 -0
- package/scripts/eval/replay/mcp-replay-stub.mjs +101 -0
- package/scripts/eval/replay/report.mjs +185 -0
- package/scripts/eval/replay/run.mjs +404 -0
- package/scripts/fleet/rollout.mjs +1094 -0
- package/scripts/hooks/pre-send-audit.sh +36 -245
- package/scripts/hooks/pre-write-yaml-validate.mjs +275 -0
- package/scripts/hooks/validate-state-yaml.sh +190 -0
- package/scripts/huddle/huddle-llm.mjs +361 -0
- package/scripts/huddle/huddle-server.mjs +46 -121
- package/scripts/local-triggers/autoupdate.sh +448 -78
- package/scripts/local-triggers/run-trigger.sh +13 -0
- package/scripts/maintenance/pin-integrity.mjs +364 -0
- package/scripts/poll-slack-events.sh +41 -9
- package/scripts/poller/slack-socket-mode.mjs +28 -3
- package/scripts/session/supervisor.mjs +80 -13
- package/scripts/spawn-session.sh +13 -0
- package/bin/maestro.test.mjs +0 -1574
- package/lib/action-executor.test.mjs +0 -871
- package/lib/archetype.test.mjs +0 -132
- package/lib/assurance/plan-note.test.mjs +0 -234
- package/lib/assurance/room-budget.test.mjs +0 -486
- package/lib/assurance/tier.test.mjs +0 -174
- package/lib/autonomy.test.mjs +0 -66
- package/lib/backlog.test.mjs +0 -302
- package/lib/backup/policy.test.mjs +0 -305
- package/lib/budget-escalate.test.mjs +0 -232
- package/lib/budget-guard.envelope.test.mjs +0 -476
- package/lib/budget-guard.test.mjs +0 -427
- package/lib/cadence-bus-requeue.test.mjs +0 -83
- package/lib/cadence-bus-schedule.test.mjs +0 -194
- package/lib/cadence-bus.test.mjs +0 -720
- package/lib/cadences.test.mjs +0 -230
- package/lib/capability/inventory.test.mjs +0 -232
- package/lib/capability.test.mjs +0 -78
- package/lib/channels/base-adapter.test.mjs +0 -590
- package/lib/channels/channels.test.mjs +0 -371
- package/lib/channels/contract.test.mjs +0 -162
- package/lib/channels/inbox-item.test.mjs +0 -368
- package/lib/channels/orgmail/adapter.test.mjs +0 -448
- package/lib/channels/pairing.test.mjs +0 -270
- package/lib/channels/repeat-suppressor.test.mjs +0 -134
- package/lib/channels/slack-adapter.test.mjs +0 -212
- package/lib/channels/telegram-adapter.test.mjs +0 -306
- package/lib/channels/voice/adapter.test.mjs +0 -278
- package/lib/channels/whatsapp/adapter-baileys.test.mjs +0 -359
- package/lib/channels/whatsapp/baileys-typing.test.mjs +0 -154
- package/lib/charter.test.mjs +0 -89
- package/lib/claude-bin.test.mjs +0 -131
- package/lib/cli/board.test.mjs +0 -227
- package/lib/cli/design.test.mjs +0 -270
- package/lib/cli/doctor-checks.test.mjs +0 -336
- package/lib/cli/global-setup-extras.test.mjs +0 -462
- package/lib/cli/inbox.test.mjs +0 -230
- package/lib/cli/session-ack.test.mjs +0 -63
- package/lib/cli/session.test.mjs +0 -613
- package/lib/collective/capture.test.mjs +0 -121
- package/lib/collective/cards.test.mjs +0 -114
- package/lib/collective/config.test.mjs +0 -123
- package/lib/collective/global-config.test.mjs +0 -220
- package/lib/collective/global-skills.test.mjs +0 -126
- package/lib/collective/presence.test.mjs +0 -95
- package/lib/collective/recall.test.mjs +0 -116
- package/lib/collective/vendor-skills.test.mjs +0 -306
- package/lib/comms/send-gate.test.mjs +0 -770
- package/lib/comms.test.mjs +0 -41
- package/lib/context/budget.test.mjs +0 -252
- package/lib/context/history-scope.test.mjs +0 -79
- package/lib/cost/ledger-row.test.mjs +0 -183
- package/lib/design/design-md.test.mjs +0 -318
- package/lib/design/fixtures/DESIGN.golden.md +0 -238
- package/lib/design/fixtures/PRODUCT.golden.md +0 -67
- package/lib/design/fixtures/foundation.json +0 -133
- package/lib/design/refresh-gate.test.mjs +0 -144
- package/lib/design/write.test.mjs +0 -241
- package/lib/diagnostics/alerts.test.mjs +0 -318
- package/lib/diagnostics/backup-freshness.test.mjs +0 -185
- package/lib/diagnostics/counters.test.mjs +0 -206
- package/lib/diagnostics/events.test.mjs +0 -290
- package/lib/diagnostics/otel.test.mjs +0 -196
- package/lib/diagnostics/trace.test.mjs +0 -251
- package/lib/env-compat.test.mjs +0 -104
- package/lib/execution/disposition.test.mjs +0 -553
- package/lib/execution/drive.test.mjs +0 -270
- package/lib/execution/effects.test.mjs +0 -344
- package/lib/execution/intake.test.mjs +0 -389
- package/lib/execution/journal.test.mjs +0 -261
- package/lib/execution/match.test.mjs +0 -235
- package/lib/execution/pipeline.test.mjs +0 -392
- package/lib/execution/route.test.mjs +0 -186
- package/lib/execution/surface-policy.test.mjs +0 -162
- package/lib/fs-atomic.test.mjs +0 -72
- package/lib/fs-ownership.test.mjs +0 -158
- package/lib/goals/admission.test.mjs +0 -164
- package/lib/goals/classify.test.mjs +0 -167
- package/lib/goals/collaborate.test.mjs +0 -336
- package/lib/goals/gaps.test.mjs +0 -284
- package/lib/goals/loop.test.mjs +0 -845
- package/lib/hooks/bus.test.mjs +0 -387
- package/lib/identity/persona.test.mjs +0 -142
- package/lib/kpi-sensors.test.mjs +0 -278
- package/lib/kpi.test.mjs +0 -244
- package/lib/learning/config.test.mjs +0 -75
- package/lib/learning/counters.test.mjs +0 -69
- package/lib/learning/curator-consolidate.test.mjs +0 -238
- package/lib/learning/curator.test.mjs +0 -106
- package/lib/learning/reflect.test.mjs +0 -0
- package/lib/learning/session-index.test.mjs +0 -125
- package/lib/learning/skill-writer.test.mjs +0 -210
- package/lib/mandate/audit.test.mjs +0 -195
- package/lib/mandate/contract.test.mjs +0 -185
- package/lib/mandate/derive.test.mjs +0 -274
- package/lib/mandate/model.test.mjs +0 -164
- package/lib/mandate/refresh.test.mjs +0 -389
- package/lib/mcp/server.test.mjs +0 -426
- package/lib/model-router/auth-profiles.test.mjs +0 -580
- package/lib/model-router/catalog.test.mjs +0 -385
- package/lib/model-router/economics.test.mjs +0 -438
- package/lib/model-router/failover.test.mjs +0 -439
- package/lib/model-router/health.test.mjs +0 -338
- package/lib/model-router/integration-coverage.test.mjs +0 -831
- package/lib/model-router/integration.test.mjs +0 -564
- package/lib/model-router/ledger.test.mjs +0 -415
- package/lib/model-router/llm-task.test.mjs +0 -392
- package/lib/model-router/org-credentials.test.mjs +0 -265
- package/lib/model-router/pricing-refresh.test.mjs +0 -286
- package/lib/model-router/reconcile.test.mjs +0 -316
- package/lib/model-router/repair.test.mjs +0 -180
- package/lib/model-router/spawn.test.mjs +0 -446
- package/lib/model-router/taxonomy.test.mjs +0 -410
- package/lib/model-router.test.mjs +0 -1207
- package/lib/org/activity.test.mjs +0 -134
- package/lib/org/approvals.test.mjs +0 -216
- package/lib/org/awareness.test.mjs +0 -159
- package/lib/org/board-mine-cache.test.mjs +0 -53
- package/lib/org/board.test.mjs +0 -187
- package/lib/org/bootstrap-context.test.mjs +0 -153
- package/lib/org/client.test.mjs +0 -1206
- package/lib/org/cohort-client.test.mjs +0 -126
- package/lib/org/cost-sync.test.mjs +0 -153
- package/lib/org/doctor.test.mjs +0 -346
- package/lib/org/engagement-ledger.test.mjs +0 -112
- package/lib/org/engagement.test.mjs +0 -739
- package/lib/org/handoff.test.mjs +0 -269
- package/lib/org/inbound/directedness.test.mjs +0 -668
- package/lib/org/inbound/facts.test.mjs +0 -471
- package/lib/org/inbound/hydrate.test.mjs +0 -908
- package/lib/org/inbound/index.test.mjs +0 -429
- package/lib/org/inbound/project.test.mjs +0 -287
- package/lib/org/integration-tools.test.mjs +0 -160
- package/lib/org/keys.test.mjs +0 -92
- package/lib/org/knowledge.test.mjs +0 -326
- package/lib/org/leases.test.mjs +0 -235
- package/lib/org/mesh-directives.test.mjs +0 -110
- package/lib/org/mesh-integration.test.mjs +0 -127
- package/lib/org/mesh.test.mjs +0 -400
- package/lib/org/messaging.test.mjs +0 -471
- package/lib/org/param-contract.test.mjs +0 -477
- package/lib/org/policy.test.mjs +0 -237
- package/lib/org/protocol.checksum.test.mjs +0 -90
- package/lib/org/protocol.test.mjs +0 -323
- package/lib/org/push.test.mjs +0 -792
- package/lib/org/registry.test.mjs +0 -100
- package/lib/org/resource-tools.test.mjs +0 -361
- package/lib/org/tool-access.test.mjs +0 -144
- package/lib/org/tool-surface-integration.test.mjs +0 -120
- package/lib/org/tool-surface.test.mjs +0 -1268
- package/lib/org/typing.test.mjs +0 -291
- package/lib/org/ui-parity.test.mjs +0 -560
- package/lib/org/verify.test.mjs +0 -194
- package/lib/org/work-ledger.test.mjs +0 -273
- package/lib/plan/adoption-e2e.test.mjs +0 -366
- package/lib/plan/budget-enforcement.test.mjs +0 -400
- package/lib/plan/compile.test.mjs +0 -382
- package/lib/plan/emit.test.mjs +0 -269
- package/lib/plan/explain.test.mjs +0 -188
- package/lib/prompts/parallelism.test.mjs +0 -177
- package/lib/rag/rag.test.mjs +0 -505
- package/lib/rate-guard.test.mjs +0 -272
- package/lib/reactive-gate.test.mjs +0 -57
- package/lib/render.test.mjs +0 -68
- package/lib/resource-governor.test.mjs +0 -488
- package/lib/scheduling/dynamic-jobs.test.mjs +0 -344
- package/lib/scheduling/jitter.test.mjs +0 -140
- package/lib/secrets/broker.test.mjs +0 -280
- package/lib/secrets/providers.test.mjs +0 -274
- package/lib/security/audit-engine.test.mjs +0 -424
- package/lib/security/coerce-args.test.mjs +0 -281
- package/lib/security/dangerous-tools.test.mjs +0 -68
- package/lib/security/external-content.test.mjs +0 -84
- package/lib/security/redact.test.mjs +0 -441
- package/lib/security/secret-equal.test.mjs +0 -55
- package/lib/session/config.test.mjs +0 -92
- package/lib/session/feed-core.test.mjs +0 -198
- package/lib/session/first-run.test.mjs +0 -121
- package/lib/session/frontdoor.test.mjs +0 -205
- package/lib/session/handoffs.test.mjs +0 -183
- package/lib/session/identity.test.mjs +0 -180
- package/lib/session/inbox-claims.test.mjs +0 -286
- package/lib/session/launch-args.test.mjs +0 -157
- package/lib/session/liveness.test.mjs +0 -100
- package/lib/session/status-summary.test.mjs +0 -118
- package/lib/session-permissions.test.mjs +0 -120
- package/lib/setup/claude-probe.test.mjs +0 -187
- package/lib/setup/completeness.test.mjs +0 -110
- package/lib/setup/context-pack.test.mjs +0 -89
- package/lib/setup/enrich.test.mjs +0 -115
- package/lib/setup/enroll-from-cohort.test.mjs +0 -300
- package/lib/setup/integration.test.mjs +0 -162
- package/lib/setup/io.test.mjs +0 -77
- package/lib/setup/runner.test.mjs +0 -132
- package/lib/setup/sections/identity.test.mjs +0 -234
- package/lib/setup/sections/inventory.test.mjs +0 -198
- package/lib/setup/sections/learning.test.mjs +0 -81
- package/lib/setup/sections/mandate.test.mjs +0 -388
- package/lib/setup/sections/messaging.test.mjs +0 -127
- package/lib/setup/sections/model.test.mjs +0 -240
- package/lib/setup/sections/org.test.mjs +0 -346
- package/lib/setup/sections/orgmail.test.mjs +0 -118
- package/lib/setup/sections/recovery.test.mjs +0 -98
- package/lib/setup/sections/subagents.test.mjs +0 -429
- package/lib/setup/sections/verify.test.mjs +0 -175
- package/lib/setup/sot.test.mjs +0 -81
- package/lib/setup/state.test.mjs +0 -115
- package/lib/singleton.test.mjs +0 -151
- package/lib/subagents/cli.test.mjs +0 -389
- package/lib/subagents/client.test.mjs +0 -309
- package/lib/subagents/gap.test.mjs +0 -234
- package/lib/subagents/lock.test.mjs +0 -248
- package/lib/subagents/manifest.test.mjs +0 -175
- package/lib/subagents/refs.test.mjs +0 -204
- package/lib/subagents/resolve.test.mjs +0 -422
- package/lib/subagents/schema.test.mjs +0 -328
- package/lib/telemetry/alerts.test.mjs +0 -109
- package/lib/telemetry/collect.test.mjs +0 -1274
- package/lib/tool-definitions-integration.test.mjs +0 -83
- package/lib/tool-definitions.test.mjs +0 -437
- package/lib/upgrade/global-refresh.test.mjs +0 -65
- package/lib/upgrade/launchd-reconcile.test.mjs +0 -272
- package/lib/upgrade/post-steps.test.mjs +0 -200
- package/lib/upgrade/verify.test.mjs +0 -164
- package/lib/util/fetch-timeout.test.mjs +0 -202
- package/lib/util/reconnect.test.mjs +0 -369
- package/lib/util/unhandled.test.mjs +0 -216
- package/lib/voice/outbound.test.mjs +0 -69
- package/lib/voice/session-rotation.test.mjs +0 -114
- package/lib/voice/stt.test.mjs +0 -226
- package/lib/voice/voice.test.mjs +0 -990
- package/scripts/cadence/enqueue-cadence-tick.test.mjs +0 -187
- package/scripts/ci/check-docs-accuracy.test.mjs +0 -409
- package/scripts/ci/check-durable-write-seam.test.mjs +0 -90
- package/scripts/ci/check-no-build-artifacts.test.mjs +0 -71
- package/scripts/ci/check-no-residual-identity.test.mjs +0 -202
- package/scripts/ci/check-skill-packs.test.mjs +0 -495
- package/scripts/ci/check-subagent-frontmatter.test.mjs +0 -124
- package/scripts/ci/check.test.mjs +0 -194
- package/scripts/ci/conformance-org-api.test.mjs +0 -425
- package/scripts/cloud-relay/voice/relay-identity.test.mjs +0 -96
- package/scripts/collective/hook-runner.test.mjs +0 -173
- package/scripts/cost/fleet-digest.test.mjs +0 -207
- package/scripts/cost/track-claude-usage-pricing.test.mjs +0 -183
- package/scripts/cost/track-claude-usage.test.mjs +0 -148
- package/scripts/daemon/agent-daemon-board-mine.test.mjs +0 -96
- package/scripts/daemon/agent-daemon-design.test.mjs +0 -238
- package/scripts/daemon/agent-daemon-frontdoor.test.mjs +0 -60
- package/scripts/daemon/agent-daemon.test.mjs +0 -995
- package/scripts/daemon/assurance-e2e.test.mjs +0 -613
- package/scripts/daemon/assurance.test.mjs +0 -1791
- package/scripts/daemon/board-mirror.test.mjs +0 -165
- package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +0 -393
- package/scripts/daemon/cadence-consumer-governance.test.mjs +0 -276
- package/scripts/daemon/cadence-consumer.test.mjs +0 -776
- package/scripts/daemon/cadence-handlers.test.mjs +0 -837
- package/scripts/daemon/classifier-identity.test.mjs +0 -137
- package/scripts/daemon/classifier.test.mjs +0 -266
- package/scripts/daemon/classify-kind.test.mjs +0 -40
- package/scripts/daemon/context-compiler.test.mjs +0 -406
- package/scripts/daemon/deliver.test.mjs +0 -564
- package/scripts/daemon/dispatcher-cooldown.test.mjs +0 -122
- package/scripts/daemon/dispatcher-governance.test.mjs +0 -1013
- package/scripts/daemon/dispatcher-resume.test.mjs +0 -166
- package/scripts/daemon/dispatcher-session-continuity.test.mjs +0 -365
- package/scripts/daemon/execution-ladder.test.mjs +0 -470
- package/scripts/daemon/goal-steward-cadence.test.mjs +0 -312
- package/scripts/daemon/inbox-deferral-session.test.mjs +0 -49
- package/scripts/daemon/inbox-deferral.test.mjs +0 -336
- package/scripts/daemon/inbox-wake.test.mjs +0 -199
- package/scripts/daemon/integration.test.mjs +0 -149
- package/scripts/daemon/lib/self-echo.test.mjs +0 -153
- package/scripts/daemon/lib/session-router.test.mjs +0 -554
- package/scripts/daemon/prompt-builder-preamble.test.mjs +0 -210
- package/scripts/daemon/prompt-builder.test.mjs +0 -556
- package/scripts/daemon/responder-cost.test.mjs +0 -68
- package/scripts/daemon/responder-history.test.mjs +0 -221
- package/scripts/daemon/sdk-version.test.mjs +0 -31
- package/scripts/daemon/session-lock.test.mjs +0 -252
- package/scripts/daemon/session-outcomes.test.mjs +0 -533
- package/scripts/daemon/typing-registry.test.mjs +0 -102
- package/scripts/hooks/pre-send-audit.test.mjs +0 -354
- package/scripts/huddle/huddle-prompt.test.mjs +0 -176
- package/scripts/local-triggers/autoupdate.test.mjs +0 -518
- package/scripts/local-triggers/generate-plists.test.mjs +0 -456
- package/scripts/media-generation/brand-clause.test.mjs +0 -135
- package/scripts/org/send-orgmail.first-contact.test.mjs +0 -102
- package/scripts/poller/inbox-privilege-injection.test.mjs +0 -167
- package/scripts/poller/inbox-scan-poller.test.mjs +0 -295
- package/scripts/poller/lib/cloud-relay-dedup.test.mjs +0 -133
- package/scripts/poller/slack-socket-mode.test.mjs +0 -805
- package/scripts/poller-launchd/install.test.mjs +0 -243
- package/scripts/restore-from-backup.test.mjs +0 -181
- package/scripts/session/feed.test.mjs +0 -196
- package/scripts/session/supervisor-sh.test.mjs +0 -218
- package/scripts/session/supervisor.test.mjs +0 -482
- package/scripts/setup/configure-macos.test.mjs +0 -306
- package/scripts/setup/gen-subagent-manifest.test.mjs +0 -124
- package/scripts/setup/generate-agent-package-json.test.mjs +0 -143
- package/scripts/setup/generate-capability.test.mjs +0 -134
- package/scripts/setup/init-agent.test.mjs +0 -370
- package/scripts/setup/init-skill-marketplace.test.mjs +0 -193
- package/scripts/vendor/sync-skill-packs.test.mjs +0 -103
- package/scripts/watchdog/memory-watchdog.test.mjs +0 -64
|
@@ -1,1274 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* collect.test.mjs — the SHARED STATUS SNAPSHOT collector (collectStatus).
|
|
3
|
-
*
|
|
4
|
-
* Hermetic: injects node:os, a child-process count, an execFile fake (so no real
|
|
5
|
-
* powermetrics/df fork), and log text — asserts the snapshot shape, the
|
|
6
|
-
* state/activity/session derivation, machine stats, sub-agent count, and
|
|
7
|
-
* claudeAuth detection. Also unit-tests the pure parsers.
|
|
8
|
-
*
|
|
9
|
-
* Run: node --test lib/telemetry/collect.test.mjs
|
|
10
|
-
*/
|
|
11
|
-
"use strict";
|
|
12
|
-
|
|
13
|
-
import { test } from "node:test";
|
|
14
|
-
import assert from "node:assert/strict";
|
|
15
|
-
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from "node:fs";
|
|
16
|
-
import { tmpdir } from "node:os";
|
|
17
|
-
import { join } from "node:path";
|
|
18
|
-
|
|
19
|
-
import {
|
|
20
|
-
collectStatus,
|
|
21
|
-
resolveActivity,
|
|
22
|
-
detectClaudeAuth,
|
|
23
|
-
countSubAgents,
|
|
24
|
-
parsePowermetrics,
|
|
25
|
-
parseDf,
|
|
26
|
-
collectMachine,
|
|
27
|
-
collectPowermetrics,
|
|
28
|
-
collectThermal,
|
|
29
|
-
parseThermalProbe,
|
|
30
|
-
parseThermalPressure,
|
|
31
|
-
powermetricsSamplers,
|
|
32
|
-
parseSystemProfilerHardware,
|
|
33
|
-
ramLabel,
|
|
34
|
-
collectSpend24h,
|
|
35
|
-
collectInventory,
|
|
36
|
-
sessionLiveness,
|
|
37
|
-
SESSION_STALE_MS,
|
|
38
|
-
_internals,
|
|
39
|
-
} from "./collect.mjs";
|
|
40
|
-
|
|
41
|
-
const NOW = Date.parse("2026-06-12T12:00:00Z");
|
|
42
|
-
|
|
43
|
-
/** A node:os fake with controllable load/mem/cpus. */
|
|
44
|
-
function fakeOs(opts = {}) {
|
|
45
|
-
return {
|
|
46
|
-
loadavg: () => opts.loadavg || [1.5, 1.2, 1.0],
|
|
47
|
-
totalmem: () => opts.totalmem ?? 16 * 1024 * 1024 * 1024,
|
|
48
|
-
freemem: () => opts.freemem ?? 8 * 1024 * 1024 * 1024,
|
|
49
|
-
cpus: () => new Array(opts.cpus ?? 8).fill({ model: "x" }),
|
|
50
|
-
platform: () => opts.platform || "darwin",
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** An execFile fake that returns canned stdout per binary. Never forks. */
|
|
55
|
-
function fakeExecFile(map = {}) {
|
|
56
|
-
return (cmd, args, optsOrCb, maybeCb) => {
|
|
57
|
-
const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
|
|
58
|
-
const out = map[cmd] ?? "";
|
|
59
|
-
queueMicrotask(() => cb(null, out, ""));
|
|
60
|
-
return { on() {} };
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// ---------------------------------------------------------------------------
|
|
65
|
-
|
|
66
|
-
test("collectStatus builds a well-formed snapshot (mocked os/powermetrics/df)", async () => {
|
|
67
|
-
const status = await collectStatus({
|
|
68
|
-
agentRoot: "/nonexistent-root", // no logs → claudeAuth unknown
|
|
69
|
-
now: NOW,
|
|
70
|
-
os: fakeOs({ loadavg: [2, 1, 1], totalmem: 16e9, freemem: 4e9, cpus: 8 }),
|
|
71
|
-
execFile: fakeExecFile({
|
|
72
|
-
"/usr/bin/powermetrics": "CPU die temperature: 61.4 C\nCPU idle residency: 78.0 %\n",
|
|
73
|
-
"/bin/df": "Filesystem 1024-blocks Used Available Capacity Mounted\n/dev/disk1 1000 600 400 60% /\n",
|
|
74
|
-
}),
|
|
75
|
-
subAgentsRunning: 2,
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
// shape
|
|
79
|
-
assert.equal(typeof status.state, "string");
|
|
80
|
-
assert.equal(typeof status.activity, "string");
|
|
81
|
-
assert.equal(typeof status.subAgentsRunning, "number");
|
|
82
|
-
assert.ok(Array.isArray(status.alerts));
|
|
83
|
-
assert.ok(Array.isArray(status.machine.loadavg));
|
|
84
|
-
assert.equal(status.machine.loadavg.length, 3);
|
|
85
|
-
assert.match(status.ts, /^2026-06-12T12:00:00/);
|
|
86
|
-
|
|
87
|
-
// machine
|
|
88
|
-
assert.equal(status.machine.memUsedPct, 75); // 1 - 4/16 = 0.75
|
|
89
|
-
assert.equal(status.machine.tempC, 61.4);
|
|
90
|
-
assert.equal(status.machine.diskUsedPct, 60);
|
|
91
|
-
assert.equal(status.machine.cpuPct, 22); // 100 - 78 idle
|
|
92
|
-
|
|
93
|
-
// sub-agents + idle→busy promotion
|
|
94
|
-
assert.equal(status.subAgentsRunning, 2);
|
|
95
|
-
assert.equal(status.state, "busy"); // idle but children running → busy
|
|
96
|
-
assert.equal(status.claudeAuth, "unknown");
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
test("collectStatus: relogin in logs → state error + claudeAuth relogin_required + critical alert", async () => {
|
|
100
|
-
const status = await collectStatus({
|
|
101
|
-
agentRoot: "/x",
|
|
102
|
-
now: NOW,
|
|
103
|
-
os: fakeOs(),
|
|
104
|
-
execFile: fakeExecFile({ "/usr/bin/powermetrics": "", "/bin/df": "" }),
|
|
105
|
-
subAgentsRunning: 0,
|
|
106
|
-
logText: "ERROR authentication_error: please run /login\n",
|
|
107
|
-
});
|
|
108
|
-
assert.equal(status.claudeAuth, "relogin_required");
|
|
109
|
-
assert.equal(status.state, "error");
|
|
110
|
-
const relogin = status.alerts.find((a) => a.id === "claude_relogin_required");
|
|
111
|
-
assert.ok(relogin, "a critical re-login alert is attached");
|
|
112
|
-
assert.equal(relogin.severity, "critical");
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
test("resolveActivity: a running work session → busy + session{taskClass,model}", () => {
|
|
116
|
-
const sessions = [
|
|
117
|
-
{ sessionId: "s1", kind: "interactive", intent: "session: inbox", model: "sonnet", lastBeat: "2026-06-12T11:59:50Z" },
|
|
118
|
-
{ sessionId: "d1", kind: "daemon", intent: "daemon", lastBeat: "2026-06-12T11:59:55Z" },
|
|
119
|
-
];
|
|
120
|
-
const act = resolveActivity({ sessions, now: NOW });
|
|
121
|
-
assert.equal(act.state, "busy");
|
|
122
|
-
assert.equal(act.session.id, "s1");
|
|
123
|
-
assert.equal(act.session.taskClass, "inbox");
|
|
124
|
-
assert.equal(act.session.model, "sonnet");
|
|
125
|
-
assert.match(act.activity, /inbox/);
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
test("resolveActivity: only a daemon present → idle, no session", () => {
|
|
129
|
-
const act = resolveActivity({ sessions: [{ sessionId: "d1", kind: "daemon", intent: "daemon", lastBeat: "2026-06-12T11:59:55Z" }], now: NOW });
|
|
130
|
-
assert.equal(act.state, "idle");
|
|
131
|
-
assert.equal(act.session, null);
|
|
132
|
-
assert.equal(act.activity, "idle");
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
test("resolveActivity: nothing running → idle", () => {
|
|
136
|
-
const act = resolveActivity({ sessions: [], now: NOW });
|
|
137
|
-
assert.equal(act.state, "idle");
|
|
138
|
-
assert.equal(act.activity, "idle");
|
|
139
|
-
assert.equal(act.session, null);
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
test("countSubAgents: explicit override wins; injected liveClaudeStats used otherwise", () => {
|
|
143
|
-
assert.equal(countSubAgents({ subAgentsRunning: 3 }), 3);
|
|
144
|
-
assert.equal(countSubAgents({ liveClaudeStats: () => ({ count: 4, rssMB: 100 }) }), 4);
|
|
145
|
-
assert.equal(countSubAgents({ liveClaudeStats: () => { throw new Error("boom"); } }), 0);
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
test("detectClaudeAuth: logText override paths", () => {
|
|
149
|
-
assert.equal(detectClaudeAuth({ logText: "all good, session complete" }), "ok");
|
|
150
|
-
assert.equal(detectClaudeAuth({ logText: "Invalid API key provided" }), "relogin_required");
|
|
151
|
-
assert.equal(detectClaudeAuth({ logText: "" }), "unknown");
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
test("detectClaudeAuth: scans recent log files under agentRoot", () => {
|
|
155
|
-
const root = mkdtempSync(join(tmpdir(), "tel-auth-"));
|
|
156
|
-
try {
|
|
157
|
-
mkdirSync(join(root, "logs", "sessions"), { recursive: true });
|
|
158
|
-
writeFileSync(join(root, "logs", "sessions", "2026-06-12-sessions.jsonl"),
|
|
159
|
-
'{"msg":"oauth token has expired, please run claude login"}\n');
|
|
160
|
-
assert.equal(detectClaudeAuth({ agentRoot: root, now: NOW }), "relogin_required");
|
|
161
|
-
|
|
162
|
-
// A clean recent log → ok.
|
|
163
|
-
const root2 = mkdtempSync(join(tmpdir(), "tel-auth2-"));
|
|
164
|
-
mkdirSync(join(root2, "logs", "sessions"), { recursive: true });
|
|
165
|
-
writeFileSync(join(root2, "logs", "sessions", "2026-06-12-sessions.jsonl"), '{"msg":"ok"}\n');
|
|
166
|
-
assert.equal(detectClaudeAuth({ agentRoot: root2, now: NOW }), "ok");
|
|
167
|
-
rmSync(root2, { recursive: true, force: true });
|
|
168
|
-
|
|
169
|
-
// No logs at all → unknown.
|
|
170
|
-
const root3 = mkdtempSync(join(tmpdir(), "tel-auth3-"));
|
|
171
|
-
assert.equal(detectClaudeAuth({ agentRoot: root3, now: NOW }), "unknown");
|
|
172
|
-
rmSync(root3, { recursive: true, force: true });
|
|
173
|
-
} finally {
|
|
174
|
-
rmSync(root, { recursive: true, force: true });
|
|
175
|
-
}
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
test("collectMachine: derives memUsedPct + loadavg without powermetrics/df", async () => {
|
|
179
|
-
// memUsedPct now prefers macOS's memory_pressure, so "no external tools" has
|
|
180
|
-
// to include that one too — otherwise this test reads the REAL machine and
|
|
181
|
-
// asserts against the fake os, which is how it started failing.
|
|
182
|
-
const noTools = (cmd, args, opts, cb) => { cb(new Error("ENOENT")); return { on() {} }; };
|
|
183
|
-
const m = await collectMachine({ os: fakeOs({ totalmem: 10e9, freemem: 2e9, loadavg: [4, 2, 1], cpus: 4 }), execFile: noTools, powermetrics: false, disk: false });
|
|
184
|
-
assert.equal(m.memUsedPct, 80);
|
|
185
|
-
assert.deepEqual(m.loadavg, [4, 2, 1]);
|
|
186
|
-
assert.equal(m.cpuPct, 100); // load 4 / 4 cpus = 100%
|
|
187
|
-
assert.equal(m.tempC, undefined);
|
|
188
|
-
assert.equal(m.diskUsedPct, undefined);
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
test("parsePowermetrics + parseDf are tolerant pure parsers", () => {
|
|
192
|
-
const pm = parsePowermetrics("GPU die temperature: 45 C\nCPU die temperature: 72.5 C\nCPU idle residency: 90.0 %\n");
|
|
193
|
-
assert.equal(pm.tempC, 72.5);
|
|
194
|
-
assert.equal(pm.cpuPct, 10);
|
|
195
|
-
assert.deepEqual(parsePowermetrics("nonsense"), {});
|
|
196
|
-
|
|
197
|
-
assert.equal(parseDf("Filesystem blocks Used Avail Capacity Mounted\n/dev/x 100 88 12 88% /\n"), 88);
|
|
198
|
-
assert.equal(parseDf(""), undefined);
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
test("collectStatus never throws on a broken execFile / os", async () => {
|
|
202
|
-
const status = await collectStatus({
|
|
203
|
-
agentRoot: "/x",
|
|
204
|
-
now: NOW,
|
|
205
|
-
os: { loadavg: () => { throw new Error("x"); }, totalmem: () => { throw new Error("x"); }, freemem: () => 0, cpus: () => { throw new Error("x"); }, platform: () => "darwin" },
|
|
206
|
-
execFile: () => { throw new Error("exec boom"); },
|
|
207
|
-
subAgentsRunning: 0,
|
|
208
|
-
});
|
|
209
|
-
assert.ok(status && typeof status === "object");
|
|
210
|
-
assert.deepEqual(status.machine.loadavg, [0, 0, 0]);
|
|
211
|
-
assert.equal(status.machine.memUsedPct, 0);
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
test("execFileSafe resolves '' on a throwing exec impl (no reject)", async () => {
|
|
215
|
-
const out = await _internals.execFileSafe(() => { throw new Error("nope"); }, "x", [], 50);
|
|
216
|
-
assert.equal(out, "");
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
// ---------------------------------------------------------------------------
|
|
220
|
-
// The Fleet telemetry vocabulary — the fields the hq Fleet row reads by name.
|
|
221
|
-
//
|
|
222
|
-
// These four (device / ram / uptimeDays / spend24h) were READ by
|
|
223
|
-
// hq/src/components/fleet/machine.ts#buildFleetMachine and emitted by NOTHING,
|
|
224
|
-
// while four others (loadavg / memUsedPct / cpuPct / diskUsedPct) were emitted
|
|
225
|
-
// here and read by nothing. The intersection in production was EMPTY, so the
|
|
226
|
-
// Fleet view em-dashed every hardware column on the one machine that was
|
|
227
|
-
// genuinely alive. These tests pin the producer half of that contract.
|
|
228
|
-
// ---------------------------------------------------------------------------
|
|
229
|
-
|
|
230
|
-
const SP_HARDWARE = [
|
|
231
|
-
"Hardware:",
|
|
232
|
-
" Hardware Overview:",
|
|
233
|
-
" Model Name: Mac mini",
|
|
234
|
-
" Model Identifier: Mac16,11",
|
|
235
|
-
" Chip: Apple M4 Pro",
|
|
236
|
-
" Total Number of Cores: 12 (8 performance and 4 efficiency)",
|
|
237
|
-
" Memory: 24 GB",
|
|
238
|
-
"",
|
|
239
|
-
].join("\n");
|
|
240
|
-
|
|
241
|
-
test("collectMachine emits the INVENTORY the Fleet row reads: device, ram, cores", async () => {
|
|
242
|
-
const m = await collectMachine({
|
|
243
|
-
os: fakeOs({ totalmem: 24 * 1024 ** 3, freemem: 2e9, cpus: 12 }),
|
|
244
|
-
execFile: fakeExecFile({ "/usr/sbin/system_profiler": SP_HARDWARE }),
|
|
245
|
-
powermetrics: false,
|
|
246
|
-
disk: false,
|
|
247
|
-
spend: false,
|
|
248
|
-
});
|
|
249
|
-
assert.equal(m.device, "Mac mini M4 Pro");
|
|
250
|
-
assert.equal(m.ram, "24 GB");
|
|
251
|
-
assert.equal(m.cores, 12);
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
test("collectMachine falls back to node:os inventory when system_profiler yields nothing", async () => {
|
|
255
|
-
const m = await collectMachine({
|
|
256
|
-
os: {
|
|
257
|
-
loadavg: () => [1, 1, 1],
|
|
258
|
-
totalmem: () => 24 * 1024 ** 3,
|
|
259
|
-
freemem: () => 12 * 1024 ** 3,
|
|
260
|
-
cpus: () => new Array(12).fill({ model: "Apple M4 Pro" }),
|
|
261
|
-
platform: () => "darwin",
|
|
262
|
-
uptime: () => 0,
|
|
263
|
-
},
|
|
264
|
-
execFile: fakeExecFile({}), // system_profiler returns ""
|
|
265
|
-
powermetrics: false,
|
|
266
|
-
disk: false,
|
|
267
|
-
spend: false,
|
|
268
|
-
});
|
|
269
|
-
// Never nothing: node:os alone still proves the chip, the RAM and the cores.
|
|
270
|
-
assert.equal(m.device, "Apple M4 Pro");
|
|
271
|
-
assert.equal(m.ram, "24 GB");
|
|
272
|
-
assert.equal(m.cores, 12);
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
test("collectMachine emits uptimeDays", async () => {
|
|
276
|
-
const osImpl = fakeOs({});
|
|
277
|
-
osImpl.uptime = () => 5756923; // ~66.6 days
|
|
278
|
-
const m = await collectMachine({
|
|
279
|
-
os: osImpl, powermetrics: false, disk: false, inventory: false, spend: false,
|
|
280
|
-
});
|
|
281
|
-
assert.equal(m.uptimeDays, 66.6);
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
test("ramLabel renders binary GB the way the spec sheet does", () => {
|
|
285
|
-
assert.equal(ramLabel(24 * 1024 ** 3), "24 GB");
|
|
286
|
-
assert.equal(ramLabel(192 * 1024 ** 3), "192 GB");
|
|
287
|
-
assert.equal(ramLabel(8 * 1024 ** 3), "8 GB");
|
|
288
|
-
assert.equal(ramLabel(0), "");
|
|
289
|
-
assert.equal(ramLabel("nonsense"), "");
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
test("parseSystemProfilerHardware is a tolerant pure parser", () => {
|
|
293
|
-
assert.deepEqual(parseSystemProfilerHardware(SP_HARDWARE), {
|
|
294
|
-
device: "Mac mini M4 Pro",
|
|
295
|
-
ram: "24 GB",
|
|
296
|
-
cores: 12,
|
|
297
|
-
});
|
|
298
|
-
// Partial output degrades field-by-field rather than throwing.
|
|
299
|
-
assert.deepEqual(parseSystemProfilerHardware(" Memory: 16 GB\n"), { ram: "16 GB" });
|
|
300
|
-
assert.deepEqual(parseSystemProfilerHardware(""), {});
|
|
301
|
-
assert.deepEqual(parseSystemProfilerHardware(null), {});
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
// ---------------------------------------------------------------------------
|
|
305
|
-
// tempC — the sampler bug, and the rule that a failure is never silent
|
|
306
|
-
// ---------------------------------------------------------------------------
|
|
307
|
-
|
|
308
|
-
test("powermetricsSamplers uses the arm64 spelling — 'smc' is Intel-only", () => {
|
|
309
|
-
// The shipped constant was "smc,cpu_power" unconditionally. On Apple Silicon
|
|
310
|
-
// powermetrics rejects that argument list outright and exits non-zero BEFORE
|
|
311
|
-
// the root check, so the probe was unreachable on every machine in the fleet.
|
|
312
|
-
assert.equal(powermetricsSamplers("arm64"), "thermal,cpu_power");
|
|
313
|
-
assert.equal(powermetricsSamplers("x64"), "smc,cpu_power");
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
test("collectPowermetrics passes the arch-correct sampler to the binary", async () => {
|
|
317
|
-
const seen = [];
|
|
318
|
-
const spy = (cmd, args, optsOrCb, maybeCb) => {
|
|
319
|
-
const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
|
|
320
|
-
seen.push({ cmd, args });
|
|
321
|
-
queueMicrotask(() => cb(null, "CPU die temperature: 54.2 C\n", ""));
|
|
322
|
-
return { on() {} };
|
|
323
|
-
};
|
|
324
|
-
const pm = await collectPowermetrics({ execFile: spy, platform: "darwin", arch: "arm64" });
|
|
325
|
-
assert.equal(pm.tempC, 54.2);
|
|
326
|
-
assert.equal(pm.source, "powermetrics");
|
|
327
|
-
const samplerArg = seen[0].args[seen[0].args.indexOf("--samplers") + 1];
|
|
328
|
-
assert.equal(samplerArg, "thermal,cpu_power");
|
|
329
|
-
assert.ok(!samplerArg.includes("smc"), "must not send the Intel-only sampler to arm64");
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
test("collectPowermetrics ALWAYS says why it has no answer (fail-open, never silent)", async () => {
|
|
333
|
-
// The unprivileged reality on every seat: the probe yields nothing.
|
|
334
|
-
const pm = await collectPowermetrics({
|
|
335
|
-
execFile: fakeExecFile({}), platform: "darwin", arch: "arm64", sudoPowermetrics: false,
|
|
336
|
-
});
|
|
337
|
-
assert.equal(pm.tempC, undefined);
|
|
338
|
-
assert.match(pm.detail, /requires root/);
|
|
339
|
-
// It no longer tells the operator to edit sudoers to get a temperature. That
|
|
340
|
-
// advice was futile: powermetrics has no die temperature on Apple Silicon at
|
|
341
|
-
// ANY privilege level, so following it to the letter still yielded nothing.
|
|
342
|
-
assert.ok(!/TELEMETRY_SUDO_POWERMETRICS/.test(pm.detail));
|
|
343
|
-
assert.ok(!/sudoers/.test(pm.detail));
|
|
344
|
-
|
|
345
|
-
// A non-darwin host is a DIFFERENT absence and says so.
|
|
346
|
-
const linux = await collectPowermetrics({ platform: "linux" });
|
|
347
|
-
assert.match(linux.detail, /platform linux/);
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
test("collectMachine surfaces a thermal failure as a NOTE, never as the value", async () => {
|
|
351
|
-
const m = await collectMachine({
|
|
352
|
-
os: fakeOs({}),
|
|
353
|
-
execFile: fakeExecFile({}),
|
|
354
|
-
platform: "darwin",
|
|
355
|
-
arch: "arm64",
|
|
356
|
-
sudoPowermetrics: false,
|
|
357
|
-
disk: false,
|
|
358
|
-
inventory: false,
|
|
359
|
-
spend: false,
|
|
360
|
-
});
|
|
361
|
-
assert.equal(m.tempC, undefined);
|
|
362
|
-
assert.equal(m.tempSource, undefined);
|
|
363
|
-
assert.equal(m.thermalState, undefined);
|
|
364
|
-
assert.equal(typeof m.tempDetail, "string");
|
|
365
|
-
// The note must not blame the operator's sudoers file for a limitation of the
|
|
366
|
-
// hardware — that is the message this whole rewrite exists to delete.
|
|
367
|
-
assert.ok(!/sudoers/.test(m.tempDetail));
|
|
368
|
-
// And the cpuPct it DOES have is labelled as the estimate it is.
|
|
369
|
-
assert.equal(m.cpuSource, "loadavg");
|
|
370
|
-
});
|
|
371
|
-
|
|
372
|
-
test("collectPowermetrics tries the opt-in sudo path only when enabled", async () => {
|
|
373
|
-
const seen = [];
|
|
374
|
-
const spy = (cmd, args, optsOrCb, maybeCb) => {
|
|
375
|
-
const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
|
|
376
|
-
seen.push(cmd);
|
|
377
|
-
// Only the sudo invocation yields.
|
|
378
|
-
const out = cmd === "/usr/bin/sudo" ? "CPU die temperature: 61.0 C\n" : "";
|
|
379
|
-
queueMicrotask(() => cb(null, out, ""));
|
|
380
|
-
return { on() {} };
|
|
381
|
-
};
|
|
382
|
-
const off = await collectPowermetrics({
|
|
383
|
-
execFile: spy, platform: "darwin", arch: "arm64", sudoPowermetrics: false,
|
|
384
|
-
});
|
|
385
|
-
assert.equal(off.tempC, undefined, "must not shell out to sudo unless asked");
|
|
386
|
-
assert.ok(!seen.includes("/usr/bin/sudo"));
|
|
387
|
-
|
|
388
|
-
seen.length = 0;
|
|
389
|
-
const on = await collectPowermetrics({
|
|
390
|
-
execFile: spy, platform: "darwin", arch: "arm64", sudoPowermetrics: true,
|
|
391
|
-
});
|
|
392
|
-
assert.equal(on.tempC, 61);
|
|
393
|
-
assert.equal(on.source, "sudo-powermetrics");
|
|
394
|
-
assert.ok(seen.includes("/usr/bin/sudo"));
|
|
395
|
-
// `-n` so a machine with no NOPASSWD rule fails instantly instead of hanging
|
|
396
|
-
// the beat on a password prompt nobody will ever see.
|
|
397
|
-
const sudoCall = seen.indexOf("/usr/bin/sudo");
|
|
398
|
-
assert.ok(sudoCall >= 0);
|
|
399
|
-
});
|
|
400
|
-
|
|
401
|
-
// ---------------------------------------------------------------------------
|
|
402
|
-
// Thermal — real °C without root, and a coarse state word that never fails.
|
|
403
|
-
//
|
|
404
|
-
// Every test here stubs the exec seam. NOTHING in this file forks `swift`,
|
|
405
|
-
// `powermetrics` or `pmset`; the probe's real output is pinned as fixture text.
|
|
406
|
-
// ---------------------------------------------------------------------------
|
|
407
|
-
|
|
408
|
-
/** Verbatim stdout of `swift lib/telemetry/thermal-probe.swift` on a Mac16,11. */
|
|
409
|
-
const PROBE_OK = "thermalState=nominal\ntempC=60.76\ntempSensors=42\n";
|
|
410
|
-
|
|
411
|
-
/** Verbatim `sudo powermetrics --samplers thermal` — note: NO temperature line. */
|
|
412
|
-
const PM_THERMAL = [
|
|
413
|
-
"Machine model: Mac16,11",
|
|
414
|
-
"",
|
|
415
|
-
"**** Thermal pressure ****",
|
|
416
|
-
"",
|
|
417
|
-
"Current pressure level: Nominal",
|
|
418
|
-
"",
|
|
419
|
-
].join("\n");
|
|
420
|
-
|
|
421
|
-
/** Route stdout by binary, so a test can say which rung of the ladder answers. */
|
|
422
|
-
const SWIFT = "/usr/bin/swift";
|
|
423
|
-
const SUDO = "/usr/bin/sudo";
|
|
424
|
-
const PMSET = "/usr/bin/pmset";
|
|
425
|
-
|
|
426
|
-
test("collectThermal reads a REAL °C unprivileged, via the IOHID probe", async () => {
|
|
427
|
-
// The headline fix. powermetrics cannot produce a die temperature on Apple
|
|
428
|
-
// Silicon even as root; IOHIDEventSystem hands it over to any uid.
|
|
429
|
-
const seen = [];
|
|
430
|
-
const spy = (cmd, args, optsOrCb, maybeCb) => {
|
|
431
|
-
const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
|
|
432
|
-
seen.push(cmd);
|
|
433
|
-
queueMicrotask(() => cb(null, cmd === SWIFT ? PROBE_OK : "", ""));
|
|
434
|
-
return { on() {} };
|
|
435
|
-
};
|
|
436
|
-
const th = await collectThermal({ execFile: spy, platform: "darwin", arch: "arm64" });
|
|
437
|
-
assert.equal(th.tempC, 60.76);
|
|
438
|
-
assert.equal(th.tempSource, "iohid");
|
|
439
|
-
assert.equal(th.thermalState, "nominal");
|
|
440
|
-
assert.equal(th.thermalSource, "processinfo");
|
|
441
|
-
// No root, no sudo, on the happy path.
|
|
442
|
-
assert.ok(!seen.includes(SUDO), "the °C path must never need sudo");
|
|
443
|
-
// The detail is an EXPLANATION riding alongside a real reading, not a
|
|
444
|
-
// substitute for one — the drawer prints the number and demotes this.
|
|
445
|
-
assert.equal(typeof th.detail, "string");
|
|
446
|
-
assert.match(th.detail, /IOHIDEventSystem/);
|
|
447
|
-
});
|
|
448
|
-
|
|
449
|
-
test("collectThermal falls back to the coarse STATE when there is no °C", async () => {
|
|
450
|
-
// The probe ran but the sensors gave nothing: state alone is still a useful,
|
|
451
|
-
// honest answer, and the field semantics say so rather than faking a number.
|
|
452
|
-
const spy = (cmd, args, optsOrCb, maybeCb) => {
|
|
453
|
-
const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
|
|
454
|
-
queueMicrotask(() => cb(null, cmd === SWIFT ? "thermalState=serious\n" : "", ""));
|
|
455
|
-
return { on() {} };
|
|
456
|
-
};
|
|
457
|
-
const th = await collectThermal({ execFile: spy, platform: "darwin", arch: "arm64" });
|
|
458
|
-
assert.equal(th.tempC, undefined);
|
|
459
|
-
assert.equal(th.thermalState, "serious");
|
|
460
|
-
assert.equal(th.thermalSource, "processinfo");
|
|
461
|
-
assert.match(th.detail, /no numeric °C/);
|
|
462
|
-
});
|
|
463
|
-
|
|
464
|
-
test("collectThermal uses sudo powermetrics for PRESSURE only, and only when opted in", async () => {
|
|
465
|
-
const seen = [];
|
|
466
|
-
const spy = (cmd, args, optsOrCb, maybeCb) => {
|
|
467
|
-
const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
|
|
468
|
-
seen.push(cmd);
|
|
469
|
-
// swift is absent on this host; sudo powermetrics answers.
|
|
470
|
-
queueMicrotask(() => cb(null, cmd === SUDO ? PM_THERMAL : "", ""));
|
|
471
|
-
return { on() {} };
|
|
472
|
-
};
|
|
473
|
-
const off = await collectThermal({
|
|
474
|
-
execFile: spy, platform: "darwin", arch: "arm64", sudoPowermetrics: false,
|
|
475
|
-
});
|
|
476
|
-
assert.ok(!seen.includes(SUDO), "must not shell out to sudo unless asked");
|
|
477
|
-
assert.equal(off.thermalState, undefined);
|
|
478
|
-
|
|
479
|
-
seen.length = 0;
|
|
480
|
-
const on = await collectThermal({
|
|
481
|
-
execFile: spy, platform: "darwin", arch: "arm64", sudoPowermetrics: true,
|
|
482
|
-
});
|
|
483
|
-
assert.ok(seen.includes(SUDO));
|
|
484
|
-
assert.equal(on.thermalState, "nominal");
|
|
485
|
-
assert.equal(on.thermalSource, "sudo-powermetrics");
|
|
486
|
-
// Root buys a pressure LEVEL and nothing more. If this ever starts asserting a
|
|
487
|
-
// tempC from powermetrics on arm64, the fixture is lying.
|
|
488
|
-
assert.equal(on.tempC, undefined);
|
|
489
|
-
assert.match(on.detail, /pressure/i);
|
|
490
|
-
});
|
|
491
|
-
|
|
492
|
-
test("collectThermal will NOT dress 'no thermal events on record' up as a live reading", async () => {
|
|
493
|
-
// `pmset -g therm` on a healthy Mac reports history, not temperature. Calling
|
|
494
|
-
// that "nominal" would be inventing a measurement nobody took.
|
|
495
|
-
const spy = (cmd, args, optsOrCb, maybeCb) => {
|
|
496
|
-
const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
|
|
497
|
-
const out = cmd === PMSET ? "Note: No thermal warning level has been recorded\n" : "";
|
|
498
|
-
queueMicrotask(() => cb(null, out, ""));
|
|
499
|
-
return { on() {} };
|
|
500
|
-
};
|
|
501
|
-
const th = await collectThermal({ execFile: spy, platform: "darwin", arch: "arm64" });
|
|
502
|
-
assert.equal(th.thermalState, undefined);
|
|
503
|
-
assert.equal(th.tempC, undefined);
|
|
504
|
-
assert.match(th.detail, /xcode-select --install/);
|
|
505
|
-
|
|
506
|
-
// But a REAL throttle event — a speed limit below 100 — is a live signal.
|
|
507
|
-
const throttling = (cmd, args, optsOrCb, maybeCb) => {
|
|
508
|
-
const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
|
|
509
|
-
const out = cmd === PMSET ? "CPU_Speed_Limit \t= 60\n" : "";
|
|
510
|
-
queueMicrotask(() => cb(null, out, ""));
|
|
511
|
-
return { on() {} };
|
|
512
|
-
};
|
|
513
|
-
const hot = await collectThermal({ execFile: throttling, platform: "darwin", arch: "arm64" });
|
|
514
|
-
assert.equal(hot.thermalState, "serious");
|
|
515
|
-
assert.equal(hot.thermalSource, "pmset");
|
|
516
|
-
});
|
|
517
|
-
|
|
518
|
-
test("parseThermalProbe rejects the sensor that lies (-9201.14 °C)", () => {
|
|
519
|
-
// `PMU tdev2` returns garbage on real hardware. The probe filters it, and the
|
|
520
|
-
// collector band-checks AGAIN — trusting a subprocess's arithmetic is how an
|
|
521
|
-
// absurd number reaches a dashboard.
|
|
522
|
-
assert.deepEqual(parseThermalProbe("thermalState=nominal\ntempC=-9201.14\n"), {
|
|
523
|
-
thermalState: "nominal",
|
|
524
|
-
});
|
|
525
|
-
assert.equal(parseThermalProbe("tempC=900\n").tempC, undefined);
|
|
526
|
-
assert.equal(parseThermalProbe("tempC=61.2\n").tempC, 61.2);
|
|
527
|
-
// Unknown keys are ignored so the probe can grow a field without breaking us.
|
|
528
|
-
assert.equal(parseThermalProbe("tempC=61.2\nfuture=x\n").tempC, 61.2);
|
|
529
|
-
assert.deepEqual(parseThermalProbe(""), {});
|
|
530
|
-
assert.deepEqual(parseThermalProbe(null), {});
|
|
531
|
-
// Only the four real levels are accepted.
|
|
532
|
-
assert.equal(parseThermalProbe("thermalState=toasty\n").thermalState, undefined);
|
|
533
|
-
});
|
|
534
|
-
|
|
535
|
-
test("parseThermalPressure reads the only thermal fact powermetrics has on arm64", () => {
|
|
536
|
-
assert.equal(parseThermalPressure(PM_THERMAL), "nominal");
|
|
537
|
-
assert.equal(parseThermalPressure("Current pressure level: Critical"), "critical");
|
|
538
|
-
assert.equal(parseThermalPressure("no such block"), undefined);
|
|
539
|
-
assert.equal(parseThermalPressure(""), undefined);
|
|
540
|
-
});
|
|
541
|
-
|
|
542
|
-
test("parsePowermetrics computes CPU% from the PER-CLUSTER arm64 shape", () => {
|
|
543
|
-
// The old regex wanted a bare `CPU idle residency:` line. Apple Silicon never
|
|
544
|
-
// prints one — only per-core and per-cluster lines — so on every seat in the
|
|
545
|
-
// fleet this silently fell through and then blamed the operator's sudoers.
|
|
546
|
-
const arm = [
|
|
547
|
-
"**** Processor usage ****",
|
|
548
|
-
"E-Cluster idle residency: 78.34%",
|
|
549
|
-
"CPU 0 idle residency: 90.00%",
|
|
550
|
-
"CPU 1 idle residency: 80.00%",
|
|
551
|
-
"CPU 2 idle residency: 70.00%",
|
|
552
|
-
"CPU 3 idle residency: 60.00%",
|
|
553
|
-
].join("\n");
|
|
554
|
-
const parsed = parsePowermetrics(arm);
|
|
555
|
-
assert.equal(parsed.cpuPct, 25); // 100 - mean(90,80,70,60)
|
|
556
|
-
// The Intel/total shape still wins outright where it exists.
|
|
557
|
-
assert.equal(parsePowermetrics("CPU Average idle residency: 78.0 %").cpuPct, 22);
|
|
558
|
-
});
|
|
559
|
-
|
|
560
|
-
test("collectMachine ships tempC + thermalState + the note TOGETHER", async () => {
|
|
561
|
-
// The old collector made the reading and the explanation MUTUALLY EXCLUSIVE
|
|
562
|
-
// (`else if`), which left hq with nothing to put in the value slot — so it
|
|
563
|
-
// printed a 118-character sudoers sentence in a column of "62°C" and "41%".
|
|
564
|
-
const spy = (cmd, args, optsOrCb, maybeCb) => {
|
|
565
|
-
const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb;
|
|
566
|
-
queueMicrotask(() => cb(null, cmd === SWIFT ? PROBE_OK : "", ""));
|
|
567
|
-
return { on() {} };
|
|
568
|
-
};
|
|
569
|
-
const m = await collectMachine({
|
|
570
|
-
os: fakeOs({}),
|
|
571
|
-
execFile: spy,
|
|
572
|
-
platform: "darwin",
|
|
573
|
-
arch: "arm64",
|
|
574
|
-
sudoPowermetrics: false,
|
|
575
|
-
disk: false,
|
|
576
|
-
inventory: false,
|
|
577
|
-
spend: false,
|
|
578
|
-
});
|
|
579
|
-
assert.equal(m.tempC, 60.8); // round1
|
|
580
|
-
assert.equal(m.tempSource, "iohid");
|
|
581
|
-
assert.equal(m.thermalState, "nominal");
|
|
582
|
-
assert.equal(m.thermalSource, "processinfo");
|
|
583
|
-
assert.equal(typeof m.tempDetail, "string");
|
|
584
|
-
});
|
|
585
|
-
|
|
586
|
-
// ---------------------------------------------------------------------------
|
|
587
|
-
// spend24h
|
|
588
|
-
// ---------------------------------------------------------------------------
|
|
589
|
-
|
|
590
|
-
test("collectSpend24h sums BILLABLE rows in a true rolling 24h window", () => {
|
|
591
|
-
const root = mkdtempSync(join(tmpdir(), "spend-"));
|
|
592
|
-
try {
|
|
593
|
-
const dir = join(root, "state", "cost-tracking");
|
|
594
|
-
mkdirSync(dir, { recursive: true });
|
|
595
|
-
const now = Date.parse("2026-08-13T06:00:00Z");
|
|
596
|
-
const row = (ts, cost) => JSON.stringify({
|
|
597
|
-
ts, model: "claude-opus-4-5", input_tokens: 10, output_tokens: 5, total_cost_usd: cost,
|
|
598
|
-
});
|
|
599
|
-
// Yesterday's file: one row inside the window, one outside it.
|
|
600
|
-
writeFileSync(join(dir, "2026-08-12.jsonl"), [
|
|
601
|
-
row("2026-08-12T05:00:00Z", 1.5), // 25h ago — OUTSIDE
|
|
602
|
-
row("2026-08-12T07:00:00Z", 2.25), // 23h ago — inside
|
|
603
|
-
].join("\n") + "\n");
|
|
604
|
-
writeFileSync(join(dir, "2026-08-13.jsonl"), [
|
|
605
|
-
row("2026-08-13T01:00:00Z", 0.75),
|
|
606
|
-
// A non-LLM attribution row: $0 is a FACT (no model ran), not an absence.
|
|
607
|
-
JSON.stringify({ ts: "2026-08-13T02:00:00Z", model: null, task_class: "messaging.send", total_cost_usd: null, estimated_usd: 0 }),
|
|
608
|
-
].join("\n") + "\n");
|
|
609
|
-
|
|
610
|
-
assert.equal(collectSpend24h({ agentRoot: root, now }), 3);
|
|
611
|
-
} finally {
|
|
612
|
-
rmSync(root, { recursive: true, force: true });
|
|
613
|
-
}
|
|
614
|
-
});
|
|
615
|
-
|
|
616
|
-
test("collectSpend24h: no ledger is UNDEFINED, not a confident zero", () => {
|
|
617
|
-
assert.equal(collectSpend24h({ agentRoot: "/nonexistent-root-xyz" }), undefined);
|
|
618
|
-
assert.equal(collectSpend24h({}), undefined);
|
|
619
|
-
});
|
|
620
|
-
|
|
621
|
-
test("collectSpend24h does not count an UNMEASURED row as free", () => {
|
|
622
|
-
const root = mkdtempSync(join(tmpdir(), "spend2-"));
|
|
623
|
-
try {
|
|
624
|
-
const dir = join(root, "state", "cost-tracking");
|
|
625
|
-
mkdirSync(dir, { recursive: true });
|
|
626
|
-
const now = Date.parse("2026-08-13T06:00:00Z");
|
|
627
|
-
writeFileSync(join(dir, "2026-08-13.jsonl"), [
|
|
628
|
-
// measurement:"unknown" → billableUsd().usd === null. Skipped, not zeroed.
|
|
629
|
-
JSON.stringify({ ts: "2026-08-13T01:00:00Z", model: "claude-opus-4-5", measurement: "unknown", input_tokens: null, output_tokens: null, total_cost_usd: null }),
|
|
630
|
-
JSON.stringify({ ts: "2026-08-13T02:00:00Z", model: "claude-opus-4-5", input_tokens: 1, output_tokens: 1, total_cost_usd: 4.5 }),
|
|
631
|
-
"{ not json",
|
|
632
|
-
].join("\n") + "\n");
|
|
633
|
-
assert.equal(collectSpend24h({ agentRoot: root, now }), 4.5);
|
|
634
|
-
} finally {
|
|
635
|
-
rmSync(root, { recursive: true, force: true });
|
|
636
|
-
}
|
|
637
|
-
});
|
|
638
|
-
|
|
639
|
-
test("collectStatus ships the FULL machine vocabulary the Fleet row reads", async () => {
|
|
640
|
-
const root = mkdtempSync(join(tmpdir(), "vocab-"));
|
|
641
|
-
try {
|
|
642
|
-
const osImpl = fakeOs({ totalmem: 24 * 1024 ** 3, freemem: 1e9, loadavg: [1.26, 1.2, 1.08], cpus: 12 });
|
|
643
|
-
osImpl.uptime = () => 5756923;
|
|
644
|
-
const status = await collectStatus({
|
|
645
|
-
agentRoot: root,
|
|
646
|
-
now: Date.parse("2026-08-13T06:00:00Z"),
|
|
647
|
-
os: osImpl,
|
|
648
|
-
arch: "arm64",
|
|
649
|
-
sudoPowermetrics: false,
|
|
650
|
-
execFile: fakeExecFile({
|
|
651
|
-
"/usr/sbin/system_profiler": SP_HARDWARE,
|
|
652
|
-
"/bin/df": "Filesystem 1024-blocks Used Available Capacity Mounted\n/dev/disk1 100 47 53 47% /\n",
|
|
653
|
-
}),
|
|
654
|
-
subAgentsRunning: 0,
|
|
655
|
-
});
|
|
656
|
-
const m = status.machine;
|
|
657
|
-
// The five keys hq reads that this collector used to emit NONE of.
|
|
658
|
-
assert.equal(m.device, "Mac mini M4 Pro");
|
|
659
|
-
assert.equal(m.ram, "24 GB");
|
|
660
|
-
assert.equal(m.uptimeDays, 66.6);
|
|
661
|
-
assert.equal(typeof m.tempDetail, "string"); // tempC needs root; the REASON ships
|
|
662
|
-
// The four it emitted that hq used to read NONE of.
|
|
663
|
-
assert.deepEqual(m.loadavg, [1.26, 1.2, 1.08]);
|
|
664
|
-
assert.ok(m.memUsedPct > 90);
|
|
665
|
-
assert.equal(typeof m.cpuPct, "number");
|
|
666
|
-
assert.equal(m.diskUsedPct, 47);
|
|
667
|
-
assert.equal(m.cpuSource, "loadavg");
|
|
668
|
-
} finally {
|
|
669
|
-
rmSync(root, { recursive: true, force: true });
|
|
670
|
-
}
|
|
671
|
-
});
|
|
672
|
-
|
|
673
|
-
// ---------------------------------------------------------------------------
|
|
674
|
-
// memUsedPct — pressure, not occupancy
|
|
675
|
-
//
|
|
676
|
-
// This was `1 - os.freemem()/os.totalmem()`, which is not a pressure measure on
|
|
677
|
-
// macOS: os.freemem() counts only entirely-free pages, and macOS keeps nearly
|
|
678
|
-
// all RAM occupied with file cache, purgeable and compressed pages that are
|
|
679
|
-
// reclaimable on demand. Measured on a healthy idle Mac mini M4 Pro at one
|
|
680
|
-
// instant: os.freemem() → 99.2% "used", memory_pressure → 68% free. The seat
|
|
681
|
-
// showed a standing CRITICAL while the watchdog, reading memory_pressure,
|
|
682
|
-
// correctly did nothing.
|
|
683
|
-
// ---------------------------------------------------------------------------
|
|
684
|
-
|
|
685
|
-
test("memUsedPct comes from memory_pressure, not from free/total", async () => {
|
|
686
|
-
const seen = [];
|
|
687
|
-
const execFile = (cmd, args, opts, cb) => {
|
|
688
|
-
seen.push(cmd);
|
|
689
|
-
if (cmd === "/usr/bin/memory_pressure") {
|
|
690
|
-
cb(null, "System-wide memory free percentage: 68%\n");
|
|
691
|
-
} else {
|
|
692
|
-
cb(new Error("not stubbed"));
|
|
693
|
-
}
|
|
694
|
-
return { on() {} };
|
|
695
|
-
};
|
|
696
|
-
// An os whose free/total ratio would say 99% used — the old, wrong answer.
|
|
697
|
-
const osImpl = {
|
|
698
|
-
loadavg: () => [1, 1, 1],
|
|
699
|
-
totalmem: () => 24 * 1024 ** 3,
|
|
700
|
-
freemem: () => 0.2 * 1024 ** 3,
|
|
701
|
-
cpus: () => [],
|
|
702
|
-
uptime: () => 3600,
|
|
703
|
-
};
|
|
704
|
-
|
|
705
|
-
const m = await collectMachine({ execFile, os: osImpl, platform: "darwin" });
|
|
706
|
-
|
|
707
|
-
assert.equal(m.memUsedPct, 32, "100 - 68 free, i.e. what the watchdog thresholds on");
|
|
708
|
-
assert.notEqual(m.memUsedPct, 99, "the free/total ratio must not be the answer on darwin");
|
|
709
|
-
assert.ok(seen.includes("/usr/bin/memory_pressure"), "memory_pressure is consulted");
|
|
710
|
-
});
|
|
711
|
-
|
|
712
|
-
test("memUsedPct falls back to free/total when memory_pressure yields nothing", async () => {
|
|
713
|
-
// Linux, or a macOS where the binary is missing/erroring. There the ratio
|
|
714
|
-
// does mean what it says, so a fallback is right — silence is not.
|
|
715
|
-
const execFile = (cmd, args, opts, cb) => { cb(new Error("ENOENT")); return { on() {} }; };
|
|
716
|
-
const osImpl = {
|
|
717
|
-
loadavg: () => [0, 0, 0],
|
|
718
|
-
totalmem: () => 1000,
|
|
719
|
-
freemem: () => 250,
|
|
720
|
-
cpus: () => [],
|
|
721
|
-
uptime: () => 1,
|
|
722
|
-
};
|
|
723
|
-
|
|
724
|
-
const m = await collectMachine({ execFile, os: osImpl, platform: "darwin" });
|
|
725
|
-
assert.equal(m.memUsedPct, 75, "1 - 250/1000");
|
|
726
|
-
});
|
|
727
|
-
|
|
728
|
-
test("memUsedPct ignores an out-of-range memory_pressure reading", async () => {
|
|
729
|
-
const execFile = (cmd, args, opts, cb) => {
|
|
730
|
-
cb(null, "System-wide memory free percentage: 8000%\n");
|
|
731
|
-
return { on() {} };
|
|
732
|
-
};
|
|
733
|
-
const osImpl = {
|
|
734
|
-
loadavg: () => [0, 0, 0],
|
|
735
|
-
totalmem: () => 1000,
|
|
736
|
-
freemem: () => 400,
|
|
737
|
-
cpus: () => [],
|
|
738
|
-
uptime: () => 1,
|
|
739
|
-
};
|
|
740
|
-
|
|
741
|
-
const m = await collectMachine({ execFile, os: osImpl, platform: "darwin" });
|
|
742
|
-
assert.equal(m.memUsedPct, 60, "a nonsense percentage falls back rather than propagating");
|
|
743
|
-
});
|
|
744
|
-
|
|
745
|
-
// ---------------------------------------------------------------------------
|
|
746
|
-
// Fleet identity + front door (§3.8): hostname / tailnetIp / sdkVersion ride
|
|
747
|
-
// the inventory; machine.frontDoor + machine.sessionLive come off the main
|
|
748
|
-
// session's heartbeat file. This is how a fleet-wide upgrade is verified.
|
|
749
|
-
//
|
|
750
|
-
// WHY `machine` and not `session`: hq's presence.beat validator is
|
|
751
|
-
// `session: z.object({id,taskClass,model}).strict()` — an unknown key there
|
|
752
|
-
// rejects the WHOLE beat (BAD_REQUEST before any write) and the seat drops out
|
|
753
|
-
// of the mesh. `machine` is `z.record(z.unknown())` by design, so a new field
|
|
754
|
-
// lands the moment the daemon emits it. `session` stays `null` when no work
|
|
755
|
-
// session runs — consumers read null as "no work session", and that contract
|
|
756
|
-
// is pinned here.
|
|
757
|
-
// ---------------------------------------------------------------------------
|
|
758
|
-
|
|
759
|
-
test("collectInventory emits hostname, tailnetIp (tailscale ip -4) and sdkVersion", async () => {
|
|
760
|
-
const osImpl = fakeOs({ cpus: 12, totalmem: 24 * 1024 ** 3 });
|
|
761
|
-
osImpl.hostname = () => "ethans-mac-mini.local";
|
|
762
|
-
const inv = await collectInventory({
|
|
763
|
-
os: osImpl,
|
|
764
|
-
execFile: fakeExecFile({ "/usr/sbin/system_profiler": SP_HARDWARE, tailscale: "100.95.116.68\n" }),
|
|
765
|
-
sdkVersion: "2.12.0",
|
|
766
|
-
});
|
|
767
|
-
assert.equal(inv.hostname, "ethans-mac-mini.local");
|
|
768
|
-
assert.equal(inv.tailnetIp, "100.95.116.68");
|
|
769
|
-
assert.equal(inv.sdkVersion, "2.12.0");
|
|
770
|
-
assert.equal(inv.device, "Mac mini M4 Pro");
|
|
771
|
-
});
|
|
772
|
-
|
|
773
|
-
test("collectInventory falls back to the Tailscale.app binary and is fail-open without any", async () => {
|
|
774
|
-
const osImpl = fakeOs({});
|
|
775
|
-
osImpl.hostname = () => "h";
|
|
776
|
-
const appBin = "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
|
|
777
|
-
const viaApp = await collectInventory({
|
|
778
|
-
os: osImpl, sdkVersion: "x",
|
|
779
|
-
execFile: fakeExecFile({ [appBin]: "100.68.98.127\nfd7a::1\n" }),
|
|
780
|
-
inventoryCache: false,
|
|
781
|
-
});
|
|
782
|
-
assert.equal(viaApp.tailnetIp, "100.68.98.127");
|
|
783
|
-
const none = await collectInventory({ os: osImpl, sdkVersion: "x", execFile: fakeExecFile({}), inventoryCache: false });
|
|
784
|
-
assert.equal(none.tailnetIp, undefined, "no tailscale → the field drops out, nothing throws");
|
|
785
|
-
assert.equal(none.hostname, "h");
|
|
786
|
-
});
|
|
787
|
-
|
|
788
|
-
test("sdkVersion is read from the installed @cohortapp/agent-sdk package.json, not from the cache", async () => {
|
|
789
|
-
const root = mkdtempSync(join(tmpdir(), "inv-sdk-"));
|
|
790
|
-
try {
|
|
791
|
-
const osImpl = fakeOs({});
|
|
792
|
-
osImpl.hostname = () => "h";
|
|
793
|
-
const first = await collectInventory({ agentRoot: root, os: osImpl, execFile: fakeExecFile({}), sdkVersion: "2.11.17" });
|
|
794
|
-
assert.equal(first.sdkVersion, "2.11.17");
|
|
795
|
-
// Same chip/RAM key → the device inventory is served from the cache, but the
|
|
796
|
-
// version (which an autoupdate changes without touching the hardware) and
|
|
797
|
-
// the tailnet IP are re-read every time.
|
|
798
|
-
const second = await collectInventory({ agentRoot: root, os: osImpl, execFile: fakeExecFile({ tailscale: "100.1.1.1\n" }), sdkVersion: "2.12.0" });
|
|
799
|
-
assert.equal(second.sdkVersion, "2.12.0");
|
|
800
|
-
assert.equal(second.tailnetIp, "100.1.1.1");
|
|
801
|
-
assert.equal(second.hostname, "h");
|
|
802
|
-
// The real resolver: this checkout IS the SDK, so it reports its own version.
|
|
803
|
-
const real = _internals.readSdkVersion();
|
|
804
|
-
assert.match(real, /^\d+\.\d+\.\d+/);
|
|
805
|
-
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
806
|
-
});
|
|
807
|
-
|
|
808
|
-
test("sessionLiveness: a heartbeat younger than 90 s is live; older, missing or malformed is not", () => {
|
|
809
|
-
const { sessionLiveness } = _internals;
|
|
810
|
-
const now = Date.parse("2026-09-08T12:00:00Z");
|
|
811
|
-
assert.deepEqual(sessionLiveness({ ts: "2026-09-08T11:59:30Z", pid: 1, sessionId: "s" }, now), { frontDoor: "session", sessionLive: true });
|
|
812
|
-
assert.deepEqual(sessionLiveness({ ts: "2026-09-08T11:58:29Z" }, now), { frontDoor: "session", sessionLive: false });
|
|
813
|
-
assert.deepEqual(sessionLiveness({ ts: now - 89_000 }, now), { frontDoor: "session", sessionLive: true });
|
|
814
|
-
assert.deepEqual(sessionLiveness(null, now), { frontDoor: "daemon", sessionLive: false });
|
|
815
|
-
assert.deepEqual(sessionLiveness({ ts: "not a date" }, now), { frontDoor: "session", sessionLive: false });
|
|
816
|
-
assert.deepEqual(sessionLiveness({ ts: "2026-09-08T11:59:30Z" }, now, { staleMs: 10_000 }), { frontDoor: "session", sessionLive: false });
|
|
817
|
-
});
|
|
818
|
-
|
|
819
|
-
test("collectStatus: machine carries frontDoor + sessionLive from state/session/heartbeat.json; session stays null when idle", async () => {
|
|
820
|
-
const root = mkdtempSync(join(tmpdir(), "front-door-"));
|
|
821
|
-
try {
|
|
822
|
-
const now = Date.parse("2026-09-08T12:00:00Z");
|
|
823
|
-
const osImpl = fakeOs({});
|
|
824
|
-
osImpl.hostname = () => "seat.local";
|
|
825
|
-
const base = { agentRoot: root, now, os: osImpl, execFile: fakeExecFile({}), subAgentsRunning: 0, powermetrics: false, disk: false, spend: false };
|
|
826
|
-
// No heartbeat file: the daemon is the front door. No work session → null.
|
|
827
|
-
const noHb = await collectStatus(base);
|
|
828
|
-
assert.equal(noHb.machine.frontDoor, "daemon");
|
|
829
|
-
assert.equal(noHb.machine.sessionLive, false);
|
|
830
|
-
assert.equal(noHb.session, null, "an idle seat beats session: null (hq's strict session schema; activity.mjs reads null as 'no work session')");
|
|
831
|
-
// Fresh heartbeat → live main session.
|
|
832
|
-
mkdirSync(join(root, "state", "session"), { recursive: true });
|
|
833
|
-
writeFileSync(join(root, "state", "session", "heartbeat.json"), JSON.stringify({ pid: 4242, sessionId: "abc", name: "ethan-main", ts: "2026-09-08T11:59:50Z" }));
|
|
834
|
-
const live = await collectStatus(base);
|
|
835
|
-
assert.equal(live.machine.frontDoor, "session");
|
|
836
|
-
assert.equal(live.machine.sessionLive, true);
|
|
837
|
-
assert.equal(live.session, null);
|
|
838
|
-
// Stale (> 90 s) → not live, but the front door is still the session job.
|
|
839
|
-
writeFileSync(join(root, "state", "session", "heartbeat.json"), JSON.stringify({ pid: 4242, ts: "2026-09-08T11:50:00Z" }));
|
|
840
|
-
const stale = await collectStatus(base);
|
|
841
|
-
assert.equal(stale.machine.frontDoor, "session");
|
|
842
|
-
assert.equal(stale.machine.sessionLive, false);
|
|
843
|
-
// A running work session keeps EXACTLY the keys hq's strict validator knows.
|
|
844
|
-
const busy = await collectStatus({ ...base, sessions: [{ sessionId: "s1", kind: "interactive", intent: "session: inbox", model: "sonnet", lastBeat: "2026-09-08T11:59:55Z" }] });
|
|
845
|
-
assert.equal(busy.session.id, "s1");
|
|
846
|
-
assert.equal(busy.session.taskClass, "inbox");
|
|
847
|
-
const allowed = new Set(["id", "taskClass", "model"]);
|
|
848
|
-
for (const k of Object.keys(busy.session)) assert.ok(allowed.has(k), `session.${k} would be rejected by hq's strict presence.beat schema`);
|
|
849
|
-
assert.equal(busy.machine.frontDoor, "session");
|
|
850
|
-
// Inventory identity rides machine.
|
|
851
|
-
assert.equal(typeof live.machine.hostname, "string");
|
|
852
|
-
assert.match(live.machine.sdkVersion, /^\d+\.\d+\.\d+/);
|
|
853
|
-
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
854
|
-
});
|
|
855
|
-
|
|
856
|
-
test("detectClaudeAuth: in subscription mode the shared probe decides; logs still win for relogin", () => {
|
|
857
|
-
const root = mkdtempSync(join(tmpdir(), "auth-probe-"));
|
|
858
|
-
try {
|
|
859
|
-
writeFileSync(join(root, ".env"), "CLAUDE_CODE_OAUTH_TOKEN=t\nMAESTRO_PREFER_SUBSCRIPTION_AUTH=1\n");
|
|
860
|
-
const calls = [];
|
|
861
|
-
// An UNVERIFIED "ok" (the CLI merely reports a login — it says loggedIn for
|
|
862
|
-
// any token value) must not flip the beat from unknown to ok: that is the
|
|
863
|
-
// one state claudeAuth exists to catch. Only a verified ok is an ok.
|
|
864
|
-
const probeClaude = (agentRoot) => { calls.push(agentRoot); return { ok: true, mode: "subscription", auth: "ok", verified: false, name: "x" }; };
|
|
865
|
-
assert.equal(detectClaudeAuth({ agentRoot: root, now: NOW, probeClaude, env: {} }), "unknown", "reported-not-verified ok + no logs → unknown, as before the probe existed");
|
|
866
|
-
assert.deepEqual(calls, [root]);
|
|
867
|
-
assert.equal(detectClaudeAuth({ agentRoot: root, now: NOW, env: {}, probeClaude: () => ({ ok: true, auth: "ok", verified: true }) }), "ok");
|
|
868
|
-
// The probe says relogin → relogin, even with no logs at all.
|
|
869
|
-
assert.equal(detectClaudeAuth({ agentRoot: root, now: NOW, env: {}, probeClaude: () => ({ ok: false, auth: "relogin_required" }) }), "relogin_required");
|
|
870
|
-
// Probe unknown → fall back to what the logs say (nothing here → unknown).
|
|
871
|
-
assert.equal(detectClaudeAuth({ agentRoot: root, now: NOW, env: {}, probeClaude: () => ({ ok: true, auth: "unknown" }) }), "unknown");
|
|
872
|
-
// Clean recent logs + an unverified ok → ok (the logs are the evidence, as before).
|
|
873
|
-
mkdirSync(join(root, "logs", "sessions"), { recursive: true });
|
|
874
|
-
const p = join(root, "logs", "sessions", "s.log");
|
|
875
|
-
writeFileSync(p, "spawn ok, 3 turns, done");
|
|
876
|
-
const t = new Date(NOW - 60_000);
|
|
877
|
-
utimesSync(p, t, t);
|
|
878
|
-
assert.equal(detectClaudeAuth({ agentRoot: root, now: NOW, probeClaude, env: {} }), "ok");
|
|
879
|
-
// A recent relogin fingerprint in the logs wins over a probe that says ok.
|
|
880
|
-
writeFileSync(p, "… OAuth token has expired …");
|
|
881
|
-
utimesSync(p, t, t);
|
|
882
|
-
assert.equal(detectClaudeAuth({ agentRoot: root, now: NOW, probeClaude, env: {} }), "relogin_required");
|
|
883
|
-
// Not in subscription mode → the probe is never forked from the beat.
|
|
884
|
-
writeFileSync(join(root, ".env"), "ANTHROPIC_API_KEY=sk\n");
|
|
885
|
-
const before = calls.length;
|
|
886
|
-
detectClaudeAuth({ agentRoot: root, now: NOW, probeClaude, env: {} });
|
|
887
|
-
assert.equal(calls.length, before);
|
|
888
|
-
// A token only in the daemon's ambient env (launchd) is subscription mode too
|
|
889
|
-
// (clean logs first — a relogin fingerprint decides before any probe runs).
|
|
890
|
-
writeFileSync(p, "spawn ok"); utimesSync(p, t, t);
|
|
891
|
-
writeFileSync(join(root, ".env"), "");
|
|
892
|
-
detectClaudeAuth({ agentRoot: root, now: NOW, probeClaude, env: { CLAUDE_CODE_OAUTH_TOKEN: "t" } });
|
|
893
|
-
assert.equal(calls.length, before + 1);
|
|
894
|
-
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
895
|
-
});
|
|
896
|
-
|
|
897
|
-
// ---------------------------------------------------------------------------
|
|
898
|
-
// Last upgrade outcome (WP-M6): machine.upgrade = {at, from, to, ok} from
|
|
899
|
-
// state/autoupdate/last.json — fail-open, absent until a first attempt.
|
|
900
|
-
// ---------------------------------------------------------------------------
|
|
901
|
-
|
|
902
|
-
test("upgradeSummary: the four fleet-view fields from autoupdate's last.json; malformed → null", () => {
|
|
903
|
-
const { upgradeSummary } = _internals;
|
|
904
|
-
assert.deepEqual(
|
|
905
|
-
upgradeSummary({ from: "2.11.17", to: "2.12.0", at: "2026-09-08T12:00:00Z", ok: true, healthy: true, reason: "" }),
|
|
906
|
-
{ at: "2026-09-08T12:00:00Z", from: "2.11.17", to: "2.12.0", ok: true },
|
|
907
|
-
);
|
|
908
|
-
assert.deepEqual(
|
|
909
|
-
upgradeSummary({ from: "2.11.17", to: "2.12.0", at: "2026-09-08T12:00:00Z", ok: false, healthy: true, reason: "unhealthy-rolled-back" }),
|
|
910
|
-
{ at: "2026-09-08T12:00:00Z", from: "2.11.17", to: "2.12.0", ok: false },
|
|
911
|
-
);
|
|
912
|
-
assert.equal(upgradeSummary(null), null);
|
|
913
|
-
assert.equal(upgradeSummary({ ok: true }), null, "no at/to → nothing to report");
|
|
914
|
-
assert.equal(upgradeSummary("2.12.0"), null);
|
|
915
|
-
});
|
|
916
|
-
|
|
917
|
-
test("collectStatus: machine.upgrade rides the beat when state/autoupdate/last.json exists, and is absent (not null) when it does not or is corrupt", async () => {
|
|
918
|
-
const root = mkdtempSync(join(tmpdir(), "tel-upgrade-"));
|
|
919
|
-
try {
|
|
920
|
-
const base = { agentRoot: root, os: fakeOs(), execFile: fakeExecFile({}), powermetrics: false, disk: false, spend: false, inventory: false, tailnet: false, sdkVersion: "2.12.0", withAlerts: false, now: Date.parse("2026-09-08T12:00:00Z") };
|
|
921
|
-
const none = await collectStatus(base);
|
|
922
|
-
assert.ok(!("upgrade" in none.machine), "absent until a first autoupdate attempt");
|
|
923
|
-
|
|
924
|
-
mkdirSync(join(root, "state", "autoupdate"), { recursive: true });
|
|
925
|
-
writeFileSync(join(root, "state", "autoupdate", "last.json"), JSON.stringify({ from: "2.11.17", to: "2.12.0", at: "2026-09-08T11:30:00Z", ok: true, healthy: true, reason: "" }));
|
|
926
|
-
const some = await collectStatus(base);
|
|
927
|
-
assert.deepEqual(some.machine.upgrade, { at: "2026-09-08T11:30:00Z", from: "2.11.17", to: "2.12.0", ok: true });
|
|
928
|
-
assert.equal(some.session, null, "nothing new lands on the strictly-validated session object");
|
|
929
|
-
|
|
930
|
-
writeFileSync(join(root, "state", "autoupdate", "last.json"), "{not json");
|
|
931
|
-
const corrupt = await collectStatus(base);
|
|
932
|
-
assert.ok(!("upgrade" in corrupt.machine));
|
|
933
|
-
|
|
934
|
-
const injected = await collectStatus({ ...base, autoupdateLast: { from: "1", to: "2", at: "2026-09-08T00:00:00Z", ok: false } });
|
|
935
|
-
assert.equal(injected.machine.upgrade.ok, false);
|
|
936
|
-
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
937
|
-
});
|
|
938
|
-
|
|
939
|
-
// ---------------------------------------------------------------------------
|
|
940
|
-
// machine.sessionNote (WP-M8) — WHY the front door is not live
|
|
941
|
-
// ---------------------------------------------------------------------------
|
|
942
|
-
|
|
943
|
-
const M8_NOW = Date.parse("2026-09-11T12:00:00Z");
|
|
944
|
-
/** The record scripts/session/supervisor.mjs writes via first-run#attentionRecord. */
|
|
945
|
-
const attention = (over = {}) => ({
|
|
946
|
-
reason: "no-heartbeat",
|
|
947
|
-
since: "2026-09-11T11:57:00.000Z",
|
|
948
|
-
runMs: 180_000,
|
|
949
|
-
attach: "tmux attach -t =maestro-maya",
|
|
950
|
-
hint: "The session has been up 180 s without a heartbeat — it is probably waiting on a first-run dialog or a permission prompt. Attach with `tmux attach -t =maestro-maya` and answer it.",
|
|
951
|
-
...over,
|
|
952
|
-
});
|
|
953
|
-
|
|
954
|
-
test("sessionNote: a live front door says nothing at all", () => {
|
|
955
|
-
const { sessionNote } = _internals;
|
|
956
|
-
assert.equal(sessionNote({ heartbeat: { ts: M8_NOW - 1_000 }, now: M8_NOW }), null);
|
|
957
|
-
assert.equal(sessionNote({ heartbeat: { ts: M8_NOW - 89_000 }, jobInstalled: false, attention: attention(), now: M8_NOW }), null,
|
|
958
|
-
"liveness wins over every other input — the note exists only to explain a door that is NOT open");
|
|
959
|
-
// A shorter staleness bound makes the same beat stale, and then it does speak.
|
|
960
|
-
assert.equal(sessionNote({ heartbeat: { ts: M8_NOW - 30_000 }, jobInstalled: true, now: M8_NOW, staleMs: 10_000 }).reason, "heartbeat-stale");
|
|
961
|
-
});
|
|
962
|
-
|
|
963
|
-
test("sessionNote: job-absent — no plist for this seat, and only ever from a hard false", () => {
|
|
964
|
-
const { sessionNote } = _internals;
|
|
965
|
-
const n = sessionNote({ jobInstalled: false, label: "ai.maestro.maya-session", now: M8_NOW });
|
|
966
|
-
assert.equal(n.reason, "job-absent");
|
|
967
|
-
assert.match(n.detail, /ai\.maestro\.maya-session/);
|
|
968
|
-
assert.equal(n.since, undefined, "there is no moment to date — the job was never there");
|
|
969
|
-
// It outranks a stale attention record left behind by a previous install.
|
|
970
|
-
assert.equal(sessionNote({ jobInstalled: false, attention: attention(), heartbeat: { ts: M8_NOW - 600_000 }, now: M8_NOW }).reason, "job-absent");
|
|
971
|
-
// UNKNOWN is not absent: a home we could not read must never accuse the seat.
|
|
972
|
-
assert.equal(sessionNote({ jobInstalled: null, now: M8_NOW }).reason, "never-beaten");
|
|
973
|
-
assert.equal(sessionNote({ now: M8_NOW }).reason, "never-beaten");
|
|
974
|
-
// No label still yields a usable line.
|
|
975
|
-
assert.match(sessionNote({ jobInstalled: false, now: M8_NOW }).detail, /main-session launchd job/);
|
|
976
|
-
// …and it does not call a deliberate daemon-front-door seat broken: this seat
|
|
977
|
-
// reports job-absent on EVERY beat forever, so the line states a configuration.
|
|
978
|
-
assert.match(n.detail, /the daemon --print lane is the front door/);
|
|
979
|
-
assert.match(n.detail, /by configuration, or the session job was never installed/);
|
|
980
|
-
});
|
|
981
|
-
|
|
982
|
-
test("sessionNote: awaiting-input passes the supervisor's own reason, since and hint through", () => {
|
|
983
|
-
const { sessionNote } = _internals;
|
|
984
|
-
const n = sessionNote({ jobInstalled: true, attention: attention(), now: M8_NOW });
|
|
985
|
-
assert.equal(n.reason, "awaiting-input", "the beat's vocabulary — the record's own reason rides in detail");
|
|
986
|
-
assert.equal(n.since, "2026-09-11T11:57:00.000Z", "attentionRecord.since, normalised to canonical ISO");
|
|
987
|
-
assert.match(n.detail, /^up, never beaten: /, "the record's own reason survives, rendered out of the watchdog's vocabulary");
|
|
988
|
-
assert.match(n.detail, /tmux attach -t =maestro-maya/, "the attach command is the actionable part");
|
|
989
|
-
assert.ok(n.detail.length <= 200);
|
|
990
|
-
// screen seats say screen.
|
|
991
|
-
assert.match(sessionNote({ attention: attention({ attach: "screen -r maestro-isla", hint: "… Attach with `screen -r maestro-isla` and answer it." }), now: M8_NOW }).detail, /screen -r maestro-isla/);
|
|
992
|
-
// It outranks a stale heartbeat: "waiting on a human" is the actionable fact.
|
|
993
|
-
assert.equal(sessionNote({ jobInstalled: true, attention: attention(), heartbeat: { ts: M8_NOW - 600_000 }, now: M8_NOW }).reason, "awaiting-input");
|
|
994
|
-
});
|
|
995
|
-
|
|
996
|
-
test("sessionNote: a heartbeat LATER than the attention record supersedes it", () => {
|
|
997
|
-
const { sessionNote } = _internals;
|
|
998
|
-
// supervisor.mjs clears attention.json only at the START of the next launch —
|
|
999
|
-
// never when the human answers the dialog — so the record outlives its truth.
|
|
1000
|
-
// Answered at 11:57, beat until 3 days later, then the session died.
|
|
1001
|
-
const beat = M8_NOW + 3 * 86_400_000;
|
|
1002
|
-
const n = sessionNote({ jobInstalled: true, attention: attention(), heartbeat: { ts: beat }, now: beat + 86_400_000 });
|
|
1003
|
-
assert.equal(n.reason, "heartbeat-stale", "a days-old attach command for a mux session that ended is worse than no note");
|
|
1004
|
-
assert.equal(n.since, new Date(beat).toISOString());
|
|
1005
|
-
// A record with no orderable `since` loses to a heartbeat that has one.
|
|
1006
|
-
assert.equal(sessionNote({ jobInstalled: true, attention: attention({ since: "not a date" }), heartbeat: { ts: M8_NOW - 600_000 }, now: M8_NOW }).reason, "heartbeat-stale");
|
|
1007
|
-
// But the record still wins while it is the LATER fact — the ordinary case:
|
|
1008
|
-
// the session came up, never beat, and the watchdog flagged it.
|
|
1009
|
-
assert.equal(sessionNote({ jobInstalled: true, attention: attention(), heartbeat: { ts: M8_NOW - 600_000 }, now: M8_NOW }).reason, "awaiting-input");
|
|
1010
|
-
// …and with no heartbeat at all there is nothing to supersede it.
|
|
1011
|
-
assert.equal(sessionNote({ jobInstalled: true, attention: attention(), heartbeat: { pid: 7 }, now: M8_NOW }).reason, "awaiting-input");
|
|
1012
|
-
});
|
|
1013
|
-
|
|
1014
|
-
test("sessionNote: `since` is re-formatted from a parsed instant, never passed through", () => {
|
|
1015
|
-
const { sessionNote } = _internals;
|
|
1016
|
-
// V8's legacy date parser accepts trailing prose, so a bare Date.parse check
|
|
1017
|
-
// let a home path and a token-shaped run ride the one field the detail
|
|
1018
|
-
// sanitiser does not cover. `since` is canonical ISO or it is absent.
|
|
1019
|
-
const evil = "Thu, 01 Jan 2026 00:00:00 GMT (token sk-ant-supersecretvalue1234567890 /Users/maya/x)";
|
|
1020
|
-
assert.ok(Number.isFinite(Date.parse(evil)), "the hostile string really does parse — that is the hazard");
|
|
1021
|
-
const n = sessionNote({ jobInstalled: true, attention: attention({ since: evil }), now: M8_NOW });
|
|
1022
|
-
assert.equal(n.since, "2026-01-01T00:00:00.000Z");
|
|
1023
|
-
assert.ok(!/Users|sk-ant/.test(n.since));
|
|
1024
|
-
// Non-ISO spellings are normalised rather than echoed.
|
|
1025
|
-
assert.equal(sessionNote({ jobInstalled: true, attention: attention({ since: "2026-09-11T11:57:00+00:00" }), now: M8_NOW }).since, "2026-09-11T11:57:00.000Z");
|
|
1026
|
-
});
|
|
1027
|
-
|
|
1028
|
-
test("sessionNote: the attention record's own vocabulary is rendered, not collided with", () => {
|
|
1029
|
-
const { sessionNote } = _internals;
|
|
1030
|
-
// first-run#heartbeatSilence's "no-heartbeat" means "up, never beaten" — the
|
|
1031
|
-
// opposite of what a beat reason of nearly that spelling would mean. Rendering
|
|
1032
|
-
// it keeps `{reason, detail}` from reading as a contradiction side by side.
|
|
1033
|
-
assert.match(sessionNote({ jobInstalled: true, attention: attention(), now: M8_NOW }).detail, /^up, never beaten: /);
|
|
1034
|
-
assert.match(sessionNote({ jobInstalled: true, attention: attention({ reason: "stale-heartbeat" }), now: M8_NOW }).detail, /^no beat since this launch: /);
|
|
1035
|
-
// A word we do not know passes through verbatim rather than being invented.
|
|
1036
|
-
assert.match(sessionNote({ jobInstalled: true, attention: attention({ reason: "wedged-mcp" }), now: M8_NOW }).detail, /^wedged-mcp: /);
|
|
1037
|
-
// And no beat reason collides with a watchdog reason any more.
|
|
1038
|
-
const beatReasons = new Set(["job-absent", "awaiting-input", "heartbeat-stale", "heartbeat-unreadable", "never-beaten"]);
|
|
1039
|
-
for (const r of ["no-heartbeat", "stale-heartbeat", "within-grace", "beating"]) assert.ok(!beatReasons.has(r), `${r} is the watchdog's word, not the beat's`);
|
|
1040
|
-
});
|
|
1041
|
-
|
|
1042
|
-
test("sessionNote: heartbeat-stale carries the last beat and its age", () => {
|
|
1043
|
-
const { sessionNote } = _internals;
|
|
1044
|
-
const n = sessionNote({ jobInstalled: true, heartbeat: { ts: M8_NOW - 600_000, pid: 4242 }, now: M8_NOW });
|
|
1045
|
-
assert.deepEqual(n, { reason: "heartbeat-stale", since: "2026-09-11T11:50:00.000Z", detail: "no beat for 600 s" });
|
|
1046
|
-
// ISO timestamps are the shape the feed actually writes.
|
|
1047
|
-
assert.equal(sessionNote({ jobInstalled: true, heartbeat: { ts: "2026-09-11T11:50:00Z" }, now: M8_NOW }).since, "2026-09-11T11:50:00.000Z");
|
|
1048
|
-
// A beat from the future is clock skew, not a negative age.
|
|
1049
|
-
assert.equal(sessionNote({ jobInstalled: true, heartbeat: { ts: M8_NOW + 600_000 }, now: M8_NOW }).detail, "last beat is in the future (clock skew)");
|
|
1050
|
-
});
|
|
1051
|
-
|
|
1052
|
-
test("sessionNote: every malformed input degrades to a coarser note or to null, and nothing throws", () => {
|
|
1053
|
-
const { sessionNote } = _internals;
|
|
1054
|
-
// No `now` at all → no note (a note with no clock behind it would be a guess).
|
|
1055
|
-
assert.equal(sessionNote({ heartbeat: { ts: 1 } }), null);
|
|
1056
|
-
assert.equal(sessionNote({ now: NaN, jobInstalled: false }), null);
|
|
1057
|
-
assert.equal(sessionNote(), null);
|
|
1058
|
-
assert.equal(sessionNote(null), null);
|
|
1059
|
-
assert.equal(sessionNote("nonsense"), null);
|
|
1060
|
-
// Absent files (the reader's null) → the installed-but-silent note.
|
|
1061
|
-
assert.deepEqual(sessionNote({ heartbeat: null, attention: null, jobInstalled: true, now: M8_NOW }),
|
|
1062
|
-
{ reason: "never-beaten", detail: "the session job is installed but has never written a heartbeat" });
|
|
1063
|
-
// …and when job presence was never established, the note says THAT rather
|
|
1064
|
-
// than affirming an installation nobody checked.
|
|
1065
|
-
assert.deepEqual(sessionNote({ jobInstalled: null, now: M8_NOW }),
|
|
1066
|
-
{ reason: "never-beaten", detail: "no heartbeat from the session job, and its presence on this seat could not be checked" });
|
|
1067
|
-
assert.equal(sessionNote({ now: M8_NOW }).detail, "no heartbeat from the session job, and its presence on this seat could not be checked");
|
|
1068
|
-
// A parsed-but-wrong SHAPE must not throw.
|
|
1069
|
-
assert.equal(sessionNote({ heartbeat: "{}", attention: "{}", now: M8_NOW }).reason, "never-beaten");
|
|
1070
|
-
assert.equal(sessionNote({ heartbeat: [], attention: [], jobInstalled: true, now: M8_NOW }).reason, "never-beaten", "arrays are not records");
|
|
1071
|
-
// A heartbeat file that is THERE but says nothing usable is its own fault —
|
|
1072
|
-
// the job may well have beaten, so it is never called "never beaten".
|
|
1073
|
-
assert.deepEqual(sessionNote({ heartbeat: { pid: 7 }, jobInstalled: true, now: M8_NOW }),
|
|
1074
|
-
{ reason: "heartbeat-unreadable", detail: "the heartbeat file is present but carries no readable timestamp" });
|
|
1075
|
-
assert.equal(sessionNote({ heartbeat: { ts: "not a date" }, jobInstalled: true, now: M8_NOW }).reason, "heartbeat-unreadable");
|
|
1076
|
-
assert.deepEqual(sessionNote({ heartbeatUnreadable: true, jobInstalled: true, now: M8_NOW }),
|
|
1077
|
-
{ reason: "heartbeat-unreadable", detail: "the heartbeat file is present but could not be parsed" });
|
|
1078
|
-
// An attention record missing every field is still the right REASON — it is
|
|
1079
|
-
// the file's existence that says "the supervisor flagged this seat".
|
|
1080
|
-
assert.deepEqual(sessionNote({ attention: {}, jobInstalled: true, now: M8_NOW }), { reason: "awaiting-input" });
|
|
1081
|
-
assert.deepEqual(sessionNote({ attention: { since: 12345, reason: 7, hint: null }, jobInstalled: true, now: M8_NOW }), { reason: "awaiting-input" });
|
|
1082
|
-
assert.equal(sessionNote({ attention: { since: "not a date", reason: "no-heartbeat" }, jobInstalled: true, now: M8_NOW }).since, undefined);
|
|
1083
|
-
});
|
|
1084
|
-
|
|
1085
|
-
test("sanitizeNoteDetail: no home paths, no ids, no control characters, never past the cap", () => {
|
|
1086
|
-
const { sanitizeNoteDetail } = _internals;
|
|
1087
|
-
assert.equal(sanitizeNoteDetail("attach with `tmux attach -t =maestro-maya`"), "attach with `tmux attach -t =maestro-maya`");
|
|
1088
|
-
// Home directories name a person and leak the seat's layout.
|
|
1089
|
-
assert.equal(sanitizeNoteDetail("blocked in /Users/maya/maya-ai/state"), "blocked in <path>");
|
|
1090
|
-
// A home directory with a space in it is ordinary on macOS, and its tail
|
|
1091
|
-
// carries the person's surname — the match may not stop at the whitespace.
|
|
1092
|
-
assert.equal(sanitizeNoteDetail("blocked in /Users/olivia chen/maya-ai/state/session"), "blocked in <path>");
|
|
1093
|
-
assert.equal(sanitizeNoteDetail("blocked in /Users/Olivia Chen/Library/Application Support/x"), "blocked in <path>");
|
|
1094
|
-
// …but ordinary prose after a path is not swallowed with it.
|
|
1095
|
-
assert.equal(sanitizeNoteDetail("blocked in /Users/maya and then retry"), "blocked in <path> and then retry");
|
|
1096
|
-
assert.equal(sanitizeNoteDetail("attach to /Users/maya/x and answer it"), "attach to <path> and answer it");
|
|
1097
|
-
assert.equal(sanitizeNoteDetail("blocked in ~/maya-ai and /home/maya/x and /private/var/folders/t/x"), "blocked in <path> and <path> and <path>");
|
|
1098
|
-
// Session ids and token-shaped runs.
|
|
1099
|
-
assert.equal(sanitizeNoteDetail("resume 3f2a1c94-7b0e-4a11-9c3d-0b7e2f8a6d51 failed"), "resume <redacted> failed");
|
|
1100
|
-
assert.equal(sanitizeNoteDetail("token sk-ant-0123456789abcdef rejected"), "token <redacted> rejected");
|
|
1101
|
-
// Newlines and control characters collapse; the note is one line.
|
|
1102
|
-
assert.equal(sanitizeNoteDetail("a\nb\tc\r\nd"), "a b c d");
|
|
1103
|
-
// The cap holds, with an ellipsis so a reader knows it was cut.
|
|
1104
|
-
const long = sanitizeNoteDetail("lorem ipsum ".repeat(50));
|
|
1105
|
-
assert.equal(long.length, 200);
|
|
1106
|
-
assert.ok(long.endsWith("…"));
|
|
1107
|
-
assert.ok(sanitizeNoteDetail("ab ".repeat(20), 10).length <= 10, "the cap is a ceiling, never exceeded (a word boundary can land under it)");
|
|
1108
|
-
// Nothing to say → "", and the caller then omits the field entirely.
|
|
1109
|
-
assert.equal(sanitizeNoteDetail(""), "");
|
|
1110
|
-
assert.equal(sanitizeNoteDetail(null), "");
|
|
1111
|
-
assert.equal(sanitizeNoteDetail(undefined), "");
|
|
1112
|
-
assert.equal(sanitizeNoteDetail(42), "");
|
|
1113
|
-
assert.equal(sanitizeNoteDetail(" \n "), "");
|
|
1114
|
-
// A hint long enough to overflow is truncated, not dropped.
|
|
1115
|
-
const hint = sanitizeNoteDetail(`no-heartbeat: The session has been up 1800 s without a heartbeat — it is probably waiting on a first-run dialog or a permission prompt. Attach with \`tmux attach -t =maestro-maya\` and answer it. ${"tail ".repeat(20)}`);
|
|
1116
|
-
assert.ok(hint.length <= 200 && hint.endsWith("…"), "an over-long hint is truncated, not dropped");
|
|
1117
|
-
});
|
|
1118
|
-
|
|
1119
|
-
test("sessionJobInstalled: a readdir of ~/Library/LaunchAgents, and 'unknown' whenever it cannot be read", () => {
|
|
1120
|
-
const { sessionJobInstalled, sessionJobLabel } = _internals;
|
|
1121
|
-
const label = "ai.maestro.maya-session";
|
|
1122
|
-
assert.equal(sessionJobInstalled({ launchAgents: ["ai.maestro.maya.plist", "ai.maestro.maya-session.plist"] }, label), true);
|
|
1123
|
-
assert.equal(sessionJobInstalled({ launchAgents: ["ai.maestro.maya-session"] }, label), true, "bare labels count too");
|
|
1124
|
-
assert.equal(sessionJobInstalled({ launchAgents: ["ai.maestro.maya.plist"] }, label), false);
|
|
1125
|
-
assert.equal(sessionJobInstalled({ launchAgents: [] }, label), false);
|
|
1126
|
-
// Unknowns: no label, an unreadable home, a home that is not there.
|
|
1127
|
-
assert.equal(sessionJobInstalled({ launchAgents: ["ai.maestro.maya-session.plist"] }, ""), null);
|
|
1128
|
-
assert.equal(sessionJobInstalled({ os: { homedir: () => "" } }, label), null);
|
|
1129
|
-
assert.equal(sessionJobInstalled({ os: { homedir: () => { throw new Error("nope"); } } }, label), null);
|
|
1130
|
-
assert.equal(sessionJobInstalled({ os: { homedir: () => join(tmpdir(), "no-such-home-xyz") } }, label), null);
|
|
1131
|
-
// An explicit override wins (the daemon may already know).
|
|
1132
|
-
assert.equal(sessionJobInstalled({ sessionJobInstalled: true, launchAgents: [] }, label), true);
|
|
1133
|
-
// A case difference is not an absence: agentFirstName leaves its directory
|
|
1134
|
-
// fallback as-is (`~/Maya-ai` -> `Maya`), and the volume is case-insensitive.
|
|
1135
|
-
assert.equal(sessionJobInstalled({ launchAgents: ["ai.maestro.Maya-session.plist"] }, "ai.maestro.maya-session"), true);
|
|
1136
|
-
assert.equal(sessionJobInstalled({ launchAgents: ["ai.maestro.maya-session.plist"] }, "ai.maestro.Maya-session"), true);
|
|
1137
|
-
// The label matches what generate-plists.sh and `maestro session` spell —
|
|
1138
|
-
// including its casing, which the generator does not lower-case either.
|
|
1139
|
-
assert.equal(sessionJobLabel("/x/maya-ai", "maya"), "ai.maestro.maya-session");
|
|
1140
|
-
assert.equal(sessionJobLabel("/x/Maya-ai", "Maya"), "ai.maestro.Maya-session");
|
|
1141
|
-
assert.equal(sessionJobLabel("", ""), "");
|
|
1142
|
-
});
|
|
1143
|
-
|
|
1144
|
-
test("collectStatus: machine.sessionNote appears ONLY when the front door is down, and rides machine (never session)", async () => {
|
|
1145
|
-
const root = mkdtempSync(join(tmpdir(), "session-note-"));
|
|
1146
|
-
try {
|
|
1147
|
-
const now = Date.parse("2026-09-11T12:00:00Z");
|
|
1148
|
-
const osImpl = fakeOs({});
|
|
1149
|
-
osImpl.hostname = () => "seat.local";
|
|
1150
|
-
const base = {
|
|
1151
|
-
agentRoot: root, now, os: osImpl, execFile: fakeExecFile({}), subAgentsRunning: 0,
|
|
1152
|
-
powermetrics: false, disk: false, spend: false, agentFirst: "maya", launchAgents: [],
|
|
1153
|
-
};
|
|
1154
|
-
const sess = join(root, "state", "session");
|
|
1155
|
-
mkdirSync(sess, { recursive: true });
|
|
1156
|
-
|
|
1157
|
-
// 1. No job on the seat — the case A010/A016 could not tell apart.
|
|
1158
|
-
const absent = await collectStatus(base);
|
|
1159
|
-
assert.equal(absent.machine.sessionLive, false);
|
|
1160
|
-
assert.equal(absent.machine.sessionNote.reason, "job-absent");
|
|
1161
|
-
assert.match(absent.machine.sessionNote.detail, /ai\.maestro\.maya-session/);
|
|
1162
|
-
assert.equal(absent.session, null, "the note NEVER touches session — hq validates that object strictly");
|
|
1163
|
-
assert.ok(!("sessionNote" in (absent.session || {})));
|
|
1164
|
-
|
|
1165
|
-
// 2. Job installed, session up, waiting on a first-run dialog.
|
|
1166
|
-
const installed = { ...base, launchAgents: ["ai.maestro.maya-session.plist"] };
|
|
1167
|
-
writeFileSync(join(sess, "attention.json"), JSON.stringify(attention()));
|
|
1168
|
-
const waiting = await collectStatus(installed);
|
|
1169
|
-
assert.equal(waiting.machine.sessionNote.reason, "awaiting-input");
|
|
1170
|
-
assert.equal(waiting.machine.sessionNote.since, "2026-09-11T11:57:00.000Z");
|
|
1171
|
-
assert.match(waiting.machine.sessionNote.detail, /tmux attach -t =maestro-maya/);
|
|
1172
|
-
|
|
1173
|
-
// 3. The supervisor cleared attention.json and the beat then went stale.
|
|
1174
|
-
rmSync(join(sess, "attention.json"));
|
|
1175
|
-
writeFileSync(join(sess, "heartbeat.json"), JSON.stringify({ pid: 4242, ts: "2026-09-11T11:50:00Z" }));
|
|
1176
|
-
const stale = await collectStatus(installed);
|
|
1177
|
-
assert.equal(stale.machine.frontDoor, "session");
|
|
1178
|
-
assert.deepEqual(stale.machine.sessionNote, { reason: "heartbeat-stale", since: "2026-09-11T11:50:00.000Z", detail: "no beat for 600 s" });
|
|
1179
|
-
|
|
1180
|
-
// 4. A live front door carries NO note — the key is absent, not null.
|
|
1181
|
-
writeFileSync(join(sess, "heartbeat.json"), JSON.stringify({ pid: 4242, ts: "2026-09-11T11:59:50Z" }));
|
|
1182
|
-
const live = await collectStatus(installed);
|
|
1183
|
-
assert.equal(live.machine.sessionLive, true);
|
|
1184
|
-
assert.ok(!("sessionNote" in live.machine), "a healthy seat must not pay for a field it has nothing to say in");
|
|
1185
|
-
|
|
1186
|
-
// 5. Corrupt state files never break the beat — and a heartbeat file that
|
|
1187
|
-
// is THERE and will not parse is reported as such, not as "never beaten".
|
|
1188
|
-
writeFileSync(join(sess, "heartbeat.json"), "{not json");
|
|
1189
|
-
writeFileSync(join(sess, "attention.json"), "{not json");
|
|
1190
|
-
const corrupt = await collectStatus(installed);
|
|
1191
|
-
assert.equal(corrupt.machine.sessionLive, false);
|
|
1192
|
-
assert.deepEqual(corrupt.machine.sessionNote,
|
|
1193
|
-
{ reason: "heartbeat-unreadable", detail: "the heartbeat file is present but could not be parsed" });
|
|
1194
|
-
assert.equal(typeof corrupt.ts, "string", "the snapshot is intact");
|
|
1195
|
-
|
|
1196
|
-
// 5b. No heartbeat file at all, job installed → never-beaten.
|
|
1197
|
-
rmSync(join(sess, "heartbeat.json"));
|
|
1198
|
-
rmSync(join(sess, "attention.json"));
|
|
1199
|
-
const silent = await collectStatus(installed);
|
|
1200
|
-
assert.deepEqual(silent.machine.sessionNote,
|
|
1201
|
-
{ reason: "never-beaten", detail: "the session job is installed but has never written a heartbeat" });
|
|
1202
|
-
|
|
1203
|
-
// 6. The whole note is a best-effort extra: a throwing os.homedir drops the
|
|
1204
|
-
// launchd read, never the beat — and an unknown job presence is reported
|
|
1205
|
-
// as neither an absence nor a presence.
|
|
1206
|
-
const hostile = await collectStatus({ ...base, launchAgents: undefined, os: { ...osImpl, homedir: () => { throw new Error("no home"); } } });
|
|
1207
|
-
assert.equal(hostile.machine.sessionNote.reason, "never-beaten", "unknown job presence is not an absence");
|
|
1208
|
-
assert.equal(hostile.machine.sessionNote.detail, "no heartbeat from the session job, and its presence on this seat could not be checked",
|
|
1209
|
-
"…and it is not reported as a presence either");
|
|
1210
|
-
assert.equal(hostile.machine.sessionLive, false);
|
|
1211
|
-
|
|
1212
|
-
// 7. The emitted shape is exactly {reason, since?, detail?} — nothing else,
|
|
1213
|
-
// and nothing unbounded.
|
|
1214
|
-
for (const s of [absent, waiting, stale, corrupt, silent, hostile]) {
|
|
1215
|
-
const note = s.machine.sessionNote;
|
|
1216
|
-
assert.equal(typeof note.reason, "string");
|
|
1217
|
-
for (const k of Object.keys(note)) assert.ok(["reason", "since", "detail"].includes(k), `sessionNote.${k} is not in the contract`);
|
|
1218
|
-
if (note.since !== undefined) assert.ok(Number.isFinite(Date.parse(note.since)), "since is ISO 8601");
|
|
1219
|
-
if (note.detail !== undefined) assert.ok(note.detail.length <= 200 && !/\/Users\//.test(note.detail));
|
|
1220
|
-
}
|
|
1221
|
-
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
1222
|
-
});
|
|
1223
|
-
|
|
1224
|
-
/**
|
|
1225
|
-
* The front-door signal must not flicker while the session is running.
|
|
1226
|
-
*
|
|
1227
|
-
* This function and `lib/session/liveness.mjs#heartbeatLiveness` answer one
|
|
1228
|
-
* question — "is the main session alive" — and they used to disagree: the
|
|
1229
|
-
* runtime accepted a heartbeat up to `staleMs` either side of now, this one
|
|
1230
|
-
* demanded `age >= 0`. So a heartbeat written microseconds before the wall
|
|
1231
|
-
* clock stepped back read as future-dated and flipped the seat to DOWN, while
|
|
1232
|
-
* the session carried on. A039 did exactly that across consecutive beats on
|
|
1233
|
-
* 2026-09-10/11 with its session healthy and its clock within 30 s of the
|
|
1234
|
-
* server. Symmetric now, and pinned here because the asymmetry was invisible:
|
|
1235
|
-
* every test used a past-dated heartbeat.
|
|
1236
|
-
*/
|
|
1237
|
-
test("sessionLiveness tolerates clock skew symmetrically, and reports a far-future beat as not live", () => {
|
|
1238
|
-
const now = Date.parse("2026-09-11T12:00:00Z");
|
|
1239
|
-
const at = (ms) => ({ ts: new Date(now - ms).toISOString() });
|
|
1240
|
-
|
|
1241
|
-
// Fresh, and a beat from the recent past: live, as before.
|
|
1242
|
-
assert.equal(sessionLiveness(at(0), now).sessionLive, true);
|
|
1243
|
-
assert.equal(sessionLiveness(at(30_000), now).sessionLive, true);
|
|
1244
|
-
|
|
1245
|
-
// THE REGRESSION: a beat dated slightly in the FUTURE is the clock moving,
|
|
1246
|
-
// not the session dying. Both of these used to return false.
|
|
1247
|
-
assert.equal(sessionLiveness(at(-1), now).sessionLive, true, "1 ms ahead is not a dead session");
|
|
1248
|
-
assert.equal(sessionLiveness(at(-30_000), now).sessionLive, true, "30 s ahead is ordinary clock motion");
|
|
1249
|
-
|
|
1250
|
-
// The window is symmetric and OPEN at both edges — the runtime's rule.
|
|
1251
|
-
assert.equal(sessionLiveness(at(SESSION_STALE_MS - 1), now).sessionLive, true);
|
|
1252
|
-
assert.equal(sessionLiveness(at(-(SESSION_STALE_MS - 1)), now).sessionLive, true);
|
|
1253
|
-
assert.equal(sessionLiveness(at(SESSION_STALE_MS), now).sessionLive, false, "too old");
|
|
1254
|
-
assert.equal(sessionLiveness(at(-SESSION_STALE_MS), now).sessionLive, false, "too far ahead to believe");
|
|
1255
|
-
|
|
1256
|
-
// The front door is still named in every case a heartbeat exists.
|
|
1257
|
-
assert.equal(sessionLiveness(at(10 * SESSION_STALE_MS), now).frontDoor, "session");
|
|
1258
|
-
// No heartbeat at all remains the daemon's lane.
|
|
1259
|
-
assert.deepEqual(sessionLiveness(null, now), { frontDoor: "daemon", sessionLive: false });
|
|
1260
|
-
});
|
|
1261
|
-
|
|
1262
|
-
test("sessionLiveness agrees with the session runtime's own window", async () => {
|
|
1263
|
-
const { heartbeatLiveness, STALE_MS } = await import("../session/liveness.mjs");
|
|
1264
|
-
assert.equal(STALE_MS, SESSION_STALE_MS, "one window, two readers");
|
|
1265
|
-
const now = Date.parse("2026-09-11T12:00:00Z");
|
|
1266
|
-
for (const offset of [0, 1_000, -1_000, 45_000, -45_000, STALE_MS, -STALE_MS, STALE_MS + 1, -(STALE_MS + 1)]) {
|
|
1267
|
-
const hb = { ts: new Date(now - offset).toISOString(), pid: 0 };
|
|
1268
|
-
assert.equal(
|
|
1269
|
-
sessionLiveness(hb, now).sessionLive,
|
|
1270
|
-
heartbeatLiveness(hb, { now }).live,
|
|
1271
|
-
`the two readers disagree at offset ${offset} ms`
|
|
1272
|
-
);
|
|
1273
|
-
}
|
|
1274
|
-
});
|