@adhdev/daemon-core 0.9.82-rc.49 → 0.9.82-rc.490
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/boot/daemon-lifecycle.d.ts +9 -0
- package/dist/boot/process-hardening.d.ts +50 -0
- package/dist/build-info.d.ts +37 -0
- package/dist/chat/source-machine.d.ts +166 -0
- package/dist/chat/source-resolver.d.ts +104 -0
- package/dist/chat/subscription-updates.d.ts +1 -0
- package/dist/cli-adapter-types.d.ts +68 -2
- package/dist/cli-adapters/cli-script-runner.d.ts +117 -0
- package/dist/cli-adapters/cli-state-engine.d.ts +220 -0
- package/dist/cli-adapters/provider-cli-adapter.d.ts +137 -64
- package/dist/cli-adapters/provider-cli-parse.d.ts +5 -0
- package/dist/cli-adapters/provider-cli-runtime.d.ts +34 -0
- package/dist/cli-adapters/provider-cli-shared.d.ts +74 -0
- package/dist/cli-adapters/pty-write-chunking.d.ts +34 -0
- package/dist/cli-adapters/raw-terminal-io.d.ts +37 -0
- package/dist/cli-adapters/resolve-executable.d.ts +69 -0
- package/dist/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +6 -3
- package/dist/cli-adapters/terminal-backends/types.d.ts +10 -2
- package/dist/cli-adapters/terminal-screen.d.ts +4 -11
- package/dist/commands/chat-commands-debug-bundle.d.ts +14 -0
- package/dist/commands/chat-commands-read.d.ts +23 -0
- package/dist/commands/chat-commands-scope.d.ts +39 -0
- package/dist/commands/chat-commands-shared.d.ts +33 -0
- package/dist/commands/chat-commands-write.d.ts +14 -0
- package/dist/commands/chat-commands.d.ts +10 -23
- package/dist/commands/cli-manager.d.ts +71 -1
- package/dist/commands/handler.d.ts +125 -0
- package/dist/commands/high-family/index.d.ts +3 -0
- package/dist/commands/high-family/mesh-coordinator-launch.d.ts +2 -0
- package/dist/commands/high-family/mesh-events.d.ts +2 -0
- package/dist/commands/high-family/mesh-status.d.ts +2 -0
- package/dist/commands/high-family/types.d.ts +64 -0
- package/dist/commands/low-family/coordinator-prompt.d.ts +9 -0
- package/dist/commands/low-family/daemon-lifecycle.d.ts +2 -0
- package/dist/commands/low-family/diagnostics.d.ts +2 -0
- package/dist/commands/low-family/index.d.ts +3 -0
- package/dist/commands/low-family/mesh-ledger.d.ts +10 -0
- package/dist/commands/low-family/mesh-node-logs.d.ts +2 -0
- package/dist/commands/low-family/notification.d.ts +2 -0
- package/dist/commands/low-family/refine-config.d.ts +2 -0
- package/dist/commands/low-family/session-host.d.ts +2 -0
- package/dist/commands/low-family/spec-providerdev.d.ts +11 -0
- package/dist/commands/low-family/status-meta.d.ts +2 -0
- package/dist/commands/low-family/types.d.ts +33 -0
- package/dist/commands/med-family/cli-agent.d.ts +2 -0
- package/dist/commands/med-family/fast-forward.d.ts +2 -0
- package/dist/commands/med-family/ide.d.ts +10 -0
- package/dist/commands/med-family/index.d.ts +3 -0
- package/dist/commands/med-family/mesh-crud.d.ts +48 -0
- package/dist/commands/med-family/mesh-host-pairing.d.ts +2 -0
- package/dist/commands/med-family/mesh-queue.d.ts +2 -0
- package/dist/commands/med-family/mesh-restart.d.ts +2 -0
- package/dist/commands/med-family/types.d.ts +152 -0
- package/dist/commands/mesh-coordinator.d.ts +85 -1
- package/dist/commands/router-aggregate-status.d.ts +24 -0
- package/dist/commands/router-mesh-session-owner.d.ts +59 -0
- package/dist/commands/router-refine.d.ts +151 -0
- package/dist/commands/router-worktree-cleanup.d.ts +133 -0
- package/dist/commands/router.d.ts +203 -15
- package/dist/commands/upgrade-helper.d.ts +41 -1
- package/dist/config/chat-history.d.ts +11 -0
- package/dist/config/config.d.ts +19 -0
- package/dist/config/mesh-config.d.ts +65 -0
- package/dist/config/mesh-json-config.d.ts +199 -0
- package/dist/config/registry-resolver.d.ts +54 -0
- package/dist/config/repo-settings.d.ts +77 -0
- package/dist/config/state-store.d.ts +30 -0
- package/dist/daemon/dev-server.d.ts +0 -2
- package/dist/detection/cli-detector.d.ts +17 -0
- package/dist/detection/ide-detector.d.ts +13 -0
- package/dist/detection/win32-ide-version.d.ts +37 -0
- package/dist/git/change-impact-config.d.ts +159 -0
- package/dist/git/git-commands.d.ts +26 -2
- package/dist/git/git-diff.d.ts +6 -0
- package/dist/git/git-executor.d.ts +11 -0
- package/dist/git/git-status.d.ts +80 -0
- package/dist/git/git-types.d.ts +2 -50
- package/dist/git/git-worktree.d.ts +71 -1
- package/dist/git/index.d.ts +3 -1
- package/dist/index.d.ts +70 -17
- package/dist/index.js +53532 -19073
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +53283 -18989
- package/dist/index.mjs.map +1 -1
- package/dist/installer.d.ts +1 -4
- package/dist/ipc/local-ipc-server.d.ts +91 -0
- package/dist/launch.d.ts +1 -1
- package/dist/logging/async-batch-writer.d.ts +10 -0
- package/dist/logging/debug-config.d.ts +16 -0
- package/dist/logging/log-redactor.d.ts +24 -0
- package/dist/logging/log-tail-reader.d.ts +81 -0
- package/dist/logging/logger.d.ts +1 -1
- package/dist/mesh/contracts.d.ts +256 -0
- package/dist/mesh/coordinator-prompt.d.ts +76 -0
- package/dist/mesh/coordinator-registry.d.ts +59 -0
- package/dist/mesh/mesh-active-work.d.ts +203 -0
- package/dist/mesh/mesh-clone-grace.d.ts +8 -0
- package/dist/mesh/mesh-completion-synthesis.d.ts +4 -0
- package/dist/mesh/mesh-coordinator-config.d.ts +21 -0
- package/dist/mesh/mesh-delivery-policy.d.ts +104 -0
- package/dist/mesh/mesh-event-classify.d.ts +7 -0
- package/dist/mesh/mesh-event-forwarding.d.ts +58 -0
- package/dist/mesh/mesh-event-trace.d.ts +21 -0
- package/dist/mesh/mesh-events-coordinator.d.ts +4 -0
- package/dist/mesh/mesh-events-pending.d.ts +258 -0
- package/dist/mesh/mesh-events-stale.d.ts +61 -0
- package/dist/mesh/mesh-events-utils.d.ts +75 -0
- package/dist/mesh/mesh-events.d.ts +6 -49
- package/dist/mesh/mesh-fast-forward.d.ts +81 -0
- package/dist/mesh/mesh-host-ownership.d.ts +21 -1
- package/dist/mesh/mesh-init.d.ts +128 -0
- package/dist/mesh/mesh-ledger-reconciliation.d.ts +23 -4
- package/dist/mesh/mesh-ledger.d.ts +139 -1
- package/dist/mesh/mesh-magi-status.d.ts +63 -0
- package/dist/mesh/mesh-missions.d.ts +281 -0
- package/dist/mesh/mesh-node-identity.d.ts +310 -0
- package/dist/mesh/mesh-queue-assignment.d.ts +162 -0
- package/dist/mesh/mesh-reconcile-acked-hold.d.ts +17 -0
- package/dist/mesh/mesh-reconcile-config.d.ts +6 -0
- package/dist/mesh/mesh-reconcile-identity.d.ts +6 -0
- package/dist/mesh/mesh-reconcile-loop.d.ts +84 -0
- package/dist/mesh/mesh-reconcile-v2-backstop.d.ts +16 -0
- package/dist/mesh/mesh-refine-batch.d.ts +68 -0
- package/dist/mesh/mesh-refine-gates.d.ts +457 -0
- package/dist/mesh/mesh-refine-status.d.ts +62 -0
- package/dist/mesh/mesh-remote-event-pull.d.ts +15 -0
- package/dist/mesh/mesh-review-inbox.d.ts +90 -0
- package/dist/mesh/mesh-routing.d.ts +70 -0
- package/dist/mesh/mesh-runtime-store.d.ts +567 -0
- package/dist/mesh/mesh-scheduling-runtime.d.ts +78 -0
- package/dist/mesh/mesh-task-inflight.d.ts +46 -0
- package/dist/mesh/mesh-task-stats.d.ts +49 -0
- package/dist/mesh/mesh-unresolved-forward-outbox.d.ts +34 -0
- package/dist/mesh/mesh-warmup-deadline.d.ts +68 -0
- package/dist/mesh/mesh-work-queue.d.ts +392 -1
- package/dist/mesh/preview-freshness.d.ts +28 -0
- package/dist/mesh/refine-config.d.ts +97 -0
- package/dist/mesh/worktree-bootstrap-config.d.ts +219 -0
- package/dist/providers/acp-provider-instance.d.ts +5 -0
- package/dist/providers/approval-utils.d.ts +46 -0
- package/dist/providers/chat-message-normalization.d.ts +58 -1
- package/dist/providers/cli-provider-effect-format.d.ts +30 -0
- package/dist/providers/cli-provider-history-dedup.d.ts +17 -0
- package/dist/providers/cli-provider-input-prompt.d.ts +12 -0
- package/dist/providers/cli-provider-instance-types.d.ts +45 -0
- package/dist/providers/cli-provider-instance.d.ts +426 -30
- package/dist/providers/cli-provider-status-helpers.d.ts +46 -0
- package/dist/providers/cli-provider-transcript-merge.d.ts +7 -0
- package/dist/providers/contracts.d.ts +196 -6
- package/dist/providers/external-sources.d.ts +71 -0
- package/dist/providers/manual-attendance.d.ts +79 -0
- package/dist/providers/native-history/antigravity-claim-registry.d.ts +39 -0
- package/dist/providers/native-history/antigravity-cli-transcript.d.ts +137 -0
- package/dist/providers/native-history/claude-cli-transcript.d.ts +70 -0
- package/dist/providers/native-history/codex-cli-transcript.d.ts +73 -0
- package/dist/providers/native-history/constants.d.ts +12 -0
- package/dist/providers/native-history/dispatcher.d.ts +44 -0
- package/dist/providers/native-history/hermes-cli-transcript.d.ts +30 -0
- package/dist/providers/native-history/index.d.ts +13 -0
- package/dist/providers/provider-instance-manager.d.ts +30 -0
- package/dist/providers/provider-instance.d.ts +31 -1
- package/dist/providers/provider-loader.d.ts +42 -5
- package/dist/providers/provider-trust.d.ts +31 -0
- package/dist/providers/read-chat-contract.d.ts +29 -0
- package/dist/providers/sdk/v1/builders/acp/detect-status.d.ts +68 -0
- package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +86 -0
- package/dist/providers/sdk/v1/builders/cli/parse-approval-squash.d.ts +59 -0
- package/dist/providers/sdk/v1/builders/cli/parse-approval.d.ts +65 -0
- package/dist/providers/sdk/v1/builders/cli/parse-session.d.ts +91 -0
- package/dist/providers/sdk/v1/builders/cli/visible-region.d.ts +42 -0
- package/dist/providers/sdk/v1/fixture-tooling/format.d.ts +126 -0
- package/dist/providers/sdk/v1/fixture-tooling/index.d.ts +8 -0
- package/dist/providers/sdk/v1/fixture-tooling/replay.d.ts +38 -0
- package/dist/providers/sdk/v1/index.d.ts +30 -0
- package/dist/providers/sdk/v1/sandbox/README-design.d.ts +193 -0
- package/dist/providers/sdk/v1/sandbox/require-whitelist.d.ts +74 -0
- package/dist/providers/sdk/v1/sandbox/script-runner.d.ts +98 -0
- package/dist/providers/sdk/v1/types/cli/index.d.ts +277 -0
- package/dist/providers/sdk/v1/types/common/index.d.ts +203 -0
- package/dist/providers/sdk/v1/validators/index.d.ts +5 -0
- package/dist/providers/sdk/v1/validators/manifest.d.ts +40 -0
- package/dist/providers/sdk/v1/validators/taint.d.ts +52 -0
- package/dist/providers/spec/adapter.d.ts +98 -0
- package/dist/providers/spec/cli-adapter.d.ts +223 -0
- package/dist/providers/spec/evaluator.d.ts +45 -0
- package/dist/providers/spec/fsm-driver.d.ts +449 -0
- package/dist/providers/spec/fsm-evaluator.d.ts +93 -0
- package/dist/providers/spec/fsm-loader.d.ts +14 -0
- package/dist/providers/spec/fsm-types.d.ts +202 -0
- package/dist/providers/spec/native-history-executor.d.ts +41 -0
- package/dist/providers/spec/pre-launch-trust.d.ts +16 -0
- package/dist/providers/spec/route.d.ts +4 -0
- package/dist/providers/spec/types.d.ts +244 -0
- package/dist/providers/status-monitor.d.ts +7 -7
- package/dist/providers/transcript-v2.d.ts +176 -0
- package/dist/providers/types/interactive-prompt.d.ts +84 -0
- package/dist/providers/working-dir.d.ts +17 -0
- package/dist/repo-mesh-types.d.ts +616 -12
- package/dist/runtime-defaults.d.ts +2 -0
- package/dist/session-host/managed-host.d.ts +64 -0
- package/dist/session-host/runtime-surface.d.ts +10 -16
- package/dist/sessions/registry.d.ts +26 -0
- package/dist/shared-types-extra.d.ts +2 -4
- package/dist/shared-types.d.ts +110 -55
- package/dist/status/chat-tail-hot-sessions.d.ts +40 -0
- package/dist/status/normalize.d.ts +1 -1
- package/dist/status/normalize.js +1 -0
- package/dist/status/normalize.js.map +1 -1
- package/dist/status/normalize.mjs +1 -0
- package/dist/status/normalize.mjs.map +1 -1
- package/dist/status/reporter.d.ts +2 -0
- package/dist/status/snapshot.d.ts +27 -0
- package/dist/system/hash.d.ts +8 -0
- package/dist/system/load-better-sqlite3.d.ts +21 -0
- package/dist/types.d.ts +20 -6
- package/package.json +11 -4
- package/src/agent-stream/poller.ts +2 -3
- package/src/agent-stream/provider-adapter.ts +1 -1
- package/src/boot/daemon-lifecycle.ts +80 -14
- package/src/boot/process-hardening.ts +89 -0
- package/src/build-info.ts +73 -0
- package/src/chat/source-machine.ts +534 -0
- package/src/chat/source-resolver.ts +0 -0
- package/src/chat/subscription-updates.ts +20 -1
- package/src/cli-adapter-types.d.ts +3 -1
- package/src/cli-adapter-types.ts +68 -2
- package/src/cli-adapters/cli-script-runner.ts +421 -0
- package/src/cli-adapters/cli-state-engine.ts +1282 -0
- package/src/cli-adapters/provider-cli-adapter.d.ts +1 -1
- package/src/cli-adapters/provider-cli-adapter.ts +1048 -1143
- package/src/cli-adapters/provider-cli-parse.d.ts +2 -0
- package/src/cli-adapters/provider-cli-parse.ts +19 -0
- package/src/cli-adapters/provider-cli-runtime.ts +56 -7
- package/src/cli-adapters/provider-cli-shared.d.ts +4 -0
- package/src/cli-adapters/provider-cli-shared.ts +121 -11
- package/src/cli-adapters/pty-transport.ts +2 -1
- package/src/cli-adapters/pty-write-chunking.ts +106 -0
- package/src/cli-adapters/raw-terminal-io.ts +252 -0
- package/src/cli-adapters/resolve-executable.ts +204 -0
- package/src/cli-adapters/session-host-transport.ts +2 -1
- package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +63 -29
- package/src/cli-adapters/terminal-backends/types.ts +10 -3
- package/src/cli-adapters/terminal-screen.ts +16 -81
- package/src/commands/chat-commands-debug-bundle.ts +398 -0
- package/src/commands/chat-commands-read.ts +2788 -0
- package/src/commands/chat-commands-scope.ts +54 -0
- package/src/commands/chat-commands-shared.ts +114 -0
- package/src/commands/chat-commands-write.ts +891 -0
- package/src/commands/chat-commands.ts +19 -1841
- package/src/commands/cli-manager.d.ts +2 -0
- package/src/commands/cli-manager.ts +724 -27
- package/src/commands/handler.ts +865 -2
- package/src/commands/high-family/index.ts +28 -0
- package/src/commands/high-family/mesh-coordinator-launch.ts +696 -0
- package/src/commands/high-family/mesh-events.ts +102 -0
- package/src/commands/high-family/mesh-status.ts +857 -0
- package/src/commands/high-family/types.ts +80 -0
- package/src/commands/low-family/coordinator-prompt.ts +125 -0
- package/src/commands/low-family/daemon-lifecycle.ts +120 -0
- package/src/commands/low-family/diagnostics.ts +57 -0
- package/src/commands/low-family/index.ts +37 -0
- package/src/commands/low-family/mesh-ledger.ts +62 -0
- package/src/commands/low-family/mesh-node-logs.ts +87 -0
- package/src/commands/low-family/notification.ts +116 -0
- package/src/commands/low-family/refine-config.ts +106 -0
- package/src/commands/low-family/session-host.ts +282 -0
- package/src/commands/low-family/spec-providerdev.ts +217 -0
- package/src/commands/low-family/status-meta.ts +112 -0
- package/src/commands/low-family/types.ts +39 -0
- package/src/commands/med-family/cli-agent.ts +270 -0
- package/src/commands/med-family/fast-forward.ts +230 -0
- package/src/commands/med-family/ide.ts +163 -0
- package/src/commands/med-family/index.ts +37 -0
- package/src/commands/med-family/mesh-crud.ts +1268 -0
- package/src/commands/med-family/mesh-host-pairing.ts +234 -0
- package/src/commands/med-family/mesh-queue.ts +167 -0
- package/src/commands/med-family/mesh-restart.ts +92 -0
- package/src/commands/med-family/types.ts +153 -0
- package/src/commands/mesh-coordinator.ts +334 -124
- package/src/commands/router-aggregate-status.ts +209 -0
- package/src/commands/router-mesh-session-owner.ts +114 -0
- package/src/commands/router-refine.ts +1794 -0
- package/src/commands/router-worktree-cleanup.ts +870 -0
- package/src/commands/router.ts +567 -4149
- package/src/commands/stream-commands.ts +8 -0
- package/src/commands/upgrade-helper.ts +310 -45
- package/src/config/chat-history.ts +517 -24
- package/src/config/config.ts +30 -0
- package/src/config/mesh-config.ts +300 -24
- package/src/config/mesh-json-config.ts +376 -0
- package/src/config/recent-activity.ts +8 -2
- package/src/config/registry-resolver.ts +100 -0
- package/src/config/repo-settings.ts +111 -0
- package/src/config/state-store.ts +55 -0
- package/src/daemon/dev-auto-implement.ts +3 -2
- package/src/daemon/dev-cli-debug.ts +10 -1
- package/src/daemon/dev-server.ts +0 -541
- package/src/detection/cli-detector.ts +94 -9
- package/src/detection/ide-detector.ts +55 -16
- package/src/detection/win32-ide-version.ts +106 -0
- package/src/git/change-impact-config.ts +354 -0
- package/src/git/git-commands.ts +112 -16
- package/src/git/git-diff.ts +81 -11
- package/src/git/git-executor.ts +12 -0
- package/src/git/git-status.ts +822 -48
- package/src/git/git-types.ts +14 -62
- package/src/git/git-worktree.ts +261 -4
- package/src/git/index.ts +17 -0
- package/src/index.ts +190 -13
- package/src/installer.d.ts +1 -1
- package/src/installer.ts +8 -6
- package/src/ipc/local-ipc-server.ts +278 -0
- package/src/launch.d.ts +1 -1
- package/src/launch.ts +37 -28
- package/src/logging/async-batch-writer.ts +55 -0
- package/src/logging/command-log.ts +7 -5
- package/src/logging/debug-config.ts +25 -0
- package/src/logging/debug-trace.ts +7 -2
- package/src/logging/log-redactor.ts +100 -0
- package/src/logging/log-tail-reader.ts +341 -0
- package/src/logging/logger.ts +14 -7
- package/src/mesh/contracts.ts +510 -0
- package/src/mesh/coordinator-prompt.ts +627 -34
- package/src/mesh/coordinator-registry.ts +121 -0
- package/src/mesh/mesh-active-work.ts +706 -0
- package/src/mesh/mesh-clone-grace.ts +68 -0
- package/src/mesh/mesh-completion-synthesis.ts +416 -0
- package/src/mesh/mesh-coordinator-config.ts +97 -0
- package/src/mesh/mesh-delivery-policy.ts +284 -0
- package/src/mesh/mesh-event-classify.ts +78 -0
- package/src/mesh/mesh-event-forwarding.ts +2030 -0
- package/src/mesh/mesh-event-trace.ts +67 -0
- package/src/mesh/mesh-events-coordinator.ts +32 -0
- package/src/mesh/mesh-events-pending.ts +1548 -0
- package/src/mesh/mesh-events-stale.ts +452 -0
- package/src/mesh/mesh-events-utils.ts +443 -0
- package/src/mesh/mesh-events.ts +43 -1035
- package/src/mesh/mesh-fast-forward.ts +856 -0
- package/src/mesh/mesh-host-ownership.ts +41 -3
- package/src/mesh/mesh-init.ts +350 -0
- package/src/mesh/mesh-ledger-reconciliation.ts +12 -7
- package/src/mesh/mesh-ledger.ts +897 -102
- package/src/mesh/mesh-magi-status.ts +223 -0
- package/src/mesh/mesh-missions.ts +695 -0
- package/src/mesh/mesh-node-identity.ts +2065 -0
- package/src/mesh/mesh-queue-assignment.ts +2498 -0
- package/src/mesh/mesh-reconcile-acked-hold.ts +230 -0
- package/src/mesh/mesh-reconcile-config.ts +66 -0
- package/src/mesh/mesh-reconcile-identity.ts +103 -0
- package/src/mesh/mesh-reconcile-loop.ts +1696 -0
- package/src/mesh/mesh-reconcile-v2-backstop.ts +62 -0
- package/src/mesh/mesh-refine-batch.ts +205 -0
- package/src/mesh/mesh-refine-gates.ts +1792 -0
- package/src/mesh/mesh-refine-status.ts +231 -0
- package/src/mesh/mesh-remote-event-pull.ts +279 -0
- package/src/mesh/mesh-review-inbox.ts +308 -0
- package/src/mesh/mesh-routing.ts +291 -0
- package/src/mesh/mesh-runtime-store.ts +2292 -0
- package/src/mesh/mesh-scheduling-runtime.ts +198 -0
- package/src/mesh/mesh-task-inflight.ts +70 -0
- package/src/mesh/mesh-task-stats.ts +161 -0
- package/src/mesh/mesh-unresolved-forward-outbox.ts +215 -0
- package/src/mesh/mesh-warmup-deadline.ts +152 -0
- package/src/mesh/mesh-work-queue.ts +1489 -145
- package/src/mesh/preview-freshness.ts +163 -0
- package/src/mesh/refine-config.ts +129 -12
- package/src/mesh/worktree-bootstrap-config.ts +599 -0
- package/src/providers/acp-provider-instance.ts +43 -10
- package/src/providers/approval-utils.d.ts +5 -0
- package/src/providers/approval-utils.ts +100 -6
- package/src/providers/chat-message-normalization.ts +126 -4
- package/src/providers/cli-provider-effect-format.ts +53 -0
- package/src/providers/cli-provider-history-dedup.ts +75 -0
- package/src/providers/cli-provider-input-prompt.ts +133 -0
- package/src/providers/cli-provider-instance-types.ts +131 -0
- package/src/providers/cli-provider-instance.ts +2771 -499
- package/src/providers/cli-provider-status-helpers.ts +123 -0
- package/src/providers/cli-provider-transcript-merge.ts +114 -0
- package/src/providers/contracts.d.ts +55 -0
- package/src/providers/contracts.ts +207 -6
- package/src/providers/extension-provider-instance.ts +12 -7
- package/src/providers/external-sources.ts +218 -0
- package/src/providers/ide-provider-instance.ts +35 -12
- package/src/providers/manual-attendance.ts +105 -0
- package/src/providers/native-history/antigravity-claim-registry.ts +138 -0
- package/src/providers/native-history/antigravity-cli-transcript.ts +1275 -0
- package/src/providers/native-history/claude-cli-transcript.ts +396 -0
- package/src/providers/native-history/codex-cli-transcript.ts +479 -0
- package/src/providers/native-history/constants.ts +19 -0
- package/src/providers/native-history/dispatcher.ts +617 -0
- package/src/providers/native-history/hermes-cli-transcript.ts +312 -0
- package/src/providers/native-history/index.ts +30 -0
- package/src/providers/provider-instance-manager.ts +71 -0
- package/src/providers/provider-instance.ts +24 -1
- package/src/providers/provider-loader.ts +700 -55
- package/src/providers/provider-schema.ts +93 -14
- package/src/providers/provider-trust.ts +114 -0
- package/src/providers/read-chat-contract.ts +76 -16
- package/src/providers/sdk/README.md +49 -0
- package/src/providers/sdk/v1/builders/acp/detect-status.ts +144 -0
- package/src/providers/sdk/v1/builders/cli/detect-status.ts +313 -0
- package/src/providers/sdk/v1/builders/cli/parse-approval-squash.ts +158 -0
- package/src/providers/sdk/v1/builders/cli/parse-approval.ts +261 -0
- package/src/providers/sdk/v1/builders/cli/parse-session.ts +276 -0
- package/src/providers/sdk/v1/builders/cli/visible-region.ts +143 -0
- package/src/providers/sdk/v1/fixture-tooling/format.ts +130 -0
- package/src/providers/sdk/v1/fixture-tooling/index.ts +22 -0
- package/src/providers/sdk/v1/fixture-tooling/replay.ts +352 -0
- package/src/providers/sdk/v1/index.ts +152 -0
- package/src/providers/sdk/v1/sandbox/README-design.ts +195 -0
- package/src/providers/sdk/v1/sandbox/require-whitelist.ts +472 -0
- package/src/providers/sdk/v1/sandbox/script-runner.ts +150 -0
- package/src/providers/sdk/v1/schemas/cli/provider.schema.json +538 -0
- package/src/providers/sdk/v1/schemas/primitives/acp-session-protocol-v1.json +131 -0
- package/src/providers/sdk/v1/schemas/primitives/native-history-codex-rollout-v1.json +66 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-approval-squash-v1.json +91 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-assistant-block-v1.json +91 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-cue-ordering-v1.json +47 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-dispatch-order-v1.json +32 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-footer-chrome-v1.json +42 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-index-finder-v1.json +27 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-modal-v1.json +125 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-prompt-marker-v1.json +45 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-session-id-extraction-v1.json +46 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-settled-prompt-v1.json +71 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-spinner-v1.json +83 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-transcript-pty-v1.json +83 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-visible-region-v1.json +57 -0
- package/src/providers/sdk/v1/schemas/primitives/tui-welcome-screen-v1.json +35 -0
- package/src/providers/sdk/v1/types/cli/index.ts +365 -0
- package/src/providers/sdk/v1/types/common/index.ts +229 -0
- package/src/providers/sdk/v1/validators/index.ts +19 -0
- package/src/providers/sdk/v1/validators/manifest.ts +110 -0
- package/src/providers/sdk/v1/validators/taint.ts +309 -0
- package/src/providers/spec/adapter.ts +246 -0
- package/src/providers/spec/cli-adapter.ts +1064 -0
- package/src/providers/spec/evaluator.ts +407 -0
- package/src/providers/spec/fsm-driver.ts +1528 -0
- package/src/providers/spec/fsm-evaluator.ts +290 -0
- package/src/providers/spec/fsm-loader.ts +128 -0
- package/src/providers/spec/fsm-types.ts +301 -0
- package/src/providers/spec/native-history-executor.ts +1241 -0
- package/src/providers/spec/pre-launch-trust.ts +104 -0
- package/src/providers/spec/route.ts +51 -0
- package/src/providers/spec/types.ts +284 -0
- package/src/providers/status-monitor.d.ts +7 -7
- package/src/providers/status-monitor.ts +37 -22
- package/src/providers/transcript-v2.ts +567 -0
- package/src/providers/types/interactive-prompt.ts +536 -0
- package/src/providers/version-archive.ts +64 -24
- package/src/providers/working-dir.ts +23 -0
- package/src/repo-mesh-types.ts +861 -14
- package/src/runtime-defaults.ts +39 -0
- package/src/session-host/managed-host.ts +218 -0
- package/src/session-host/runtime-surface.ts +20 -80
- package/src/sessions/registry.ts +44 -0
- package/src/shared-types-extra.ts +2 -4
- package/src/shared-types.d.ts +8 -0
- package/src/shared-types.ts +148 -55
- package/src/status/builders.ts +69 -6
- package/src/status/chat-tail-hot-sessions.ts +117 -2
- package/src/status/normalize.ts +2 -0
- package/src/status/reporter.ts +19 -1
- package/src/status/snapshot.ts +100 -27
- package/src/system/hash.ts +23 -0
- package/src/system/host-memory.ts +29 -12
- package/src/system/load-better-sqlite3.ts +68 -0
- package/src/types.ts +26 -6
- package/dist/cli-adapters/terminal-backends/xterm-backend.d.ts +0 -17
- package/dist/mesh/mesh-sync.d.ts +0 -53
- package/src/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +0 -16
- package/src/cli-adapters/terminal-backends/xterm-backend.ts +0 -97
- package/src/mesh/mesh-sync.ts +0 -111
|
@@ -0,0 +1,2065 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mesh node-identity & git-freshness helpers
|
|
3
|
+
*
|
|
4
|
+
* Extracted from commands/router.ts (behavior-preserving move). Contains:
|
|
5
|
+
* - daemon-id / machine-id / hostname normalization + canonicalization
|
|
6
|
+
* - inline-mesh node identity reconciliation and transient-state handling
|
|
7
|
+
* - git-status normalization, branch-convergence, and freshness/staleness derivation
|
|
8
|
+
* - the direct git-probe cache and remote git-status probe machinery
|
|
9
|
+
*
|
|
10
|
+
* router.ts re-exports every public symbol from here so existing import paths
|
|
11
|
+
* (e.g. `from '.../commands/router.js'`) keep working.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ProviderLoader } from '../providers/provider-loader.js';
|
|
15
|
+
import { detectCLI } from '../detection/cli-detector.js';
|
|
16
|
+
import { getGitRepoStatus } from '../git/git-status.js';
|
|
17
|
+
import { normalizeGitStatus as sharedNormalizeGitStatus, pickBestTransitGitStatus as sharedPickBestTransitGitStatus, summarizeGitShape as sharedSummarizeGitShape, normalizeMeshNodeId, daemonIdsEquivalent, meshWorkspacesEquivalent, sessionIdsEquivalent } from '@adhdev/mesh-shared';
|
|
18
|
+
import { LOG } from '../logging/logger.js';
|
|
19
|
+
import { getSessionHostSurfaceKind } from '../session-host/runtime-surface.js';
|
|
20
|
+
import { awaitWithWarmupDeadline, resolveWarmupDeadlineOpts } from '../mesh/mesh-warmup-deadline.js';
|
|
21
|
+
import * as fs from 'fs';
|
|
22
|
+
import { workingDirBasename } from '../providers/working-dir.js';
|
|
23
|
+
import { readMeshTimeoutEnvMs, MESH_CONNECT_TIMEOUT_MS } from '../runtime-defaults.js';
|
|
24
|
+
|
|
25
|
+
export function readProviderPriorityFromPolicy(policy: unknown): string[] {
|
|
26
|
+
const record = policy && typeof policy === 'object' && !Array.isArray(policy)
|
|
27
|
+
? policy as Record<string, unknown>
|
|
28
|
+
: {};
|
|
29
|
+
const raw = record.providerPriority;
|
|
30
|
+
if (!Array.isArray(raw)) return [];
|
|
31
|
+
const seen = new Set<string>();
|
|
32
|
+
return raw
|
|
33
|
+
.map(type => typeof type === 'string' ? type.trim() : '')
|
|
34
|
+
.filter(Boolean)
|
|
35
|
+
.filter(type => {
|
|
36
|
+
if (seen.has(type)) return false;
|
|
37
|
+
seen.add(type);
|
|
38
|
+
return true;
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Normalize a providerRoles array (RepoMeshNodePolicy.providerRoles) from raw
|
|
44
|
+
* tool args. Each entry binds a providerType to an optional `maxParallel` cap.
|
|
45
|
+
* Entries without a usable providerType are dropped; the last entry wins on
|
|
46
|
+
* duplicate providerType. Returns [] when no valid entries — callers then omit
|
|
47
|
+
* the field entirely (full backward compat). Routing is governed by required_tags;
|
|
48
|
+
* any legacy `role` field on the input is ignored.
|
|
49
|
+
*/
|
|
50
|
+
export function normalizeProviderRoles(value: unknown): Array<{ providerType: string; maxParallel?: number }> {
|
|
51
|
+
if (!Array.isArray(value)) return [];
|
|
52
|
+
const byType = new Map<string, { providerType: string; maxParallel?: number }>();
|
|
53
|
+
for (const raw of value) {
|
|
54
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue;
|
|
55
|
+
const rec = raw as Record<string, unknown>;
|
|
56
|
+
const providerType = typeof rec.providerType === 'string' ? rec.providerType.trim() : '';
|
|
57
|
+
if (!providerType) continue;
|
|
58
|
+
const entry: { providerType: string; maxParallel?: number } = { providerType };
|
|
59
|
+
const maxParallel = Number(rec.maxParallel);
|
|
60
|
+
if (Number.isFinite(maxParallel) && maxParallel >= 0) entry.maxParallel = Math.floor(maxParallel);
|
|
61
|
+
byType.set(providerType.toLowerCase(), entry);
|
|
62
|
+
}
|
|
63
|
+
return [...byType.values()];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function readObjectRecord(value: unknown): Record<string, any> {
|
|
67
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
68
|
+
? value as Record<string, any>
|
|
69
|
+
: {};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function readStringValue(...values: unknown[]): string | undefined {
|
|
73
|
+
for (const value of values) {
|
|
74
|
+
if (typeof value === 'string' && value.trim()) return value.trim();
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function readNumberValue(...values: unknown[]): number | undefined {
|
|
80
|
+
for (const value of values) {
|
|
81
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function readBooleanValue(...values: unknown[]): boolean | undefined {
|
|
87
|
+
for (const value of values) {
|
|
88
|
+
if (typeof value === 'boolean') return value;
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// summarizeRepoMeshDebugGit was a hand-synced copy of the cloud git-shape
|
|
94
|
+
// summarizer; both now call shared summarizeGitShape (@adhdev/mesh-shared).
|
|
95
|
+
|
|
96
|
+
export function summarizeRepoMeshStatusDebug(status: any): Record<string, unknown> {
|
|
97
|
+
const nodes = Array.isArray(status?.nodes) ? status.nodes : [];
|
|
98
|
+
return {
|
|
99
|
+
success: status?.success,
|
|
100
|
+
meshId: readStringValue(status?.meshId, status?.mesh_id) ?? null,
|
|
101
|
+
refreshedAt: readStringValue(status?.refreshedAt, status?.refreshed_at) ?? null,
|
|
102
|
+
sourceOfTruth: status?.sourceOfTruth ?? null,
|
|
103
|
+
branchConvergenceSummary: status?.branchConvergenceSummary ?? status?.branch_convergence_summary ?? null,
|
|
104
|
+
nodeCount: nodes.length,
|
|
105
|
+
nodes: nodes.map((node: any) => ({
|
|
106
|
+
// Status emits the id under `nodeId` (3-way input absorbed). The
|
|
107
|
+
// inline cache keeps `id` and `nodeId` equal, so this serialized form
|
|
108
|
+
// round-trips back through the cache without flipping shape.
|
|
109
|
+
nodeId: normalizeMeshNodeId(node) ?? null,
|
|
110
|
+
daemonId: readStringValue(node?.daemonId, node?.daemon_id) ?? null,
|
|
111
|
+
workspace: readStringValue(node?.workspace, node?.git?.workspace) ?? null,
|
|
112
|
+
health: readStringValue(node?.health) ?? null,
|
|
113
|
+
machineStatus: readStringValue(node?.machineStatus, node?.machine_status) ?? null,
|
|
114
|
+
connection: node?.connection && typeof node.connection === 'object' ? {
|
|
115
|
+
state: readStringValue(node.connection.state) ?? null,
|
|
116
|
+
transport: readStringValue(node.connection.transport) ?? null,
|
|
117
|
+
source: readStringValue(node.connection.source) ?? null,
|
|
118
|
+
reported: readBooleanValue(node.connection.reported) ?? null,
|
|
119
|
+
} : null,
|
|
120
|
+
gitProbePending: node?.gitProbePending === true,
|
|
121
|
+
launchReady: node?.launchReady === true,
|
|
122
|
+
git: sharedSummarizeGitShape(node?.git),
|
|
123
|
+
branchConvergence: node?.branchConvergence ?? node?.branch_convergence ?? null,
|
|
124
|
+
})),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function logRepoMeshStatusDebug(event: string, fields: Record<string, unknown>): void {
|
|
129
|
+
try {
|
|
130
|
+
LOG.info('MeshStatusDebug', `[RepoMeshStatusDebug] ${JSON.stringify({ event, ...fields })}`);
|
|
131
|
+
} catch {
|
|
132
|
+
LOG.info('MeshStatusDebug', `[RepoMeshStatusDebug] ${event}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// joinRepoPath + readGitSubmodules moved to @adhdev/mesh-shared (readGitSubmodules)
|
|
137
|
+
// — used via sharedNormalizeGitStatus / sharedPickBestTransitGitStatus below.
|
|
138
|
+
|
|
139
|
+
export function buildMeshNodeDisplayLabel(node: Record<string, unknown>, nodeId: string, providerPriority: string[]): string {
|
|
140
|
+
const explicit = readStringValue(node.machineLabel, node.machine_label, node.machineNickname, node.machine_nickname, node.alias);
|
|
141
|
+
if (explicit) return explicit;
|
|
142
|
+
const workspace = readStringValue(node.workspace, node.repoRoot, node.repo_root);
|
|
143
|
+
// Use the OS-agnostic basename: a workspace reported by a Windows node
|
|
144
|
+
// (`D:\gh\adhdev-cloud`) must still collapse to its trailing segment even when
|
|
145
|
+
// this coordinator's own `path.basename` is POSIX-only and would not split `\`.
|
|
146
|
+
const workspaceName = workspace ? workingDirBasename(workspace) : undefined;
|
|
147
|
+
const host = readStringValue(node.machineName, node.machine_name, node.hostname, node.host, node.daemonId, node.daemon_id, node.machineId, node.machine_id);
|
|
148
|
+
const provider = providerPriority[0] || (Array.isArray(node.providers) ? readStringValue(...node.providers) : undefined);
|
|
149
|
+
const parts = [workspaceName, host, provider].filter(Boolean);
|
|
150
|
+
if (parts.length > 0) return parts.join(' · ');
|
|
151
|
+
return nodeId || 'unidentified mesh node';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function normalizeMeshHostname(value: unknown): string | undefined {
|
|
155
|
+
const hostname = readStringValue(value);
|
|
156
|
+
if (!hostname) return undefined;
|
|
157
|
+
return hostname.toLowerCase().replace(/\.$/, '');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function readMeshNodeMachineId(node: Record<string, unknown>): string | undefined {
|
|
161
|
+
return readStringValue(
|
|
162
|
+
node.machineId,
|
|
163
|
+
node.machine_id,
|
|
164
|
+
readObjectRecord(node.machine)?.id,
|
|
165
|
+
readObjectRecord(node.machine)?.machineId,
|
|
166
|
+
readObjectRecord(node.lastProbe)?.machineId,
|
|
167
|
+
readObjectRecord(node.last_probe)?.machine_id,
|
|
168
|
+
readObjectRecord(readObjectRecord(node.lastProbe)?.machine)?.id,
|
|
169
|
+
readObjectRecord(readObjectRecord(node.lastProbe)?.machine)?.machineId,
|
|
170
|
+
readObjectRecord(readObjectRecord(node.last_probe)?.machine)?.id,
|
|
171
|
+
readObjectRecord(readObjectRecord(node.last_probe)?.machine)?.machine_id,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function readMeshNodeDaemonId(node: Record<string, unknown>): string | undefined {
|
|
176
|
+
return readStringValue(
|
|
177
|
+
node.daemonId,
|
|
178
|
+
node.daemon_id,
|
|
179
|
+
readObjectRecord(node.machine)?.daemonId,
|
|
180
|
+
readObjectRecord(node.machine)?.daemon_id,
|
|
181
|
+
readObjectRecord(node.lastProbe)?.daemonId,
|
|
182
|
+
readObjectRecord(node.last_probe)?.daemon_id,
|
|
183
|
+
readObjectRecord(readObjectRecord(node.lastProbe)?.machine)?.daemonId,
|
|
184
|
+
readObjectRecord(readObjectRecord(node.lastProbe)?.machine)?.daemon_id,
|
|
185
|
+
readObjectRecord(readObjectRecord(node.last_probe)?.machine)?.daemonId,
|
|
186
|
+
readObjectRecord(readObjectRecord(node.last_probe)?.machine)?.daemon_id,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function readMeshNodeHostname(node: Record<string, unknown>): string | undefined {
|
|
191
|
+
return readStringValue(
|
|
192
|
+
node.hostname,
|
|
193
|
+
node.host,
|
|
194
|
+
node.machineHostname,
|
|
195
|
+
node.machine_hostname,
|
|
196
|
+
readObjectRecord(node.machine)?.hostname,
|
|
197
|
+
readObjectRecord(node.machine)?.host,
|
|
198
|
+
readObjectRecord(node.lastProbe)?.hostname,
|
|
199
|
+
readObjectRecord(node.last_probe)?.hostname,
|
|
200
|
+
readObjectRecord(readObjectRecord(node.lastProbe)?.machine)?.hostname,
|
|
201
|
+
readObjectRecord(readObjectRecord(node.last_probe)?.machine)?.hostname,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function readMeshNodeDisplayMachineName(node: Record<string, unknown>): string | undefined {
|
|
206
|
+
return readStringValue(
|
|
207
|
+
node.machineName,
|
|
208
|
+
node.machine_name,
|
|
209
|
+
node.machineLabel,
|
|
210
|
+
node.machine_label,
|
|
211
|
+
node.machineNickname,
|
|
212
|
+
node.machine_nickname,
|
|
213
|
+
node.alias,
|
|
214
|
+
readObjectRecord(node.machine)?.name,
|
|
215
|
+
readObjectRecord(node.machine)?.displayName,
|
|
216
|
+
readObjectRecord(node.machine)?.display_name,
|
|
217
|
+
readObjectRecord(node.lastProbe)?.machineName,
|
|
218
|
+
readObjectRecord(node.last_probe)?.machine_name,
|
|
219
|
+
readObjectRecord(readObjectRecord(node.lastProbe)?.machine)?.name,
|
|
220
|
+
readObjectRecord(readObjectRecord(node.last_probe)?.machine)?.name,
|
|
221
|
+
readMeshNodeHostname(node),
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function compactMeshIdentityEvidence(value: string | undefined): string | undefined {
|
|
226
|
+
if (!value) return undefined;
|
|
227
|
+
return value.length > 24 ? `${value.slice(0, 12)}…${value.slice(-8)}` : value;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function buildMeshNodeMachineIdentity(node: Record<string, unknown>, opts: {
|
|
231
|
+
localMachineId?: string;
|
|
232
|
+
localDaemonId?: string;
|
|
233
|
+
coordinatorHostname?: string;
|
|
234
|
+
isSelfNode?: boolean;
|
|
235
|
+
}): Record<string, unknown> {
|
|
236
|
+
const machineId = readMeshNodeMachineId(node);
|
|
237
|
+
const daemonId = readMeshNodeDaemonId(node);
|
|
238
|
+
const hostname = readMeshNodeHostname(node);
|
|
239
|
+
const machineName = readMeshNodeDisplayMachineName(node);
|
|
240
|
+
const coordinatorHostname = readStringValue(opts.coordinatorHostname);
|
|
241
|
+
const machineIdMatches = Boolean(opts.localMachineId && machineId && opts.localMachineId === machineId);
|
|
242
|
+
const daemonIdMatches = Boolean(opts.localDaemonId && daemonId && daemonIdsEquivalent(opts.localDaemonId, daemonId));
|
|
243
|
+
const hostnameMatches = Boolean(
|
|
244
|
+
normalizeMeshHostname(hostname)
|
|
245
|
+
&& normalizeMeshHostname(coordinatorHostname)
|
|
246
|
+
&& normalizeMeshHostname(hostname) === normalizeMeshHostname(coordinatorHostname),
|
|
247
|
+
);
|
|
248
|
+
const sameMachine = opts.isSelfNode === true || machineIdMatches || daemonIdMatches || hostnameMatches;
|
|
249
|
+
const evidence: string[] = [];
|
|
250
|
+
for (const [label, value] of [['machineName', machineName], ['hostname', hostname], ['machineId', machineId], ['daemonId', daemonId]] as const) {
|
|
251
|
+
const compact = compactMeshIdentityEvidence(value);
|
|
252
|
+
if (compact) evidence.push(`${label}:${compact}`);
|
|
253
|
+
}
|
|
254
|
+
const locality = sameMachine ? 'same_machine' : (evidence.length > 0 ? 'remote_known' : 'remote_or_unknown');
|
|
255
|
+
const localityReason = sameMachine
|
|
256
|
+
? (machineIdMatches ? 'matched coordinator machine id'
|
|
257
|
+
: daemonIdMatches ? 'matched coordinator daemon id'
|
|
258
|
+
: hostnameMatches ? 'matched coordinator hostname'
|
|
259
|
+
: 'selected coordinator node')
|
|
260
|
+
: evidence.length > 0
|
|
261
|
+
? `known remote/other machine identity; no local coordinator match (${evidence.join(', ')})`
|
|
262
|
+
: 'no useful machine identity evidence available';
|
|
263
|
+
return {
|
|
264
|
+
daemonId,
|
|
265
|
+
machineId,
|
|
266
|
+
hostname,
|
|
267
|
+
machineName,
|
|
268
|
+
displayName: machineName || hostname || daemonId || machineId,
|
|
269
|
+
coordinatorHostname,
|
|
270
|
+
sameMachine,
|
|
271
|
+
locality,
|
|
272
|
+
localityReason,
|
|
273
|
+
identityEvidence: evidence,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// normalizeInlineMeshGitStatus / scoreInlineMeshGitStatus /
|
|
278
|
+
// buildInlineMeshTransitGitStatus were the standalone-side copies of the cloud
|
|
279
|
+
// transit git normalizers. They now delegate to @adhdev/mesh-shared so the two
|
|
280
|
+
// transports can no longer drift (e.g. on the submodule drop / evidence rules).
|
|
281
|
+
|
|
282
|
+
function normalizeInlineMeshGitStatus(
|
|
283
|
+
status: Record<string, unknown>,
|
|
284
|
+
node: any,
|
|
285
|
+
options?: { lastCheckedAt?: number },
|
|
286
|
+
): Record<string, unknown> | undefined {
|
|
287
|
+
return sharedNormalizeGitStatus(status, readObjectRecord(node), options) as Record<string, unknown> | undefined;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function buildInlineMeshTransitGitStatus(node: any): Record<string, unknown> | undefined {
|
|
291
|
+
return sharedPickBestTransitGitStatus(readObjectRecord(node), { lastCheckedAt: Date.now() }) as Record<string, unknown> | undefined;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function shouldRefreshStalePendingAggregate(snapshot: any, options?: { requireDirectPeerTruth?: boolean }): boolean {
|
|
295
|
+
if (options?.requireDirectPeerTruth !== true || !Array.isArray(snapshot?.nodes)) return false;
|
|
296
|
+
return snapshot.nodes.some((node: any) => {
|
|
297
|
+
if (node?.gitProbePending !== true) return false;
|
|
298
|
+
const git = readObjectRecord(node?.git);
|
|
299
|
+
return !readBooleanValue(git.isGitRepo) && !readStringValue(git.branch, git.headCommit, git.upstream);
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function buildLivePeerGitConnection(connection: Record<string, unknown>, timestamp = new Date().toISOString()): Record<string, unknown> {
|
|
304
|
+
const source = readStringValue(connection.source);
|
|
305
|
+
const transport = readStringValue(connection.transport);
|
|
306
|
+
return {
|
|
307
|
+
...connection,
|
|
308
|
+
perspective: readStringValue(connection.perspective) ?? 'selected_coordinator',
|
|
309
|
+
source: source && source !== 'not_reported' ? source : 'mesh_peer_status',
|
|
310
|
+
state: 'connected',
|
|
311
|
+
transport: transport && transport !== 'unknown' ? transport : 'direct',
|
|
312
|
+
reported: true,
|
|
313
|
+
reason: 'Live peer git snapshot reported by the selected coordinator.',
|
|
314
|
+
lastStateChangeAt: readStringValue(connection.lastStateChangeAt) ?? timestamp,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function recordInlineMeshDirectGitTruth(
|
|
319
|
+
node: any,
|
|
320
|
+
git: Record<string, unknown>,
|
|
321
|
+
source: 'selected_coordinator_local_git' | 'selected_coordinator_mesh_p2p_git',
|
|
322
|
+
): {
|
|
323
|
+
reporterPlatform: string | null;
|
|
324
|
+
reporterArch: string | null;
|
|
325
|
+
reporterMachineNickname: string | null;
|
|
326
|
+
reporterProviderVersions: Record<string, string> | null;
|
|
327
|
+
reporterDaemonBuildVersion: string | null;
|
|
328
|
+
} {
|
|
329
|
+
if (!node || typeof node !== 'object' || Array.isArray(node)) {
|
|
330
|
+
return {
|
|
331
|
+
reporterPlatform: null,
|
|
332
|
+
reporterArch: null,
|
|
333
|
+
reporterMachineNickname: null,
|
|
334
|
+
reporterProviderVersions: null,
|
|
335
|
+
reporterDaemonBuildVersion: null,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
const checkedAt = readNumberValue(git.lastCheckedAt) ?? Date.now();
|
|
339
|
+
const updatedAt = new Date(checkedAt).toISOString();
|
|
340
|
+
const nextGit: Record<string, unknown> = {
|
|
341
|
+
...git,
|
|
342
|
+
lastCheckedAt: checkedAt,
|
|
343
|
+
};
|
|
344
|
+
node.lastGit = {
|
|
345
|
+
source,
|
|
346
|
+
checkedAt,
|
|
347
|
+
status: nextGit,
|
|
348
|
+
};
|
|
349
|
+
node.last_git = node.lastGit;
|
|
350
|
+
node.machineStatus = 'online';
|
|
351
|
+
node.updatedAt = updatedAt;
|
|
352
|
+
node.lastSeenAt = updatedAt;
|
|
353
|
+
const repoRoot = readStringValue(nextGit.repoRoot);
|
|
354
|
+
if (repoRoot && !readStringValue(node.repoRoot)) node.repoRoot = repoRoot;
|
|
355
|
+
// Self-heal per-node platform/arch from the live probe. For a remote member
|
|
356
|
+
// this is the platform the member daemon reported in its git_status envelope
|
|
357
|
+
// (threaded through as reporter*); for the local coordinator's own / worktree
|
|
358
|
+
// nodes the git was computed locally (source 'selected_coordinator_local_git'
|
|
359
|
+
// ⇒ the workspace lives on THIS machine), so process.platform/process.arch is
|
|
360
|
+
// the correct value. Stamp into userOverrides — the exact fields
|
|
361
|
+
// buildMeshNodeCapabilityTags reads — only when absent, so an operator's
|
|
362
|
+
// explicit override is preserved and the value is corrected once per reconnect
|
|
363
|
+
// without any migration.
|
|
364
|
+
const isLocalSource = source === 'selected_coordinator_local_git';
|
|
365
|
+
const reporterPlatform = readStringValue(git.reporterPlatform) ?? (isLocalSource ? process.platform : null);
|
|
366
|
+
const reporterArch = readStringValue(git.reporterArch) ?? (isLocalSource ? process.arch : null);
|
|
367
|
+
stampNodeReporterPlatform(node, reporterPlatform, reporterArch);
|
|
368
|
+
// Mirror onto the in-memory node's dedicated reporter fields too (distinct
|
|
369
|
+
// from userOverrides). For a local_config mesh the caller also persists these
|
|
370
|
+
// to meshes.json via updateNode so the value survives a coordinator restart;
|
|
371
|
+
// for an inline/cache mesh this keeps the runtime object self-consistent.
|
|
372
|
+
if (reporterPlatform) node.reportedPlatform = reporterPlatform;
|
|
373
|
+
if (reporterArch) node.reportedArch = reporterArch;
|
|
374
|
+
// Machine nickname: only a remote member self-reports it (reporterMachineNickname
|
|
375
|
+
// rides the git_status envelope). For a local_source probe the workspace lives on
|
|
376
|
+
// THIS machine, but the self/base node already carries the local config nickname
|
|
377
|
+
// (addNode stamps it), so we only stamp from an explicit report here — never
|
|
378
|
+
// overwrite an existing nickname with an empty value.
|
|
379
|
+
const reporterMachineNickname = readStringValue(git.reporterMachineNickname) ?? null;
|
|
380
|
+
if (reporterMachineNickname) node.machineNickname = reporterMachineNickname;
|
|
381
|
+
// T7: self-heal provider versions + daemon build version from the same git_status
|
|
382
|
+
// envelope. These are best-effort observability (never routing), so the raw
|
|
383
|
+
// reported map is stamped onto dedicated node fields and overwritten by the next
|
|
384
|
+
// report — never merged with a stale value the way an operator override would be.
|
|
385
|
+
const reporterProviderVersions = readProviderVersionsRecord(git.reporterProviderVersions);
|
|
386
|
+
if (reporterProviderVersions) node.reportedProviderVersions = reporterProviderVersions;
|
|
387
|
+
const reporterDaemonBuildVersion = readStringValue(git.reporterDaemonBuildVersion) ?? null;
|
|
388
|
+
if (reporterDaemonBuildVersion) node.reportedDaemonBuildVersion = reporterDaemonBuildVersion;
|
|
389
|
+
return {
|
|
390
|
+
reporterPlatform,
|
|
391
|
+
reporterArch,
|
|
392
|
+
reporterMachineNickname,
|
|
393
|
+
reporterProviderVersions,
|
|
394
|
+
reporterDaemonBuildVersion,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Coerce an unknown git-envelope `reporterProviderVersions` field into a clean
|
|
400
|
+
* `{ providerId: version }` record: only string→non-empty-string entries survive.
|
|
401
|
+
* Returns null when nothing usable is present so callers can skip the stamp.
|
|
402
|
+
*/
|
|
403
|
+
function readProviderVersionsRecord(value: unknown): Record<string, string> | null {
|
|
404
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
405
|
+
const out: Record<string, string> = {};
|
|
406
|
+
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
|
|
407
|
+
if (typeof key !== 'string' || !key.trim()) continue;
|
|
408
|
+
const version = typeof raw === 'string' ? raw.trim() : '';
|
|
409
|
+
if (!version) continue;
|
|
410
|
+
out[key] = version;
|
|
411
|
+
}
|
|
412
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Fill node.userOverrides.platform/arch from a live report, but never overwrite a
|
|
417
|
+
* value that is already present (an operator override or an earlier report). Used
|
|
418
|
+
* by both the remote-member probe path and the local self-stamp path so the
|
|
419
|
+
* coordinator advertises each node's real OS instead of falling back to the
|
|
420
|
+
* coordinator's own process.platform.
|
|
421
|
+
*/
|
|
422
|
+
function stampNodeReporterPlatform(node: any, platform: string | null, arch: string | null): void {
|
|
423
|
+
if (!node || typeof node !== 'object' || Array.isArray(node)) return;
|
|
424
|
+
if (!platform && !arch) return;
|
|
425
|
+
const overrides = (node.userOverrides && typeof node.userOverrides === 'object' && !Array.isArray(node.userOverrides))
|
|
426
|
+
? node.userOverrides as Record<string, unknown>
|
|
427
|
+
: {};
|
|
428
|
+
let changed = false;
|
|
429
|
+
if (platform && !readStringValue(overrides.platform)) { overrides.platform = platform; changed = true; }
|
|
430
|
+
if (arch && !readStringValue(overrides.arch)) { overrides.arch = arch; changed = true; }
|
|
431
|
+
if (changed) node.userOverrides = overrides;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Persist the live self-reported platform/arch onto the local meshes.json node
|
|
436
|
+
* record so capability-tag os=/arch= self-heals across coordinator restarts.
|
|
437
|
+
*
|
|
438
|
+
* The in-memory stamp done by recordInlineMeshDirectGitTruth lives on the
|
|
439
|
+
* mesh_status assembly object and is discarded after the response; only a
|
|
440
|
+
* `local_config` mesh has a backing meshes.json node to write through to. Inline
|
|
441
|
+
* cache/bootstrap meshes have no local node to update, so we no-op for them.
|
|
442
|
+
* Fire-and-forget (same pattern as the worktreeBootstrap writer) — a persistence
|
|
443
|
+
* failure must never block the status response.
|
|
444
|
+
*/
|
|
445
|
+
export function persistNodeReporterPlatform(
|
|
446
|
+
meshSource: 'inline_cache' | 'inline_bootstrap' | 'local_config',
|
|
447
|
+
mesh: any,
|
|
448
|
+
nodeId: string | undefined,
|
|
449
|
+
reporter: {
|
|
450
|
+
reporterPlatform: string | null;
|
|
451
|
+
reporterArch: string | null;
|
|
452
|
+
reporterMachineNickname?: string | null;
|
|
453
|
+
reporterProviderVersions?: Record<string, string> | null;
|
|
454
|
+
reporterDaemonBuildVersion?: string | null;
|
|
455
|
+
},
|
|
456
|
+
): void {
|
|
457
|
+
if (meshSource !== 'local_config') return;
|
|
458
|
+
const meshId = readStringValue(mesh?.id);
|
|
459
|
+
if (!meshId || !nodeId) return;
|
|
460
|
+
const reportedPlatform = reporter.reporterPlatform ?? undefined;
|
|
461
|
+
const reportedArch = reporter.reporterArch ?? undefined;
|
|
462
|
+
const reportedMachineNickname = reporter.reporterMachineNickname ?? undefined;
|
|
463
|
+
const reportedProviderVersions = reporter.reporterProviderVersions ?? undefined;
|
|
464
|
+
const reportedDaemonBuildVersion = reporter.reporterDaemonBuildVersion ?? undefined;
|
|
465
|
+
if (
|
|
466
|
+
!reportedPlatform &&
|
|
467
|
+
!reportedArch &&
|
|
468
|
+
!reportedMachineNickname &&
|
|
469
|
+
!reportedProviderVersions &&
|
|
470
|
+
!reportedDaemonBuildVersion
|
|
471
|
+
) {
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
void import('../config/mesh-config.js')
|
|
475
|
+
.then(({ updateNode }) => updateNode(meshId, nodeId, {
|
|
476
|
+
reportedPlatform,
|
|
477
|
+
reportedArch,
|
|
478
|
+
reportedMachineNickname,
|
|
479
|
+
reportedProviderVersions,
|
|
480
|
+
reportedDaemonBuildVersion,
|
|
481
|
+
}))
|
|
482
|
+
.catch(() => { /* best-effort self-heal; never block status assembly */ });
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function buildCachedInlineMeshGitStatus(node: any): Record<string, unknown> | undefined {
|
|
486
|
+
const liveGit = buildInlineMeshTransitGitStatus(node);
|
|
487
|
+
if (liveGit) return liveGit;
|
|
488
|
+
|
|
489
|
+
const cachedStatus = readObjectRecord(node?.cachedStatus);
|
|
490
|
+
const cachedGit = readObjectRecord(cachedStatus.git);
|
|
491
|
+
if (!Object.keys(cachedGit).length) return undefined;
|
|
492
|
+
return normalizeInlineMeshGitStatus(cachedGit, node);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function shouldDiscardCachedInlineMeshStatus(node: any): boolean {
|
|
496
|
+
const cachedStatus = readObjectRecord(node?.cachedStatus);
|
|
497
|
+
if (!Object.keys(cachedStatus).length) return false;
|
|
498
|
+
const cachedGit = readObjectRecord(cachedStatus.git);
|
|
499
|
+
const workspaceError = readStringValue(cachedStatus.error, node?.error);
|
|
500
|
+
if (workspaceError && /workspace must be an existing directory/i.test(workspaceError)) return true;
|
|
501
|
+
const isGitRepo = readBooleanValue(cachedGit.isGitRepo);
|
|
502
|
+
const branch = readStringValue(cachedGit.branch);
|
|
503
|
+
const headCommit = readStringValue(cachedGit.headCommit);
|
|
504
|
+
return isGitRepo === false && !branch && !headCommit;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function stripInlineMeshTransientNodeState(node: any): any {
|
|
508
|
+
if (!node || typeof node !== 'object' || Array.isArray(node)) return node;
|
|
509
|
+
const {
|
|
510
|
+
cachedStatus,
|
|
511
|
+
lastGit: _lastGit,
|
|
512
|
+
last_git: _lastGitLegacy,
|
|
513
|
+
lastProbe: _lastProbe,
|
|
514
|
+
last_probe: _lastProbeLegacy,
|
|
515
|
+
error: _error,
|
|
516
|
+
health: _health,
|
|
517
|
+
machineStatus: _machineStatus,
|
|
518
|
+
lastSeenAt: _lastSeenAt,
|
|
519
|
+
last_seen_at: _lastSeenAtLegacy,
|
|
520
|
+
updatedAt: _updatedAt,
|
|
521
|
+
updated_at: _updatedAtLegacy,
|
|
522
|
+
activeSession: _activeSession,
|
|
523
|
+
active_session: _activeSessionLegacy,
|
|
524
|
+
activeSessionId: _activeSessionId,
|
|
525
|
+
active_session_id: _activeSessionIdLegacy,
|
|
526
|
+
sessionId: _sessionId,
|
|
527
|
+
session_id: _sessionIdLegacy,
|
|
528
|
+
providerType: _providerType,
|
|
529
|
+
provider_type: _providerTypeLegacy,
|
|
530
|
+
...rest
|
|
531
|
+
} = node as Record<string, unknown>;
|
|
532
|
+
if (cachedStatus && !shouldDiscardCachedInlineMeshStatus(node)) {
|
|
533
|
+
return { ...rest, cachedStatus };
|
|
534
|
+
}
|
|
535
|
+
return rest;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function hasInlineMeshTransientNodeState(node: any): boolean {
|
|
539
|
+
if (!node || typeof node !== 'object' || Array.isArray(node)) return false;
|
|
540
|
+
return 'cachedStatus' in node
|
|
541
|
+
|| 'lastGit' in node
|
|
542
|
+
|| 'last_git' in node
|
|
543
|
+
|| 'lastProbe' in node
|
|
544
|
+
|| 'last_probe' in node
|
|
545
|
+
|| 'error' in node
|
|
546
|
+
|| 'health' in node
|
|
547
|
+
|| 'machineStatus' in node
|
|
548
|
+
|| 'lastSeenAt' in node
|
|
549
|
+
|| 'last_seen_at' in node
|
|
550
|
+
|| 'updatedAt' in node
|
|
551
|
+
|| 'updated_at' in node
|
|
552
|
+
|| 'activeSession' in node
|
|
553
|
+
|| 'active_session' in node
|
|
554
|
+
|| 'activeSessionId' in node
|
|
555
|
+
|| 'active_session_id' in node
|
|
556
|
+
|| 'sessionId' in node
|
|
557
|
+
|| 'session_id' in node
|
|
558
|
+
|| 'providerType' in node
|
|
559
|
+
|| 'provider_type' in node;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
export function inlineMeshCarriesTransientNodeTruth(inlineMesh: any): boolean {
|
|
563
|
+
if (!inlineMesh || typeof inlineMesh !== 'object' || Array.isArray(inlineMesh)) return false;
|
|
564
|
+
if (!Array.isArray(inlineMesh.nodes) || inlineMesh.nodes.length === 0) return false;
|
|
565
|
+
return inlineMesh.nodes.some((node: any) => hasInlineMeshTransientNodeState(node));
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
export function readInlineMeshNodeId(node: any): string {
|
|
569
|
+
// 3-way (id / nodeId / node_id) via the shared normalizer. The old 2-way
|
|
570
|
+
// `id ?? nodeId` dropped the SQLite `node_id` form, so an inline-cached node
|
|
571
|
+
// that arrived in that form failed to reconcile against its cached twin.
|
|
572
|
+
return normalizeMeshNodeId(node) ?? '';
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// A local worktree node whose workspace directory has been deleted from disk.
|
|
576
|
+
// The worktree was removed (or the machine pruned it) but the node still lingers
|
|
577
|
+
// in the inline mesh cache. Such a node has no live truth to confirm and must
|
|
578
|
+
// never be probed or counted toward direct-peer-truth — doing so blocks the
|
|
579
|
+
// graph with a permanent `direct_peer_truth_unavailable`. Deliberately narrow:
|
|
580
|
+
// it only fires for `isLocalWorktree === true` nodes with a recorded workspace
|
|
581
|
+
// that does not exist. Remote nodes and nodes whose workspace is present on disk
|
|
582
|
+
// are never matched, so a slow remote peer is still classified unavailable.
|
|
583
|
+
export function isDeadLocalWorktreeNode(node: any): boolean {
|
|
584
|
+
if (node?.isLocalWorktree !== true) return false;
|
|
585
|
+
const workspace = readStringValue(node?.workspace);
|
|
586
|
+
if (!workspace) return false;
|
|
587
|
+
return !fs.existsSync(workspace);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Boundary normalization: reconcile a node's identity so `id` and `nodeId` both
|
|
591
|
+
// carry the same canonical value (any incoming form — id / nodeId / node_id — is
|
|
592
|
+
// absorbed by normalizeMeshNodeId, and the SQLite `node_id` leak is dropped).
|
|
593
|
+
// See foldMeshNodeIdentityToCanonical for why both fields are kept equal rather
|
|
594
|
+
// than collapsing to one. The rewrite is shallow (other runtime fields are
|
|
595
|
+
// preserved); records that already agree are returned unchanged so
|
|
596
|
+
// identity-equality fast paths hold.
|
|
597
|
+
export function foldMeshNodeIdentityToCanonical(node: any): any {
|
|
598
|
+
if (!node || typeof node !== 'object' || Array.isArray(node)) return node;
|
|
599
|
+
const canonical = normalizeMeshNodeId(node);
|
|
600
|
+
if (canonical === undefined) return node;
|
|
601
|
+
// Save-boundary identity folding, applied IN PLACE. We DUAL-WRITE both `id`
|
|
602
|
+
// and `nodeId` to the single canonical value (and drop the SQLite `node_id`
|
|
603
|
+
// leak), rather than collapsing to one field. Two halves of the system read
|
|
604
|
+
// different field names: the mesh_status serializer emits `nodeId`, while the
|
|
605
|
+
// worktree clone path and get_mesh membership consumers read `node.id`.
|
|
606
|
+
// Folding to ONE form would break whichever side reads the other. Keeping
|
|
607
|
+
// both fields equal makes every reader correct AND makes the
|
|
608
|
+
// snapshot→cache→reconcile→snapshot round-trip form-stable (no field ever
|
|
609
|
+
// flips, because both always agree). Mutating in place (not returning a new
|
|
610
|
+
// object) preserves the cached node-object identity that callers warming an
|
|
611
|
+
// inline mesh from an already-shared snapshot rely on.
|
|
612
|
+
// Intentional per-field raw compare against the already-computed canonical
|
|
613
|
+
// value: this is the fold's no-op fast path, checking whether EACH form field
|
|
614
|
+
// is already folded. Using meshNodeIdMatches (which normalizes across forms)
|
|
615
|
+
// would defeat the point — we must inspect each raw field's current state, not
|
|
616
|
+
// a form-agnostic match. Hence the identity-guard opt-out below.
|
|
617
|
+
// eslint-disable-next-line no-restricted-syntax -- verified same-source canonical no-op guard (see above)
|
|
618
|
+
if (node.id === canonical && node.nodeId === canonical && node.node_id === undefined) return node;
|
|
619
|
+
node.id = canonical;
|
|
620
|
+
node.nodeId = canonical;
|
|
621
|
+
if ('node_id' in node) delete node.node_id;
|
|
622
|
+
return node;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
export function normalizeInlineMeshNodeIdentity(inlineMesh: any): any {
|
|
626
|
+
if (!inlineMesh || typeof inlineMesh !== 'object' || Array.isArray(inlineMesh)) return inlineMesh;
|
|
627
|
+
if (!Array.isArray(inlineMesh.nodes) || inlineMesh.nodes.length === 0) return inlineMesh;
|
|
628
|
+
// Fold each node IN PLACE so the mesh object and its nodes array keep their
|
|
629
|
+
// identity — sanitizeInlineMesh and the cache-sharing callers depend on
|
|
630
|
+
// unchanged inputs returning the same reference.
|
|
631
|
+
for (const node of inlineMesh.nodes) foldMeshNodeIdentityToCanonical(node);
|
|
632
|
+
return inlineMesh;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
export function sanitizeInlineMesh(inlineMesh: any): any {
|
|
636
|
+
if (!inlineMesh || typeof inlineMesh !== 'object' || Array.isArray(inlineMesh)) return inlineMesh;
|
|
637
|
+
if (!Array.isArray(inlineMesh.nodes)) return inlineMesh;
|
|
638
|
+
let changed = false;
|
|
639
|
+
const nodes = inlineMesh.nodes.map((node: any) => {
|
|
640
|
+
if (!hasInlineMeshTransientNodeState(node)) return node;
|
|
641
|
+
changed = true;
|
|
642
|
+
return stripInlineMeshTransientNodeState(node);
|
|
643
|
+
});
|
|
644
|
+
if (!changed) return inlineMesh;
|
|
645
|
+
return {
|
|
646
|
+
...inlineMesh,
|
|
647
|
+
nodes,
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
export function reconcileInlineMeshCache(cached: any, incoming: any): any {
|
|
652
|
+
if (!cached || typeof cached !== 'object' || Array.isArray(cached)) return incoming;
|
|
653
|
+
if (!incoming || typeof incoming !== 'object' || Array.isArray(incoming)) return cached;
|
|
654
|
+
const cachedNodes = Array.isArray(cached.nodes) ? cached.nodes : [];
|
|
655
|
+
const incomingNodes = Array.isArray(incoming.nodes) ? incoming.nodes : [];
|
|
656
|
+
if (!cachedNodes.length || !incomingNodes.length) return { ...cached, ...incoming };
|
|
657
|
+
|
|
658
|
+
const cachedUpdatedAt = Date.parse(readStringValue(cached.updatedAt, cached.updated_at) || '');
|
|
659
|
+
const incomingUpdatedAt = Date.parse(readStringValue(incoming.updatedAt, incoming.updated_at) || '');
|
|
660
|
+
const preserveCachedMembership = Number.isFinite(cachedUpdatedAt)
|
|
661
|
+
&& (!Number.isFinite(incomingUpdatedAt) || cachedUpdatedAt > incomingUpdatedAt);
|
|
662
|
+
|
|
663
|
+
const cachedById = new Map<string, any>();
|
|
664
|
+
for (const node of cachedNodes) {
|
|
665
|
+
const nodeId = readInlineMeshNodeId(node);
|
|
666
|
+
if (nodeId) cachedById.set(nodeId, node);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const mergedIncomingIds = new Set<string>();
|
|
670
|
+
const nodes = incomingNodes.map((incomingNode: any) => {
|
|
671
|
+
const nodeId = readInlineMeshNodeId(incomingNode);
|
|
672
|
+
const cachedNode = nodeId ? cachedById.get(nodeId) : undefined;
|
|
673
|
+
if (!cachedNode && preserveCachedMembership) return null;
|
|
674
|
+
if (nodeId) mergedIncomingIds.add(nodeId);
|
|
675
|
+
if (!cachedNode) return incomingNode;
|
|
676
|
+
if (hasInlineMeshTransientNodeState(incomingNode)) {
|
|
677
|
+
return { ...cachedNode, ...incomingNode };
|
|
678
|
+
}
|
|
679
|
+
return { ...stripInlineMeshTransientNodeState(cachedNode), ...incomingNode };
|
|
680
|
+
}).filter(Boolean);
|
|
681
|
+
|
|
682
|
+
// When the cached membership is authoritative (newer than the incoming
|
|
683
|
+
// snapshot), nodes that exist only in the cache must survive reconciliation.
|
|
684
|
+
// A freshly cloned worktree node lives only in the coordinator's cache until
|
|
685
|
+
// the next snapshot catches up; iterating incomingNodes alone would silently
|
|
686
|
+
// drop it, making the node invisible to get_mesh / membership reads even
|
|
687
|
+
// though worktree_bootstrap_complete already fired.
|
|
688
|
+
if (preserveCachedMembership) {
|
|
689
|
+
for (const cachedNode of cachedNodes) {
|
|
690
|
+
const nodeId = readInlineMeshNodeId(cachedNode);
|
|
691
|
+
if (nodeId && !mergedIncomingIds.has(nodeId)) {
|
|
692
|
+
nodes.push(cachedNode);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
return {
|
|
698
|
+
...cached,
|
|
699
|
+
...incoming,
|
|
700
|
+
nodes,
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function hasGitWorktreeChanges(git: Record<string, unknown> | null | undefined): boolean {
|
|
705
|
+
return countGitWorktreeChanges(git) > 0;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function countGitWorktreeChanges(git: Record<string, unknown> | null | undefined): number {
|
|
709
|
+
if (!git) return 0;
|
|
710
|
+
return Number(git.staged || 0)
|
|
711
|
+
+ Number(git.modified || 0)
|
|
712
|
+
+ Number(git.untracked || 0)
|
|
713
|
+
+ Number(git.deleted || 0)
|
|
714
|
+
+ Number(git.renamed || 0);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function getGitSubmoduleDriftState(git: Record<string, unknown> | null | undefined): { dirty: boolean; outOfSync: boolean } {
|
|
718
|
+
const submodules = Array.isArray(git?.submodules) ? git.submodules : [];
|
|
719
|
+
let dirty = false;
|
|
720
|
+
let outOfSync = false;
|
|
721
|
+
for (const entry of submodules) {
|
|
722
|
+
const submodule = readObjectRecord(entry);
|
|
723
|
+
if (readBooleanValue(submodule.dirty) === true) dirty = true;
|
|
724
|
+
if (readBooleanValue(submodule.outOfSync) === true || !!readStringValue(submodule.error)) outOfSync = true;
|
|
725
|
+
}
|
|
726
|
+
return { dirty, outOfSync };
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function isInlineMeshAutoFastForwardEligible(git: Record<string, unknown> | null | undefined): boolean {
|
|
730
|
+
if (!git) return false;
|
|
731
|
+
if (readBooleanValue(git.isGitRepo) !== true) return false;
|
|
732
|
+
if (!readStringValue(git.branch)) return false;
|
|
733
|
+
if (!readStringValue(git.upstream)) return false;
|
|
734
|
+
const upstreamStatus = readStringValue(git.upstreamStatus, git.upstream_status);
|
|
735
|
+
if (upstreamStatus !== 'fresh') return false;
|
|
736
|
+
if ((readNumberValue(git.ahead) ?? 0) !== 0) return false;
|
|
737
|
+
if ((readNumberValue(git.behind) ?? 0) <= 0) return false;
|
|
738
|
+
const hasConflicts = readBooleanValue(git.hasConflicts)
|
|
739
|
+
?? (Array.isArray(git.conflictFiles) && git.conflictFiles.length > 0);
|
|
740
|
+
if (hasConflicts) return false;
|
|
741
|
+
if ((readNumberValue(git.stashCount, git.stash_count) ?? 0) > 0) return false;
|
|
742
|
+
const submoduleDrift = getGitSubmoduleDriftState(git);
|
|
743
|
+
if (submoduleDrift.dirty || submoduleDrift.outOfSync) return false;
|
|
744
|
+
const dirty = readBooleanValue(git.dirty) ?? (countGitWorktreeChanges(git) > 0);
|
|
745
|
+
return dirty !== true && countGitWorktreeChanges(git) === 0;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
export function deriveMeshNodeHealthFromGit(git: Record<string, unknown> | null | undefined): 'online' | 'dirty' | 'degraded' {
|
|
749
|
+
if (!git || readBooleanValue(git.isGitRepo) === false) return 'degraded';
|
|
750
|
+
const branch = readStringValue(git.branch);
|
|
751
|
+
if (!branch) return 'degraded';
|
|
752
|
+
const submoduleDrift = getGitSubmoduleDriftState(git);
|
|
753
|
+
if (submoduleDrift.outOfSync) return 'degraded';
|
|
754
|
+
if (submoduleDrift.dirty || hasGitWorktreeChanges(git)) return 'dirty';
|
|
755
|
+
return 'online';
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function readMeshNodeLabel(status: Record<string, unknown>, node: any): string {
|
|
759
|
+
return readStringValue(status.nodeId, normalizeMeshNodeId(node)) ?? 'unknown';
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function buildInlineMeshBranchConvergence(args: {
|
|
763
|
+
mesh: any;
|
|
764
|
+
node: any;
|
|
765
|
+
status: Record<string, unknown>;
|
|
766
|
+
}): Record<string, unknown> {
|
|
767
|
+
const git = readObjectRecord(args.status.git);
|
|
768
|
+
const nodeLabel = readMeshNodeLabel(args.status, args.node);
|
|
769
|
+
const defaultBranch = readStringValue(args.mesh?.defaultBranch) ?? 'main';
|
|
770
|
+
const branch = readStringValue(git.branch, args.node?.worktreeBranch) ?? null;
|
|
771
|
+
const upstream = readStringValue(git.upstream) ?? null;
|
|
772
|
+
const upstreamStatus = readStringValue(git.upstreamStatus, git.upstream_status)
|
|
773
|
+
?? (upstream ? 'unchecked' : 'no_upstream');
|
|
774
|
+
const ahead = readNumberValue(git.ahead) ?? 0;
|
|
775
|
+
const behind = readNumberValue(git.behind) ?? 0;
|
|
776
|
+
const uncommittedChanges = countGitWorktreeChanges(git);
|
|
777
|
+
const hasConflicts = readBooleanValue(git.hasConflicts)
|
|
778
|
+
?? (Array.isArray(git.conflictFiles) && git.conflictFiles.length > 0);
|
|
779
|
+
const base = {
|
|
780
|
+
defaultBranch,
|
|
781
|
+
branch,
|
|
782
|
+
upstream,
|
|
783
|
+
upstreamStatus,
|
|
784
|
+
ahead,
|
|
785
|
+
behind,
|
|
786
|
+
isWorktree: args.node?.isLocalWorktree === true || args.status.isLocalWorktree === true,
|
|
787
|
+
isDefaultBranch: branch === defaultBranch,
|
|
788
|
+
};
|
|
789
|
+
|
|
790
|
+
if (readBooleanValue(git.isGitRepo) !== true) {
|
|
791
|
+
return {
|
|
792
|
+
...base,
|
|
793
|
+
status: 'blocked_review',
|
|
794
|
+
needsConvergence: true,
|
|
795
|
+
reason: 'git_status_unavailable',
|
|
796
|
+
nextStep: `Resolve git status for node '${nodeLabel}' before marking the task complete.`,
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
if (!branch) {
|
|
801
|
+
return {
|
|
802
|
+
...base,
|
|
803
|
+
status: 'blocked_review',
|
|
804
|
+
needsConvergence: true,
|
|
805
|
+
reason: 'branch_unknown',
|
|
806
|
+
nextStep: `Inspect node '${nodeLabel}' git branch before deciding whether it is merged to ${defaultBranch}.`,
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
if (hasConflicts || uncommittedChanges > 0) {
|
|
811
|
+
return {
|
|
812
|
+
...base,
|
|
813
|
+
status: 'not_mergeable',
|
|
814
|
+
needsConvergence: true,
|
|
815
|
+
reason: hasConflicts ? 'conflicts_present' : 'dirty_workspace',
|
|
816
|
+
nextStep: `Commit, checkpoint, or resolve node '${nodeLabel}' before any main convergence step.`,
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
if (branch === defaultBranch) {
|
|
821
|
+
if (upstream && upstreamStatus !== 'fresh') {
|
|
822
|
+
return {
|
|
823
|
+
...base,
|
|
824
|
+
status: 'blocked_review',
|
|
825
|
+
needsConvergence: true,
|
|
826
|
+
reason: 'default_branch_upstream_unverified',
|
|
827
|
+
nextStep: `Refresh ${defaultBranch}'s upstream refs or resolve the fetch failure before declaring convergence complete for node '${nodeLabel}'.`,
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
if (ahead > 0 || behind > 0) {
|
|
831
|
+
return {
|
|
832
|
+
...base,
|
|
833
|
+
status: 'blocked_review',
|
|
834
|
+
needsConvergence: true,
|
|
835
|
+
reason: 'default_branch_not_even_with_upstream',
|
|
836
|
+
nextStep: `Bring ${defaultBranch} even with its upstream before declaring convergence complete.`,
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
return {
|
|
840
|
+
...base,
|
|
841
|
+
status: 'merged_to_main',
|
|
842
|
+
needsConvergence: false,
|
|
843
|
+
reason: 'clean_default_branch',
|
|
844
|
+
nextStep: null,
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
if (args.node?.isLocalWorktree === true || args.status.isLocalWorktree === true) {
|
|
849
|
+
return {
|
|
850
|
+
...base,
|
|
851
|
+
status: 'cleanup_candidate',
|
|
852
|
+
needsConvergence: true,
|
|
853
|
+
reason: 'clean_non_default_worktree_branch',
|
|
854
|
+
nextStep: `Run mesh_refine_node(node_id: "${nodeLabel}") or explicitly classify this worktree as blocked_review/not_mergeable before ending the task.`,
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
if (upstream && upstreamStatus !== 'fresh') {
|
|
859
|
+
return {
|
|
860
|
+
...base,
|
|
861
|
+
status: 'blocked_review',
|
|
862
|
+
needsConvergence: true,
|
|
863
|
+
reason: 'feature_branch_upstream_unverified',
|
|
864
|
+
nextStep: `Refresh branch '${branch}' upstream refs or resolve the fetch failure before deciding whether it is ready to merge into ${defaultBranch}.`,
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
if (!upstream || ahead > 0 || behind > 0) {
|
|
869
|
+
return {
|
|
870
|
+
...base,
|
|
871
|
+
status: 'blocked_review',
|
|
872
|
+
needsConvergence: true,
|
|
873
|
+
reason: !upstream ? 'feature_branch_missing_upstream' : 'feature_branch_not_even_with_upstream',
|
|
874
|
+
nextStep: `Push or reconcile branch '${branch}', then merge it into ${defaultBranch} or mark it not_mergeable with a reason.`,
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
return {
|
|
879
|
+
...base,
|
|
880
|
+
status: 'pushed_feature_branch_needs_merge',
|
|
881
|
+
needsConvergence: true,
|
|
882
|
+
reason: 'clean_non_default_branch',
|
|
883
|
+
nextStep: `Review and merge branch '${branch}' into ${defaultBranch}; do not report the task as fully complete while it remains off main.`,
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
export function applyInlineMeshBranchConvergence(mesh: any, node: any, status: Record<string, unknown>): void {
|
|
888
|
+
const git = readObjectRecord(status.git);
|
|
889
|
+
if (Object.keys(git).length === 0 && !status.gitProbePending) return;
|
|
890
|
+
const uncommittedChanges = countGitWorktreeChanges(git);
|
|
891
|
+
status.isDirty = uncommittedChanges > 0;
|
|
892
|
+
status.uncommittedChanges = uncommittedChanges;
|
|
893
|
+
status.branchConvergence = buildInlineMeshBranchConvergence({ mesh, node, status });
|
|
894
|
+
status.autoFastForwardEligible = isInlineMeshAutoFastForwardEligible(git);
|
|
895
|
+
if (status.autoFastForwardEligible) {
|
|
896
|
+
status.suggestedAction = 'auto_fast_forward';
|
|
897
|
+
} else {
|
|
898
|
+
delete status.suggestedAction;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
export function summarizeInlineMeshBranchConvergence(nodes: Array<Record<string, unknown>>): Record<string, unknown> {
|
|
903
|
+
const followUps = nodes
|
|
904
|
+
.filter(node => {
|
|
905
|
+
if (readObjectRecord(node.branchConvergence).needsConvergence !== true) return false;
|
|
906
|
+
const workspace = typeof node.workspace === 'string' ? node.workspace : '';
|
|
907
|
+
if (workspace && !fs.existsSync(workspace)) return false;
|
|
908
|
+
return true;
|
|
909
|
+
})
|
|
910
|
+
.map(node => {
|
|
911
|
+
const convergence = readObjectRecord(node.branchConvergence);
|
|
912
|
+
return {
|
|
913
|
+
nodeId: node.nodeId,
|
|
914
|
+
workspace: node.workspace,
|
|
915
|
+
branch: convergence.branch,
|
|
916
|
+
status: convergence.status,
|
|
917
|
+
reason: convergence.reason,
|
|
918
|
+
nextStep: convergence.nextStep,
|
|
919
|
+
};
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
return {
|
|
923
|
+
needsFollowUp: followUps.length > 0,
|
|
924
|
+
unresolvedCount: followUps.length,
|
|
925
|
+
requiredFinalStates: ['merged_to_main', 'pushed_feature_branch_needs_merge', 'blocked_review', 'cleanup_candidate', 'not_mergeable'],
|
|
926
|
+
followUps,
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
export function readCachedInlineMeshActiveSessions(node: any): string[] {
|
|
931
|
+
const cachedStatus = readObjectRecord(node?.cachedStatus);
|
|
932
|
+
const activeSession = readObjectRecord(cachedStatus.activeSession);
|
|
933
|
+
const fallbackSession = Object.keys(activeSession).length
|
|
934
|
+
? activeSession
|
|
935
|
+
: readObjectRecord(node?.activeSession ?? node?.active_session);
|
|
936
|
+
const sessionId = readStringValue(fallbackSession.id, fallbackSession.sessionId, fallbackSession.session_id, node?.activeSessionId, node?.active_session_id, node?.sessionId, node?.session_id);
|
|
937
|
+
return sessionId ? [sessionId] : [];
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* Wider session-ownership scan used ONLY by resolveRemoteMeshSessionOwnerDaemonId: collect
|
|
942
|
+
* EVERY session id a mesh node currently hosts. readCachedInlineMeshActiveSessions above only
|
|
943
|
+
* surfaces the node's single primary session (cachedStatus.activeSession) — other consumers
|
|
944
|
+
* depend on that one-session semantics, so it is left untouched. A worker hosting more than one
|
|
945
|
+
* session exposes its non-primary sessions only through the plural live-session arrays the
|
|
946
|
+
* coordinator carries: status.activeSessions / activeSessionDetails (built from live records on
|
|
947
|
+
* the aggregate snapshot, see get_mesh_status), or a worker's merged session report. A
|
|
948
|
+
* controlbar/modal command (invoke_provider_script / resolve_action / set_mode / …) targeting a
|
|
949
|
+
* non-primary remote session resolves its owner daemon only when those plural shapes are scanned
|
|
950
|
+
* too. Mirrors sessionStatusFromNodes' shape tolerance (mesh-active-work.ts): plural arrays of
|
|
951
|
+
* string ids OR objects keyed by id/sessionId/session_id/runtimeSessionId/instanceId, on both
|
|
952
|
+
* camelCase and snake_case, at the node root and under cachedStatus / lastProbe.
|
|
953
|
+
*/
|
|
954
|
+
export function collectMeshNodeHostedSessionIds(node: any): Set<string> {
|
|
955
|
+
const ids = new Set<string>();
|
|
956
|
+
for (const id of readCachedInlineMeshActiveSessions(node)) ids.add(id);
|
|
957
|
+
const cachedStatus = readObjectRecord(node?.cachedStatus);
|
|
958
|
+
for (const value of [
|
|
959
|
+
node?.activeSessions,
|
|
960
|
+
node?.active_sessions,
|
|
961
|
+
node?.activeSessionDetails,
|
|
962
|
+
node?.active_session_details,
|
|
963
|
+
node?.sessions,
|
|
964
|
+
node?.sessionDetails,
|
|
965
|
+
node?.session_details,
|
|
966
|
+
readObjectRecord(node?.lastProbe).sessions,
|
|
967
|
+
readObjectRecord(node?.last_probe).sessions,
|
|
968
|
+
cachedStatus.activeSessions,
|
|
969
|
+
cachedStatus.active_sessions,
|
|
970
|
+
cachedStatus.activeSessionDetails,
|
|
971
|
+
cachedStatus.active_session_details,
|
|
972
|
+
cachedStatus.sessions,
|
|
973
|
+
]) {
|
|
974
|
+
if (!Array.isArray(value)) continue;
|
|
975
|
+
for (const item of value) {
|
|
976
|
+
if (typeof item === 'string') {
|
|
977
|
+
const id = readStringValue(item);
|
|
978
|
+
if (id) ids.add(id);
|
|
979
|
+
continue;
|
|
980
|
+
}
|
|
981
|
+
const record = readObjectRecord(item);
|
|
982
|
+
const id = readStringValue(record.id, record.sessionId, record.session_id, record.runtimeSessionId, record.instanceId);
|
|
983
|
+
if (id) ids.add(id);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
return ids;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* Resolve the owning-node attribution for a mesh node record so a coordinator can
|
|
991
|
+
* stamp the TRUE owner onto a synthetic session entry instead of letting the
|
|
992
|
+
* dashboard fall back to the coordinator's own daemonId. Returns whichever of the
|
|
993
|
+
* owning node's `daemonId` / display machine name could be read from the node's
|
|
994
|
+
* (possibly multi-serialization-path) shape; both may be undefined for a node that
|
|
995
|
+
* never carried machine identity.
|
|
996
|
+
*/
|
|
997
|
+
export function resolveMeshNodeAttribution(node: unknown): { daemonId?: string; machineName?: string } {
|
|
998
|
+
const record = readObjectRecord(node);
|
|
999
|
+
return {
|
|
1000
|
+
daemonId: readMeshNodeDaemonId(record),
|
|
1001
|
+
machineName: readMeshNodeDisplayMachineName(record),
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
export function readCachedInlineMeshActiveSessionDetails(node: any): Array<Record<string, unknown>> {
|
|
1006
|
+
const cachedStatus = readObjectRecord(node?.cachedStatus);
|
|
1007
|
+
const activeSession = readObjectRecord(cachedStatus.activeSession);
|
|
1008
|
+
const fallbackSession = Object.keys(activeSession).length
|
|
1009
|
+
? activeSession
|
|
1010
|
+
: readObjectRecord(node?.activeSession ?? node?.active_session);
|
|
1011
|
+
const sessionId = readStringValue(
|
|
1012
|
+
fallbackSession.id,
|
|
1013
|
+
fallbackSession.sessionId,
|
|
1014
|
+
fallbackSession.session_id,
|
|
1015
|
+
node?.activeSessionId,
|
|
1016
|
+
node?.active_session_id,
|
|
1017
|
+
node?.sessionId,
|
|
1018
|
+
node?.session_id,
|
|
1019
|
+
);
|
|
1020
|
+
if (!sessionId) return [];
|
|
1021
|
+
return [{
|
|
1022
|
+
sessionId,
|
|
1023
|
+
providerType: readStringValue(
|
|
1024
|
+
fallbackSession.providerType,
|
|
1025
|
+
fallbackSession.provider_type,
|
|
1026
|
+
fallbackSession.cliType,
|
|
1027
|
+
fallbackSession.cli_type,
|
|
1028
|
+
fallbackSession.provider,
|
|
1029
|
+
node?.providerType,
|
|
1030
|
+
node?.provider_type,
|
|
1031
|
+
),
|
|
1032
|
+
state: readStringValue(fallbackSession.status, fallbackSession.state, fallbackSession.lifecycle),
|
|
1033
|
+
chatStatus: readStringValue(fallbackSession.chatStatus, fallbackSession.chat_status),
|
|
1034
|
+
lifecycle: readStringValue(fallbackSession.lifecycle),
|
|
1035
|
+
title: readStringValue(fallbackSession.title, fallbackSession.displayName, fallbackSession.display_name) ?? null,
|
|
1036
|
+
workspace: readStringValue(fallbackSession.workspace, node?.workspace) ?? null,
|
|
1037
|
+
role: readStringValue(fallbackSession.role) ?? null,
|
|
1038
|
+
isSelfCoordinator: fallbackSession.isSelfCoordinator === true || fallbackSession.is_self_coordinator === true,
|
|
1039
|
+
createdAt: readStringValue(fallbackSession.createdAt, fallbackSession.created_at) ?? null,
|
|
1040
|
+
startedAt: readStringValue(fallbackSession.startedAt, fallbackSession.started_at) ?? null,
|
|
1041
|
+
lastActivityAt: readStringValue(fallbackSession.lastActivityAt, fallbackSession.last_activity_at) ?? null,
|
|
1042
|
+
recoveryState: readStringValue(fallbackSession.recoveryState, fallbackSession.recovery_state) ?? null,
|
|
1043
|
+
// [T2] Carry the worker-computed last-message preview through the cached inline-mesh
|
|
1044
|
+
// active-session entry. The worker's get_status_metadata slim now ships these
|
|
1045
|
+
// (mesh-tools.ts), and this is the surface the coordinator's inbox-preview path reads
|
|
1046
|
+
// (buildDaemonMetadataUpdateForSubscription → stampLocalAssistantPreviewOnCachedEntry).
|
|
1047
|
+
// Without carrying them here, the coordinator could only derive the preview from a live
|
|
1048
|
+
// in-process instance it doesn't host for a remote worker, so the inbox stuck on the
|
|
1049
|
+
// dispatched user task. Only present when the worker reported them.
|
|
1050
|
+
...(readStringValue(fallbackSession.lastMessagePreview, fallbackSession.last_message_preview)
|
|
1051
|
+
? { lastMessagePreview: readStringValue(fallbackSession.lastMessagePreview, fallbackSession.last_message_preview) } : {}),
|
|
1052
|
+
...(readStringValue(fallbackSession.lastMessageRole, fallbackSession.last_message_role)
|
|
1053
|
+
? { lastMessageRole: readStringValue(fallbackSession.lastMessageRole, fallbackSession.last_message_role) } : {}),
|
|
1054
|
+
...(readNumberValue(fallbackSession.lastMessageAt, fallbackSession.last_message_at) !== undefined
|
|
1055
|
+
? { lastMessageAt: readNumberValue(fallbackSession.lastMessageAt, fallbackSession.last_message_at) } : {}),
|
|
1056
|
+
isCached: true,
|
|
1057
|
+
}];
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function readLiveMeshSessionState(record: any): string | undefined {
|
|
1061
|
+
return readStringValue(
|
|
1062
|
+
record?.meta?.sessionStatus,
|
|
1063
|
+
record?.meta?.status,
|
|
1064
|
+
record?.meta?.providerStatus,
|
|
1065
|
+
record?.status,
|
|
1066
|
+
record?.state,
|
|
1067
|
+
record?.lifecycle,
|
|
1068
|
+
);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function toIsoTimestamp(value: unknown): string | null {
|
|
1072
|
+
if (typeof value === 'number' && Number.isFinite(value)) return new Date(value).toISOString();
|
|
1073
|
+
const stringValue = readStringValue(value);
|
|
1074
|
+
return stringValue || null;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function synthesizeMeshNodeFreshnessFromConnection(status: Record<string, unknown>): void {
|
|
1078
|
+
const connection = readObjectRecord(status.connection);
|
|
1079
|
+
const connectionFreshAt = toIsoTimestamp(connection.lastCommandAt ?? connection.lastConnectedAt ?? connection.lastStateChangeAt);
|
|
1080
|
+
const git = readObjectRecord(status.git);
|
|
1081
|
+
const gitCheckedAt = toIsoTimestamp(git.lastCheckedAt);
|
|
1082
|
+
if (!status.lastSeenAt && connectionFreshAt) status.lastSeenAt = connectionFreshAt;
|
|
1083
|
+
if (!status.updatedAt && (gitCheckedAt || connectionFreshAt)) {
|
|
1084
|
+
status.updatedAt = gitCheckedAt ?? connectionFreshAt;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/**
|
|
1089
|
+
* Transient per-node marker the mesh_status render loop stamps onto a node
|
|
1090
|
+
* `status` at the two sites that obtain git truth from a FRESH probe this call
|
|
1091
|
+
* (a successful local `getGitRepoStatus`, or a successful P2P `git_status`
|
|
1092
|
+
* round-trip). finalizeMeshNodeStatus consumes and deletes it. Held/standing
|
|
1093
|
+
* truth (node.lastGit / cachedStatus / inline transit) is deliberately NOT
|
|
1094
|
+
* stamped — its absence is exactly how the freshness marker tells "live" apart
|
|
1095
|
+
* from "cached". Internal only; never serialized in the response.
|
|
1096
|
+
*/
|
|
1097
|
+
export const MESH_NODE_LIVE_TRUTH_MARKER = '__liveTruthProbed';
|
|
1098
|
+
|
|
1099
|
+
type MeshNodeDataSource =
|
|
1100
|
+
| 'self' // the selected coordinator's own node — local truth
|
|
1101
|
+
| 'live' // git/session truth confirmed by a fresh probe THIS call
|
|
1102
|
+
| 'cached' // rendered from held standing truth (possibly old — see staleness)
|
|
1103
|
+
| 'pending' // reachable/known but no probe attempted yet (default load)
|
|
1104
|
+
| 'unreachable' // peer could not be reached (P2P probe failed / not connected, no held truth)
|
|
1105
|
+
| 'empty' // reachable but genuinely no session + git data
|
|
1106
|
+
| 'unconfigured'; // node has no daemonId, so transport truth cannot be reported
|
|
1107
|
+
|
|
1108
|
+
type MeshNodeStaleness = 'fresh' | 'recent' | 'stale' | 'unknown';
|
|
1109
|
+
|
|
1110
|
+
// Staleness buckets (ms). Held/cached truth younger than FRESH reads as fresh,
|
|
1111
|
+
// younger than RECENT as recent, older as stale. Kept coarse on purpose — the
|
|
1112
|
+
// coordinator only needs "just-now / minutes-old / old", not millisecond precision.
|
|
1113
|
+
const MESH_FRESHNESS_FRESH_MS = 30_000;
|
|
1114
|
+
const MESH_FRESHNESS_RECENT_MS = 300_000;
|
|
1115
|
+
|
|
1116
|
+
function classifyMeshNodeStaleness(dataSource: MeshNodeDataSource, ageMs: number | null): MeshNodeStaleness {
|
|
1117
|
+
if (dataSource === 'self' || dataSource === 'live') return 'fresh';
|
|
1118
|
+
if (ageMs === null) return 'unknown';
|
|
1119
|
+
if (ageMs < MESH_FRESHNESS_FRESH_MS) return 'fresh';
|
|
1120
|
+
if (ageMs < MESH_FRESHNESS_RECENT_MS) return 'recent';
|
|
1121
|
+
return 'stale';
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* Build the additive per-node `dataFreshness` marker. This NEVER mutates any
|
|
1126
|
+
* existing field — it only adds an explicit, machine-readable answer to the
|
|
1127
|
+
* question the legacy fields blurred: is this node's data live (just probed),
|
|
1128
|
+
* cached (held truth, maybe old), or absent because the peer was unreachable?
|
|
1129
|
+
*
|
|
1130
|
+
* The crucial separation: an UNREACHABLE peer (P2P probe failed / not connected)
|
|
1131
|
+
* is no longer indistinguishable from an idle/EMPTY node. Both used to render as
|
|
1132
|
+
* `health:'unknown'` with no sessions; now `dataFreshness.dataSource` and
|
|
1133
|
+
* `reachable` tell them apart so a coordinator never reads a dead peer as "online
|
|
1134
|
+
* but doing nothing".
|
|
1135
|
+
*/
|
|
1136
|
+
export function buildMeshNodeDataFreshness(args: {
|
|
1137
|
+
status: Record<string, unknown>;
|
|
1138
|
+
node?: any;
|
|
1139
|
+
isSelfNode: boolean;
|
|
1140
|
+
daemonId?: string;
|
|
1141
|
+
/** True when this node was stamped with a fresh live git probe this call. */
|
|
1142
|
+
liveTruthProbed: boolean;
|
|
1143
|
+
/** True when direct-peer-truth accounting classified this node unavailable. */
|
|
1144
|
+
directTruthUnavailable?: boolean;
|
|
1145
|
+
now?: () => number;
|
|
1146
|
+
}): Record<string, unknown> {
|
|
1147
|
+
const { status, node, isSelfNode, daemonId, liveTruthProbed, directTruthUnavailable } = args;
|
|
1148
|
+
const now = args.now ?? Date.now;
|
|
1149
|
+
const connection = readObjectRecord(status.connection);
|
|
1150
|
+
const connectionState = readStringValue(connection.state);
|
|
1151
|
+
const git = readObjectRecord(status.git);
|
|
1152
|
+
const hasGit = readBooleanValue(git.isGitRepo) === true
|
|
1153
|
+
|| !!readStringValue(git.branch, git.headCommit, git.head, git.upstream);
|
|
1154
|
+
const connectionFreshAt = toIsoTimestamp(connection.lastCommandAt ?? connection.lastConnectedAt ?? connection.lastStateChangeAt);
|
|
1155
|
+
// Provenance-aware probe time. A FRESH probe this call writes a genuine
|
|
1156
|
+
// git.lastCheckedAt, so trust it for live nodes. Held/standing truth, however,
|
|
1157
|
+
// is re-normalized through pickBestTransitGitStatus which stamps lastCheckedAt
|
|
1158
|
+
// with Date.now() on assembly (git-normalize.ts) — so status.git.lastCheckedAt
|
|
1159
|
+
// would falsely read fresh. For cached nodes prefer the authentic peer-reported
|
|
1160
|
+
// check time persisted on node.lastGit.checkedAt / cachedStatus, so a genuinely
|
|
1161
|
+
// old cache is correctly reported stale.
|
|
1162
|
+
const liveGitCheckedAt = liveTruthProbed ? toIsoTimestamp(git.lastCheckedAt) : null;
|
|
1163
|
+
const heldGit = readObjectRecord(node?.lastGit ?? node?.last_git);
|
|
1164
|
+
const heldCheckedAt = toIsoTimestamp(heldGit.checkedAt ?? heldGit.checked_at);
|
|
1165
|
+
const cachedStatus = readObjectRecord(node?.cachedStatus);
|
|
1166
|
+
const cachedGitCheckedAt = toIsoTimestamp(readObjectRecord(cachedStatus.git).lastCheckedAt);
|
|
1167
|
+
const lastProbeAt = liveGitCheckedAt
|
|
1168
|
+
?? heldCheckedAt
|
|
1169
|
+
?? cachedGitCheckedAt
|
|
1170
|
+
?? toIsoTimestamp(git.lastCheckedAt)
|
|
1171
|
+
?? connectionFreshAt
|
|
1172
|
+
?? toIsoTimestamp(status.updatedAt)
|
|
1173
|
+
?? toIsoTimestamp(status.lastSeenAt);
|
|
1174
|
+
|
|
1175
|
+
// connectionReachable: true (connected) / false (terminally down) / null (unknown,
|
|
1176
|
+
// not yet reported) — used so a cached/pending node carries the coordinator's last
|
|
1177
|
+
// known transport state rather than guessing.
|
|
1178
|
+
const connectionReachable: boolean | null = connectionState === 'connected'
|
|
1179
|
+
? true
|
|
1180
|
+
: (!connectionState || connectionState === 'unknown' || connectionState === 'connecting')
|
|
1181
|
+
? (connectionState === 'connecting' ? true : null)
|
|
1182
|
+
: false;
|
|
1183
|
+
|
|
1184
|
+
let dataSource: MeshNodeDataSource;
|
|
1185
|
+
let reachable: boolean | null;
|
|
1186
|
+
if (isSelfNode) {
|
|
1187
|
+
dataSource = 'self';
|
|
1188
|
+
reachable = true;
|
|
1189
|
+
} else if (liveTruthProbed) {
|
|
1190
|
+
dataSource = 'live';
|
|
1191
|
+
reachable = true;
|
|
1192
|
+
} else if (readBooleanValue(status.gitProbePending) === true) {
|
|
1193
|
+
dataSource = 'pending';
|
|
1194
|
+
reachable = connectionReachable;
|
|
1195
|
+
} else if (directTruthUnavailable) {
|
|
1196
|
+
dataSource = 'unreachable';
|
|
1197
|
+
reachable = false;
|
|
1198
|
+
} else if (hasGit) {
|
|
1199
|
+
dataSource = 'cached';
|
|
1200
|
+
reachable = connectionReachable;
|
|
1201
|
+
} else if (!daemonId) {
|
|
1202
|
+
dataSource = 'unconfigured';
|
|
1203
|
+
reachable = null;
|
|
1204
|
+
} else if (connectionState === 'connected') {
|
|
1205
|
+
dataSource = 'empty';
|
|
1206
|
+
reachable = true;
|
|
1207
|
+
} else {
|
|
1208
|
+
dataSource = 'unreachable';
|
|
1209
|
+
reachable = false;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
const probeOk = dataSource === 'live' || dataSource === 'self';
|
|
1213
|
+
let ageMs: number | null = null;
|
|
1214
|
+
if (lastProbeAt) {
|
|
1215
|
+
const parsed = Date.parse(lastProbeAt);
|
|
1216
|
+
if (Number.isFinite(parsed)) ageMs = Math.max(0, now() - parsed);
|
|
1217
|
+
}
|
|
1218
|
+
const staleness = classifyMeshNodeStaleness(dataSource, ageMs);
|
|
1219
|
+
|
|
1220
|
+
return {
|
|
1221
|
+
dataSource,
|
|
1222
|
+
probeOk,
|
|
1223
|
+
reachable,
|
|
1224
|
+
lastProbeAt: lastProbeAt ?? null,
|
|
1225
|
+
ageMs,
|
|
1226
|
+
staleness,
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/**
|
|
1231
|
+
* Canonical live-probe → freshness adapter. The coordinator-facing mesh_status
|
|
1232
|
+
* (mcp-server `meshStatus`) builds each node entry from a SINGLE fresh git_status
|
|
1233
|
+
* probe that either returns (live truth) or throws (peer unreachable). It used to
|
|
1234
|
+
* hand-reconstruct the freshness INPUT inline — a synthetic `{ git, connection }`
|
|
1235
|
+
* status plus the directTruthUnavailable/liveTruthProbed wiring — which is exactly
|
|
1236
|
+
* how a field added to `buildMeshNodeDataFreshness`'s input contract ends up "wired
|
|
1237
|
+
* on the daemon surface, null on the coordinator surface" (the rc.371
|
|
1238
|
+
* null-everywhere regression). Routing every live-probe surface through this one
|
|
1239
|
+
* adapter keeps the marker derivation canonical: there is a SINGLE place that turns
|
|
1240
|
+
* a probe outcome into freshness args, so the two mesh_status surfaces cannot drift.
|
|
1241
|
+
*
|
|
1242
|
+
* `liveTruthProbed` true → the probe returned (live/self truth); false → it threw,
|
|
1243
|
+
* so a configured peer is unreachable while an unconfigured node (no daemonId) falls
|
|
1244
|
+
* through to the classifier's `unconfigured` branch.
|
|
1245
|
+
*/
|
|
1246
|
+
export function buildMeshNodeProbeFreshness(args: {
|
|
1247
|
+
/** The git snapshot this probe stamped on the node entry (entry.git). */
|
|
1248
|
+
git: unknown;
|
|
1249
|
+
/** True when the fresh git_status probe RETURNED (live truth); false when it threw. */
|
|
1250
|
+
liveTruthProbed: boolean;
|
|
1251
|
+
isSelfNode: boolean;
|
|
1252
|
+
/** The node's resolved daemonId; absent → unconfigured node. */
|
|
1253
|
+
daemonId?: string;
|
|
1254
|
+
/** The mesh node record, for held-git fallback when the probe did not return live. */
|
|
1255
|
+
node?: any;
|
|
1256
|
+
now?: () => number;
|
|
1257
|
+
}): Record<string, unknown> {
|
|
1258
|
+
const { git, liveTruthProbed, isSelfNode, daemonId, node, now } = args;
|
|
1259
|
+
const status: Record<string, unknown> = {
|
|
1260
|
+
git,
|
|
1261
|
+
connection: { state: liveTruthProbed ? 'connected' : 'disconnected' },
|
|
1262
|
+
};
|
|
1263
|
+
if (liveTruthProbed) status[MESH_NODE_LIVE_TRUTH_MARKER] = true;
|
|
1264
|
+
return buildMeshNodeDataFreshness({
|
|
1265
|
+
status,
|
|
1266
|
+
node,
|
|
1267
|
+
isSelfNode,
|
|
1268
|
+
daemonId,
|
|
1269
|
+
liveTruthProbed,
|
|
1270
|
+
directTruthUnavailable: !liveTruthProbed && !!daemonId,
|
|
1271
|
+
now,
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
export function finalizeMeshNodeStatus(args: {
|
|
1276
|
+
status: Record<string, unknown>;
|
|
1277
|
+
node: any;
|
|
1278
|
+
daemonId?: string;
|
|
1279
|
+
isSelfNode: boolean;
|
|
1280
|
+
/** True when direct-peer-truth accounting classified this node unavailable. */
|
|
1281
|
+
directTruthUnavailable?: boolean;
|
|
1282
|
+
}): void {
|
|
1283
|
+
const { status, node, daemonId, isSelfNode, directTruthUnavailable } = args;
|
|
1284
|
+
if (!readStringValue(status.machineStatus)) {
|
|
1285
|
+
const cachedStatus = readObjectRecord(node?.cachedStatus);
|
|
1286
|
+
const machineStatus = readStringValue(cachedStatus.machineStatus, cachedStatus.machine_status, node?.machineStatus);
|
|
1287
|
+
if (machineStatus) status.machineStatus = machineStatus;
|
|
1288
|
+
}
|
|
1289
|
+
synthesizeMeshNodeFreshnessFromConnection(status);
|
|
1290
|
+
// Stamp the additive freshness/reachability marker before any early return so
|
|
1291
|
+
// every node — including bootstrap-blocked ones — carries it. Consume and drop
|
|
1292
|
+
// the transient live-probe marker so it never leaks into the response.
|
|
1293
|
+
const liveTruthProbed = readBooleanValue(status[MESH_NODE_LIVE_TRUTH_MARKER]) === true;
|
|
1294
|
+
delete status[MESH_NODE_LIVE_TRUTH_MARKER];
|
|
1295
|
+
status.dataFreshness = buildMeshNodeDataFreshness({
|
|
1296
|
+
status,
|
|
1297
|
+
node,
|
|
1298
|
+
isSelfNode,
|
|
1299
|
+
daemonId,
|
|
1300
|
+
liveTruthProbed,
|
|
1301
|
+
directTruthUnavailable,
|
|
1302
|
+
});
|
|
1303
|
+
const bootstrap = readObjectRecord(node?.worktreeBootstrap);
|
|
1304
|
+
if (node?.isLocalWorktree && readStringValue(bootstrap.status)) {
|
|
1305
|
+
status.worktreeBootstrap = bootstrap;
|
|
1306
|
+
if (bootstrap.status === 'failed' && bootstrap.required !== false) {
|
|
1307
|
+
status.launchReady = false;
|
|
1308
|
+
status.launchBlockedReason = 'worktree_bootstrap_failed';
|
|
1309
|
+
status.launchBlockedMessage = readStringValue(bootstrap.error)
|
|
1310
|
+
|| 'Required worktree bootstrap failed; resolve it before launching an agent into this node.';
|
|
1311
|
+
status.recoveryHint = 'Run retry_mesh_node_bootstrap to retry';
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
if (bootstrap.status === 'running' && bootstrap.required !== false) {
|
|
1315
|
+
status.launchReady = false;
|
|
1316
|
+
status.launchBlockedReason = 'worktree_bootstrap_running';
|
|
1317
|
+
status.launchBlockedMessage = 'Required worktree bootstrap is still running; wait for it to finish before launching an agent into this node.';
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
const connectionState = readStringValue(readObjectRecord(status.connection).state);
|
|
1322
|
+
status.launchReady = !!daemonId && (
|
|
1323
|
+
readStringValue(status.machineStatus) === 'online'
|
|
1324
|
+
|| connectionState === 'connected'
|
|
1325
|
+
|| isSelfNode
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
|
|
1330
|
+
// Direct-peer git_status probe timeout for the dashboard's requireDirectPeerTruth
|
|
1331
|
+
// bootstrap. The previous hard-coded 8s/12s were shorter than the real P2P
|
|
1332
|
+
// round-trip to slow (often TURN-relayed) peers, so such a node was permanently
|
|
1333
|
+
// marked unavailable and blocked the whole mesh graph. Default raised to 25s
|
|
1334
|
+
// (still under the P2P REQUEST_TIMEOUT of 30s) and made env-overridable.
|
|
1335
|
+
export const MESH_DIRECT_PROBE_TIMEOUT_MS = readMeshTimeoutEnvMs('MESH_DIRECT_PROBE_TIMEOUT_MS', 25_000);
|
|
1336
|
+
export const MESH_DIRECT_PROBE_RETRY_TIMEOUT_MS = readMeshTimeoutEnvMs('MESH_DIRECT_PROBE_RETRY_TIMEOUT_MS', 25_000);
|
|
1337
|
+
// Cold-open warmup budget for the FIRST direct-peer probe to a peer whose mesh
|
|
1338
|
+
// DataChannel is not open yet. A fresh cross-machine, TURN-relayed handshake
|
|
1339
|
+
// (ICE gather + TURN allocation + DTLS across two residential networks) routinely
|
|
1340
|
+
// needs many seconds. Charging that warmup against the response deadline
|
|
1341
|
+
// (MESH_DIRECT_PROBE_TIMEOUT_MS) made the very first git_status to a cold peer
|
|
1342
|
+
// false-timeout, after which the warm retry — reusing the now-open channel —
|
|
1343
|
+
// succeeded: the classic cold-open signature. This budget bounds ONLY the
|
|
1344
|
+
// "channel not open yet" phase; once the channel opens the response deadline
|
|
1345
|
+
// governs the round trip. A genuine connect failure still rejects immediately —
|
|
1346
|
+
// the mesh manager fails the peer the instant its PeerConnection state goes
|
|
1347
|
+
// terminal, and isMeshConnectionDefinitivelyDown pre-gates an already-dead peer —
|
|
1348
|
+
// so this never masks a real failure for the whole window; it only grants a
|
|
1349
|
+
// still-handshaking peer the time it legitimately needs. Matches the daemon-cloud
|
|
1350
|
+
// DaemonMeshManager CONNECT_TIMEOUT_MS (45s). Env-overridable for very slow links.
|
|
1351
|
+
// Re-exported from the unified MESH_CONNECT_TIMEOUT_MS (runtime-defaults) so this
|
|
1352
|
+
// probe path and the coordinator's remote task-dispatch path share ONE
|
|
1353
|
+
// env-overridable connect budget instead of silently diverging when the env is set.
|
|
1354
|
+
export const MESH_DIRECT_PROBE_CONNECT_TIMEOUT_MS = MESH_CONNECT_TIMEOUT_MS;
|
|
1355
|
+
// How long a successful per-peer git_status probe stays fresh enough to be
|
|
1356
|
+
// reused instead of issuing another blocking `refreshUpstream:true` fan-out.
|
|
1357
|
+
// A slow (TURN-relayed) peer's probe can take 9-23s, and the dashboard's
|
|
1358
|
+
// auto-retry loop re-fires every few seconds; without this gate every retry
|
|
1359
|
+
// would start a brand new probe storm to the same peer. Within this window the
|
|
1360
|
+
// last successful result is reused so a refresh quiesces instead of looping.
|
|
1361
|
+
// Min-clamped to 1s by readMeshTimeoutEnvMs; raise via env for very slow peers.
|
|
1362
|
+
export const MESH_DIRECT_PROBE_REUSE_MS = readMeshTimeoutEnvMs('MESH_DIRECT_PROBE_REUSE_MS', 12_000);
|
|
1363
|
+
|
|
1364
|
+
/**
|
|
1365
|
+
* De-duplicates and rate-limits per-peer git_status probes so a single mesh
|
|
1366
|
+
* refresh — or a burst of refreshes from the dashboard auto-retry loop — cannot
|
|
1367
|
+
* launch a storm of concurrent/back-to-back `refreshUpstream:true` commands to
|
|
1368
|
+
* the same slow peer.
|
|
1369
|
+
*
|
|
1370
|
+
* Two gates, both keyed by `daemonId::workspace`:
|
|
1371
|
+
* - In-flight dedup: a second probe for a key with a probe already running
|
|
1372
|
+
* shares (awaits) the in-flight promise instead of issuing a second command.
|
|
1373
|
+
* - Recently-probed reuse: a successful probe younger than `reuseMs` is reused
|
|
1374
|
+
* verbatim instead of issuing a fresh probe. Failures are NOT cached (so a
|
|
1375
|
+
* transient timeout doesn't pin a peer to "no truth" for the whole window).
|
|
1376
|
+
*
|
|
1377
|
+
* Lives on the router instance so the gate spans separate mesh_status calls,
|
|
1378
|
+
* which is exactly where the refresh storm happens.
|
|
1379
|
+
*/
|
|
1380
|
+
export class MeshGitProbeCache {
|
|
1381
|
+
private inflight = new Map<string, Promise<Record<string, unknown> | null>>();
|
|
1382
|
+
private recent = new Map<string, { at: number; value: Record<string, unknown> }>();
|
|
1383
|
+
|
|
1384
|
+
constructor(private readonly reuseMs: number, private readonly now: () => number = Date.now) {}
|
|
1385
|
+
|
|
1386
|
+
private key(daemonId: string, workspace: string): string {
|
|
1387
|
+
return `${daemonId}::${workspace}`;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
/**
|
|
1391
|
+
* Local (same-machine) git_status dedup. The bootstrap direct-truth hydrate
|
|
1392
|
+
* and the per-node render loop both call getGitRepoStatus(refreshUpstream:true)
|
|
1393
|
+
* for the same local workspace within one mesh_status call. Each such probe
|
|
1394
|
+
* fans out ~13-15 git subprocesses, and because the two passes are separated by
|
|
1395
|
+
* the render/hydrate work of every OTHER node they routinely straddle the
|
|
1396
|
+
* getGitRepoStatus 1.5s TTL, so the second pass re-shells the whole ~14-process
|
|
1397
|
+
* collection. Routing both through this cache (namespaced under a reserved
|
|
1398
|
+
* daemon id so it never collides with a remote-peer key) collapses them to one
|
|
1399
|
+
* collection per workspace per request, and reuses it across the reuse window
|
|
1400
|
+
* so the dashboard auto-retry loop can't restart a fresh local probe seconds
|
|
1401
|
+
* apart either.
|
|
1402
|
+
*/
|
|
1403
|
+
private static readonly LOCAL_PROBE_DAEMON_ID = '__local_git__';
|
|
1404
|
+
|
|
1405
|
+
async probeLocal(
|
|
1406
|
+
workspace: string,
|
|
1407
|
+
probe: () => Promise<Record<string, unknown> | null>,
|
|
1408
|
+
): Promise<Record<string, unknown> | null> {
|
|
1409
|
+
return this.probe(MeshGitProbeCache.LOCAL_PROBE_DAEMON_ID, workspace, probe);
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/**
|
|
1413
|
+
* Run `probe` for this peer, but reuse a fresh recent result or an in-flight
|
|
1414
|
+
* probe for the same key when one is available. `probe` is only invoked when
|
|
1415
|
+
* neither gate is satisfied.
|
|
1416
|
+
*/
|
|
1417
|
+
async probe(
|
|
1418
|
+
daemonId: string,
|
|
1419
|
+
workspace: string,
|
|
1420
|
+
probe: () => Promise<Record<string, unknown> | null>,
|
|
1421
|
+
): Promise<Record<string, unknown> | null> {
|
|
1422
|
+
const key = this.key(daemonId, workspace);
|
|
1423
|
+
const cached = this.recent.get(key);
|
|
1424
|
+
if (cached && this.now() - cached.at < this.reuseMs) {
|
|
1425
|
+
return cached.value;
|
|
1426
|
+
}
|
|
1427
|
+
const existing = this.inflight.get(key);
|
|
1428
|
+
if (existing) return existing;
|
|
1429
|
+
const pending = (async () => {
|
|
1430
|
+
const result = await probe();
|
|
1431
|
+
if (result) this.recent.set(key, { at: this.now(), value: result });
|
|
1432
|
+
return result;
|
|
1433
|
+
})();
|
|
1434
|
+
this.inflight.set(key, pending);
|
|
1435
|
+
try {
|
|
1436
|
+
return await pending;
|
|
1437
|
+
} finally {
|
|
1438
|
+
// Only clear the slot if it is still ours — a later overlapping call
|
|
1439
|
+
// would have reused this very promise, so it is safe to delete here.
|
|
1440
|
+
if (this.inflight.get(key) === pending) this.inflight.delete(key);
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// The warmup-aware deadline now lives in the dependency-free mesh leaf so BOTH the
|
|
1446
|
+
// dashboard git_status probe (here) and the general task-dispatch path
|
|
1447
|
+
// (mesh/mesh-events-coordinator.ts) can share it without an import cycle. Re-exported
|
|
1448
|
+
// for the existing `from '../commands/router.js'` callers/tests.
|
|
1449
|
+
export { awaitWithWarmupDeadline };
|
|
1450
|
+
|
|
1451
|
+
async function probeRemoteMeshGitStatus(args: {
|
|
1452
|
+
dispatchMeshCommand?: (daemonId: string, cmd: string, args: Record<string, unknown>) => Promise<unknown>;
|
|
1453
|
+
daemonId: string;
|
|
1454
|
+
workspace: string;
|
|
1455
|
+
// Response deadline — applies only once the peer's DataChannel is open (warm).
|
|
1456
|
+
responseTimeoutMs: number;
|
|
1457
|
+
// Cold-open warmup budget — applies only while the channel is still opening.
|
|
1458
|
+
connectTimeoutMs: number;
|
|
1459
|
+
// Live peer connection snapshot getter; lets the deadline tell "still warming
|
|
1460
|
+
// up" apart from "warm but slow". Absent → degrade conservatively (fail-loud,
|
|
1461
|
+
// combined connect+response window) rather than silently assuming "always warm"
|
|
1462
|
+
// — see resolveWarmupDeadlineOpts.
|
|
1463
|
+
getConnection?: (daemonId: string) => Record<string, unknown> | null;
|
|
1464
|
+
}): Promise<Record<string, unknown> | null> {
|
|
1465
|
+
if (!args.dispatchMeshCommand) return null;
|
|
1466
|
+
// Fire the dispatch first — this is what drives the mesh manager to ensure /
|
|
1467
|
+
// open the peer connection. The warmup-aware deadline then charges the
|
|
1468
|
+
// cold-open handshake to the connect budget and only the warm round trip to
|
|
1469
|
+
// the response budget, so the first probe to a cold peer is no longer
|
|
1470
|
+
// false-timed-out before its channel has even opened.
|
|
1471
|
+
const dispatch = args.dispatchMeshCommand(args.daemonId, 'git_status', { workspace: args.workspace, refreshUpstream: true });
|
|
1472
|
+
// A missing connection getter no longer silently becomes `() => true`
|
|
1473
|
+
// ("always warm") — that charged a still-opening channel against the response
|
|
1474
|
+
// budget and re-introduced the cold-open false-timeout. resolveWarmupDeadlineOpts
|
|
1475
|
+
// warns once per peer and grants the combined budget instead.
|
|
1476
|
+
const remoteResult = await awaitWithWarmupDeadline(dispatch, resolveWarmupDeadlineOpts({
|
|
1477
|
+
getConnection: args.getConnection,
|
|
1478
|
+
daemonId: args.daemonId,
|
|
1479
|
+
connectTimeoutMs: args.connectTimeoutMs,
|
|
1480
|
+
responseTimeoutMs: args.responseTimeoutMs,
|
|
1481
|
+
onMissingGetter: warnMeshWarmupGetterMissingOnce,
|
|
1482
|
+
})) as any;
|
|
1483
|
+
const remoteGit = remoteResult?.status ?? remoteResult?.git ?? remoteResult;
|
|
1484
|
+
if (!remoteGit || typeof remoteGit !== 'object' || typeof remoteGit.isGitRepo !== 'boolean') return null;
|
|
1485
|
+
// The member daemon stamps its own platform/arch onto the git_status result
|
|
1486
|
+
// envelope (see git-commands.ts). Reflect them onto the returned git object
|
|
1487
|
+
// under non-colliding reporter* keys so recordInlineMeshDirectGitTruth can
|
|
1488
|
+
// persist them to node.userOverrides without touching the git status shape.
|
|
1489
|
+
const reporterPlatform = readStringValue(remoteResult?.reporterPlatform);
|
|
1490
|
+
const reporterArch = readStringValue(remoteResult?.reporterArch);
|
|
1491
|
+
const reporterMachineNickname = readStringValue(remoteResult?.reporterMachineNickname);
|
|
1492
|
+
const git = remoteGit as Record<string, unknown>;
|
|
1493
|
+
if (reporterPlatform) git.reporterPlatform = reporterPlatform;
|
|
1494
|
+
if (reporterArch) git.reporterArch = reporterArch;
|
|
1495
|
+
if (reporterMachineNickname) git.reporterMachineNickname = reporterMachineNickname;
|
|
1496
|
+
// T7: propagate the member's self-reported provider versions + build version on
|
|
1497
|
+
// the same reporter* channel so a remote node's providerVersions self-heal too.
|
|
1498
|
+
const reporterProviderVersions = readProviderVersionsRecord(remoteResult?.reporterProviderVersions);
|
|
1499
|
+
if (reporterProviderVersions) git.reporterProviderVersions = reporterProviderVersions;
|
|
1500
|
+
const reporterDaemonBuildVersion = readStringValue(remoteResult?.reporterDaemonBuildVersion);
|
|
1501
|
+
if (reporterDaemonBuildVersion) git.reporterDaemonBuildVersion = reporterDaemonBuildVersion;
|
|
1502
|
+
return git;
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
/** Number of bounded retries after the initial direct-peer git probe attempt. */
|
|
1506
|
+
const MESH_DIRECT_PROBE_MAX_RETRIES = 2;
|
|
1507
|
+
|
|
1508
|
+
function readMeshConnectionState(connection: Record<string, unknown> | null | undefined): string | undefined {
|
|
1509
|
+
return readStringValue((connection as any)?.state);
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
// Fail-loud (but throttled) trace for the degraded-warmup case: a direct-peer mesh
|
|
1513
|
+
// dispatch ran with NO live connection getter wired. This is a misconfiguration in a
|
|
1514
|
+
// P2P-capable daemon (the getter should be present), and the old `() => true`
|
|
1515
|
+
// fallback hid it while silently re-introducing the cold-open false-timeout. Warn
|
|
1516
|
+
// once per peer so the degrade is visible without flooding the log on every probe.
|
|
1517
|
+
const meshWarmupGetterMissingWarned = new Set<string>();
|
|
1518
|
+
function warnMeshWarmupGetterMissingOnce(daemonId: string): void {
|
|
1519
|
+
if (meshWarmupGetterMissingWarned.has(daemonId)) return;
|
|
1520
|
+
meshWarmupGetterMissingWarned.add(daemonId);
|
|
1521
|
+
LOG.warn('Mesh', `Mesh peer connection getter unavailable for ${String(daemonId).slice(0, 12)}; warmup deadline degraded to the combined connect+response window (cannot observe DataChannel open). This avoids a cold-open false-timeout but loses warm/cold precision — wire getMeshPeerConnectionStatus on this daemon.`);
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
/**
|
|
1525
|
+
* Connection states that mean the peer is definitively NOT reachable right now —
|
|
1526
|
+
* an offline machine (no peer entry at all) or a transport that has dropped
|
|
1527
|
+
* (failed/closed/disconnected). Probing such a peer would just burn the full
|
|
1528
|
+
* MESH_DIRECT_PROBE_TIMEOUT_MS window before timing out, so the cold-open of the
|
|
1529
|
+
* mesh graph stalls 25s behind one powered-off node. `connecting` is deliberately
|
|
1530
|
+
* NOT here: a peer mid-handshake may complete during the probe window, so it still
|
|
1531
|
+
* gets its attempt. Held standing git truth is consulted by the caller BEFORE this
|
|
1532
|
+
* runs, so the invariant "connected+held is never unavailable" is untouched — this
|
|
1533
|
+
* only short-circuits a peer that has no usable transport to probe over.
|
|
1534
|
+
*/
|
|
1535
|
+
function isMeshConnectionDefinitivelyDown(
|
|
1536
|
+
connection: Record<string, unknown> | null | undefined,
|
|
1537
|
+
): boolean {
|
|
1538
|
+
if (!connection) return true;
|
|
1539
|
+
const state = readMeshConnectionState(connection);
|
|
1540
|
+
return state === 'failed' || state === 'closed' || state === 'disconnected';
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
/**
|
|
1544
|
+
* Probe a remote peer's git_status with a bounded retry budget, but only while
|
|
1545
|
+
* the peer is reported `connected`. A single slow (often TURN-relayed) peer can
|
|
1546
|
+
* exceed one probe window; retrying — with the connection re-checked before each
|
|
1547
|
+
* attempt so we abandon a peer that dropped — recovers it without blocking the
|
|
1548
|
+
* mesh forever. Shared by the bootstrap hydrate path and the per-node render
|
|
1549
|
+
* path so both treat a connected-but-slow peer identically.
|
|
1550
|
+
*
|
|
1551
|
+
* Returns the git status on success, or null if every attempt failed/timed out
|
|
1552
|
+
* (caller decides how to classify). `getConnection` is consulted before each
|
|
1553
|
+
* attempt; a non-`connected` state short-circuits the retry loop (the very first
|
|
1554
|
+
* attempt always runs so a missing connection getter still gets one try).
|
|
1555
|
+
*/
|
|
1556
|
+
export async function probeRemoteMeshGitStatusWithRetry(args: {
|
|
1557
|
+
dispatchMeshCommand?: (daemonId: string, cmd: string, args: Record<string, unknown>) => Promise<unknown>;
|
|
1558
|
+
daemonId: string;
|
|
1559
|
+
workspace: string;
|
|
1560
|
+
timeoutMs: number;
|
|
1561
|
+
/** Per-attempt timeout for retries (attempts > 0); defaults to timeoutMs. */
|
|
1562
|
+
retryTimeoutMs?: number;
|
|
1563
|
+
/** Cold-open warmup budget per attempt; defaults to MESH_DIRECT_PROBE_CONNECT_TIMEOUT_MS. */
|
|
1564
|
+
connectTimeoutMs?: number;
|
|
1565
|
+
getConnection?: (daemonId: string) => Record<string, unknown> | null;
|
|
1566
|
+
onConnection?: (connection: Record<string, unknown>) => void;
|
|
1567
|
+
}): Promise<Record<string, unknown> | null> {
|
|
1568
|
+
// Fast-fail an offline / dropped peer BEFORE the first attempt. Previously the
|
|
1569
|
+
// liveness re-check only ran *between* attempts, so a powered-off node still ate
|
|
1570
|
+
// the full first MESH_DIRECT_PROBE_TIMEOUT_MS (25s) window — stalling the mesh
|
|
1571
|
+
// graph cold-open behind one dead machine. If a connection getter is wired and
|
|
1572
|
+
// it reports the peer as definitively down (no peer entry / failed / closed /
|
|
1573
|
+
// disconnected), skip straight to "no truth" instead of awaiting a 25s timeout.
|
|
1574
|
+
// A `connecting` peer still gets its attempt (it may complete mid-probe). No
|
|
1575
|
+
// onConnection side effect here: this is a pure liveness gate, and the caller's
|
|
1576
|
+
// own connection read already seeds status.connection — only the between-attempt
|
|
1577
|
+
// path needs to surface a freshly-observed connection.
|
|
1578
|
+
if (args.getConnection && isMeshConnectionDefinitivelyDown(args.getConnection(args.daemonId))) {
|
|
1579
|
+
return null;
|
|
1580
|
+
}
|
|
1581
|
+
for (let attempt = 0; attempt <= MESH_DIRECT_PROBE_MAX_RETRIES; attempt += 1) {
|
|
1582
|
+
if (attempt > 0) {
|
|
1583
|
+
// Re-check liveness before spending another probe window; a peer that
|
|
1584
|
+
// dropped between attempts is not worth retrying.
|
|
1585
|
+
const connection = args.getConnection?.(args.daemonId);
|
|
1586
|
+
if (args.getConnection && readMeshConnectionState(connection) !== 'connected') break;
|
|
1587
|
+
if (connection) args.onConnection?.(connection);
|
|
1588
|
+
// Exponential backoff: 250ms, 500ms before attempts 1 and 2.
|
|
1589
|
+
await new Promise(resolve => setTimeout(resolve, 250 * 2 ** (attempt - 1)));
|
|
1590
|
+
}
|
|
1591
|
+
try {
|
|
1592
|
+
const remoteGit = await probeRemoteMeshGitStatus({
|
|
1593
|
+
dispatchMeshCommand: args.dispatchMeshCommand,
|
|
1594
|
+
daemonId: args.daemonId,
|
|
1595
|
+
workspace: args.workspace,
|
|
1596
|
+
responseTimeoutMs: attempt === 0 ? args.timeoutMs : (args.retryTimeoutMs ?? args.timeoutMs),
|
|
1597
|
+
connectTimeoutMs: args.connectTimeoutMs ?? MESH_DIRECT_PROBE_CONNECT_TIMEOUT_MS,
|
|
1598
|
+
getConnection: args.getConnection,
|
|
1599
|
+
});
|
|
1600
|
+
if (remoteGit) return remoteGit;
|
|
1601
|
+
} catch {
|
|
1602
|
+
// Timed out or P2P error — fall through to the next bounded attempt.
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
return null;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
export async function hydrateInlineMeshDirectTruth(args: {
|
|
1609
|
+
mesh: any;
|
|
1610
|
+
meshSource: 'inline_cache' | 'inline_bootstrap' | 'local_config';
|
|
1611
|
+
dispatchMeshCommand?: (daemonId: string, cmd: string, args: Record<string, unknown>) => Promise<unknown>;
|
|
1612
|
+
getMeshPeerConnectionStatus?: (daemonId: string) => Record<string, unknown> | null;
|
|
1613
|
+
statusInstanceId?: string;
|
|
1614
|
+
localMachineId?: string;
|
|
1615
|
+
// Standing-state model: the default (non-refresh) bootstrap load must NOT
|
|
1616
|
+
// fan out a blocking git_status probe to every peer — a single slow
|
|
1617
|
+
// (TURN-relayed) peer would time out and mark the whole mesh unavailable,
|
|
1618
|
+
// blocking the graph. When false, a non-local node is satisfied from its
|
|
1619
|
+
// held standing git truth (lastGit / cachedStatus reflected via mesh
|
|
1620
|
+
// events) and is never pushed to unavailableNodeIds merely because no live
|
|
1621
|
+
// probe was attempted. Only an explicit refresh (probeRemotePeers=true)
|
|
1622
|
+
// performs the fan-out and classifies an unreachable peer as unavailable.
|
|
1623
|
+
probeRemotePeers: boolean;
|
|
1624
|
+
// Optional shared probe cache: dedups concurrent probes and reuses a
|
|
1625
|
+
// recently-probed peer's result instead of re-issuing a blocking
|
|
1626
|
+
// refreshUpstream probe within the reuse window.
|
|
1627
|
+
probeCache?: MeshGitProbeCache;
|
|
1628
|
+
}): Promise<{
|
|
1629
|
+
directEvidenceCount: number;
|
|
1630
|
+
localConfirmedCount: number;
|
|
1631
|
+
peerAttemptedCount: number;
|
|
1632
|
+
peerConfirmedCount: number;
|
|
1633
|
+
standingEvidenceCount: number;
|
|
1634
|
+
unavailableNodeIds: string[];
|
|
1635
|
+
deadNodeIds: string[];
|
|
1636
|
+
}> {
|
|
1637
|
+
const nodes = Array.isArray(args.mesh?.nodes) ? args.mesh.nodes : [];
|
|
1638
|
+
if (!nodes.length) {
|
|
1639
|
+
return {
|
|
1640
|
+
directEvidenceCount: 0,
|
|
1641
|
+
localConfirmedCount: 0,
|
|
1642
|
+
peerAttemptedCount: 0,
|
|
1643
|
+
peerConfirmedCount: 0,
|
|
1644
|
+
standingEvidenceCount: 0,
|
|
1645
|
+
unavailableNodeIds: [],
|
|
1646
|
+
deadNodeIds: [],
|
|
1647
|
+
};
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
const selectedCoordinatorNodeId = readStringValue(
|
|
1651
|
+
args.mesh?.coordinator?.preferredNodeId,
|
|
1652
|
+
nodes[0]?.id,
|
|
1653
|
+
nodes[0]?.nodeId,
|
|
1654
|
+
);
|
|
1655
|
+
|
|
1656
|
+
let localConfirmedCount = 0;
|
|
1657
|
+
let peerAttemptedCount = 0;
|
|
1658
|
+
let peerConfirmedCount = 0;
|
|
1659
|
+
let standingEvidenceCount = 0;
|
|
1660
|
+
const unavailableNodeIds: string[] = [];
|
|
1661
|
+
const deadNodeIds: string[] = [];
|
|
1662
|
+
|
|
1663
|
+
// Each node's classification (local git probe, standing truth, or the remote
|
|
1664
|
+
// P2P fan-out) is independent, so probing them serially stacked one slow
|
|
1665
|
+
// (often TURN-relayed) peer's latency onto every other node — the 3×25s serial
|
|
1666
|
+
// stall. Classify all nodes concurrently via Promise.allSettled; each node has
|
|
1667
|
+
// its own bounded per-peer timeout + definitively-down fast-fail inside
|
|
1668
|
+
// probeRemoteMeshGitStatusWithRetry, so a hung peer degrades to `unavailable`
|
|
1669
|
+
// for THAT node only and never blocks the aggregate. The counters and
|
|
1670
|
+
// unavailable/dead node lists are folded from the settled results afterward so
|
|
1671
|
+
// no shared mutable state is touched concurrently.
|
|
1672
|
+
type NodeTruthResult =
|
|
1673
|
+
| { kind: 'dead'; nodeId: string }
|
|
1674
|
+
| { kind: 'unavailable'; nodeId: string; attempted?: boolean }
|
|
1675
|
+
| { kind: 'local' }
|
|
1676
|
+
| { kind: 'standing' }
|
|
1677
|
+
| { kind: 'peerConfirmed' }
|
|
1678
|
+
| { kind: 'peerUnavailable'; nodeId: string }
|
|
1679
|
+
| { kind: 'skip' };
|
|
1680
|
+
|
|
1681
|
+
const classifyNode = async (nodeIndex: number, node: any): Promise<NodeTruthResult> => {
|
|
1682
|
+
const nodeId = normalizeMeshNodeId(node) || `node_${nodeIndex}`;
|
|
1683
|
+
const workspace = readStringValue(node?.workspace);
|
|
1684
|
+
const daemonId = readStringValue(node?.daemonId);
|
|
1685
|
+
const isSelfNode = Boolean(
|
|
1686
|
+
nodeId && selectedCoordinatorNodeId && daemonIdsEquivalent(nodeId, selectedCoordinatorNodeId),
|
|
1687
|
+
) || Boolean(
|
|
1688
|
+
daemonId && (daemonIdsEquivalent(daemonId, args.localMachineId) || daemonIdsEquivalent(daemonId, args.statusInstanceId)),
|
|
1689
|
+
) || Boolean(args.meshSource !== 'local_config' && nodeIndex === 0);
|
|
1690
|
+
|
|
1691
|
+
// A dead local worktree owned by this coordinator (isLocalWorktree, the
|
|
1692
|
+
// node's daemon is us, workspace path gone) has no live truth and cannot
|
|
1693
|
+
// be probed — the directory it would self-probe no longer exists. Exclude
|
|
1694
|
+
// it entirely from direct-peer-truth accounting: do not probe it, do not
|
|
1695
|
+
// attempt it, do not push it to unavailableNodeIds (which would otherwise
|
|
1696
|
+
// wedge the graph in a permanent direct_peer_truth_unavailable). This is
|
|
1697
|
+
// strictly self + isLocalWorktree + absent-path; remote peers and nodes
|
|
1698
|
+
// whose workspace still exists are unaffected and stay classifiable.
|
|
1699
|
+
const isSelfDaemonNode = Boolean(
|
|
1700
|
+
daemonId && (daemonIdsEquivalent(daemonId, args.localMachineId) || daemonIdsEquivalent(daemonId, args.statusInstanceId)),
|
|
1701
|
+
);
|
|
1702
|
+
if ((isSelfNode || isSelfDaemonNode) && isDeadLocalWorktreeNode(node)) {
|
|
1703
|
+
return { kind: 'dead', nodeId };
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
if (!workspace) {
|
|
1707
|
+
return (!isSelfNode && daemonId) ? { kind: 'unavailable', nodeId } : { kind: 'skip' };
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
if (fs.existsSync(workspace)) {
|
|
1711
|
+
try {
|
|
1712
|
+
// Route the local probe through the shared cache so the per-node
|
|
1713
|
+
// render loop's getGitRepoStatus for the same workspace reuses this
|
|
1714
|
+
// exact result instead of re-shelling ~14 git processes when the two
|
|
1715
|
+
// passes straddle the getGitRepoStatus 1.5s TTL.
|
|
1716
|
+
const runLocalProbe = () => getGitRepoStatus(workspace, { timeoutMs: 10_000, refreshUpstream: true }) as unknown as Promise<Record<string, unknown> | null>;
|
|
1717
|
+
const localGit = args.probeCache
|
|
1718
|
+
? await args.probeCache.probeLocal(workspace, runLocalProbe)
|
|
1719
|
+
: await runLocalProbe();
|
|
1720
|
+
if (localGit?.isGitRepo) {
|
|
1721
|
+
const reporter = recordInlineMeshDirectGitTruth(node, localGit as unknown as Record<string, unknown>, 'selected_coordinator_local_git');
|
|
1722
|
+
persistNodeReporterPlatform(args.meshSource, args.mesh, nodeId, reporter);
|
|
1723
|
+
return { kind: 'local' };
|
|
1724
|
+
}
|
|
1725
|
+
} catch {
|
|
1726
|
+
// Fall through to remote classification.
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
// Standing-state first: a non-local peer's held git truth (reflected
|
|
1731
|
+
// from its self-emitted mesh events into node.lastGit / cachedStatus)
|
|
1732
|
+
// counts as direct evidence without any probe. On the default load this
|
|
1733
|
+
// is the ONLY thing we consult — no fan-out, so one slow peer can't
|
|
1734
|
+
// block the bootstrap.
|
|
1735
|
+
const standingGit = buildInlineMeshTransitGitStatus(node);
|
|
1736
|
+
if (standingGit) {
|
|
1737
|
+
return { kind: 'standing' };
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
if (!args.probeRemotePeers) {
|
|
1741
|
+
// Default (non-refresh) load: a peer with no held truth yet is left
|
|
1742
|
+
// pending (the per-node loop marks it gitProbePending and the graph
|
|
1743
|
+
// shows setup inventory for it). It is NOT unavailable — the graph
|
|
1744
|
+
// must still render. An explicit refresh will fan out and freshen it.
|
|
1745
|
+
return { kind: 'skip' };
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
if (!daemonId || !args.dispatchMeshCommand) {
|
|
1749
|
+
return !isSelfNode ? { kind: 'unavailable', nodeId } : { kind: 'skip' };
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
// Bounded retry, gated on the peer staying `connected`: a slow
|
|
1753
|
+
// (TURN-relayed) peer that just exceeds one probe window is recovered
|
|
1754
|
+
// instead of being hard-failed. The connection is re-checked before each
|
|
1755
|
+
// retry so a peer that actually dropped is abandoned promptly. Routed
|
|
1756
|
+
// through the shared probe cache so a refresh burst reuses a recent
|
|
1757
|
+
// result / shares an in-flight probe instead of storming the peer.
|
|
1758
|
+
const runProbe = () => probeRemoteMeshGitStatusWithRetry({
|
|
1759
|
+
dispatchMeshCommand: args.dispatchMeshCommand,
|
|
1760
|
+
daemonId,
|
|
1761
|
+
workspace,
|
|
1762
|
+
timeoutMs: MESH_DIRECT_PROBE_TIMEOUT_MS,
|
|
1763
|
+
retryTimeoutMs: MESH_DIRECT_PROBE_RETRY_TIMEOUT_MS,
|
|
1764
|
+
connectTimeoutMs: MESH_DIRECT_PROBE_CONNECT_TIMEOUT_MS,
|
|
1765
|
+
getConnection: args.getMeshPeerConnectionStatus,
|
|
1766
|
+
});
|
|
1767
|
+
const remoteGit = args.probeCache
|
|
1768
|
+
? await args.probeCache.probe(daemonId, workspace, runProbe)
|
|
1769
|
+
: await runProbe();
|
|
1770
|
+
if (remoteGit) {
|
|
1771
|
+
const reporter = recordInlineMeshDirectGitTruth(node, remoteGit, 'selected_coordinator_mesh_p2p_git');
|
|
1772
|
+
persistNodeReporterPlatform(args.meshSource, args.mesh, nodeId, reporter);
|
|
1773
|
+
return { kind: 'peerConfirmed' };
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
// Invariant: a connected peer that still holds standing git truth is
|
|
1777
|
+
// never classified unavailable (standingGit short-circuited above, so by
|
|
1778
|
+
// here there is no held truth). Only push to unavailable when the peer is
|
|
1779
|
+
// not currently connected, or it is connected but every bounded probe
|
|
1780
|
+
// failed — that is the genuine "connected, no truth, retries exhausted"
|
|
1781
|
+
// case that drives the explicit-refresh hard-fail.
|
|
1782
|
+
return { kind: 'peerUnavailable', nodeId };
|
|
1783
|
+
};
|
|
1784
|
+
|
|
1785
|
+
const nodeEntries = [...nodes.entries()];
|
|
1786
|
+
const settledResults = await Promise.allSettled(
|
|
1787
|
+
nodeEntries.map(([nodeIndex, node]) => classifyNode(nodeIndex, node)),
|
|
1788
|
+
);
|
|
1789
|
+
settledResults.forEach((settled, i) => {
|
|
1790
|
+
const [nodeIndex, node] = nodeEntries[i];
|
|
1791
|
+
// A classifier should never reject (every probe is caught internally), but
|
|
1792
|
+
// if one does, degrade that node to `unavailable` when it is a remote peer —
|
|
1793
|
+
// never silently drop it, never fail the whole aggregate.
|
|
1794
|
+
const result: NodeTruthResult = settled.status === 'fulfilled'
|
|
1795
|
+
? settled.value
|
|
1796
|
+
: (() => {
|
|
1797
|
+
const nodeId = normalizeMeshNodeId(node) || `node_${nodeIndex}`;
|
|
1798
|
+
const daemonId = readStringValue(node?.daemonId);
|
|
1799
|
+
const isSelfNode = Boolean(
|
|
1800
|
+
nodeId && selectedCoordinatorNodeId && daemonIdsEquivalent(nodeId, selectedCoordinatorNodeId),
|
|
1801
|
+
) || Boolean(
|
|
1802
|
+
daemonId && (daemonIdsEquivalent(daemonId, args.localMachineId) || daemonIdsEquivalent(daemonId, args.statusInstanceId)),
|
|
1803
|
+
);
|
|
1804
|
+
return (!isSelfNode && daemonId) ? { kind: 'unavailable', nodeId } as NodeTruthResult : { kind: 'skip' } as NodeTruthResult;
|
|
1805
|
+
})();
|
|
1806
|
+
switch (result.kind) {
|
|
1807
|
+
case 'dead':
|
|
1808
|
+
deadNodeIds.push(result.nodeId);
|
|
1809
|
+
break;
|
|
1810
|
+
case 'unavailable':
|
|
1811
|
+
unavailableNodeIds.push(result.nodeId);
|
|
1812
|
+
break;
|
|
1813
|
+
case 'local':
|
|
1814
|
+
localConfirmedCount += 1;
|
|
1815
|
+
break;
|
|
1816
|
+
case 'standing':
|
|
1817
|
+
standingEvidenceCount += 1;
|
|
1818
|
+
break;
|
|
1819
|
+
case 'peerConfirmed':
|
|
1820
|
+
peerAttemptedCount += 1;
|
|
1821
|
+
peerConfirmedCount += 1;
|
|
1822
|
+
break;
|
|
1823
|
+
case 'peerUnavailable':
|
|
1824
|
+
peerAttemptedCount += 1;
|
|
1825
|
+
unavailableNodeIds.push(result.nodeId);
|
|
1826
|
+
break;
|
|
1827
|
+
case 'skip':
|
|
1828
|
+
default:
|
|
1829
|
+
break;
|
|
1830
|
+
}
|
|
1831
|
+
});
|
|
1832
|
+
|
|
1833
|
+
return {
|
|
1834
|
+
directEvidenceCount: localConfirmedCount + peerConfirmedCount + standingEvidenceCount,
|
|
1835
|
+
localConfirmedCount,
|
|
1836
|
+
peerAttemptedCount,
|
|
1837
|
+
peerConfirmedCount,
|
|
1838
|
+
standingEvidenceCount,
|
|
1839
|
+
unavailableNodeIds,
|
|
1840
|
+
deadNodeIds,
|
|
1841
|
+
};
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
export function summarizeMeshSessionRecord(record: any): Record<string, unknown> {
|
|
1845
|
+
const meta = readObjectRecord(record?.meta);
|
|
1846
|
+
const isSelfCoordinator = Boolean(readStringValue(meta.meshCoordinatorFor));
|
|
1847
|
+
const chatStatus = readStringValue(record?.chatStatus, record?.activeChat?.status, meta.chatStatus, meta.sessionStatus);
|
|
1848
|
+
const state = readLiveMeshSessionState(record);
|
|
1849
|
+
const statusNote = isSelfCoordinator && (!chatStatus || chatStatus === 'idle' || state === 'idle')
|
|
1850
|
+
? 'Coordinator self status is sampled from the session host and may read idle while the coordinator is generating this response.'
|
|
1851
|
+
: null;
|
|
1852
|
+
return {
|
|
1853
|
+
sessionId: readStringValue(record?.sessionId) || 'unknown',
|
|
1854
|
+
providerType: readStringValue(record?.providerType),
|
|
1855
|
+
state,
|
|
1856
|
+
chatStatus,
|
|
1857
|
+
lifecycle: readStringValue(record?.lifecycle),
|
|
1858
|
+
surfaceKind: getSessionHostSurfaceKind(record as any),
|
|
1859
|
+
recoveryState: readStringValue(meta.runtimeRecoveryState) ?? null,
|
|
1860
|
+
workspace: readStringValue(record?.workspace) ?? null,
|
|
1861
|
+
title: readStringValue(record?.displayName, record?.workspaceLabel) ?? null,
|
|
1862
|
+
role: isSelfCoordinator ? 'coordinator' : readStringValue(meta.meshRole, meta.role) ?? null,
|
|
1863
|
+
isSelfCoordinator,
|
|
1864
|
+
statusNote,
|
|
1865
|
+
createdAt: toIsoTimestamp(record?.createdAt ?? record?.created_at),
|
|
1866
|
+
startedAt: toIsoTimestamp(record?.startedAt ?? record?.started_at ?? record?.spawnedAtMs ?? record?.spawned_at_ms),
|
|
1867
|
+
lastActivityAt: toIsoTimestamp(record?.updatedAt ?? record?.lastActivityAt ?? record?.last_activity_at),
|
|
1868
|
+
isCached: false,
|
|
1869
|
+
};
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
function liveSessionRecordMatchesMeshNode(record: any, meshId: string, nodeId: string, nodeWorkspace = '', nodeIsMissingLocalWorktree = false): boolean {
|
|
1873
|
+
const recordNodeId = readStringValue(record?.meta?.meshNodeId);
|
|
1874
|
+
// A session's stamped meshNodeId and the node's id can carry interchangeable
|
|
1875
|
+
// daemon-id forms (bare `mach_X` vs `daemon_mach_X`) — compare under the
|
|
1876
|
+
// canonical machine core, not raw `!==` (CANON-IDENTITY class).
|
|
1877
|
+
if (!recordNodeId || !daemonIdsEquivalent(recordNodeId, nodeId)) return false;
|
|
1878
|
+
if (nodeIsMissingLocalWorktree) return false;
|
|
1879
|
+
const recordWorkspace = readStringValue(record?.workspace);
|
|
1880
|
+
// Normalized compare (shared WTCLAIM rule): a base node and a co-located worktree
|
|
1881
|
+
// clone differ ONLY by workspace root, so a separator/case-skewed exact compare
|
|
1882
|
+
// could wrongly keep a sibling worktree's session attached to this node.
|
|
1883
|
+
if (nodeWorkspace && recordWorkspace && !meshWorkspacesEquivalent(recordWorkspace, nodeWorkspace)) return false;
|
|
1884
|
+
const recordMeshId = readStringValue(record?.meta?.meshNodeFor);
|
|
1885
|
+
return !recordMeshId || recordMeshId === meshId;
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
function liveSessionRecordMatchesMeshWorkspace(record: any, meshId: string, workspace: string): boolean {
|
|
1889
|
+
const recordWorkspace = readStringValue(record?.workspace);
|
|
1890
|
+
if (!recordWorkspace || !workspace || !meshWorkspacesEquivalent(recordWorkspace, workspace)) return false;
|
|
1891
|
+
|
|
1892
|
+
const recordMeshId = readStringValue(record?.meta?.meshNodeFor);
|
|
1893
|
+
if (recordMeshId) return recordMeshId === meshId;
|
|
1894
|
+
|
|
1895
|
+
return record?.meta?.launchedByCoordinator === true || !!readStringValue(record?.meta?.meshNodeId);
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
export function readLiveMeshNodeWorkspace(args: {
|
|
1899
|
+
meshId: string;
|
|
1900
|
+
nodeId: string;
|
|
1901
|
+
liveSessionRecords: any[];
|
|
1902
|
+
allowCoordinatorSession?: boolean;
|
|
1903
|
+
}): string {
|
|
1904
|
+
const directNodeWorkspace = args.liveSessionRecords.find((record) => (
|
|
1905
|
+
liveSessionRecordMatchesMeshNode(record, args.meshId, args.nodeId)
|
|
1906
|
+
&& readStringValue(record?.workspace)
|
|
1907
|
+
));
|
|
1908
|
+
if (directNodeWorkspace) {
|
|
1909
|
+
return readStringValue(directNodeWorkspace.workspace) || '';
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
if (args.allowCoordinatorSession) {
|
|
1913
|
+
const coordinatorWorkspace = args.liveSessionRecords.find((record) => (
|
|
1914
|
+
readStringValue(record?.meta?.meshCoordinatorFor) === args.meshId
|
|
1915
|
+
&& readStringValue(record?.workspace)
|
|
1916
|
+
));
|
|
1917
|
+
if (coordinatorWorkspace) {
|
|
1918
|
+
return readStringValue(coordinatorWorkspace.workspace) || '';
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
return '';
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
export function collectLiveMeshSessionRecords(args: {
|
|
1926
|
+
meshId: string;
|
|
1927
|
+
node: any;
|
|
1928
|
+
nodeId: string;
|
|
1929
|
+
liveSessionRecords: any[];
|
|
1930
|
+
allowCoordinatorSession?: boolean;
|
|
1931
|
+
}): any[] {
|
|
1932
|
+
const nodeWorkspace = readStringValue(args.node?.workspace);
|
|
1933
|
+
const nodeIsMissingLocalWorktree = args.node?.isLocalWorktree === true
|
|
1934
|
+
&& !!nodeWorkspace
|
|
1935
|
+
&& !fs.existsSync(nodeWorkspace);
|
|
1936
|
+
const matches = args.liveSessionRecords.filter((record) => {
|
|
1937
|
+
const recordNodeId = readStringValue(record?.meta?.meshNodeId);
|
|
1938
|
+
if (recordNodeId && !daemonIdsEquivalent(recordNodeId, args.nodeId)) return false;
|
|
1939
|
+
if (liveSessionRecordMatchesMeshNode(record, args.meshId, args.nodeId, nodeWorkspace || '', nodeIsMissingLocalWorktree)) return true;
|
|
1940
|
+
if (nodeIsMissingLocalWorktree) return false;
|
|
1941
|
+
return !!nodeWorkspace && liveSessionRecordMatchesMeshWorkspace(record, args.meshId, nodeWorkspace);
|
|
1942
|
+
});
|
|
1943
|
+
|
|
1944
|
+
if (args.allowCoordinatorSession) {
|
|
1945
|
+
for (const record of args.liveSessionRecords) {
|
|
1946
|
+
if (readStringValue(record?.meta?.meshCoordinatorFor) !== args.meshId) continue;
|
|
1947
|
+
const sessionId = readStringValue(record?.sessionId);
|
|
1948
|
+
if (sessionId && matches.some((entry) => sessionIdsEquivalent(readStringValue(entry?.sessionId), sessionId))) continue;
|
|
1949
|
+
matches.push(record);
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
return matches;
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
export function buildHistoricalMeshSessions(args: {
|
|
1957
|
+
meshId: string;
|
|
1958
|
+
nodes: any[];
|
|
1959
|
+
liveSessionRecords: any[];
|
|
1960
|
+
}): { count: number; sessions: Record<string, unknown>[]; instruction: string } | undefined {
|
|
1961
|
+
const liveNodeIds = new Set<string>();
|
|
1962
|
+
const liveWorkspaces = new Set<string>();
|
|
1963
|
+
const missingLocalWorktreeNodeIds = new Set<string>();
|
|
1964
|
+
for (const node of args.nodes || []) {
|
|
1965
|
+
const nodeId = normalizeMeshNodeId(node);
|
|
1966
|
+
const workspace = readStringValue(node?.workspace);
|
|
1967
|
+
if (nodeId) liveNodeIds.add(nodeId);
|
|
1968
|
+
if (workspace) liveWorkspaces.add(workspace);
|
|
1969
|
+
if (nodeId && node?.isLocalWorktree === true && workspace && !fs.existsSync(workspace)) {
|
|
1970
|
+
missingLocalWorktreeNodeIds.add(nodeId);
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
const sessions: Record<string, unknown>[] = [];
|
|
1975
|
+
for (const record of args.liveSessionRecords || []) {
|
|
1976
|
+
const meta = readObjectRecord(record?.meta);
|
|
1977
|
+
const recordMeshId = readStringValue(meta.meshNodeFor, meta.meshCoordinatorFor);
|
|
1978
|
+
if (recordMeshId !== args.meshId) continue;
|
|
1979
|
+
const recordNodeId = readStringValue(meta.meshNodeId);
|
|
1980
|
+
const workspace = readStringValue(record?.workspace);
|
|
1981
|
+
const removedNode = !!recordNodeId && (!liveNodeIds.has(recordNodeId) || missingLocalWorktreeNodeIds.has(recordNodeId));
|
|
1982
|
+
const orphanedWorkspace = !!workspace && !liveWorkspaces.has(workspace) && meta.meshCoordinatorFor !== args.meshId;
|
|
1983
|
+
if (!removedNode && !orphanedWorkspace) continue;
|
|
1984
|
+
sessions.push({
|
|
1985
|
+
...summarizeMeshSessionRecord(record),
|
|
1986
|
+
classification: removedNode ? 'removedNode' : 'orphanedSession',
|
|
1987
|
+
historical: true,
|
|
1988
|
+
meshNodeId: recordNodeId || null,
|
|
1989
|
+
reason: removedNode
|
|
1990
|
+
? 'Session is tagged to a mesh node that is no longer in live membership.'
|
|
1991
|
+
: 'Session workspace is no longer attached to a live mesh node.',
|
|
1992
|
+
});
|
|
1993
|
+
}
|
|
1994
|
+
if (sessions.length === 0) return undefined;
|
|
1995
|
+
return {
|
|
1996
|
+
count: sessions.length,
|
|
1997
|
+
sessions: sessions.slice(0, 5),
|
|
1998
|
+
instruction: 'These sessions are separated from normal node activeSessions because their mesh node/workspace is no longer live. Use mesh_cleanup_sessions only if cleanup is intended.',
|
|
1999
|
+
};
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
export function applyCachedInlineMeshNodeStatus(
|
|
2003
|
+
status: Record<string, unknown>,
|
|
2004
|
+
node: any,
|
|
2005
|
+
options?: { skipGit?: boolean; skipError?: boolean; skipHealth?: boolean },
|
|
2006
|
+
): boolean {
|
|
2007
|
+
const cachedStatus = readObjectRecord(node?.cachedStatus);
|
|
2008
|
+
const liveGit = buildInlineMeshTransitGitStatus(node);
|
|
2009
|
+
const git = options?.skipGit ? undefined : (liveGit ?? buildCachedInlineMeshGitStatus(node));
|
|
2010
|
+
const error = options?.skipError ? undefined : (liveGit ? undefined : readStringValue(cachedStatus.error, node?.error));
|
|
2011
|
+
const health = options?.skipHealth ? undefined : (liveGit ? undefined : readStringValue(cachedStatus.health, node?.health));
|
|
2012
|
+
const machineStatus = readStringValue(cachedStatus.machineStatus, node?.machineStatus);
|
|
2013
|
+
const lastSeenAt = toIsoTimestamp(cachedStatus.lastSeenAt ?? cachedStatus.last_seen_at ?? node?.lastSeenAt ?? node?.last_seen_at);
|
|
2014
|
+
const updatedAt = toIsoTimestamp(cachedStatus.updatedAt ?? cachedStatus.updated_at ?? node?.updatedAt ?? node?.updated_at);
|
|
2015
|
+
const activeSessions = readCachedInlineMeshActiveSessions(node);
|
|
2016
|
+
const activeSessionDetails = readCachedInlineMeshActiveSessionDetails(node);
|
|
2017
|
+
if (!git && !error && !health && !machineStatus && !lastSeenAt && !updatedAt && activeSessions.length === 0) return false;
|
|
2018
|
+
if (git) status.git = git;
|
|
2019
|
+
if (error) status.error = error;
|
|
2020
|
+
if (machineStatus) status.machineStatus = machineStatus;
|
|
2021
|
+
if (lastSeenAt) status.lastSeenAt = lastSeenAt;
|
|
2022
|
+
if (updatedAt) status.updatedAt = updatedAt;
|
|
2023
|
+
if (activeSessions.length > 0) status.activeSessions = activeSessions;
|
|
2024
|
+
if (activeSessionDetails.length > 0) status.activeSessionDetails = activeSessionDetails;
|
|
2025
|
+
if (health) {
|
|
2026
|
+
status.health = health;
|
|
2027
|
+
return true;
|
|
2028
|
+
}
|
|
2029
|
+
if (git) {
|
|
2030
|
+
status.health = deriveMeshNodeHealthFromGit(git);
|
|
2031
|
+
return true;
|
|
2032
|
+
}
|
|
2033
|
+
return activeSessions.length > 0 || !!machineStatus || !!lastSeenAt || !!updatedAt;
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
export async function resolveProviderTypeFromPriority(args: {
|
|
2037
|
+
nodeId: string;
|
|
2038
|
+
providerPriority: string[];
|
|
2039
|
+
providerLoader: ProviderLoader;
|
|
2040
|
+
onStatusChange?: () => void;
|
|
2041
|
+
}): Promise<{ providerType?: string; error?: string }> {
|
|
2042
|
+
if (!args.providerPriority.length) {
|
|
2043
|
+
return { error: `Node '${args.nodeId}' has no providerPriority policy; pass cliType explicitly or configure node.policy.providerPriority` };
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
const failed: string[] = [];
|
|
2047
|
+
for (const requestedType of args.providerPriority) {
|
|
2048
|
+
const normalizedType = args.providerLoader.resolveAlias(requestedType);
|
|
2049
|
+
if (!args.providerLoader.isMachineProviderEnabled(normalizedType)) {
|
|
2050
|
+
failed.push(`${requestedType}: disabled`);
|
|
2051
|
+
continue;
|
|
2052
|
+
}
|
|
2053
|
+
const detected = await detectCLI(normalizedType, args.providerLoader, { includeVersion: false });
|
|
2054
|
+
args.providerLoader.setCliDetectionResults([{
|
|
2055
|
+
id: normalizedType,
|
|
2056
|
+
installed: !!detected,
|
|
2057
|
+
path: detected?.path,
|
|
2058
|
+
}], false);
|
|
2059
|
+
args.onStatusChange?.();
|
|
2060
|
+
if (detected) return { providerType: normalizedType };
|
|
2061
|
+
failed.push(`${requestedType}: not detected`);
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
return { error: `No usable provider detected for node '${args.nodeId}' from providerPriority: ${failed.join('; ')}` };
|
|
2065
|
+
}
|