@volter/editor-core 0.5.57
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/BUNDLED_NOTICES +1527 -0
- package/LICENSE +686 -0
- package/LICENSE-APACHE +202 -0
- package/NOTICE +18 -0
- package/README.md +24 -0
- package/dist/build/chunk-JS4D4XZ3.js +175 -0
- package/dist/build/chunk-JS4D4XZ3.js.map +7 -0
- package/dist/build/vite-plugin-product-contributions.js +100 -0
- package/dist/build/vite-plugin-product-contributions.js.map +7 -0
- package/dist/build/vite-plugin-shared-react.js +25 -0
- package/dist/build/vite-plugin-shared-react.js.map +7 -0
- package/dist/build/vite-plugin-shared-three.js +86 -0
- package/dist/build/vite-plugin-shared-three.js.map +7 -0
- package/dist/server/chunk-KMJC7AGO.js +98 -0
- package/dist/server/chunk-KMJC7AGO.js.map +7 -0
- package/dist/server/chunk-PSILUMGS.js +46 -0
- package/dist/server/chunk-PSILUMGS.js.map +7 -0
- package/dist/server/frame-proxy.js +255 -0
- package/dist/server/frame-proxy.js.map +7 -0
- package/dist/server/open-browser.js +53 -0
- package/dist/server/open-browser.js.map +7 -0
- package/dist/server/process-shutdown.js +127 -0
- package/dist/server/process-shutdown.js.map +7 -0
- package/dist/server/session-registry.js +141 -0
- package/dist/server/session-registry.js.map +7 -0
- package/dist/server/spawn-opener.js +11 -0
- package/dist/server/spawn-opener.js.map +7 -0
- package/dist/server/worktree-identity.js +11 -0
- package/dist/server/worktree-identity.js.map +7 -0
- package/dist-server/packaged.mjs +88816 -0
- package/package.json +118 -0
- package/scripts/build-plugins.mjs +14 -0
- package/scripts/build-server.mjs +26 -0
- package/scripts/build-session.mjs +9 -0
- package/server/account-credentials.ts +60 -0
- package/server/account-service.ts +1434 -0
- package/server/adapter-region-includes.ts +47 -0
- package/server/asset-catalog-v2.ts +175 -0
- package/server/asset-history-snapshots.ts +196 -0
- package/server/asset-ledger-store.ts +195 -0
- package/server/asset-library-routes.ts +1595 -0
- package/server/blender-wasm-artifact.ts +430 -0
- package/server/boot-timings.ts +106 -0
- package/server/canonical-path.ts +50 -0
- package/server/checkout-workspace-preflight.ts +349 -0
- package/server/cloud-asset-catalog.ts +158 -0
- package/server/coding-inference-launch.ts +237 -0
- package/server/collaboration-account-client.ts +202 -0
- package/server/collaboration-attribution.ts +122 -0
- package/server/collaboration-session.ts +934 -0
- package/server/comfyui-bridge.ts +248 -0
- package/server/console-ledger.ts +499 -0
- package/server/creation-site-transform.ts +304 -0
- package/server/creation-site-write.ts +648 -0
- package/server/data-file-serialize.ts +158 -0
- package/server/editor-brand-html.ts +72 -0
- package/server/editor-control-socket.ts +366 -0
- package/server/editor-server-options.ts +190 -0
- package/server/editor-server.ts +1483 -0
- package/server/editor-sse.ts +352 -0
- package/server/engine-provenance.ts +169 -0
- package/server/engine-source-restart.ts +117 -0
- package/server/frame-bridge.ts +115 -0
- package/server/frame-proxy.ts +490 -0
- package/server/frame-workbench.ts +259 -0
- package/server/frontend-handoff.ts +229 -0
- package/server/game-globals-shadow.ts +113 -0
- package/server/gameplay-session-retention.ts +149 -0
- package/server/gameplay-sessions.ts +123 -0
- package/server/generation-jobs.ts +201 -0
- package/server/generation-reconciler.ts +137 -0
- package/server/generative-execution-context.ts +59 -0
- package/server/git-workflow.ts +612 -0
- package/server/harness-chat-caller.ts +36 -0
- package/server/harness-chat-service.ts +1681 -0
- package/server/idle-shutdown.ts +170 -0
- package/server/js-profiling-policy.ts +52 -0
- package/server/launcher-settings.ts +84 -0
- package/server/local-asset-catalog.ts +410 -0
- package/server/managed-account-defaults.ts +22 -0
- package/server/mock-control-plane.ts +540 -0
- package/server/model-import-conversion.ts +345 -0
- package/server/native-credential-store.ts +162 -0
- package/server/open-browser.ts +118 -0
- package/server/packaged.ts +1192 -0
- package/server/play-stall.ts +126 -0
- package/server/process-shutdown.ts +218 -0
- package/server/product-presets.ts +55 -0
- package/server/project-build-artifact.ts +95 -0
- package/server/project-components.ts +100 -0
- package/server/project-dependency-invalidation.ts +114 -0
- package/server/project-file-scan.ts +235 -0
- package/server/project-hmr-files.ts +215 -0
- package/server/project-install-roots.ts +210 -0
- package/server/project-kinds.ts +71 -0
- package/server/project-mcp-servers.ts +82 -0
- package/server/project-module-freshness.ts +109 -0
- package/server/project-module-instance.ts +375 -0
- package/server/project-optimize-deps-entries.ts +827 -0
- package/server/project-output-writer.ts +679 -0
- package/server/project-package-origin.ts +63 -0
- package/server/project-root-surface.ts +831 -0
- package/server/project-scratch-path.ts +47 -0
- package/server/project-script-hmr.ts +619 -0
- package/server/project-serving-plugins.ts +236 -0
- package/server/project-tools.ts +653 -0
- package/server/project-validation.ts +250 -0
- package/server/project-verbs.ts +127 -0
- package/server/project-view.ts +52 -0
- package/server/project-watch.ts +1134 -0
- package/server/project-work-coordinator.ts +288 -0
- package/server/provider-credentials.ts +331 -0
- package/server/recent-projects-store.ts +98 -0
- package/server/redact-secrets.ts +71 -0
- package/server/repository-presence.ts +267 -0
- package/server/routes/account.ts +357 -0
- package/server/routes/agents.ts +60 -0
- package/server/routes/assets.ts +154 -0
- package/server/routes/build.ts +13 -0
- package/server/routes/collaboration.ts +595 -0
- package/server/routes/configurations.ts +419 -0
- package/server/routes/context.ts +264 -0
- package/server/routes/control-plane.ts +1811 -0
- package/server/routes/logs.ts +272 -0
- package/server/routes/project-identity.ts +302 -0
- package/server/routes/project-open.ts +429 -0
- package/server/routes/project-source.ts +686 -0
- package/server/routes/project-state.ts +383 -0
- package/server/routes/relay.ts +1029 -0
- package/server/routes/route-helpers.ts +29 -0
- package/server/routes/served-modules.ts +138 -0
- package/server/routes/session-tabs.ts +196 -0
- package/server/routes/settings.ts +121 -0
- package/server/routes/share-control.ts +486 -0
- package/server/routes/themes.ts +116 -0
- package/server/routes/tools.ts +230 -0
- package/server/routes/worktrees.ts +595 -0
- package/server/scoped-game-css.ts +461 -0
- package/server/server-utils.ts +1194 -0
- package/server/session-product.ts +112 -0
- package/server/session-registry.ts +230 -0
- package/server/share-claims.ts +138 -0
- package/server/share-host.ts +526 -0
- package/server/share-session-gateway.ts +1362 -0
- package/server/share-tunnel.ts +123 -0
- package/server/spawn-opener.ts +74 -0
- package/server/support/play/log-format.ts +76 -0
- package/server/support/play/session-record.ts +199 -0
- package/server/support/project/build-discipline.ts +718 -0
- package/server/support/project/inspection-node.ts +119 -0
- package/server/support/project/log-naming.ts +54 -0
- package/server/support/project/provenance.ts +290 -0
- package/server/support/project/run-name.ts +18 -0
- package/server/support/project/session-journal.ts +830 -0
- package/server/support/project/shared.ts +327 -0
- package/server/tab-bootstrap.ts +62 -0
- package/server/tab-heartbeat.ts +477 -0
- package/server/tab-lifecycle.ts +550 -0
- package/server/tab-presence.ts +1508 -0
- package/server/team-agent-mirror.ts +95 -0
- package/server/twin-auth.ts +138 -0
- package/server/vendored-lock-recorder.ts +968 -0
- package/server/worktree-identity.ts +121 -0
- package/server/worktree-management.ts +398 -0
- package/server/worktree-retention.d.mts +13 -0
- package/server/worktree-retention.mjs +205 -0
- package/src/EditorContext.tsx +169 -0
- package/src/account.ts +333 -0
- package/src/action-registry.ts +532 -0
- package/src/active-product.ts +74 -0
- package/src/active-project.ts +155 -0
- package/src/adapter-editor-config.ts +25 -0
- package/src/adapter-observation.ts +49 -0
- package/src/animation/stage-transport.ts +379 -0
- package/src/animation/three-clips-subject.ts +172 -0
- package/src/api/asset-library-wire.ts +45 -0
- package/src/api/assets.ts +365 -0
- package/src/api/base.ts +10 -0
- package/src/api/build.ts +99 -0
- package/src/api/git-wire.ts +56 -0
- package/src/api/logs.ts +92 -0
- package/src/api/project-identity.ts +74 -0
- package/src/api/project-open.ts +355 -0
- package/src/api/project-source.ts +162 -0
- package/src/api/project-state.ts +107 -0
- package/src/api/relay.ts +268 -0
- package/src/api/settings.ts +36 -0
- package/src/api/themes.ts +45 -0
- package/src/api/worktrees.ts +257 -0
- package/src/asset-compare-core.ts +171 -0
- package/src/asset-compare.ts +294 -0
- package/src/asset-editor-context.tsx +100 -0
- package/src/asset-events.ts +97 -0
- package/src/asset-inspector-actions.ts +87 -0
- package/src/asset-preview-framing.ts +357 -0
- package/src/asset-preview.ts +2802 -0
- package/src/asset-selection-viewer-registry.ts +113 -0
- package/src/asset-selection.ts +133 -0
- package/src/asset-workflow/asset-capabilities.ts +344 -0
- package/src/asset-workflow/asset-import-jobs.ts +106 -0
- package/src/asset-workflow/asset-ledger-backend.ts +126 -0
- package/src/asset-workflow/asset-ledger.ts +156 -0
- package/src/asset-workflow/asset-materialization-report.ts +140 -0
- package/src/asset-workflow/asset-pack-manifest.ts +320 -0
- package/src/asset-workflow/asset-types.ts +142 -0
- package/src/asset-workflow/asset-workflow-quality.ts +61 -0
- package/src/asset-workflow/audio-preview-player.ts +193 -0
- package/src/asset-workflow/audio-waveform.ts +22 -0
- package/src/asset-workflow/cloud-asset-client.ts +263 -0
- package/src/asset-workflow/folder-preview.ts +345 -0
- package/src/asset-workflow/hosted-asset-materialization.ts +236 -0
- package/src/asset-workflow/image-view-scale.ts +32 -0
- package/src/asset-workflow/import-contract.ts +124 -0
- package/src/asset-workflow/ledger-write-lock.ts +244 -0
- package/src/asset-workflow/model-inspection.ts +818 -0
- package/src/asset-workflow/pixi-spritesheet.ts +197 -0
- package/src/asset-workflow/preview-resource-lifetime.ts +44 -0
- package/src/asset-workflow/project-asset-commands.ts +23 -0
- package/src/asset-workflow/project-asset-health.ts +139 -0
- package/src/asset-workflow/project-asset-operations.ts +154 -0
- package/src/asset-workflow/project-asset-roots.ts +68 -0
- package/src/asset-workflow/project-content.ts +288 -0
- package/src/asset-workflow/project-source-index.ts +550 -0
- package/src/asset-workflow/thumbnail-system.ts +256 -0
- package/src/authoring/active-adapter.ts +199 -0
- package/src/authoring/active-systems.ts +422 -0
- package/src/authoring/adapter-key.ts +18 -0
- package/src/authoring/authoring-asset-url.ts +27 -0
- package/src/authoring/bootstrap-state.ts +49 -0
- package/src/authoring/boundary-authoring-adapter.ts +184 -0
- package/src/authoring/breakpoint-state.ts +43 -0
- package/src/authoring/canvas-scene-guides.ts +76 -0
- package/src/authoring/component-instance-root.ts +171 -0
- package/src/authoring/composite-authoring-adapter.ts +2110 -0
- package/src/authoring/consumer-actions.ts +520 -0
- package/src/authoring/css-numeric-style.ts +97 -0
- package/src/authoring/design-time-layers.ts +849 -0
- package/src/authoring/design-time-mount-registry.ts +235 -0
- package/src/authoring/design-time-settle.ts +343 -0
- package/src/authoring/edit-mode-authoring.ts +619 -0
- package/src/authoring/eyedropper-session.ts +60 -0
- package/src/authoring/instance-source-menu-register.ts +13 -0
- package/src/authoring/instance-source-menu.ts +135 -0
- package/src/authoring/layered-pick.ts +183 -0
- package/src/authoring/live-gesture-lock.ts +50 -0
- package/src/authoring/live-object-transform.ts +62 -0
- package/src/authoring/mount-failure-report.ts +154 -0
- package/src/authoring/mounted-root-subjects.ts +144 -0
- package/src/authoring/no-authoring-adapter.ts +55 -0
- package/src/authoring/null-inspection-subjects.tsx +75 -0
- package/src/authoring/object3d-document-persistence.ts +106 -0
- package/src/authoring/object3d-document-session-registry.ts +120 -0
- package/src/authoring/object3d-document-session.ts +1326 -0
- package/src/authoring/object3d-gesture-controller.ts +113 -0
- package/src/authoring/panel-authoring.ts +121 -0
- package/src/authoring/prefab-instance-inspector-section.tsx +237 -0
- package/src/authoring/prefab-instance-section-model.ts +55 -0
- package/src/authoring/project-authoring-session.ts +105 -0
- package/src/authoring/provenance.ts +99 -0
- package/src/authoring/quarks-particle-systems.ts +19 -0
- package/src/authoring/react-canvas-navigation.ts +255 -0
- package/src/authoring/react-design-canvas-style.ts +20 -0
- package/src/authoring/react-story-board.ts +937 -0
- package/src/authoring/selection-scope.ts +195 -0
- package/src/authoring/shell-document-ops.ts +169 -0
- package/src/authoring/shell-object3d-document-write-policy.ts +108 -0
- package/src/authoring/shell-viewport-policy.ts +48 -0
- package/src/authoring/source-object3d-authoring-adapter.ts +526 -0
- package/src/authoring/stories-scope.ts +35 -0
- package/src/authoring/story-board-chrome-fit.ts +107 -0
- package/src/authoring/story-board-presentation.ts +111 -0
- package/src/authoring/three-projection-core.ts +226 -0
- package/src/authoring/viewport-pick-context.ts +40 -0
- package/src/authoring/viewport-raycast.ts +240 -0
- package/src/authoring/viewport-tool-context.ts +73 -0
- package/src/authoring/world-canvas-viewport-state.ts +35 -0
- package/src/authoring/world-hidden-viewport.ts +152 -0
- package/src/authoring/world-pan-state.ts +198 -0
- package/src/authoring/world-session-state.ts +101 -0
- package/src/authoring/write-pipe.ts +173 -0
- package/src/availability-tick.ts +66 -0
- package/src/bitmap-label.ts +120 -0
- package/src/blender-tab-metrics.ts +161 -0
- package/src/board-open-actions.ts +20 -0
- package/src/boot-routing.ts +391 -0
- package/src/build-identity.ts +15 -0
- package/src/bytes-codec.ts +62 -0
- package/src/camera-authoring.ts +175 -0
- package/src/cancellation-reason.ts +58 -0
- package/src/canvas-preview-frames.ts +482 -0
- package/src/capture-camera-pose.ts +77 -0
- package/src/chrome-registry.ts +159 -0
- package/src/chrome-slot-registry.ts +91 -0
- package/src/collaboration-client.ts +264 -0
- package/src/collaboration-presence.ts +41 -0
- package/src/command-dispatch.ts +19 -0
- package/src/command-listener.ts +2743 -0
- package/src/command-registry.ts +70 -0
- package/src/component-board-registry.ts +205 -0
- package/src/component-states-registry.ts +200 -0
- package/src/components/AgentPresentationNotice.tsx +75 -0
- package/src/components/AlignToolbar.tsx +204 -0
- package/src/components/AppRoot.tsx +394 -0
- package/src/components/ApplicationMenus.tsx +415 -0
- package/src/components/AssetBrowser.tsx +2843 -0
- package/src/components/AssetEditorShell.tsx +216 -0
- package/src/components/AssetInspectorToolSection.tsx +124 -0
- package/src/components/BoardRulers.tsx +354 -0
- package/src/components/CameraInfo.tsx +81 -0
- package/src/components/CanvasSceneViewport.tsx +556 -0
- package/src/components/CapabilityCoverageSection.tsx +185 -0
- package/src/components/CenterDocuments.tsx +22 -0
- package/src/components/ChromeSlot.tsx +20 -0
- package/src/components/CodeView.tsx +470 -0
- package/src/components/CollaborationHeaderControl.css +74 -0
- package/src/components/CompactInspectorCard.tsx +190 -0
- package/src/components/CompactInspectorShell.tsx +39 -0
- package/src/components/ConsolePanel.css +55 -0
- package/src/components/ConsolePanel.tsx +270 -0
- package/src/components/DefaultEditorLayout.tsx +106 -0
- package/src/components/DocumentHeaderStrip.tsx +96 -0
- package/src/components/DocumentShelfRail.tsx +45 -0
- package/src/components/DocumentThumbnail.tsx +345 -0
- package/src/components/EditorLeaseGuard.tsx +372 -0
- package/src/components/ErrorBanner.tsx +123 -0
- package/src/components/FolderPreviewTile.tsx +159 -0
- package/src/components/GameHierarchy.tsx +3842 -0
- package/src/components/GameplaySessionTimeline.tsx +295 -0
- package/src/components/InspectionProjection.tsx +968 -0
- package/src/components/Inspector.tsx +233 -0
- package/src/components/InspectorCanvasPreview.tsx +35 -0
- package/src/components/InspectorFieldsSection.tsx +290 -0
- package/src/components/InspectorObjectPreview.tsx +57 -0
- package/src/components/InspectorStoriesSection.tsx +92 -0
- package/src/components/InspectorToolSection.tsx +96 -0
- package/src/components/InspectorTransformSection.tsx +245 -0
- package/src/components/LightExplorerPanel.tsx +433 -0
- package/src/components/MediaProperties.tsx +145 -0
- package/src/components/NonThreeAuthoringBootstrap.tsx +19 -0
- package/src/components/Object3DDocumentToolbar.css +100 -0
- package/src/components/Object3DDocumentToolbar.tsx +454 -0
- package/src/components/Object3DDocumentViewport.tsx +58 -0
- package/src/components/ProjectHeader.css +617 -0
- package/src/components/ProjectHeader.tsx +328 -0
- package/src/components/ProjectLayout.tsx +249 -0
- package/src/components/ReactCanvasControls.tsx +284 -0
- package/src/components/RootSelectionOverlay.tsx +3162 -0
- package/src/components/RootTextEditor.tsx +79 -0
- package/src/components/SaveStatus.tsx +70 -0
- package/src/components/StageHost.tsx +1954 -0
- package/src/components/StageOverlays.tsx +21 -0
- package/src/components/StartupErrorScreen.tsx +76 -0
- package/src/components/StartupLoadingScreen.tsx +65 -0
- package/src/components/StatsOverlay.tsx +78 -0
- package/src/components/SurfaceStateOverlay.tsx +24 -0
- package/src/components/ToolContributionSurfaces.tsx +65 -0
- package/src/components/ToolHost.tsx +370 -0
- package/src/components/ToolObject3DPreview.tsx +39 -0
- package/src/components/Toolbar.tsx +595 -0
- package/src/components/TransientHint.tsx +44 -0
- package/src/components/TransportStrip.tsx +174 -0
- package/src/components/VersionControlSection.tsx +470 -0
- package/src/components/VgaiLogo.css +83 -0
- package/src/components/VgaiLogo.tsx +35 -0
- package/src/components/ViewportControlsHint.tsx +60 -0
- package/src/components/ViewportFurniture.tsx +401 -0
- package/src/components/ViewportOverlay.tsx +145 -0
- package/src/components/ViewportOverlaysMenu.tsx +153 -0
- package/src/components/ViewportShadingMenu.tsx +300 -0
- package/src/components/ViewportViewMenu.tsx +101 -0
- package/src/components/WorkspaceDocumentSurface.tsx +204 -0
- package/src/components/WorkspaceUtilitySurface.tsx +22 -0
- package/src/components/WorktreeSwitcher.css +239 -0
- package/src/components/WorktreeSwitcher.tsx +890 -0
- package/src/components/account-documents.tsx +1162 -0
- package/src/components/asset-documents.tsx +812 -0
- package/src/components/asset-editor-persistence.ts +216 -0
- package/src/components/asset-selection-section.tsx +535 -0
- package/src/components/asset-thumbnails.tsx +312 -0
- package/src/components/asset-viewers/AudioViewer.tsx +201 -0
- package/src/components/asset-viewers/EntityModelDocument.tsx +122 -0
- package/src/components/asset-viewers/EnvironmentAssetDocument.tsx +440 -0
- package/src/components/asset-viewers/GenericJsonViewer.tsx +102 -0
- package/src/components/asset-viewers/ImageViewer.tsx +300 -0
- package/src/components/asset-viewers/JsonAssetDocument.tsx +93 -0
- package/src/components/asset-viewers/LiveModuleDocument.tsx +417 -0
- package/src/components/asset-viewers/LutAssetDocument.tsx +444 -0
- package/src/components/asset-viewers/ModelAssetDocument.tsx +105 -0
- package/src/components/asset-viewers/Object3DPreview.tsx +359 -0
- package/src/components/asset-viewers/OnlineAssetDetail.tsx +421 -0
- package/src/components/asset-viewers/PasteboardModuleDocument.tsx +67 -0
- package/src/components/asset-viewers/QuarksAssetDocument.tsx +526 -0
- package/src/components/asset-viewers/ShaderAssetDocument.tsx +743 -0
- package/src/components/asset-viewers/SourceAssetViewer.tsx +281 -0
- package/src/components/asset-viewers/SpritesheetSpriteView.tsx +102 -0
- package/src/components/asset-viewers/VideoViewer.tsx +101 -0
- package/src/components/asset-viewers/shader-source.ts +144 -0
- package/src/components/asset-workflow.css +731 -0
- package/src/components/board-guides.ts +150 -0
- package/src/components/compact-inspector.css +539 -0
- package/src/components/core-utilities.tsx +90 -0
- package/src/components/editor-notifications.css +40 -0
- package/src/components/engine-workspace.css +274 -0
- package/src/components/inspector-preview-section.tsx +238 -0
- package/src/components/inspector-property-grouping.ts +64 -0
- package/src/components/inspector-revert-label.ts +20 -0
- package/src/components/inspector-selection.ts +42 -0
- package/src/components/inspector-stories-gating.ts +171 -0
- package/src/components/inspector-transform-subject.ts +11 -0
- package/src/components/inspector-transform.ts +75 -0
- package/src/components/kind-documents.tsx +500 -0
- package/src/components/palette-action-publisher.tsx +140 -0
- package/src/components/primitives/DraftColorInput.tsx +74 -0
- package/src/components/product-shell.css +1829 -0
- package/src/components/project-tool-documents.tsx +410 -0
- package/src/components/scene-documents.tsx +223 -0
- package/src/components/stage-keyboard.tsx +37 -0
- package/src/components/stage-overlay-set.tsx +107 -0
- package/src/components/stage-presence-markers.ts +478 -0
- package/src/components/standard-viewport-dressing.ts +417 -0
- package/src/components/status-contributions.tsx +403 -0
- package/src/components/tool-documents.tsx +309 -0
- package/src/components/tool-schema-form.tsx +262 -0
- package/src/components/use-after-paint.ts +41 -0
- package/src/components/use-project-image-assets.ts +86 -0
- package/src/components/viewport-header-controls.css +90 -0
- package/src/components/viewport-surface-status.tsx +55 -0
- package/src/components/workspace-history.ts +32 -0
- package/src/components/workspace-static-panel-registry.tsx +152 -0
- package/src/components/workspace-surfaces.css +621 -0
- package/src/components/world-documents.tsx +586 -0
- package/src/components/world-overlay-gestures.ts +1694 -0
- package/src/composite-screenshot.ts +1641 -0
- package/src/console-sync.ts +131 -0
- package/src/constraint-helper.ts +338 -0
- package/src/content-entry-source-registry.ts +184 -0
- package/src/coverage/authoring-seam-evidence.ts +300 -0
- package/src/coverage/canvas-reveal.ts +192 -0
- package/src/coverage/design-time-surfaces.ts +101 -0
- package/src/coverage/live-seam-evidence.ts +11 -0
- package/src/coverage/ontology-invariants.ts +466 -0
- package/src/coverage/session-vitals.ts +501 -0
- package/src/coverage/system-seam-evidence.ts +72 -0
- package/src/crash-null-boundary.ts +36 -0
- package/src/creation-site-edit.ts +1479 -0
- package/src/creation-site-registry.ts +160 -0
- package/src/delegate-harness-registry.ts +143 -0
- package/src/document-context-registry.ts +177 -0
- package/src/document-open-registry.ts +200 -0
- package/src/document-preview-source.ts +20 -0
- package/src/document-renderer-session.ts +138 -0
- package/src/editor-api.ts +46 -0
- package/src/editor-chrome-capture.ts +139 -0
- package/src/editor-commands.ts +164 -0
- package/src/editor-console.ts +563 -0
- package/src/editor-current-view.ts +82 -0
- package/src/editor-document-probe.ts +881 -0
- package/src/editor-git-client.ts +115 -0
- package/src/editor-host-door.ts +462 -0
- package/src/editor-hotkeys.ts +842 -0
- package/src/editor-lease-view.ts +39 -0
- package/src/editor-lease.ts +415 -0
- package/src/editor-mode.ts +19 -0
- package/src/editor-notifications.ts +140 -0
- package/src/editor-presence.ts +563 -0
- package/src/editor-presentation-activity.ts +50 -0
- package/src/editor-presentation-notice.ts +42 -0
- package/src/editor-runtime.tsx +145 -0
- package/src/editor-server-response.ts +86 -0
- package/src/editor-session-attribution.ts +75 -0
- package/src/editor-session-mode.ts +54 -0
- package/src/editor-shell-store.ts +1304 -0
- package/src/editor-state-facets.ts +74 -0
- package/src/editor-styles.css +33 -0
- package/src/editor-view-presentation.ts +810 -0
- package/src/editor-viewport.ts +5302 -0
- package/src/entity-lod.ts +31 -0
- package/src/entity-object.ts +91 -0
- package/src/files/file-provider.ts +62 -0
- package/src/files/project-files.ts +264 -0
- package/src/finders/index.ts +136 -0
- package/src/finders/scenes-from-entrypoint-selection.ts +387 -0
- package/src/frame/bridge.tsx +1408 -0
- package/src/frame/product.ts +77 -0
- package/src/gameplay-dom-recording.ts +319 -0
- package/src/gameplay-export-state.ts +14 -0
- package/src/gameplay-replay.ts +417 -0
- package/src/gameplay-session-time.ts +9 -0
- package/src/gameplay-sessions.ts +204 -0
- package/src/harness-chat-types.ts +253 -0
- package/src/hierarchy-component-marks.ts +298 -0
- package/src/hierarchy-drop.ts +91 -0
- package/src/hierarchy-expansion-state.ts +80 -0
- package/src/hierarchy-header-slot.ts +52 -0
- package/src/hierarchy-internals.ts +197 -0
- package/src/hierarchy-kind-icon.ts +217 -0
- package/src/hierarchy-mark-reader.ts +73 -0
- package/src/hierarchy-menu-registry.ts +67 -0
- package/src/hierarchy-node-rows.ts +307 -0
- package/src/hierarchy-panel-view.ts +280 -0
- package/src/hierarchy-projection.ts +76 -0
- package/src/hierarchy-row-cache.ts +243 -0
- package/src/hierarchy-row-model.ts +308 -0
- package/src/hierarchy-rows.ts +11 -0
- package/src/hierarchy-walk.ts +86 -0
- package/src/history/editor-session.ts +25 -0
- package/src/history/history-commands.ts +147 -0
- package/src/history/history-delegate.ts +163 -0
- package/src/history/history-limit-notices.ts +43 -0
- package/src/history/history-service.ts +1173 -0
- package/src/history/persistence-coordinator.ts +35 -0
- package/src/history/project-file-history.ts +386 -0
- package/src/history/project-root-history-backends.ts +139 -0
- package/src/history/resource-registry.ts +209 -0
- package/src/history/snapshot-store.ts +103 -0
- package/src/history/source-history-backend.ts +546 -0
- package/src/history/types.ts +124 -0
- package/src/hmr-registration-group.ts +67 -0
- package/src/hmr-stable-react-context.ts +23 -0
- package/src/hotkeys.ts +188 -0
- package/src/inference-diagnostics.ts +69 -0
- package/src/initial-project.ts +80 -0
- package/src/inspection/active-subject.ts +578 -0
- package/src/inspection/active-surface.ts +142 -0
- package/src/inspection/compose.ts +1064 -0
- package/src/inspection/display.ts +170 -0
- package/src/inspection/document-subject.ts +109 -0
- package/src/inspection/game-subject.ts +85 -0
- package/src/inspection/model.ts +542 -0
- package/src/inspection/null-subject.ts +115 -0
- package/src/inspection/serialize.ts +357 -0
- package/src/inspection/use-active-inspection.ts +180 -0
- package/src/inspector-presentation.ts +201 -0
- package/src/inspector-section-registry.ts +221 -0
- package/src/instance-source-actions.ts +163 -0
- package/src/instanced-presentation.ts +164 -0
- package/src/js-heap.ts +71 -0
- package/src/key-actions.ts +91 -0
- package/src/keymap-presets.ts +401 -0
- package/src/layout-policy.ts +31 -0
- package/src/learn-links.ts +73 -0
- package/src/light-explorer-model.ts +134 -0
- package/src/live-canvas-frame.ts +55 -0
- package/src/live-document.ts +279 -0
- package/src/live-module-source.ts +230 -0
- package/src/live-session-registry.ts +220 -0
- package/src/live-transition.ts +633 -0
- package/src/manifest-project.ts +107 -0
- package/src/model-thumbnail.ts +523 -0
- package/src/native-selection-style.ts +202 -0
- package/src/object3d-document-write-policy.ts +137 -0
- package/src/packaged-runtime.ts +108 -0
- package/src/palettes/maya.palette.json +57 -0
- package/src/palettes/substance.palette.json +57 -0
- package/src/pasteboard-module.ts +81 -0
- package/src/performance-profiler.ts +367 -0
- package/src/performance-sources.ts +69 -0
- package/src/play-boot-phase.ts +145 -0
- package/src/presentation-surface.ts +248 -0
- package/src/project-adapter.ts +1143 -0
- package/src/project-asset-refresh.ts +26 -0
- package/src/project-declaration-refresh.ts +31 -0
- package/src/project-local-state.ts +118 -0
- package/src/project-manager.ts +243 -0
- package/src/project-module-changes.ts +163 -0
- package/src/project-module-split.ts +266 -0
- package/src/project-provenance.ts +115 -0
- package/src/project-ready.ts +42 -0
- package/src/project-session-reset.ts +67 -0
- package/src/project-shape.ts +68 -0
- package/src/project-tool-discovery.ts +66 -0
- package/src/project-tools.ts +107 -0
- package/src/project-work-types.ts +149 -0
- package/src/projection/three.ts +898 -0
- package/src/projection/types.ts +44 -0
- package/src/readiness.ts +113 -0
- package/src/reflection-probe-helper.ts +142 -0
- package/src/reported-play-state.ts +90 -0
- package/src/resolve-relative-specifier.ts +33 -0
- package/src/scene-document-plan.ts +315 -0
- package/src/scene-framing.ts +315 -0
- package/src/scene-live-open.ts +210 -0
- package/src/scene-view-fog.ts +89 -0
- package/src/scoped-game-css.ts +152 -0
- package/src/session-orphan-record.ts +193 -0
- package/src/session-tombstone.ts +126 -0
- package/src/settings/settings-provider.ts +82 -0
- package/src/settings-store.ts +345 -0
- package/src/shared-view-restore.ts +42 -0
- package/src/shell-store-door.ts +45 -0
- package/src/source-conflict.ts +122 -0
- package/src/spatial-handle-visuals.ts +332 -0
- package/src/stage-context.ts +341 -0
- package/src/stage-store-registry.ts +63 -0
- package/src/stale-chunk-recovery.ts +34 -0
- package/src/state-report-deferral.ts +73 -0
- package/src/storage/host-files-storage.ts +97 -0
- package/src/storage/http-storage.ts +174 -0
- package/src/storage/index.ts +75 -0
- package/src/storage/mem-storage.ts +149 -0
- package/src/storage/path-lock.ts +44 -0
- package/src/storage/paths.ts +26 -0
- package/src/storage/types.ts +116 -0
- package/src/stories/StoryComponentThumbnail.tsx +184 -0
- package/src/stories/StoryPreviewMount.tsx +306 -0
- package/src/stories/component-content-source.tsx +106 -0
- package/src/stories/component-states-source.ts +78 -0
- package/src/stories/compose-project-stories.ts +255 -0
- package/src/stories/pixi-story-model.ts +30 -0
- package/src/stories/prefabs-finder.ts +54 -0
- package/src/stories/prefabs-from-stories.ts +182 -0
- package/src/stories/project-story-discovery.ts +78 -0
- package/src/stories/project-story-regions.ts +24 -0
- package/src/stories/story-actions.ts +54 -0
- package/src/stories/story-capture-command.ts +124 -0
- package/src/stories/story-capture.ts +584 -0
- package/src/stories/story-declared-medium.ts +126 -0
- package/src/stories/story-discovery.ts +176 -0
- package/src/stories/story-document-openers.ts +36 -0
- package/src/stories/story-dom-runtime.ts +78 -0
- package/src/stories/story-grouping.ts +111 -0
- package/src/stories/story-lane.ts +88 -0
- package/src/stories/story-mount-turn.ts +27 -0
- package/src/stories/story-opener.ts +260 -0
- package/src/stories/story-pixi-preview.ts +408 -0
- package/src/stories/story-presentation.ts +215 -0
- package/src/stories/story-registry.ts +517 -0
- package/src/stories/story-three-preview.ts +807 -0
- package/src/stories/three-story-model.ts +96 -0
- package/src/story-three-preview-runtime.ts +56 -0
- package/src/surface-keyboard.ts +112 -0
- package/src/surface-state.ts +124 -0
- package/src/tab-bootstrap.js +628 -0
- package/src/tab-census.ts +198 -0
- package/src/tab-lifecycle-client.ts +196 -0
- package/src/theme-library.ts +844 -0
- package/src/theme-preference.ts +378 -0
- package/src/theme.css +3437 -0
- package/src/three-viewport/bone-selection-highlight.ts +119 -0
- package/src/three-viewport/camera-fit.ts +41 -0
- package/src/three-viewport/interactive-renderer.ts +132 -0
- package/src/three-viewport/selection-brackets.ts +310 -0
- package/src/three-viewport/selection-outline.ts +183 -0
- package/src/three-viewport/skeleton-helper.ts +61 -0
- package/src/three-viewport/source-color.ts +167 -0
- package/src/three-viewport/studio-environment.ts +96 -0
- package/src/three-viewport-presentation.ts +23 -0
- package/src/tool-contribution-play.ts +74 -0
- package/src/tool-loader.ts +1798 -0
- package/src/transform-mode-request.ts +57 -0
- package/src/transient-hint.ts +78 -0
- package/src/trigger-volume-helper.ts +116 -0
- package/src/ui-source/adapter-region-includes.ts +241 -0
- package/src/ui-source/ensure-import.ts +132 -0
- package/src/ui-source/file-region-resolver.ts +302 -0
- package/src/ui-source/inspect.ts +775 -0
- package/src/ui-source/oid-transform.ts +1845 -0
- package/src/ui-source/plan-csf-story.ts +196 -0
- package/src/ui-source/plan-extract-component.ts +421 -0
- package/src/ui-source/plan-fork-component.ts +621 -0
- package/src/ui-source/plan-named-style.ts +95 -0
- package/src/ui-source/plan-source-edit.ts +353 -0
- package/src/ui-source/r3f-contract-resolver.ts +327 -0
- package/src/ui-source/r3f-diagnostic-index.ts +165 -0
- package/src/ui-source/r3f-environment-binding.ts +133 -0
- package/src/ui-source/r3f-joint-binding.ts +251 -0
- package/src/ui-source/r3f-lod-binding.ts +91 -0
- package/src/ui-source/r3f-particle-binding.ts +235 -0
- package/src/ui-source/r3f-physics-binding.ts +483 -0
- package/src/ui-source/r3f-project-contracts.ts +444 -0
- package/src/ui-source/relative-import-specifier.ts +34 -0
- package/src/ui-source/reparent-guard.ts +544 -0
- package/src/ui-source/source-edit-request.ts +97 -0
- package/src/ui-source/source-write-backend.ts +618 -0
- package/src/ui-source/syntactic-prop-specs.ts +296 -0
- package/src/ui-source/tier-source-write-backend.ts +279 -0
- package/src/ui-source/ts-ast.ts +171 -0
- package/src/ui-source/utility-class-support.ts +169 -0
- package/src/ui-source/write-component-default.ts +133 -0
- package/src/ui-source/writer.ts +1989 -0
- package/src/viewport-activation-timings.ts +840 -0
- package/src/viewport-authoring-policy.ts +186 -0
- package/src/viewport-controls-hint.ts +57 -0
- package/src/viewport-door.ts +237 -0
- package/src/viewport-shading-boundary.ts +12 -0
- package/src/vite-error-surface.ts +141 -0
- package/src/wait-until.ts +37 -0
- package/src/workspace-areas.ts +156 -0
- package/src/workspace-aux-commands.ts +11 -0
- package/src/workspace-available-documents.ts +127 -0
- package/src/workspace-core-utilities.ts +31 -0
- package/src/workspace-document-ids.ts +59 -0
- package/src/workspace-document-registry.ts +612 -0
- package/src/workspace-document-restore.ts +145 -0
- package/src/workspace-host-commands.ts +141 -0
- package/src/workspace-persistence-gate.ts +40 -0
- package/src/workspace-play-utilities.ts +44 -0
- package/src/workspace-presets.ts +425 -0
- package/src/workspace-regions.ts +294 -0
- package/src/workspace-state-persistence.ts +536 -0
- package/src/workspace-static-panels.ts +73 -0
- package/src/workspace-status-registry.ts +121 -0
- package/src/workspace-style.ts +239 -0
- package/src/workspace-utility-commands.ts +74 -0
- package/src/workspace-utility-registry.ts +263 -0
- package/src/workspace-viewport-rect.ts +97 -0
- package/src/world-adoption.ts +115 -0
- package/src/world-document-routing.ts +104 -0
- package/vite-plugin-creation-site-write.ts +184 -0
- package/vite-plugin-creation-site.ts +80 -0
- package/vite-plugin-game-static.ts +303 -0
- package/vite-plugin-module-doorways.ts +336 -0
- package/vite-plugin-product-contributions.ts +197 -0
- package/vite-plugin-project-game-static.ts +125 -0
- package/vite-plugin-project-jsx-js.ts +65 -0
- package/vite-plugin-project-root-absolute-assets.ts +359 -0
- package/vite-plugin-shared-react.ts +420 -0
- package/vite-plugin-shared-three.ts +203 -0
- package/vite-plugin-ui-oid.ts +2106 -0
|
@@ -0,0 +1,1508 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE TAB TABLE — what the server knows about the browser tabs that exist for
|
|
3
|
+
* this session, and the only thing any presence question is allowed to read.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS (owner, 2026-08-09): "there exist tabs that may or may not
|
|
6
|
+
* be open; if one or more tabs is open, choose one of them to be the editor;
|
|
7
|
+
* if no tabs are open, auto-open one. Make sure the tabs do NOT get
|
|
8
|
+
* disconnected, and if they do, make it clear that it happened. You're
|
|
9
|
+
* relying on sockets to prove connection — that's highly unreliable. It
|
|
10
|
+
* should be heartbeats."
|
|
11
|
+
*
|
|
12
|
+
* The invariant: IF a tab for this session exists in a browser, the backend
|
|
13
|
+
* sees it — 100%.
|
|
14
|
+
*
|
|
15
|
+
* WHAT A SOCKET ACTUALLY PROVES. Almost nothing, in either direction. A
|
|
16
|
+
* socket can be OPEN to a tab that cannot run a line of JavaScript (cold
|
|
17
|
+
* module evaluation blocks the main thread for a minute), and a socket can
|
|
18
|
+
* CLOSE for a tab that never went anywhere (a reload, a laptop lid). The old
|
|
19
|
+
* model read both as facts about the tab: an open socket meant "here", a
|
|
20
|
+
* close meant "lost — reopening". Worse, it demanded a THIRD thing before it
|
|
21
|
+
* would route a command — an application-level state report — so a live,
|
|
22
|
+
* duplex-granted, blessed tab that had not yet finished loading its module
|
|
23
|
+
* graph was invisible to the relay and its commands came back "Editor
|
|
24
|
+
* disconnected before the command could be delivered." Measured 2026-08-09
|
|
25
|
+
* and reproduced headlessly: `editorsConnected: 1, connected: true`, no state
|
|
26
|
+
* report, five refused plays.
|
|
27
|
+
*
|
|
28
|
+
* SO PRESENCE IS A BEAT. A dedicated Worker in the page beats every second
|
|
29
|
+
* over its OWN socket. It keeps beating while the page's main thread is
|
|
30
|
+
* blocked, its socket is not the page's socket, and it dies exactly when the
|
|
31
|
+
* tab dies — which is the whole list of properties presence needs and a page
|
|
32
|
+
* socket has none of.
|
|
33
|
+
*
|
|
34
|
+
* IDENTITY. `tabId` lives in `sessionStorage`, which is scoped to one tab and
|
|
35
|
+
* survives its reloads, so a reload is the SAME tab with a new `epoch`. The
|
|
36
|
+
* one thing sessionStorage does not survive correctly is "Duplicate Tab",
|
|
37
|
+
* which copies it — two epochs beating at once under one tabId, detected here
|
|
38
|
+
* and answered with a re-mint.
|
|
39
|
+
*
|
|
40
|
+
* PURE ON PURPOSE. Every decision below is a function of (state, input, now)
|
|
41
|
+
* with the clock injected, so the tests never wait: `tab-lifecycle.test.ts`
|
|
42
|
+
* drives real gaps, reloads and departures in microseconds — against these
|
|
43
|
+
* functions directly as well as through the shell. The stateful shell that
|
|
44
|
+
* wires this to sockets, the opener and a tick lives in `tab-lifecycle.ts`.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import type { SessionJournalEvent } from './support/project/session-journal';
|
|
48
|
+
import type { TabCensus } from '@volter/editor-sdk/project/tab-census';
|
|
49
|
+
|
|
50
|
+
/** What the page last told its worker about itself. */
|
|
51
|
+
export type TabVisibility = 'visible' | 'hidden';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Which surface a tab is showing.
|
|
55
|
+
* - `'project'` — the editor, on the project this session serves.
|
|
56
|
+
* - `'no-project'` — the launcher/hub or the startup-failure surface: a real
|
|
57
|
+
* tab that is not on the project and can be adopted into it.
|
|
58
|
+
* - `'unknown'` — the page has not said yet (still booting). Read as "on
|
|
59
|
+
* its way to the project", which is what it is for every tab this session
|
|
60
|
+
* opened itself.
|
|
61
|
+
*/
|
|
62
|
+
export type TabRoute = 'project' | 'no-project' | 'unknown';
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* WHAT KIND OF PAGE this tab is.
|
|
66
|
+
*
|
|
67
|
+
* - `'editor'` — the vgai editor's own page, which is every tab this session
|
|
68
|
+
* opens itself.
|
|
69
|
+
* - `'vscode'` — a Code-OSS workbench window running the editor through the
|
|
70
|
+
* frame (docs/CODE-OSS.md §Boot, DESKTOP). It is a tab like any other under
|
|
71
|
+
* the bijection; the only difference is that vgai does not author its HTML,
|
|
72
|
+
* so the things a page does for itself — minting an identity, beating,
|
|
73
|
+
* saying goodbye — arrive from a script the session serves it
|
|
74
|
+
* (`server/tab-bootstrap.ts`).
|
|
75
|
+
*
|
|
76
|
+
* Why the table carries it at all: without it, a VS Code window that had not
|
|
77
|
+
* yet been given that script was reported as an anomaly ("SOMETHING IS OFF")
|
|
78
|
+
* with no way to say WHICH bootstrap was missing, and a window that had been
|
|
79
|
+
* given it was indistinguishable from a browser tab. Both readings are now
|
|
80
|
+
* the truth, in one word.
|
|
81
|
+
*
|
|
82
|
+
* TWO SOURCES, one field, because each covers what the other cannot: the
|
|
83
|
+
* bootstrap DECLARES it (`?surface=` on the script's own url, which survives
|
|
84
|
+
* the web shape where the workbench and the session share an origin), and the
|
|
85
|
+
* server OBSERVES it (a control connection whose `Origin` is the desktop
|
|
86
|
+
* frame's, which needs no cooperation from a page that never got the script).
|
|
87
|
+
*/
|
|
88
|
+
export type TabSurface = 'editor' | 'vscode';
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* THE RESOURCE CENSUS — what the tab was holding, sampled by the page and
|
|
92
|
+
* carried on the beat. Declared in `@vgai/sdk/tab-census` (the one package
|
|
93
|
+
* every unit that speaks this shape already depends on) and re-exported here,
|
|
94
|
+
* because this file is where the server's readers look for it.
|
|
95
|
+
*
|
|
96
|
+
* WHY IT EXISTS (measured 2026-08-10): a game tab's Chrome RENDERER PROCESS was
|
|
97
|
+
* killed repeatedly — WS close code 1006, no goodbye — while a 3840x2080 WebGL
|
|
98
|
+
* world with an HDR bloom chain was resident and painting. JS heap stayed flat
|
|
99
|
+
* at ~190 MB throughout, so the kill was GPU/compositor-side, at a per-process
|
|
100
|
+
* ceiling Chrome does not document and never announces. The tab table recovered
|
|
101
|
+
* perfectly (departed → auto-open) and recorded NOTHING about why.
|
|
102
|
+
*
|
|
103
|
+
* The class is agent-native: an agent authors lush effects with zero cost
|
|
104
|
+
* feedback, and the quality loop grades looks and never cost. So the tab now
|
|
105
|
+
* says what it is holding, on a channel that already exists, and a death gets
|
|
106
|
+
* a cause line instead of a shrug.
|
|
107
|
+
*
|
|
108
|
+
* MEASUREMENT ONLY. Nothing here enforces a budget — budgets are a later
|
|
109
|
+
* decision, once real profiles exist to set them from.
|
|
110
|
+
*/
|
|
111
|
+
export type { TabCensus };
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* One census as the SERVER filed it: the profile plus the server's own clock.
|
|
115
|
+
*
|
|
116
|
+
* THE INVARIANT `at` DEPENDS ON: a census reaches the server only on the one
|
|
117
|
+
* beat that carries a FRESH page sample. The worker drops it after sending
|
|
118
|
+
* (`tab-heartbeat.ts`'s `body()`), so `at` means "the page sampled this, and
|
|
119
|
+
* it arrived", within the one beat (≤1s) between the two.
|
|
120
|
+
*
|
|
121
|
+
* That is load-bearing rather than tidy. The worker used to echo its cached
|
|
122
|
+
* profile on every beat and this stamp moved with the echo, so a hidden tab
|
|
123
|
+
* that had not sampled in half an hour still reported a half-second-old
|
|
124
|
+
* profile — and the age, whose whole job is to make a stale profile READ as
|
|
125
|
+
* stale, lied on the path that matters most: Chrome kills background tabs
|
|
126
|
+
* under memory pressure. The stamp is also the server's, never the page's;
|
|
127
|
+
* an age computed across two clocks is not an age.
|
|
128
|
+
*/
|
|
129
|
+
export type TabCensusSample = TabCensus & { readonly at: number };
|
|
130
|
+
|
|
131
|
+
/** How many samples per tab the table keeps. Two: the last one, and the one
|
|
132
|
+
* before it — enough to see a resource CLIMB in the line that reports a death,
|
|
133
|
+
* and short enough that the table stays a table. */
|
|
134
|
+
export const TAB_CENSUS_HISTORY = 2;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* THE PAGE SAYING GOODBYE — the one signal a page sends about its own end.
|
|
138
|
+
*
|
|
139
|
+
* WHY IT EXISTS (measured 2026-09-17): four different causes produced one
|
|
140
|
+
* symptom, "the battery stopped", and the product reported all four the same
|
|
141
|
+
* way. A renderer killed by a dev-server reload and a page whose main thread
|
|
142
|
+
* was stuck in a 46 MB encode are indistinguishable from the beats alone —
|
|
143
|
+
* both are silence. The difference is whether the page got a chance to say it
|
|
144
|
+
* was going: `pagehide` fires for a close, a navigation and a reload, and does
|
|
145
|
+
* NOT fire for a renderer kill or a wedged main thread. So a silence WITH a
|
|
146
|
+
* goodbye is `closed`/`reloading` and a silence WITHOUT one is `crashed`, and
|
|
147
|
+
* that is the whole discriminator.
|
|
148
|
+
*
|
|
149
|
+
* Sent with `navigator.sendBeacon` (index.html's inline bootstrap), which is
|
|
150
|
+
* the one transport the browser promises to deliver after the document is
|
|
151
|
+
* gone. `persisted` is `PageTransitionEvent.persisted`: the page went into the
|
|
152
|
+
* back/forward cache and may yet come back alive, which is a different sentence
|
|
153
|
+
* from "this page is over" and is carried rather than collapsed.
|
|
154
|
+
*/
|
|
155
|
+
export interface TabCloseBeacon {
|
|
156
|
+
readonly tabId: string;
|
|
157
|
+
/** The page-load that is leaving — never assumed to be the current one. */
|
|
158
|
+
readonly epoch: string;
|
|
159
|
+
/** True when the document went into the back/forward cache. */
|
|
160
|
+
readonly persisted: boolean;
|
|
161
|
+
/**
|
|
162
|
+
* WHY the page is saying goodbye, and the only thing that makes this beacon
|
|
163
|
+
* an ACKNOWLEDGEMENT rather than a departure notice.
|
|
164
|
+
*
|
|
165
|
+
* `'pagehide'` — the document is unloading (index.html's bootstrap). The
|
|
166
|
+
* original and the default: a beacon that does not say is one of these.
|
|
167
|
+
*
|
|
168
|
+
* `'session-ended'` — the page received the session's `tab-close` and has
|
|
169
|
+
* already run every terminator behind `markSessionEnded`
|
|
170
|
+
* (`src/tab-lifecycle-client.ts`). Measured 2026-09-19/20: the session used
|
|
171
|
+
* to broadcast `tab-close`, sleep 150 ms blind and tear the server down,
|
|
172
|
+
* with nothing waiting for or recording whether any page heard it — and a
|
|
173
|
+
* page that did not hear it kept its Blender engine worker (one engine
|
|
174
|
+
* thread plus a 16-thread pool) running at 100%+ CPU for minutes after
|
|
175
|
+
* `vgai close`. This is the page's half of that fact; `tab-lifecycle.ts`'s
|
|
176
|
+
* `notifySessionEnded` is the half that waits for it.
|
|
177
|
+
*/
|
|
178
|
+
readonly reason: 'pagehide' | 'session-ended';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** One heartbeat, as it arrives from a tab's worker. */
|
|
182
|
+
export interface TabBeat {
|
|
183
|
+
readonly tabId: string;
|
|
184
|
+
readonly epoch: string;
|
|
185
|
+
readonly seq: number;
|
|
186
|
+
readonly visibility: TabVisibility;
|
|
187
|
+
/**
|
|
188
|
+
* A FRESH page sample, on the one beat that carries it — see
|
|
189
|
+
* {@link TabCensusSample}. Beats run at 1 Hz and the page samples at 0.2 Hz
|
|
190
|
+
* while visible and not at all while hidden, so most beats have no census
|
|
191
|
+
* and a hidden tab's beats have none for as long as it stays hidden. That
|
|
192
|
+
* silence is the honest signal, not a gap to paper over.
|
|
193
|
+
*/
|
|
194
|
+
readonly census?: TabCensus;
|
|
195
|
+
/** What the page's main thread announced it was entering (`building
|
|
196
|
+
* src/models/x.ts`), or `null` when it left; present on the one beat
|
|
197
|
+
* that carries the change. */
|
|
198
|
+
readonly phase?: string | null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** One page-load of a tab, tracked only long enough to tell reload from duplicate. */
|
|
202
|
+
interface EpochRecord {
|
|
203
|
+
readonly epoch: string;
|
|
204
|
+
readonly firstSeenAt: number;
|
|
205
|
+
readonly lastBeatAt: number;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Everything the server knows about one tab. */
|
|
209
|
+
export interface TabRecord {
|
|
210
|
+
readonly tabId: string;
|
|
211
|
+
/** First time this tabId was seen at all (beat or connection). */
|
|
212
|
+
readonly firstSeenAt: number;
|
|
213
|
+
/** Newest beat from any of this tab's live epochs; 0 if it has never beaten. */
|
|
214
|
+
readonly lastBeatAt: number;
|
|
215
|
+
/** True once at least one beat has arrived — see `tabPresent`. */
|
|
216
|
+
readonly beatEver: boolean;
|
|
217
|
+
/** The primary (oldest live) epoch — this tab's current page load. */
|
|
218
|
+
readonly epoch: string | null;
|
|
219
|
+
readonly epochStartedAt: number;
|
|
220
|
+
/** How many distinct page loads this tab has had. A reload increments it. */
|
|
221
|
+
readonly epochCount: number;
|
|
222
|
+
readonly epochs: readonly EpochRecord[];
|
|
223
|
+
readonly visibility: TabVisibility;
|
|
224
|
+
readonly route: TabRoute;
|
|
225
|
+
/** What kind of page this tab is — see {@link TabSurface}. */
|
|
226
|
+
readonly surface: TabSurface;
|
|
227
|
+
/** Does this tab hold a live control (command) channel right now? */
|
|
228
|
+
readonly connected: boolean;
|
|
229
|
+
/**
|
|
230
|
+
* Has THIS page-load ever opened a command channel? Reset on every epoch.
|
|
231
|
+
*
|
|
232
|
+
* The zombie-page discriminator. A heartbeat proves the TAB exists; it says
|
|
233
|
+
* nothing about the document. A page whose main thread died after the
|
|
234
|
+
* inline bootstrap keeps beating (the worker is a separate thread) and can
|
|
235
|
+
* never open a channel or run a command — so a tab that has been beating
|
|
236
|
+
* without ever establishing one this epoch is not a candidate for blessing,
|
|
237
|
+
* while a tab that HAD one and is mid-reload obviously still is.
|
|
238
|
+
*/
|
|
239
|
+
readonly channelThisEpoch: boolean;
|
|
240
|
+
/**
|
|
241
|
+
* Has THIS page-load reported an ATTACHED COMMAND LISTENER? Reset on every
|
|
242
|
+
* epoch, and the second half of the zombie discriminator above.
|
|
243
|
+
*
|
|
244
|
+
* A channel is not a document. `index.html`'s inline bootstrap opens the
|
|
245
|
+
* control connection BEFORE the module graph exists, precisely so a tab
|
|
246
|
+
* that is merely slow stays visible — which means a page whose module graph
|
|
247
|
+
* never evaluates at all opens a channel and then does nothing forever.
|
|
248
|
+
* That tab used to read as fully healthy here: blessed in the same
|
|
249
|
+
* millisecond its socket connected, `vgai edit` reporting a live session,
|
|
250
|
+
* and every command buffered in the bootstrap's replay queue with no
|
|
251
|
+
* consumer that would ever arrive. Measured 2026-08-13 in a session journal
|
|
252
|
+
* left behind by a real boot failure — `client-connected` and `tab-blessed`
|
|
253
|
+
* on the same millisecond, no `tab-appeared` (the heartbeat worker never
|
|
254
|
+
* started either), no listener, 77 seconds of nothing, then the tab closed.
|
|
255
|
+
*
|
|
256
|
+
* `connectCommandListener` reporting itself is the only evidence that the
|
|
257
|
+
* document is RUNNING, so it is what blessing is allowed to require —
|
|
258
|
+
* after {@link TabPresenceConfig.listenerBudgetMs}, never before.
|
|
259
|
+
*/
|
|
260
|
+
readonly listenerThisEpoch: boolean;
|
|
261
|
+
/** Recent page-owned listener reports, including one whose first beat is still in flight. */
|
|
262
|
+
readonly listenerEpochs: readonly string[];
|
|
263
|
+
/** Has the unresponsive verdict already been journaled for this epoch? */
|
|
264
|
+
readonly unresponsiveNoticed: boolean;
|
|
265
|
+
/** When beats stopped arriving; null while they are current. */
|
|
266
|
+
readonly gapSince: number | null;
|
|
267
|
+
/** Has the open gap already been journaled? (One line per gap, not per tick.) */
|
|
268
|
+
readonly gapNoticed: boolean;
|
|
269
|
+
/**
|
|
270
|
+
* The last {@link TAB_CENSUS_HISTORY} DISTINCT resource profiles this tab
|
|
271
|
+
* reported, oldest first. Empty for a tab that has never sent one (an older
|
|
272
|
+
* page, a tab bridged through the share tunnel, a browser with no census
|
|
273
|
+
* yet). The newest is what a death line quotes; the one before it is what
|
|
274
|
+
* makes a climb visible.
|
|
275
|
+
*/
|
|
276
|
+
readonly census: readonly TabCensusSample[];
|
|
277
|
+
/**
|
|
278
|
+
* The page-load that sent a {@link TabCloseBeacon}, or null when none has.
|
|
279
|
+
* See {@link tabState}: this is what separates `closed`/`reloading` from
|
|
280
|
+
* `crashed`, and it is a fact about an EPOCH, never about the tab — a reload
|
|
281
|
+
* closes one page-load and opens another under the same tabId.
|
|
282
|
+
*/
|
|
283
|
+
readonly closedEpoch: string | null;
|
|
284
|
+
/** SERVER clock: when that beacon landed. Null with no beacon. */
|
|
285
|
+
readonly closedAt: number | null;
|
|
286
|
+
/** That beacon's `persisted` — the document went into the back/forward cache. */
|
|
287
|
+
readonly closedPersisted: boolean;
|
|
288
|
+
/** That beacon's {@link TabCloseBeacon.reason}; null with no beacon. It is
|
|
289
|
+
* what separates a page that ACKNOWLEDGED the session's end (`ended`) from
|
|
290
|
+
* one that merely went away (`closed`). */
|
|
291
|
+
readonly closedReason: 'pagehide' | 'session-ended' | null;
|
|
292
|
+
/**
|
|
293
|
+
* The length of the most recent CLOSED gap in this tab's beats, and when it
|
|
294
|
+
* closed. Two fields rather than one because the verdict needs both: a gap
|
|
295
|
+
* longer than the grace that a beat later CLOSED is a suspend (a lid, a
|
|
296
|
+
* sleep), not a death — and only while the return is recent enough to be
|
|
297
|
+
* what a reader is looking at. `gapSince`/`gapNoticed` above cannot answer
|
|
298
|
+
* this: `recordBeat` clears them the moment the gap ends, which is exactly
|
|
299
|
+
* when this question gets asked.
|
|
300
|
+
*/
|
|
301
|
+
readonly lastGapMs: number | null;
|
|
302
|
+
readonly lastGapEndedAt: number | null;
|
|
303
|
+
/**
|
|
304
|
+
* When a relayed command last EXPIRED against this tab, or null when the
|
|
305
|
+
* last one answered.
|
|
306
|
+
*
|
|
307
|
+
* The second half of `hung`, and the half no page-side measurement can
|
|
308
|
+
* supply: a page whose main thread is inside a 46 MB encode samples no
|
|
309
|
+
* census and answers no command, and the census-age test below sees only a
|
|
310
|
+
* profile that stopped arriving. A command that went out and never came back
|
|
311
|
+
* while the beats stayed fresh is the server's OWN evidence for the same
|
|
312
|
+
* fact, and it is the one an operator already has in hand.
|
|
313
|
+
*/
|
|
314
|
+
readonly commandTimeoutAt: number | null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export interface TabPresenceConfig {
|
|
318
|
+
/** The worker's beat cadence. Everything else is expressed in multiples. */
|
|
319
|
+
readonly beatIntervalMs: number;
|
|
320
|
+
/** A visible tab is PRESENT while its newest beat is younger than this. */
|
|
321
|
+
readonly graceMs: number;
|
|
322
|
+
/**
|
|
323
|
+
* The same for a hidden tab. Chrome throttles background timers and may
|
|
324
|
+
* throttle workers, so cadence is not assumed: the grace is long, and the
|
|
325
|
+
* server actively probes over the heartbeat socket (message delivery is not
|
|
326
|
+
* throttled) — a probe answer is recorded as a beat, so a hidden tab that
|
|
327
|
+
* still answers stays present indefinitely.
|
|
328
|
+
*/
|
|
329
|
+
readonly hiddenGraceMs: number;
|
|
330
|
+
/** A gap this long is worth a journal line (owner: make it clear). */
|
|
331
|
+
readonly gapNoticeMs: number;
|
|
332
|
+
/** Beats older than this stop probing and let the tab depart on grace. */
|
|
333
|
+
readonly probeAfterMs: number;
|
|
334
|
+
/**
|
|
335
|
+
* How long a page-load gets to open a command channel before the tab is
|
|
336
|
+
* called unresponsive. Generous: a cold editor boot blocks the main thread
|
|
337
|
+
* for 60-100s, but the CHANNEL is opened by index.html's inline bootstrap
|
|
338
|
+
* before any module loads, so a page that has not opened one in this long
|
|
339
|
+
* never will.
|
|
340
|
+
*/
|
|
341
|
+
readonly channelBudgetMs: number;
|
|
342
|
+
/**
|
|
343
|
+
* How long a page-load gets to attach its COMMAND LISTENER before the tab
|
|
344
|
+
* is called unresponsive — the whole module graph, not just the inline
|
|
345
|
+
* bootstrap, so it is far longer than `channelBudgetMs`.
|
|
346
|
+
*
|
|
347
|
+
* Sized from the two measurements that bound it. A cold editor boot blocks
|
|
348
|
+
* the main thread for 60-100s (the same measurement `channelBudgetMs`'s
|
|
349
|
+
* comment cites), and `arrivalGraceMs` already concedes that a cold Vite
|
|
350
|
+
* dep-optimize can take minutes — so a smaller budget would demote healthy
|
|
351
|
+
* tabs mid-boot, which is the 2026-08-09 defect this whole file exists to
|
|
352
|
+
* undo. Bigger than either is not a cost worth paying either: this budget
|
|
353
|
+
* only ever decides how long the session may keep CLAIMING a tab can run
|
|
354
|
+
* commands before it says otherwise, and nothing waits on it — a command
|
|
355
|
+
* relayed meanwhile is held on its own budget exactly as before.
|
|
356
|
+
*/
|
|
357
|
+
readonly listenerBudgetMs: number;
|
|
358
|
+
/** How long the table must be EMPTY before auto-open fires. */
|
|
359
|
+
/** Base interval between auto-opens; each attempt doubles it. */
|
|
360
|
+
/**
|
|
361
|
+
* No blessing may be REASSIGNED within this long of the last change.
|
|
362
|
+
* Hysteresis against a beat landing either side of a grace boundary and
|
|
363
|
+
* churning which tab owns the session. A DEPARTED blessed tab is exempt —
|
|
364
|
+
* there is nothing to keep sticky — so the dwell only ever delays an
|
|
365
|
+
* upgrade, never a repair.
|
|
366
|
+
*/
|
|
367
|
+
readonly blessDwellMs: number;
|
|
368
|
+
/**
|
|
369
|
+
* A tab that requested the index page this recently is ARRIVING — booting,
|
|
370
|
+
* no worker yet. Neither auto-open nor `ensure` may open a duplicate under
|
|
371
|
+
* it. Cold Vite dep-optimize can take minutes, hence the size.
|
|
372
|
+
*/
|
|
373
|
+
readonly arrivalGraceMs: number;
|
|
374
|
+
/**
|
|
375
|
+
* Beats fresh, census this old — the page's MAIN THREAD is not sampling, and
|
|
376
|
+
* {@link tabState} says `hung`.
|
|
377
|
+
*
|
|
378
|
+
* SIZED FROM THE CENSUS CADENCE, with the same margin the beat grace uses.
|
|
379
|
+
* The page samples every `TAB_CENSUS_INTERVAL_MS` = 5s
|
|
380
|
+
* (`packages/editor/src/tab-census.ts`), and `graceMs` is 3 × the 1s beat
|
|
381
|
+
* interval — so this is 3 × 5s. Two skipped samples are weather (a beat that
|
|
382
|
+
* neither transport could carry drops the sample it was carrying, and the
|
|
383
|
+
* next is 5s behind it); three is a thread that has stopped running timers.
|
|
384
|
+
*
|
|
385
|
+
* ONLY FOR A VISIBLE TAB, and {@link tabState} enforces that rather than
|
|
386
|
+
* softening the number: a hidden tab is deliberately not sampled at all, so
|
|
387
|
+
* its census age is a fact about the tab being backgrounded and says nothing
|
|
388
|
+
* about its main thread.
|
|
389
|
+
*/
|
|
390
|
+
readonly hungAfterMs: number;
|
|
391
|
+
/**
|
|
392
|
+
* How long a DEPARTED tab's record is kept so the session can still say what
|
|
393
|
+
* happened to it. `closed` and `crashed` are verdicts about a tab that is no
|
|
394
|
+
* longer present, so without a memory the table answers them with silence —
|
|
395
|
+
* which is the 2026-09-17 defect exactly. Bounded because it is a memory,
|
|
396
|
+
* not a log; the session journal is the archive.
|
|
397
|
+
*/
|
|
398
|
+
readonly departedMemoryMs: number;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export const DEFAULT_TAB_PRESENCE_CONFIG: TabPresenceConfig = {
|
|
402
|
+
beatIntervalMs: 1_000,
|
|
403
|
+
graceMs: 3_000,
|
|
404
|
+
hiddenGraceMs: 30_000,
|
|
405
|
+
gapNoticeMs: 2_000,
|
|
406
|
+
probeAfterMs: 2_000,
|
|
407
|
+
channelBudgetMs: 30_000,
|
|
408
|
+
listenerBudgetMs: 120_000,
|
|
409
|
+
blessDwellMs: 2_000,
|
|
410
|
+
arrivalGraceMs: 120_000,
|
|
411
|
+
hungAfterMs: 15_000,
|
|
412
|
+
departedMemoryMs: 120_000,
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
/** How many departed records the table keeps, whatever
|
|
416
|
+
* {@link TabPresenceConfig.departedMemoryMs} says. A session has a handful of
|
|
417
|
+
* page-loads; this is an order of magnitude past that. */
|
|
418
|
+
export const DEPARTED_TAB_MEMORY = 8;
|
|
419
|
+
|
|
420
|
+
export interface TabPresenceState {
|
|
421
|
+
readonly tabs: ReadonlyMap<string, TabRecord>;
|
|
422
|
+
/**
|
|
423
|
+
* TABS THAT ARE GONE, newest departure last — the table's short memory.
|
|
424
|
+
*
|
|
425
|
+
* A departed tab used to be deleted outright, so the one question an
|
|
426
|
+
* operator actually asks ("what happened to it?") had no answer anywhere:
|
|
427
|
+
* `closed` and `crashed` are both verdicts about a tab that is no longer
|
|
428
|
+
* present. Kept SEPARATE from `tabs` deliberately — every decision above
|
|
429
|
+
* reads `tabs`, and a dead record among the live ones would have to be
|
|
430
|
+
* filtered out at each of them. Bounded by
|
|
431
|
+
* {@link TabPresenceConfig.departedMemoryMs} and {@link DEPARTED_TAB_MEMORY}.
|
|
432
|
+
*/
|
|
433
|
+
readonly departed: ReadonlyMap<string, TabRecord>;
|
|
434
|
+
/** The one tab commands and lifecycle events address. */
|
|
435
|
+
readonly blessedTabId: string | null;
|
|
436
|
+
/** When the blessing last changed — the dwell clock for `blessDwellMs`. */
|
|
437
|
+
readonly blessedAt: number | null;
|
|
438
|
+
/** Since when the table has had ZERO present tabs; null while any is present. */
|
|
439
|
+
readonly absentSince: number | null;
|
|
440
|
+
/** True once any tab has ever been present. Auto-open only REPLACES. */
|
|
441
|
+
readonly everPresent: boolean;
|
|
442
|
+
/**
|
|
443
|
+
* Auto-open has given up and said so. Set once the budget is spent with
|
|
444
|
+
* still nothing in the table, cleared the moment any tab appears (or a
|
|
445
|
+
* fresh `vgai edit` mandate arrives).
|
|
446
|
+
*/
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export function initialTabPresenceState(): TabPresenceState {
|
|
450
|
+
return {
|
|
451
|
+
tabs: new Map(),
|
|
452
|
+
departed: new Map(),
|
|
453
|
+
blessedTabId: null,
|
|
454
|
+
blessedAt: null,
|
|
455
|
+
absentSince: null,
|
|
456
|
+
everPresent: false,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** The grace this tab gets before it counts as departed. */
|
|
461
|
+
function graceFor(tab: TabRecord, config: TabPresenceConfig): number {
|
|
462
|
+
return tab.visibility === 'hidden' ? config.hiddenGraceMs : config.graceMs;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Is this tab PRESENT?
|
|
467
|
+
*
|
|
468
|
+
* A UNION, deliberately: a fresh beat OR a live control channel. Both are
|
|
469
|
+
* POSITIVE evidence that a tab exists; neither absence is proof on its own,
|
|
470
|
+
* and requiring both would manufacture exactly the false absences this model
|
|
471
|
+
* replaces (a tab mid-reload has no channel for a moment; a tab whose worker
|
|
472
|
+
* was never allowed to start has no beats at all — the share tunnel bridges
|
|
473
|
+
* the event stream one way, so a bridged tab can never beat).
|
|
474
|
+
*
|
|
475
|
+
* What the heartbeat buys, then, is that it ADDS presence and never removes
|
|
476
|
+
* it: a tab whose page socket is down — mid-reload, main thread blocked by a
|
|
477
|
+
* cold module graph, control POSTs starved in the browser's connection pool —
|
|
478
|
+
* is still visibly here, which is the whole failure this replaces. A gap in
|
|
479
|
+
* the beats of a tab that is otherwise connected does not evict it; it gets
|
|
480
|
+
* journaled loudly and shows up in `vgai status` as `lastBeatAgo`, which is
|
|
481
|
+
* the owner's "if they DO get disconnected, make it clear".
|
|
482
|
+
*/
|
|
483
|
+
export function tabPresent(tab: TabRecord, now: number, config: TabPresenceConfig): boolean {
|
|
484
|
+
if (tab.connected) return true;
|
|
485
|
+
if (!tab.beatEver) return false;
|
|
486
|
+
return now - tab.lastBeatAt < graceFor(tab, config);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Every present tab, oldest first (the tiebreak bless order). */
|
|
490
|
+
export function presentTabs(
|
|
491
|
+
state: TabPresenceState,
|
|
492
|
+
now: number,
|
|
493
|
+
config: TabPresenceConfig,
|
|
494
|
+
): TabRecord[] {
|
|
495
|
+
return [...state.tabs.values()]
|
|
496
|
+
.filter((tab) => tabPresent(tab, now, config))
|
|
497
|
+
.sort((a, b) => a.firstSeenAt - b.firstSeenAt || (a.tabId < b.tabId ? -1 : 1));
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** Should the server probe this tab's worker rather than wait out the grace? */
|
|
501
|
+
export function tabNeedsProbe(tab: TabRecord, now: number, config: TabPresenceConfig): boolean {
|
|
502
|
+
if (!tab.beatEver) return false;
|
|
503
|
+
const age = now - tab.lastBeatAt;
|
|
504
|
+
return age >= config.probeAfterMs && age < graceFor(tab, config);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function blankTab(tabId: string, now: number): TabRecord {
|
|
508
|
+
return {
|
|
509
|
+
tabId,
|
|
510
|
+
firstSeenAt: now,
|
|
511
|
+
lastBeatAt: 0,
|
|
512
|
+
beatEver: false,
|
|
513
|
+
epoch: null,
|
|
514
|
+
epochStartedAt: 0,
|
|
515
|
+
epochCount: 0,
|
|
516
|
+
epochs: [],
|
|
517
|
+
visibility: 'visible',
|
|
518
|
+
route: 'unknown',
|
|
519
|
+
surface: 'editor',
|
|
520
|
+
connected: false,
|
|
521
|
+
channelThisEpoch: false,
|
|
522
|
+
listenerThisEpoch: false,
|
|
523
|
+
listenerEpochs: [],
|
|
524
|
+
unresponsiveNoticed: false,
|
|
525
|
+
gapSince: null,
|
|
526
|
+
gapNoticed: false,
|
|
527
|
+
census: [],
|
|
528
|
+
closedEpoch: null,
|
|
529
|
+
closedAt: null,
|
|
530
|
+
closedPersisted: false,
|
|
531
|
+
closedReason: null,
|
|
532
|
+
lastGapMs: null,
|
|
533
|
+
lastGapEndedAt: null,
|
|
534
|
+
commandTimeoutAt: null,
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* File a fresh page sample against a tab's history, newest last.
|
|
540
|
+
*
|
|
541
|
+
* A plain append, because every arrival IS a fresh sample — the worker sends a
|
|
542
|
+
* profile once and drops it (see {@link TabCensusSample}). A beat with no
|
|
543
|
+
* census leaves the history untouched: silence is not a profile, and a hidden
|
|
544
|
+
* tab is silent for exactly as long as it is hidden.
|
|
545
|
+
*
|
|
546
|
+
* Two identical-looking samples five seconds apart are kept as two, and that
|
|
547
|
+
* is the point: they differ in the field the death line reads, `at`.
|
|
548
|
+
*/
|
|
549
|
+
function fileCensus(
|
|
550
|
+
history: readonly TabCensusSample[],
|
|
551
|
+
census: TabCensus | undefined,
|
|
552
|
+
now: number,
|
|
553
|
+
): readonly TabCensusSample[] {
|
|
554
|
+
if (census === undefined) return history;
|
|
555
|
+
return [...history, { ...census, at: now }].slice(-TAB_CENSUS_HISTORY);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* A tab whose PAGE never came up this page-load, past the budget for the
|
|
560
|
+
* stage it is stuck at.
|
|
561
|
+
*
|
|
562
|
+
* This is the zombie: everything the server can see about the tab is fine and
|
|
563
|
+
* the document is not. It is present (the tab really is open, and saying
|
|
564
|
+
* otherwise would be a lie a user can see through), it is passed over for
|
|
565
|
+
* blessing so a healthy sibling wins, and when it is the only tab the refusal
|
|
566
|
+
* names this exact fact instead of holding a command for a page that will
|
|
567
|
+
* never take it.
|
|
568
|
+
*
|
|
569
|
+
* TWO STAGES, because there are two things a live tab can fail to do and each
|
|
570
|
+
* has its own evidence and its own budget:
|
|
571
|
+
*
|
|
572
|
+
* 1. NO CHANNEL. The worker beats and the page never opened a control
|
|
573
|
+
* connection. `channelBudgetMs` — short, because the connection is opened
|
|
574
|
+
* by the inline bootstrap before any module loads, so a page that has not
|
|
575
|
+
* opened one in half a minute never will.
|
|
576
|
+
* 2. NO LISTENER. The channel is open and the module graph behind it never
|
|
577
|
+
* produced a command listener. `listenerBudgetMs` — long, because this
|
|
578
|
+
* stage is waiting on the whole editor app.
|
|
579
|
+
*
|
|
580
|
+
* Stage 2 exempts a tab that says it is on the LAUNCHER (`route:
|
|
581
|
+
* 'no-project'` — the hub, or the startup-failure surface): those surfaces
|
|
582
|
+
* legitimately run no project command listener, and they are healthy targets
|
|
583
|
+
* for `ensure`'s adopt path, not zombies. A tab whose document never ran says
|
|
584
|
+
* nothing at all and stays `'unknown'`, which is exactly the case stage 2 is
|
|
585
|
+
* for.
|
|
586
|
+
*/
|
|
587
|
+
export function tabUnresponsive(tab: TabRecord, now: number, config: TabPresenceConfig): boolean {
|
|
588
|
+
const epochStartedAt = tab.epochStartedAt || tab.firstSeenAt;
|
|
589
|
+
if (!tab.connected && !tab.channelThisEpoch) {
|
|
590
|
+
if (!tab.beatEver) return false;
|
|
591
|
+
return now - epochStartedAt >= config.channelBudgetMs;
|
|
592
|
+
}
|
|
593
|
+
if (tab.listenerThisEpoch) return false;
|
|
594
|
+
if (tab.route === 'no-project') return false;
|
|
595
|
+
return now - epochStartedAt >= config.listenerBudgetMs;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** Which stage {@link tabUnresponsive} is reporting — the word a journal line
|
|
599
|
+
* and a refusal both need, derived from the same record rather than restated. */
|
|
600
|
+
export function tabUnresponsiveReason(tab: TabRecord): 'no-channel' | 'no-command-listener' {
|
|
601
|
+
return !tab.connected && !tab.channelThisEpoch ? 'no-channel' : 'no-command-listener';
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function replaceTab(state: TabPresenceState, tab: TabRecord): TabPresenceState {
|
|
605
|
+
const tabs = new Map(state.tabs);
|
|
606
|
+
tabs.set(tab.tabId, tab);
|
|
607
|
+
// A tab that is live again is not a memory. Dropping it here rather than at
|
|
608
|
+
// each caller is why this is the ONE function that writes the table.
|
|
609
|
+
if (!state.departed.has(tab.tabId)) return { ...state, tabs };
|
|
610
|
+
const departed = new Map(state.departed);
|
|
611
|
+
departed.delete(tab.tabId);
|
|
612
|
+
return { ...state, tabs, departed };
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** An epoch is "live" while it has beaten within the duplicate-detection window. */
|
|
616
|
+
function liveEpochs(
|
|
617
|
+
epochs: readonly EpochRecord[],
|
|
618
|
+
now: number,
|
|
619
|
+
config: TabPresenceConfig,
|
|
620
|
+
): EpochRecord[] {
|
|
621
|
+
return epochs.filter((e) => now - e.lastBeatAt < config.beatIntervalMs * 3);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
export interface BeatOutcome {
|
|
625
|
+
readonly state: TabPresenceState;
|
|
626
|
+
readonly events: SessionJournalEvent[];
|
|
627
|
+
/**
|
|
628
|
+
* `'re-mint'` when this beat came from a tab whose sessionStorage was
|
|
629
|
+
* COPIED (Chrome's "Duplicate Tab"): two epochs are beating under one
|
|
630
|
+
* tabId, and the younger one is told to mint a fresh tabId.
|
|
631
|
+
*/
|
|
632
|
+
readonly reply: 're-mint' | null;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Record one heartbeat.
|
|
637
|
+
*
|
|
638
|
+
* Reload vs duplicate is decided by one measurement, not a timer: a dedicated
|
|
639
|
+
* Worker dies with its page, so after a RELOAD the old epoch never beats
|
|
640
|
+
* again — its last beat necessarily predates the new epoch's first. Two
|
|
641
|
+
* epochs whose beats INTERLEAVE can only be two live pages, which is the
|
|
642
|
+
* duplicate. The re-mint goes to the younger epoch (the beat that observed
|
|
643
|
+
* the interleave), so the original tab keeps its identity.
|
|
644
|
+
*/
|
|
645
|
+
export function recordBeat(
|
|
646
|
+
state: TabPresenceState,
|
|
647
|
+
beat: TabBeat,
|
|
648
|
+
now: number,
|
|
649
|
+
config: TabPresenceConfig = DEFAULT_TAB_PRESENCE_CONFIG,
|
|
650
|
+
): BeatOutcome {
|
|
651
|
+
const events: SessionJournalEvent[] = [];
|
|
652
|
+
// A beat from a tab the table has already DEPARTED is a RETURN, not a new
|
|
653
|
+
// tab: a machine that went to sleep froze the server's tick and the page's
|
|
654
|
+
// worker together, and on wake the beat can easily land after the sweep. Its
|
|
655
|
+
// old record is what makes that readable — resurrect it, and the gap it
|
|
656
|
+
// carries is what `tabState` reads as `suspended` rather than as a death.
|
|
657
|
+
// (A CLOSED tab cannot come back this way: `sessionStorage` died with it, so
|
|
658
|
+
// a genuinely new tab arrives under a new tabId.)
|
|
659
|
+
const existing = state.tabs.get(beat.tabId) ?? state.departed.get(beat.tabId);
|
|
660
|
+
const base = existing ?? blankTab(beat.tabId, now);
|
|
661
|
+
const tabId8 = beat.tabId.slice(0, 8);
|
|
662
|
+
|
|
663
|
+
const others = liveEpochs(base.epochs, now, config).filter((e) => e.epoch !== beat.epoch);
|
|
664
|
+
const mine = base.epochs.find((e) => e.epoch === beat.epoch);
|
|
665
|
+
const myFirstSeenAt = mine?.firstSeenAt ?? now;
|
|
666
|
+
|
|
667
|
+
// Two live pages under one tabId: another epoch has beaten AT OR AFTER this
|
|
668
|
+
// one first appeared, so they overlap in time. Only the YOUNGEST re-mints.
|
|
669
|
+
const rival = others.find((e) => e.lastBeatAt >= myFirstSeenAt);
|
|
670
|
+
const duplicated = rival !== undefined && others.every((e) => e.firstSeenAt <= myFirstSeenAt);
|
|
671
|
+
if (duplicated) {
|
|
672
|
+
events.push({ kind: 'tab-duplicated', tabId8 });
|
|
673
|
+
// The record is NOT advanced by an impostor beat: the original tab owns
|
|
674
|
+
// this tabId, and the duplicate is about to re-mint and come back as a
|
|
675
|
+
// tab of its own.
|
|
676
|
+
return { state, events, reply: 're-mint' };
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const epochs: EpochRecord[] = [
|
|
680
|
+
...others,
|
|
681
|
+
{ epoch: beat.epoch, firstSeenAt: myFirstSeenAt, lastBeatAt: now },
|
|
682
|
+
];
|
|
683
|
+
// The beat's own epoch IS the tab's current page-load. (Reading the OLDEST
|
|
684
|
+
// live epoch instead looks defensible and is not: a reload's dead epoch
|
|
685
|
+
// stays "live" for the pruning window, so the reload went unnoticed for
|
|
686
|
+
// three seconds and the new page-load inherited the old one's channel
|
|
687
|
+
// credit — long enough for the zombie check to be answering about a page
|
|
688
|
+
// that no longer existed.)
|
|
689
|
+
const epochChanged = base.epoch !== null && base.epoch !== beat.epoch;
|
|
690
|
+
|
|
691
|
+
if (!existing) {
|
|
692
|
+
events.push({ kind: 'tab-appeared', tabId8, visibility: beat.visibility });
|
|
693
|
+
} else if (existing.beatEver && epochChanged) {
|
|
694
|
+
// A duplicate's FIRST beat is indistinguishable from a reload and is
|
|
695
|
+
// reported as one; its second beat interleaves with the original's and
|
|
696
|
+
// lands on the `tab-duplicated` branch above. One arguable line, followed
|
|
697
|
+
// immediately by the correction — better than withholding the reload
|
|
698
|
+
// report that is right in every other case.
|
|
699
|
+
events.push({ kind: 'tab-reloaded', tabId8, epochCount: existing.epochCount + 1 });
|
|
700
|
+
}
|
|
701
|
+
if (existing?.gapSince !== null && existing?.gapNoticed === true) {
|
|
702
|
+
events.push({ kind: 'tab-gap-closed', tabId8, gapMs: now - existing.gapSince });
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// THE GAP THIS BEAT JUST CLOSED. Recorded here and not in `sweepPresence`
|
|
706
|
+
// because a gap longer than the grace normally ends in a DEPARTURE — the
|
|
707
|
+
// only way one gets closed instead is a tab that came back, which is the
|
|
708
|
+
// suspend `tabState` has to tell apart from a death.
|
|
709
|
+
const gapMs = base.beatEver ? now - base.lastBeatAt : 0;
|
|
710
|
+
const closedGap = gapMs >= graceFor(base, config);
|
|
711
|
+
// A beat from the epoch that said goodbye means the page came BACK — the
|
|
712
|
+
// back/forward cache restored it. The goodbye is no longer true of it.
|
|
713
|
+
const sameEpochReturned = base.closedEpoch !== null && base.closedEpoch === beat.epoch;
|
|
714
|
+
|
|
715
|
+
const tab: TabRecord = {
|
|
716
|
+
...base,
|
|
717
|
+
lastBeatAt: now,
|
|
718
|
+
beatEver: true,
|
|
719
|
+
epoch: beat.epoch,
|
|
720
|
+
epochStartedAt: myFirstSeenAt,
|
|
721
|
+
epochCount: base.epochCount + (epochChanged || base.epochCount === 0 ? 1 : 0),
|
|
722
|
+
epochs,
|
|
723
|
+
visibility: beat.visibility,
|
|
724
|
+
// A new page-load starts owing a channel again — and gets a fresh chance
|
|
725
|
+
// to be called unresponsive, or not.
|
|
726
|
+
channelThisEpoch: epochChanged ? base.connected : base.channelThisEpoch,
|
|
727
|
+
// A listener belongs to the page-load that reported it and to no other:
|
|
728
|
+
// a reload has to load the module graph again, so the new epoch owes its
|
|
729
|
+
// own listener and its own budget starts here.
|
|
730
|
+
listenerThisEpoch: base.listenerEpochs.includes(beat.epoch),
|
|
731
|
+
unresponsiveNoticed: epochChanged ? false : base.unresponsiveNoticed,
|
|
732
|
+
gapSince: null,
|
|
733
|
+
gapNoticed: false,
|
|
734
|
+
// A reload does NOT clear the history: the profile of the page that just
|
|
735
|
+
// went away is the whole point of keeping one, and a renderer death is
|
|
736
|
+
// followed by exactly such a reload.
|
|
737
|
+
census: fileCensus(base.census, beat.census, now),
|
|
738
|
+
...(sameEpochReturned
|
|
739
|
+
? { closedEpoch: null, closedAt: null, closedPersisted: false, closedReason: null }
|
|
740
|
+
: {}),
|
|
741
|
+
...(closedGap ? { lastGapMs: gapMs, lastGapEndedAt: now } : {}),
|
|
742
|
+
// A beat is not an answer to a command, so it does not clear a timeout:
|
|
743
|
+
// that is `noteTabCommandOutcome`'s job, and conflating them is how the
|
|
744
|
+
// worker's liveness would end up vouching for the page's again. A NEW
|
|
745
|
+
// page-load owes nothing to the old one's commands, though.
|
|
746
|
+
...(epochChanged ? { commandTimeoutAt: null } : {}),
|
|
747
|
+
};
|
|
748
|
+
const next = replaceTab(state, tab);
|
|
749
|
+
return {
|
|
750
|
+
state: next,
|
|
751
|
+
events,
|
|
752
|
+
reply: null,
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Record a page's goodbye ({@link TabCloseBeacon}).
|
|
758
|
+
*
|
|
759
|
+
* Filed against whichever half of the table holds the tab — a beacon can
|
|
760
|
+
* easily land after the sweep has already moved the record into the departed
|
|
761
|
+
* memory (a close is exactly the event that ends the beats), and a verdict
|
|
762
|
+
* that can only be filed on a live record would miss the case it exists for.
|
|
763
|
+
*
|
|
764
|
+
* A beacon for a tab the server has never seen is DROPPED. There is no record
|
|
765
|
+
* to describe and nothing to say about one; inventing a blank record here
|
|
766
|
+
* would put a tab in the memory that was never in the table.
|
|
767
|
+
*/
|
|
768
|
+
export function recordTabClose(
|
|
769
|
+
state: TabPresenceState,
|
|
770
|
+
beacon: TabCloseBeacon,
|
|
771
|
+
now: number,
|
|
772
|
+
): TabPresenceState {
|
|
773
|
+
const closed = {
|
|
774
|
+
closedEpoch: beacon.epoch,
|
|
775
|
+
closedAt: now,
|
|
776
|
+
closedPersisted: beacon.persisted,
|
|
777
|
+
closedReason: beacon.reason,
|
|
778
|
+
};
|
|
779
|
+
const live = state.tabs.get(beacon.tabId);
|
|
780
|
+
if (live !== undefined) return replaceTab(state, { ...live, ...closed });
|
|
781
|
+
const gone = state.departed.get(beacon.tabId);
|
|
782
|
+
if (gone === undefined) return state;
|
|
783
|
+
const departed = new Map(state.departed);
|
|
784
|
+
departed.set(beacon.tabId, { ...gone, ...closed });
|
|
785
|
+
return { ...state, departed };
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* Has this tab acknowledged the session's end?
|
|
790
|
+
*
|
|
791
|
+
* Reads BOTH halves of the table, for {@link recordTabClose}'s reason: the ack
|
|
792
|
+
* is the last thing a page does, so its record can already have been swept
|
|
793
|
+
* into the departed memory by the time the question is asked.
|
|
794
|
+
*/
|
|
795
|
+
export function tabEndAcknowledged(state: TabPresenceState, tabId: string): boolean {
|
|
796
|
+
const tab = state.tabs.get(tabId) ?? state.departed.get(tabId);
|
|
797
|
+
return tab?.closedReason === 'session-ended';
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* How a relayed command against this tab ENDED — the server's own half of the
|
|
802
|
+
* `hung` verdict (see {@link TabRecord.commandTimeoutAt}).
|
|
803
|
+
*
|
|
804
|
+
* Latches on a timeout and clears on any answer, which is the honest pair:
|
|
805
|
+
* one expired command is evidence the page was not running, and the next
|
|
806
|
+
* answered one is proof it is. Nothing else clears it — a fresh beat is the
|
|
807
|
+
* WORKER speaking, and the whole point of this field is that the worker cannot
|
|
808
|
+
* vouch for the page.
|
|
809
|
+
*/
|
|
810
|
+
export function noteTabCommandOutcome(
|
|
811
|
+
state: TabPresenceState,
|
|
812
|
+
tabId: string,
|
|
813
|
+
now: number,
|
|
814
|
+
outcome: 'timed-out' | 'answered',
|
|
815
|
+
): TabPresenceState {
|
|
816
|
+
const tab = state.tabs.get(tabId);
|
|
817
|
+
if (tab === undefined) return state;
|
|
818
|
+
const commandTimeoutAt = outcome === 'timed-out' ? now : null;
|
|
819
|
+
if (tab.commandTimeoutAt === commandTimeoutAt) return state;
|
|
820
|
+
if (outcome === 'timed-out' && tab.commandTimeoutAt !== null) return state;
|
|
821
|
+
return replaceTab(state, { ...tab, commandTimeoutAt });
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* A tab's CONTROL channel opened or closed. This is a hint, never a verdict:
|
|
826
|
+
* it updates one boolean on the record and schedules a reconcile. Nothing
|
|
827
|
+
* here opens, closes, or blesses a tab.
|
|
828
|
+
*/
|
|
829
|
+
export function setTabConnected(
|
|
830
|
+
state: TabPresenceState,
|
|
831
|
+
tabId: string,
|
|
832
|
+
connected: boolean,
|
|
833
|
+
now: number,
|
|
834
|
+
): TabPresenceState {
|
|
835
|
+
const existing = state.tabs.get(tabId);
|
|
836
|
+
if (existing === undefined) {
|
|
837
|
+
if (!connected) return state;
|
|
838
|
+
const fresh = replaceTab(state, {
|
|
839
|
+
...blankTab(tabId, now),
|
|
840
|
+
connected: true,
|
|
841
|
+
channelThisEpoch: true,
|
|
842
|
+
});
|
|
843
|
+
return fresh;
|
|
844
|
+
}
|
|
845
|
+
if (existing.connected === connected) return state;
|
|
846
|
+
return replaceTab(state, {
|
|
847
|
+
...existing,
|
|
848
|
+
connected,
|
|
849
|
+
// Opening a channel is the proof this page-load is alive; losing one is
|
|
850
|
+
// not proof of the opposite, so the flag only ever latches ON.
|
|
851
|
+
channelThisEpoch: existing.channelThisEpoch || connected,
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* This page-load reported its COMMAND LISTENER attached.
|
|
857
|
+
*
|
|
858
|
+
* Latches ON for the epoch, for the same reason `channelThisEpoch` does:
|
|
859
|
+
* attaching is proof the document is running, while the detach report that
|
|
860
|
+
* `connectCommandListener`'s cleanup sends (an unmount, an HMR swap) is not
|
|
861
|
+
* proof of the opposite. The listener and heartbeat travel independently:
|
|
862
|
+
* retain the reporting page's identity even if its first beat has not arrived.
|
|
863
|
+
* Never credit whichever page happens to own the tab table at report time.
|
|
864
|
+
*/
|
|
865
|
+
export function setTabListener(
|
|
866
|
+
state: TabPresenceState,
|
|
867
|
+
tabId: string,
|
|
868
|
+
now: number,
|
|
869
|
+
epoch: string,
|
|
870
|
+
): TabPresenceState {
|
|
871
|
+
const existing = state.tabs.get(tabId) ?? blankTab(tabId, now);
|
|
872
|
+
if (existing.listenerEpochs.includes(epoch)) return state;
|
|
873
|
+
// Reloads must not grow the tab record forever. Keep the current page's
|
|
874
|
+
// credit plus recent reports that may be racing a heartbeat or a duplicate.
|
|
875
|
+
const recent = existing.listenerEpochs.filter((value) => value !== existing.epoch);
|
|
876
|
+
const listenerEpochs = [
|
|
877
|
+
...(existing.epoch !== null && existing.listenerThisEpoch ? [existing.epoch] : []),
|
|
878
|
+
...recent.slice(-7),
|
|
879
|
+
epoch,
|
|
880
|
+
];
|
|
881
|
+
return replaceTab(state, {
|
|
882
|
+
...existing,
|
|
883
|
+
listenerEpochs,
|
|
884
|
+
listenerThisEpoch:
|
|
885
|
+
existing.listenerThisEpoch || existing.epoch === null || existing.epoch === epoch,
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* What kind of page this tab is — declared by the bootstrap, or observed from
|
|
891
|
+
* the frame's own origin. Never DOWNGRADED to `'editor'`: the two sources
|
|
892
|
+
* arrive on different connections of one tab, and the narrower reading is the
|
|
893
|
+
* one that knows something.
|
|
894
|
+
*/
|
|
895
|
+
export function setTabSurface(
|
|
896
|
+
state: TabPresenceState,
|
|
897
|
+
tabId: string,
|
|
898
|
+
surface: TabSurface,
|
|
899
|
+
): TabPresenceState {
|
|
900
|
+
const existing = state.tabs.get(tabId);
|
|
901
|
+
if (existing === undefined || existing.surface === surface) return state;
|
|
902
|
+
if (surface === 'editor') return state;
|
|
903
|
+
return replaceTab(state, { ...existing, surface });
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/** The tab said which surface it is showing. */
|
|
907
|
+
export function setTabRoute(
|
|
908
|
+
state: TabPresenceState,
|
|
909
|
+
tabId: string,
|
|
910
|
+
route: TabRoute,
|
|
911
|
+
): TabPresenceState {
|
|
912
|
+
const existing = state.tabs.get(tabId);
|
|
913
|
+
if (existing === undefined || existing.route === route) return state;
|
|
914
|
+
return replaceTab(state, { ...existing, route });
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
export interface ReconcileInput {
|
|
918
|
+
readonly now: number;
|
|
919
|
+
/** Last time this server served its index page — an ARRIVING tab. */
|
|
920
|
+
readonly lastIndexRequestAt: number | null;
|
|
921
|
+
/** False for a session that must never open a browser (--no-open, remote). */
|
|
922
|
+
readonly mayOpen: boolean;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
export interface ReconcileResult {
|
|
926
|
+
readonly state: TabPresenceState;
|
|
927
|
+
/** The blessed tab AFTER reconciliation (null when no tab is present). */
|
|
928
|
+
readonly blessedTabId: string | null;
|
|
929
|
+
/** Present tabs that are not blessed — each is told to yield. */
|
|
930
|
+
readonly yieldTabIds: string[];
|
|
931
|
+
/** Open exactly one tab: the table says there are none. */
|
|
932
|
+
readonly events: SessionJournalEvent[];
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* THE ONE FUNCTION THAT DECIDES ANYTHING.
|
|
937
|
+
*
|
|
938
|
+
* Socket events never open, close or bless a tab — they only schedule a call
|
|
939
|
+
* to this. That inversion is the reason the old model could not be repaired
|
|
940
|
+
* in place: its decisions were spread across a connect handler, a disconnect
|
|
941
|
+
* handler, a route handler and a self-heal tick, each holding a fragment of
|
|
942
|
+
* the truth and racing the others. Here the whole truth is the table, read
|
|
943
|
+
* once, and everything downstream is derived from it.
|
|
944
|
+
*
|
|
945
|
+
* Blessing is STICKY: the current blessed tab keeps the blessing while it
|
|
946
|
+
* remains present, so an ordinary reload (same tabId, new epoch) does not
|
|
947
|
+
* hand the session to a different tab. Otherwise the oldest present tab wins,
|
|
948
|
+
* which is stable under any arrival order.
|
|
949
|
+
*/
|
|
950
|
+
/**
|
|
951
|
+
* Step 1 — gaps and departures, in TAB vocabulary.
|
|
952
|
+
*
|
|
953
|
+
* `lastBeatAt` only ever moves forward, so "age >= grace" IS "continuously
|
|
954
|
+
* absent for the whole grace window": there is no partial-credit state a beat
|
|
955
|
+
* near the boundary could oscillate across. Reappearance, by contrast, is
|
|
956
|
+
* instant — a beat is proof, and making a returning tab serve a probation
|
|
957
|
+
* would be inventing an absence.
|
|
958
|
+
*/
|
|
959
|
+
function sweepPresence(
|
|
960
|
+
state: TabPresenceState,
|
|
961
|
+
now: number,
|
|
962
|
+
config: TabPresenceConfig,
|
|
963
|
+
events: SessionJournalEvent[],
|
|
964
|
+
): TabPresenceState {
|
|
965
|
+
const tabs = new Map(state.tabs);
|
|
966
|
+
const departed = new Map(state.departed);
|
|
967
|
+
for (const tab of [...tabs.values()]) {
|
|
968
|
+
const tabId8 = tab.tabId.slice(0, 8);
|
|
969
|
+
if (!tabPresent(tab, now, config)) {
|
|
970
|
+
events.push({
|
|
971
|
+
kind: 'tab-departed',
|
|
972
|
+
tabId8,
|
|
973
|
+
absentMs: tab.beatEver ? now - tab.lastBeatAt : 0,
|
|
974
|
+
});
|
|
975
|
+
tabs.delete(tab.tabId);
|
|
976
|
+
departed.set(tab.tabId, tab);
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
979
|
+
if (!tab.beatEver) continue;
|
|
980
|
+
const age = now - tab.lastBeatAt;
|
|
981
|
+
if (age < config.gapNoticeMs) continue;
|
|
982
|
+
if (!tab.gapNoticed) events.push({ kind: 'tab-heartbeat-gap', tabId8, sinceMs: age });
|
|
983
|
+
tabs.set(tab.tabId, { ...tab, gapSince: tab.gapSince ?? tab.lastBeatAt, gapNoticed: true });
|
|
984
|
+
}
|
|
985
|
+
return { ...state, tabs, departed: pruneDeparted(departed, now, config) };
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/** The departed memory, bounded by age and by count — a memory, not a log. */
|
|
989
|
+
function pruneDeparted(
|
|
990
|
+
departed: Map<string, TabRecord>,
|
|
991
|
+
now: number,
|
|
992
|
+
config: TabPresenceConfig,
|
|
993
|
+
): Map<string, TabRecord> {
|
|
994
|
+
for (const [tabId, tab] of departed) {
|
|
995
|
+
// A tab that never beat has no departure age of its own; the tab's own
|
|
996
|
+
// first-seen stamp is the only clock it has.
|
|
997
|
+
const goneAt = tab.beatEver ? tab.lastBeatAt : tab.firstSeenAt;
|
|
998
|
+
if (now - goneAt >= config.departedMemoryMs) departed.delete(tabId);
|
|
999
|
+
}
|
|
1000
|
+
while (departed.size > DEPARTED_TAB_MEMORY) {
|
|
1001
|
+
const oldest = departed.keys().next();
|
|
1002
|
+
if (oldest.done === true) break;
|
|
1003
|
+
departed.delete(oldest.value);
|
|
1004
|
+
}
|
|
1005
|
+
return departed;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/** Step 2 — the zombies: present and beating, but their page never woke up. */
|
|
1009
|
+
function partitionEligible(
|
|
1010
|
+
state: TabPresenceState,
|
|
1011
|
+
present: readonly TabRecord[],
|
|
1012
|
+
now: number,
|
|
1013
|
+
config: TabPresenceConfig,
|
|
1014
|
+
events: SessionJournalEvent[],
|
|
1015
|
+
): { state: TabPresenceState; eligible: TabRecord[] } {
|
|
1016
|
+
const tabs = new Map(state.tabs);
|
|
1017
|
+
const eligible: TabRecord[] = [];
|
|
1018
|
+
for (const tab of present) {
|
|
1019
|
+
if (!tabUnresponsive(tab, now, config)) {
|
|
1020
|
+
eligible.push(tab);
|
|
1021
|
+
continue;
|
|
1022
|
+
}
|
|
1023
|
+
if (tab.unresponsiveNoticed) continue;
|
|
1024
|
+
events.push({
|
|
1025
|
+
kind: 'tab-unresponsive',
|
|
1026
|
+
tabId8: tab.tabId.slice(0, 8),
|
|
1027
|
+
reason: tabUnresponsiveReason(tab),
|
|
1028
|
+
unresponsiveForMs: now - (tab.epochStartedAt || tab.firstSeenAt),
|
|
1029
|
+
});
|
|
1030
|
+
tabs.set(tab.tabId, { ...tab, unresponsiveNoticed: true });
|
|
1031
|
+
}
|
|
1032
|
+
return { state: { ...state, tabs }, eligible };
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* Step 3 — exactly one blessed tab.
|
|
1037
|
+
*
|
|
1038
|
+
* Sticky while the holder stays ELIGIBLE, and no reassignment inside
|
|
1039
|
+
* `blessDwellMs` unless the holder is genuinely gone. Together those are the
|
|
1040
|
+
* anti-flap: an eligibility flip cannot ping-pong the session between two
|
|
1041
|
+
* tabs, while a real departure is repaired immediately.
|
|
1042
|
+
*/
|
|
1043
|
+
function chooseBlessed(
|
|
1044
|
+
state: TabPresenceState,
|
|
1045
|
+
present: readonly TabRecord[],
|
|
1046
|
+
eligible: readonly TabRecord[],
|
|
1047
|
+
now: number,
|
|
1048
|
+
config: TabPresenceConfig,
|
|
1049
|
+
events: SessionJournalEvent[],
|
|
1050
|
+
): TabPresenceState {
|
|
1051
|
+
let blessedTabId = state.blessedTabId;
|
|
1052
|
+
let blessedAt = state.blessedAt;
|
|
1053
|
+
const holderEligible = eligible.some((tab) => tab.tabId === blessedTabId);
|
|
1054
|
+
const holderPresent = present.some((tab) => tab.tabId === blessedTabId);
|
|
1055
|
+
const dwelling = blessedAt !== null && now - blessedAt < config.blessDwellMs;
|
|
1056
|
+
if (blessedTabId !== null && !holderEligible && !(holderPresent && dwelling)) {
|
|
1057
|
+
blessedTabId = null;
|
|
1058
|
+
}
|
|
1059
|
+
if (blessedTabId === null && eligible.length > 0) {
|
|
1060
|
+
// Oldest first, but a tab with a live channel beats one without: a healthy
|
|
1061
|
+
// sibling must win over a page that is merely mid-something.
|
|
1062
|
+
const chosen = eligible.find((tab) => tab.connected) ?? eligible[0]!;
|
|
1063
|
+
events.push({
|
|
1064
|
+
kind: 'tab-blessed',
|
|
1065
|
+
tabId8: chosen.tabId.slice(0, 8),
|
|
1066
|
+
previousTabId8: state.blessedTabId === null ? null : state.blessedTabId.slice(0, 8),
|
|
1067
|
+
reason: state.blessedTabId === null ? 'oldest' : 'sticky',
|
|
1068
|
+
});
|
|
1069
|
+
blessedTabId = chosen.tabId;
|
|
1070
|
+
blessedAt = now;
|
|
1071
|
+
}
|
|
1072
|
+
return { ...state, blessedTabId, blessedAt };
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
export function reconcile(
|
|
1076
|
+
state: TabPresenceState,
|
|
1077
|
+
input: ReconcileInput,
|
|
1078
|
+
config: TabPresenceConfig = DEFAULT_TAB_PRESENCE_CONFIG,
|
|
1079
|
+
): ReconcileResult {
|
|
1080
|
+
const { now } = input;
|
|
1081
|
+
const events: SessionJournalEvent[] = [];
|
|
1082
|
+
|
|
1083
|
+
let next = sweepPresence(state, now, config, events);
|
|
1084
|
+
|
|
1085
|
+
const present = presentTabs(next, now, config);
|
|
1086
|
+
next =
|
|
1087
|
+
present.length > 0
|
|
1088
|
+
? { ...next, absentSince: null, everPresent: true }
|
|
1089
|
+
: next.absentSince === null
|
|
1090
|
+
? { ...next, absentSince: now }
|
|
1091
|
+
: next;
|
|
1092
|
+
|
|
1093
|
+
const partition = partitionEligible(next, present, now, config, events);
|
|
1094
|
+
next = chooseBlessed(partition.state, present, partition.eligible, now, config, events);
|
|
1095
|
+
const blessedTabId = next.blessedTabId;
|
|
1096
|
+
// YIELD MEANS "ANOTHER TAB HOLDS THIS SESSION" — so there has to BE another
|
|
1097
|
+
// tab. With nothing blessed, no present tab is an extra, and every one of
|
|
1098
|
+
// them would be told to go away.
|
|
1099
|
+
//
|
|
1100
|
+
// MEASURED 2026-09-19, reproducing the wedge in WORK.md §"The editor tab can
|
|
1101
|
+
// wedge in a state `vgai edit` cannot self-heal". A page that stalls before
|
|
1102
|
+
// React mounts beats forever with no command listener; at
|
|
1103
|
+
// `listenerBudgetMs` `partitionEligible` drops it from `eligible`,
|
|
1104
|
+
// `chooseBlessed` releases the blessing, and the filter below then named the
|
|
1105
|
+
// session's ONLY tab as an extra:
|
|
1106
|
+
//
|
|
1107
|
+
// 08:59:57.381 tab-unresponsive tabId8 025db401 reason no-command-listener
|
|
1108
|
+
// 08:59:57.382 tab-yielded tabId8 025db401
|
|
1109
|
+
// 08:59:57.390 client-disconnected code 1001 ← the page closed itself
|
|
1110
|
+
// 09:00:27.418 tab-departed tabId8 025db401
|
|
1111
|
+
//
|
|
1112
|
+
// `tab-lifecycle-client.ts`'s `handleYield` is `window.close()`, falling back
|
|
1113
|
+
// to navigating away to the yield page — so the bijection destroyed the one
|
|
1114
|
+
// tab it exists to keep, and `ensure`'s heal (`tab-reload`, the one message a
|
|
1115
|
+
// page with no module graph can still act on) never got the chance: the yield
|
|
1116
|
+
// fires on the reconcile TICK, before any `vgai edit` can reach the heal.
|
|
1117
|
+
const yieldTabIds =
|
|
1118
|
+
blessedTabId === null
|
|
1119
|
+
? []
|
|
1120
|
+
: present.filter((tab) => tab.tabId !== blessedTabId).map((tab) => tab.tabId);
|
|
1121
|
+
|
|
1122
|
+
return { state: next, blessedTabId, yieldTabIds, events };
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
/**
|
|
1126
|
+
* Is a tab on its way in — index page served, app not yet beating?
|
|
1127
|
+
*
|
|
1128
|
+
* A booting tab has no worker, so the table cannot see it and would happily
|
|
1129
|
+
* open a second one on top of it. The index request is the only evidence
|
|
1130
|
+
* available in that window, and it is bounded so a load that died never
|
|
1131
|
+
* suppresses the reconciler forever.
|
|
1132
|
+
*/
|
|
1133
|
+
export function tabArriving(
|
|
1134
|
+
state: TabPresenceState,
|
|
1135
|
+
now: number,
|
|
1136
|
+
lastIndexRequestAt: number | null,
|
|
1137
|
+
config: TabPresenceConfig = DEFAULT_TAB_PRESENCE_CONFIG,
|
|
1138
|
+
): boolean {
|
|
1139
|
+
if (lastIndexRequestAt === null) return false;
|
|
1140
|
+
if (now - lastIndexRequestAt >= config.arrivalGraceMs) return false;
|
|
1141
|
+
if (state.absentSince === null) return !state.everPresent;
|
|
1142
|
+
return lastIndexRequestAt >= state.absentSince;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/**
|
|
1146
|
+
* WHAT IS TRUE OF THIS TAB, in one word — the first thing every `vgai` verb
|
|
1147
|
+
* says about a tab, and the reason this file exists in its current form.
|
|
1148
|
+
*
|
|
1149
|
+
* MEASURED 2026-09-17. One symptom — "the battery stopped" — had four causes
|
|
1150
|
+
* in one night, and the product reported all four the same way or not at all:
|
|
1151
|
+
* a renderer killed by a dev-server reload; a worker that never finished
|
|
1152
|
+
* booting; a page whose main thread was stuck in a 46 MB encode (`tab present,
|
|
1153
|
+
* last heartbeat 20.5s ago, did not respond`); and a twin call genuinely
|
|
1154
|
+
* running for 16 minutes at 21% CPU. Every one of those is a DIFFERENT
|
|
1155
|
+
* instruction to whoever is reading, and "present / did not respond" is none
|
|
1156
|
+
* of them.
|
|
1157
|
+
*
|
|
1158
|
+
* The seven words, and the evidence each is allowed to be derived from:
|
|
1159
|
+
*
|
|
1160
|
+
* - `ended` — the page ACKNOWLEDGED the session's end: it received
|
|
1161
|
+
* `tab-close`, ran every terminator behind `markSessionEnded`
|
|
1162
|
+
* (its Blender engine worker among them) and said so. The one
|
|
1163
|
+
* verdict that means nothing of this page is still running.
|
|
1164
|
+
* - `closed` — the page said goodbye and no successor is beating.
|
|
1165
|
+
* - `reloading` — the page said goodbye and a NEW page-load is already beating.
|
|
1166
|
+
* - `crashed` — the beats stopped past the grace with NO goodbye. The worker
|
|
1167
|
+
* died with its renderer; nothing got a chance to speak.
|
|
1168
|
+
* - `suspended` — the beats stopped past the grace and RESUMED under the same
|
|
1169
|
+
* epoch. A lid, a sleep, a throttle — not a death.
|
|
1170
|
+
* - `hung` — the beats are fresh and the PAGE is not running: its census
|
|
1171
|
+
* has stopped being sampled, or a command went out and never
|
|
1172
|
+
* came back. The worker is a separate thread, which is exactly
|
|
1173
|
+
* why it keeps saying the tab is fine.
|
|
1174
|
+
* - `busy` — the page is answering and a Blender call is outstanding.
|
|
1175
|
+
* - `present` — none of the above.
|
|
1176
|
+
*
|
|
1177
|
+
* THE NUMBER IS THE VERDICT. `busy` deliberately does not try to tell a hung
|
|
1178
|
+
* twin call from a long one: from outside the worker those are the same
|
|
1179
|
+
* observation, and `ms` (the in-flight age) is the whole truth available. A
|
|
1180
|
+
* reader decides; nothing here cancels, kills, restarts or budgets anything.
|
|
1181
|
+
*/
|
|
1182
|
+
export type TabState =
|
|
1183
|
+
| 'ended'
|
|
1184
|
+
| 'closed'
|
|
1185
|
+
| 'reloading'
|
|
1186
|
+
| 'crashed'
|
|
1187
|
+
| 'suspended'
|
|
1188
|
+
| 'hung'
|
|
1189
|
+
| 'busy'
|
|
1190
|
+
| 'present';
|
|
1191
|
+
|
|
1192
|
+
export interface TabStateVerdict {
|
|
1193
|
+
readonly state: TabState;
|
|
1194
|
+
/**
|
|
1195
|
+
* THE number that goes with the word, in ms — and a different measurement
|
|
1196
|
+
* per state, because each word is about a different clock: time since the
|
|
1197
|
+
* goodbye (`closed`/`reloading`), beat age (`crashed`/`present`), the gap
|
|
1198
|
+
* that was resumed (`suspended`), census age or time since the command
|
|
1199
|
+
* expired (`hung`), the outstanding call's age (`busy`). Null only for a tab
|
|
1200
|
+
* that has never beaten, which has no clock of its own at all.
|
|
1201
|
+
*/
|
|
1202
|
+
readonly ms: number | null;
|
|
1203
|
+
/** The evidence, in a sentence. Printed beside the word so a reader never
|
|
1204
|
+
* has to know which field the verdict came from. */
|
|
1205
|
+
readonly because: string;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
const seconds = (ms: number): string => `${Math.round(ms / 100) / 10}s`;
|
|
1209
|
+
|
|
1210
|
+
/**
|
|
1211
|
+
* Derive {@link TabState} — ONE function, ONE place, from the table's own
|
|
1212
|
+
* fields. Every surface that says what a tab is doing calls this; a second
|
|
1213
|
+
* derivation somewhere else is a second opinion, which is the bug this whole
|
|
1214
|
+
* file replaces.
|
|
1215
|
+
*/
|
|
1216
|
+
export function tabState(
|
|
1217
|
+
tab: TabRecord,
|
|
1218
|
+
now: number,
|
|
1219
|
+
config: TabPresenceConfig = DEFAULT_TAB_PRESENCE_CONFIG,
|
|
1220
|
+
): TabStateVerdict {
|
|
1221
|
+
const grace = graceFor(tab, config);
|
|
1222
|
+
const beatAge = tab.beatEver ? now - tab.lastBeatAt : null;
|
|
1223
|
+
const beating = beatAge !== null && beatAge < grace;
|
|
1224
|
+
|
|
1225
|
+
if (tab.closedEpoch !== null && tab.closedAt !== null) {
|
|
1226
|
+
const sinceClose = Math.max(0, now - tab.closedAt);
|
|
1227
|
+
const successor = tab.epoch !== null && tab.epoch !== tab.closedEpoch && beating;
|
|
1228
|
+
if (!successor && tab.closedReason === 'session-ended') {
|
|
1229
|
+
return {
|
|
1230
|
+
state: 'ended',
|
|
1231
|
+
ms: sinceClose,
|
|
1232
|
+
because:
|
|
1233
|
+
`its page acknowledged the session's end ${seconds(sinceClose)} ago — every ` +
|
|
1234
|
+
'terminator it holds (the Blender engine worker among them) had already run when ' +
|
|
1235
|
+
'it said so',
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
if (!successor) {
|
|
1239
|
+
return {
|
|
1240
|
+
state: 'closed',
|
|
1241
|
+
ms: sinceClose,
|
|
1242
|
+
because:
|
|
1243
|
+
`its page sent a close beacon ${seconds(sinceClose)} ago` +
|
|
1244
|
+
(tab.closedPersisted ? ' (into the back/forward cache — it may yet come back)' : '') +
|
|
1245
|
+
' and no new page-load is beating',
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1248
|
+
// A successor that has outlived the grace is no longer RELOADING; it is
|
|
1249
|
+
// just the tab, and the goodbye it replaced is history.
|
|
1250
|
+
if (sinceClose < grace) {
|
|
1251
|
+
return {
|
|
1252
|
+
state: 'reloading',
|
|
1253
|
+
ms: sinceClose,
|
|
1254
|
+
because: `its page said goodbye ${seconds(sinceClose)} ago and a new page-load is already beating`,
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
if (!tab.beatEver) {
|
|
1260
|
+
return {
|
|
1261
|
+
state: 'present',
|
|
1262
|
+
ms: null,
|
|
1263
|
+
because:
|
|
1264
|
+
tab.surface === 'vscode'
|
|
1265
|
+
? "this VS Code window has never beaten: its page never ran the session's tab " +
|
|
1266
|
+
'bootstrap (/__editor/tab-bootstrap.js), which is what starts the heartbeat ' +
|
|
1267
|
+
'worker — the frame loads it before the bridge (docs/CODE-OSS.md §Boot, ' +
|
|
1268
|
+
'DESKTOP). Its control channel is all that proves it'
|
|
1269
|
+
: 'this tab has never beaten (no heartbeat worker — a tunnelled tab is refused the ' +
|
|
1270
|
+
'script); its control channel is what proves it',
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
if (!beating) {
|
|
1275
|
+
const age = beatAge ?? 0;
|
|
1276
|
+
return {
|
|
1277
|
+
state: 'crashed',
|
|
1278
|
+
ms: age,
|
|
1279
|
+
because:
|
|
1280
|
+
`its beats stopped ${seconds(age)} ago with no close beacon — the heartbeat worker ` +
|
|
1281
|
+
'dies with its renderer, and nothing said goodbye',
|
|
1282
|
+
};
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
if (
|
|
1286
|
+
tab.lastGapMs !== null &&
|
|
1287
|
+
tab.lastGapEndedAt !== null &&
|
|
1288
|
+
tab.lastGapMs >= grace &&
|
|
1289
|
+
now - tab.lastGapEndedAt < grace
|
|
1290
|
+
) {
|
|
1291
|
+
return {
|
|
1292
|
+
state: 'suspended',
|
|
1293
|
+
ms: tab.lastGapMs,
|
|
1294
|
+
because: `its beats stopped for ${seconds(tab.lastGapMs)} and resumed under the SAME page-load — a sleep, not a death`,
|
|
1295
|
+
};
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
if (tab.commandTimeoutAt !== null) {
|
|
1299
|
+
const since = Math.max(0, now - tab.commandTimeoutAt);
|
|
1300
|
+
return {
|
|
1301
|
+
state: 'hung',
|
|
1302
|
+
ms: since,
|
|
1303
|
+
because:
|
|
1304
|
+
`a command timed out against it ${seconds(since)} ago while its beats stayed fresh — ` +
|
|
1305
|
+
'the heartbeat is a worker, so it says nothing about the page',
|
|
1306
|
+
};
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
const newest = latestCensus(tab);
|
|
1310
|
+
if (newest !== null) {
|
|
1311
|
+
const censusAge = Math.max(0, now - newest.at);
|
|
1312
|
+
// ONLY a visible tab. A hidden one is deliberately not sampled at all
|
|
1313
|
+
// (`src/tab-census.ts`), so its census age is a fact about being
|
|
1314
|
+
// backgrounded and no evidence at all about its main thread.
|
|
1315
|
+
if (tab.visibility === 'visible' && censusAge >= config.hungAfterMs) {
|
|
1316
|
+
return {
|
|
1317
|
+
state: 'hung',
|
|
1318
|
+
ms: censusAge,
|
|
1319
|
+
because:
|
|
1320
|
+
`its beats are fresh but the page has not sampled its census in ${seconds(censusAge)} ` +
|
|
1321
|
+
`(it samples every 5s while visible) — the MAIN THREAD is not running`,
|
|
1322
|
+
};
|
|
1323
|
+
}
|
|
1324
|
+
const inFlight = newest.blender?.inFlightMs ?? null;
|
|
1325
|
+
if (censusAge < config.hungAfterMs && inFlight !== null && inFlight > 0) {
|
|
1326
|
+
return {
|
|
1327
|
+
state: 'busy',
|
|
1328
|
+
ms: inFlight,
|
|
1329
|
+
because:
|
|
1330
|
+
`a Blender call has been outstanding for ${seconds(inFlight)}; the page is still ` +
|
|
1331
|
+
'sampling, so it is running — whether that call is stuck is what the number is for',
|
|
1332
|
+
};
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
return {
|
|
1337
|
+
state: 'present',
|
|
1338
|
+
ms: beatAge,
|
|
1339
|
+
because: `beating ${seconds(beatAge ?? 0)} ago, page sampling`,
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/** What `vgai status` prints per tab, and what a refusal quotes. */
|
|
1344
|
+
export interface TabPresenceReport {
|
|
1345
|
+
readonly tabId8: string;
|
|
1346
|
+
readonly presentFor: number;
|
|
1347
|
+
readonly lastBeatAgo: number | null;
|
|
1348
|
+
readonly epochCount: number;
|
|
1349
|
+
/**
|
|
1350
|
+
* How long ago THIS page-load started (`epochStartedAt`), i.e. the age of
|
|
1351
|
+
* the document currently running in this tab. `presentFor` above is the
|
|
1352
|
+
* age of the TAB and survives its reloads, which is a different question
|
|
1353
|
+
* and the wrong one for anything that cares about what the running
|
|
1354
|
+
* document has in memory.
|
|
1355
|
+
*
|
|
1356
|
+
* P20 reads it as the reference point for "did bytes under `public/`
|
|
1357
|
+
* change after this document loaded" — a page-lifetime asset cache is
|
|
1358
|
+
* exactly as old as its epoch. It is the server's own observation (the
|
|
1359
|
+
* heartbeat's epoch id changing), not a page claim.
|
|
1360
|
+
*/
|
|
1361
|
+
readonly epochAgeMs: number;
|
|
1362
|
+
readonly visibility: TabVisibility;
|
|
1363
|
+
readonly route: TabRoute;
|
|
1364
|
+
/** What kind of page this tab is — see {@link TabSurface}. */
|
|
1365
|
+
readonly surface: TabSurface;
|
|
1366
|
+
readonly blessed: boolean;
|
|
1367
|
+
readonly channel: 'open' | 'down';
|
|
1368
|
+
/** Beating, but this page-load has never opened a command channel. */
|
|
1369
|
+
readonly unresponsive: boolean;
|
|
1370
|
+
/** This tab's newest resource profile, or null when it has never sent one. */
|
|
1371
|
+
readonly census: TabCensus | null;
|
|
1372
|
+
/** How long ago that profile was filed. Null when there is none. */
|
|
1373
|
+
readonly censusAgeMs: number | null;
|
|
1374
|
+
/**
|
|
1375
|
+
* {@link tabState}'s verdict, flattened — the FIRST thing every reader
|
|
1376
|
+
* prints about this tab, before it attempts anything that can fail. Every
|
|
1377
|
+
* other field on this row answers a narrower question; this one answers the
|
|
1378
|
+
* question that was actually asked.
|
|
1379
|
+
*/
|
|
1380
|
+
readonly state: TabState;
|
|
1381
|
+
readonly stateMs: number | null;
|
|
1382
|
+
readonly stateBecause: string;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
/** The newest profile this tab filed, or null. */
|
|
1386
|
+
export function latestCensus(tab: TabRecord): TabCensusSample | null {
|
|
1387
|
+
return tab.census[tab.census.length - 1] ?? null;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
/**
|
|
1391
|
+
* The whole table, for a human or an agent. Owner: "if they DO get
|
|
1392
|
+
* disconnected make it clear that it happened" — a per-tab row with the beat
|
|
1393
|
+
* age in it is that clarity, and it is the same data every decision above
|
|
1394
|
+
* read, not a parallel summary that can disagree with them.
|
|
1395
|
+
*/
|
|
1396
|
+
export function tabPresenceReport(
|
|
1397
|
+
state: TabPresenceState,
|
|
1398
|
+
now: number,
|
|
1399
|
+
config: TabPresenceConfig = DEFAULT_TAB_PRESENCE_CONFIG,
|
|
1400
|
+
): TabPresenceReport[] {
|
|
1401
|
+
return presentTabs(state, now, config).map((tab) => reportRow(state, tab, now, config));
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
/**
|
|
1405
|
+
* The same row for a tab that is GONE — the table's short memory
|
|
1406
|
+
* ({@link TabPresenceState.departed}).
|
|
1407
|
+
*
|
|
1408
|
+
* Separate from {@link tabPresenceReport} on purpose: `editorsConnected` and
|
|
1409
|
+
* every present-tab reader counts that array, and a dead row inside it would
|
|
1410
|
+
* make a crashed tab read as a connected one. The words a departed row carries
|
|
1411
|
+
* (`closed`, `crashed`) are exactly the ones nobody could print before.
|
|
1412
|
+
*/
|
|
1413
|
+
export function departedTabReport(
|
|
1414
|
+
state: TabPresenceState,
|
|
1415
|
+
now: number,
|
|
1416
|
+
config: TabPresenceConfig = DEFAULT_TAB_PRESENCE_CONFIG,
|
|
1417
|
+
): TabPresenceReport[] {
|
|
1418
|
+
return [...state.departed.values()].map((tab) => reportRow(state, tab, now, config));
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
function reportRow(
|
|
1422
|
+
state: TabPresenceState,
|
|
1423
|
+
tab: TabRecord,
|
|
1424
|
+
now: number,
|
|
1425
|
+
config: TabPresenceConfig,
|
|
1426
|
+
): TabPresenceReport {
|
|
1427
|
+
const newest = latestCensus(tab);
|
|
1428
|
+
const verdict = tabState(tab, now, config);
|
|
1429
|
+
return {
|
|
1430
|
+
tabId8: tab.tabId.slice(0, 8),
|
|
1431
|
+
presentFor: now - tab.firstSeenAt,
|
|
1432
|
+
lastBeatAgo: tab.beatEver ? now - tab.lastBeatAt : null,
|
|
1433
|
+
epochCount: tab.epochCount,
|
|
1434
|
+
// `|| firstSeenAt` matches `tabUnresponsive`'s own fallback for a tab
|
|
1435
|
+
// whose epoch has not been stamped yet — never `now`, which would read
|
|
1436
|
+
// as a document that just loaded.
|
|
1437
|
+
epochAgeMs: now - (tab.epochStartedAt || tab.firstSeenAt),
|
|
1438
|
+
visibility: tab.visibility,
|
|
1439
|
+
route: tab.route,
|
|
1440
|
+
surface: tab.surface,
|
|
1441
|
+
blessed: tab.tabId === state.blessedTabId,
|
|
1442
|
+
channel: tab.connected ? 'open' : 'down',
|
|
1443
|
+
unresponsive: tabUnresponsive(tab, now, config),
|
|
1444
|
+
census: newest === null ? null : stripStamp(newest),
|
|
1445
|
+
censusAgeMs: newest === null ? null : now - newest.at,
|
|
1446
|
+
state: verdict.state,
|
|
1447
|
+
stateMs: verdict.ms,
|
|
1448
|
+
stateBecause: verdict.because,
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
/** The profile without the server's filing stamp — the report carries the age
|
|
1453
|
+
* as its own field, and two ways to say "when" is one too many. */
|
|
1454
|
+
function stripStamp(sample: TabCensusSample): TabCensus {
|
|
1455
|
+
const { at: _at, ...census } = sample;
|
|
1456
|
+
return census;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
/**
|
|
1460
|
+
* The refusal/timeout wording, stated as TABLE FACTS and nothing else.
|
|
1461
|
+
*
|
|
1462
|
+
* Every previous version of this message described a socket ("its connection
|
|
1463
|
+
* is still open", "editor tab lost") and was routinely wrong about the tab.
|
|
1464
|
+
* These two sentences can only be wrong if the table is, and the table is
|
|
1465
|
+
* what the decision was made from.
|
|
1466
|
+
*/
|
|
1467
|
+
export function tabAbsenceMessage(state: TabPresenceState, now: number): string {
|
|
1468
|
+
const absentMs = state.absentSince === null ? 0 : now - state.absentSince;
|
|
1469
|
+
return (
|
|
1470
|
+
`No tab has been present for ${(absentMs / 1000).toFixed(1)}s` +
|
|
1471
|
+
` (nothing reopens it: run \`volter-editor edit\` to open one)`
|
|
1472
|
+
);
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
/** The zombie's refusal: the tab is open, the page is not running. */
|
|
1476
|
+
export function tabUnresponsiveMessage(tab: TabRecord, now: number): string {
|
|
1477
|
+
const forMs = now - (tab.epochStartedAt || tab.firstSeenAt);
|
|
1478
|
+
const beat = tab.beatEver
|
|
1479
|
+
? `beating (last heartbeat ${((now - tab.lastBeatAt) / 1000).toFixed(1)}s ago)`
|
|
1480
|
+
: 'connected (it has never sent a heartbeat)';
|
|
1481
|
+
const stuck =
|
|
1482
|
+
tabUnresponsiveReason(tab) === 'no-channel'
|
|
1483
|
+
? `has opened no command channel in ${(forMs / 1000).toFixed(0)}s`
|
|
1484
|
+
: `opened a control channel but has attached no command listener in ` +
|
|
1485
|
+
`${(forMs / 1000).toFixed(0)}s — it never finished loading the editor app, so every ` +
|
|
1486
|
+
`command sent there queues with nothing to run it`;
|
|
1487
|
+
return (
|
|
1488
|
+
`The one tab present is ${beat} but its PAGE ${stuck}. The tab is open and its ` +
|
|
1489
|
+
`document is not running. Reload it, or re-run \`volter-editor edit\` (which reuses this session ` +
|
|
1490
|
+
`and opens a fresh tab). \`volter-editor status\` prints any page errors that tab reported.`
|
|
1491
|
+
);
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
/** The other half: the tab IS here, and here is what it has been doing. */
|
|
1495
|
+
export function tabWaitingMessage(
|
|
1496
|
+
tab: TabRecord,
|
|
1497
|
+
now: number,
|
|
1498
|
+
reloadsWhileWaiting: number,
|
|
1499
|
+
): string {
|
|
1500
|
+
const beat = tab.beatEver
|
|
1501
|
+
? `last heartbeat ${((now - tab.lastBeatAt) / 1000).toFixed(1)}s ago`
|
|
1502
|
+
: 'no heartbeat yet';
|
|
1503
|
+
const reloads =
|
|
1504
|
+
reloadsWhileWaiting > 0
|
|
1505
|
+
? ` but reloaded ${reloadsWhileWaiting === 1 ? 'once' : `${reloadsWhileWaiting} times`} while this command waited`
|
|
1506
|
+
: '';
|
|
1507
|
+
return `the tab is present (${beat})${reloads}`;
|
|
1508
|
+
}
|