create-walle 0.10.0 → 0.10.2
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/README.md +88 -41
- package/lib/owner-write-safety.js +135 -0
- package/lib/public-runtime.js +13 -5
- package/lib/release-schema.json +3 -2
- package/lib/runtime-closure-policy.json +7 -5
- package/package.json +10 -4
- package/template/bin/deps-doctor.js +1 -1
- package/template/bin/node-bin.sh +11 -0
- package/template/coding-task-manager/api-prompts.js +10 -22
- package/template/coding-task-manager/bin/capture-codex-rollout-records.js +417 -0
- package/template/coding-task-manager/bin/census-codex-lead-history.js +325 -0
- package/template/coding-task-manager/bin/claude-hook.js +7 -3
- package/template/coding-task-manager/bin/claude-table-parity.js +307 -0
- package/template/coding-task-manager/bin/conversation-storage-auto-migration-lib.js +16 -1
- package/template/coding-task-manager/bin/conversation-storage-generation-evidence.js +9 -2
- package/template/coding-task-manager/bin/conversation-storage-service-entry-lib.js +16 -1
- package/template/coding-task-manager/bin/conversation-storage-walle-owner.js +247 -0
- package/template/coding-task-manager/bin/ctm-machine-pair.js +160 -0
- package/template/coding-task-manager/bin/lead-wait-census.js +1348 -0
- package/template/coding-task-manager/bin/repair-foreign-clone.js +564 -0
- package/template/coding-task-manager/bin/repair-resume-echo-titles.js +248 -0
- package/template/coding-task-manager/bin/seam-replay-codex.js +301 -0
- package/template/coding-task-manager/bin/write-lock-bench.js +111 -0
- package/template/coding-task-manager/db.js +705 -286
- package/template/coding-task-manager/git-utils.js +44 -0
- package/template/coding-task-manager/lib/adopted-host-redial.js +4 -0
- package/template/coding-task-manager/lib/agent-presets.js +15 -0
- package/template/coding-task-manager/lib/agent-title-apply.js +88 -0
- package/template/coding-task-manager/lib/agent-title-backfill-read.js +92 -0
- package/template/coding-task-manager/lib/api/host-stats.js +98 -0
- package/template/coding-task-manager/lib/api/ingress.js +35 -12
- package/template/coding-task-manager/lib/api/machine-pairing.js +285 -0
- package/template/coding-task-manager/lib/api/machine-pool.js +307 -0
- package/template/coding-task-manager/lib/api/machines.js +461 -0
- package/template/coding-task-manager/lib/api/misc-ops.js +20 -5
- package/template/coding-task-manager/lib/api/providers.js +53 -6
- package/template/coding-task-manager/lib/api/resume-from-store.js +25 -0
- package/template/coding-task-manager/lib/api/sessions.js +160 -8
- package/template/coding-task-manager/lib/api/transcript-history.js +17 -4
- package/template/coding-task-manager/lib/api/worktrees.js +38 -37
- package/template/coding-task-manager/lib/approval-drift.js +16 -1
- package/template/coding-task-manager/lib/approval-logical-frame.js +5 -1
- package/template/coding-task-manager/lib/approval-scope.js +68 -4
- package/template/coding-task-manager/lib/auth-context.js +32 -2
- package/template/coding-task-manager/lib/auth-rate-limit.js +41 -1
- package/template/coding-task-manager/lib/auth-rules.js +186 -45
- package/template/coding-task-manager/lib/broadcast-serialize.js +2 -1
- package/template/coding-task-manager/lib/browser-session-broker.js +9 -1
- package/template/coding-task-manager/lib/browser-use-adapter.js +33 -2
- package/template/coding-task-manager/lib/claude-markdown-ansi.js +27 -85
- package/template/coding-task-manager/lib/claude-session-title.js +6 -30
- package/template/coding-task-manager/lib/claude-table-layout.js +322 -0
- package/template/coding-task-manager/lib/client-diag-handler.js +25 -0
- package/template/coding-task-manager/lib/codex-config-guard.js +33 -3
- package/template/coding-task-manager/lib/codex-harness-qualification.js +12 -0
- package/template/coding-task-manager/lib/codex-history-terminal-renderer.js +33 -33
- package/template/coding-task-manager/lib/codex-launch-health.js +29 -2
- package/template/coding-task-manager/lib/codex-link-preparation.js +9 -8
- package/template/coding-task-manager/lib/codex-local-links.js +267 -0
- package/template/coding-task-manager/lib/codex-markdown-lists.js +160 -0
- package/template/coding-task-manager/lib/codex-native-history-prefix.js +4 -0
- package/template/coding-task-manager/lib/codex-paths.js +2 -1
- package/template/coding-task-manager/lib/codex-source-handoff.js +186 -0
- package/template/coding-task-manager/lib/coding-agent-models.js +21 -30
- package/template/coding-task-manager/lib/coding-session-model-adapters.js +27 -2
- package/template/coding-task-manager/lib/coding-session-model-projection.js +5 -0
- package/template/coding-task-manager/lib/coding-session-model-runtime.js +1 -2
- package/template/coding-task-manager/lib/coding-session-model-state.js +10 -0
- package/template/coding-task-manager/lib/coding-session-model-sync-state.js +0 -1
- package/template/coding-task-manager/lib/conversation-import/cursor-guard-owner.js +10 -0
- package/template/coding-task-manager/lib/create/resolve-launch-params.js +8 -1
- package/template/coding-task-manager/lib/ctm-boot-lifecycle.js +17 -1
- package/template/coding-task-manager/lib/db-domain-tables.js +7 -0
- package/template/coding-task-manager/lib/db-domains.js +21 -2
- package/template/coding-task-manager/lib/db-owner-op-registry.js +7 -0
- package/template/coding-task-manager/lib/db-owner-worker-client.js +22 -0
- package/template/coding-task-manager/lib/db-schema-source-files.js +1 -0
- package/template/coding-task-manager/lib/feature-manifest.js +1 -3
- package/template/coding-task-manager/lib/fs-watcher.js +3 -2
- package/template/coding-task-manager/lib/harness-history-transition.js +41 -14
- package/template/coding-task-manager/lib/headless-inflight-ops.js +203 -0
- package/template/coding-task-manager/lib/headless-missing-reply.js +42 -0
- package/template/coding-task-manager/lib/headless-term-service.js +60 -23
- package/template/coding-task-manager/lib/history-transition-read-op.js +179 -23
- package/template/coding-task-manager/lib/host-control-dial.js +167 -0
- package/template/coding-task-manager/lib/host-handoff-orchestrator.js +16 -2
- package/template/coding-task-manager/lib/host-handoff.js +51 -13
- package/template/coding-task-manager/lib/host-health-ledger.js +164 -12
- package/template/coding-task-manager/lib/host-self-truth-watchdog.js +4 -0
- package/template/coding-task-manager/lib/host-stats.js +328 -0
- package/template/coding-task-manager/lib/host-threadpool-evacuation.js +160 -0
- package/template/coding-task-manager/lib/host-threadpool-monitor.js +14 -1
- package/template/coding-task-manager/lib/hosted-input-dispatch.js +203 -0
- package/template/coding-task-manager/lib/hosted-sync-worker.js +113 -5
- package/template/coding-task-manager/lib/instance-id-ownership-manifest.js +4 -0
- package/template/coding-task-manager/lib/instance-manifest-runtime.js +3 -0
- package/template/coding-task-manager/lib/instance-manifest.js +13 -1
- package/template/coding-task-manager/lib/instance-sync-status.js +15 -0
- package/template/coding-task-manager/lib/kernel/diag-log.js +12 -0
- package/template/coding-task-manager/lib/launch-model-override.js +160 -1
- package/template/coding-task-manager/lib/launch-plan-worker.js +59 -2
- package/template/coding-task-manager/lib/lead-model-observer.js +17 -2
- package/template/coding-task-manager/lib/lead-model-switch-runtime.js +57 -4
- package/template/coding-task-manager/lib/lead-model-switch.js +11 -2
- package/template/coding-task-manager/lib/lead-model-transition-runtime.js +42 -4
- package/template/coding-task-manager/lib/lead-model-transition-store.js +61 -3
- package/template/coding-task-manager/lib/legacy-draft-cancellation-recovery.js +111 -0
- package/template/coding-task-manager/lib/login-tripwire.js +18 -0
- package/template/coding-task-manager/lib/machine-pairing.js +238 -0
- package/template/coding-task-manager/lib/machine-pool.js +409 -0
- package/template/coding-task-manager/lib/machine-registry.js +749 -0
- package/template/coding-task-manager/lib/machine-self.js +66 -0
- package/template/coding-task-manager/lib/main-thread-lint-exemptions.json +56 -4
- package/template/coding-task-manager/lib/media-journal-control-reader.js +473 -0
- package/template/coding-task-manager/lib/media-journal.js +84 -27
- package/template/coding-task-manager/lib/media-store-options.js +43 -15
- package/template/coding-task-manager/lib/migrations/v56-host-samples-and-machine-pool.js +76 -0
- package/template/coding-task-manager/lib/mirror-feed-sanitize.js +12 -3
- package/template/coding-task-manager/lib/model-execution-recipe-metadata.js +16 -3
- package/template/coding-task-manager/lib/model-proxy/anthropic-face.js +4 -0
- package/template/coding-task-manager/lib/model-proxy/chat-response-translators.js +3 -0
- package/template/coding-task-manager/lib/model-proxy/collaboration-codec.js +94 -0
- package/template/coding-task-manager/lib/model-proxy/handoff.js +494 -0
- package/template/coding-task-manager/lib/model-proxy/lead-worker-picks.js +25 -0
- package/template/coding-task-manager/lib/model-proxy/native-lead-listener.js +61 -7
- package/template/coding-task-manager/lib/model-proxy/proxy-cli.js +200 -0
- package/template/coding-task-manager/lib/model-proxy/proxy-instance-record.js +257 -0
- package/template/coding-task-manager/lib/model-proxy/responses-face.js +24 -2
- package/template/coding-task-manager/lib/model-proxy/stream-accounting.js +205 -0
- package/template/coding-task-manager/lib/model-proxy/tokens.js +78 -0
- package/template/coding-task-manager/lib/model-proxy/upstream-outcome.js +9 -0
- package/template/coding-task-manager/lib/native-history-control.js +62 -14
- package/template/coding-task-manager/lib/native-lead-settings-client.js +2 -1
- package/template/coding-task-manager/lib/native-lead-tls.js +18 -4
- package/template/coding-task-manager/lib/oauth-proxy-supervisor.js +628 -64
- package/template/coding-task-manager/lib/owner-action-manifest-data.json +14 -0
- package/template/coding-task-manager/lib/painted-row-recorder.js +9 -1
- package/template/coding-task-manager/lib/prompt-admission-trace.js +69 -0
- package/template/coding-task-manager/lib/prompt-submission-contract.js +21 -1
- package/template/coding-task-manager/lib/provider-login-broker.js +192 -9
- package/template/coding-task-manager/lib/provider-login-capture.js +153 -0
- package/template/coding-task-manager/lib/queue-draft-status-overlay.js +36 -0
- package/template/coding-task-manager/lib/raw-codex-history-window.js +5 -3
- package/template/coding-task-manager/lib/raw-history-block.js +3 -1
- package/template/coding-task-manager/lib/read-pool-client.js +36 -24
- package/template/coding-task-manager/lib/read-pool-op-registry.js +10 -1
- package/template/coding-task-manager/lib/read-pool-telemetry.js +14 -5
- package/template/coding-task-manager/lib/recent-catalog-query.js +26 -0
- package/template/coding-task-manager/lib/recent-session-catalog-response.js +9 -0
- package/template/coding-task-manager/lib/remote-command-context.js +12 -0
- package/template/coding-task-manager/lib/remote-command-runtime.js +18 -5
- package/template/coding-task-manager/lib/remote-machine-catalog.js +1062 -0
- package/template/coding-task-manager/lib/remote-machine-create.js +693 -0
- package/template/coding-task-manager/lib/remote-machine-link.js +939 -0
- package/template/coding-task-manager/lib/remote-machine-proxy.js +520 -0
- package/template/coding-task-manager/lib/remote-session-forward.js +306 -0
- package/template/coding-task-manager/lib/remote-submission-status.js +6 -0
- package/template/coding-task-manager/lib/restart-outcome-truth.js +20 -3
- package/template/coding-task-manager/lib/restart-preflight.js +9 -2
- package/template/coding-task-manager/lib/restore-backoff-gate.js +10 -2
- package/template/coding-task-manager/lib/restore-prepare-retry.js +12 -5
- package/template/coding-task-manager/lib/restore-resume-batch.js +22 -1
- package/template/coding-task-manager/lib/resume-blocked-wire.js +62 -1
- package/template/coding-task-manager/lib/resume-cwd.js +13 -0
- package/template/coding-task-manager/lib/resume-from-store.js +12 -1
- package/template/coding-task-manager/lib/resume-origin-facts.js +38 -1
- package/template/coding-task-manager/lib/resume-preflight.js +21 -16
- package/template/coding-task-manager/lib/review-repo-admission.js +39 -6
- package/template/coding-task-manager/lib/review-repo-registry.js +17 -0
- package/template/coding-task-manager/lib/root-delivery-runtime.js +72 -15
- package/template/coding-task-manager/lib/root-delivery-selection.js +37 -1
- package/template/coding-task-manager/lib/root-prompt-admission.js +43 -11
- package/template/coding-task-manager/lib/root-prompt-flow.js +175 -38
- package/template/coding-task-manager/lib/scrollback-archive-store.js +7 -4
- package/template/coding-task-manager/lib/server-memory-sampler-runtime.js +10 -0
- package/template/coding-task-manager/lib/server-memory-sampler.js +41 -2
- package/template/coding-task-manager/lib/session-analysis-memory.js +2 -0
- package/template/coding-task-manager/lib/session-create/codex-native-provider-gate.js +218 -0
- package/template/coding-task-manager/lib/session-create/codex-resume-exit-classify.js +52 -0
- package/template/coding-task-manager/lib/session-create/delegate-worker.js +95 -6
- package/template/coding-task-manager/lib/session-create/identity.js +20 -1
- package/template/coding-task-manager/lib/session-create/prepared.js +69 -1
- package/template/coding-task-manager/lib/session-create/provider-launch-context.js +77 -0
- package/template/coding-task-manager/lib/session-create/provider-process.js +38 -39
- package/template/coding-task-manager/lib/session-create/response.js +30 -12
- package/template/coding-task-manager/lib/session-create/transcript.js +13 -4
- package/template/coding-task-manager/lib/session-create/worker-continuation.js +43 -1
- package/template/coding-task-manager/lib/session-create/worker-notices.js +94 -5
- package/template/coding-task-manager/lib/session-create/worker-observability.js +99 -7
- package/template/coding-task-manager/lib/session-create/worker-stop.js +11 -3
- package/template/coding-task-manager/lib/session-create/worker-wait-watcher.js +315 -0
- package/template/coding-task-manager/lib/session-geometry-authority-handler.js +10 -1
- package/template/coding-task-manager/lib/session-host-manager.js +152 -206
- package/template/coding-task-manager/lib/session-jobs.js +17 -9
- package/template/coding-task-manager/lib/session-lifecycle.js +20 -50
- package/template/coding-task-manager/lib/session-model-action-preconditions.js +68 -3
- package/template/coding-task-manager/lib/session-resource-sampler.js +67 -9
- package/template/coding-task-manager/lib/session-standup.js +67 -4
- package/template/coding-task-manager/lib/session-status-ledger.js +42 -3
- package/template/coding-task-manager/lib/session-status-runtime.js +45 -1
- package/template/coding-task-manager/lib/session-status.js +2 -2
- package/template/coding-task-manager/lib/session-title-mirror.js +25 -57
- package/template/coding-task-manager/lib/session-transcript-trash.js +8 -1
- package/template/coding-task-manager/lib/startup-pointer-write.js +57 -0
- package/template/coding-task-manager/lib/state-sync/painted-frame-field.js +11 -0
- package/template/coding-task-manager/lib/storage/coding-session-model-store.js +21 -22
- package/template/coding-task-manager/lib/surviving-owner-bootstrap.js +7 -2
- package/template/coding-task-manager/lib/surviving-owner-recovery.js +2 -1
- package/template/coding-task-manager/lib/terminal-draft-http-status.js +25 -0
- package/template/coding-task-manager/lib/transcript-archiver.js +229 -229
- package/template/coding-task-manager/lib/transcript-frame-cache.js +12 -2
- package/template/coding-task-manager/lib/transcript-guard-job.js +55 -4
- package/template/coding-task-manager/lib/transcript-link.js +77 -0
- package/template/coding-task-manager/lib/transcript-manifest-store.js +8 -4
- package/template/coding-task-manager/lib/transcript-source.js +8 -3
- package/template/coding-task-manager/lib/transcript-store.js +40 -0
- package/template/coding-task-manager/lib/tunnel-lane-governor.js +6 -3
- package/template/coding-task-manager/lib/typing-interaction-trace.js +136 -2
- package/template/coding-task-manager/lib/typing-worker-stages.js +208 -1
- package/template/coding-task-manager/lib/usage-ledger/claude-parse.js +53 -2
- package/template/coding-task-manager/lib/usage-ledger/ingest.js +13 -4
- package/template/coding-task-manager/lib/usage-ledger/read.js +14 -0
- package/template/coding-task-manager/lib/usage-ledger/schema.js +49 -1
- package/template/coding-task-manager/lib/usage-ledger/store.js +337 -6
- package/template/coding-task-manager/lib/usage-ledger/task-read.js +123 -5
- package/template/coding-task-manager/lib/visible-revision-watchdog.js +8 -12
- package/template/coding-task-manager/lib/wake-skip-reasons.js +46 -0
- package/template/coding-task-manager/lib/walle-supervisor.js +98 -14
- package/template/coding-task-manager/lib/worker-failure-classify.js +13 -1
- package/template/coding-task-manager/lib/ws/input.js +1 -0
- package/template/coding-task-manager/lib/ws/state-sync.js +19 -2
- package/template/coding-task-manager/lib/ws/terminal-attach.js +32 -1
- package/template/coding-task-manager/lib/ws-router.js +68 -1
- package/template/coding-task-manager/lib/ws-send-admission.js +22 -4
- package/template/coding-task-manager/oauth-proxy.js +99 -55
- package/template/coding-task-manager/package-lock.json +24 -0
- package/template/coding-task-manager/package.json +3 -0
- package/template/coding-task-manager/protocol/message-table.json +0 -10
- package/template/coding-task-manager/protocol/messages.js +0 -10
- package/template/coding-task-manager/protocol/ws-schema.json +0 -20
- package/template/coding-task-manager/protocol/ws-schema.md +0 -2
- package/template/coding-task-manager/providers/codex-mcp-extract.js +121 -19
- package/template/coding-task-manager/providers/codex-mcp.js +14 -5
- package/template/coding-task-manager/public/css/app.css +279 -2
- package/template/coding-task-manager/public/css/reviews.css +4 -0
- package/template/coding-task-manager/public/index.html +297 -240
- package/template/coding-task-manager/public/js/app/machines-page.js +1252 -0
- package/template/coding-task-manager/public/js/app/models-tab.js +546 -36
- package/template/coding-task-manager/public/js/app/worktrees-panel.js +7 -5
- package/template/coding-task-manager/public/js/background-work.js +13 -7
- package/template/coding-task-manager/public/js/client-health-core.js +81 -0
- package/template/coding-task-manager/public/js/client-health-main.js +61 -0
- package/template/coding-task-manager/public/js/coding-session-model-presentation.js +44 -4
- package/template/coding-task-manager/public/js/core/session-commands.js +676 -0
- package/template/coding-task-manager/public/js/core/session-selectors.js +5 -1
- package/template/coding-task-manager/public/js/history-app-seam.js +26 -2
- package/template/coding-task-manager/public/js/history-painted-rows.js +17 -2
- package/template/coding-task-manager/public/js/history-reader-park.js +13 -1
- package/template/coding-task-manager/public/js/history-scroller.js +187 -9
- package/template/coding-task-manager/public/js/history-transcript-pages.js +5 -5
- package/template/coding-task-manager/public/js/machine-pool-shared.js +436 -0
- package/template/coding-task-manager/public/js/private-state-scope.js +31 -3
- package/template/coding-task-manager/public/js/prompt-submission-client.js +173 -4
- package/template/coding-task-manager/public/js/prompt-submission-tray.js +281 -0
- package/template/coding-task-manager/public/js/protocol.js +0 -10
- package/template/coding-task-manager/public/js/queue-panel-submission-reconcile.js +174 -1
- package/template/coding-task-manager/public/js/reviews.js +7 -3
- package/template/coding-task-manager/public/js/root-draft-lifetime.js +139 -2
- package/template/coding-task-manager/public/js/root-queue-builder-lifetime.js +16 -0
- package/template/coding-task-manager/public/js/row-join.js +18 -3
- package/template/coding-task-manager/public/js/session-model-picker.js +1 -1
- package/template/coding-task-manager/public/js/session-search-utils.js +46 -19
- package/template/coding-task-manager/public/js/session-usage-view.js +1 -0
- package/template/coding-task-manager/public/js/state-sync-client.js +4 -0
- package/template/coding-task-manager/public/js/terminal/runtime-02.js +16 -7
- package/template/coding-task-manager/public/js/terminal/runtime-03.js +41 -0
- package/template/coding-task-manager/public/js/terminal/runtime-04.js +41 -4
- package/template/coding-task-manager/public/js/terminal/runtime-12.js +52 -2
- package/template/coding-task-manager/public/js/terminal/runtime-16.js +12 -12
- package/template/coding-task-manager/public/js/terminal/runtime-18.js +14 -0
- package/template/coding-task-manager/public/js/terminal/runtime-19.js +8 -4
- package/template/coding-task-manager/public/js/terminal/runtime-20.js +210 -73
- package/template/coding-task-manager/public/js/terminal/runtime-21.js +19 -6
- package/template/coding-task-manager/public/js/terminal/runtime-22.js +86 -0
- package/template/coding-task-manager/public/js/terminal-history-viewport.js +1 -3
- package/template/coding-task-manager/public/js/terminal-wheel.js +39 -2
- package/template/coding-task-manager/public/js/walle/chat-conversation.js +11 -3
- package/template/coding-task-manager/public/js/walle-session.js +30 -7
- package/template/coding-task-manager/public/m/app.css +226 -8
- package/template/coding-task-manager/public/m/app.js +502 -9
- package/template/coding-task-manager/public/m/index.html +38 -1
- package/template/coding-task-manager/public/m/machines.js +1313 -0
- package/template/coding-task-manager/public/m/pool-failover.js +391 -0
- package/template/coding-task-manager/public/m/private-queue-actions.js +26 -7
- package/template/coding-task-manager/public/m/sw.js +5 -0
- package/template/coding-task-manager/queue-engine.js +13 -0
- package/template/coding-task-manager/server/approvals.js +5 -6
- package/template/coding-task-manager/server/boot.js +136 -17
- package/template/coding-task-manager/server.js +964 -425
- package/template/coding-task-manager/session-integrity.js +7 -9
- package/template/coding-task-manager/workers/db-owner-worker.js +53 -7
- package/template/coding-task-manager/workers/read-pool-worker.js +17 -6
- package/template/coding-task-manager/workers/session-host-pool-process.js +18 -4
- package/template/coding-task-manager/workers/session-host-process.js +14 -3
- package/template/coding-task-manager/workers/transcript-archive-worker.js +1 -1
- package/template/coding-task-manager/workers/worktree-worker.js +34 -0
- package/template/package-lock.json +2 -2
- package/template/package.json +1 -1
- package/template/scripts/conversation-storage-v2-migration.js +78 -11
- package/template/shared/conversation-storage-v2-contract.js +42 -6
- package/template/shared/fixtures/worker-status-payloads.json +392 -0
- package/template/shared/git-remote-key.js +83 -0
- package/template/shared/host-identity.js +98 -2
- package/template/shared/owner-write-safety.js +135 -0
- package/template/shared/public-runtime.js +13 -5
- package/template/shared/release-schema.json +3 -2
- package/template/shared/report-citations.js +14 -0
- package/template/shared/sqlite-write-lock.js +361 -223
- package/template/shared/storage/contract.js +35 -3
- package/template/wall-e/agent.js +21 -0
- package/template/wall-e/api-walle.js +5 -0
- package/template/wall-e/bin/brain-purge-legacy-ctm.js +3 -2
- package/template/wall-e/bin/walle-mcp-stdio.js +12 -6
- package/template/wall-e/brain/helper-rules-schema.js +129 -0
- package/template/wall-e/brain/migrations.js +51 -22
- package/template/wall-e/brain/model-deployment-facts.js +31 -1
- package/template/wall-e/brain/owner-call-whitelist.js +16 -0
- package/template/wall-e/brain/schema-ensures.js +22 -0
- package/template/wall-e/brain.js +1 -1
- package/template/wall-e/coding/host-capabilities.js +2 -2
- package/template/wall-e/coding/journal-dispatcher.js +51 -0
- package/template/wall-e/coding/session-journal.js +28 -40
- package/template/wall-e/coding-orchestrator.js +23 -26
- package/template/wall-e/fly.toml +1 -1
- package/template/wall-e/http/auth.js +18 -1
- package/template/wall-e/http/blog-views-api.js +318 -0
- package/template/wall-e/http/helper-rules-api.js +321 -0
- package/template/wall-e/http/model-proxy-providers.js +3 -1
- package/template/wall-e/http/telemetry-api.js +12 -0
- package/template/wall-e/lib/blog-view-policy.js +88 -0
- package/template/wall-e/lib/blog-view-store.js +169 -0
- package/template/wall-e/lib/client-config-lock.js +2 -1
- package/template/wall-e/lib/coding-journal-writer-child-client.js +67 -18
- package/template/wall-e/lib/error-kinds.js +10 -1
- package/template/wall-e/lib/feature-manifest.js +1 -1
- package/template/wall-e/lib/helper-rules/cell-state.js +123 -0
- package/template/wall-e/lib/helper-rules/evaluate.js +357 -0
- package/template/wall-e/lib/helper-rules/explore-rubric.md +12 -0
- package/template/wall-e/lib/helper-rules/grade.js +277 -0
- package/template/wall-e/lib/helper-rules/label.js +247 -0
- package/template/wall-e/lib/helper-rules/lead-call-hook.js +522 -0
- package/template/wall-e/lib/helper-rules/store.js +667 -0
- package/template/wall-e/lib/mcp-integration.js +5 -6
- package/template/wall-e/lib/runtime-worker-pool.js +1 -0
- package/template/wall-e/lib/scheduler-worker-jobs.js +6 -0
- package/template/wall-e/lib/telemetry-db-schema.js +37 -0
- package/template/wall-e/lib/telemetry-process-client.js +76 -1
- package/template/wall-e/llm/default-fallback.js +4 -0
- package/template/wall-e/llm/model-catalog.js +13 -4
- package/template/wall-e/llm/model-roles.js +6 -0
- package/template/wall-e/llm/portkey-http.js +150 -0
- package/template/wall-e/llm/portkey-sync.js +15 -145
- package/template/wall-e/llm/worker-mcp-routing.js +61 -0
- package/template/wall-e/llm/worker-route.js +43 -1
- package/template/wall-e/loops/brain-custom-writes.js +45 -0
- package/template/wall-e/mcp-protocol-contract.js +41 -1
- package/template/wall-e/mcp-server.js +13 -2
- package/template/wall-e/mcp-worker-schemas.js +20 -2
- package/template/wall-e/memory/ctm-context-client.js +25 -5
- package/template/wall-e/shared/release-schema.json +3 -2
- package/template/wall-e/shared/sqlite-write-lock.js +361 -223
- package/template/wall-e/shared/storage/contract.js +35 -3
- package/template/wall-e/skills/_templates/delegate-worker/VERSION +1 -1
- package/template/wall-e/skills/_templates/delegate-worker/released-sha256.json +6 -0
- package/template/wall-e/skills/_templates/delegate-worker/templates/claude/SKILL.md +7 -2
- package/template/wall-e/skills/_templates/delegate-worker/templates/codex/SKILL.md +7 -2
- package/template/wall-e/tools/local-tools.js +12 -5
- package/template/wall-e/workers/brain-owner-worker.js +9 -0
- package/template/wall-e/workers/coding-journal-writer-child.js +8 -1
- package/template/wall-e/workers/telemetry-worker.js +8 -0
- package/template/coding-task-manager/lib/giant-freeze-park.js +0 -31
|
@@ -0,0 +1,939 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// WP-3 (docs/remote-machine-hub-sessions-2026-10-22.html §6.4, §7, §8 AC-3a..3d, §9 S-22/S-23/S-24):
|
|
4
|
+
// the hub-side LIVE PLANE relay.
|
|
5
|
+
//
|
|
6
|
+
// A remote session (created through lib/remote-machine-create.js — WP-2) lives IN THE BOX's
|
|
7
|
+
// process. It is deliberately NOT an entry in this process's `sessions` Map (OQ-4: ~16 list/attach
|
|
8
|
+
// `.ptyProcess` dereferences is more than merging is worth), so every frame the hub's browser sends
|
|
9
|
+
// about it — a keystroke, a prompt, an approval verdict, a resize — must be carried to the box and
|
|
10
|
+
// every frame the box produces must be carried back. This module owns that carry:
|
|
11
|
+
//
|
|
12
|
+
// LINK one upstream WS per machine, lazy, opened on the first viewed session and closed
|
|
13
|
+
// after IDLE_CLOSE_MS with no viewers. `listLinks().length <= 1` per machine by
|
|
14
|
+
// construction (a Map keyed by the machine's minted instance id).
|
|
15
|
+
// INTERCEPT the single dispatch seam (lib/ws-router.js's pre-dispatch hook, consulted at
|
|
16
|
+
// `dispatchWs` before ANY local handler). A message naming a MIRROR session is
|
|
17
|
+
// diverted here; a message naming a local session returns false immediately and takes
|
|
18
|
+
// the byte-identical existing path (S-22, and the brief's "local sessions must take
|
|
19
|
+
// the byte-identical existing path").
|
|
20
|
+
// FORWARD hub browser -> box, VERBATIM. `clientMessageId` (S-24) and every other id is the
|
|
21
|
+
// ORIGINAL one the browser minted — this module never re-mints an idempotency key.
|
|
22
|
+
// FAN-OUT box -> every hub browser socket viewing that session (S-22). Reply frames
|
|
23
|
+
// (submit-prompt-status, approval-resolution-result, model-change-result, parked,
|
|
24
|
+
// killed, …) go to the ORIGINATING hub socket ONLY, matched by the original
|
|
25
|
+
// `clientMessageId` when the frame carries one.
|
|
26
|
+
// RECONNECT full-jitter backoff 500 ms -> 30 s cap, reset on success; on every (re)connect the
|
|
27
|
+
// link re-attaches every viewed session and issues `keyframe-req`, so the box's own
|
|
28
|
+
// seq/keyframe authority is what re-syncs content (no duplicate/no gap by design).
|
|
29
|
+
// PINGS two consecutive missed WS pings force a reconnect (the link owns its own liveness —
|
|
30
|
+
// the peer-control allowlist deliberately has no CTM `ping` message).
|
|
31
|
+
//
|
|
32
|
+
// BACKPRESSURE (§10 risk 2), the choice this module makes: the box sees exactly ONE attached
|
|
33
|
+
// client — the link — so its credit/window is the LINK's, not any single hub browser's. Fan-out to
|
|
34
|
+
// hub sockets is a local, unconditional, non-blocking send per socket, so a slow/stalled hub viewer
|
|
35
|
+
// can never stall the frames the OTHER viewers of the same session receive; the hub socket acks
|
|
36
|
+
// (`ack`/`nack`/`frame-receipt`) are forwarded upstream VERBATIM, per session, which is the credit
|
|
37
|
+
// signal the box already understands (lib/ws/state-sync.js keys acks by session). Nothing is
|
|
38
|
+
// re-minted, no local credit is invented.
|
|
39
|
+
//
|
|
40
|
+
// No durable state (lead ruling 4): links and viewer refcounts are in-memory and rebuilt lazily
|
|
41
|
+
// from viewed sessions after a hub restart. Everything is injectable for tests.
|
|
42
|
+
|
|
43
|
+
const { registerWsPreDispatchHook } = require('./ws-router');
|
|
44
|
+
const remoteMachineCreate = require('./remote-machine-create');
|
|
45
|
+
|
|
46
|
+
const BACKOFF_BASE_MS = 500;
|
|
47
|
+
const BACKOFF_CAP_MS = 30000;
|
|
48
|
+
const IDLE_CLOSE_MS = 60000;
|
|
49
|
+
const PING_MISS_LIMIT = 2;
|
|
50
|
+
const HEARTBEAT_MS = 15000;
|
|
51
|
+
|
|
52
|
+
// Message types a hub browser sends that name a session and therefore may be relayed upstream.
|
|
53
|
+
// Deliberately a closed set: a type nobody classified is NOT relayed (it falls through to the
|
|
54
|
+
// local handler, which is the pre-WP-3 behaviour for a session it does not know).
|
|
55
|
+
const RELAY_TYPES = Object.freeze(new Set([
|
|
56
|
+
// viewing / resync (S-22)
|
|
57
|
+
// WP-3b: `terminal-bootstrap-ack` is the matched pair of `terminal-bootstrap` (the viewer's
|
|
58
|
+
// receipt of the cold bootstrap) — both cross the hop, so both are relayed.
|
|
59
|
+
'attach', 'view', 'keyframe-req', 'snapshot', 'terminal-bootstrap', 'terminal-bootstrap-ack', 'wake-resync',
|
|
60
|
+
// typing + geometry
|
|
61
|
+
'input', 'resize', 'reflow', 'reconcile', 'flow', 'detach', 'interrupt-session',
|
|
62
|
+
// prompts + approvals + model (S-23/S-24). `image-deliver-now` is WP-3 of
|
|
63
|
+
// docs/remote-session-image-upload-forwarding-2026-10-24.html §6: the viewer asks the BOX that
|
|
64
|
+
// owns the session to deliver the already-uploaded draft, so the frame must cross the hop.
|
|
65
|
+
// WP-1 (docs/remote-resume-choice-click-2026-10-25.html §5 D-1, §9 S-1): `resume-prompt-select`
|
|
66
|
+
// is the resume strip's choice chip. A mirror id must reach the BOX that owns the session —
|
|
67
|
+
// otherwise the hub's LOCAL handler rejects the click
|
|
68
|
+
// (`resume-prompt-rejected {reason:'session_not_found',channel:'choice'}`).
|
|
69
|
+
'submit-prompt', 'approval-resolve', 'model-change', 'image-deliver-now',
|
|
70
|
+
'resume-prompt-select',
|
|
71
|
+
// lifecycle (WP-6 routes the hub UI here; the relay just carries them)
|
|
72
|
+
'park', 'wake', 'kill',
|
|
73
|
+
// delivery credit (lib/ws/state-sync.js keys these by session)
|
|
74
|
+
'ack', 'nack', 'frame-receipt',
|
|
75
|
+
]));
|
|
76
|
+
|
|
77
|
+
// The subset that means "this socket is viewing the session" — the first viewer of a session
|
|
78
|
+
// triggers the upstream attach + keyframe-req.
|
|
79
|
+
const VIEW_TYPES = Object.freeze(new Set([
|
|
80
|
+
'attach', 'view', 'keyframe-req', 'snapshot', 'terminal-bootstrap', 'wake-resync',
|
|
81
|
+
]));
|
|
82
|
+
|
|
83
|
+
// Downstream types that are a REPLY to one specific request, so they are delivered to the
|
|
84
|
+
// originating hub socket only (never fanned out to co-viewers).
|
|
85
|
+
//
|
|
86
|
+
// WP-6 fix: this set lists the frame types the BOX ACTUALLY SENDS back for a relayed request. The
|
|
87
|
+
// pre-WP-6 set named `parked`, `killed` and `wake-result`, which do not exist anywhere in the box's
|
|
88
|
+
// code — so a box's park/wake reply was fanned out to every co-viewer of the session instead of
|
|
89
|
+
// being routed to the socket that asked. The real answers are:
|
|
90
|
+
// park / pin / wake -> lib/ws/lifecycle.js `session-lifecycle-result` (+ `action`)
|
|
91
|
+
// interrupt-session -> lib/ws/input.js `session-interrupt-result`
|
|
92
|
+
// resume-prompt-select -> lib/ws/input.js `resume-prompt-result`
|
|
93
|
+
// kill -> a BROADCAST `exit` frame (server.js handleKill), which is NOT a
|
|
94
|
+
// reply: every viewer must see it, so it is deliberately absent here
|
|
95
|
+
// and fans out.
|
|
96
|
+
// tests/remote-machine-link.test.js pins this set against the `type:` literals the box's own
|
|
97
|
+
// handlers emit, so a renamed reply type fails the test instead of silently fanning out.
|
|
98
|
+
const REPLY_TYPES = Object.freeze(new Set([
|
|
99
|
+
'submit-prompt-status',
|
|
100
|
+
'approval-resolution-result',
|
|
101
|
+
'model-change-result',
|
|
102
|
+
'session-lifecycle-result',
|
|
103
|
+
'session-interrupt-result',
|
|
104
|
+
'resume-prompt-result',
|
|
105
|
+
'request-error',
|
|
106
|
+
'error',
|
|
107
|
+
]));
|
|
108
|
+
|
|
109
|
+
// Requests whose reply (or auth-error verdict) is routed back to the originating socket. `input`
|
|
110
|
+
// is deliberately absent: a keystroke has no reply, and remembering every one would evict the
|
|
111
|
+
// pending prompt/approval entries from the bounded map below.
|
|
112
|
+
// WP-1 (docs/remote-resume-choice-click-2026-10-25.html §5 D-3, §9 S-2): `resume-prompt-select`
|
|
113
|
+
// is remembered so its `resume-prompt-result` (which echoes the browser's `requestId`) can be
|
|
114
|
+
// routed to the socket that asked instead of being fanned out to every co-viewer.
|
|
115
|
+
const REMEMBER_TYPES = Object.freeze(new Set([
|
|
116
|
+
'submit-prompt', 'approval-resolve', 'model-change', 'park', 'wake', 'kill', 'interrupt-session',
|
|
117
|
+
'resume-prompt-select',
|
|
118
|
+
]));
|
|
119
|
+
const PENDING_TTL_MS = 60000;
|
|
120
|
+
const PENDING_MAX = 512;
|
|
121
|
+
// Bounded queue of frames sent to a link that is still dialing (see forward()):
|
|
122
|
+
const PENDING_SEND_MAX = 64;
|
|
123
|
+
|
|
124
|
+
// WP-6: the request -> reply-type pairs the box's handlers really implement (see REPLY_TYPES).
|
|
125
|
+
const REPLY_FOR_REQUEST = Object.freeze({
|
|
126
|
+
'submit-prompt': 'submit-prompt-status',
|
|
127
|
+
'approval-resolve': 'approval-resolution-result',
|
|
128
|
+
'model-change': 'model-change-result',
|
|
129
|
+
'park': 'session-lifecycle-result',
|
|
130
|
+
'wake': 'session-lifecycle-result',
|
|
131
|
+
'interrupt-session': 'session-interrupt-result',
|
|
132
|
+
'resume-prompt-select': 'resume-prompt-result',
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// WP-6: how long a catalog `list` request waits for the box's `sessions` reply before giving up.
|
|
136
|
+
const CATALOG_TIMEOUT_MS = 5000;
|
|
137
|
+
|
|
138
|
+
let _injected = null;
|
|
139
|
+
let _uninstallIntercept = null;
|
|
140
|
+
let _productionCache = null;
|
|
141
|
+
|
|
142
|
+
/** Test/host seam: override any production dependency (registry, WebSocket, timers, clock, log). */
|
|
143
|
+
function configure(deps) {
|
|
144
|
+
_injected = deps && typeof deps === 'object' ? deps : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function _string(value, max = 200) {
|
|
148
|
+
return String(value == null ? '' : value).trim().slice(0, max);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------------------------
|
|
152
|
+
// Dependencies (lazy: a pure unit test must never load db.js / machine-registry)
|
|
153
|
+
// ---------------------------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
function _defaultMachineRow(machineId, deps) {
|
|
156
|
+
const registry = require('./machine-registry');
|
|
157
|
+
const db = require('../db-owner-client');
|
|
158
|
+
const listed = registry.listMachines({ db }) || {};
|
|
159
|
+
const machines = Array.isArray(listed.machines) ? listed.machines : [];
|
|
160
|
+
return machines.find((row) => _string(row.instanceId, 160) === _string(machineId, 160)) || null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function _productionDeps() {
|
|
164
|
+
return {
|
|
165
|
+
env: process.env,
|
|
166
|
+
now: () => Date.now(),
|
|
167
|
+
random: Math.random,
|
|
168
|
+
setTimeoutFn: global.setTimeout,
|
|
169
|
+
clearTimeoutFn: global.clearTimeout,
|
|
170
|
+
setIntervalFn: global.setInterval,
|
|
171
|
+
clearIntervalFn: global.clearInterval,
|
|
172
|
+
// The machine ROW (url/enabled/health) for a registered machine — resolved through the same
|
|
173
|
+
// registry read the routes and WP-2's forward use.
|
|
174
|
+
machineRow: _defaultMachineRow,
|
|
175
|
+
// Which machine owns a ctm session id (lib/remote-machine-create.js's mirror registry, which
|
|
176
|
+
// WP-6 widened to the union over the live catalog and the at-rest sync mirror rows).
|
|
177
|
+
mirrorFor: (sessionId) => remoteMachineCreate.getMirrorSessionMachine(sessionId),
|
|
178
|
+
// WP-6 catalog lane. The hooks are lazy `require`s and the catalog never requires THIS module
|
|
179
|
+
// back (it takes the link as a peer wired by server.js), so the lib/** graph stays acyclic.
|
|
180
|
+
onLinkReady: (link) => { try { require('./remote-machine-catalog').onLinkReady(link); } catch { /* expected: a catalog fault must never break the link */ } },
|
|
181
|
+
onSessionList: (link, rows, frame) => { try { require('./remote-machine-catalog').recordSessionList(link.machineInstanceId, rows); } catch { /* expected: ditto — the box's list is a freshness bonus, not a link obligation */ } },
|
|
182
|
+
onRelayedKill: (link) => { try { require('./remote-machine-catalog').fetchCatalog(link.machineInstanceId); } catch { /* expected: ditto — the kill already crossed; the refresh is best effort */ } },
|
|
183
|
+
catalogTimeoutMs: CATALOG_TIMEOUT_MS,
|
|
184
|
+
log: console,
|
|
185
|
+
idleCloseMs: IDLE_CLOSE_MS,
|
|
186
|
+
heartbeatMs: HEARTBEAT_MS,
|
|
187
|
+
backoffBaseMs: BACKOFF_BASE_MS,
|
|
188
|
+
backoffCapMs: BACKOFF_CAP_MS,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function _d(overrides) {
|
|
193
|
+
// The production dependency object is PURE (constants + closures, no per-call state), and it is
|
|
194
|
+
// built on the hot path: `interceptRelay` runs for every relayable frame of every LOCAL session
|
|
195
|
+
// too (a keystroke, a resize, a frame-receipt), so re-assembling 14 properties per message would
|
|
196
|
+
// tax exactly the typing lane this module must not slow down. Memoized once; a caller that passes
|
|
197
|
+
// its own deps still gets a private merged object.
|
|
198
|
+
if (!_productionCache) _productionCache = _productionDeps();
|
|
199
|
+
if (!_injected && !overrides) return _productionCache;
|
|
200
|
+
return Object.assign({}, _productionCache, _injected || {}, overrides || {});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---------------------------------------------------------------------------------------------
|
|
204
|
+
// Small helpers
|
|
205
|
+
// ---------------------------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The session id a frame names. `view` is the one set-declaring type (D-1), so it yields every id
|
|
209
|
+
* it declares; every other type yields its single `id` (or `sessionId`, the spelling a few frames
|
|
210
|
+
* use).
|
|
211
|
+
*/
|
|
212
|
+
function sessionIdsOf(msg) {
|
|
213
|
+
if (!msg || typeof msg !== 'object') return [];
|
|
214
|
+
if (msg.type === 'view') {
|
|
215
|
+
const ids = Array.isArray(msg.ids) ? msg.ids : [];
|
|
216
|
+
const out = [];
|
|
217
|
+
for (const raw of ids.slice(0, 64)) {
|
|
218
|
+
const id = _string(raw, 200);
|
|
219
|
+
if (id && !out.includes(id)) out.push(id);
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
const id = _string(msg.id || msg.sessionId, 200);
|
|
224
|
+
return id ? [id] : [];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function _frameSessionId(frame) {
|
|
228
|
+
if (!frame || typeof frame !== 'object') return '';
|
|
229
|
+
return _string(frame.id || frame.sessionId, 200);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Deliver an already-serialized frame to one hub browser socket (fan-out or reply routing). */
|
|
233
|
+
function _sendToCtx(ctx, payload) {
|
|
234
|
+
try {
|
|
235
|
+
if (ctx && ctx.ws && ctx.ws.readyState === 1 && typeof ctx.send === 'function') ctx.send(payload);
|
|
236
|
+
} catch {
|
|
237
|
+
// expected: the hub browser socket died while a relayed frame was in flight — the box owns the
|
|
238
|
+
// session and WP-5's UI re-subscribes on the next connect.
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function _sendFrame(ctx, frame) {
|
|
243
|
+
_sendToCtx(ctx, JSON.stringify(frame));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ---------------------------------------------------------------------------------------------
|
|
247
|
+
// Links
|
|
248
|
+
// ---------------------------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
const _links = new Map(); // machineInstanceId -> link
|
|
251
|
+
|
|
252
|
+
function _newLink(machineId, machineName, url) {
|
|
253
|
+
return {
|
|
254
|
+
machineInstanceId: machineId,
|
|
255
|
+
machineName,
|
|
256
|
+
url,
|
|
257
|
+
socket: null,
|
|
258
|
+
ready: false,
|
|
259
|
+
closed: false,
|
|
260
|
+
dialing: false,
|
|
261
|
+
// WP-1 (D-3): 'idle' | 'credential_missing'. The latter is terminal — the link stays usable
|
|
262
|
+
// in-memory (no socket, no timers) and only an explicit openLink on a hub whose env now
|
|
263
|
+
// carries the token dials again.
|
|
264
|
+
state: 'idle',
|
|
265
|
+
attempts: 0,
|
|
266
|
+
generation: 0,
|
|
267
|
+
pingMisses: 0,
|
|
268
|
+
heartbeatTimer: null,
|
|
269
|
+
reconnectTimer: null,
|
|
270
|
+
idleTimer: null,
|
|
271
|
+
viewers: new Map(), // sessionId -> Set<ctx>
|
|
272
|
+
pending: new Map(), // key -> {ctx, replyType, at}
|
|
273
|
+
pendingSeq: 0,
|
|
274
|
+
pendingSends: [], // frames sent while the link was still dialing (flushed on hello)
|
|
275
|
+
// WP-6 catalog lane: the box's `sessions` reply to a `list` request. `catalogUsers` is the
|
|
276
|
+
// refcount of catalog-initiated users — it holds the 60 s idle close off while a request is in
|
|
277
|
+
// flight and is released the moment the reply lands, so a catalog fetch never keeps a machine
|
|
278
|
+
// link open.
|
|
279
|
+
catalogWaiters: new Map(), // seq -> finish(result)
|
|
280
|
+
catalogSeq: 0,
|
|
281
|
+
catalogUsers: 0,
|
|
282
|
+
lastSessions: null, // {rows, frame, at} — the most recent list the box sent this link
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function linkFor(machineId) {
|
|
287
|
+
return _links.get(_string(machineId, 160)) || null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function listLinks() {
|
|
291
|
+
return [..._links.values()];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Open (or reuse) the one link to a machine. `machine` is a registry row or a machine instance id.
|
|
296
|
+
* Never throws: a machine that is not registered / disabled / url-less yields null and the caller
|
|
297
|
+
* degrades to the existing "no frames" UX.
|
|
298
|
+
*/
|
|
299
|
+
function openLink(machine, deps) {
|
|
300
|
+
const d = _d(deps);
|
|
301
|
+
const row = typeof machine === 'string' ? d.machineRow(_string(machine, 160), d) : machine;
|
|
302
|
+
if (!row) return null;
|
|
303
|
+
const machineId = _string(row.instanceId, 160);
|
|
304
|
+
if (!machineId) return null;
|
|
305
|
+
const existing = _links.get(machineId);
|
|
306
|
+
if (existing && !existing.closed) {
|
|
307
|
+
// D-3: a link parked on a missing credential is retried by the NEXT acquire — that is the
|
|
308
|
+
// "hub restarted with CTM_SYNC_PEER_TOKEN set" path, and it is the only thing that can clear
|
|
309
|
+
// the state, because nothing here polls. The state is deliberately NOT reset first: `_dial`
|
|
310
|
+
// reads `credential_missing` as "already reported", so a hub that still has no credential
|
|
311
|
+
// re-dials on each acquire without logging the same line again (and a successful dial clears it).
|
|
312
|
+
if (existing.state === 'credential_missing') _dial(existing, d);
|
|
313
|
+
return existing;
|
|
314
|
+
}
|
|
315
|
+
if (!row.enabled) return null;
|
|
316
|
+
const url = _string(row.url, 1000);
|
|
317
|
+
if (!url) return null;
|
|
318
|
+
if (_string(row.health, 40) === 'unreachable') return null;
|
|
319
|
+
const link = _newLink(machineId, _string(row.displayName, 200), url);
|
|
320
|
+
_links.set(machineId, link);
|
|
321
|
+
_dial(link, d);
|
|
322
|
+
return link;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function _dial(link, d) {
|
|
326
|
+
if (link.closed || link.dialing || link.ready) return;
|
|
327
|
+
link.dialing = true;
|
|
328
|
+
const dial = remoteMachineCreate.preparePeerDial(link.url, { ...d, machineName: link.machineName });
|
|
329
|
+
if (!dial.ok) {
|
|
330
|
+
link.dialing = false;
|
|
331
|
+
// WP-1 (D-3): a missing peer credential is a CONFIGURATION error, not a transient one. The
|
|
332
|
+
// token is read from process.env on every call and env changes only on restart, so a retry
|
|
333
|
+
// would fail identically forever — one report, then a terminal state, no reconnect timer.
|
|
334
|
+
// `openLink` re-dials from that state, which is what a hub restarted WITH the credential needs.
|
|
335
|
+
if (dial.code === remoteMachineCreate.PEER_CREDENTIAL_MISSING) {
|
|
336
|
+
if (link.state !== 'credential_missing') {
|
|
337
|
+
link.state = 'credential_missing';
|
|
338
|
+
_report(d, `link ${link.machineInstanceId}: ${dial.error}`);
|
|
339
|
+
}
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
_report(d, `link ${link.machineInstanceId}: ${dial.error}`);
|
|
343
|
+
_scheduleReconnect(link, d);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
link.state = 'idle';
|
|
347
|
+
const generation = link.generation + 1;
|
|
348
|
+
link.generation = generation;
|
|
349
|
+
let socket = null;
|
|
350
|
+
try {
|
|
351
|
+
socket = new dial.WebSocketImpl(dial.url, { headers: dial.headers });
|
|
352
|
+
} catch (error) {
|
|
353
|
+
link.dialing = false;
|
|
354
|
+
_report(d, `link ${link.machineInstanceId}: connect failed: ${error?.message || error}`);
|
|
355
|
+
_scheduleReconnect(link, d);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
link.socket = socket;
|
|
359
|
+
const isCurrent = () => link.generation === generation && !link.closed;
|
|
360
|
+
socket.on('message', (raw) => { if (isCurrent()) _onUpstreamFrame(link, raw, d); });
|
|
361
|
+
socket.on('pong', () => { if (isCurrent()) link.pingMisses = 0; });
|
|
362
|
+
socket.on('error', (error) => { if (isCurrent()) _report(d, `link ${link.machineInstanceId}: socket error: ${error?.message || error}`); });
|
|
363
|
+
socket.on('close', () => { if (isCurrent()) _onSocketGone(link, d); });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function _onSocketGone(link, d) {
|
|
367
|
+
link.socket = null;
|
|
368
|
+
link.ready = false;
|
|
369
|
+
link.dialing = false;
|
|
370
|
+
_stopHeartbeat(link, d);
|
|
371
|
+
if (link.closed) return;
|
|
372
|
+
_scheduleReconnect(link, d);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Full-jitter backoff: `random() * min(cap, base * 2^attempt)`. */
|
|
376
|
+
function backoffDelayMs(attempt, d) {
|
|
377
|
+
const base = Number(d?.backoffBaseMs || BACKOFF_BASE_MS) || BACKOFF_BASE_MS;
|
|
378
|
+
const cap = Number(d?.backoffCapMs || BACKOFF_CAP_MS) || BACKOFF_CAP_MS;
|
|
379
|
+
const ceiling = Math.min(cap, base * (2 ** Math.max(0, attempt)));
|
|
380
|
+
const random = typeof d?.random === 'function' ? d.random : Math.random;
|
|
381
|
+
return Math.max(0, Math.floor(random() * ceiling));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function _scheduleReconnect(link, d) {
|
|
385
|
+
if (link.closed || link.reconnectTimer) return;
|
|
386
|
+
const delay = backoffDelayMs(link.attempts, d);
|
|
387
|
+
link.attempts += 1;
|
|
388
|
+
const timer = d.setTimeoutFn(() => {
|
|
389
|
+
link.reconnectTimer = null;
|
|
390
|
+
if (!link.closed) _dial(link, d);
|
|
391
|
+
}, delay);
|
|
392
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
393
|
+
link.reconnectTimer = timer;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function _startHeartbeat(link, d) {
|
|
397
|
+
_stopHeartbeat(link, d);
|
|
398
|
+
const every = Number(d.heartbeatMs || HEARTBEAT_MS) || HEARTBEAT_MS;
|
|
399
|
+
const timer = d.setIntervalFn(() => heartbeatTick(link, d), every);
|
|
400
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
401
|
+
link.heartbeatTimer = timer;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function _stopHeartbeat(link, d) {
|
|
405
|
+
if (link.heartbeatTimer) {
|
|
406
|
+
try { d.clearIntervalFn(link.heartbeatTimer); } catch { /* expected: the injected interval is already cleared */ }
|
|
407
|
+
link.heartbeatTimer = null;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* One liveness tick. Exposed (and driven by the injected interval) so a test can prove the
|
|
413
|
+
* "two missed pings -> reconnect" rule with mock timers and no real sleep.
|
|
414
|
+
*/
|
|
415
|
+
function heartbeatTick(link, deps) {
|
|
416
|
+
const d = _d(deps);
|
|
417
|
+
if (link.closed || !link.ready || !link.socket) return;
|
|
418
|
+
if (link.pingMisses >= PING_MISS_LIMIT) {
|
|
419
|
+
_report(d, `link ${link.machineInstanceId}: ${link.pingMisses} missed pings — reconnecting`);
|
|
420
|
+
_forceReconnect(link, d);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
link.pingMisses += 1;
|
|
424
|
+
try { link.socket.ping?.(); } catch { /* expected: the socket died between the ready check and the ping */ }
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function _forceReconnect(link, d) {
|
|
428
|
+
const socket = link.socket;
|
|
429
|
+
// Bump the generation FIRST: the close/error handlers of the socket we are abandoning must not
|
|
430
|
+
// schedule a second reconnect for the same dead connection.
|
|
431
|
+
link.generation += 1;
|
|
432
|
+
link.socket = null;
|
|
433
|
+
link.ready = false;
|
|
434
|
+
link.dialing = false;
|
|
435
|
+
_stopHeartbeat(link, d);
|
|
436
|
+
try { socket?.terminate?.(); } catch { /* expected: the abandoned socket is already gone */ }
|
|
437
|
+
if (!link.closed) _scheduleReconnect(link, d);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ---------------------------------------------------------------------------------------------
|
|
441
|
+
// Upstream (box -> hub)
|
|
442
|
+
// ---------------------------------------------------------------------------------------------
|
|
443
|
+
|
|
444
|
+
function _onUpstreamFrame(link, raw, d) {
|
|
445
|
+
let frame = null;
|
|
446
|
+
try {
|
|
447
|
+
frame = JSON.parse(raw.toString());
|
|
448
|
+
} catch {
|
|
449
|
+
return; // expected: a binary/non-JSON frame on this lane is not a relayable protocol frame
|
|
450
|
+
}
|
|
451
|
+
if (!frame || typeof frame !== 'object') return;
|
|
452
|
+
|
|
453
|
+
if (frame.type === 'hello') {
|
|
454
|
+
_onHello(link, frame, d);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (frame.type === 'sessions') {
|
|
458
|
+
// WP-6: the box's session list — the reply to our catalog `list` request, and also a frame the
|
|
459
|
+
// box BROADCASTS on every session-list change (server.js handleList / the list broadcast). It
|
|
460
|
+
// names no single session, so it is recorded for the catalog and settled to any waiter; it is
|
|
461
|
+
// never fanned out to a hub browser (the hub builds its own list).
|
|
462
|
+
_deliverSessionList(link, frame, d);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (frame.type === 'terminal-protocol-incompatible') {
|
|
466
|
+
_report(d, `link ${link.machineInstanceId}: box refused the protocol: ${_string(frame.reason, 80)}`);
|
|
467
|
+
_forceReconnect(link, d);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
if (frame.type === 'auth-error') {
|
|
471
|
+
// The box answers a peer's client-info with `auth-error insufficient_scope` (ws build
|
|
472
|
+
// compatibility runs before the peer-control rule table) — routine noise on this lane,
|
|
473
|
+
// tolerated exactly as WP-2's dial tolerates it. A verdict that NAMES a relayed message type
|
|
474
|
+
// is the box refusing THAT frame, so it is reported to the socket that sent it instead of
|
|
475
|
+
// being swallowed (a silent refusal is indistinguishable from a broken relay).
|
|
476
|
+
_routeAuthError(link, frame);
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const sessionId = _frameSessionId(frame);
|
|
481
|
+
if (!sessionId) return;
|
|
482
|
+
if (_routeReplyToOrigin(link, frame, sessionId, d)) return;
|
|
483
|
+
_fanout(link, frame, sessionId, d);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* The box's own handshake. Both the client-info AND the re-attach of every viewed session must
|
|
488
|
+
* follow it, in that order: the box's protocol gate drops a terminal-protocol frame that arrives
|
|
489
|
+
* before a compatible client-info (see lib/remote-machine-create.js's dial-order note).
|
|
490
|
+
*/
|
|
491
|
+
function _onHello(link, frame, d) {
|
|
492
|
+
const alreadyReady = link.ready;
|
|
493
|
+
link.ready = true;
|
|
494
|
+
link.dialing = false;
|
|
495
|
+
link.attempts = 0; // backoff resets ON SUCCESS (full-jitter contract)
|
|
496
|
+
_sendUpstream(link, remoteMachineCreate.peerClientInfoFrame(frame), d);
|
|
497
|
+
// Re-attach AFTER the client-info above: the box's protocol gate drops a terminal-protocol frame
|
|
498
|
+
// that arrives before a compatible client-info (the dial-order rule the create lane learned).
|
|
499
|
+
link.pingMisses = 0;
|
|
500
|
+
_startHeartbeat(link, d);
|
|
501
|
+
for (const sessionId of link.viewers.keys()) _attachUpstream(link, sessionId, d, alreadyReady ? 'link-reconnect' : 'link-ready');
|
|
502
|
+
// WP-6: a catalog `list` requested while the link was still dialing is flushed HERE, after the
|
|
503
|
+
// client-info above — the box's protocol gate drops a terminal-protocol frame that arrives first.
|
|
504
|
+
for (const queued of link.pendingSends.splice(0)) _sendUpstream(link, queued, d);
|
|
505
|
+
// A catalog request that was queued while this link was dialing sends its `list` HERE, so this
|
|
506
|
+
// hello has already asked the box for its list — skip the refresh hook below rather than paying a
|
|
507
|
+
// second identical round-trip on the same connection.
|
|
508
|
+
const catalogAsked = link.catalogWaiters.size > 0;
|
|
509
|
+
for (const finish of [...link.catalogWaiters.values()]) _sendCatalogList(link, finish, d);
|
|
510
|
+
// WP-6 §8: "on link (re)connect" is one of the catalog's refresh triggers. Injected (not
|
|
511
|
+
// required) so this module never has to depend on the catalog module it feeds.
|
|
512
|
+
if (!catalogAsked && typeof d.onLinkReady === 'function') {
|
|
513
|
+
try { d.onLinkReady(link); } catch { /* expected: a catalog hook fault must not break the link */ }
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function _sendCatalogList(link, finish, d) {
|
|
518
|
+
if (!_sendUpstream(link, { type: 'list' }, d)) {
|
|
519
|
+
finish({ ok: false, code: 'machine_unreachable', error: 'catalog_send_failed' });
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function _deliverSessionList(link, frame, d) {
|
|
524
|
+
const rows = Array.isArray(frame.sessions) ? frame.sessions : [];
|
|
525
|
+
link.lastSessions = { rows, frame, at: d.now ? d.now() : Date.now() };
|
|
526
|
+
if (typeof d.onSessionList === 'function') {
|
|
527
|
+
try { d.onSessionList(link, rows, frame); } catch { /* expected: a catalog hook fault must not break the link */ }
|
|
528
|
+
}
|
|
529
|
+
for (const finish of [...link.catalogWaiters.values()]) finish({ ok: true, rows, frame });
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* WP-6 (§6.6/AC-6a): ask the box for its session list over this link. One link user for the
|
|
534
|
+
* duration of the request, released on the reply — so the 60 s idle close still applies to a
|
|
535
|
+
* catalog-only link and the hub never accumulates a permanently open connection per machine.
|
|
536
|
+
*
|
|
537
|
+
* @returns {Promise<{ok: true, rows: Array, frame: object}|{ok: false, code: string, error: string}>}
|
|
538
|
+
*/
|
|
539
|
+
function requestSessionList(link, deps = {}) {
|
|
540
|
+
return new Promise((resolve) => {
|
|
541
|
+
const d = _d(deps);
|
|
542
|
+
if (!link || link.closed) return resolve({ ok: false, code: 'machine_unreachable', error: 'link_closed' });
|
|
543
|
+
const timeoutMs = Number(d.catalogTimeoutMs || CATALOG_TIMEOUT_MS) || CATALOG_TIMEOUT_MS;
|
|
544
|
+
const seq = ++link.catalogSeq;
|
|
545
|
+
let done = false;
|
|
546
|
+
let timer = null;
|
|
547
|
+
const finish = (result) => {
|
|
548
|
+
if (done) return;
|
|
549
|
+
done = true;
|
|
550
|
+
if (timer) { try { d.clearTimeoutFn(timer); } catch { /* expected: the injected timer already fired */ } }
|
|
551
|
+
link.catalogWaiters.delete(seq);
|
|
552
|
+
_releaseCatalogUser(link, d);
|
|
553
|
+
resolve(result);
|
|
554
|
+
};
|
|
555
|
+
link.catalogWaiters.set(seq, finish);
|
|
556
|
+
_holdCatalogUser(link, d);
|
|
557
|
+
timer = d.setTimeoutFn(() => finish({ ok: false, code: 'machine_unreachable', error: 'catalog_timeout' }), timeoutMs);
|
|
558
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
559
|
+
if (link.ready) _sendCatalogList(link, finish, d);
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function _holdCatalogUser(link, d) {
|
|
564
|
+
link.catalogUsers = (Number(link.catalogUsers) || 0) + 1;
|
|
565
|
+
_clearIdleClose(link, d);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function _releaseCatalogUser(link, d) {
|
|
569
|
+
link.catalogUsers = Math.max(0, (Number(link.catalogUsers) || 0) - 1);
|
|
570
|
+
_maybeScheduleIdleClose(link, d);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function _attachUpstream(link, sessionId, d, reason) {
|
|
574
|
+
const sentAttach = _sendUpstream(link, { type: 'attach', id: sessionId }, d);
|
|
575
|
+
const sentKeyframe = _sendUpstream(link, {
|
|
576
|
+
type: 'keyframe-req', id: sessionId, requestToken: `hub-link-${sessionId}`, reason: _string(reason, 48) || 'hub-link',
|
|
577
|
+
}, d);
|
|
578
|
+
return sentAttach && sentKeyframe;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** The ONE raw send site for this module's upstream lane (the peer socket, not a hub browser). */
|
|
582
|
+
function _sendUpstream(link, frame, d) {
|
|
583
|
+
if (!link || !link.socket || !link.ready) return false;
|
|
584
|
+
try {
|
|
585
|
+
link.socket.send(JSON.stringify(frame));
|
|
586
|
+
return true;
|
|
587
|
+
} catch (error) {
|
|
588
|
+
_report(d, `link ${link.machineInstanceId}: send failed: ${error?.message || error}`);
|
|
589
|
+
return false;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function _pendingKey(sessionId, frame) {
|
|
594
|
+
const cm = _string(frame && frame.clientMessageId, 200);
|
|
595
|
+
return cm ? `${sessionId}\u0000cm:${cm}` : '';
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function _rememberPending(link, sessionId, msg, ctx, d) {
|
|
599
|
+
if (!REMEMBER_TYPES.has(msg.type)) return;
|
|
600
|
+
const now = d.now();
|
|
601
|
+
// Bounded by construction: a keystroke stream cannot grow this map without bound.
|
|
602
|
+
for (const [key, entry] of link.pending) {
|
|
603
|
+
if (now - entry.at > PENDING_TTL_MS) link.pending.delete(key);
|
|
604
|
+
}
|
|
605
|
+
while (link.pending.size >= PENDING_MAX) {
|
|
606
|
+
const oldest = link.pending.keys().next();
|
|
607
|
+
if (oldest.done) break;
|
|
608
|
+
link.pending.delete(oldest.value);
|
|
609
|
+
}
|
|
610
|
+
const clientMessageId = _string(msg.clientMessageId, 200);
|
|
611
|
+
// WP-1 (docs/remote-resume-choice-click-2026-10-25.html §5 D-3, §6): the browser mints a
|
|
612
|
+
// `requestId` for a resume choice and the box echoes it back; keeping it on the pending entry is
|
|
613
|
+
// what lets two concurrent viewers' results be told apart (a requestId-less request stores '').
|
|
614
|
+
const requestId = _string(msg.requestId, 200);
|
|
615
|
+
const key = _pendingKey(sessionId, msg) || `${sessionId}\u0000seq:${++link.pendingSeq}`;
|
|
616
|
+
link.pending.set(key, {
|
|
617
|
+
ctx, replyType: REPLY_FOR_REQUEST[msg.type] || '', requestType: msg.type, clientMessageId, requestId, at: now, sessionId,
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function _takePending(link, match) {
|
|
622
|
+
for (const [key, entry] of link.pending) {
|
|
623
|
+
if (match(entry)) {
|
|
624
|
+
link.pending.delete(key);
|
|
625
|
+
return entry;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* The box's `auth-error` verdict on a relayed frame names the message type it refused (the §6.2
|
|
633
|
+
* peer-control allowlist is a closed set, so a relayed type it does not contain is refused here).
|
|
634
|
+
* Deliver it to the originating socket; the routine verdict on our own client-info carries no
|
|
635
|
+
* `messageType` and stays noise.
|
|
636
|
+
*/
|
|
637
|
+
function _routeAuthError(link, frame) {
|
|
638
|
+
const messageType = _string(frame.messageType, 40);
|
|
639
|
+
if (!messageType) return;
|
|
640
|
+
const entry = _takePending(link, (e) => e.requestType === messageType);
|
|
641
|
+
if (entry) _sendFrame(entry.ctx, frame);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Route a reply frame to the ORIGINATING hub socket only (S-22): matched by the ORIGINAL
|
|
646
|
+
* `clientMessageId` first (S-24 — the id the browser minted, forwarded verbatim), then by the
|
|
647
|
+
* request/response type pair.
|
|
648
|
+
*/
|
|
649
|
+
function _routeReplyToOrigin(link, frame, sessionId, d) {
|
|
650
|
+
if (!REPLY_TYPES.has(frame.type)) return false;
|
|
651
|
+
// WP-1 (docs/remote-resume-choice-click-2026-10-25.html §5 D-3, §6, RFC 8887 §4.3.2): a resume
|
|
652
|
+
// choice result is correlated by the `requestId` the browser minted and the box echoed — never by
|
|
653
|
+
// "oldest pending entry of this reply type" — because two viewers of ONE mirror session can have
|
|
654
|
+
// their choices in flight at once and the box may complete them out of order. An identified result
|
|
655
|
+
// that matches no pending request belongs to nobody: it is CONSUMED here (`return true`) so
|
|
656
|
+
// `_onUpstreamFrame` never fans it out to co-viewers who did not ask for it.
|
|
657
|
+
if (frame.type === 'resume-prompt-result') {
|
|
658
|
+
const requestId = _string(frame.requestId, 200);
|
|
659
|
+
if (requestId) {
|
|
660
|
+
const entry = _takePending(link, (e) => e.sessionId === sessionId
|
|
661
|
+
&& e.replyType === frame.type && e.requestId === requestId);
|
|
662
|
+
if (entry) _sendFrame(entry.ctx, frame);
|
|
663
|
+
return true;
|
|
664
|
+
}
|
|
665
|
+
// A result carrying no requestId keeps the legacy clientMessageId/type fallback below.
|
|
666
|
+
}
|
|
667
|
+
const clientMessageId = _string(frame.clientMessageId, 200);
|
|
668
|
+
const entry = (clientMessageId
|
|
669
|
+
? _takePending(link, (e) => e.sessionId === sessionId && e.clientMessageId === clientMessageId)
|
|
670
|
+
: null)
|
|
671
|
+
|| _takePending(link, (e) => e.sessionId === sessionId && e.replyType === frame.type);
|
|
672
|
+
if (!entry) return false;
|
|
673
|
+
_sendFrame(entry.ctx, frame);
|
|
674
|
+
return true;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function _fanout(link, frame, sessionId, d) {
|
|
678
|
+
const viewers = link.viewers.get(sessionId);
|
|
679
|
+
if (!viewers || viewers.size === 0) return;
|
|
680
|
+
const payload = JSON.stringify(frame);
|
|
681
|
+
for (const ctx of viewers) _sendToCtx(ctx, payload);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// ---------------------------------------------------------------------------------------------
|
|
685
|
+
// Downstream (hub browser -> box)
|
|
686
|
+
// ---------------------------------------------------------------------------------------------
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Register `ctx` as a viewer of `sessionId` on this link. The FIRST viewer of a session makes the
|
|
690
|
+
* link attach upstream and ask for a keyframe, so the box pushes that session's frames to the link.
|
|
691
|
+
*
|
|
692
|
+
* @returns {boolean} true when this call was the session's first viewer
|
|
693
|
+
*/
|
|
694
|
+
function viewSession(link, sessionId, ctx, deps) {
|
|
695
|
+
if (!link || link.closed) return false;
|
|
696
|
+
const d = _d(deps);
|
|
697
|
+
const id = _string(sessionId, 200);
|
|
698
|
+
if (!id) return false;
|
|
699
|
+
_clearIdleClose(link, d);
|
|
700
|
+
let viewers = link.viewers.get(id);
|
|
701
|
+
const first = !viewers || viewers.size === 0;
|
|
702
|
+
if (!viewers) {
|
|
703
|
+
viewers = new Set();
|
|
704
|
+
link.viewers.set(id, viewers);
|
|
705
|
+
}
|
|
706
|
+
if (ctx) {
|
|
707
|
+
viewers.add(ctx);
|
|
708
|
+
// AC-3d: a hub browser tab closing RELEASES its viewers (and only then arms the machine's
|
|
709
|
+
// 60 s idle close) — it never closes the upstream link directly. Hooked here, once per socket,
|
|
710
|
+
// because the relay must not need an edit to server.js's own `ws.on('close')`.
|
|
711
|
+
if (ctx.ws && typeof ctx.ws.once === 'function' && !ctx.ws._remoteRelayCloseHooked) {
|
|
712
|
+
ctx.ws._remoteRelayCloseHooked = true;
|
|
713
|
+
ctx.ws.once('close', () => releaseSocket(ctx, d));
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
if (first && link.ready) _attachUpstream(link, id, d, 'hub-view');
|
|
717
|
+
return first;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function releaseViewer(link, sessionId, ctx, deps) {
|
|
721
|
+
if (!link) return;
|
|
722
|
+
const d = _d(deps);
|
|
723
|
+
const viewers = link.viewers.get(_string(sessionId, 200));
|
|
724
|
+
if (!viewers) return;
|
|
725
|
+
viewers.delete(ctx);
|
|
726
|
+
if (viewers.size === 0) link.viewers.delete(_string(sessionId, 200));
|
|
727
|
+
_maybeScheduleIdleClose(link, d);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/** A hub socket went away: drop it from every session's viewer set on every link. */
|
|
731
|
+
function releaseSocket(ctx, deps) {
|
|
732
|
+
const d = _d(deps);
|
|
733
|
+
for (const link of _links.values()) {
|
|
734
|
+
for (const [sessionId, viewers] of [...link.viewers]) {
|
|
735
|
+
if (viewers.delete(ctx) && viewers.size === 0) link.viewers.delete(sessionId);
|
|
736
|
+
}
|
|
737
|
+
_maybeScheduleIdleClose(link, d);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function _maybeScheduleIdleClose(link, d) {
|
|
742
|
+
for (const viewers of link.viewers.values()) {
|
|
743
|
+
if (viewers.size > 0) return; // a machine link lives as long as ANY of its sessions is viewed
|
|
744
|
+
}
|
|
745
|
+
// WP-6: a catalog request in flight is a link user too (released on the reply), so the link is
|
|
746
|
+
// never closed out from under it — but it is NOT a reason to keep the link open afterwards.
|
|
747
|
+
if ((Number(link.catalogUsers) || 0) > 0) return;
|
|
748
|
+
if (link.idleTimer || link.closed) return;
|
|
749
|
+
const ms = Number(d.idleCloseMs || IDLE_CLOSE_MS) || IDLE_CLOSE_MS;
|
|
750
|
+
const timer = d.setTimeoutFn(() => {
|
|
751
|
+
link.idleTimer = null;
|
|
752
|
+
if (link.closed) return;
|
|
753
|
+
for (const viewers of link.viewers.values()) {
|
|
754
|
+
if (viewers.size > 0) return; // someone came back during the idle window
|
|
755
|
+
}
|
|
756
|
+
if ((Number(link.catalogUsers) || 0) > 0) return; // a catalog request arrived during the window
|
|
757
|
+
closeLink(link, d);
|
|
758
|
+
}, ms);
|
|
759
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
760
|
+
link.idleTimer = timer;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function _clearIdleClose(link, d) {
|
|
764
|
+
if (!link.idleTimer) return;
|
|
765
|
+
try { d.clearTimeoutFn(link.idleTimer); } catch { /* expected: the injected idle timer is already fired/cleared */ }
|
|
766
|
+
link.idleTimer = null;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Carry one hub-browser frame to the box, VERBATIM (S-24: `clientMessageId` and every other id is
|
|
771
|
+
* forwarded as-is, never re-minted). A request that can produce a reply is remembered so the reply
|
|
772
|
+
* can be routed back to the originating socket only.
|
|
773
|
+
*
|
|
774
|
+
* While the link is down this drops the frame (and returns false): hub browsers already show the
|
|
775
|
+
* existing reconnect/stale UX for a session that stops producing frames, and WP-5 owns any UI
|
|
776
|
+
* beyond that. Nothing is queued or replayed — the box is the authority and the reconnect path
|
|
777
|
+
* re-syncs via keyframe.
|
|
778
|
+
*/
|
|
779
|
+
function forward(link, msg, ctx, deps) {
|
|
780
|
+
if (!link || link.closed) return false;
|
|
781
|
+
const d = _d(deps);
|
|
782
|
+
const sessionId = sessionIdsOf(msg)[0] || '';
|
|
783
|
+
_rememberPending(link, sessionId, msg, ctx, d);
|
|
784
|
+
if (!link.ready) {
|
|
785
|
+
// WP-6 (§7, AC-6b): a lifecycle frame can be the FIRST thing a hub browser sends about a
|
|
786
|
+
// session nobody has viewed yet — its mirror lookup opens the link, and the frame arrives while
|
|
787
|
+
// the socket is still dialing. Dropping it would mean "the hub ignored park" for exactly the
|
|
788
|
+
// sync-mirror-only case WP-6 must route. Bounded, and flushed the moment the box says hello.
|
|
789
|
+
if (link.pendingSends.length < PENDING_SEND_MAX) link.pendingSends.push(msg);
|
|
790
|
+
return true;
|
|
791
|
+
}
|
|
792
|
+
const sent = _sendUpstream(link, msg, d);
|
|
793
|
+
// WP-6: a relayed kill changes the box's session list, so the catalog re-reads it right away
|
|
794
|
+
// rather than waiting out the ~15 s probe cadence (the row becomes exited on the box), and the
|
|
795
|
+
// in-memory mirror registration for the killed id is dropped (the catalog keeps routing it).
|
|
796
|
+
if (sent && msg.type === 'kill') {
|
|
797
|
+
remoteMachineCreate.forgetMirrorSession(sessionId);
|
|
798
|
+
if (typeof d.onRelayedKill === 'function') {
|
|
799
|
+
try { d.onRelayedKill(link); } catch { /* expected: a catalog fault never breaks the relay */ }
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
return sent;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// ---------------------------------------------------------------------------------------------
|
|
806
|
+
// The dispatch seam
|
|
807
|
+
// ---------------------------------------------------------------------------------------------
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Pre-dispatch hook (lib/ws-router.js). Returns true only for a frame that names at least one
|
|
811
|
+
* MIRROR session; every other frame returns false immediately — no msg mutation, no allocation on
|
|
812
|
+
* the local path — so a local session's handling is byte-identical to pre-WP-3.
|
|
813
|
+
*/
|
|
814
|
+
function interceptRelay(ctx, msg, deps) {
|
|
815
|
+
if (!msg || typeof msg !== 'object') return false;
|
|
816
|
+
const type = typeof msg.type === 'string' ? msg.type : '';
|
|
817
|
+
if (!RELAY_TYPES.has(type)) return false;
|
|
818
|
+
const d = _d(deps);
|
|
819
|
+
// The overwhelmingly common case is a LOCAL session, which must take the byte-identical existing
|
|
820
|
+
// path: one id string, one mirror-map miss, `false`. `sessionIdsOf` (an array per frame) is only
|
|
821
|
+
// paid for `view`, the one type that declares a set — so a keystroke or an ack on a local session
|
|
822
|
+
// never allocates here.
|
|
823
|
+
const ids = type === 'view' ? sessionIdsOf(msg) : [(_string(msg.id || msg.sessionId, 200))];
|
|
824
|
+
if (ids.length === 0 || !ids[0]) return false;
|
|
825
|
+
|
|
826
|
+
const mirrors = [];
|
|
827
|
+
for (const id of ids) {
|
|
828
|
+
const mirror = d.mirrorFor(id);
|
|
829
|
+
if (mirror) mirrors.push({ id, mirror });
|
|
830
|
+
}
|
|
831
|
+
if (mirrors.length === 0) return false;
|
|
832
|
+
// A `view` frame declaring BOTH local and remote sessions is left to the local handler (it owns
|
|
833
|
+
// the local viewer set); the mirror ids are still registered below.
|
|
834
|
+
const mixedView = type === 'view' && mirrors.length !== ids.length;
|
|
835
|
+
|
|
836
|
+
let handled = true;
|
|
837
|
+
for (const { id, mirror } of mirrors) {
|
|
838
|
+
const link = _ensureLink(mirror, d);
|
|
839
|
+
if (!link) { handled = false; continue; }
|
|
840
|
+
if (VIEW_TYPES.has(type)) {
|
|
841
|
+
const first = viewSession(link, id, ctx, d);
|
|
842
|
+
// `attach`/`view` are synthesized upstream by viewSession; `keyframe-req` and the rest are
|
|
843
|
+
// carried verbatim below (a repeat keyframe-req is exactly what a re-sync asks for).
|
|
844
|
+
if (type === 'attach' || type === 'view') continue;
|
|
845
|
+
if (type === 'keyframe-req' && first) continue; // viewSession just issued the equivalent one
|
|
846
|
+
}
|
|
847
|
+
if (type === 'detach') {
|
|
848
|
+
releaseViewer(link, id, ctx, d);
|
|
849
|
+
continue;
|
|
850
|
+
}
|
|
851
|
+
forward(link, msg, ctx, d);
|
|
852
|
+
}
|
|
853
|
+
return mixedView ? false : handled;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function _ensureLink(mirror, d) {
|
|
857
|
+
const machineId = _string(mirror.machineInstanceId, 160);
|
|
858
|
+
const existing = _links.get(machineId);
|
|
859
|
+
if (existing && !existing.closed) {
|
|
860
|
+
_clearIdleClose(existing, d);
|
|
861
|
+
return existing;
|
|
862
|
+
}
|
|
863
|
+
return openLink(machineId, d);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function installIntercept() {
|
|
867
|
+
if (_uninstallIntercept) return _uninstallIntercept;
|
|
868
|
+
_uninstallIntercept = registerWsPreDispatchHook('remote-machine-link', (ctx, msg) => interceptRelay(ctx, msg));
|
|
869
|
+
return _uninstallIntercept;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function closeLink(link, deps) {
|
|
873
|
+
const d = _d(deps);
|
|
874
|
+
if (!link || link.closed) return;
|
|
875
|
+
link.closed = true;
|
|
876
|
+
if (link.reconnectTimer) {
|
|
877
|
+
try { d.clearTimeoutFn(link.reconnectTimer); } catch { /* expected: the injected timer is already cleared */ }
|
|
878
|
+
link.reconnectTimer = null;
|
|
879
|
+
}
|
|
880
|
+
_clearIdleClose(link, d);
|
|
881
|
+
_stopHeartbeat(link, d);
|
|
882
|
+
const socket = link.socket;
|
|
883
|
+
link.socket = null;
|
|
884
|
+
link.ready = false;
|
|
885
|
+
link.generation += 1;
|
|
886
|
+
try { socket?.close?.(); } catch { /* expected: the upstream socket is already gone */ }
|
|
887
|
+
if (_links.get(link.machineInstanceId) === link) _links.delete(link.machineInstanceId);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function _report(d, message) {
|
|
891
|
+
// REPORT (never throw): a link fault is a degraded remote session, not a hub fault.
|
|
892
|
+
if (d && d.log && typeof d.log.warn === 'function') d.log.warn(`[remote-machine-link] ${message}`);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/** Test seam: links and their timers are module state and must never leak between tests. */
|
|
896
|
+
function _resetForTests() {
|
|
897
|
+
for (const link of [..._links.values()]) {
|
|
898
|
+
link.closed = true;
|
|
899
|
+
// A pending catalog request is settled, not abandoned: an unresolved promise would keep a
|
|
900
|
+
// test's await (and the process) alive.
|
|
901
|
+
for (const finish of [...(link.catalogWaiters || new Map()).values()]) {
|
|
902
|
+
try { finish({ ok: false, code: 'machine_unreachable', error: 'link_closed' }); } catch { /* expected: test teardown */ }
|
|
903
|
+
}
|
|
904
|
+
try { link.reconnectTimer && clearTimeout(link.reconnectTimer); } catch { /* expected: test teardown */ }
|
|
905
|
+
try { link.idleTimer && clearTimeout(link.idleTimer); } catch { /* expected: test teardown */ }
|
|
906
|
+
try { link.heartbeatTimer && clearInterval(link.heartbeatTimer); } catch { /* expected: test teardown */ }
|
|
907
|
+
}
|
|
908
|
+
_links.clear();
|
|
909
|
+
_injected = null;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
module.exports = {
|
|
913
|
+
BACKOFF_BASE_MS,
|
|
914
|
+
BACKOFF_CAP_MS,
|
|
915
|
+
IDLE_CLOSE_MS,
|
|
916
|
+
PING_MISS_LIMIT,
|
|
917
|
+
CATALOG_TIMEOUT_MS,
|
|
918
|
+
RELAY_TYPES,
|
|
919
|
+
VIEW_TYPES,
|
|
920
|
+
REPLY_TYPES,
|
|
921
|
+
REPLY_FOR_REQUEST,
|
|
922
|
+
REMEMBER_TYPES,
|
|
923
|
+
configure,
|
|
924
|
+
installIntercept,
|
|
925
|
+
openLink,
|
|
926
|
+
linkFor,
|
|
927
|
+
listLinks,
|
|
928
|
+
closeLink,
|
|
929
|
+
viewSession,
|
|
930
|
+
releaseViewer,
|
|
931
|
+
releaseSocket,
|
|
932
|
+
forward,
|
|
933
|
+
interceptRelay,
|
|
934
|
+
heartbeatTick,
|
|
935
|
+
backoffDelayMs,
|
|
936
|
+
sessionIdsOf,
|
|
937
|
+
requestSessionList,
|
|
938
|
+
_resetForTests,
|
|
939
|
+
};
|