@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
|
@@ -0,0 +1,1094 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scripts/fleet/rollout.mjs — publish one SDK version to the fleet, then PROVE
|
|
4
|
+
* every seat took it.
|
|
5
|
+
*
|
|
6
|
+
* ── WHY THIS EXISTS ───────────────────────────────────────────────────────────
|
|
7
|
+
* Propagation across the fleet has exactly one live path: publish to npm, and
|
|
8
|
+
* each seat's hourly `scripts/local-triggers/autoupdate.sh` installs @latest.
|
|
9
|
+
* There is no fan-out, no ssh, no push. That path is fine — what was missing is
|
|
10
|
+
* the OPERATOR half of it:
|
|
11
|
+
*
|
|
12
|
+
* · the publish is gated on facts nobody re-checked at the moment of publish
|
|
13
|
+
* (auth, a clean tree, a pushed tag, green check+test), so "I ran the tests
|
|
14
|
+
* an hour ago" stood in for "the tests pass on the bytes I am about to
|
|
15
|
+
* publish";
|
|
16
|
+
* · and nothing ever ASKED whether the fleet took it. One seat ran six days
|
|
17
|
+
* behind (2026-09-15..21) while its own log said "up to date" every hour;
|
|
18
|
+
* the org noticed nothing, because nobody was looking at the fleet's
|
|
19
|
+
* versions as a set.
|
|
20
|
+
*
|
|
21
|
+
* So: one command, three stages, and a non-zero exit while any seat is behind.
|
|
22
|
+
*
|
|
23
|
+
* 1. PRE-FLIGHT — refuse unless `npm whoami` succeeds, the local version is
|
|
24
|
+
* strictly greater than the registry's @latest, the working tree is clean,
|
|
25
|
+
* the version's tag exists locally AND on the remote, and `npm run check`
|
|
26
|
+
* + `npm test` pass. The gates are RE-RUN here; a log from an earlier run
|
|
27
|
+
* is not evidence about the bytes being published.
|
|
28
|
+
* 2. PUBLISH — `npm publish`, then poll the registry until @latest IS the
|
|
29
|
+
* new version. A 404/stale read in the first seconds after a publish is
|
|
30
|
+
* REGISTRY READ LAG, not failure (this has been mistaken for a failed
|
|
31
|
+
* publish before), so the poll retries on a stale answer and only fails
|
|
32
|
+
* when the deadline passes.
|
|
33
|
+
* 3. VERIFY — poll the org directory (hq's server-stamped presence-beat
|
|
34
|
+
* derivation) for every fleet seat until each reports the new version or
|
|
35
|
+
* the deadline passes. A per-seat table prints every round; the run exits
|
|
36
|
+
* non-zero naming exactly which seats are behind.
|
|
37
|
+
*
|
|
38
|
+
* ── WHAT IT WILL NEVER DO ─────────────────────────────────────────────────────
|
|
39
|
+
* NO SSH. Not as a fallback, not with `--force`, not "just to check". Seats are
|
|
40
|
+
* updated by their own autoupdater; this tool publishes and then OBSERVES. It
|
|
41
|
+
* also takes NO credential as an argument or environment variable of its own:
|
|
42
|
+
* npm auth comes from the operator's own `npm login`, org auth from the agent
|
|
43
|
+
* config the rest of lib/org reads.
|
|
44
|
+
*
|
|
45
|
+
* Both promises are enforced twice, because a source-reading test can only see
|
|
46
|
+
* the shapes it was taught: {@link commandRefusal} refuses any command but npm
|
|
47
|
+
* and git at RUNTIME, on the value, and {@link parseArgs} drops everything
|
|
48
|
+
* after the first `=` before any error string is built, so no branch can echo a
|
|
49
|
+
* credential back. The structural tests in scripts/fleet/rollout.test.mjs sit
|
|
50
|
+
* on top of those, pinning the import surface of node:child_process so nothing
|
|
51
|
+
* can spawn a process without going through the one guarded seam.
|
|
52
|
+
*
|
|
53
|
+
* ── `unverifiable` NOW MEANS THE SEAT IS SILENT, NOT THE READ ─────────────────
|
|
54
|
+
* Stage 3 wants each seat's RUNNING version. This used to be unanswerable from
|
|
55
|
+
* outside the machines: the beat carried `machine.sdkVersion` and hq persisted
|
|
56
|
+
* it, but `directory` (src/server/rpc/reads.ts#directory) selected five
|
|
57
|
+
* AgentStatus columns and `machine` was not among them — so every seat in a
|
|
58
|
+
* perfect fleet read `unverifiable` and the run exited 3. That read now
|
|
59
|
+
* projects the identity fields (`sdkVersion`, `sdkVersionRunning`, `hostname`),
|
|
60
|
+
* narrowed deliberately: the rest of `machine` carries per-seat spend and a
|
|
61
|
+
* private-network address, which stay behind `fleet.view`.
|
|
62
|
+
*
|
|
63
|
+
* So `unverifiable` has changed meaning, and the new meaning is a finding. A
|
|
64
|
+
* seat reads unverifiable when IT reports no version — which, in this fleet,
|
|
65
|
+
* means its copied tree predates SDK 2.12, the release that first put identity
|
|
66
|
+
* on the beat. The field that would announce the staleness is the field the
|
|
67
|
+
* staleness removes, so such a seat cannot be told from a healthy one by its
|
|
68
|
+
* version alone; what gives it away is that its beat also carries no
|
|
69
|
+
* `frontDoor`/`sessionLive`, which every 2.12+ collector emits unconditionally.
|
|
70
|
+
* Either way it still exits non-zero: an unverified seat is not a verified one,
|
|
71
|
+
* and "it beat, so it must be current" is exactly the substitution that hid a
|
|
72
|
+
* six-day-stale seat.
|
|
73
|
+
*
|
|
74
|
+
* INSTALLED IS NOT RUNNING. {@link seatVersions} keeps the two apart and judges
|
|
75
|
+
* on `running` whenever the seat reports it (`machine.daemon.sdkVersion`, SDK
|
|
76
|
+
* 2.18+), because autoupdate installs into node_modules while the old daemon
|
|
77
|
+
* keeps executing the code it booted on. A seat whose two disagree is `stale`.
|
|
78
|
+
*
|
|
79
|
+
* ── USAGE ─────────────────────────────────────────────────────────────────────
|
|
80
|
+
* node scripts/fleet/rollout.mjs [--dry-run] [--deadline <min>]
|
|
81
|
+
* [--interval <s>] [--seat-window <min>]
|
|
82
|
+
* [--agent-root <path>] [--json] [--help]
|
|
83
|
+
*
|
|
84
|
+
* `--dry-run` does everything except the publish itself (pre-flight gates run
|
|
85
|
+
* for real, including check+test) and then verifies propagation against the
|
|
86
|
+
* version in package.json — which is how you see, today, exactly which seats
|
|
87
|
+
* would still be behind.
|
|
88
|
+
*
|
|
89
|
+
* Idempotent: when the registry's @latest already equals the local version the
|
|
90
|
+
* run prints "already published, verifying propagation" and goes straight to
|
|
91
|
+
* stage 3. Re-running it is the supported way to watch a rollout land.
|
|
92
|
+
*
|
|
93
|
+
* Exit codes: 0 verified · 1 pre-flight refusal · 2 publish/registry failure ·
|
|
94
|
+
* 3 propagation deadline passed with seats behind or unverifiable.
|
|
95
|
+
*
|
|
96
|
+
* @module scripts/fleet/rollout
|
|
97
|
+
*/
|
|
98
|
+
|
|
99
|
+
"use strict";
|
|
100
|
+
|
|
101
|
+
import { readFileSync } from "node:fs";
|
|
102
|
+
import { spawnSync } from "node:child_process";
|
|
103
|
+
import { dirname, join, resolve as resolvePath } from "node:path";
|
|
104
|
+
import { fileURLToPath } from "node:url";
|
|
105
|
+
|
|
106
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
107
|
+
const REPO_ROOT = resolvePath(dirname(__filename), "..", "..");
|
|
108
|
+
|
|
109
|
+
/** Package this tool publishes. Read from package.json, never hardcoded. */
|
|
110
|
+
export function readLocalPackage(repoRoot = REPO_ROOT) {
|
|
111
|
+
const doc = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf-8"));
|
|
112
|
+
return { name: String(doc.name || ""), version: String(doc.version || "") };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// PURE: version comparison
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Parse a semver-ish string into comparable parts. Tolerant: a leading `v`, and
|
|
121
|
+
* missing minor/patch, are accepted; anything unparseable yields null so the
|
|
122
|
+
* caller can refuse rather than guess.
|
|
123
|
+
* @param {string} v
|
|
124
|
+
* @returns {{major:number,minor:number,patch:number,pre:string[]}|null}
|
|
125
|
+
*/
|
|
126
|
+
export function parseVersion(v) {
|
|
127
|
+
const s = String(v == null ? "" : v).trim().replace(/^v/i, "");
|
|
128
|
+
const m = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(s);
|
|
129
|
+
if (!m) return null;
|
|
130
|
+
return {
|
|
131
|
+
major: Number(m[1]),
|
|
132
|
+
minor: Number(m[2] || 0),
|
|
133
|
+
patch: Number(m[3] || 0),
|
|
134
|
+
pre: m[4] ? m[4].split(".") : [],
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Compare two versions: -1 (a<b), 0 (equal), 1 (a>b). An unparseable version
|
|
140
|
+
* sorts BELOW a parseable one (and two unparseable ones compare equal), so a
|
|
141
|
+
* garbage registry answer can never read as "newer than mine" and wave a
|
|
142
|
+
* publish through.
|
|
143
|
+
* @param {string} a
|
|
144
|
+
* @param {string} b
|
|
145
|
+
* @returns {-1|0|1}
|
|
146
|
+
*/
|
|
147
|
+
export function compareVersions(a, b) {
|
|
148
|
+
const pa = parseVersion(a);
|
|
149
|
+
const pb = parseVersion(b);
|
|
150
|
+
if (!pa && !pb) return 0;
|
|
151
|
+
if (!pa) return -1;
|
|
152
|
+
if (!pb) return 1;
|
|
153
|
+
for (const k of ["major", "minor", "patch"]) {
|
|
154
|
+
if (pa[k] !== pb[k]) return pa[k] < pb[k] ? -1 : 1;
|
|
155
|
+
}
|
|
156
|
+
// 1.0.0-rc.1 < 1.0.0 : a release with no prerelease outranks one with.
|
|
157
|
+
if (pa.pre.length === 0 && pb.pre.length === 0) return 0;
|
|
158
|
+
if (pa.pre.length === 0) return 1;
|
|
159
|
+
if (pb.pre.length === 0) return -1;
|
|
160
|
+
const n = Math.max(pa.pre.length, pb.pre.length);
|
|
161
|
+
for (let i = 0; i < n; i++) {
|
|
162
|
+
const x = pa.pre[i];
|
|
163
|
+
const y = pb.pre[i];
|
|
164
|
+
if (x === undefined) return -1;
|
|
165
|
+
if (y === undefined) return 1;
|
|
166
|
+
const xn = /^\d+$/.test(x);
|
|
167
|
+
const yn = /^\d+$/.test(y);
|
|
168
|
+
if (xn && yn) {
|
|
169
|
+
if (Number(x) !== Number(y)) return Number(x) < Number(y) ? -1 : 1;
|
|
170
|
+
} else if (x !== y) {
|
|
171
|
+
return x < y ? -1 : 1;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** True when `a` is a strictly greater version than `b`. */
|
|
178
|
+
export function isNewer(a, b) {
|
|
179
|
+
return compareVersions(a, b) === 1;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// PURE: pre-flight verdict
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Decide whether a publish may proceed, from already-gathered FACTS. Pure: it
|
|
188
|
+
* runs nothing, so every refusal is unit-testable and the order of refusals is
|
|
189
|
+
* stable (cheapest, most-likely-human-fixable first).
|
|
190
|
+
*
|
|
191
|
+
* @param {object} f
|
|
192
|
+
* @param {{ok:boolean, user?:string, error?:string}} f.whoami
|
|
193
|
+
* @param {string} f.localVersion
|
|
194
|
+
* @param {{ok:boolean, version:string|null, error?:string}} f.registryLatest
|
|
195
|
+
* @param {{clean:boolean, dirty:string[]}} f.worktree
|
|
196
|
+
* @param {{local:boolean, remote:boolean, name:string}} f.tag
|
|
197
|
+
* @param {{ok:boolean, code:number}} f.check
|
|
198
|
+
* @param {{ok:boolean, code:number}} f.test
|
|
199
|
+
* @returns {{ok:boolean, refusals:string[]}}
|
|
200
|
+
*/
|
|
201
|
+
export function evaluatePreflight(f) {
|
|
202
|
+
const refusals = [];
|
|
203
|
+
const local = String(f.localVersion || "");
|
|
204
|
+
|
|
205
|
+
if (!f.whoami || !f.whoami.ok) {
|
|
206
|
+
refusals.push(
|
|
207
|
+
`npm auth: \`npm whoami\` failed${f.whoami && f.whoami.error ? ` (${f.whoami.error})` : ""} — run \`npm login\` as the publisher. This tool never accepts a token as an argument.`
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (!parseVersion(local)) {
|
|
212
|
+
refusals.push(`local version: package.json version ${JSON.stringify(local)} is not a version this tool can compare.`);
|
|
213
|
+
} else if (!f.registryLatest || !f.registryLatest.ok) {
|
|
214
|
+
refusals.push(
|
|
215
|
+
`registry read: could not read the published @latest${f.registryLatest && f.registryLatest.error ? ` (${f.registryLatest.error})` : ""} — refusing to publish blind.`
|
|
216
|
+
);
|
|
217
|
+
} else if (!isNewer(local, f.registryLatest.version)) {
|
|
218
|
+
refusals.push(
|
|
219
|
+
`version: local ${local} is not greater than the registry's @latest ${f.registryLatest.version || "(none)"} — bump package.json first.`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (!f.worktree || !f.worktree.clean) {
|
|
224
|
+
const dirty = (f.worktree && f.worktree.dirty) || [];
|
|
225
|
+
const shown = dirty.slice(0, 5).join(", ");
|
|
226
|
+
refusals.push(
|
|
227
|
+
`working tree: ${dirty.length} uncommitted path(s)${shown ? ` (${shown}${dirty.length > 5 ? ", …" : ""})` : ""} — publish only what is committed.`
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (!f.tag || !f.tag.local) {
|
|
232
|
+
refusals.push(`tag: ${f.tag && f.tag.name ? f.tag.name : `v${local}`} does not exist locally — tag the release commit.`);
|
|
233
|
+
} else if (!f.tag.remote) {
|
|
234
|
+
refusals.push(`tag: ${f.tag.name} is not on the remote — \`git push origin ${f.tag.name}\` so the published bytes stay findable.`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
for (const g of [{ k: "check", cmd: "npm run check" }, { k: "test", cmd: "npm test" }]) {
|
|
238
|
+
const r = f[g.k];
|
|
239
|
+
if (!r) { refusals.push(`gate: \`${g.cmd}\` was not run.`); continue; }
|
|
240
|
+
if (r.ok) continue;
|
|
241
|
+
// Could not run vs ran and failed: the operator's next move differs, so the
|
|
242
|
+
// refusal must not blur them (ENOBUFS once made a passing suite read as a
|
|
243
|
+
// failing one).
|
|
244
|
+
if (r.ran === false) refusals.push(`gate: \`${g.cmd}\` could not be run (${r.failure || "unknown"}) — this is NOT a test failure; the harness could not capture its output.`);
|
|
245
|
+
else refusals.push(`gate: \`${g.cmd}\` failed (exit ${r.code}).`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return { ok: refusals.length === 0, refusals };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* One gate's line in the pre-flight table. Pure.
|
|
253
|
+
*
|
|
254
|
+
* Three outcomes, not two: passed, ran and failed, or never got to answer.
|
|
255
|
+
* The third used to print as the second.
|
|
256
|
+
*/
|
|
257
|
+
export function gateLine(r) {
|
|
258
|
+
if (!r) return "not run";
|
|
259
|
+
if (r.ok) return "pass";
|
|
260
|
+
if (r.ran === false) return `COULD NOT RUN (${r.failure || "unknown"}) — not a test failure`;
|
|
261
|
+
return `FAILED (exit ${r.code})`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* A note about paths the GATES themselves dirtied. `npm test` appends to a
|
|
266
|
+
* tracked runtime ledger (.claude-flow/policy/state.json), so a second run in a
|
|
267
|
+
* row would refuse on a file the first run wrote. The clean-tree gate is NOT
|
|
268
|
+
* weakened for this — publishing a tree you cannot describe stays a refusal —
|
|
269
|
+
* the run just says which paths are the gates' own leavings so the operator
|
|
270
|
+
* reverts them instead of hunting them. Pure.
|
|
271
|
+
*
|
|
272
|
+
* @param {string[]} before dirty paths before the gates ran
|
|
273
|
+
* @param {string[]} after dirty paths after
|
|
274
|
+
* @returns {string|null}
|
|
275
|
+
*/
|
|
276
|
+
export function gateDirtNote(before, after) {
|
|
277
|
+
const was = new Set(before || []);
|
|
278
|
+
const added = (after || []).filter((p) => !was.has(p));
|
|
279
|
+
if (added.length === 0) return null;
|
|
280
|
+
return (
|
|
281
|
+
`the gates just dirtied ${added.length} tracked path(s): ${added.join(", ")}. ` +
|
|
282
|
+
`They are runtime artefacts, not your change — \`git checkout --\` them before re-running, ` +
|
|
283
|
+
`or the clean-tree gate will refuse the next run.`
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
// PURE: registry read-lag retry decision
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Decide what to do with one post-publish registry read. The registry is
|
|
293
|
+
* eventually consistent: for a few seconds after a successful publish a read
|
|
294
|
+
* can 404 the version or still answer with the PREVIOUS @latest. That is READ
|
|
295
|
+
* LAG, not a failed publish — treating it as failure has previously sent people
|
|
296
|
+
* hunting a publish that had in fact worked.
|
|
297
|
+
*
|
|
298
|
+
* @param {object} o
|
|
299
|
+
* @param {number} o.attempt 1-based attempt number
|
|
300
|
+
* @param {number} o.maxAttempts
|
|
301
|
+
* @param {string} o.target version just published
|
|
302
|
+
* @param {{ok:boolean, version:string|null, error?:string}} o.read
|
|
303
|
+
* @returns {{action:"done"|"retry"|"fail", reason:string}}
|
|
304
|
+
*/
|
|
305
|
+
export function readLagDecision(o) {
|
|
306
|
+
const { attempt, maxAttempts, target, read } = o;
|
|
307
|
+
if (read && read.ok && read.version && compareVersions(read.version, target) === 0) {
|
|
308
|
+
return { action: "done", reason: `registry @latest is ${target}` };
|
|
309
|
+
}
|
|
310
|
+
const why = !read || !read.ok
|
|
311
|
+
? `registry read failed (${(read && read.error) || "unknown"})`
|
|
312
|
+
: `registry @latest is ${read.version || "(none)"}, not ${target}`;
|
|
313
|
+
if (attempt < maxAttempts) return { action: "retry", reason: `${why} — read lag, retrying` };
|
|
314
|
+
return { action: "fail", reason: `${why} after ${maxAttempts} attempt(s)` };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ---------------------------------------------------------------------------
|
|
318
|
+
// PURE: seats, versions, staleness table
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
/** Default window in which a beat makes a member a FLEET SEAT (ms). */
|
|
322
|
+
export const DEFAULT_SEAT_WINDOW_MS = 30 * 60 * 1000;
|
|
323
|
+
|
|
324
|
+
/** First non-blank string among the candidates, trimmed; null when none is. */
|
|
325
|
+
function firstString(candidates) {
|
|
326
|
+
for (const c of candidates) {
|
|
327
|
+
if (typeof c === "string" && c.trim()) return c.trim();
|
|
328
|
+
}
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* The two versions a directory entry reports, kept APART.
|
|
334
|
+
*
|
|
335
|
+
* installed — what `npm install` put on the seat (`machine.sdkVersion`)
|
|
336
|
+
* running — what the beating process actually executes
|
|
337
|
+
* (`machine.daemon.sdkVersion`, SDK 2.18+)
|
|
338
|
+
*
|
|
339
|
+
* They are different facts and a rollout is about the second one. autoupdate
|
|
340
|
+
* installs into node_modules while the old daemon keeps executing the code it
|
|
341
|
+
* booted on, and a hand-run `maestro upgrade` never restarts the daemon at all
|
|
342
|
+
* — so "installed" is where a propagation check goes wrong quietly: the seat is
|
|
343
|
+
* carrying the new package and running the old one, and a checker that only
|
|
344
|
+
* reads `installed` calls that done.
|
|
345
|
+
*
|
|
346
|
+
* `version` is therefore RUNNING when the seat reports it, and falls back to
|
|
347
|
+
* installed only when it does not — with `basis` naming which, so no reader has
|
|
348
|
+
* to infer it. A seat whose two disagree is `stale`, correctly: it has the fix
|
|
349
|
+
* on disk and is not running it.
|
|
350
|
+
*
|
|
351
|
+
* Several shapes are accepted because the field has travelled: the directory
|
|
352
|
+
* read now projects it as `status.sdkVersion` / `status.sdkVersionRunning`,
|
|
353
|
+
* `presence.fleetVersions` returns `installed` / `running`, and the raw
|
|
354
|
+
* `machine` record is what both are read from.
|
|
355
|
+
*
|
|
356
|
+
* EXPECT EVERY ROW TO SAY `(installed)` UNTIL 2.18.x IS ON THE SEATS. `running`
|
|
357
|
+
* comes from `machine.daemon`, which only an SDK 2.18+ daemon emits, and no
|
|
358
|
+
* seat in the fleet writes that block today — so `basis` is `"installed"` for
|
|
359
|
+
* every row and the table prints the suffix everywhere. That is the check
|
|
360
|
+
* reporting honestly about what it can see, NOT a defect and NOT a reason to
|
|
361
|
+
* drop the suffix: the suffix disappears seat by seat as the daemons restart
|
|
362
|
+
* onto a version that reports what it is running, and a table that went quiet
|
|
363
|
+
* about the difference would be back to the failure this file exists to catch.
|
|
364
|
+
*
|
|
365
|
+
* @param {object} entry
|
|
366
|
+
* @returns {{installed:string|null, running:string|null, version:string|null, basis:"running"|"installed"|null}}
|
|
367
|
+
*/
|
|
368
|
+
export function seatVersions(entry) {
|
|
369
|
+
const e = entry && typeof entry === "object" ? entry : {};
|
|
370
|
+
const st = (e.status && typeof e.status === "object" && e.status) || {};
|
|
371
|
+
const mach = (st.machine && typeof st.machine === "object" && st.machine) || (e.machine && typeof e.machine === "object" && e.machine) || {};
|
|
372
|
+
const daemon = (mach.daemon && typeof mach.daemon === "object" && mach.daemon) || {};
|
|
373
|
+
|
|
374
|
+
const installed = firstString([
|
|
375
|
+
mach.sdkVersion,
|
|
376
|
+
st.sdkVersion,
|
|
377
|
+
e.presence && e.presence.sdkVersion,
|
|
378
|
+
e.installed,
|
|
379
|
+
e.sdkVersion,
|
|
380
|
+
]);
|
|
381
|
+
const running = firstString([
|
|
382
|
+
daemon.sdkVersion,
|
|
383
|
+
st.sdkVersionRunning,
|
|
384
|
+
e.running,
|
|
385
|
+
]);
|
|
386
|
+
const version = running || installed;
|
|
387
|
+
return { installed, running, version, basis: version === null ? null : running ? "running" : "installed" };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* The single version a seat is judged on, or null when the read carries none.
|
|
392
|
+
* Kept as its own export because it is the whole of what `classifySeat` needs;
|
|
393
|
+
* {@link seatVersions} is what a caller reaches for when it wants to SAY which
|
|
394
|
+
* of the two it got.
|
|
395
|
+
* @param {object} entry
|
|
396
|
+
* @returns {string|null}
|
|
397
|
+
*/
|
|
398
|
+
export function seatVersion(entry) {
|
|
399
|
+
return seatVersions(entry).version;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Turn a directory `members` array into the rollout's seat rows. A member is a
|
|
404
|
+
* SEAT when it is an AI colleague whose server-stamped beat is inside
|
|
405
|
+
* `seatWindowMs`: an agent with no machine (LLM-responder mode) never takes an
|
|
406
|
+
* SDK version and must not be counted as behind. Pure.
|
|
407
|
+
*
|
|
408
|
+
* `readCarriesVersionField` is the tool's answer to "am I blind, or is the
|
|
409
|
+
* fleet silent?" — the two produce an identical table and want opposite
|
|
410
|
+
* responses. It is true when ANY member's status object OWNS an sdkVersion key,
|
|
411
|
+
* even set to null: a read that projects the field says so by carrying it,
|
|
412
|
+
* whatever the value. Without this the tool would have to assert a cause it
|
|
413
|
+
* cannot see, and it would be wrong on whichever side of the hq deploy it was
|
|
414
|
+
* not written for.
|
|
415
|
+
*
|
|
416
|
+
* @param {object[]} members
|
|
417
|
+
* @param {{seatWindowMs?:number}} [o]
|
|
418
|
+
* @returns {{seats:object[], nonSeats:object[], readCarriesVersionField:boolean}}
|
|
419
|
+
*/
|
|
420
|
+
export function toSeats(members, o = {}) {
|
|
421
|
+
const windowMs = Number.isFinite(o.seatWindowMs) ? o.seatWindowMs : DEFAULT_SEAT_WINDOW_MS;
|
|
422
|
+
const seats = [];
|
|
423
|
+
const nonSeats = [];
|
|
424
|
+
let readCarriesVersionField = false;
|
|
425
|
+
for (const m of Array.isArray(members) ? members : []) {
|
|
426
|
+
if (!m || typeof m !== "object") continue;
|
|
427
|
+
if (m.kind && m.kind !== "AI_AGENT") continue;
|
|
428
|
+
const st = m.status;
|
|
429
|
+
if (st && typeof st === "object" && ("sdkVersion" in st || "sdkVersionRunning" in st || (st.machine && typeof st.machine === "object"))) {
|
|
430
|
+
readCarriesVersionField = true;
|
|
431
|
+
}
|
|
432
|
+
const p = m.presence && typeof m.presence === "object" ? m.presence : null;
|
|
433
|
+
const beatMs = p && Number.isFinite(p.lastBeatMs) ? p.lastBeatMs : null;
|
|
434
|
+
const row = {
|
|
435
|
+
id: String(m.id || ""),
|
|
436
|
+
slug: String(m.slug || ""),
|
|
437
|
+
name: String(m.displayName || m.name || m.slug || m.id || ""),
|
|
438
|
+
lane: (p && p.lane) || "unknown",
|
|
439
|
+
detail: (p && p.detail) || "",
|
|
440
|
+
lastBeatMs: beatMs,
|
|
441
|
+
...seatVersions(m),
|
|
442
|
+
};
|
|
443
|
+
if (beatMs !== null && beatMs <= windowMs) seats.push(row);
|
|
444
|
+
else nonSeats.push(row);
|
|
445
|
+
}
|
|
446
|
+
const bySlug = (a, b) => (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0);
|
|
447
|
+
return { seats: seats.sort(bySlug), nonSeats: nonSeats.sort(bySlug), readCarriesVersionField };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Verdict for one seat against the target version. Pure.
|
|
452
|
+
* current — reported version equals the target
|
|
453
|
+
* stale — reported a different (older or newer) version
|
|
454
|
+
* unverifiable — beating, but the read carries no version at all
|
|
455
|
+
* @param {object} seat
|
|
456
|
+
* @param {string} target
|
|
457
|
+
* @returns {"current"|"stale"|"unverifiable"}
|
|
458
|
+
*/
|
|
459
|
+
export function classifySeat(seat, target) {
|
|
460
|
+
const v = seat && seat.version;
|
|
461
|
+
if (!v) return "unverifiable";
|
|
462
|
+
return compareVersions(v, target) === 0 ? "current" : "stale";
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Summarise a fleet read against the target. `done` is true only when every
|
|
467
|
+
* seat is `current` — an unverifiable seat is never counted as done. Pure.
|
|
468
|
+
* @param {{seats:object[], nonSeats:object[]}} fleet
|
|
469
|
+
* @param {string} target
|
|
470
|
+
* @returns {object}
|
|
471
|
+
*/
|
|
472
|
+
export function summarise(fleet, target) {
|
|
473
|
+
const rows = (fleet.seats || []).map((s) => ({ ...s, verdict: classifySeat(s, target) }));
|
|
474
|
+
const counts = { current: 0, stale: 0, unverifiable: 0 };
|
|
475
|
+
for (const r of rows) counts[r.verdict] += 1;
|
|
476
|
+
return {
|
|
477
|
+
target,
|
|
478
|
+
rows,
|
|
479
|
+
counts,
|
|
480
|
+
nonSeats: fleet.nonSeats || [],
|
|
481
|
+
stale: rows.filter((r) => r.verdict === "stale"),
|
|
482
|
+
unverifiable: rows.filter((r) => r.verdict === "unverifiable"),
|
|
483
|
+
readCarriesVersionField: fleet.readCarriesVersionField === true,
|
|
484
|
+
done: rows.length > 0 && counts.current === rows.length,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Human "27ms"/"18s"/"4m"/"2h" age. */
|
|
489
|
+
export function ageLabel(ms) {
|
|
490
|
+
if (!Number.isFinite(ms)) return "never";
|
|
491
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
492
|
+
const s = Math.round(ms / 1000);
|
|
493
|
+
if (s < 90) return `${s}s`;
|
|
494
|
+
const m = Math.round(s / 60);
|
|
495
|
+
if (m < 90) return `${m}m`;
|
|
496
|
+
const h = Math.round(m / 60);
|
|
497
|
+
if (h < 48) return `${h}h`;
|
|
498
|
+
return `${Math.round(h / 24)}d`;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Render the per-seat table. Pure (returns a string; prints nothing).
|
|
503
|
+
* @param {object} summary from {@link summarise}
|
|
504
|
+
* @param {{round?:number, elapsedMs?:number}} [o]
|
|
505
|
+
* @returns {string}
|
|
506
|
+
*/
|
|
507
|
+
export function renderSeatTable(summary, o = {}) {
|
|
508
|
+
const head = ["SEAT", "ID", "BEAT", "REPORTS", "VERDICT"];
|
|
509
|
+
const body = summary.rows.map((r) => [
|
|
510
|
+
r.name,
|
|
511
|
+
r.slug,
|
|
512
|
+
ageLabel(r.lastBeatMs),
|
|
513
|
+
r.version ? (r.basis === "installed" ? `${r.version} (installed)` : r.version) : "unknown",
|
|
514
|
+
r.verdict,
|
|
515
|
+
]);
|
|
516
|
+
const widths = head.map((h, i) => Math.max(h.length, ...body.map((row) => row[i].length), 0));
|
|
517
|
+
const line = (cells) => cells.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd();
|
|
518
|
+
const out = [];
|
|
519
|
+
const stamp = [
|
|
520
|
+
o.round ? `round ${o.round}` : null,
|
|
521
|
+
Number.isFinite(o.elapsedMs) ? `${ageLabel(o.elapsedMs)} elapsed` : null,
|
|
522
|
+
].filter(Boolean).join(" · ");
|
|
523
|
+
out.push(`Fleet vs ${summary.target}${stamp ? ` (${stamp})` : ""}`);
|
|
524
|
+
out.push(line(head));
|
|
525
|
+
out.push(widths.map((w) => "-".repeat(w)).join(" "));
|
|
526
|
+
for (const row of body) out.push(line(row));
|
|
527
|
+
out.push(
|
|
528
|
+
`${summary.rows.length} seat(s): ${summary.counts.current} current · ${summary.counts.stale} stale · ${summary.counts.unverifiable} unverifiable` +
|
|
529
|
+
(summary.nonSeats.length ? ` (+${summary.nonSeats.length} agent(s) with no beating machine — not rollout targets)` : "")
|
|
530
|
+
);
|
|
531
|
+
return out.join("\n");
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** The closing sentence naming exactly which seats are behind. Pure. */
|
|
535
|
+
export function renderVerdict(summary) {
|
|
536
|
+
if (summary.done) return `All ${summary.rows.length} seat(s) report ${summary.target}.`;
|
|
537
|
+
const parts = [];
|
|
538
|
+
if (summary.stale.length) {
|
|
539
|
+
parts.push(
|
|
540
|
+
`${summary.stale.length} seat(s) still behind ${summary.target}: ` +
|
|
541
|
+
summary.stale.map((r) => `${r.name} (${r.slug}, reports ${r.version})`).join(", ")
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
if (summary.unverifiable.length) {
|
|
545
|
+
parts.push(
|
|
546
|
+
`${summary.unverifiable.length} seat(s) could not be verified — the org read carries no sdkVersion: ` +
|
|
547
|
+
summary.unverifiable.map((r) => `${r.name} (${r.slug})`).join(", ")
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
if (!summary.rows.length) parts.push("no beating seats found in the org directory — nothing to verify.");
|
|
551
|
+
return parts.join("\n");
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* What stage 3's exit code will be, and WHY — as data, so a caller can tell the
|
|
556
|
+
* three very different things a `3` means apart.
|
|
557
|
+
*
|
|
558
|
+
* This exists because of an asymmetry that is easy to mistake for a bug. An
|
|
559
|
+
* unverified seat is deliberately NOT counted as done, so a run can exit 3 with
|
|
560
|
+
* no seat known to be behind. That is the honest call and it is not going to
|
|
561
|
+
* change: "it beat, so it must be current" is exactly the substitution that hid
|
|
562
|
+
* a six-day-stale seat.
|
|
563
|
+
*
|
|
564
|
+
* What changed is why a seat goes unverified. It used to be every seat, because
|
|
565
|
+
* hq's directory read did not project the column; that read carries the
|
|
566
|
+
* identity fields now, so `unverifiable` is a statement about THE SEAT — it
|
|
567
|
+
* reports no version, which means a copied tree older than SDK 2.12. A caller
|
|
568
|
+
* that wants to branch on "is anything behind" still reads `reason`, not the
|
|
569
|
+
* exit code: `behind` and `unverifiable` are different problems with different
|
|
570
|
+
* fixes, and only the first is answered by waiting.
|
|
571
|
+
*
|
|
572
|
+
* Pure.
|
|
573
|
+
*
|
|
574
|
+
* @param {ReturnType<typeof summarise>|null|undefined} summary
|
|
575
|
+
* @returns {{code:number, reason:"verified"|"behind"|"unverifiable"|"empty-fleet"|"no-fleet-read", note:string|null}}
|
|
576
|
+
*/
|
|
577
|
+
export function propagationOutcome(summary) {
|
|
578
|
+
if (!summary || !Array.isArray(summary.rows)) {
|
|
579
|
+
return {
|
|
580
|
+
code: 3,
|
|
581
|
+
reason: "no-fleet-read",
|
|
582
|
+
note: "No successful org read before the deadline — this says nothing about the fleet's versions.",
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
if (summary.done) return { code: 0, reason: "verified", note: null };
|
|
586
|
+
if (!summary.rows.length) {
|
|
587
|
+
return {
|
|
588
|
+
code: 3,
|
|
589
|
+
reason: "empty-fleet",
|
|
590
|
+
note: "No beating seats were found, so nothing was verified. An empty fleet is never 'done'.",
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
if (summary.counts.stale === 0 && summary.counts.unverifiable > 0) {
|
|
594
|
+
const head = "Exit 3 here does NOT mean a seat is behind — no seat is known to be. ";
|
|
595
|
+
const seatIsSilent =
|
|
596
|
+
"The directory read DOES project sdkVersion and these seats carry none, so the silence is theirs: a " +
|
|
597
|
+
"copied tree older than SDK 2.12, the release that first put the field on the beat. Waiting will not fix " +
|
|
598
|
+
"that — the code that would report the version is the code that is missing. Confirm it by checking " +
|
|
599
|
+
"whether those seats' beats also lack frontDoor/sessionLive (every 2.12+ collector emits those " +
|
|
600
|
+
"unconditionally); if so, the seat's autoupdater has not merged a release in weeks and someone has to " +
|
|
601
|
+
"reach the machine.";
|
|
602
|
+
const readIsBlind =
|
|
603
|
+
"No member in this read carried an sdkVersion field AT ALL — not even null — so the blindness is the " +
|
|
604
|
+
"READ's, not the fleet's, and this run says nothing about any seat's version. The projection lives in " +
|
|
605
|
+
"hq's src/server/rpc/reads.ts#directory; an hq old enough to predate it, or one deployed behind the " +
|
|
606
|
+
"branch that added it, answers exactly like this. Check what hq is actually serving before reading " +
|
|
607
|
+
"anything into the table above.";
|
|
608
|
+
return {
|
|
609
|
+
code: 3,
|
|
610
|
+
reason: "unverifiable",
|
|
611
|
+
note: head + (summary.readCarriesVersionField ? seatIsSilent : readIsBlind),
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
return {
|
|
615
|
+
code: 3,
|
|
616
|
+
reason: "behind",
|
|
617
|
+
note: `${summary.counts.stale} seat(s) are provably behind ${summary.target}.`,
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ---------------------------------------------------------------------------
|
|
622
|
+
// EFFECTS: shelling out, reading the org, sleeping
|
|
623
|
+
// ---------------------------------------------------------------------------
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* The only commands this tool may ever run. An allowlist, not a denylist: a
|
|
627
|
+
* denylist has to predict the name of the thing it is trying to stop, and
|
|
628
|
+
* `ssh` has plenty of spellings (`/usr/bin/ssh`, `sshpass`, a wrapper on PATH).
|
|
629
|
+
*/
|
|
630
|
+
export const ROLLOUT_ALLOWED_COMMANDS = ["npm", "git"];
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Commands this tool must never run. Remote execution is not a fallback. Kept
|
|
634
|
+
* as an explicit list because the refusal message should be able to say "this
|
|
635
|
+
* looks like remote execution" rather than only "not on the allowlist".
|
|
636
|
+
*/
|
|
637
|
+
export const ROLLOUT_FORBIDDEN_COMMANDS = ["ssh", "scp", "rsync", "sshpass", "expect"];
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Refuse any command that is not on {@link ROLLOUT_ALLOWED_COMMANDS}. Pure —
|
|
641
|
+
* returns the refusal rather than throwing it, so it can be tested without a
|
|
642
|
+
* process.
|
|
643
|
+
*
|
|
644
|
+
* This is the guard that actually holds. The structural test over this file's
|
|
645
|
+
* source can only see call sites it recognises, and a call site can be written
|
|
646
|
+
* not to look like one (`execSync("ssh …")`, `deps.exec(cmd, [])` where `cmd`
|
|
647
|
+
* is a variable). Everything still has to come through here to reach a process,
|
|
648
|
+
* so the check lives here, at runtime, on the value.
|
|
649
|
+
*
|
|
650
|
+
* @param {unknown} cmd
|
|
651
|
+
* @returns {string|null} the refusal, or null when the command is allowed
|
|
652
|
+
*/
|
|
653
|
+
export function commandRefusal(cmd) {
|
|
654
|
+
if (typeof cmd !== "string" || !cmd.length) return `refusing to run a non-string command (${typeof cmd})`;
|
|
655
|
+
if (ROLLOUT_ALLOWED_COMMANDS.includes(cmd)) return null;
|
|
656
|
+
const base = cmd.split("/").pop() || cmd;
|
|
657
|
+
if (ROLLOUT_FORBIDDEN_COMMANDS.includes(base) || /\bssh\b/.test(cmd)) {
|
|
658
|
+
return `refusing to run "${cmd}": this tool never executes anything remotely — seats update themselves.`;
|
|
659
|
+
}
|
|
660
|
+
return `refusing to run "${cmd}": only ${ROLLOUT_ALLOWED_COMMANDS.join(" and ")} may be run from here.`;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Run a command, capturing output. The only process-spawning seam in this file;
|
|
665
|
+
* {@link commandRefusal} is applied to every command before it can reach a
|
|
666
|
+
* process, and a test asserts no call site violates it either.
|
|
667
|
+
*
|
|
668
|
+
* A refused command THROWS rather than returning `{ok:false}`: a call site
|
|
669
|
+
* asking for `ssh` is a defect in this file, not an expected failure of a
|
|
670
|
+
* rollout, and must not be swallowed by a caller's error handling.
|
|
671
|
+
*/
|
|
672
|
+
export function runCommand(cmd, args, o = {}) {
|
|
673
|
+
const refusal = commandRefusal(cmd);
|
|
674
|
+
if (refusal) throw new Error(`rollout: ${refusal}`);
|
|
675
|
+
const r = spawnSync(cmd, args, {
|
|
676
|
+
cwd: o.cwd || REPO_ROOT,
|
|
677
|
+
encoding: "utf-8",
|
|
678
|
+
stdio: o.inherit ? "inherit" : "pipe",
|
|
679
|
+
env: process.env,
|
|
680
|
+
// Node's default maxBuffer is 1 MiB and `npm test` prints ~1.04 MiB of TAP.
|
|
681
|
+
// Exceeding it makes spawnSync KILL the child and report status null — the
|
|
682
|
+
// green suite arrived here as "npm test FAILED (exit -1)" and refused a
|
|
683
|
+
// sound release. A gate that cannot be run must never read as a gate that
|
|
684
|
+
// failed, so this is raised well past any plausible gate and the two
|
|
685
|
+
// outcomes are reported separately below.
|
|
686
|
+
maxBuffer: o.maxBuffer ?? 256 * 1024 * 1024,
|
|
687
|
+
});
|
|
688
|
+
// `error` set (ENOBUFS, ENOENT, a signal) means the command did not get to
|
|
689
|
+
// answer. `ran:false` says so; callers must not read it as a failing gate.
|
|
690
|
+
return {
|
|
691
|
+
ok: !r.error && r.status === 0,
|
|
692
|
+
ran: !r.error,
|
|
693
|
+
failure: r.error ? r.error.code || r.error.message : null,
|
|
694
|
+
code: r.status == null ? -1 : r.status,
|
|
695
|
+
stdout: (r.stdout || "").trim(),
|
|
696
|
+
stderr: (r.stderr || "").trim(),
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const defaultDeps = {
|
|
701
|
+
exec: runCommand,
|
|
702
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
703
|
+
now: () => Date.now(),
|
|
704
|
+
log: (s) => process.stdout.write(`${s}\n`),
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
/** `npm whoami` → {ok,user?,error?}. Never prints or returns a token. */
|
|
708
|
+
export function npmWhoami(deps) {
|
|
709
|
+
const r = deps.exec("npm", ["whoami"]);
|
|
710
|
+
if (r.ok && r.stdout) return { ok: true, user: r.stdout.split("\n").pop().trim() };
|
|
711
|
+
const err = (r.stderr || r.stdout || "").split("\n").find((l) => /E401|401|ENEEDAUTH|error/i.test(l));
|
|
712
|
+
return { ok: false, error: (err || `exit ${r.code}`).trim() };
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/** `npm view <pkg> version` → {ok,version,error?}. Needs no auth. */
|
|
716
|
+
export function npmLatest(pkg, deps) {
|
|
717
|
+
const r = deps.exec("npm", ["view", pkg, "version"]);
|
|
718
|
+
if (r.ok && r.stdout) return { ok: true, version: r.stdout.split("\n").pop().trim() };
|
|
719
|
+
return { ok: false, version: null, error: ((r.stderr || r.stdout || `exit ${r.code}`).split("\n")[0] || "").trim() };
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** Working-tree cleanliness from `git status --porcelain`. */
|
|
723
|
+
export function gitWorktree(deps) {
|
|
724
|
+
const r = deps.exec("git", ["status", "--porcelain"]);
|
|
725
|
+
if (!r.ok) return { clean: false, dirty: [`git status failed: ${r.stderr || r.code}`] };
|
|
726
|
+
const dirty = r.stdout.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
727
|
+
return { clean: dirty.length === 0, dirty };
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/** Whether `v<version>` exists locally and on the remote. */
|
|
731
|
+
export function gitTag(version, deps) {
|
|
732
|
+
const name = `v${version}`;
|
|
733
|
+
const local = deps.exec("git", ["tag", "--list", name]);
|
|
734
|
+
const remote = deps.exec("git", ["ls-remote", "--tags", "origin", name]);
|
|
735
|
+
return {
|
|
736
|
+
name,
|
|
737
|
+
local: local.ok && local.stdout.includes(name),
|
|
738
|
+
remote: remote.ok && remote.stdout.includes(`refs/tags/${name}`),
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Read the fleet from the org directory — hq's server-stamped presence
|
|
744
|
+
* derivation, i.e. the same beats the human /fleet page paints. Fail-soft:
|
|
745
|
+
* a failed read yields `ok:false` and the caller keeps polling rather than
|
|
746
|
+
* declaring the fleet dark.
|
|
747
|
+
*
|
|
748
|
+
* @param {object} o - { agentRoot, client?, seatWindowMs? }
|
|
749
|
+
* @returns {Promise<{ok:boolean, error?:string, seats:object[], nonSeats:object[]}>}
|
|
750
|
+
*/
|
|
751
|
+
export async function readFleet(o = {}) {
|
|
752
|
+
const client = o.client || (await import("../../lib/org/client.mjs"));
|
|
753
|
+
let cfg;
|
|
754
|
+
try {
|
|
755
|
+
cfg = client.loadOrgConfig(o.agentRoot);
|
|
756
|
+
} catch (err) {
|
|
757
|
+
return { ok: false, error: `org config unreadable (${err && err.message})`, seats: [], nonSeats: [] };
|
|
758
|
+
}
|
|
759
|
+
if (!client.isEnabled(cfg)) {
|
|
760
|
+
return { ok: false, error: "org integration is not enabled for this seat — cannot read the fleet", seats: [], nonSeats: [] };
|
|
761
|
+
}
|
|
762
|
+
let r;
|
|
763
|
+
try {
|
|
764
|
+
r = await client.read("directory", client.configFromAgent(cfg));
|
|
765
|
+
} catch (err) {
|
|
766
|
+
return { ok: false, error: `directory read threw (${err && err.message})`, seats: [], nonSeats: [] };
|
|
767
|
+
}
|
|
768
|
+
if (!r || !r.ok || !r.payload) {
|
|
769
|
+
return { ok: false, error: `directory read failed (${(r && r.error) || "no payload"})`, seats: [], nonSeats: [] };
|
|
770
|
+
}
|
|
771
|
+
const members = Array.isArray(r.payload) ? r.payload : r.payload.members || [];
|
|
772
|
+
return { ok: true, ...toSeats(members, { seatWindowMs: o.seatWindowMs }) };
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// ---------------------------------------------------------------------------
|
|
776
|
+
// STAGES
|
|
777
|
+
// ---------------------------------------------------------------------------
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Stage 1 — gather the facts (running the gates for real) and apply
|
|
781
|
+
* {@link evaluatePreflight}.
|
|
782
|
+
*/
|
|
783
|
+
export function preflight(o) {
|
|
784
|
+
const deps = { ...defaultDeps, ...(o.deps || {}) };
|
|
785
|
+
const { name, version } = o.pkg;
|
|
786
|
+
deps.log(`\n── pre-flight ──────────────────────────────────────────────`);
|
|
787
|
+
|
|
788
|
+
const whoami = npmWhoami(deps);
|
|
789
|
+
deps.log(`npm whoami ${whoami.ok ? `ok (${whoami.user})` : `FAILED — ${whoami.error}`}`);
|
|
790
|
+
|
|
791
|
+
const registryLatest = o.registryLatest || npmLatest(name, deps);
|
|
792
|
+
deps.log(`registry @latest ${registryLatest.ok ? registryLatest.version : `unreadable — ${registryLatest.error}`} (local ${version})`);
|
|
793
|
+
|
|
794
|
+
const worktree = gitWorktree(deps);
|
|
795
|
+
deps.log(`working tree ${worktree.clean ? "clean" : `${worktree.dirty.length} uncommitted path(s)`}`);
|
|
796
|
+
|
|
797
|
+
const tag = gitTag(version, deps);
|
|
798
|
+
deps.log(`tag ${tag.name}${" ".repeat(Math.max(1, 16 - tag.name.length))}${tag.local ? (tag.remote ? "local + pushed" : "local only — NOT pushed") : "missing"}`);
|
|
799
|
+
|
|
800
|
+
// The cheap facts first. If any of them already blocks the publish, the two
|
|
801
|
+
// multi-minute gates are NOT run — there is nothing they could unblock, and
|
|
802
|
+
// an operator staring at a 401 should not wait three minutes to be told so.
|
|
803
|
+
// They are never SKIPPED on a run that could publish: see below.
|
|
804
|
+
const cheap = {
|
|
805
|
+
whoami,
|
|
806
|
+
localVersion: version,
|
|
807
|
+
registryLatest,
|
|
808
|
+
worktree,
|
|
809
|
+
tag,
|
|
810
|
+
check: { ok: true, code: 0 },
|
|
811
|
+
test: { ok: true, code: 0 },
|
|
812
|
+
};
|
|
813
|
+
const cheapVerdict = evaluatePreflight(cheap);
|
|
814
|
+
if (!cheapVerdict.ok) {
|
|
815
|
+
deps.log(`npm run check not run (publish already blocked)`);
|
|
816
|
+
deps.log(`npm test not run (publish already blocked)`);
|
|
817
|
+
return {
|
|
818
|
+
ok: false,
|
|
819
|
+
refusals: cheapVerdict.refusals,
|
|
820
|
+
notes: ["`npm run check` and `npm test` were not run: the blockers above stop the publish regardless. They are re-run in full on any run that could publish."],
|
|
821
|
+
facts: { whoami, registryLatest, worktree, tag, check: null, test: null },
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// The gates are re-run, never trusted from a log: they must pass on the bytes
|
|
826
|
+
// about to be published, not on whatever was in the tree an hour ago.
|
|
827
|
+
deps.log(`npm run check running…`);
|
|
828
|
+
const check = deps.exec("npm", ["run", "check"], { inherit: Boolean(o.inheritGates) });
|
|
829
|
+
deps.log(`npm run check ${gateLine(check)}`);
|
|
830
|
+
deps.log(`npm test running…`);
|
|
831
|
+
const test = deps.exec("npm", ["test"], { inherit: Boolean(o.inheritGates) });
|
|
832
|
+
deps.log(`npm test ${gateLine(test)}`);
|
|
833
|
+
|
|
834
|
+
const verdict = evaluatePreflight({
|
|
835
|
+
...cheap,
|
|
836
|
+
check: { ok: check.ok, ran: check.ran, failure: check.failure, code: check.code },
|
|
837
|
+
test: { ok: test.ok, ran: test.ran, failure: test.failure, code: test.code },
|
|
838
|
+
});
|
|
839
|
+
const notes = [];
|
|
840
|
+
const dirt = gateDirtNote(worktree.dirty, gitWorktree(deps).dirty);
|
|
841
|
+
if (dirt) {
|
|
842
|
+
notes.push(dirt);
|
|
843
|
+
deps.log(`note: ${dirt}`);
|
|
844
|
+
}
|
|
845
|
+
return { ...verdict, notes, facts: { whoami, registryLatest, worktree, tag, check, test } };
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Stage 2 — publish, then poll the registry through the read lag.
|
|
850
|
+
* @returns {Promise<{ok:boolean, published:boolean, reason:string}>}
|
|
851
|
+
*/
|
|
852
|
+
export async function publish(o) {
|
|
853
|
+
const deps = { ...defaultDeps, ...(o.deps || {}) };
|
|
854
|
+
const { name, version } = o.pkg;
|
|
855
|
+
deps.log(`\n── publish ─────────────────────────────────────────────────`);
|
|
856
|
+
if (o.dryRun) {
|
|
857
|
+
deps.log(`--dry-run: NOT publishing ${name}@${version}. Everything else ran for real.`);
|
|
858
|
+
return { ok: true, published: false, reason: "dry run" };
|
|
859
|
+
}
|
|
860
|
+
const r = deps.exec("npm", ["publish"], { inherit: true });
|
|
861
|
+
if (!r.ok) return { ok: false, published: false, reason: `npm publish failed (exit ${r.code})` };
|
|
862
|
+
deps.log(`published ${name}@${version} — waiting for the registry to serve it`);
|
|
863
|
+
|
|
864
|
+
const maxAttempts = Number.isFinite(o.registryAttempts) ? o.registryAttempts : 10;
|
|
865
|
+
const waitMs = Number.isFinite(o.registryWaitMs) ? o.registryWaitMs : 6000;
|
|
866
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
867
|
+
const read = npmLatest(name, deps);
|
|
868
|
+
const d = readLagDecision({ attempt, maxAttempts, target: version, read });
|
|
869
|
+
deps.log(` registry check ${attempt}/${maxAttempts}: ${d.reason}`);
|
|
870
|
+
if (d.action === "done") return { ok: true, published: true, reason: d.reason };
|
|
871
|
+
if (d.action === "fail") return { ok: false, published: true, reason: d.reason };
|
|
872
|
+
await deps.sleep(waitMs);
|
|
873
|
+
}
|
|
874
|
+
return { ok: false, published: true, reason: "registry never served the new version" };
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* Stage 3 — poll the org until every seat reports `target`, or the deadline
|
|
879
|
+
* passes. Prints the table every round.
|
|
880
|
+
* @returns {Promise<{ok:boolean, summary:object|null, rounds:number}>}
|
|
881
|
+
*/
|
|
882
|
+
export async function verifyPropagation(o) {
|
|
883
|
+
const deps = { ...defaultDeps, ...(o.deps || {}) };
|
|
884
|
+
const target = o.target;
|
|
885
|
+
const started = deps.now();
|
|
886
|
+
const deadlineMs = Number.isFinite(o.deadlineMs) ? o.deadlineMs : 90 * 60 * 1000;
|
|
887
|
+
const intervalMs = Number.isFinite(o.intervalMs) ? o.intervalMs : 60 * 1000;
|
|
888
|
+
deps.log(`\n── verify propagation ──────────────────────────────────────`);
|
|
889
|
+
deps.log(`target ${target} · deadline ${ageLabel(deadlineMs)} · poll every ${ageLabel(intervalMs)}`);
|
|
890
|
+
deps.log(`seats update themselves from npm on their own hourly autoupdater; nothing is pushed from here.`);
|
|
891
|
+
|
|
892
|
+
let round = 0;
|
|
893
|
+
let summary = null;
|
|
894
|
+
let saidWhyUnknown = false;
|
|
895
|
+
for (;;) {
|
|
896
|
+
round += 1;
|
|
897
|
+
const fleet = await (o.readFleet || readFleet)({
|
|
898
|
+
agentRoot: o.agentRoot,
|
|
899
|
+
client: o.client,
|
|
900
|
+
seatWindowMs: o.seatWindowMs,
|
|
901
|
+
});
|
|
902
|
+
if (!fleet.ok) {
|
|
903
|
+
deps.log(`round ${round}: fleet read failed — ${fleet.error}`);
|
|
904
|
+
} else {
|
|
905
|
+
summary = summarise(fleet, target);
|
|
906
|
+
deps.log("");
|
|
907
|
+
deps.log(renderSeatTable(summary, { round, elapsedMs: deps.now() - started }));
|
|
908
|
+
if (!saidWhyUnknown && summary.counts.unverifiable > 0) {
|
|
909
|
+
saidWhyUnknown = true;
|
|
910
|
+
deps.log(
|
|
911
|
+
`\n ${propagationOutcome(summary).note}\n`
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
if (summary.done) {
|
|
915
|
+
deps.log(`\n${renderVerdict(summary)}`);
|
|
916
|
+
return { ok: true, summary, rounds: round };
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
const elapsed = deps.now() - started;
|
|
920
|
+
if (elapsed + intervalMs > deadlineMs) {
|
|
921
|
+
deps.log(`\ndeadline (${ageLabel(deadlineMs)}) reached.`);
|
|
922
|
+
if (summary) deps.log(renderVerdict(summary));
|
|
923
|
+
else deps.log("no successful fleet read before the deadline.");
|
|
924
|
+
return { ok: false, summary, rounds: round };
|
|
925
|
+
}
|
|
926
|
+
await deps.sleep(intervalMs);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// ---------------------------------------------------------------------------
|
|
931
|
+
// CLI
|
|
932
|
+
// ---------------------------------------------------------------------------
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* Flag names that must never be accepted — and whose VALUE must never be
|
|
936
|
+
* echoed. Deliberately wider than the flags this tool knows about: the point is
|
|
937
|
+
* not to enumerate our own options, it is that an operator who types a secret
|
|
938
|
+
* at this CLI must not find it in their scrollback (or in a tee'd log) after the
|
|
939
|
+
* refusal. `--token` and `--registry-token` and `--npmAuthToken` are all the
|
|
940
|
+
* same mistake.
|
|
941
|
+
*/
|
|
942
|
+
const CREDENTIAL_FLAG = /(token|auth|pass|secret|otp|key|credential|cookie|session|bearer|api[-_]?k)/i;
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* The flag name alone. Everything from the first `=` onwards is dropped
|
|
946
|
+
* unread, so no code path downstream of parsing can echo a `--flag=value`
|
|
947
|
+
* value — not the credential branch, not the unknown-argument branch.
|
|
948
|
+
* @param {string} a
|
|
949
|
+
* @returns {string}
|
|
950
|
+
*/
|
|
951
|
+
function flagName(a) {
|
|
952
|
+
const eq = a.indexOf("=");
|
|
953
|
+
return eq === -1 ? a : a.slice(0, eq);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* Parse argv. Pure. Rejects anything that smells like a credential, and never
|
|
958
|
+
* puts an argument's VALUE into an error string — see {@link CREDENTIAL_FLAG}.
|
|
959
|
+
* A bare (non-flag) argument is reported by shape and length only, because a
|
|
960
|
+
* pasted secret most often arrives as one.
|
|
961
|
+
*/
|
|
962
|
+
export function parseArgs(argv) {
|
|
963
|
+
const o = {
|
|
964
|
+
dryRun: false,
|
|
965
|
+
verifyOnly: false,
|
|
966
|
+
deadlineMs: 90 * 60 * 1000,
|
|
967
|
+
intervalMs: 60 * 1000,
|
|
968
|
+
seatWindowMs: DEFAULT_SEAT_WINDOW_MS,
|
|
969
|
+
agentRoot: process.env.AGENT_ROOT || process.env.AGENT_DIR || undefined,
|
|
970
|
+
json: false,
|
|
971
|
+
help: false,
|
|
972
|
+
errors: [],
|
|
973
|
+
};
|
|
974
|
+
for (let i = 0; i < argv.length; i++) {
|
|
975
|
+
const a = argv[i];
|
|
976
|
+
if (a === "--dry-run") o.dryRun = true;
|
|
977
|
+
else if (a === "--verify-only") o.verifyOnly = true;
|
|
978
|
+
else if (a === "--json") o.json = true;
|
|
979
|
+
else if (a === "--help" || a === "-h") o.help = true;
|
|
980
|
+
else if (a === "--deadline") o.deadlineMs = Math.max(0, Number(argv[++i]) * 60 * 1000);
|
|
981
|
+
else if (a === "--interval") o.intervalMs = Math.max(1000, Number(argv[++i]) * 1000);
|
|
982
|
+
else if (a === "--seat-window") o.seatWindowMs = Math.max(0, Number(argv[++i]) * 60 * 1000);
|
|
983
|
+
else if (a === "--agent-root") o.agentRoot = argv[++i];
|
|
984
|
+
else if (a.startsWith("-")) {
|
|
985
|
+
const name = flagName(a);
|
|
986
|
+
if (CREDENTIAL_FLAG.test(name)) {
|
|
987
|
+
o.errors.push(`${name}: this tool never takes a credential as an argument — run \`npm login\` instead.`);
|
|
988
|
+
// `--token secret` (space-separated) would otherwise reach the next
|
|
989
|
+
// iteration as a bare argument. Swallow it so it is never echoed.
|
|
990
|
+
if (a.indexOf("=") === -1 && i + 1 < argv.length && !argv[i + 1].startsWith("-")) i++;
|
|
991
|
+
} else {
|
|
992
|
+
o.errors.push(`unknown argument ${name}`);
|
|
993
|
+
}
|
|
994
|
+
} else {
|
|
995
|
+
o.errors.push(`unknown argument: a bare value of ${a.length} character(s) — every option here starts with \`--\``);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
return o;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
const USAGE = `rollout — publish one SDK version and prove the fleet took it
|
|
1002
|
+
|
|
1003
|
+
node scripts/fleet/rollout.mjs [--dry-run] [--deadline <min>] [--interval <s>]
|
|
1004
|
+
[--seat-window <min>] [--agent-root <path>] [--json]
|
|
1005
|
+
|
|
1006
|
+
--dry-run run every gate for real, skip only the publish itself
|
|
1007
|
+
--verify-only skip pre-flight and publish; just watch the fleet take a version
|
|
1008
|
+
--deadline min how long to wait for the fleet (default 90)
|
|
1009
|
+
--interval s seconds between fleet polls (default 60)
|
|
1010
|
+
--seat-window m a member counts as a seat if it beat within this window (default 30)
|
|
1011
|
+
--json print the verdict as JSON: {code, reason, note, summary}
|
|
1012
|
+
|
|
1013
|
+
Exit: 0 verified · 1 pre-flight refusal · 2 publish/registry failure ·
|
|
1014
|
+
3 deadline passed with seats behind or unverifiable.
|
|
1015
|
+
|
|
1016
|
+
UNVERIFIED IS NOT DONE. A seat that reports no version is never counted as
|
|
1017
|
+
current, so a run can exit 3 with nothing known to be behind. Branch on the
|
|
1018
|
+
--json 'reason' field ("verified" | "behind" | "unverifiable" | "empty-fleet" |
|
|
1019
|
+
"no-fleet-read"), not on the exit code: 'behind' is fixed by waiting,
|
|
1020
|
+
'unverifiable' is a seat whose copied tree predates SDK 2.12 and is not.
|
|
1021
|
+
|
|
1022
|
+
Never ssh's anywhere and never accepts a credential as an argument — a
|
|
1023
|
+
credential-shaped flag is refused and its value is never echoed back.`;
|
|
1024
|
+
|
|
1025
|
+
/** @returns {Promise<number>} process exit code */
|
|
1026
|
+
export async function main(argv = process.argv.slice(2), injected = {}) {
|
|
1027
|
+
const deps = { ...defaultDeps, ...(injected.deps || {}) };
|
|
1028
|
+
const args = parseArgs(argv);
|
|
1029
|
+
if (args.help) {
|
|
1030
|
+
deps.log(USAGE);
|
|
1031
|
+
return 0;
|
|
1032
|
+
}
|
|
1033
|
+
if (args.errors.length) {
|
|
1034
|
+
for (const e of args.errors) deps.log(`rollout: ${e}`);
|
|
1035
|
+
return 1;
|
|
1036
|
+
}
|
|
1037
|
+
const pkg = injected.pkg || readLocalPackage();
|
|
1038
|
+
deps.log(`rollout ${pkg.name}@${pkg.version}${args.dryRun ? " [dry run]" : ""}`);
|
|
1039
|
+
|
|
1040
|
+
const latest = npmLatest(pkg.name, deps);
|
|
1041
|
+
const already = latest.ok && latest.version && compareVersions(latest.version, pkg.version) === 0;
|
|
1042
|
+
|
|
1043
|
+
if (args.verifyOnly) {
|
|
1044
|
+
deps.log(
|
|
1045
|
+
`--verify-only: not publishing. Registry @latest is ${latest.ok ? latest.version : `unreadable (${latest.error})`}; ` +
|
|
1046
|
+
`checking which seats are running ${pkg.version}.`
|
|
1047
|
+
);
|
|
1048
|
+
} else if (already) {
|
|
1049
|
+
deps.log(`already published — registry @latest is ${latest.version}. Verifying propagation.`);
|
|
1050
|
+
} else {
|
|
1051
|
+
const pre = preflight({ pkg, deps, registryLatest: latest, ...injected.preflight });
|
|
1052
|
+
if (!pre.ok) {
|
|
1053
|
+
deps.log(`\nREFUSING TO PUBLISH — ${pre.refusals.length} blocker(s):`);
|
|
1054
|
+
for (const r of pre.refusals) deps.log(` · ${r}`);
|
|
1055
|
+
for (const n of pre.notes || []) deps.log(` (${n})`);
|
|
1056
|
+
deps.log(`\nNothing was published and nothing was changed. Fix the blockers and re-run.`);
|
|
1057
|
+
return 1;
|
|
1058
|
+
}
|
|
1059
|
+
deps.log(`\npre-flight clear.`);
|
|
1060
|
+
const pub = await publish({ pkg, deps, dryRun: args.dryRun, ...injected.publishOpts });
|
|
1061
|
+
if (!pub.ok) {
|
|
1062
|
+
deps.log(`publish stage failed: ${pub.reason}`);
|
|
1063
|
+
return 2;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
const v = await verifyPropagation({
|
|
1068
|
+
target: pkg.version,
|
|
1069
|
+
deps,
|
|
1070
|
+
agentRoot: args.agentRoot,
|
|
1071
|
+
deadlineMs: args.deadlineMs,
|
|
1072
|
+
intervalMs: args.intervalMs,
|
|
1073
|
+
seatWindowMs: args.seatWindowMs,
|
|
1074
|
+
readFleet: injected.readFleet,
|
|
1075
|
+
client: injected.client,
|
|
1076
|
+
});
|
|
1077
|
+
const outcome = propagationOutcome(v.summary);
|
|
1078
|
+
if (outcome.note) deps.log(`\n${outcome.note}`);
|
|
1079
|
+
if (args.json) deps.log(JSON.stringify({ ...outcome, summary: v.summary ?? null }, null, 2));
|
|
1080
|
+
// A failed verify never exits 0, whatever the outcome says.
|
|
1081
|
+
return v.ok ? 0 : outcome.code || 3;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
if (process.argv[1] && resolvePath(process.argv[1]) === resolvePath(__filename)) {
|
|
1085
|
+
main().then(
|
|
1086
|
+
(code) => { process.exitCode = code; },
|
|
1087
|
+
(err) => {
|
|
1088
|
+
process.stderr.write(`rollout failed: ${(err && err.stack) || err}\n`);
|
|
1089
|
+
process.exitCode = 2;
|
|
1090
|
+
}
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
export default { main, preflight, publish, verifyPropagation, readFleet, propagationOutcome, commandRefusal };
|