elanous 0.2.15 → 0.2.17
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/apps/pwa/out/404/index.html +1 -1
- package/apps/pwa/out/404.html +1 -1
- package/apps/pwa/out/_next/static/chunks/{1307-33ffcc49ab45123b.js → 1307-aac7332576c4d6fc.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/{6877-7c6208705c284124.js → 6877-fe94e76a5841ee9b.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/8401-4ce1c42c8d89903f.js +1 -0
- package/apps/pwa/out/_next/static/chunks/899-f41be85e8a680651.js +1 -0
- package/apps/pwa/out/_next/static/chunks/{9103-7a3ae7f60af3b99f.js → 9103-60fbef33e66f0a46.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/9247-269e4464cc4b491c.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/approvals/{page-0e2c57fe6add4b2a.js → page-3143b07a817d1da9.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/autopilot/{page-e05a77df7e005b04.js → page-17ba086ef82d339b.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/board/page-7eb661ff23af7da2.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/botlab/page-6b8444914da74ca6.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/bots/{page-b8c49f6b4fa2df91.js → page-6e5c3a867557298b.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/ceo/page-7110f732f45bf944.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/chat/{page-976813c91d7394ea.js → page-9a41d65355fc25b9.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/consult/page-5e77cd1802567c89.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/control/{page-1d4901eb7c2d8822.js → page-41afec0b255ed2bb.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/design-check/{page-72d3e7ff35a8c954.js → page-4187666132fc98c4.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/editor/{page-60c7a8e1a4fe62ce.js → page-09328d499162bae4.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/exec/{page-2a52b316cb143d9c.js → page-29ff3a8f84f03b6a.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/field/{page-c3ce483c8d5db370.js → page-80ec7401bc83f511.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/inside/page-ab93b240f687d69a.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/intake/{page-96f074f6538e8a2c.js → page-fc7d86467291ae38.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/{layout-e97be5334d83a9f5.js → layout-de43a186e5ba5683.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/live/page-8b08cafad27759c9.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/loops/page-0e2d6985fb42cd2f.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/market/page-13e4108999f58d13.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-e8296ee284aa1293.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/morning/{page-c1343f9499d86f3f.js → page-043a9111e74f6b24.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/{not-found-8739a2e0ae793130.js → not-found-23a4601ceb7f452e.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/observatory/page-1ed1324b1dc36754.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/ops/checklist/{page-d23b12bb92242558.js → page-10d958605a77b3bd.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/ops/release/page-166745e51ac0c7b0.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/ops/seats/{page-c7fea475488201a8.js → page-a04260b8ccc968d4.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/outputs/page-7a1d85e121065cc8.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/page-1f50e3d4e13435f4.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/reflection/{page-5efbf81124c474ae.js → page-12916918988ac97e.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/scheduler/page-d8a3347a9d0c3f4a.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/sessions/page-a9117d2cd3093d36.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/settings/devices/{page-06ee266953d9c879.js → page-5a64cb5208c6a8fd.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/settings/{page-153c2b3bcc89e8de.js → page-1407ec83ea64c1f9.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/setup/done/page-1d824510df025d18.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/setup/page-6e878ebbe1737d00.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/share/{page-0d6d23b40263a7ea.js → page-7f49fe645d5132f7.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/showroom/page-81a4e7f8dbfe74cb.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/tasks/page-27d71f131acd9320.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/term/page-3ab59d19913b200e.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/today/page-bf85b2f5bf077925.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/trace/{page-73735b64bfe1ae67.js → page-424659bacfb8cdd7.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/vault/page-ab32a8f522a60371.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/{page-f7e631e9b4daf66e.js → page-fc2df261f8a2c7df.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/workflows/page-09461366af82ef13.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/workspace/{page-9817c7f9007466cc.js → page-5d4fef9ccd7d406b.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/worktrees/page-ea90a8ac73ce2749.js +1 -0
- package/apps/pwa/out/_next/static/css/001d5fbaee388a25.css +1 -0
- package/apps/pwa/out/_next/static/css/{04dabcbcc4bfa6db.css → 085673d826b98b62.css} +1 -1
- package/apps/pwa/out/approvals/index.html +1 -1
- package/apps/pwa/out/approvals/index.txt +15 -15
- package/apps/pwa/out/autopilot/index.html +1 -1
- package/apps/pwa/out/autopilot/index.txt +15 -15
- package/apps/pwa/out/board/index.html +1 -1
- package/apps/pwa/out/board/index.txt +15 -15
- package/apps/pwa/out/botlab/index.html +1 -1
- package/apps/pwa/out/botlab/index.txt +15 -15
- package/apps/pwa/out/bots/index.html +1 -1
- package/apps/pwa/out/bots/index.txt +15 -15
- package/apps/pwa/out/ceo/index.html +1 -1
- package/apps/pwa/out/ceo/index.txt +15 -15
- package/apps/pwa/out/chat/index.html +1 -1
- package/apps/pwa/out/chat/index.txt +15 -15
- package/apps/pwa/out/consult/index.html +1 -1
- package/apps/pwa/out/consult/index.txt +15 -15
- package/apps/pwa/out/control/index.html +1 -1
- package/apps/pwa/out/control/index.txt +15 -15
- package/apps/pwa/out/design-check/index.html +1 -1
- package/apps/pwa/out/design-check/index.txt +15 -15
- package/apps/pwa/out/editor/index.html +1 -1
- package/apps/pwa/out/editor/index.txt +15 -15
- package/apps/pwa/out/exec/index.html +1 -1
- package/apps/pwa/out/exec/index.txt +15 -15
- package/apps/pwa/out/field/index.html +1 -1
- package/apps/pwa/out/field/index.txt +15 -15
- package/apps/pwa/out/healthz +1 -1
- package/apps/pwa/out/index.html +1 -1
- package/apps/pwa/out/index.txt +15 -15
- package/apps/pwa/out/inside/index.html +1 -1
- package/apps/pwa/out/inside/index.txt +15 -15
- package/apps/pwa/out/intake/index.html +1 -1
- package/apps/pwa/out/intake/index.txt +15 -15
- package/apps/pwa/out/live/index.html +1 -1
- package/apps/pwa/out/live/index.txt +15 -15
- package/apps/pwa/out/loops/index.html +1 -1
- package/apps/pwa/out/loops/index.txt +15 -15
- package/apps/pwa/out/market/index.html +1 -1
- package/apps/pwa/out/market/index.txt +15 -15
- package/apps/pwa/out/missions/placeholder/index.html +1 -1
- package/apps/pwa/out/missions/placeholder/index.txt +15 -15
- package/apps/pwa/out/morning/index.html +1 -1
- package/apps/pwa/out/morning/index.txt +15 -15
- package/apps/pwa/out/observatory/index.html +1 -1
- package/apps/pwa/out/observatory/index.txt +15 -15
- package/apps/pwa/out/ops/checklist/index.html +1 -1
- package/apps/pwa/out/ops/checklist/index.txt +15 -15
- package/apps/pwa/out/ops/release/index.html +1 -1
- package/apps/pwa/out/ops/release/index.txt +15 -15
- package/apps/pwa/out/ops/seats/index.html +1 -1
- package/apps/pwa/out/ops/seats/index.txt +15 -15
- package/apps/pwa/out/outputs/index.html +1 -1
- package/apps/pwa/out/outputs/index.txt +15 -15
- package/apps/pwa/out/reflection/index.html +1 -1
- package/apps/pwa/out/reflection/index.txt +16 -16
- package/apps/pwa/out/scheduler/index.html +1 -1
- package/apps/pwa/out/scheduler/index.txt +15 -15
- package/apps/pwa/out/sessions/index.html +1 -1
- package/apps/pwa/out/sessions/index.txt +15 -15
- package/apps/pwa/out/settings/devices/index.html +1 -1
- package/apps/pwa/out/settings/devices/index.txt +15 -15
- package/apps/pwa/out/settings/index.html +1 -1
- package/apps/pwa/out/settings/index.txt +15 -15
- package/apps/pwa/out/setup/done/index.html +1 -1
- package/apps/pwa/out/setup/done/index.txt +15 -15
- package/apps/pwa/out/setup/index.html +1 -1
- package/apps/pwa/out/setup/index.txt +15 -15
- package/apps/pwa/out/share/index.html +1 -1
- package/apps/pwa/out/share/index.txt +15 -15
- package/apps/pwa/out/showroom/index.html +1 -1
- package/apps/pwa/out/showroom/index.txt +15 -15
- package/apps/pwa/out/tasks/index.html +1 -1
- package/apps/pwa/out/tasks/index.txt +15 -15
- package/apps/pwa/out/term/index.html +1 -1
- package/apps/pwa/out/term/index.txt +15 -15
- package/apps/pwa/out/today/index.html +1 -1
- package/apps/pwa/out/today/index.txt +15 -15
- package/apps/pwa/out/trace/index.html +1 -1
- package/apps/pwa/out/trace/index.txt +15 -15
- package/apps/pwa/out/vault/index.html +1 -1
- package/apps/pwa/out/vault/index.txt +15 -15
- package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
- package/apps/pwa/out/workflows/chat-ui/index.txt +15 -15
- package/apps/pwa/out/workflows/index.html +1 -1
- package/apps/pwa/out/workflows/index.txt +15 -15
- package/apps/pwa/out/workspace/index.html +1 -1
- package/apps/pwa/out/workspace/index.txt +15 -15
- package/apps/pwa/out/worktrees/index.html +1 -1
- package/apps/pwa/out/worktrees/index.txt +15 -15
- package/graphs/doc-gen/doc-gen.yaml +24 -0
- package/graphs/doc-gen/recipes.yaml +3 -0
- package/graphs/intake/intake-daily.yaml +9 -2
- package/graphs/intake/news-sources.yaml +4 -0
- package/graphs/intake/recipes.yaml +6 -1
- package/graphs/orchestrator/orchestrator.yaml +7 -0
- package/graphs/orchestrator/recipes.yaml +3 -0
- package/graphs/steward/recipes.yaml +2 -1
- package/package.json +1 -1
- package/scripts/__fixtures__/intake-news.xml +7 -0
- package/scripts/ci-public-export-import-gate.ts +154 -0
- package/scripts/ci-test-interference-gate.ts +5 -1
- package/scripts/ci-typecheck-changed.ts +34 -15
- package/scripts/docs/doc-rot.ts +164 -0
- package/scripts/docs/generate-graph-anatomy.ts +131 -9
- package/scripts/docs-cli-check.ts +4 -4
- package/scripts/goals/goal-census.ts +343 -0
- package/scripts/intake-news.ts +169 -0
- package/scripts/measure/author-section-contribution.ts +84 -0
- package/scripts/release-loop/cut-branch.ts +43 -13
- package/scripts/release-loop/docs-land-node.ts +69 -10
- package/scripts/release-loop/gate-node.ts +3 -1
- package/scripts/release-loop/publish-node.ts +2 -2
- package/scripts/release-loop/release-docs.ts +134 -0
- package/scripts/release-loop/vault-note-node.ts +19 -2
- package/scripts/release-loop/version-node.ts +7 -4
- package/scripts/release-story/draft.ts +42 -14
- package/scripts/rhythm/monthly.ts +97 -0
- package/src/acp/client.ts +17 -2
- package/src/agent-mission/driver.ts +104 -11
- package/src/agent-mission/supervisor-metrics-cli.ts +40 -0
- package/src/agent-mission/supervisor-metrics.ts +218 -0
- package/src/autopilot/track-agent-shadow.ts +2 -1
- package/src/autopilot/track-agent.ts +5 -3
- package/src/briefing/brief-items.ts +191 -36
- package/src/budget/usage-outcomes.ts +204 -0
- package/src/cli/autopilot-cli.ts +66 -0
- package/src/cli/brief-cli.ts +73 -0
- package/src/cli/decisions-cli.ts +4 -1
- package/src/cli/intake-cli.ts +1 -1
- package/src/cli/know-cli.ts +20 -0
- package/src/cli/log-event-names.ts +1 -0
- package/src/cli/logs-cli.ts +43 -1
- package/src/cli/pr-cli.ts +92 -13
- package/src/cli/provider-cli.ts +14 -0
- package/src/cli/release-cli.ts +11 -8
- package/src/cli/self-update-canary.ts +46 -0
- package/src/cli/self-update.ts +399 -1
- package/src/cli/usage-cli.ts +26 -0
- package/src/context-card/index.ts +45 -0
- package/src/context-card/task.ts +76 -0
- package/src/dashboard/slash-runtime/dashboard-handlers.ts +2 -0
- package/src/decisions/decision-ledger.ts +10 -1
- package/src/discord-self-message.ts +9 -0
- package/src/flow-loop/split.ts +24 -0
- package/src/harness/harness-cli-command.ts +178 -12
- package/src/harness/harness-pod-dispatch.ts +36 -55
- package/src/harness/harness-queue.ts +86 -7
- package/src/harness/harness-seams.ts +13 -3
- package/src/harness/helper-repair.ts +139 -8
- package/src/harness/helper-scan.ts +13 -2
- package/src/harness/nested-elanous-policy.ts +133 -0
- package/src/hq/fence-wrapper.ts +123 -0
- package/src/hq/hq.ts +16 -2
- package/src/hq/lease.ts +5 -3
- package/src/hq/transfer.ts +185 -0
- package/src/index.ts +168 -13
- package/src/intake-plane/digest.ts +85 -28
- package/src/knowledge/know-find.ts +105 -0
- package/src/llm.ts +75 -19
- package/src/loops/orchestrator/finish-rate.ts +14 -3
- package/src/loops/orchestrator/tick.ts +96 -25
- package/src/maturity/bot-slash-catalog.ts +11 -2
- package/src/maturity/feature-maturity.ts +2 -2
- package/src/maturity/tui-slash-availability.ts +9 -0
- package/src/nexus/api/grid.ts +61 -0
- package/src/nexus/api/http-server.ts +19 -1
- package/src/nexus/api/task-cards-api.ts +19 -0
- package/src/oauth/codex.ts +82 -2
- package/src/oauth/overload-failover-launch.ts +75 -0
- package/src/oauth/overload-failover.ts +137 -0
- package/src/release-loop/checklist.ts +10 -5
- package/src/release-loop/feature-store.ts +10 -9
- package/src/release-loop/landing-freeze.ts +18 -10
- package/src/release-loop/placement.ts +17 -8
- package/src/seat-dispatch/seat-ask.ts +33 -6
- package/src/seat-loop/seat-loop.ts +172 -21
- package/src/self-dev/ask-launch-flow.ts +48 -8
- package/src/self-dev/authored-goal-trace.ts +64 -0
- package/src/self-dev/dev-cli.ts +19 -1
- package/src/self-dev/dev-pipeline.ts +6 -5
- package/src/self-dev/entrance-registry.ts +10 -6
- package/src/self-dev/old-door.ts +73 -0
- package/src/self-dev/run-store.ts +5 -1
- package/src/self-dev/run-supervisor.ts +7 -1
- package/src/self-dev/supervisor-verdict-edges.ts +46 -0
- package/src/self-implement/app-api-budget.ts +166 -0
- package/src/self-implement/budget-gate.ts +7 -5
- package/src/self-implement/follow-up-goals.ts +289 -0
- package/src/self-implement/frozen-merges.ts +25 -20
- package/src/self-implement/goal-author-clarification.ts +81 -0
- package/src/self-implement/goal-author-cli.ts +33 -5
- package/src/self-implement/goal-author.ts +107 -25
- package/src/self-implement/goal-clarification-escalation.ts +37 -1
- package/src/self-implement/goal-document-write.ts +28 -0
- package/src/self-implement/heal-intake.ts +4 -0
- package/src/self-implement/heal-triage.ts +44 -0
- package/src/self-implement/host-regate.ts +3 -1
- package/src/self-implement/orchestrator.ts +157 -8
- package/src/self-implement/run-ledger.ts +4 -0
- package/src/self-implement/seams.ts +44 -15
- package/src/session-runtime/index.ts +2 -0
- package/src/session-runtime/retry-api.ts +87 -0
- package/src/session-runtime/retry-policy.ts +107 -6
- package/src/steward/working-backwards.ts +16 -1
- package/src/task-agent/actions.ts +118 -0
- package/src/task-agent/judge.ts +96 -0
- package/src/task-cards/card-cli.ts +25 -0
- package/src/task-cards/card-store.ts +9 -4
- package/src/task-orchestrator/surfaces/pod-failure-reason.ts +33 -4
- package/src/task-orchestrator/surfaces/pod-lease.ts +5 -2
- package/src/task-orchestrator/surfaces/pod-ledger-collect.ts +4 -3
- package/src/task-orchestrator/surfaces/pod-ledger-prod-sink.ts +75 -0
- package/src/task-orchestrator/surfaces/pod-pool.ts +95 -18
- package/src/task-orchestrator/surfaces/pod-source-receive.ts +37 -1
- package/src/task-orchestrator/surfaces/self-implement-pod.ts +168 -37
- package/src/task-orchestrator/surfaces/self-implement.ts +2 -0
- package/src/telegram-commands.ts +6 -1
- package/src/telegram-loops-command.ts +21 -14
- package/src/telegram.ts +47 -0
- package/src/user-config.ts +59 -4
- package/src/version/packed-revision.json +1 -1
- package/apps/pwa/out/_next/static/chunks/8827-e2f5b09a8d37e83b.js +0 -1
- package/apps/pwa/out/_next/static/chunks/899-46946fe8363e1262.js +0 -1
- package/apps/pwa/out/_next/static/chunks/9247-29d889b809288e65.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/board/page-f544338193a9a762.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/botlab/page-5491c115b1f9a923.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/ceo/page-96398e364fbd4548.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/consult/page-28b9448091776c06.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/inside/page-13c7b53e0c2519a9.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/live/page-65e484302921a932.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/loops/page-34d08facaf08f556.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/market/page-955ec2da3be4c560.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-9f88fbc15d9058d1.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/observatory/page-c9fc5706b8abaa11.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/ops/release/page-8d47f45a0dc044ac.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/outputs/page-e2ee2896811e98d0.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/page-83b19dae8619de05.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/scheduler/page-85f1a1fc0a82738c.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/sessions/page-da6c08aae0f92768.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/setup/done/page-87a11e6a05f15199.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/setup/page-7d4182964407c09c.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/showroom/page-7e8c39241f8eb6d0.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/tasks/page-008c1a96618a4887.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/term/page-f5597e894c02cd33.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/today/page-0337fdf7fb5e37b6.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/vault/page-8fcec4c9ea2e7f70.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/workflows/page-4881b8f9f63694b4.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/worktrees/page-97f9f43172a3a90e.js +0 -1
- package/apps/pwa/out/_next/static/css/1f7c958c41ffa3b9.css +0 -1
- package/src/launch-head/seat-goal-queue-lock.fixture.ts +0 -15
- package/src/launch-head/seat-goal-queue.ts +0 -198
- /package/apps/pwa/out/_next/static/{LZcstaO9_NXypPFmujolv → yRA5ur6DcPl0YHSRheH2O}/_buildManifest.js +0 -0
- /package/apps/pwa/out/_next/static/{LZcstaO9_NXypPFmujolv → yRA5ur6DcPl0YHSRheH2O}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { chmodSync, constants, copyFileSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { createPatch } from 'diff';
|
|
6
|
+
|
|
7
|
+
const quote = (value: string): string => `'${value.replaceAll("'", "'\"'\"'")}'`;
|
|
8
|
+
|
|
9
|
+
/** Only the installed entrypoint runs the fence; never use a checkout as a fallback.
|
|
10
|
+
* Arbiter (cloud-vm) outage retains the current lease and forbids promotion;
|
|
11
|
+
* this wrapper does not change the fence's lease or promotion decisions.
|
|
12
|
+
* cron runs with PATH=/usr/bin:/bin, so bun is baked in as an absolute path (OP 10-06 NO-GO). */
|
|
13
|
+
export function renderHqFenceWrapper({ entry, configDir, bun, logFile }: { entry: string; configDir: string; bun: string; logFile?: string }): string {
|
|
14
|
+
if (!isAbsolute(bun)) throw new Error(`hq-fence: bun must be an absolute path: ${bun}`);
|
|
15
|
+
const log = logFile ?? join(configDir, 'logs', 'hq-fence.log');
|
|
16
|
+
return `#!/bin/sh
|
|
17
|
+
# Arbiter (cloud-vm) outage: retain the lease, do not promote; hq fence owns that decision.
|
|
18
|
+
ENTRY=${quote(entry)}
|
|
19
|
+
CONFIG_DIR=${quote(configDir)}
|
|
20
|
+
# cron has no PATH for bun — the absolute path was resolved when this wrapper was installed.
|
|
21
|
+
BUN=${quote(bun)}
|
|
22
|
+
LOG=${quote(log)}
|
|
23
|
+
# Last resort when no alert path works: one line in a log file, no bun needed.
|
|
24
|
+
log_line() {
|
|
25
|
+
mkdir -p "$(dirname "$LOG")" 2>/dev/null
|
|
26
|
+
printf '%s %s\\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" >> "$LOG" 2>/dev/null
|
|
27
|
+
}
|
|
28
|
+
# The alert goes through the same installed entry; PATH \`elanous\` is only a fallback when the entry itself is missing.
|
|
29
|
+
# Alert bun: the installed one first, then usual absolute install locations (still no PATH lookup).
|
|
30
|
+
alert_bun() {
|
|
31
|
+
for b in "$BUN" "$HOME/.bun/bin/bun" /opt/homebrew/bin/bun /usr/local/bin/bun; do
|
|
32
|
+
if [ -x "$b" ]; then echo "$b"; return 0; fi
|
|
33
|
+
done
|
|
34
|
+
return 1
|
|
35
|
+
}
|
|
36
|
+
alert() {
|
|
37
|
+
if [ -f "$ENTRY" ] && AB=$(alert_bun); then
|
|
38
|
+
if "$AB" "$ENTRY" --config-dir "$CONFIG_DIR" hq fence-wrapper alert "$1"; then return 0; fi
|
|
39
|
+
echo "hq-fence: owner alert failed: $1" >&2
|
|
40
|
+
log_line "owner alert failed: $1"
|
|
41
|
+
return 0
|
|
42
|
+
fi
|
|
43
|
+
if command -v elanous >/dev/null 2>&1 && elanous --config-dir "$CONFIG_DIR" hq fence-wrapper alert "$1"; then return 0; fi
|
|
44
|
+
echo "hq-fence: owner alert failed: $1" >&2
|
|
45
|
+
log_line "owner alert failed: $1"
|
|
46
|
+
}
|
|
47
|
+
if [ ! -x "$BUN" ]; then
|
|
48
|
+
echo "hq-fence: bun missing or not executable: $BUN" >&2
|
|
49
|
+
# Exactly one line on disk; the alert is tried only through an absolute fallback bun (never PATH).
|
|
50
|
+
log_line "bun missing or not executable: $BUN (rc=127)"
|
|
51
|
+
if [ -f "$ENTRY" ] && AB=$(alert_bun); then
|
|
52
|
+
"$AB" "$ENTRY" --config-dir "$CONFIG_DIR" hq fence-wrapper alert "hq-fence: bun missing or not executable: $BUN (rc=127)" || echo "hq-fence: owner alert failed (fallback bun $AB)" >&2
|
|
53
|
+
fi
|
|
54
|
+
exit 127
|
|
55
|
+
fi
|
|
56
|
+
if [ ! -f "$ENTRY" ] || [ ! -r "$ENTRY" ]; then
|
|
57
|
+
echo "hq-fence: installed entrypoint missing: $ENTRY" >&2
|
|
58
|
+
alert "hq-fence: installed entrypoint missing: $ENTRY"
|
|
59
|
+
exit 1
|
|
60
|
+
fi
|
|
61
|
+
cd "$(dirname "$ENTRY")/.." || { alert "hq-fence: installed directory missing"; exit 1; }
|
|
62
|
+
if [ $# -lt 1 ] || [ -z "$1" ]; then
|
|
63
|
+
echo "hq-fence: role required" >&2
|
|
64
|
+
alert "hq-fence: role required"
|
|
65
|
+
exit 2
|
|
66
|
+
fi
|
|
67
|
+
ROLE=$1
|
|
68
|
+
shift
|
|
69
|
+
"$BUN" "$ENTRY" --config-dir "$CONFIG_DIR" hq fence --role "$ROLE" -- /bin/sh -c "$*"
|
|
70
|
+
rc=$?
|
|
71
|
+
if [ "$rc" -ne 0 ]; then alert "hq-fence: fence failed (rc=$rc, role=$ROLE)"; fi
|
|
72
|
+
exit "$rc"
|
|
73
|
+
`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Absolute bun for the wrapper: this process when it is bun, else `command -v bun` resolved now (install time). */
|
|
77
|
+
export function resolveHqFenceBun(deps: { isBun?: boolean; execPath?: string; lookup?: () => string } = {}): string {
|
|
78
|
+
const isBun = deps.isBun ?? typeof process.versions.bun === 'string';
|
|
79
|
+
const candidate = isBun
|
|
80
|
+
? (deps.execPath ?? process.execPath)
|
|
81
|
+
: (deps.lookup ?? (() => spawnSync('/bin/sh', ['-c', 'command -v bun'], { encoding: 'utf8' }).stdout ?? ''))().trim();
|
|
82
|
+
if (!candidate || !isAbsolute(candidate)) throw new Error(`hq-fence: cannot resolve an absolute bun path (got ${JSON.stringify(candidate)})`);
|
|
83
|
+
return realpathSync(candidate);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function installedHqFenceEntry(home = homedir()): string {
|
|
87
|
+
return join(home, '.local', 'share', 'elanous', 'current', 'node_modules', 'elanous', 'bin', 'elanous.mjs');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Literal \`cd <path>\` lines only — a worktree path built from a variable is not detected. */
|
|
91
|
+
export function repositoryWorktreeCdWarning(body: string): string | undefined {
|
|
92
|
+
return body.split('\n').some(line => /^\s*cd\s+(?:['"]?[^\s'";]+\/)?(?:wt-[^\s'";]+|[^\s'";]+\/repo\.worktrees\/[^\s'";]+)(?:['"]?\s*(?:&&|\|\||;|#|$))/.test(line))
|
|
93
|
+
? 'hq fence-audit: warning — hq-fence wrapper cd targets a repository worktree' : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function installHqFenceWrapper(configDir: string, body: string, yes: boolean, deps: {
|
|
97
|
+
now?: () => Date;
|
|
98
|
+
} = {}): { path: string; preview: string; backup?: string } {
|
|
99
|
+
const path = join(configDir, 'bin', 'hq-fence');
|
|
100
|
+
let present = false;
|
|
101
|
+
try { present = !!lstatSync(path); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
|
|
102
|
+
if (present && !lstatSync(path).isFile()) throw new Error(`hq-fence: refusing non-regular wrapper: ${path}`);
|
|
103
|
+
const old = present ? readFileSync(path, 'utf8') : undefined;
|
|
104
|
+
const preview = `hq-fence proposed: ${path}\n${body}\n${createPatch(path, old ?? '', body, 'existing', 'proposed')}`;
|
|
105
|
+
if (!yes) return { path, preview };
|
|
106
|
+
let backup: string | undefined;
|
|
107
|
+
if (present) {
|
|
108
|
+
const backups = join(configDir, 'backups');
|
|
109
|
+
mkdirSync(backups, { recursive: true });
|
|
110
|
+
const stamp = (deps.now ?? (() => new Date()))().toISOString().replaceAll(':', '-');
|
|
111
|
+
backup = join(backups, `hq-fence.${stamp}`);
|
|
112
|
+
copyFileSync(path, backup, constants.COPYFILE_EXCL);
|
|
113
|
+
}
|
|
114
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
115
|
+
const tempDir = mkdtempSync(join(dirname(path), '.hq-fence-'));
|
|
116
|
+
try {
|
|
117
|
+
const temp = join(tempDir, 'hq-fence');
|
|
118
|
+
writeFileSync(temp, body, { flag: 'wx', mode: 0o755 });
|
|
119
|
+
chmodSync(temp, 0o755);
|
|
120
|
+
renameSync(temp, path);
|
|
121
|
+
} finally { rmSync(tempDir, { recursive: true, force: true }); }
|
|
122
|
+
return { path, preview, ...(backup ? { backup } : {}) };
|
|
123
|
+
}
|
package/src/hq/hq.ts
CHANGED
|
@@ -205,8 +205,16 @@ function mutate(r: Resolved, decide: (record: LeaseRecord | null, now: number) =
|
|
|
205
205
|
return { ok: false, reason: 'lost the race five times', record: null };
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
+
/** Snapshot for read-only consumers: never advances the seen-generation marker or CASes the arbiter. */
|
|
209
|
+
export function readHqLease(deps: HqDeps = {}) {
|
|
210
|
+
const { store } = resolve(deps);
|
|
211
|
+
const { now, raw } = store.read();
|
|
212
|
+
const record = parseLease(raw);
|
|
213
|
+
return { record, ageSeconds: record ? now - record.renewedAt : null, expired: record ? leaseExpired(record, now) : null };
|
|
214
|
+
}
|
|
215
|
+
|
|
208
216
|
export type LeaseAction = 'acquire' | 'renew' | 'status' | 'release';
|
|
209
|
-
export function hqLease(action: LeaseAction, deps: HqDeps = {}, opts: { host?: string } = {}) {
|
|
217
|
+
export function hqLease(action: LeaseAction, deps: HqDeps = {}, opts: { host?: string; expectedHolder?: string; expectedGeneration?: number } = {}) {
|
|
210
218
|
const r0 = resolve(deps);
|
|
211
219
|
const r = opts.host ? { ...r0, me: opts.host } : r0;
|
|
212
220
|
if (action === 'status') {
|
|
@@ -215,9 +223,15 @@ export function hqLease(action: LeaseAction, deps: HqDeps = {}, opts: { host?: s
|
|
|
215
223
|
noteSeen(r, record);
|
|
216
224
|
return { ok: true as const, action, record, ageSeconds: record ? now - record.renewedAt : null, expired: record ? leaseExpired(record, now) : null };
|
|
217
225
|
}
|
|
226
|
+
if (action === 'release' && ((opts.expectedHolder === undefined) !== (opts.expectedGeneration === undefined)
|
|
227
|
+
|| (opts.expectedGeneration !== undefined && (!Number.isSafeInteger(opts.expectedGeneration) || opts.expectedGeneration < 1))
|
|
228
|
+
|| (opts.expectedHolder !== undefined && !opts.expectedHolder)))
|
|
229
|
+
throw new Error('hq lease release: expected holder and positive generation must be supplied together');
|
|
218
230
|
const local = readLocal(r.localPath);
|
|
231
|
+
const expected = opts.expectedHolder !== undefined && opts.expectedGeneration !== undefined
|
|
232
|
+
? { holder: opts.expectedHolder, generation: opts.expectedGeneration } : undefined;
|
|
219
233
|
const result = mutate(r, (record, now) => action === 'acquire' ? decideAcquire(record, r.me, now, r.ttl)
|
|
220
|
-
: action === 'renew' ? decideRenew(record, r.me, local.generation, now) : decideRelease(record, r.me));
|
|
234
|
+
: action === 'renew' ? decideRenew(record, r.me, local.generation, now) : decideRelease(record, r.me, expected));
|
|
221
235
|
noteSeen(r, result.record);
|
|
222
236
|
if (result.ok && action !== 'release') writeLocal(r.localPath, { holder: result.record.holder, generation: result.record.generation, confirmedAt: r.now(), ttlSeconds: result.record.ttlSeconds });
|
|
223
237
|
if (result.ok && action === 'release') writeLocal(r.localPath, { ...local, holder: undefined, confirmedAt: undefined });
|
package/src/hq/lease.ts
CHANGED
|
@@ -39,8 +39,8 @@ export const leaseExpired = (record: LeaseRecord, now: number): boolean => recor
|
|
|
39
39
|
/** acquire: only when there is no lease or it has expired; a new holder gets the next generation. */
|
|
40
40
|
export function decideAcquire(record: LeaseRecord | null, me: string, now: number, ttlSeconds = DEFAULT_TTL_SECONDS):
|
|
41
41
|
{ ok: true; next: LeaseRecord } | { ok: false; reason: string } {
|
|
42
|
-
if (record && record.holder === me && !leaseExpired(record, now)) return { ok: true, next: { ...record, renewedAt: now, ttlSeconds } };
|
|
43
|
-
if (record && !leaseExpired(record, now)) return { ok: false, reason: `held by ${record.holder} (generation ${record.generation})` };
|
|
42
|
+
if (record && record.renewedAt !== 0 && record.holder === me && !leaseExpired(record, now)) return { ok: true, next: { ...record, renewedAt: now, ttlSeconds } };
|
|
43
|
+
if (record && record.renewedAt !== 0 && !leaseExpired(record, now)) return { ok: false, reason: `held by ${record.holder} (generation ${record.generation})` };
|
|
44
44
|
return { ok: true, next: { holder: me, generation: (record?.generation ?? 0) + 1, acquiredAt: now, renewedAt: now, ttlSeconds, views: {}, dualUnreachableStreak: 0 } };
|
|
45
45
|
}
|
|
46
46
|
|
|
@@ -52,8 +52,10 @@ export function decideRenew(record: LeaseRecord | null, me: string, generation:
|
|
|
52
52
|
return { ok: true, next: { ...record, renewedAt: now, dualUnreachableStreak: 0 } };
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
export function decideRelease(record: LeaseRecord | null, me: string): { ok: true; next: LeaseRecord } | { ok: false; reason: string } {
|
|
55
|
+
export function decideRelease(record: LeaseRecord | null, me: string, expected?: { holder: string; generation: number }): { ok: true; next: LeaseRecord } | { ok: false; reason: string } {
|
|
56
56
|
if (!record || record.holder !== me) return { ok: false, reason: record ? `held by ${record.holder}` : 'no lease' };
|
|
57
|
+
if (expected && (record.holder !== expected.holder || record.generation !== expected.generation))
|
|
58
|
+
return { ok: false, reason: `lease changed: expected ${expected.holder} generation ${expected.generation}, observed ${record.holder} generation ${record.generation}` };
|
|
57
59
|
return { ok: true, next: { ...record, renewedAt: 0 } };
|
|
58
60
|
}
|
|
59
61
|
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, readSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { getElanousConfigDir } from '../elanous-config-dir.js';
|
|
5
|
+
import { getUserConfig } from '../user-config.js';
|
|
6
|
+
import { defaultSshRunner, localShellRunner, parseLease, sshLeaseStore, type LeaseStore, type SshRunner } from './lease.js';
|
|
7
|
+
import { promoteHq, type PromoteResult } from './promote.js';
|
|
8
|
+
|
|
9
|
+
type Direction = 'promote' | 'handback';
|
|
10
|
+
export interface TransferStep { step: string; status: 'done' | 'failed' | 'ready'; measurement: string }
|
|
11
|
+
export interface TransferResult { ok: boolean; direction: Direction; from: string; to: string; runId: string; journal: string | null; steps: TransferStep[] }
|
|
12
|
+
export interface TransferDeps {
|
|
13
|
+
store: LeaseStore;
|
|
14
|
+
run: SshRunner;
|
|
15
|
+
confirm: (question: string) => boolean;
|
|
16
|
+
promote: (host: string) => PromoteResult;
|
|
17
|
+
journalDir: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const aliases: Record<string, string[]> = { mbp: ['mbp', 'MacBookProM5'], node-b: ['node-b', 'MacStudioB1'] };
|
|
21
|
+
const valid = (alias: string, holder: string) => aliases[alias]?.includes(holder) ?? false;
|
|
22
|
+
const leaseCommand = (action: 'release' | 'acquire', expected?: { holder: string; generation: number }) =>
|
|
23
|
+
`elanous hq lease ${action}${expected ? ` --expected-holder '${expected.holder}' --expected-generation ${expected.generation}` : ''} --json`;
|
|
24
|
+
const identityCommand = `python3 - <<'PY'
|
|
25
|
+
import json, pathlib
|
|
26
|
+
home=pathlib.Path.home()
|
|
27
|
+
local=home/'.elanous-hq/host'
|
|
28
|
+
config=home/'.elanous-hqcfg/config.json'
|
|
29
|
+
name=local.read_text().strip() if local.exists() else json.loads(config.read_text())['hq']['hostName']
|
|
30
|
+
print(name)
|
|
31
|
+
PY`;
|
|
32
|
+
const cronCommand = `crontab -l && printf '\\n---HQ-AUDIT---\\n' && elanous hq fence-audit --json`;
|
|
33
|
+
const fenceCommand = `elanous hq fence --role cron -- sh -c 'printf "HQ_FENCE_ALLOWED:%s\\n" "$ELANOUS_HQ_GENERATION"'`;
|
|
34
|
+
|
|
35
|
+
export function defaultTransferDeps(): TransferDeps {
|
|
36
|
+
const arbiter = getUserConfig().hq?.arbiter ?? 'cloud-vm';
|
|
37
|
+
return {
|
|
38
|
+
store: sshLeaseStore(arbiter, arbiter === 'local' ? localShellRunner : defaultSshRunner),
|
|
39
|
+
run: defaultSshRunner,
|
|
40
|
+
confirm: (question) => {
|
|
41
|
+
if (!process.stdin.isTTY) return false;
|
|
42
|
+
process.stderr.write(`${question}\nType TRANSFER to continue: `);
|
|
43
|
+
const buffer = Buffer.alloc(128);
|
|
44
|
+
const count = requireRead(buffer);
|
|
45
|
+
return buffer.toString('utf8', 0, count).trim() === 'TRANSFER';
|
|
46
|
+
},
|
|
47
|
+
promote: (host) => promoteHq(host, true),
|
|
48
|
+
journalDir: join(getElanousConfigDir(), 'hq', 'transfers'),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function requireRead(buffer: Buffer): number {
|
|
53
|
+
// Read one terminal line without treating EOF or a non-interactive caller as consent.
|
|
54
|
+
let size = 0;
|
|
55
|
+
while (size < buffer.length) {
|
|
56
|
+
const n = requireReadByte(buffer, size);
|
|
57
|
+
if (n === 0) break;
|
|
58
|
+
size += n;
|
|
59
|
+
if (buffer[size - 1] === 10) break;
|
|
60
|
+
}
|
|
61
|
+
return size;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function requireReadByte(buffer: Buffer, offset: number): number {
|
|
65
|
+
return readSync(0, buffer, offset, 1, null);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function transferHq(direction: Direction, apply = false, deps: TransferDeps = defaultTransferDeps()): TransferResult {
|
|
69
|
+
const from = direction === 'promote' ? 'mbp' : 'node-b';
|
|
70
|
+
const to = direction === 'promote' ? 'node-b' : 'mbp';
|
|
71
|
+
const runId = randomUUID();
|
|
72
|
+
const journal = apply ? join(deps.journalDir, `${runId}.jsonl`) : null;
|
|
73
|
+
const steps: TransferStep[] = [];
|
|
74
|
+
const result = (ok: boolean): TransferResult => ({ ok, direction, from, to, runId, journal, steps });
|
|
75
|
+
const record = (step: string, status: TransferStep['status'], measurement: string) => {
|
|
76
|
+
const row = { step, status, measurement };
|
|
77
|
+
steps.push(row);
|
|
78
|
+
if (journal) {
|
|
79
|
+
mkdirSync(deps.journalDir, { recursive: true, mode: 0o700 });
|
|
80
|
+
appendFileSync(journal, `${JSON.stringify({ at: new Date().toISOString(), runId, direction, from, to, ...row })}\n`, { mode: 0o600 });
|
|
81
|
+
}
|
|
82
|
+
return status !== 'failed';
|
|
83
|
+
};
|
|
84
|
+
const lease = () => {
|
|
85
|
+
const { now, raw } = deps.store.read();
|
|
86
|
+
const current = parseLease(raw);
|
|
87
|
+
return { current, now };
|
|
88
|
+
};
|
|
89
|
+
const remote = (host: string, command: string) => {
|
|
90
|
+
const r = deps.run(host, command);
|
|
91
|
+
if (r.status !== 0) throw new Error(`${host}: ${r.stderr.trim() || r.stdout.trim() || `exit=${r.status}`}`);
|
|
92
|
+
return r.stdout.trim();
|
|
93
|
+
};
|
|
94
|
+
try {
|
|
95
|
+
const before = lease();
|
|
96
|
+
if (!before.current || !valid(from, before.current.holder) || before.current.renewedAt + before.current.ttlSeconds < before.now) {
|
|
97
|
+
record('① current lease', 'failed', `holder=${before.current?.holder ?? 'none'} generation=${before.current?.generation ?? 'none'} expired=${before.current ? before.current.renewedAt + before.current.ttlSeconds < before.now : true}`);
|
|
98
|
+
return result(false);
|
|
99
|
+
}
|
|
100
|
+
const source = remote(from, identityCommand);
|
|
101
|
+
const target = remote(to, identityCommand);
|
|
102
|
+
if (source !== before.current.holder || !valid(to, target) || source === target) {
|
|
103
|
+
record('② host identities', 'failed', `source=${source} holder=${before.current.holder} target=${target}`);
|
|
104
|
+
return result(false);
|
|
105
|
+
}
|
|
106
|
+
record('① current lease', 'done', `holder=${source} generation=${before.current.generation}`);
|
|
107
|
+
record('② host identities', 'done', `source=${source} target=${target}`);
|
|
108
|
+
for (const host of [from, to]) {
|
|
109
|
+
const inventory = remote(host, cronCommand);
|
|
110
|
+
const marker = '\n---HQ-AUDIT---\n';
|
|
111
|
+
const index = inventory.lastIndexOf(marker);
|
|
112
|
+
if (index < 0) throw new Error(`${host}: missing cron audit`);
|
|
113
|
+
const cron = inventory.slice(0, index);
|
|
114
|
+
const audit = JSON.parse(inventory.slice(index + marker.length)) as unknown;
|
|
115
|
+
const live = cron.split('\n').filter(line => line.trim() && !line.trimStart().startsWith('#'));
|
|
116
|
+
if (!Array.isArray(audit) || audit.length > 0 || !live.some(line => /hq heartbeat\b/.test(line)) || !live.some(line => /hq fence --role cron\b/.test(line))) {
|
|
117
|
+
record(`③ cron fence ${host}`, 'failed', `unfenced=${Array.isArray(audit) ? audit.length : 'invalid'} heartbeat=${live.some(line => /hq heartbeat\b/.test(line))} fenced=${live.some(line => /hq fence --role cron\b/.test(line))}`);
|
|
118
|
+
return result(false);
|
|
119
|
+
}
|
|
120
|
+
record(`③ cron fence ${host}`, 'done', `unfenced=0 heartbeat=true fenced=true`);
|
|
121
|
+
}
|
|
122
|
+
const oldFence = remote(from, fenceCommand);
|
|
123
|
+
const newFence = remote(to, fenceCommand);
|
|
124
|
+
if (oldFence !== `HQ_FENCE_ALLOWED:${before.current.generation}` || newFence.includes('HQ_FENCE_ALLOWED:')) {
|
|
125
|
+
record('④ pre-transfer fence', 'failed', `source=${oldFence} target=${newFence}`);
|
|
126
|
+
return result(false);
|
|
127
|
+
}
|
|
128
|
+
record('④ pre-transfer fence', 'done', `source=allowed generation=${before.current.generation} target=skipped`);
|
|
129
|
+
if (!apply) {
|
|
130
|
+
record('⑤ human confirmation', 'ready', 'dry-run: no writes; --apply requires interactive TRANSFER confirmation');
|
|
131
|
+
return result(true);
|
|
132
|
+
}
|
|
133
|
+
if (!deps.confirm(`Release ${source} generation ${before.current.generation}, acquire ${target}, and switch cron fence? This cannot undo work already sent.`)) {
|
|
134
|
+
record('⑤ human confirmation', 'failed', 'confirmation declined');
|
|
135
|
+
return result(false);
|
|
136
|
+
}
|
|
137
|
+
record('⑤ human confirmation', 'done', 'operator typed TRANSFER');
|
|
138
|
+
const still = lease().current;
|
|
139
|
+
if (!still || still.holder !== source || still.generation !== before.current.generation || still.renewedAt + still.ttlSeconds < deps.store.read().now) {
|
|
140
|
+
record('⑥ release old lease', 'failed', 'lease changed before release');
|
|
141
|
+
return result(false);
|
|
142
|
+
}
|
|
143
|
+
let released: { ok?: boolean; record?: { holder?: string; generation?: number } };
|
|
144
|
+
try { released = JSON.parse(remote(from, leaseCommand('release', { holder: source, generation: before.current.generation }))); }
|
|
145
|
+
catch (error) {
|
|
146
|
+
record('⑥ release old lease', 'failed', error instanceof Error ? error.message : String(error));
|
|
147
|
+
return result(false);
|
|
148
|
+
}
|
|
149
|
+
if (released.ok !== true || released.record?.holder !== source || released.record.generation !== before.current.generation) {
|
|
150
|
+
record('⑥ release old lease', 'failed', 'release not confirmed');
|
|
151
|
+
return result(false);
|
|
152
|
+
}
|
|
153
|
+
record('⑥ release old lease', 'done', `holder=${source} generation=${released.record.generation} released=true`);
|
|
154
|
+
const acquired = JSON.parse(remote(to, leaseCommand('acquire'))) as { ok?: boolean; record?: { holder?: string; generation?: number } };
|
|
155
|
+
const current = lease().current;
|
|
156
|
+
if (acquired.ok !== true || acquired.record?.holder !== target || acquired.record.generation !== before.current.generation + 1 || current?.holder !== target || current.generation !== acquired.record.generation) {
|
|
157
|
+
record('⑦ acquire new lease', 'failed', `expected=${target} generation=${before.current.generation + 1} observed=${current?.holder ?? 'none'} generation=${current?.generation ?? 'none'}; keep old host fenced, recover manually`);
|
|
158
|
+
return result(false);
|
|
159
|
+
}
|
|
160
|
+
record('⑦ acquire new lease', 'done', `holder=${target} generation=${current.generation}`);
|
|
161
|
+
if (direction === 'promote') {
|
|
162
|
+
const promoted = deps.promote(to);
|
|
163
|
+
for (const line of promoted.lines) record(`⑧ promotion ${line.step}`, line.status, line.measurement);
|
|
164
|
+
if (!promoted.ok) return result(false);
|
|
165
|
+
}
|
|
166
|
+
const sourceAfter = remote(from, fenceCommand);
|
|
167
|
+
const targetAfter = remote(to, fenceCommand);
|
|
168
|
+
const observed = lease().current;
|
|
169
|
+
if (sourceAfter.includes('HQ_FENCE_ALLOWED:') || targetAfter !== `HQ_FENCE_ALLOWED:${current.generation}` || observed?.holder !== target || observed.generation !== current.generation) {
|
|
170
|
+
record('⑨ final fence', 'failed', `source=${sourceAfter} target=${targetAfter} lease=${observed?.holder ?? 'none'} generation=${observed?.generation ?? 'none'}`);
|
|
171
|
+
return result(false);
|
|
172
|
+
}
|
|
173
|
+
record('⑨ final fence', 'done', `source=skipped target=allowed generation=${current.generation}`);
|
|
174
|
+
return result(true);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
record('halt', 'failed', error instanceof Error ? error.message : String(error));
|
|
177
|
+
return result(false);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function formatTransfer(result: TransferResult): string {
|
|
182
|
+
return [`hq ${result.direction}: ${result.ok ? 'ok' : 'failed'} · ${result.from} → ${result.to} · run=${result.runId}`,
|
|
183
|
+
...result.steps.map(s => `${s.step}: ${s.status} · ${s.measurement}`),
|
|
184
|
+
...(result.journal ? [`journal=${result.journal}`] : [])].join('\n');
|
|
185
|
+
}
|