elanous 0.2.12 → 0.2.13
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/{1394-ee3795b112907122.js → 1394-931c9827067b2276.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/{3992-2e522a8ac5f2afde.js → 3992-fe5c06d6b31f8b25.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/5250-c78ca295585bbf95.js +1 -0
- package/apps/pwa/out/_next/static/chunks/6511-ff8360be3d65aaa4.js +1 -0
- package/apps/pwa/out/_next/static/chunks/6588-ba380869b67c4fa7.js +1 -0
- package/apps/pwa/out/_next/static/chunks/{7758-0b018a9e4fc56200.js → 7758-85c2b53f731da024.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/{8605-0259994816604c49.js → 8605-9da4678f8a310d18.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/9906-e7b8b5310a169dfe.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/approvals/{page-cb169620945d1952.js → page-e5b4e1b256f41076.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/autopilot/{page-cefe8169e36cf62a.js → page-e49b9b76c565a0b1.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/board/page-b4f032c893b21cc9.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/botlab/page-f14987d5850a31e0.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/bots/page-b433308aad13ceb6.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/chat/{page-813c4a52c9d05ca8.js → page-4c1dbb21861beab2.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/consult/{page-4731f2c215358937.js → page-c85b816e3b269dbb.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/control/{page-a022b257950f39e6.js → page-f6bc66b919b2ea7d.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/design-check/{page-a2ffc717e04b8b0a.js → page-60603c1e056c9b25.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/editor/{page-b59ee4e70fc18fcf.js → page-a0ea8f5a2341ee6b.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/exec/page-45c5bf04b4a605e6.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/field/{page-c172dabd796eb7a3.js → page-36c6c4183353ac9e.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/inside/{page-1efd0cb9e0ed9d13.js → page-cb2eb0fd809980da.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/intake/{page-d5dc7ff69a37b4cf.js → page-31012264da8a341f.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/layout-50309aeb0bdd554a.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/live/page-bc00e964e855f4df.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/market/{page-dfca9b7d2de0b135.js → page-0696ee3fac9fe56c.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/missions/[id]/{page-793fa536b3a6ef37.js → page-66c27608f651b866.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/morning/{page-6dd3bfbbde2a5c2c.js → page-6ddd2109b7fc3a80.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/not-found-4e50b7298c1f69a5.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/observatory/{page-77447356baad75d2.js → page-7a2bf830038a2264.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-1f327f18694fabca.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/ops/release/page-e5cef2fe6e4028df.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-cf596128d2b420ba.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/outputs/{page-6d604227e1664b71.js → page-f672536f14f0969f.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/page-b52365662232db89.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/reflection/{page-4048543b4b7ad5e0.js → page-e7a6c80f288b1948.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/scheduler/{page-4c3a3126abed551a.js → page-29a709616df9b050.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/sessions/{page-64b0aecb1ca75e75.js → page-fee842d7c2151129.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/settings/devices/page-78b40a344ed22f24.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/settings/page-75d199f448171014.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/setup/done/page-a011dd5b0bff29c5.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/setup/{page-9547b5cec7cf76bf.js → page-d92e32d51ab5157e.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/share/page-5f21aed6932ef375.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/showroom/page-1e1a53946804debf.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/tasks/{page-2cddeb9e2aeedc55.js → page-a1833cd43d832601.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/term/{page-e297866051595d81.js → page-63d14a4d8fe1e1c1.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/today/{page-baba2e047872fb0f.js → page-2a511d320f762e06.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/trace/{page-e895da4ec9cac345.js → page-1c3e03f88dac1e21.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/vault/page-c4d5289c2ea97a89.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-b91f8952deaeaf27.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/workflows/page-71b43a2588489987.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/workspace/{page-2e9e2fb01c9da1c9.js → page-2b256feed331546f.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/worktrees/{page-d62ceb13411ebba4.js → page-505bb96d69a8622c.js} +1 -1
- package/apps/pwa/out/approvals/index.html +1 -1
- package/apps/pwa/out/approvals/index.txt +13 -13
- package/apps/pwa/out/autopilot/index.html +1 -1
- package/apps/pwa/out/autopilot/index.txt +13 -13
- package/apps/pwa/out/board/index.html +1 -1
- package/apps/pwa/out/board/index.txt +13 -13
- package/apps/pwa/out/botlab/index.html +1 -1
- package/apps/pwa/out/botlab/index.txt +13 -13
- package/apps/pwa/out/bots/index.html +1 -1
- package/apps/pwa/out/bots/index.txt +13 -13
- package/apps/pwa/out/chat/index.html +1 -1
- package/apps/pwa/out/chat/index.txt +13 -13
- package/apps/pwa/out/consult/index.html +1 -1
- package/apps/pwa/out/consult/index.txt +13 -13
- package/apps/pwa/out/control/index.html +1 -1
- package/apps/pwa/out/control/index.txt +13 -13
- package/apps/pwa/out/design-check/index.html +1 -1
- package/apps/pwa/out/design-check/index.txt +13 -13
- package/apps/pwa/out/editor/index.html +1 -1
- package/apps/pwa/out/editor/index.txt +13 -13
- package/apps/pwa/out/exec/index.html +1 -1
- package/apps/pwa/out/exec/index.txt +13 -13
- package/apps/pwa/out/field/index.html +1 -1
- package/apps/pwa/out/field/index.txt +13 -13
- package/apps/pwa/out/healthz +1 -1
- package/apps/pwa/out/index.html +1 -1
- package/apps/pwa/out/index.txt +13 -13
- package/apps/pwa/out/inside/index.html +1 -1
- package/apps/pwa/out/inside/index.txt +13 -13
- package/apps/pwa/out/intake/index.html +1 -1
- package/apps/pwa/out/intake/index.txt +13 -13
- package/apps/pwa/out/live/index.html +1 -1
- package/apps/pwa/out/live/index.txt +13 -13
- package/apps/pwa/out/market/index.html +1 -1
- package/apps/pwa/out/market/index.txt +13 -13
- package/apps/pwa/out/missions/placeholder/index.html +1 -1
- package/apps/pwa/out/missions/placeholder/index.txt +13 -13
- package/apps/pwa/out/morning/index.html +1 -1
- package/apps/pwa/out/morning/index.txt +13 -13
- package/apps/pwa/out/observatory/index.html +1 -1
- package/apps/pwa/out/observatory/index.txt +13 -13
- package/apps/pwa/out/ops/checklist/index.html +1 -1
- package/apps/pwa/out/ops/checklist/index.txt +13 -13
- package/apps/pwa/out/ops/release/index.html +1 -1
- package/apps/pwa/out/ops/release/index.txt +13 -13
- package/apps/pwa/out/ops/seats/index.html +1 -1
- package/apps/pwa/out/ops/seats/index.txt +13 -13
- package/apps/pwa/out/outputs/index.html +1 -1
- package/apps/pwa/out/outputs/index.txt +13 -13
- package/apps/pwa/out/reflection/index.html +1 -1
- package/apps/pwa/out/reflection/index.txt +14 -14
- package/apps/pwa/out/scheduler/index.html +1 -1
- package/apps/pwa/out/scheduler/index.txt +13 -13
- package/apps/pwa/out/sessions/index.html +1 -1
- package/apps/pwa/out/sessions/index.txt +13 -13
- package/apps/pwa/out/settings/devices/index.html +1 -1
- package/apps/pwa/out/settings/devices/index.txt +13 -13
- package/apps/pwa/out/settings/index.html +1 -1
- package/apps/pwa/out/settings/index.txt +13 -13
- package/apps/pwa/out/setup/done/index.html +1 -1
- package/apps/pwa/out/setup/done/index.txt +13 -13
- package/apps/pwa/out/setup/index.html +1 -1
- package/apps/pwa/out/setup/index.txt +13 -13
- package/apps/pwa/out/share/index.html +1 -1
- package/apps/pwa/out/share/index.txt +13 -13
- package/apps/pwa/out/showroom/index.html +1 -1
- package/apps/pwa/out/showroom/index.txt +13 -13
- package/apps/pwa/out/tasks/index.html +1 -1
- package/apps/pwa/out/tasks/index.txt +13 -13
- package/apps/pwa/out/term/index.html +1 -1
- package/apps/pwa/out/term/index.txt +13 -13
- package/apps/pwa/out/today/index.html +1 -1
- package/apps/pwa/out/today/index.txt +13 -13
- package/apps/pwa/out/trace/index.html +1 -1
- package/apps/pwa/out/trace/index.txt +13 -13
- package/apps/pwa/out/vault/index.html +1 -1
- package/apps/pwa/out/vault/index.txt +13 -13
- package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
- package/apps/pwa/out/workflows/chat-ui/index.txt +13 -13
- package/apps/pwa/out/workflows/index.html +1 -1
- package/apps/pwa/out/workflows/index.txt +13 -13
- package/apps/pwa/out/workspace/index.html +1 -1
- package/apps/pwa/out/workspace/index.txt +13 -13
- package/apps/pwa/out/worktrees/index.html +1 -1
- package/apps/pwa/out/worktrees/index.txt +13 -13
- package/examples/teams/personas/backend.yaml +4 -0
- package/examples/teams/personas/pm.yaml +4 -0
- package/examples/teams/personas/qa.yaml +4 -0
- package/examples/teams/small-product.yaml +20 -0
- package/graphs/lecture/lecture-note.yaml +28 -0
- package/graphs/lecture/recipes.yaml +17 -0
- package/package.json +2 -1
- package/scripts/context-hooks/emit.ts +36 -14
- package/scripts/coord-channel-watch.sh +1 -0
- package/scripts/discord-seat-probe.ts +144 -0
- package/scripts/hq/hq-standby.sh +65 -0
- package/scripts/hq/standby-promote.ts +173 -0
- package/scripts/hq/standby-role.ts +59 -0
- package/scripts/hq/standby-snapshot.ts +226 -0
- package/scripts/hq/standby-verify.ts +219 -0
- package/scripts/lecture-note/node.ts +256 -0
- package/scripts/release-loop/gate-node.ts +53 -15
- package/scripts/release-loop/ops-upgrade-node.ts +17 -5
- package/scripts/test-deterministic.ts +53 -3
- package/skills/omni-crawl/SKILL.md +16 -16
- package/skills/omni-crawl/package-lock.json +1 -544
- package/skills/omni-crawl/package.json +3 -4
- package/skills/omni-crawl/scripts/main.ts +13 -13
- package/skills/omni-crawl/scripts/monitor.ts +8 -8
- package/skills/omni-crawl/src/apify.ts +13 -2
- package/skills/omni-crawl/src/capture.ts +6 -0
- package/skills/omni-crawl/src/registry.ts +2 -2
- package/skills/omni-crawl/src/stdout-json.ts +4 -0
- package/skills/omni-digest/SKILL.md +10 -11
- package/skills/omni-digest/package-lock.json +0 -545
- package/skills/omni-digest/package.json +2 -5
- package/skills/omni-digest/scripts/main.ts +3 -3
- package/skills/omni-digest/src/delegate.ts +5 -5
- package/skills/omni-digest/src/fetch.ts +2 -2
- package/skills/omni-digest/src/media.ts +25 -11
- package/skills/omni-digest/src/visual.ts +3 -2
- package/skills/omni-market/scripts/main.ts +1 -1
- package/skills/omni-market/scripts/stdout-json.ts +4 -0
- package/src/agent/identity-env.ts +11 -0
- package/src/agent-mission/driver.ts +12 -7
- package/src/agent-mission/duo.ts +94 -0
- package/src/agent-mission/pty-decision.ts +1 -1
- package/src/autopilot/track-agent-shadow.ts +2 -1
- package/src/autopilot/track-agent.ts +4 -1
- package/src/boot/daemon-prompt-request.ts +3 -0
- package/src/boot/daemon-prompt-turn.ts +6 -2
- package/src/boot/daemon-runtime.ts +48 -16
- package/src/cli/decisions-cli.ts +51 -19
- package/src/cli/outputs-cli.ts +92 -0
- package/src/cli/persona-cli.ts +29 -0
- package/src/cli/pod-cli.ts +10 -3
- package/src/cli/pr-cli.ts +15 -1
- package/src/cli/release-cli.ts +108 -28
- package/src/context-bus/condense.ts +194 -0
- package/src/context-bus/context-day.ts +82 -0
- package/src/context-bus/context-now-surfaces.ts +15 -2
- package/src/context-bus/long-term-memory.ts +118 -22
- package/src/context-bus/nightly-condense.ts +1 -1
- package/src/decisions/decision-cards.ts +13 -7
- package/src/decisions/decision-ledger.ts +43 -17
- package/src/graph-kinds/registry.ts +2 -2
- package/src/graph-runner/graph-approval-card.ts +2 -2
- package/src/graph-runner/graph-grow.ts +64 -11
- package/src/graph-runner/graph-tick.ts +9 -2
- package/src/graph-runner/runner.ts +81 -17
- package/src/harness/harness-cli-command.ts +27 -1
- package/src/harness/harness-queue-child.ts +37 -0
- package/src/harness/harness-queue.ts +306 -0
- package/src/hooks/github-shadow.ts +72 -2
- package/src/hooks/receiver.ts +4 -2
- package/src/hq/hq.ts +318 -0
- package/src/hq/lease.ts +210 -0
- package/src/hq/ledger-write-target.ts +41 -0
- package/src/index.ts +199 -7
- package/src/intake-plane/discord-seat-work.ts +13 -6
- package/src/intake-plane/linear-wish.ts +44 -0
- package/src/intake-plane/persona-answer.ts +5 -0
- package/src/intake-plane/telegram-seat-work.ts +13 -6
- package/src/intake-plane/wish-card.ts +1 -1
- package/src/loops/checker.ts +178 -0
- package/src/loops/loop-cli.ts +37 -3
- package/src/loops/observe.ts +49 -0
- package/src/loops/orchestrator/overlap.ts +141 -0
- package/src/loops/orchestrator/traffic.ts +150 -0
- package/src/loops/package/verify.ts +125 -0
- package/src/loops/verdict.ts +22 -0
- package/src/maturity/feature-maturity.ts +3 -3
- package/src/memory.ts +37 -4
- package/src/nexus/api/card-followup-route.ts +118 -0
- package/src/nexus/api/decisions-route.ts +68 -0
- package/src/nexus/api/http-server.ts +47 -0
- package/src/nexus/api/personas.ts +2 -1
- package/src/nexus/api/skill-exec-route.ts +97 -0
- package/src/persona/loader.ts +18 -0
- package/src/persona/persona-todo.ts +14 -9
- package/src/persona/presets.ts +2 -1
- package/src/persona/team.ts +105 -0
- package/src/persona/types.ts +4 -0
- package/src/pod-lease/host-lease.ts +130 -20
- package/src/release-loop/landed-but-yellow.ts +12 -0
- package/src/seat-dispatch/seat-ask.ts +57 -12
- package/src/seat-loop/seat-loop.ts +90 -16
- package/src/seat-loop/seat-pack.ts +127 -0
- package/src/self-dev/ask-launch-flow.ts +3 -0
- package/src/self-implement/l8-merge-queue.ts +157 -1
- package/src/steward/launch.ts +5 -1
- package/src/steward/readiness.ts +125 -0
- package/src/steward/rescue.ts +75 -0
- package/src/steward/triage.ts +48 -9
- package/src/task-cards/card-cli.ts +78 -0
- package/src/task-orchestrator/surfaces/pod-lease.ts +18 -6
- package/src/task-orchestrator/surfaces/pod-pool.ts +27 -10
- package/src/task-orchestrator/surfaces/self-implement-pod.ts +38 -8
- package/src/telegram-dispatch-forward.ts +1 -1
- package/src/telegram-fabric-plan.ts +90 -0
- package/src/telegram-project-command.ts +10 -4
- package/src/telegram.ts +5 -0
- package/src/tool-runtime/elanous-control-runtimes.ts +2 -1
- package/src/user-config.ts +94 -10
- package/src/version/packed-revision.json +1 -1
- package/src/workflow-runtime/executor.ts +4 -0
- package/src/workflow-runtime/node-catalog.ts +13 -0
- package/src/workflow-runtime/nodes/knowledge.ts +38 -0
- package/src/workflow-runtime/schema.ts +17 -1
- package/src/workflow-runtime/types.ts +13 -1
- package/apps/pwa/out/_next/static/chunks/1147-466ec15afacc1a65.js +0 -1
- package/apps/pwa/out/_next/static/chunks/7277-0b2fc7c16ba2d64b.js +0 -1
- package/apps/pwa/out/_next/static/chunks/9906-482c6619b3fdda20.js +0 -1
- package/apps/pwa/out/_next/static/chunks/9920-5cfdc5e8fd209825.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/board/page-d2e35228bc497119.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/botlab/page-5c05b0da4bc1392c.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/bots/page-e0ccdb576812b523.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/exec/page-a67e11ba962337b5.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/layout-e71c84189e8c683e.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/live/page-1d6a533ce5a6efcb.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/not-found-490653e8d4cc8852.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/ops/checklist/page-29e961191f6057df.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/ops/release/page-c822f55b884d9104.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-fa4ccfc432424e52.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/page-9c7e8f744a765daf.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/settings/devices/page-4292d0149020e8a7.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/settings/page-db14d0be7f1c460c.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/setup/done/page-e5d8072cd7cfef73.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/share/page-e6dcb83265530636.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/showroom/page-45248f08d6aecae5.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/vault/page-4344faf20216233b.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-698bb29f11864b89.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/workflows/page-bd6a6dc1ddc273f2.js +0 -1
- /package/apps/pwa/out/_next/static/{-6XrAoD3eU7NyMHAE4xPS → D51l8jUBplJrIpWvENsDK}/_buildManifest.js +0 -0
- /package/apps/pwa/out/_next/static/{-6XrAoD3eU7NyMHAE4xPS → D51l8jUBplJrIpWvENsDK}/_ssgManifest.js +0 -0
package/src/hq/hq.ts
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// HQ lease operations — CLI-facing glue over the pure decisions in ./lease.ts (HQ-HB · HQ-FENCE · 10-04).
|
|
2
|
+
// Every outcome leaves one observation line (hq.lease / hq.fence) so the 12:00 drill is measurable from logs.
|
|
3
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { homedir, hostname } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { debug } from '../debug/log.js';
|
|
8
|
+
import { DEFAULT_NEXUS_HTTP_PORT } from '../nexus/default-port.js';
|
|
9
|
+
import { getElanousConfigDir } from '../elanous-config-dir.js';
|
|
10
|
+
import { getUserConfig, type HqConfig, type HqFenceRole } from '../user-config.js';
|
|
11
|
+
import {
|
|
12
|
+
DEFAULT_TTL_SECONDS, decideAcquire, decideArbiterCheck, decideFence, decideRelease, decideRenew, defaultSshRunner,
|
|
13
|
+
fileLeaseStore, leaseExpired, parseLease, probeReachable, recordView, serializeLease, sshLeaseStore, sshReachable, localShellRunner,
|
|
14
|
+
type HostProbe, type LeaseRecord, type LeaseStore, type LocalHqState, type SshRunner,
|
|
15
|
+
} from './lease.js';
|
|
16
|
+
|
|
17
|
+
export const FENCE_ROLES: readonly HqFenceRole[] = ['telegram-poller', 'cron', 'seat-loop', 'release-run', 'conatus', 'git-push', 'ledger-cli'];
|
|
18
|
+
|
|
19
|
+
export interface HqDeps {
|
|
20
|
+
config?: HqConfig;
|
|
21
|
+
store?: LeaseStore;
|
|
22
|
+
ssh?: SshRunner;
|
|
23
|
+
/** Holder/standby views and the arbiter's check use this tailnet probe — never ssh (OP 10-04 09:04). */
|
|
24
|
+
probe?: HostProbe;
|
|
25
|
+
localPath?: string;
|
|
26
|
+
/** Host-local identity, deliberately outside the replicated config/state directory. */
|
|
27
|
+
hostPath?: string;
|
|
28
|
+
/** Seen-generation marker (default hq.seenGenerationFile · ~/.elanous-hq/seen-generation). */
|
|
29
|
+
seenPath?: string;
|
|
30
|
+
now?: () => number;
|
|
31
|
+
log?: typeof debug.log;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface Resolved { me: string; arbiter: string; standby: string; ttl: number; failOpen: Set<HqFenceRole>; store: LeaseStore; ssh: SshRunner; probe: HostProbe; localPath: string; seenPath: string; now: () => number; log: typeof debug.log }
|
|
35
|
+
const defaultHostPath = () => join(homedir(), '.elanous-hq', 'host');
|
|
36
|
+
|
|
37
|
+
function readHost(path: string): string | undefined {
|
|
38
|
+
try {
|
|
39
|
+
lstatSync(path);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
const text = readFileSync(path, 'utf8');
|
|
45
|
+
const name = text.endsWith('\n') ? text.slice(0, -1) : text;
|
|
46
|
+
if (!name || name !== name.trim() || /[\r\n]/.test(name)) {
|
|
47
|
+
throw new Error(`hq host: invalid host file: ${path}`);
|
|
48
|
+
}
|
|
49
|
+
return name;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Set this machine's identity outside the replicable config.json and HQ state universe. */
|
|
53
|
+
export function hqHostSet(name: string, path = defaultHostPath()): string {
|
|
54
|
+
if (!name || name !== name.trim() || /[\r\n]/.test(name)) throw new Error('hq host set: name must be one nonempty line without surrounding whitespace');
|
|
55
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
56
|
+
const tempDir = mkdtempSync(`${path}.`);
|
|
57
|
+
try {
|
|
58
|
+
chmodSync(tempDir, 0o700);
|
|
59
|
+
const tmp = join(tempDir, 'host');
|
|
60
|
+
writeFileSync(tmp, `${name}\n`, { flag: 'wx', mode: 0o600 });
|
|
61
|
+
chmodSync(tmp, 0o600);
|
|
62
|
+
renameSync(tmp, path);
|
|
63
|
+
} finally {
|
|
64
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
65
|
+
}
|
|
66
|
+
return path;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function resolve(deps: HqDeps = {}): Resolved {
|
|
70
|
+
const config = deps.config ?? getUserConfig().hq ?? {};
|
|
71
|
+
const ssh = deps.ssh ?? defaultSshRunner;
|
|
72
|
+
const arbiter = config.arbiter ?? 'cloud-vm';
|
|
73
|
+
const local = readHost(deps.hostPath ?? defaultHostPath());
|
|
74
|
+
const me = local ?? config.hostName ?? hostname().replace(/\.local$/, '');
|
|
75
|
+
const log = deps.log ?? debug.log.bind(debug);
|
|
76
|
+
if (local && config.hostName && local !== config.hostName) {
|
|
77
|
+
try { log('hq.lease', 'hostname-mismatch', { local, config: config.hostName }); } catch { /* observation is fail-soft */ }
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
me,
|
|
81
|
+
arbiter,
|
|
82
|
+
standby: config.standby ?? 'node-b',
|
|
83
|
+
ttl: config.ttlSeconds ?? DEFAULT_TTL_SECONDS,
|
|
84
|
+
failOpen: new Set(config.failOpenRoles ?? []),
|
|
85
|
+
store: deps.store ?? sshLeaseStore(arbiter, arbiter === 'local' ? localShellRunner : ssh),
|
|
86
|
+
ssh,
|
|
87
|
+
probe: deps.probe ?? tailnetProbe(config),
|
|
88
|
+
localPath: deps.localPath ?? join(getElanousConfigDir(), 'hq', 'local.json'),
|
|
89
|
+
seenPath: deps.seenPath ?? config.seenGenerationFile ?? (deps.localPath ? `${deps.localPath}.seen-generation` : join(homedir(), '.elanous-hq', 'seen-generation')),
|
|
90
|
+
now: deps.now ?? (() => Math.floor(Date.now() / 1000)),
|
|
91
|
+
log,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** MagicDNS suffix from `tailscale status --json` (cached per process). */
|
|
96
|
+
let dnsSuffix: string | null | undefined;
|
|
97
|
+
function magicDnsSuffix(bin: string): string | undefined {
|
|
98
|
+
if (dnsSuffix !== undefined) return dnsSuffix ?? undefined;
|
|
99
|
+
const r = spawnSync(bin, ['status', '--json'], { encoding: 'utf8', timeout: 10_000 });
|
|
100
|
+
try { dnsSuffix = (JSON.parse(r.stdout || '{}') as { MagicDNSSuffix?: string }).MagicDNSSuffix?.replace(/\.$/, '') || null; } catch { dnsSuffix = null; }
|
|
101
|
+
return dnsSuffix ?? undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Health URL of a remote host's nexus over the tailnet — its `tailscale serve` TLS mapping of the default nexus port
|
|
105
|
+
* (resolveDaemonEndpoint only knows this machine's daemon; the arbiter probes another host). */
|
|
106
|
+
export function healthUrlFor(host: string, config: HqConfig, suffix: () => string | undefined): string | undefined {
|
|
107
|
+
const pinned = config.healthUrls?.[host];
|
|
108
|
+
if (pinned) return pinned;
|
|
109
|
+
const domain = config.tailnetDomain ?? suffix();
|
|
110
|
+
return domain ? `https://${host}.${domain}:${DEFAULT_NEXUS_HTTP_PORT}/v1/health` : undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Default probe: `tailscale ping -c 1` ⊕ curl of the nexus health (200 only). Both are tailnet-only, no ssh. */
|
|
114
|
+
export function tailnetProbe(config: HqConfig): HostProbe {
|
|
115
|
+
const bin = config.tailscaleBin ?? 'tailscale';
|
|
116
|
+
const timeout = config.probeTimeoutSeconds ?? 5;
|
|
117
|
+
return {
|
|
118
|
+
ping(host) {
|
|
119
|
+
const r = spawnSync(bin, ['ping', '-c', '1', '--timeout', `${timeout}s`, host], { encoding: 'utf8', timeout: (timeout + 5) * 1000 });
|
|
120
|
+
return r.status === 0 && /\bpong\b/.test(r.stdout ?? '');
|
|
121
|
+
},
|
|
122
|
+
health(host) {
|
|
123
|
+
const url = healthUrlFor(host, config, () => magicDnsSuffix(bin));
|
|
124
|
+
if (!url) return false;
|
|
125
|
+
const r = spawnSync('curl', ['-s', '-o', '/dev/null', '-m', String(timeout), '-w', '%{http_code}', url], { encoding: 'utf8', timeout: (timeout + 5) * 1000 });
|
|
126
|
+
return (r.stdout ?? '').trim() === '200';
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function readLocal(path: string): LocalHqState {
|
|
132
|
+
try { return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) as LocalHqState : {}; } catch { return {}; }
|
|
133
|
+
}
|
|
134
|
+
function writeLocal(path: string, state: LocalHqState): void {
|
|
135
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
136
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
137
|
+
writeFileSync(tmp, `${JSON.stringify(state)}\n`, { mode: 0o600 });
|
|
138
|
+
renameSync(tmp, path);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Highest lease generation this host ever observed (null = never). */
|
|
142
|
+
export function readSeenGeneration(path: string): number | null {
|
|
143
|
+
try {
|
|
144
|
+
if (!existsSync(path)) return null;
|
|
145
|
+
const n = Number(readFileSync(path, 'utf8').trim());
|
|
146
|
+
return Number.isSafeInteger(n) && n > 0 ? n : null;
|
|
147
|
+
} catch { return null; }
|
|
148
|
+
}
|
|
149
|
+
/** Keep the max generation seen (atomic · 600). Fail-soft: a lost write never blocks the lease operation. */
|
|
150
|
+
function noteSeen(r: Resolved, record: LeaseRecord | null | undefined): void {
|
|
151
|
+
if (!record || !Number.isSafeInteger(record.generation) || record.generation < 1) return;
|
|
152
|
+
try {
|
|
153
|
+
const prev = readSeenGeneration(r.seenPath);
|
|
154
|
+
if (prev !== null && prev >= record.generation) return;
|
|
155
|
+
mkdirSync(dirname(r.seenPath), { recursive: true, mode: 0o700 });
|
|
156
|
+
const tmp = `${r.seenPath}.${process.pid}.tmp`;
|
|
157
|
+
writeFileSync(tmp, `${record.generation}\n`, { mode: 0o600 });
|
|
158
|
+
renameSync(tmp, r.seenPath);
|
|
159
|
+
} catch (error) { observe(r, 'hq.lease', 'seen-write-failed', { error: String(error).slice(0, 200) }); }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** `elanous hq seen` — wrappers use «never seen» (exit 3) to allow the pre-drill default-source path only. */
|
|
163
|
+
export function hqSeen(deps: HqDeps = {}): { seen: boolean; generation: number | null; path: string } {
|
|
164
|
+
const r = resolve(deps);
|
|
165
|
+
const generation = readSeenGeneration(r.seenPath);
|
|
166
|
+
return { seen: generation !== null, generation, path: r.seenPath };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function observe(r: Resolved, category: 'hq.lease' | 'hq.fence', event: string, data: Record<string, unknown>): void {
|
|
170
|
+
try { r.log(category, event, { host: r.me, ...data }); } catch { /* observation is fail-soft */ }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Read → decide → CAS, retried on a lost race (two hosts racing cannot both win: only one CAS sees the old bytes). */
|
|
174
|
+
function mutate(r: Resolved, decide: (record: LeaseRecord | null, now: number) => { ok: true; next: LeaseRecord } | { ok: false; reason: string }):
|
|
175
|
+
{ ok: true; record: LeaseRecord } | { ok: false; reason: string; record: LeaseRecord | null } {
|
|
176
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
177
|
+
const { now, raw } = r.store.read();
|
|
178
|
+
const record = parseLease(raw);
|
|
179
|
+
const decided = decide(record, now);
|
|
180
|
+
if (!decided.ok) return { ok: false, reason: decided.reason, record };
|
|
181
|
+
if (r.store.cas(raw, serializeLease(decided.next))) return { ok: true, record: decided.next };
|
|
182
|
+
}
|
|
183
|
+
return { ok: false, reason: 'lost the race five times', record: null };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export type LeaseAction = 'acquire' | 'renew' | 'status' | 'release';
|
|
187
|
+
export function hqLease(action: LeaseAction, deps: HqDeps = {}, opts: { host?: string } = {}) {
|
|
188
|
+
const r0 = resolve(deps);
|
|
189
|
+
const r = opts.host ? { ...r0, me: opts.host } : r0;
|
|
190
|
+
if (action === 'status') {
|
|
191
|
+
const { now, raw } = r.store.read();
|
|
192
|
+
const record = parseLease(raw);
|
|
193
|
+
noteSeen(r, record);
|
|
194
|
+
return { ok: true as const, action, record, ageSeconds: record ? now - record.renewedAt : null, expired: record ? leaseExpired(record, now) : null };
|
|
195
|
+
}
|
|
196
|
+
const local = readLocal(r.localPath);
|
|
197
|
+
const result = mutate(r, (record, now) => action === 'acquire' ? decideAcquire(record, r.me, now, r.ttl)
|
|
198
|
+
: action === 'renew' ? decideRenew(record, r.me, local.generation, now) : decideRelease(record, r.me));
|
|
199
|
+
noteSeen(r, result.record);
|
|
200
|
+
if (result.ok && action !== 'release') writeLocal(r.localPath, { holder: result.record.holder, generation: result.record.generation, confirmedAt: r.now(), ttlSeconds: result.record.ttlSeconds });
|
|
201
|
+
if (result.ok && action === 'release') writeLocal(r.localPath, { ...local, holder: undefined, confirmedAt: undefined });
|
|
202
|
+
observe(r, 'hq.lease', result.ok ? (action === 'acquire' ? 'acquired' : action === 'renew' ? 'renewed' : 'released') : `${action}-refused`,
|
|
203
|
+
result.ok ? { generation: result.record.generation } : { reason: result.reason });
|
|
204
|
+
return { action, ...result };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Heartbeat (every 10 min on mbp and node-b). Holder → renew at the arbiter, or (arbiter down) confirm quorum through the standby.
|
|
209
|
+
* Non-holder → report its view of the holder to the arbiter (rule ②) and adopt a generation the arbiter granted it.
|
|
210
|
+
*/
|
|
211
|
+
export function hqHeartbeat(deps: HqDeps = {}) {
|
|
212
|
+
const r = resolve(deps);
|
|
213
|
+
const local = readLocal(r.localPath);
|
|
214
|
+
let read: { now: number; raw: string | null };
|
|
215
|
+
try { read = r.store.read(); } catch (error) {
|
|
216
|
+
const standbyUp = local.holder === r.me ? sshReachable(r.standby, r.ssh) : false;
|
|
217
|
+
if (local.holder === r.me && standbyUp) writeLocal(r.localPath, { ...local, confirmedAt: r.now() });
|
|
218
|
+
const outcome = local.holder === r.me ? (standbyUp ? 'quorum-standby' : 'arbiter-unreachable') : 'arbiter-unreachable';
|
|
219
|
+
observe(r, 'hq.lease', outcome, { error: String(error).slice(0, 200), standbyReachable: standbyUp });
|
|
220
|
+
return { outcome };
|
|
221
|
+
}
|
|
222
|
+
const record = parseLease(read.raw);
|
|
223
|
+
noteSeen(r, record);
|
|
224
|
+
if (!record) { observe(r, 'hq.lease', 'no-lease', { seenGeneration: readSeenGeneration(r.seenPath) }); return { outcome: 'no-lease' as const }; }
|
|
225
|
+
if (record.holder === r.me) {
|
|
226
|
+
// The record names me as holder: a generation the arbiter granted me (promotion) is adopted, never refused.
|
|
227
|
+
const renewed = mutate(r, (current, now) => decideRenew(current, r.me, Math.max(local.generation ?? 0, current?.generation ?? 0), now));
|
|
228
|
+
if (renewed.ok && (local.generation ?? 0) < renewed.record.generation) observe(r, 'hq.lease', 'generation-adopted', { from: local.generation ?? null, to: renewed.record.generation });
|
|
229
|
+
if (renewed.ok) {
|
|
230
|
+
noteSeen(r, renewed.record);
|
|
231
|
+
writeLocal(r.localPath, { holder: r.me, generation: renewed.record.generation, confirmedAt: r.now(), ttlSeconds: renewed.record.ttlSeconds });
|
|
232
|
+
observe(r, 'hq.lease', 'renewed', { generation: renewed.record.generation });
|
|
233
|
+
return { outcome: 'renewed' as const, generation: renewed.record.generation };
|
|
234
|
+
}
|
|
235
|
+
observe(r, 'hq.lease', 'renew-refused', { reason: renewed.reason });
|
|
236
|
+
return { outcome: 'renew-refused' as const, reason: renewed.reason };
|
|
237
|
+
}
|
|
238
|
+
// Not the holder: report the view, keep the granted generation, stay standby.
|
|
239
|
+
const seen = probeReachable(record.holder, r.probe);
|
|
240
|
+
const reachable = seen.reachable;
|
|
241
|
+
mutate(r, (current) => current ? { ok: true, next: recordView(current, r.me, reachable, read.now) } : { ok: false, reason: 'no lease' });
|
|
242
|
+
if (leaseExpired(record, read.now)) observe(r, 'hq.lease', 'expired-seen', { holder: record.holder, generation: record.generation });
|
|
243
|
+
writeLocal(r.localPath, { holder: record.holder, generation: record.generation, ttlSeconds: record.ttlSeconds });
|
|
244
|
+
observe(r, 'hq.lease', 'standby', { holder: record.holder, generation: record.generation, holderReachable: reachable, ping: seen.ping, health: seen.health });
|
|
245
|
+
return { outcome: 'standby' as const, holder: record.holder, holderReachable: reachable };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Arbiter check (cron on cloud-vm every 10 min, next to the file): probe the holder over the tailnet
|
|
250
|
+
* (`tailscale ping` ⊕ nexus health · no ssh into home machines) and promote per rule ②.
|
|
251
|
+
*/
|
|
252
|
+
export function hqArbiterCheck(deps: HqDeps & { leasePath?: string } = {}) {
|
|
253
|
+
const r0 = resolve(deps);
|
|
254
|
+
const r = deps.store ? r0 : { ...r0, store: fileLeaseStore(deps.leasePath ?? join(process.env.HOME ?? '', '.elanous-hq', 'lease.json')) };
|
|
255
|
+
let outcome: { promoted: boolean; streak: number; holder?: string; generation?: number; ping?: boolean; health?: boolean } = { promoted: false, streak: 0 };
|
|
256
|
+
const result = mutate(r, (record, now) => {
|
|
257
|
+
if (!record) return { ok: false, reason: 'no lease' };
|
|
258
|
+
const seen = probeReachable(record.holder, r.probe);
|
|
259
|
+
const checked = decideArbiterCheck(record, { arbiter: r.me, standby: r.standby, arbiterReachesHolder: seen.reachable, now });
|
|
260
|
+
outcome = { promoted: checked.promoted, streak: checked.streak, holder: checked.next.holder, generation: checked.next.generation, ping: seen.ping, health: seen.health };
|
|
261
|
+
return { ok: true, next: checked.next };
|
|
262
|
+
});
|
|
263
|
+
observe(r, 'hq.lease', outcome.promoted ? 'promoted' : 'arbiter-check', { ...outcome, ok: result.ok });
|
|
264
|
+
return { ok: result.ok, ...outcome };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Standby's own generation, asked over ssh (rule ③ when the arbiter is down). */
|
|
268
|
+
function standbyGeneration(r: Resolved): number | undefined {
|
|
269
|
+
const res = r.ssh(r.standby, 'cat "$HOME/.elanous/hq/local.json" 2>/dev/null || true');
|
|
270
|
+
try { const g = (JSON.parse(res.stdout || '{}') as LocalHqState).generation; return typeof g === 'number' ? g : undefined; } catch { return undefined; }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function hqFenceDecision(role: HqFenceRole, deps: HqDeps = {}) {
|
|
274
|
+
const r = resolve(deps);
|
|
275
|
+
const local = readLocal(r.localPath);
|
|
276
|
+
let record: LeaseRecord | null | 'unreachable';
|
|
277
|
+
try { record = parseLease(r.store.read().raw); } catch { record = 'unreachable'; }
|
|
278
|
+
if (record !== 'unreachable') noteSeen(r, record);
|
|
279
|
+
const seenGeneration = readSeenGeneration(r.seenPath);
|
|
280
|
+
const standbyUp = record === 'unreachable' && local.holder === r.me ? sshReachable(r.standby, r.ssh) : undefined;
|
|
281
|
+
const decided = decideFence({
|
|
282
|
+
me: r.me, now: r.now(), local, record,
|
|
283
|
+
...(standbyUp !== undefined ? { standbyReachable: standbyUp } : {}),
|
|
284
|
+
...(standbyUp ? { standbyGeneration: standbyGeneration(r) } : {}),
|
|
285
|
+
failOpen: role !== 'ledger-cli' && r.failOpen.has(role),
|
|
286
|
+
...(seenGeneration !== null ? { seenGeneration } : {}),
|
|
287
|
+
});
|
|
288
|
+
if (decided.run && record !== 'unreachable' && record) writeLocal(r.localPath, { holder: r.me, generation: record.generation, confirmedAt: r.now(), ttlSeconds: record.ttlSeconds });
|
|
289
|
+
observe(r, 'hq.fence', decided.run ? 'allowed' : 'skipped', { role, reason: decided.reason, holder: decided.holder, generation: decided.generation });
|
|
290
|
+
return decided;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** CLI ledger writes fail closed after the first lease; bootstrap is allowed only before any generation was seen. */
|
|
294
|
+
export function hqCliWriteAllowed(command: string, override = false, deps: HqDeps = {}): boolean {
|
|
295
|
+
const decision = hqFenceDecision('ledger-cli', deps);
|
|
296
|
+
const allowed = decision.run || decision.reason === 'no-lease';
|
|
297
|
+
const r = resolve(deps);
|
|
298
|
+
if (override) {
|
|
299
|
+
observe(r, 'hq.fence', 'cli-override', { command, reason: decision.reason, holder: decision.holder, generation: decision.generation, allowed });
|
|
300
|
+
console.error(`hq override: ${command} — ${decision.reason} (holder ${decision.holder ?? 'unknown'} gen ${decision.generation ?? 'unknown'})`);
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
if (allowed) return true;
|
|
304
|
+
observe(r, 'hq.fence', 'cli-refused', { command, reason: decision.reason, holder: decision.holder, generation: decision.generation });
|
|
305
|
+
console.error(`본부는 ${decision.holder ?? 'unknown'} gen ${decision.generation ?? 'unknown'} — 거기서 쓰거나 --hq-override(관측)`);
|
|
306
|
+
process.exitCode = 4;
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Run `command` only when this host may write; the child sees ELANOUS_HQ_GENERATION. Skips exit 0. */
|
|
311
|
+
export function hqFenceRun(role: HqFenceRole, command: string[], deps: HqDeps = {}): number {
|
|
312
|
+
const decided = hqFenceDecision(role, deps);
|
|
313
|
+
if (!decided.run) { console.error(`hq fence: skip ${role} — ${decided.reason}${decided.holder ? ` (holder ${decided.holder} gen ${decided.generation})` : ''}`); return 0; }
|
|
314
|
+
const [cmd, ...args] = command;
|
|
315
|
+
if (!cmd) throw new Error('hq fence: command required after --');
|
|
316
|
+
const r = spawnSync(cmd, args, { stdio: 'inherit', env: { ...process.env, ELANOUS_HQ_GENERATION: String(decided.generation ?? '') } });
|
|
317
|
+
return r.status ?? 1;
|
|
318
|
+
}
|
package/src/hq/lease.ts
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// HQ lease (본부 임대) — single writer across mbp · node-b · cloud-vm with a 2-of-3 quorum (OP 10-04 08:33 · 08:40).
|
|
2
|
+
// The arbiter (cloud-vm) keeps one record; only the arbiter promotes the standby, and only after two consecutive
|
|
3
|
+
// checks in which neither the arbiter nor the standby can reach the holder. Fenced work carries the generation.
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmdirSync, statSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { dirname } from 'node:path';
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_TTL_SECONDS = 1500;
|
|
10
|
+
/** A standby view older than this is not evidence (two heartbeats at the 10-minute cadence). */
|
|
11
|
+
export const VIEW_FRESH_SECONDS = 1500;
|
|
12
|
+
/** Consecutive dual-unreachable checks before the arbiter promotes the standby. */
|
|
13
|
+
export const PROMOTE_AFTER_CHECKS = 2;
|
|
14
|
+
|
|
15
|
+
export interface HostView { target: string; reachable: boolean; at: number }
|
|
16
|
+
export interface LeaseRecord {
|
|
17
|
+
holder: string;
|
|
18
|
+
generation: number;
|
|
19
|
+
acquiredAt: number;
|
|
20
|
+
renewedAt: number;
|
|
21
|
+
ttlSeconds: number;
|
|
22
|
+
/** Each observer's latest view of the holder (arbiter and standby). */
|
|
23
|
+
views?: Record<string, HostView>;
|
|
24
|
+
/** Consecutive arbiter checks in which both arbiter and standby could not reach the holder. */
|
|
25
|
+
dualUnreachableStreak?: number;
|
|
26
|
+
lastCheckAt?: number;
|
|
27
|
+
promotedFrom?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function parseLease(raw: string | null): LeaseRecord | null {
|
|
31
|
+
if (!raw || !raw.trim()) return null;
|
|
32
|
+
const data = JSON.parse(raw) as LeaseRecord;
|
|
33
|
+
if (typeof data.holder !== 'string' || !Number.isSafeInteger(data.generation) || typeof data.renewedAt !== 'number') throw new Error('invalid lease record');
|
|
34
|
+
return data;
|
|
35
|
+
}
|
|
36
|
+
export const serializeLease = (record: LeaseRecord): string => `${JSON.stringify(record)}\n`;
|
|
37
|
+
export const leaseExpired = (record: LeaseRecord, now: number): boolean => record.renewedAt + record.ttlSeconds < now;
|
|
38
|
+
|
|
39
|
+
/** acquire: only when there is no lease or it has expired; a new holder gets the next generation. */
|
|
40
|
+
export function decideAcquire(record: LeaseRecord | null, me: string, now: number, ttlSeconds = DEFAULT_TTL_SECONDS):
|
|
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})` };
|
|
44
|
+
return { ok: true, next: { holder: me, generation: (record?.generation ?? 0) + 1, acquiredAt: now, renewedAt: now, ttlSeconds, views: {}, dualUnreachableStreak: 0 } };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function decideRenew(record: LeaseRecord | null, me: string, generation: number | undefined, now: number):
|
|
48
|
+
{ ok: true; next: LeaseRecord } | { ok: false; reason: string } {
|
|
49
|
+
if (!record) return { ok: false, reason: 'no lease' };
|
|
50
|
+
if (record.holder !== me) return { ok: false, reason: `held by ${record.holder} (generation ${record.generation})` };
|
|
51
|
+
if (generation !== undefined && generation < record.generation) return { ok: false, reason: `stale generation ${generation} < ${record.generation}` };
|
|
52
|
+
return { ok: true, next: { ...record, renewedAt: now, dualUnreachableStreak: 0 } };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function decideRelease(record: LeaseRecord | null, me: string): { ok: true; next: LeaseRecord } | { ok: false; reason: string } {
|
|
56
|
+
if (!record || record.holder !== me) return { ok: false, reason: record ? `held by ${record.holder}` : 'no lease' };
|
|
57
|
+
return { ok: true, next: { ...record, renewedAt: 0 } };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The standby reports whether it can reach the holder (rule ②: both views go into the record). */
|
|
61
|
+
export function recordView(record: LeaseRecord, observer: string, reachable: boolean, now: number): LeaseRecord {
|
|
62
|
+
return { ...record, views: { ...(record.views ?? {}), [observer]: { target: record.holder, reachable, at: now } } };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Arbiter check (rule ②). Records the arbiter's own view; counts a dual-unreachable check only when the standby's
|
|
67
|
+
* fresh view of the same holder also says unreachable; promotes the standby after PROMOTE_AFTER_CHECKS in a row.
|
|
68
|
+
*/
|
|
69
|
+
export function decideArbiterCheck(record: LeaseRecord, input: { arbiter: string; standby: string; arbiterReachesHolder: boolean; now: number }):
|
|
70
|
+
{ next: LeaseRecord; promoted: boolean; streak: number } {
|
|
71
|
+
let next = recordView(record, input.arbiter, input.arbiterReachesHolder, input.now);
|
|
72
|
+
const view = next.views?.[input.standby];
|
|
73
|
+
const standbyDown = !!view && view.target === record.holder && !view.reachable && input.now - view.at <= VIEW_FRESH_SECONDS;
|
|
74
|
+
const dual = !input.arbiterReachesHolder && standbyDown && record.holder !== input.standby;
|
|
75
|
+
const streak = dual ? (record.dualUnreachableStreak ?? 0) + 1 : 0;
|
|
76
|
+
next = { ...next, dualUnreachableStreak: streak, lastCheckAt: input.now };
|
|
77
|
+
if (streak < PROMOTE_AFTER_CHECKS) return { next, promoted: false, streak };
|
|
78
|
+
return {
|
|
79
|
+
next: { holder: input.standby, generation: record.generation + 1, acquiredAt: input.now, renewedAt: input.now, ttlSeconds: record.ttlSeconds, views: {}, dualUnreachableStreak: 0, lastCheckAt: input.now, promotedFrom: record.holder },
|
|
80
|
+
promoted: true, streak,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** What this host remembers between runs: its last confirmed generation and when quorum last confirmed it. */
|
|
85
|
+
export interface LocalHqState { holder?: string; generation?: number; confirmedAt?: number; ttlSeconds?: number }
|
|
86
|
+
|
|
87
|
+
export type FenceReason = 'holder' | 'quorum-standby' | 'within-ttl' | 'not-holder' | 'stale-generation' | 'no-quorum-expired' | 'no-lease' | 'no-lease-after-seen' | 'fail-open';
|
|
88
|
+
/**
|
|
89
|
+
* Fence (rules ① · ③). Arbiter reachable → run only as the current holder at the current generation.
|
|
90
|
+
* Arbiter unreachable → the holder keeps running while it reaches the standby or its own confirmation is within TTL;
|
|
91
|
+
* a standby that reports a higher generation always blocks (a returning old HQ stays standby).
|
|
92
|
+
*/
|
|
93
|
+
export function decideFence(input: {
|
|
94
|
+
me: string; now: number; local: LocalHqState;
|
|
95
|
+
record: LeaseRecord | null | 'unreachable';
|
|
96
|
+
standbyReachable?: boolean; standbyGeneration?: number; failOpen?: boolean;
|
|
97
|
+
/** Highest generation this host ever saw — a vanished record after that is «unknown», not «no lease yet». */
|
|
98
|
+
seenGeneration?: number;
|
|
99
|
+
}): { run: boolean; reason: FenceReason; generation?: number; holder?: string } {
|
|
100
|
+
const { me, now, local, record } = input;
|
|
101
|
+
if (record !== 'unreachable') {
|
|
102
|
+
if (!record) return { run: false, reason: input.seenGeneration !== undefined ? 'no-lease-after-seen' : 'no-lease', ...(input.seenGeneration !== undefined ? { generation: input.seenGeneration } : {}) };
|
|
103
|
+
if (record.holder !== me) return { run: false, reason: 'not-holder', holder: record.holder, generation: record.generation };
|
|
104
|
+
if (local.generation !== undefined && local.generation > record.generation) return { run: false, reason: 'stale-generation', holder: record.holder, generation: record.generation };
|
|
105
|
+
return { run: true, reason: 'holder', generation: record.generation, holder: me };
|
|
106
|
+
}
|
|
107
|
+
if (local.holder !== me || local.generation === undefined) return { run: false, reason: 'not-holder', holder: local.holder, generation: local.generation };
|
|
108
|
+
if (input.standbyGeneration !== undefined && input.standbyGeneration > local.generation) return { run: false, reason: 'stale-generation', generation: input.standbyGeneration };
|
|
109
|
+
if (input.standbyReachable) return { run: true, reason: 'quorum-standby', generation: local.generation, holder: me };
|
|
110
|
+
const ttl = local.ttlSeconds ?? DEFAULT_TTL_SECONDS;
|
|
111
|
+
if (local.confirmedAt !== undefined && now - local.confirmedAt <= ttl) return { run: true, reason: 'within-ttl', generation: local.generation, holder: me };
|
|
112
|
+
if (input.failOpen) return { run: true, reason: 'fail-open', generation: local.generation, holder: me };
|
|
113
|
+
return { run: false, reason: 'no-quorum-expired', generation: local.generation, holder: me };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── stores ────────────────────────────────────────────────────────────────────
|
|
117
|
+
export interface LeaseStore {
|
|
118
|
+
/** null raw = no record · throws when the store cannot be reached. */
|
|
119
|
+
read(): { now: number; raw: string | null };
|
|
120
|
+
/** Compare-and-swap on the raw bytes; false when someone else wrote first. */
|
|
121
|
+
cas(expected: string | null, next: string): boolean;
|
|
122
|
+
}
|
|
123
|
+
export const sha = (raw: string | null): string => raw === null ? 'none' : createHash('sha256').update(raw).digest('hex');
|
|
124
|
+
|
|
125
|
+
/** Arbiter-local store (the arbiter's own check runs next to the file). mkdir is the lock. */
|
|
126
|
+
export function fileLeaseStore(path: string, clock: () => number = () => Math.floor(Date.now() / 1000)): LeaseStore {
|
|
127
|
+
const lock = `${path}.lock`;
|
|
128
|
+
return {
|
|
129
|
+
read: () => ({ now: clock(), raw: existsSync(path) ? readFileSync(path, 'utf8') : null }),
|
|
130
|
+
cas(expected, next) {
|
|
131
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
132
|
+
for (let i = 0; ; i++) {
|
|
133
|
+
try { mkdirSync(lock); break; } catch {
|
|
134
|
+
try { if (Date.now() - statSync(lock).mtimeMs > 30_000) { rmdirSync(lock); continue; } } catch { /* raced */ }
|
|
135
|
+
if (i > 100) throw new Error('lease lock busy');
|
|
136
|
+
Bun.sleepSync(50);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
const current = existsSync(path) ? readFileSync(path, 'utf8') : null;
|
|
141
|
+
if (sha(current) !== sha(expected)) return false;
|
|
142
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
143
|
+
writeFileSync(tmp, next, { mode: 0o600 });
|
|
144
|
+
renameSync(tmp, path);
|
|
145
|
+
return true;
|
|
146
|
+
} finally { try { rmdirSync(lock); } catch { /* already gone */ } }
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export type SshRunner = (host: string, script: string, input?: string) => { status: number | null; stdout: string; stderr: string };
|
|
152
|
+
export const defaultSshRunner: SshRunner = (host, script, input) => {
|
|
153
|
+
// ssh joins its arguments into one remote shell line — hand bash a single-quoted script.
|
|
154
|
+
const quoted = `'${script.replaceAll("'", "'\\''")}'`;
|
|
155
|
+
const r = spawnSync('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', host, `bash -c ${quoted}`],
|
|
156
|
+
{ input: input ?? '', encoding: 'utf8', timeout: 30_000 });
|
|
157
|
+
return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/** The arbiter itself (cloud-vm) runs the same lease script locally — `hq.arbiter: "local"` (drill 10-04: gcp has no ssh to itself). */
|
|
161
|
+
export const localShellRunner: SshRunner = (_host, script, input) => {
|
|
162
|
+
const r = spawnSync('bash', ['-c', script], { input: input ?? '', encoding: 'utf8', timeout: 30_000 });
|
|
163
|
+
return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const REMOTE_DIR = '$HOME/.elanous-hq';
|
|
167
|
+
/** Remote store over ssh: the remote side only compares a sha and renames under a mkdir lock — all decisions stay here. */
|
|
168
|
+
export function sshLeaseStore(host: string, run: SshRunner = defaultSshRunner): LeaseStore {
|
|
169
|
+
return {
|
|
170
|
+
read() {
|
|
171
|
+
const r = run(host, `date +%s && echo ---- && { cat ${REMOTE_DIR}/lease.json 2>/dev/null || true; }`);
|
|
172
|
+
if (r.status !== 0) throw new Error(`arbiter ${host} unreachable: ${r.stderr.trim().split('\n')[0] ?? r.status}`);
|
|
173
|
+
const [head, ...rest] = r.stdout.split('----\n');
|
|
174
|
+
const now = Number.parseInt((head ?? '').trim(), 10);
|
|
175
|
+
if (!Number.isFinite(now)) throw new Error(`arbiter ${host} gave no clock`);
|
|
176
|
+
const raw = rest.join('----\n');
|
|
177
|
+
return { now, raw: raw.length ? raw : null };
|
|
178
|
+
},
|
|
179
|
+
cas(expected, next) {
|
|
180
|
+
const script = [
|
|
181
|
+
`set -e; d=${REMOTE_DIR}; f=$d/lease.json; mkdir -p "$d"`,
|
|
182
|
+
'i=0; until mkdir "$d/lock" 2>/dev/null; do i=$((i+1)); [ $i -gt 100 ] && { echo BUSY; exit 3; }; sleep 0.05; done',
|
|
183
|
+
'trap \'rmdir "$d/lock" 2>/dev/null || true\' EXIT',
|
|
184
|
+
'if [ -f "$f" ]; then cur=$( (sha256sum "$f" 2>/dev/null || shasum -a 256 "$f") | cut -d" " -f1); else cur=none; fi',
|
|
185
|
+
`[ "$cur" = "${sha(expected)}" ] || { echo CONFLICT; exit 0; }`,
|
|
186
|
+
'cat > "$f.tmp.$$"; chmod 600 "$f.tmp.$$"; mv "$f.tmp.$$" "$f"; echo OK',
|
|
187
|
+
].join('\n');
|
|
188
|
+
const r = run(host, script, next);
|
|
189
|
+
if (r.status !== 0) throw new Error(`arbiter ${host} write failed: ${r.stdout.trim() || r.stderr.trim().split('\n')[0]}`);
|
|
190
|
+
return r.stdout.trim().endsWith('OK');
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Home-to-home reachability (ssh true) — used only by the holder to reach the standby, never by the arbiter. */
|
|
196
|
+
export const sshReachable = (host: string, run: SshRunner = defaultSshRunner): boolean => {
|
|
197
|
+
try { return run(host, 'true').status === 0; } catch { return false; }
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Tailnet probe (OP 10-04 09:04): liveness = `tailscale ping`, application = nexus health over the tailnet.
|
|
202
|
+
* One «unreachable» observation needs BOTH to fail. No ssh — the arbiter holds no shell on home machines.
|
|
203
|
+
*/
|
|
204
|
+
export interface HostProbe { ping(host: string): boolean; health(host: string): boolean }
|
|
205
|
+
export function probeReachable(host: string, probe: HostProbe): { reachable: boolean; ping: boolean; health: boolean } {
|
|
206
|
+
let ping = false; let health = false;
|
|
207
|
+
try { ping = probe.ping(host); } catch { ping = false; }
|
|
208
|
+
try { health = probe.health(host); } catch { health = false; }
|
|
209
|
+
return { reachable: ping || health, ping, health };
|
|
210
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { dirname, isAbsolute, join, relative, sep } from 'node:path';
|
|
3
|
+
import { effectiveInstanceRoot, prodInstanceRoot } from '../instance/resolve.js';
|
|
4
|
+
|
|
5
|
+
function physicalPath(path: string): string {
|
|
6
|
+
// Resolve one component at a time: lexical normalization of `link/..` before
|
|
7
|
+
// realpathSync would discard the link even though the filesystem follows it.
|
|
8
|
+
const raw = isAbsolute(path) ? path : `${process.cwd()}${sep}${path}`;
|
|
9
|
+
let current: string = sep;
|
|
10
|
+
for (const component of raw.split(sep)) {
|
|
11
|
+
if (!component || component === '.') continue;
|
|
12
|
+
if (component === '..') {
|
|
13
|
+
current = dirname(current);
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
const candidate = join(current, component);
|
|
17
|
+
try { current = realpathSync(candidate); }
|
|
18
|
+
catch (error) {
|
|
19
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
20
|
+
current = candidate;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return current;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function within(root: string, target: string): boolean {
|
|
27
|
+
const suffix = relative(root, target);
|
|
28
|
+
return suffix === '' || (suffix !== '..' && !suffix.startsWith(`..${sep}`));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Only writes inside this process's non-operational instance universe bypass the HQ ledger lease. */
|
|
32
|
+
export function isIsolatedLedgerWriteRoot(
|
|
33
|
+
ledgerRoot: string,
|
|
34
|
+
instanceRoot = effectiveInstanceRoot(),
|
|
35
|
+
operationalRoot = prodInstanceRoot(),
|
|
36
|
+
): boolean {
|
|
37
|
+
const target = physicalPath(ledgerRoot);
|
|
38
|
+
const instance = physicalPath(instanceRoot);
|
|
39
|
+
const prod = physicalPath(operationalRoot);
|
|
40
|
+
return instance !== prod && within(instance, target) && !within(prod, target);
|
|
41
|
+
}
|