@kabmo-labs/kabmo 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +403 -384
- package/dist/client/static/js/{index-0wvbca0b.js → index-bpptfkz2.js} +905 -875
- package/dist/client/static/js/index-k315p1e8.css +1 -0
- package/package.json +10 -1
- package/src/client/components/common/code-surface/documents.test.ts +120 -0
- package/src/client/components/common/code-surface/documents.ts +201 -0
- package/src/client/components/common/code-surface/extensions.test.ts +110 -0
- package/src/client/components/common/code-surface/extensions.ts +162 -0
- package/src/client/components/common/code-surface/handle.ts +102 -0
- package/src/client/components/common/code-surface/index.tsx +253 -314
- package/src/client/components/common/code-surface/keymap.test.ts +133 -0
- package/src/client/components/common/code-surface/keymap.ts +267 -0
- package/src/client/components/common/code-surface/marks.ts +74 -0
- package/src/client/components/common/code-surface/shiki.ts +234 -0
- package/src/client/components/common/code-surface/sync.test.ts +185 -0
- package/src/client/components/common/code-surface/sync.ts +58 -0
- package/src/client/components/common/code-surface/theme.test.ts +202 -0
- package/src/client/components/common/code-surface/theme.ts +111 -0
- package/src/client/components/common/editor-view-buttons/index.test.tsx +148 -0
- package/src/client/components/common/editor-view-buttons/index.tsx +109 -0
- package/src/client/components/common/header-controls/index.tsx +24 -1
- package/src/client/components/common/header-controls/shell-chrome.test.tsx +272 -0
- package/src/client/components/common/shell-update-button/Modal.tsx +163 -0
- package/src/client/components/common/shell-update-button/index.test.tsx +251 -0
- package/src/client/components/common/shell-update-button/index.tsx +125 -0
- package/src/client/components/common/toolbar-row/index.test.tsx +166 -0
- package/src/client/components/common/toolbar-row/index.tsx +189 -0
- package/src/client/components/common/workspace-options-menu/index.tsx +19 -1
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -6
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +41 -78
- package/src/client/components/layout/desktop-layout/index.tsx +55 -69
- package/src/client/components/layout/desktop-layout/resizer/index.tsx +6 -3
- package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +24 -0
- package/src/client/components/layout/session-sidebar/CategoryHeader.worktree.test.tsx +126 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +4 -0
- package/src/client/components/layout/session-sidebar/Footer.tsx +8 -15
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -0
- package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
- package/src/client/components/layout/session-sidebar/SessionsView.tsx +24 -0
- package/src/client/components/layout/session-sidebar/WorktreeBadge.test.ts +48 -0
- package/src/client/components/layout/session-sidebar/WorktreeBadge.tsx +55 -0
- package/src/client/components/mobile/LayoutWrapper.tsx +23 -16
- package/src/client/components/mobile/RightSidebar.tsx +21 -10
- package/src/client/components/mobile/SessionSidebar.tsx +8 -0
- package/src/client/components/mobile/header-titlebar.test.tsx +172 -0
- package/src/client/components/mobile/mobile-main-view/Header.tsx +10 -1
- package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -0
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +63 -38
- package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.test.tsx +137 -0
- package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.tsx +14 -3
- package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +9 -21
- package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -2
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +16 -0
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +4 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +5 -0
- package/src/client/components/mobile/mobile-session-sidebar/WorktreeDialog.tsx +49 -0
- package/src/client/components/mobile/worktree-session.ts +51 -0
- package/src/client/components/settings/Modal.tsx +8 -0
- package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
- package/src/client/components/settings/categories/ProjectSettings.tsx +22 -8
- package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.test.tsx +167 -0
- package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.tsx +184 -0
- package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +146 -0
- package/src/client/components/settings/categories/project-settings/worktree-row.test.ts +83 -0
- package/src/client/components/settings/categories/project-settings/worktree-row.ts +47 -0
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +36 -55
- package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +8 -0
- package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +9 -4
- package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +9 -7
- package/src/client/components/workspace/chat-timeline/index.tsx +5 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.test.tsx +132 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.tsx +120 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.test.tsx +319 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.tsx +285 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +34 -29
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +11 -9
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +10 -7
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +25 -8
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.test.tsx +146 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +32 -17
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.test.ts +114 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.tsx +111 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +7 -7
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +9 -59
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ArtifactBody.tsx +2 -7
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.test.ts +27 -28
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +39 -26
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ExcerptCode.tsx +8 -8
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/HighlightedCode.tsx +35 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock.tsx +4 -4
- package/src/client/components/workspace/diff-panel/MergeDiffView.tsx +179 -0
- package/src/client/components/workspace/diff-panel/ThemedMergeDiffView.tsx +24 -0
- package/src/client/components/workspace/diff-panel/Toolbar.tsx +224 -186
- package/src/client/components/workspace/diff-panel/ToolbarSegments.tsx +98 -0
- package/src/client/components/workspace/diff-panel/index.tsx +76 -145
- package/src/client/components/workspace/diff-panel/merge.ts +315 -0
- package/src/client/components/workspace/editor/Body.tsx +4 -7
- package/src/client/components/workspace/editor/CommandPalette.tsx +2 -2
- package/src/client/components/workspace/editor/FileDocument.tsx +58 -63
- package/src/client/components/workspace/editor/FilePane.tsx +4 -7
- package/src/client/components/workspace/editor/FindWidget.tsx +7 -2
- package/src/client/components/workspace/editor/Toolbar.tsx +220 -123
- package/src/client/components/workspace/editor/index.tsx +7 -9
- package/src/client/components/workspace/file-explorer/ContextMenu.tsx +2 -1
- package/src/client/components/workspace/file-explorer/Modals.tsx +40 -12
- package/src/client/components/workspace/file-explorer/OutlineSection.test.tsx +18 -0
- package/src/client/components/workspace/file-explorer/OutlineSection.tsx +10 -3
- package/src/client/components/workspace/file-explorer/RootMenu.tsx +14 -5
- package/src/client/components/workspace/file-explorer/Toolbar.tsx +74 -0
- package/src/client/components/workspace/file-explorer/TreeItem.tsx +38 -3
- package/src/client/components/workspace/file-explorer/filter.ts +47 -0
- package/src/client/components/workspace/file-explorer/index.tsx +77 -64
- package/src/client/components/workspace/file-explorer/local-edit.test.ts +135 -0
- package/src/client/components/workspace/search-panel/Body.tsx +1 -1
- package/src/client/components/workspace/worktree-modal/index.test.tsx +118 -0
- package/src/client/components/workspace/worktree-modal/index.tsx +261 -0
- package/src/client/hooks/chat/omp/reconnect.test.ts +338 -0
- package/src/client/hooks/chat/omp/reconnect.ts +187 -0
- package/src/client/hooks/chat/omp/run-liveness.test.ts +71 -0
- package/src/client/hooks/chat/omp/run-liveness.ts +65 -0
- package/src/client/hooks/chat/omp/session-list.activity.test.ts +114 -0
- package/src/client/hooks/chat/omp/session-list.test-util.ts +164 -0
- package/src/client/hooks/chat/omp/session-list.test.ts +30 -150
- package/src/client/hooks/chat/omp/session-list.tsx +69 -9
- package/src/client/hooks/chat/timeline/generating.ts +19 -2
- package/src/client/hooks/chat/timeline/index.ts +27 -28
- package/src/client/hooks/chat/timeline/lost-run.test.ts +329 -0
- package/src/client/hooks/chat/timeline/lost-run.ts +177 -0
- package/src/client/hooks/chat/timeline/send-execute.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send-steer.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send.ts +11 -1
- package/src/client/hooks/chat/timeline/session-load.test-util.ts +205 -0
- package/src/client/hooks/chat/timeline/session-load.test.ts +61 -162
- package/src/client/hooks/chat/timeline/session-load.ts +69 -37
- package/src/client/hooks/chat/timeline/session-switch.test.ts +65 -0
- package/src/client/hooks/chat/timeline/session-switch.ts +63 -0
- package/src/client/hooks/chat/timeline/spawn-target.ts +52 -0
- package/src/client/hooks/chat/timeline/tool-open.test.ts +74 -0
- package/src/client/hooks/chat/timeline/tool-open.ts +52 -8
- package/src/client/hooks/code/deferred-highlight.test.ts +182 -0
- package/src/client/hooks/code/deferred-highlight.ts +129 -0
- package/src/client/hooks/editor/find-state.ts +1 -1
- package/src/client/hooks/editor/theme.test.ts +206 -0
- package/src/client/hooks/editor/theme.ts +82 -0
- package/src/client/hooks/editor/use-editor-drop.test.ts +101 -0
- package/src/client/hooks/editor/use-editor-drop.ts +67 -14
- package/src/client/hooks/editor/use-editor-find.ts +7 -7
- package/src/client/hooks/editor/use-outline-view.test.ts +182 -0
- package/src/client/hooks/editor/use-outline-view.ts +94 -7
- package/src/client/hooks/editor/use-scroll-restore.test.ts +55 -2
- package/src/client/hooks/editor/use-scroll-restore.ts +124 -4
- package/src/client/hooks/editor/use-search-reveal.ts +1 -1
- package/src/client/hooks/ui/long-press.test.ts +174 -0
- package/src/client/hooks/ui/long-press.ts +112 -0
- package/src/client/hooks/ui/settings-modal.ts +56 -0
- package/src/client/hooks/ui/updates/shell-download.test.ts +75 -0
- package/src/client/hooks/ui/updates/shell-download.ts +153 -28
- package/src/client/hooks/ui/updates/shell-offer.test.ts +213 -0
- package/src/client/hooks/ui/updates/shell-update.test.ts +140 -0
- package/src/client/hooks/ui/updates/shell-update.ts +119 -0
- package/src/client/hooks/workspace/diff-file.ts +29 -10
- package/src/client/hooks/workspace/diff-git-actions.ts +147 -0
- package/src/client/hooks/workspace/editor-view.ts +61 -0
- package/src/client/hooks/workspace/file-create.ts +41 -11
- package/src/client/hooks/workspace/file-listing.ts +56 -1
- package/src/client/hooks/workspace/file-tabs.test.ts +62 -5
- package/src/client/hooks/workspace/file-tabs.ts +24 -11
- package/src/client/hooks/workspace/file-tree-actions.ts +37 -5
- package/src/client/hooks/workspace/navbar-session-actions.ts +72 -0
- package/src/client/hooks/workspace/worktree/index.ts +165 -0
- package/src/client/lib/desktop/bridge.ts +45 -0
- package/src/client/lib/search-reveal.ts +1 -1
- package/src/client/main.tsx +5 -1
- package/src/client/tailwind.css +56 -8
- package/src/server/index.ts +7 -2
- package/src/server/lib/auth/guard.test.ts +28 -0
- package/src/server/lib/auth/guard.ts +10 -0
- package/src/server/lib/bundler/titlebar-css.test.ts +42 -1
- package/src/server/lib/db/schema.ts +4 -0
- package/src/server/lib/db/worktree-registry.server.ts +91 -0
- package/src/server/lib/fs/canonical.ts +52 -0
- package/src/server/lib/fs/relative-path.ts +48 -0
- package/src/server/lib/fs/root-worktree.test.ts +116 -0
- package/src/server/lib/fs/root.ts +39 -10
- package/src/server/lib/fs/worktree/carry.ts +134 -0
- package/src/server/lib/fs/worktree/create.test.ts +249 -0
- package/src/server/lib/fs/worktree/create.ts +262 -0
- package/src/server/lib/fs/worktree/errors.ts +43 -0
- package/src/server/lib/fs/worktree/list.test.ts +207 -0
- package/src/server/lib/fs/worktree/list.ts +142 -0
- package/src/server/lib/fs/worktree/location.test.ts +206 -0
- package/src/server/lib/fs/worktree/location.ts +114 -0
- package/src/server/lib/fs/worktree/project-id.ts +53 -0
- package/src/server/lib/fs/worktree/remove.test.ts +242 -0
- package/src/server/lib/fs/worktree/remove.ts +152 -0
- package/src/server/lib/fs/worktree/settings.server.ts +56 -0
- package/src/server/lib/fs/worktree/topology.ts +142 -0
- package/src/server/lib/lifecycle/claim-port.test.ts +156 -0
- package/src/server/lib/lifecycle/port-guard.test.ts +20 -164
- package/src/server/lib/lifecycle/port-guard.ts +39 -7
- package/src/server/lib/lifecycle/probe-health.test.ts +166 -0
- package/src/server/lib/lifecycle/probe.test.ts +77 -164
- package/src/server/lib/lifecycle/probe.ts +121 -13
- package/src/server/lib/omp/rpc/frame-fold-signals.test.ts +112 -0
- package/src/server/lib/omp/rpc/frame-fold.ts +10 -5
- package/src/server/lib/omp/session/sidebar-activity.server.test.ts +162 -0
- package/src/server/lib/omp/session/sidebar-activity.server.ts +70 -0
- package/src/server/lib/omp/session/sidebar-data.canonical.test.ts +155 -0
- package/src/server/lib/omp/session/sidebar-data.server.ts +28 -1
- package/src/server/lib/omp/session/telemetry/compute.ts +13 -1
- package/src/server/lib/omp/session/telemetry.test.ts +33 -0
- package/src/server/lib/panels/local-edits.server.ts +33 -0
- package/src/server/lib/panels/sync.fixture.test-util.ts +92 -0
- package/src/server/lib/panels/sync.local-edits.test.ts +150 -0
- package/src/server/lib/panels/sync.server.test.ts +125 -147
- package/src/server/lib/panels/sync.server.ts +153 -48
- package/src/server/lib/realtime/signals.server.ts +10 -0
- package/src/server/lib/realtime/topics/catalog.server.ts +4 -1
- package/src/server/lib/realtime/topics.server.ts +16 -0
- package/src/server/lib/updates/shell-assets.test.ts +114 -0
- package/src/server/lib/updates/shell-assets.ts +200 -0
- package/src/server/lib/updates/shell-cache.ts +105 -0
- package/src/server/lib/updates/shell-pages.test.ts +187 -0
- package/src/server/lib/updates/shell-pages.ts +157 -0
- package/src/server/lib/updates/shell-release-desktop.test.ts +166 -0
- package/src/server/lib/updates/shell-release.test.ts +59 -71
- package/src/server/lib/updates/shell-release.ts +178 -198
- package/src/server/routes/fs/action-create.test.ts +18 -13
- package/src/server/routes/fs/action-nested.test.ts +184 -0
- package/src/server/routes/fs/action.ts +47 -16
- package/src/server/routes/fs/git-merge.test.ts +11 -1
- package/src/server/routes/fs/read.test.ts +24 -7
- package/src/server/routes/fs/scope.test.ts +15 -10
- package/src/server/routes/fs/scope.ts +3 -14
- package/src/server/routes/index.ts +2 -0
- package/src/server/routes/updates/android.ts +6 -1
- package/src/server/routes/updates/desktop.ts +59 -0
- package/src/server/routes/updates/index.ts +2 -0
- package/src/server/routes/updates/ios.ts +5 -1
- package/src/server/routes/worktrees/create.ts +138 -0
- package/src/server/routes/worktrees/index.ts +25 -0
- package/src/server/routes/worktrees/list.ts +134 -0
- package/src/server/routes/worktrees/remove.ts +102 -0
- package/src/server/routes/worktrees/routes.test.ts +326 -0
- package/src/server/routes/worktrees/shared.ts +90 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +36 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -0
- package/src/shared/lib/chat/timeline/run-signals.test.ts +2 -0
- package/src/shared/lib/code/editor/comment-syntax.test.ts +37 -0
- package/src/shared/lib/code/editor/comment-syntax.ts +55 -0
- package/src/shared/lib/code/editor/find-replace.test.ts +134 -0
- package/src/shared/lib/code/editor/{find.ts → find-replace.ts} +23 -107
- package/src/shared/lib/code/editor/{find.test.ts → find-scan.test.ts} +19 -73
- package/src/shared/lib/code/editor/find-scan.ts +177 -0
- package/src/shared/lib/code/editor/keymap.ts +3 -3
- package/src/shared/lib/code/editor/lines.test.ts +7 -55
- package/src/shared/lib/code/editor/lines.ts +8 -48
- package/src/shared/lib/code/editor/marks.test.ts +4 -5
- package/src/shared/lib/code/editor/marks.ts +13 -25
- package/src/shared/lib/code/editor/theme-loader.ts +97 -0
- package/src/shared/lib/code/editor/themes.generated.ts +156 -0
- package/src/shared/lib/code/editor/themes.test.ts +165 -0
- package/src/shared/lib/code/editor/themes.ts +165 -0
- package/src/shared/lib/code/highlight-memo.test.ts +103 -0
- package/src/shared/lib/code/highlight-memo.ts +149 -0
- package/src/shared/lib/code/highlighter-warmup.ts +112 -0
- package/src/shared/lib/code/highlighter.ts +3 -1
- package/src/shared/lib/code/idle.ts +37 -0
- package/src/shared/lib/code/outline/rows.test.ts +96 -0
- package/src/shared/lib/code/outline/rows.ts +94 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +1 -79
- package/src/shared/lib/code/syntax-highlight.ts +2 -3
- package/src/shared/lib/fs/file-kind-classify.test.ts +25 -11
- package/src/shared/lib/fs/file-kind.ts +47 -13
- package/src/shared/lib/fs/file-tree.test.ts +183 -0
- package/src/shared/lib/fs/file-tree.ts +159 -0
- package/src/shared/lib/fs/file-url.test.ts +52 -0
- package/src/shared/lib/fs/file-url.ts +37 -0
- package/src/shared/lib/fs/patch-sides.test.ts +114 -0
- package/src/shared/lib/fs/patch-sides.ts +139 -0
- package/src/shared/lib/fs/paths.test.ts +24 -1
- package/src/shared/lib/fs/paths.ts +17 -0
- package/src/shared/lib/fs/worktree/settings-keys.ts +18 -0
- package/src/shared/lib/omp/session/prewarm.ts +7 -0
- package/src/shared/lib/realtime/protocol.ts +16 -0
- package/src/shared/lib/ui/toolbar-overflow.test.ts +83 -0
- package/src/shared/lib/ui/toolbar-overflow.ts +86 -0
- package/src/shared/lib/updates/shell-arch.ts +54 -0
- package/src/shared/lib/updates/shell-ua.ts +24 -4
- package/src/shared/lib/workspace/active-project.ts +23 -0
- package/src/shared/lib/workspace/editor-view-mode.test.ts +70 -0
- package/src/shared/lib/workspace/editor-view-mode.ts +48 -0
- package/src/shared/lib/workspace/panel-widths.test.ts +30 -0
- package/src/shared/lib/workspace/panel-widths.ts +25 -5
- package/src/shared/lib/workspace/session-state/cache.ts +159 -0
- package/src/shared/lib/workspace/session-state/harness.test-util.ts +55 -0
- package/src/shared/lib/workspace/session-state/merge.ts +100 -0
- package/src/shared/lib/workspace/session-state/store-load.test.ts +206 -0
- package/src/shared/lib/workspace/session-state/store.test.ts +17 -139
- package/src/shared/lib/workspace/session-state/store.ts +92 -130
- package/src/shared/lib/workspace/workspace-modules.test.ts +49 -1
- package/src/shared/lib/workspace/workspace-panel-widths.ts +124 -0
- package/src/shared/lib/workspace/worktree/branch-name.test.ts +95 -0
- package/src/shared/lib/workspace/worktree/branch-name.ts +115 -0
- package/src/shared/lib/workspace/worktree/types.ts +104 -0
- package/src/shared/types/fs.ts +10 -0
- package/src/shared/types/realtime.ts +29 -0
- package/src/shared/types/settings/state.ts +10 -0
- package/src/shared/types/workspace.ts +14 -0
- package/src/test-support/file-explorer-disk.test-util.ts +76 -0
- package/src/test-support/file-explorer.test-util.ts +71 -40
- package/src/test-support/loopback.ts +183 -0
- package/dist/client/static/js/index-nzyjf92m.css +0 -1
- package/src/client/components/common/code-editor/handler.test.ts +0 -274
- package/src/client/components/common/code-editor/handler.ts +0 -175
- package/src/client/components/common/code-editor/index.tsx +0 -314
- package/src/client/components/common/code-surface/measure.ts +0 -148
- package/src/client/components/workspace/diff-panel/SplitView.tsx +0 -209
- package/src/client/components/workspace/diff-panel/UnifiedView.tsx +0 -150
- package/src/client/hooks/editor/scroll-container.test.ts +0 -102
- package/src/client/hooks/editor/scroll-container.ts +0 -26
- package/src/client/hooks/editor/use-code-window.ts +0 -100
- package/src/client/hooks/editor/use-editor-history.test.ts +0 -216
- package/src/client/hooks/editor/use-editor-history.ts +0 -134
- package/src/client/hooks/editor/use-occurrences.test.ts +0 -236
- package/src/client/hooks/editor/use-occurrences.ts +0 -98
- package/src/shared/lib/code/editor/commands.test.ts +0 -155
- package/src/shared/lib/code/editor/commands.ts +0 -260
- package/src/shared/lib/code/editor/edits.test.ts +0 -100
- package/src/shared/lib/code/editor/edits.ts +0 -122
- package/src/shared/lib/code/editor/history.test.ts +0 -118
- package/src/shared/lib/code/editor/history.ts +0 -101
- package/src/shared/lib/code/editor/layers.test.ts +0 -77
- package/src/shared/lib/code/editor/layers.ts +0 -67
- package/src/shared/lib/code/editor/line-edits.test.ts +0 -131
- package/src/shared/lib/code/editor/line-edits.ts +0 -159
- package/src/shared/lib/code/editor/occurrences.test.ts +0 -117
- package/src/shared/lib/code/editor/occurrences.ts +0 -111
- package/src/shared/lib/code/editor/reset-css.test.ts +0 -35
- package/src/shared/lib/code/editor/reset-css.ts +0 -32
- package/src/shared/lib/code/editor/text-commands.test.ts +0 -178
- package/src/shared/lib/code/editor/text-commands.ts +0 -154
- package/src/shared/lib/code/lazy-window.test.ts +0 -161
- package/src/shared/lib/code/lazy-window.ts +0 -156
- package/src/shared/lib/code/windowed-highlight.ts +0 -114
- package/src/shared/lib/fs/diff-parser.test.ts +0 -116
- package/src/shared/lib/fs/diff-parser.ts +0 -213
|
@@ -4,64 +4,34 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* `probe.ts`
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* translation
|
|
14
|
-
* place.
|
|
7
|
+
* The ADDRESS RULES `probe.ts` applies before it asks anything: which address a
|
|
8
|
+
* bind host stands for, and whether that address can be bound.
|
|
9
|
+
*
|
|
10
|
+
* Getting either wrong is expensive — a false "free" turns into an EADDRINUSE
|
|
11
|
+
* after the server has already announced itself, and a false "occupied" blocks
|
|
12
|
+
* startup on a port nobody holds — so the cases drive real listeners on
|
|
13
|
+
* OS-assigned ports and pin the translation that decides which address is asked
|
|
14
|
+
* about in the first place. The health half of the module is `probe-health`.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import {
|
|
18
|
-
import type { Server } from 'bun';
|
|
17
|
+
import { afterAll, describe, expect, test } from 'bun:test';
|
|
19
18
|
|
|
20
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
connectAddresses,
|
|
21
|
+
isLoopbackHost,
|
|
22
|
+
isPortAvailable,
|
|
23
|
+
probeAddresses,
|
|
24
|
+
probeHost,
|
|
25
|
+
unavailableAddresses,
|
|
26
|
+
waitForPortFree,
|
|
27
|
+
} from '@/server/lib/lifecycle/probe';
|
|
28
|
+
import { createLoopbackHarness, ipv6Loopback } from '@/test-support/loopback';
|
|
21
29
|
|
|
22
|
-
const
|
|
30
|
+
const harness = createLoopbackHarness();
|
|
31
|
+
const { occupy, occupyAt, freePort, freePortBothFamilies } = harness;
|
|
23
32
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
* of every call site. */
|
|
27
|
-
function occupy(fetch: (request: Request) => Response | Promise<Response>, hostname = '127.0.0.1'): { server: Server<undefined>; port: number } {
|
|
28
|
-
const server = Bun.serve({ port: 0, hostname, fetch }) as Server<undefined>;
|
|
29
|
-
const port = server.port;
|
|
30
|
-
if (port === undefined) throw new Error('the OS did not assign an ephemeral port');
|
|
31
|
-
servers.push(server);
|
|
32
|
-
return { server, port };
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* A port that nothing holds: take one from the OS, give it back, and wait for
|
|
37
|
-
* the release to actually land — Bun closes a stopped listener's socket on a
|
|
38
|
-
* later event-loop turn, so an immediate check would be a race, not a test.
|
|
39
|
-
*/
|
|
40
|
-
async function freePort(): Promise<number> {
|
|
41
|
-
const held = occupy(() => new Response('unused'));
|
|
42
|
-
held.server.stop(true);
|
|
43
|
-
servers.splice(servers.indexOf(held.server), 1);
|
|
44
|
-
if (!(await waitForPortFree(held.port, '127.0.0.1', 2_000, 25))) {
|
|
45
|
-
throw new Error(`ephemeral port ${held.port} never became free`);
|
|
46
|
-
}
|
|
47
|
-
return held.port;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* The runner's own fetch, captured before any test runs. Another file in the
|
|
52
|
-
* same `bun test` process may have left a stub installed on the global, and
|
|
53
|
-
* every case here talks to a REAL listener it just started on loopback — so
|
|
54
|
-
* the global is put back before each one rather than trusting the ambient one.
|
|
55
|
-
*/
|
|
56
|
-
/** The runner's own fetch, reached through `Bun` so a stub leaked onto the global cannot be mistaken for it. */
|
|
57
|
-
const realFetch = Bun.fetch;
|
|
58
|
-
|
|
59
|
-
beforeEach(() => {
|
|
60
|
-
globalThis.fetch = realFetch;
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
afterEach(() => {
|
|
64
|
-
for (const server of servers.splice(0)) server.stop(true);
|
|
33
|
+
afterAll(async () => {
|
|
34
|
+
await harness.dispose();
|
|
65
35
|
});
|
|
66
36
|
|
|
67
37
|
describe('probeHost', () => {
|
|
@@ -82,6 +52,31 @@ describe('probeHost', () => {
|
|
|
82
52
|
});
|
|
83
53
|
});
|
|
84
54
|
|
|
55
|
+
describe('probeAddresses', () => {
|
|
56
|
+
test('a loopback name is every loopback family, because a bind picks one', () => {
|
|
57
|
+
// Measured: `Bun.serve({ hostname: 'localhost' })` listens on `[::1]` alone.
|
|
58
|
+
// Asking only about that family is how a stale instance kept `127.0.0.1`
|
|
59
|
+
// while the newcomer's own log reported the port free.
|
|
60
|
+
expect(probeAddresses('localhost')).toEqual(['127.0.0.1', '::1']);
|
|
61
|
+
expect(probeAddresses('LOCALHOST')).toEqual(['127.0.0.1', '::1']);
|
|
62
|
+
expect(probeAddresses(undefined)).toEqual(['127.0.0.1', '::1']);
|
|
63
|
+
expect(probeAddresses(' ')).toEqual(['127.0.0.1', '::1']);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('a literal address is one address', () => {
|
|
67
|
+
expect(probeAddresses('127.0.0.1')).toEqual(['127.0.0.1']);
|
|
68
|
+
expect(probeAddresses('0.0.0.0')).toEqual(['0.0.0.0']);
|
|
69
|
+
expect(probeAddresses('192.168.1.4')).toEqual(['192.168.1.4']);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('connect addresses translate a bind-any address, and keep the rest', () => {
|
|
73
|
+
expect(connectAddresses('0.0.0.0')).toEqual(['127.0.0.1']);
|
|
74
|
+
expect(connectAddresses('::')).toEqual(['127.0.0.1']);
|
|
75
|
+
expect(connectAddresses('localhost')).toEqual(['127.0.0.1', '::1']);
|
|
76
|
+
expect(connectAddresses('192.168.1.4')).toEqual(['192.168.1.4']);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
85
80
|
describe('isLoopbackHost', () => {
|
|
86
81
|
test('treats an unset address as private', () => {
|
|
87
82
|
expect(isLoopbackHost(undefined)).toBe(true);
|
|
@@ -122,7 +117,6 @@ describe('isPortAvailable', () => {
|
|
|
122
117
|
expect(await isPortAvailable(heldPort, '127.0.0.1')).toBe(false);
|
|
123
118
|
|
|
124
119
|
held.stop(true);
|
|
125
|
-
servers.splice(servers.indexOf(held), 1);
|
|
126
120
|
// An ephemeral port is released once the listener closes; the poll is what
|
|
127
121
|
// callers use to wait for exactly that.
|
|
128
122
|
expect(await waitForPortFree(heldPort, '127.0.0.1', 2_000, 25)).toBe(true);
|
|
@@ -140,12 +134,10 @@ describe('isPortAvailable', () => {
|
|
|
140
134
|
// Linux refuses it (reads busy). Neither is pinned — both are honest
|
|
141
135
|
// answers about the address asked for.
|
|
142
136
|
loopback.server.stop(true);
|
|
143
|
-
servers.splice(servers.indexOf(loopback.server), 1);
|
|
144
137
|
|
|
145
138
|
const wildcard = occupy(() => new Response('held'), '0.0.0.0');
|
|
146
139
|
expect(await isPortAvailable(wildcard.port, '0.0.0.0')).toBe(false);
|
|
147
140
|
wildcard.server.stop(true);
|
|
148
|
-
servers.splice(servers.indexOf(wildcard.server), 1);
|
|
149
141
|
|
|
150
142
|
// The listener that held the port is gone, so the same question flips.
|
|
151
143
|
expect(await waitForPortFree(loopback.port, '127.0.0.1', 2_000, 25)).toBe(true);
|
|
@@ -157,6 +149,35 @@ describe('isPortAvailable', () => {
|
|
|
157
149
|
expect(await isPortAvailable(-1, '127.0.0.1')).toBe(false);
|
|
158
150
|
expect(await isPortAvailable(Number.NaN, '127.0.0.1')).toBe(false);
|
|
159
151
|
});
|
|
152
|
+
|
|
153
|
+
test.skipIf(!ipv6Loopback)('a name is busy when only ONE of its addresses is held', async () => {
|
|
154
|
+
// The reported failure, reduced: a stale instance holds `127.0.0.1:3000`,
|
|
155
|
+
// `bun run dev` binds `localhost` (i.e. `::1`) and finds that family free.
|
|
156
|
+
// Both servers then serve "port 3000" and the browser lands on whichever
|
|
157
|
+
// family it resolves — the stale one, in a different data mode.
|
|
158
|
+
const shared = await freePortBothFamilies();
|
|
159
|
+
const stale = occupyAt(shared, '127.0.0.1');
|
|
160
|
+
expect(await isPortAvailable(shared, 'localhost')).toBe(false);
|
|
161
|
+
expect(await unavailableAddresses(shared, 'localhost')).toEqual(['127.0.0.1']);
|
|
162
|
+
stale.stop(true);
|
|
163
|
+
expect(await waitForPortFree(shared, 'localhost', 2_000, 25)).toBe(true);
|
|
164
|
+
|
|
165
|
+
// The other family alone is just as busy.
|
|
166
|
+
const other = await freePortBothFamilies();
|
|
167
|
+
const v6 = occupyAt(other, '::1');
|
|
168
|
+
expect(await isPortAvailable(other, 'localhost')).toBe(false);
|
|
169
|
+
expect(await unavailableAddresses(other, 'localhost')).toEqual(['::1']);
|
|
170
|
+
v6.stop(true);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('reports the address that is held, and none when the port is free', async () => {
|
|
174
|
+
const free = await freePort();
|
|
175
|
+
expect(await unavailableAddresses(free, '127.0.0.1')).toEqual([]);
|
|
176
|
+
expect(await unavailableAddresses(free, 'localhost')).toEqual([]);
|
|
177
|
+
|
|
178
|
+
const held = occupy(() => new Response('held'));
|
|
179
|
+
expect(await unavailableAddresses(held.port, '127.0.0.1')).toEqual(['127.0.0.1']);
|
|
180
|
+
});
|
|
160
181
|
});
|
|
161
182
|
|
|
162
183
|
describe('waitForPortFree', () => {
|
|
@@ -169,111 +190,3 @@ describe('waitForPortFree', () => {
|
|
|
169
190
|
expect(await waitForPortFree(held.port, '127.0.0.1', 0)).toBe(false);
|
|
170
191
|
});
|
|
171
192
|
});
|
|
172
|
-
|
|
173
|
-
describe('fetchHealth', () => {
|
|
174
|
-
test('returns the payload of a Kabmo listener, keeping only known fields', async () => {
|
|
175
|
-
const full = occupy(() => Response.json({
|
|
176
|
-
service: 'kabmo',
|
|
177
|
-
pid: 4242,
|
|
178
|
-
version: '3.10.1',
|
|
179
|
-
startedAt: '2026-01-01T00:00:00.000Z',
|
|
180
|
-
uptime: 12.5,
|
|
181
|
-
mock: false,
|
|
182
|
-
runtime: 'bun',
|
|
183
|
-
mode: 'dev',
|
|
184
|
-
port: 1234,
|
|
185
|
-
host: '127.0.0.1',
|
|
186
|
-
tls: false,
|
|
187
|
-
tray: true,
|
|
188
|
-
authEnabled: true,
|
|
189
|
-
fds: { open: 3352, highest: 3351, limit: 61_440, nearCliff: false },
|
|
190
|
-
extra: 'dropped',
|
|
191
|
-
}));
|
|
192
|
-
|
|
193
|
-
const health = await fetchHealth(full.port, '127.0.0.1', 1000, false);
|
|
194
|
-
|
|
195
|
-
expect(health).toEqual({
|
|
196
|
-
service: 'kabmo',
|
|
197
|
-
pid: 4242,
|
|
198
|
-
version: '3.10.1',
|
|
199
|
-
startedAt: '2026-01-01T00:00:00.000Z',
|
|
200
|
-
uptime: 12.5,
|
|
201
|
-
mock: false,
|
|
202
|
-
runtime: 'bun',
|
|
203
|
-
mode: 'dev',
|
|
204
|
-
port: 1234,
|
|
205
|
-
host: '127.0.0.1',
|
|
206
|
-
tls: false,
|
|
207
|
-
tray: true,
|
|
208
|
-
authEnabled: true,
|
|
209
|
-
// Carried, not dropped: `status` prints this as its warning line, and
|
|
210
|
-
// whitelisting it away left the report without one.
|
|
211
|
-
fds: { open: 3352, highest: 3351, limit: 61_440, nearCliff: false },
|
|
212
|
-
});
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
test('reports a malformed fds field as null rather than a half-trusted object', async () => {
|
|
216
|
-
const partial = occupy(() => Response.json({
|
|
217
|
-
service: 'kabmo',
|
|
218
|
-
fds: { open: 1, highest: 1, limit: 2 },
|
|
219
|
-
}));
|
|
220
|
-
|
|
221
|
-
const health = await fetchHealth(partial.port, '127.0.0.1', 1000, false);
|
|
222
|
-
|
|
223
|
-
expect(health?.service).toBe('kabmo');
|
|
224
|
-
expect(health?.fds).toBeNull();
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
test('drops fields whose type is wrong rather than passing them on', async () => {
|
|
228
|
-
const mistyped = occupy(() => Response.json({ service: 'kabmo', pid: '4242', version: 7, tls: 'yes' }));
|
|
229
|
-
|
|
230
|
-
const health = await fetchHealth(mistyped.port, '127.0.0.1', 1000, false);
|
|
231
|
-
|
|
232
|
-
expect(health?.service).toBe('kabmo');
|
|
233
|
-
expect(health?.pid).toBeUndefined();
|
|
234
|
-
expect(health?.version).toBeUndefined();
|
|
235
|
-
expect(health?.tls).toBeUndefined();
|
|
236
|
-
});
|
|
237
|
-
|
|
238
|
-
test('recognizes a pre-rename listener, and reports it under the current name', async () => {
|
|
239
|
-
// A server started before the rename is still running the old build: its
|
|
240
|
-
// health payload says `ompchamber`, and `status`/`stop` must still find it.
|
|
241
|
-
const legacy = occupy(() => Response.json({ service: 'ompchamber', pid: 4242, port: 3000 }));
|
|
242
|
-
|
|
243
|
-
const health = await fetchHealth(legacy.port, '127.0.0.1', 1000, false);
|
|
244
|
-
|
|
245
|
-
expect(health?.service).toBe('kabmo');
|
|
246
|
-
expect(health?.pid).toBe(4242);
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
test('ignores a listener that is not Kabmo', async () => {
|
|
250
|
-
const foreign = occupy(() => Response.json({ service: 'something-else', pid: process.pid }));
|
|
251
|
-
expect(await fetchHealth(foreign.port, '127.0.0.1', 1000, false)).toBeNull();
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
test('ignores error responses, non-JSON bodies, and non-objects', async () => {
|
|
255
|
-
const broken = occupy(() => new Response('nope', { status: 500 }));
|
|
256
|
-
expect(await fetchHealth(broken.port, '127.0.0.1', 1000, false)).toBeNull();
|
|
257
|
-
|
|
258
|
-
const html = occupy(() => new Response('<html>not json</html>', { headers: { 'content-type': 'text/html' } }));
|
|
259
|
-
expect(await fetchHealth(html.port, '127.0.0.1', 1000, false)).toBeNull();
|
|
260
|
-
|
|
261
|
-
const list = occupy(() => Response.json([{ service: 'kabmo' }]));
|
|
262
|
-
expect(await fetchHealth(list.port, '127.0.0.1', 1000, false)).toBeNull();
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
test('returns null for a port nobody listens on, and for a bad port', async () => {
|
|
266
|
-
expect(await fetchHealth(await freePort(), '127.0.0.1', 500)).toBeNull();
|
|
267
|
-
expect(await fetchHealth(0, '127.0.0.1', 500)).toBeNull();
|
|
268
|
-
expect(await fetchHealth(Number.NaN, '127.0.0.1', 500)).toBeNull();
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
test('a plaintext listener is not found over TLS, and the mode decides the scheme', async () => {
|
|
272
|
-
const plain = occupy(() => Response.json({ service: 'kabmo', pid: process.pid }));
|
|
273
|
-
|
|
274
|
-
expect(await fetchHealth(plain.port, '127.0.0.1', 1000, true)).toBeNull();
|
|
275
|
-
expect(await fetchHealth(plain.port, '127.0.0.1', 1000, false)).not.toBeNull();
|
|
276
|
-
// 'auto' cannot know which scheme a live instance chose, so it tries both.
|
|
277
|
-
expect(await fetchHealth(plain.port, '127.0.0.1', 1000)).not.toBeNull();
|
|
278
|
-
});
|
|
279
|
-
});
|
|
@@ -69,27 +69,100 @@ export function isLoopbackHost(host?: string | null): boolean {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
/**
|
|
72
|
-
*
|
|
72
|
+
* Addresses a bind to `host` can land on.
|
|
73
|
+
*
|
|
74
|
+
* A NAME is not one address, and that is the whole bug this function exists
|
|
75
|
+
* for. `localhost` resolves to both loopback families, while Bun binds only the
|
|
76
|
+
* one it resolves first — verified: `Bun.serve({ hostname: 'localhost' })`
|
|
77
|
+
* listens on `[::1]` alone. Asking about that family alone answers "is the
|
|
78
|
+
* socket I am about to open free", which is not the question a port guard
|
|
79
|
+
* exists to answer: the other address stays available to a second server, and a
|
|
80
|
+
* client that resolves THAT one reaches it. Measured on a real dev run against a
|
|
81
|
+
* stale instance on `127.0.0.1:3000`: both servers reported the port as theirs,
|
|
82
|
+
* the browser landed on the old build (a MOCK instance), and the newcomer's own
|
|
83
|
+
* log said the port was free.
|
|
73
84
|
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
* up until the real `listen` failed.
|
|
85
|
+
* A literal address and a bind-any address are one address by definition. An
|
|
86
|
+
* arbitrary hostname is deliberately left alone: its resolution belongs to the
|
|
87
|
+
* OS, a second lookup here could disagree with the one the bind will use, and
|
|
88
|
+
* only loopback is guaranteed multi-family on every host.
|
|
79
89
|
*/
|
|
80
|
-
export
|
|
81
|
-
|
|
82
|
-
|
|
90
|
+
export function probeAddresses(host?: string | null): string[] {
|
|
91
|
+
const value = (host ?? '').trim();
|
|
92
|
+
if (value.length === 0 || value.toLowerCase() === 'localhost') return ['127.0.0.1', '::1'];
|
|
93
|
+
return [value];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Addresses a health request should be sent to, for a bind host.
|
|
98
|
+
*
|
|
99
|
+
* The same list as `probeAddresses`, with bind-any addresses translated to the
|
|
100
|
+
* loopback that reaches them. Kept separate because the two questions differ:
|
|
101
|
+
* occupancy is asked with a BIND (so `0.0.0.0` is a legitimate address to bind)
|
|
102
|
+
* while identification is asked with a CONNECT (where `0.0.0.0` is not an
|
|
103
|
+
* address at all).
|
|
104
|
+
*/
|
|
105
|
+
export function connectAddresses(host?: string | null): string[] {
|
|
106
|
+
return probeAddresses(host).map(probeHost);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Bind failures that mean "this host has no such address", not "someone holds
|
|
111
|
+
* it": a kernel booted without IPv6 answers `EADDRNOTAVAIL` for `::1`, and
|
|
112
|
+
* reading that as occupied would refuse every start on that machine.
|
|
113
|
+
*/
|
|
114
|
+
const ABSENT_ADDRESS_CODES: Record<string, true> = {
|
|
115
|
+
EADDRNOTAVAIL: true,
|
|
116
|
+
EAFNOSUPPORT: true,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/** Whether one address can be bound. */
|
|
120
|
+
async function bindable(port: number, host: string): Promise<boolean> {
|
|
83
121
|
const { promise, resolve } = Promise.withResolvers<boolean>();
|
|
84
122
|
const probe = net.createServer();
|
|
85
123
|
probe.unref();
|
|
86
|
-
probe.once('error', () =>
|
|
87
|
-
|
|
124
|
+
probe.once('error', (error: NodeJS.ErrnoException) => {
|
|
125
|
+
resolve(ABSENT_ADDRESS_CODES[error.code ?? ''] === true);
|
|
126
|
+
});
|
|
127
|
+
probe.listen({ port, host }, () => {
|
|
88
128
|
probe.close(() => resolve(true));
|
|
89
129
|
});
|
|
90
130
|
return await promise;
|
|
91
131
|
}
|
|
92
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Every address of `host` where `port` is already taken; empty means free.
|
|
135
|
+
*
|
|
136
|
+
* The list rather than a boolean, because the guard's failure message has to
|
|
137
|
+
* name the address that is held — "port busy" on a name that resolves to two
|
|
138
|
+
* addresses does not say which one the user has to free.
|
|
139
|
+
*/
|
|
140
|
+
export async function unavailableAddresses(port: number, host?: string | null): Promise<string[]> {
|
|
141
|
+
if (!Number.isFinite(port) || port <= 0) return [];
|
|
142
|
+
const blocked: string[] = [];
|
|
143
|
+
for (const address of probeAddresses(host)) {
|
|
144
|
+
if (!(await bindable(port, address))) blocked.push(address);
|
|
145
|
+
}
|
|
146
|
+
return blocked;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* True when `port` can still be bound on `host`.
|
|
151
|
+
*
|
|
152
|
+
* The probe binds *the same addresses the server will bind* — a wildcard server
|
|
153
|
+
* is probed with a wildcard bind, and a name is probed on every address it
|
|
154
|
+
* resolves to. Probing loopback instead of a wildcard would ask a different
|
|
155
|
+
* question: `net` sets SO_REUSEADDR, so a loopback bind can succeed while
|
|
156
|
+
* `0.0.0.0:<port>` is already taken, and the port would look free right up
|
|
157
|
+
* until the real `listen` failed. Probing one family of a name asks a narrower
|
|
158
|
+
* question still: whether *this* socket is free, when the answer that matters
|
|
159
|
+
* is whether the port is free for the name a browser will type.
|
|
160
|
+
*/
|
|
161
|
+
export async function isPortAvailable(port: number, host?: string | null): Promise<boolean> {
|
|
162
|
+
if (!Number.isFinite(port) || port <= 0) return false;
|
|
163
|
+
return (await unavailableAddresses(port, host)).length === 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
93
166
|
/** Poll until the port can be bound, or the deadline passes. */
|
|
94
167
|
export async function waitForPortFree(
|
|
95
168
|
port: number,
|
|
@@ -136,6 +209,20 @@ export interface HealthPayload {
|
|
|
136
209
|
fds?: FdPressure | null;
|
|
137
210
|
}
|
|
138
211
|
|
|
212
|
+
/**
|
|
213
|
+
* A host as a URL authority.
|
|
214
|
+
*
|
|
215
|
+
* An IPv6 literal has to be bracketed: `http://::1:3000/api/health` is not a
|
|
216
|
+
* URL, and the failure is a throw rather than a connection attempt — so asking
|
|
217
|
+
* `::1` without this reads as "nothing there", which is the same silent answer
|
|
218
|
+
* the multi-address probe exists to stop. `localhost` never reaches this branch
|
|
219
|
+
* (no colon), and an already-bracketed address is left alone.
|
|
220
|
+
*/
|
|
221
|
+
function urlAuthority(host: string): string {
|
|
222
|
+
if (host.startsWith('[') || !host.includes(':')) return host;
|
|
223
|
+
return `[${host}]`;
|
|
224
|
+
}
|
|
225
|
+
|
|
139
226
|
/**
|
|
140
227
|
* One health request over `scheme`.
|
|
141
228
|
*
|
|
@@ -152,7 +239,7 @@ async function probeHealthOver(
|
|
|
152
239
|
timeoutMs: number,
|
|
153
240
|
): Promise<HealthPayload | null> {
|
|
154
241
|
try {
|
|
155
|
-
const response = await fetch(`${scheme}://${host}:${port}/api/health`, {
|
|
242
|
+
const response = await fetch(`${scheme}://${urlAuthority(host)}:${port}/api/health`, {
|
|
156
243
|
headers: { accept: 'application/json' },
|
|
157
244
|
signal: AbortSignal.timeout(timeoutMs),
|
|
158
245
|
...(scheme === 'https' ? { tls: { rejectUnauthorized: false } } : {}),
|
|
@@ -194,6 +281,15 @@ async function probeHealthOver(
|
|
|
194
281
|
* caller here is asking "is a Kabmo server on this port", so a foreign
|
|
195
282
|
* HTTP service answers null rather than a half-trusted object.
|
|
196
283
|
*
|
|
284
|
+
* A NAME is asked on every address it resolves to, concurrently, and the first
|
|
285
|
+
* answer wins. A single-address request is the false negative that made a live
|
|
286
|
+
* instance invisible to `status` and `stop`: `localhost` resolves to both
|
|
287
|
+
* loopback families, a server binds only one of them, and a request aimed at the
|
|
288
|
+
* other reaches nothing — so a MOCK instance holding `127.0.0.1:3000` answered
|
|
289
|
+
* no CLI command while the browser kept landing on it. Concurrent rather than
|
|
290
|
+
* sequential because a port nobody serves must not cost one timeout per family,
|
|
291
|
+
* and ECONNREFUSED on the family with no listener returns immediately.
|
|
292
|
+
*
|
|
197
293
|
* `tls: 'auto'` (the default) tries HTTPS first, then HTTP. Discovery cannot
|
|
198
294
|
* know which a live instance chose, and the two are mutually exclusive on a port,
|
|
199
295
|
* so one wasted request is the cheapest way to be right. A caller that DOES know
|
|
@@ -206,7 +302,19 @@ export async function fetchHealth(
|
|
|
206
302
|
tls: boolean | 'auto' = 'auto',
|
|
207
303
|
): Promise<HealthPayload | null> {
|
|
208
304
|
if (!Number.isFinite(port) || port <= 0) return null;
|
|
209
|
-
const
|
|
305
|
+
const answers = await Promise.all(
|
|
306
|
+
connectAddresses(host).map((target) => probeHealthOn(port, target, timeoutMs, tls)),
|
|
307
|
+
);
|
|
308
|
+
return answers.find((answer) => answer !== null) ?? null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** One address, in whichever scheme the caller allows. */
|
|
312
|
+
async function probeHealthOn(
|
|
313
|
+
port: number,
|
|
314
|
+
target: string,
|
|
315
|
+
timeoutMs: number,
|
|
316
|
+
tls: boolean | 'auto',
|
|
317
|
+
): Promise<HealthPayload | null> {
|
|
210
318
|
if (tls === true) return probeHealthOver('https', port, target, timeoutMs);
|
|
211
319
|
if (tls === false) return probeHealthOver('http', port, target, timeoutMs);
|
|
212
320
|
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What a completed tool call republishes, and — the load-bearing half — what it
|
|
8
|
+
* does NOT.
|
|
9
|
+
*
|
|
10
|
+
* The shipped shape republished the whole `sidebar` structure here: every
|
|
11
|
+
* folder and session, re-scanned from disk. Measured on the author's install,
|
|
12
|
+
* that is 216 KB over 731 sessions PER TOOL CALL, against 87 bytes for the
|
|
13
|
+
* activity entry it actually needed to carry, and it is what made a phone's
|
|
14
|
+
* stream feel slow. These cases pin the split:
|
|
15
|
+
*
|
|
16
|
+
* - the activity signal fires per tool call;
|
|
17
|
+
* - the STRUCTURE signal does not, because nothing about the LIST changed (a
|
|
18
|
+
* new session's file is the watcher's `rename` event, a rename is
|
|
19
|
+
* `session_info_update`, and the turn boundary is `settleTerminalRun`);
|
|
20
|
+
* - the data signal still fires, because a tool result IS a transcript entry.
|
|
21
|
+
*
|
|
22
|
+
* Split from `frame-fold.test.ts` when the two together crossed the repo's
|
|
23
|
+
* per-file ceiling.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { describe, expect, test } from 'bun:test';
|
|
27
|
+
|
|
28
|
+
import { foldSessionFrame, type SessionFrameHost } from '@/server/lib/omp/rpc/frame-fold';
|
|
29
|
+
import { onRealtimeSignal } from '@/server/lib/realtime/signals.server';
|
|
30
|
+
import { ModeMirror } from '@/server/lib/omp/rpc/mode-mirror';
|
|
31
|
+
|
|
32
|
+
/** The same minimal host the sibling suite uses. */
|
|
33
|
+
function makeHost(overrides: Partial<SessionFrameHost> = {}): SessionFrameHost {
|
|
34
|
+
return {
|
|
35
|
+
sessionId: '',
|
|
36
|
+
autoTitleWindowUntil: 0,
|
|
37
|
+
autoTitleClaims: 0,
|
|
38
|
+
autoTitleDispatch: null,
|
|
39
|
+
autoTitlePending: true,
|
|
40
|
+
autoTitleRequested: false,
|
|
41
|
+
promptRunning: false,
|
|
42
|
+
streaming: false,
|
|
43
|
+
compacting: false,
|
|
44
|
+
awaitingAgentStart: false,
|
|
45
|
+
awaitingAgentStartDeadline: 0,
|
|
46
|
+
continuationGraceUntil: 0,
|
|
47
|
+
proc: { sendCommand: async () => undefined as never },
|
|
48
|
+
emit() {},
|
|
49
|
+
trackUiDialog() {},
|
|
50
|
+
observeSubagent() {},
|
|
51
|
+
syncRunModel() {},
|
|
52
|
+
modeMirror: new ModeMirror(),
|
|
53
|
+
isAlive: () => true,
|
|
54
|
+
isBusy: () => false,
|
|
55
|
+
send: async () => undefined,
|
|
56
|
+
...overrides,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The `tool_execution_end` frame is what moves a running session's sidebar row
|
|
62
|
+
* between turn boundaries — and it must move ONLY the two live fields.
|
|
63
|
+
*
|
|
64
|
+
* The shipped shape republished the whole `sidebar` structure here: every
|
|
65
|
+
* folder and session, re-scanned from disk. Measured on the author's install,
|
|
66
|
+
* that is 216 KB over 731 sessions PER TOOL CALL, against 87 bytes for the
|
|
67
|
+
* activity entry it actually needed to carry — and it is what made a phone's
|
|
68
|
+
* stream feel slow. The rules pinned below are the ones that make the smaller
|
|
69
|
+
* payload sufficient:
|
|
70
|
+
*
|
|
71
|
+
* - the activity signal fires per tool call;
|
|
72
|
+
* - the STRUCTURE signal does not, because nothing about the list changed (a
|
|
73
|
+
* new session's file is the watcher's `rename` event, a rename is
|
|
74
|
+
* `session_info_update`, and the turn boundary is `settleTerminalRun`);
|
|
75
|
+
* - the data signal still fires, because a tool result IS a transcript entry.
|
|
76
|
+
*/
|
|
77
|
+
describe('tool_execution_end publishes activity, never the structure', () => {
|
|
78
|
+
test('raises the activity signal and leaves the structure alone', () => {
|
|
79
|
+
const seen: string[] = [];
|
|
80
|
+
const off = onRealtimeSignal((event) => seen.push(event.signal));
|
|
81
|
+
try {
|
|
82
|
+
const host = makeHost({ sessionId: 's1' });
|
|
83
|
+
foldSessionFrame(host, { type: 'tool_execution_end' });
|
|
84
|
+
} finally {
|
|
85
|
+
off();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
expect(seen).toContain('sidebar-activity');
|
|
89
|
+
expect(seen).not.toContain('sidebar-structure');
|
|
90
|
+
// The transcript grew, so the session's data topics re-read.
|
|
91
|
+
expect(seen).toContain('session-data-dirty');
|
|
92
|
+
expect(seen).toContain('workspace-dirty');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('still publishes the structure at the turn boundary', () => {
|
|
96
|
+
// The other half of the split: the final answer appends after the last tool
|
|
97
|
+
// call, and the run's own settle is where the list catches up. Asserted
|
|
98
|
+
// through the scan cache, which is the observable a structure publish
|
|
99
|
+
// leaves behind (`settleTerminalRun` calls the publisher directly rather
|
|
100
|
+
// than raising the signal).
|
|
101
|
+
globalThis.__kabmoSidebarDataCache = {
|
|
102
|
+
data: {} as never,
|
|
103
|
+
expiresAt: Date.now() + 60_000,
|
|
104
|
+
inFlight: null,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const host = makeHost({ sessionId: 's1' });
|
|
108
|
+
foldSessionFrame(host, { type: 'agent_end', isTerminal: true, messages: [] });
|
|
109
|
+
|
|
110
|
+
expect(globalThis.__kabmoSidebarDataCache?.expiresAt).toBe(0);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -117,14 +117,19 @@ function handleMessageEnd(host: SessionFrameHost, event: AgentEvent): void {
|
|
|
117
117
|
* pays nothing. The session's data topics (todos, telemetry, queue) stay
|
|
118
118
|
* turn-boundary-only on purpose: their resolvers are the expensive ones
|
|
119
119
|
* (whole-transcript parses/scans).
|
|
120
|
+
*
|
|
121
|
+
* The SIDEBAR's activity stamp moved too, and it is published on its own topic.
|
|
122
|
+
* It used to republish the whole structure here — every folder and session —
|
|
123
|
+
* and that is what made a phone's stream feel slow: measured 216 KB over 731
|
|
124
|
+
* sessions per tool call, against 87 bytes for the activity payload. Nothing
|
|
125
|
+
* about the LIST changes on a transcript append, so nothing about the list is
|
|
126
|
+
* republished: a new session's file is the watcher's `rename` event, a rename is
|
|
127
|
+
* `session_info_update`, and the turn boundary is `settleTerminalRun` — each
|
|
128
|
+
* already publishes the structure for its own reason.
|
|
120
129
|
*/
|
|
121
130
|
function handleToolExecutionEnd(host: SessionFrameHost): void {
|
|
122
131
|
emitRealtimeSignal('workspace-dirty');
|
|
123
|
-
|
|
124
|
-
// time-ago labels) is read off a scan that only re-runs on this signal, and
|
|
125
|
-
// the sessions watcher deliberately ignores transcript appends. The coalesce +
|
|
126
|
-
// dedupe above bound the cost the same way.
|
|
127
|
-
publishSidebarStructure();
|
|
132
|
+
emitRealtimeSignal('sidebar-activity');
|
|
128
133
|
// The session's DATA moved as well: a tool result is a transcript entry, so
|
|
129
134
|
// the raw-messages paged view and the session-data topics re-read. Their
|
|
130
135
|
// resolvers are the expensive ones (whole-transcript parses), but the
|