mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 08:02:38 +00:00
Merge remote-tracking branch 'origin/main' into ota-fu-bundle-gzip-ranges
# Conflicts: # mobile/rpc-foundation/goldens/aivault-history-scan-fulfilled.json # mobile/rpc-foundation/goldens/aivault-history-scan-unsupported.json # mobile/rpc-foundation/goldens/aivault-history-scan-worktrees-late.json # mobile/rpc-foundation/goldens/aivault-history-screen-listed.json # mobile/rpc-foundation/goldens/aivault-history-screen-worktrees.json # mobile/rpc-foundation/goldens/aivault-resume-launch-create-refused.json # mobile/rpc-foundation/goldens/aivault-resume-launch-invalid-tab.json # mobile/rpc-foundation/goldens/aivault-resume-launch-locked.json # mobile/rpc-foundation/goldens/aivault-resume-launch-sent.json # mobile/rpc-foundation/goldens/aivault-resume-prepare-refused.json # mobile/rpc-foundation/goldens/aivault-resume-prepare-repin.json # mobile/rpc-foundation/goldens/aivault-resume-prepare-skipped.json # mobile/rpc-foundation/goldens/aivault-resume-prepare-unavailable.json # mobile/rpc-foundation/goldens/b1.json # mobile/rpc-foundation/goldens/b2.json # mobile/rpc-foundation/goldens/b3.json # mobile/rpc-foundation/goldens/browser-dialog-accepted.json # mobile/rpc-foundation/goldens/browser-dialog-dismissed.json # mobile/rpc-foundation/goldens/browser-keyboard-input.json # mobile/rpc-foundation/goldens/browser-pointer-click-accepted.json # mobile/rpc-foundation/goldens/browser-pointer-click-fallback.json # mobile/rpc-foundation/goldens/browser-wheel-scrolled.json # mobile/rpc-foundation/goldens/clipboard-image-attachment-anonymous.json # mobile/rpc-foundation/goldens/clipboard-image-attachment-blocked-before-send.json # mobile/rpc-foundation/goldens/clipboard-image-attachment-cancelled.json # mobile/rpc-foundation/goldens/clipboard-image-attachment-pasted.json # mobile/rpc-foundation/goldens/clipboard-image-attachment-upload-refused.json # mobile/rpc-foundation/goldens/clipboard-image-upload-aborts-on-chunk-failure.json # mobile/rpc-foundation/goldens/clipboard-image-upload-chunked.json # mobile/rpc-foundation/goldens/clipboard-image-upload-single-frame-fallback.json # mobile/rpc-foundation/goldens/clipboard-image-upload-start-refused.json # mobile/rpc-foundation/goldens/codex-reset-credit-consumed.json # mobile/rpc-foundation/goldens/codex-reset-credit-resumed.json # mobile/rpc-foundation/goldens/components-codex-capability.json # mobile/rpc-foundation/goldens/components-setup-ask.json # mobile/rpc-foundation/goldens/components-target-local.json # mobile/rpc-foundation/goldens/components-target-ssh.json # mobile/rpc-foundation/goldens/diff-review-branch-compare.json # mobile/rpc-foundation/goldens/diff-review-branch-file-diff.json # mobile/rpc-foundation/goldens/diff-review-notes-refused-before-compare.json # mobile/rpc-foundation/goldens/diff-review-refused-file-diff.json # mobile/rpc-foundation/goldens/diff-review-snapshot.json # mobile/rpc-foundation/goldens/diff-review-status-unavailable.json # mobile/rpc-foundation/goldens/diff-review-worktree-file-diff.json # mobile/rpc-foundation/goldens/file-tap-open-refused.json # mobile/rpc-foundation/goldens/file-tap-opens-worktree-file.json # mobile/rpc-foundation/goldens/file-tap-previews-absolute-artifact.json # mobile/rpc-foundation/goldens/file-tap-resolve-miss.json # mobile/rpc-foundation/goldens/file-tap-resolve-refused.json # mobile/rpc-foundation/goldens/files-explorer-legacy-fallback.json # mobile/rpc-foundation/goldens/files-explorer-readdir.json # mobile/rpc-foundation/goldens/files-ownership-local.json # mobile/rpc-foundation/goldens/files-ownership-ssh.json # mobile/rpc-foundation/goldens/files-preview-artifact-direct.json # mobile/rpc-foundation/goldens/files-preview-artifact-image-read.json # mobile/rpc-foundation/goldens/files-preview-artifact-image.json # mobile/rpc-foundation/goldens/files-preview-grant-refresh.json # mobile/rpc-foundation/goldens/files-preview-worktree-image-read.json # mobile/rpc-foundation/goldens/files-preview-worktree-image.json # mobile/rpc-foundation/goldens/files-preview-worktree-text-read.json # mobile/rpc-foundation/goldens/files-preview-worktree.json # mobile/rpc-foundation/goldens/files-save-blind.json # mobile/rpc-foundation/goldens/files-save-verified.json # mobile/rpc-foundation/goldens/files-tab-doc-shapes.json # mobile/rpc-foundation/goldens/home-host-accounts.json # mobile/rpc-foundation/goldens/home-host-stats.json # mobile/rpc-foundation/goldens/host-view-settings-sync.json # mobile/rpc-foundation/goldens/host-worktree-actions-pin-open-delete.json # mobile/rpc-foundation/goldens/host-worktree-delete-refused.json # mobile/rpc-foundation/goldens/host-worktree-refresh-stream.json # mobile/rpc-foundation/goldens/interruptions-inventory-lifecycle.json # mobile/rpc-foundation/goldens/interruptions-settings-bot-overrides-fulfilled.json # mobile/rpc-foundation/goldens/inventory-lifecycle.json # mobile/rpc-foundation/goldens/inventory-repeat-query.json # mobile/rpc-foundation/goldens/lifecycle-b3.json # mobile/rpc-foundation/goldens/lifecycle-inventory-lifecycle.json # mobile/rpc-foundation/goldens/lifecycle-settings-bot-overrides-fulfilled.json # mobile/rpc-foundation/goldens/lifecycle-settings-task-hydration-fulfilled.json # mobile/rpc-foundation/goldens/lifecycle-settings-workspace-context-fulfilled.json # mobile/rpc-foundation/goldens/linear-select-workspace.json # mobile/rpc-foundation/goldens/live-worktree-name-stream.json # mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.create-1.json # mobile/rpc-foundation/goldens/matrix-agentsession.structured-create-agentsession.createsupport-1.json # mobile/rpc-foundation/goldens/matrix-agentsession.structured-launch-agentsession.createsupport-1.json # mobile/rpc-foundation/goldens/matrix-aivault.history-aivault.listsessions-1.json # mobile/rpc-foundation/goldens/matrix-aivault.history-screen-platform-status.json # mobile/rpc-foundation/goldens/matrix-aivault.history-screen-status.get-2.json # mobile/rpc-foundation/goldens/matrix-aivault.history-screen-worktree.ps-1.json # mobile/rpc-foundation/goldens/matrix-aivault.history-status.get-1.json # mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-session.tabs.createterminal-1.json # mobile/rpc-foundation/goldens/matrix-aivault.resume-launch-terminal.send-1.json # mobile/rpc-foundation/goldens/matrix-aivault.resume-preparation-aivault.preparesessionresume-1.json # mobile/rpc-foundation/goldens/matrix-browser.dialog-browser.dialogaccept-1.json # mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keyboardinserttext-1.json # mobile/rpc-foundation/goldens/matrix-browser.keyboard-browser.keypress-1.json # mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseclick-1.json # mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousedown-1.json # mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mousemove-1.json # mobile/rpc-foundation/goldens/matrix-browser.pointer-click-browser.mouseup-1.json # mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousemove-1.json # mobile/rpc-foundation/goldens/matrix-browser.wheel-browser.mousewheel-1.json # mobile/rpc-foundation/goldens/matrix-clipboard.image-attachment-clipboard.startimageupload-1.json # mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.saveimageastempfile-1.json # mobile/rpc-foundation/goldens/matrix-clipboard.image-upload-clipboard.startimageupload-1.json # mobile/rpc-foundation/goldens/matrix-components.codex-reset-capability-status.get-1.json # mobile/rpc-foundation/goldens/matrix-components.codex-reset-credit-accounts.consumecodexresetcredit-1.json # mobile/rpc-foundation/goldens/matrix-components.execution-target-local-preflight.detectagents-1.json # mobile/rpc-foundation/goldens/matrix-components.execution-target-preflight.detectremoteagents-1.json # mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.connect-1.json # mobile/rpc-foundation/goldens/matrix-components.execution-target-ssh.getstate-1.json # mobile/rpc-foundation/goldens/matrix-components.new-workspace-repositories-repo.list-1.json # mobile/rpc-foundation/goldens/matrix-components.setup-script-repo.hooks-1.json # mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.list-1.json # mobile/rpc-foundation/goldens/matrix-files.explorer-screen-files.readdir-1.json # mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-ssh.getstate-1.json # mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-status.get-1.json # mobile/rpc-foundation/goldens/matrix-files.mutation-ownership-worktree.show-1.json # mobile/rpc-foundation/goldens/matrix-files.preview-artifact-image-files.readterminalartifactpreview-1.json # mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-1.json # mobile/rpc-foundation/goldens/matrix-files.preview-load-files.readterminalartifact-2.json # mobile/rpc-foundation/goldens/matrix-files.preview-load-files.resolveterminalpath-1.json # mobile/rpc-foundation/goldens/matrix-files.preview-save-files.readterminalartifact-1.json # mobile/rpc-foundation/goldens/matrix-files.preview-save-files.writeterminalartifact-1.json # mobile/rpc-foundation/goldens/matrix-files.preview-worktree-image-files.readpreview-1.json # mobile/rpc-foundation/goldens/matrix-files.preview-worktree-text-files.read-1.json # mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.read-1.json # mobile/rpc-foundation/goldens/matrix-files.tab-doc-files.readpreview-1.json # mobile/rpc-foundation/goldens/matrix-files.tab-doc-git.diff-1.json # mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.open-1.json # mobile/rpc-foundation/goldens/matrix-files.terminal-path-tap-files.resolveterminalpath-1.json # mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.baserefdefault-1.json # mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-repo.list-1.json # mobile/rpc-foundation/goldens/matrix-git.base-ref-chain-worktree.show-1.json # mobile/rpc-foundation/goldens/matrix-git.branch-diff-preview-git.branchdiff-1.json # mobile/rpc-foundation/goldens/matrix-git.changes-load-git.branchcompare-1.json # mobile/rpc-foundation/goldens/matrix-git.changes-load-git.status-1.json # mobile/rpc-foundation/goldens/matrix-git.changes-load-repo.list-1.json # mobile/rpc-foundation/goldens/matrix-git.changes-load-worktree.show-1.json # mobile/rpc-foundation/goldens/matrix-git.commit-message-ai-git.generatecommitmessage-1.json # mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.commitcompare-1.json # mobile/rpc-foundation/goldens/matrix-git.history-commit-files-git.history-1.json # mobile/rpc-foundation/goldens/matrix-git.history-read-git.history-1.json # mobile/rpc-foundation/goldens/matrix-git.remote-prerequisite-git.push-1.json # mobile/rpc-foundation/goldens/matrix-git.review-preparation-git.status-1.json # mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addissuecomment-1.json # mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.addprreviewcommentreply-1.json # mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.deleteissuecommentbyslug-1.json # mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.project.updateissuecommentbyslug-1.json # mobile/rpc-foundation/goldens/matrix-github.pr-comment-mutation-github.resolvereviewthread-1.json # mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.mergepr-1.json # mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.removeprreviewers-1.json # mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.requestprreviewers-1.json # mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.rerunprchecks-1.json # mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.setprautomerge-1.json # mobile/rpc-foundation/goldens/matrix-github.pr-mutation-github.updateprstate-1.json # mobile/rpc-foundation/goldens/matrix-github.pr-read-github.listassignableusers-1.json # mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prcheckdetails-1.json # mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prchecks-1.json # mobile/rpc-foundation/goldens/matrix-github.pr-read-github.prforbranch-1.json # mobile/rpc-foundation/goldens/matrix-github.pr-read-github.reposlug-1.json # mobile/rpc-foundation/goldens/matrix-github.pr-read-github.workitemdetails-1.json # mobile/rpc-foundation/goldens/matrix-github.pr-read-hostedreview.forbranch-1.json # mobile/rpc-foundation/goldens/matrix-github.pr-title-mutation-github.updateprtitle-1.json # mobile/rpc-foundation/goldens/matrix-home.host-accounts-accounts.list-1.json # mobile/rpc-foundation/goldens/matrix-home.host-stats-stats.summary-1.json # mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-1.json # mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-2.json # mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-1-3.json # mobile/rpc-foundation/goldens/matrix-host-worktree-refresh-runtime.clientevents.subscribe-2-1.json # mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.get-1.json # mobile/rpc-foundation/goldens/matrix-host.view-settings-ui.set-1.json # mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.activate-1.json # mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.rm-1.json # mobile/rpc-foundation/goldens/matrix-host.worktree-actions-worktree.set-1.json # mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-git.push-1.json # mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-hostedreview.create-1.json # mobile/rpc-foundation/goldens/matrix-hostedreview.create-chain-worktree.set-1.json # mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.bulkstage-1.json # mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.commit-1.json # mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.generatecommitmessage-1.json # mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.push-1.json # mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-1.json # mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-2.json # mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-3.json # mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-git.status-4.json # mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.create-1.json # mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-1.json # mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-hostedreview.getcreationeligibility-2.json # mobile/rpc-foundation/goldens/matrix-hostedreview.create-intent-worktree.set-1.json # mobile/rpc-foundation/goldens/matrix-hostedreview.eligibility-hostedreview.getcreationeligibility-1.json # mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-1.json # mobile/rpc-foundation/goldens/matrix-legacy-inventory-files.searchpaths-2.json # mobile/rpc-foundation/goldens/matrix-legacy-inventory-fresh-inventory.json # mobile/rpc-foundation/goldens/matrix-legacy-inventory-old-inventory.json # mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.getissue-1.json # mobile/rpc-foundation/goldens/matrix-linear-detail-barrier-linear.issuecomments-1.json # mobile/rpc-foundation/goldens/matrix-linear.select-workspace-picker-linear.selectworkspace-1.json # mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-1.json # mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-1-2.json # mobile/rpc-foundation/goldens/matrix-live-worktree-name-runtime.clientevents.subscribe-2-1.json # mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-1.json # mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-2.json # mobile/rpc-foundation/goldens/matrix-live-worktree-name-worktree.show-3.json # mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-app-js.json # mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-head.json # mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-index-tail.json # mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-fetch-mobileweb.bundle.manifest-1.json # mobile/rpc-foundation/goldens/matrix-mobileweb.bundle-manifest-mobileweb.bundle.manifest-1.json # mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-1.json # mobile/rpc-foundation/goldens/matrix-nativechat.image-paste-terminal.send-2.json # mobile/rpc-foundation/goldens/matrix-nativechat.image-upload-clipboard.startimageupload-1.json # mobile/rpc-foundation/goldens/matrix-nativechat.session-option-pick-settings.mutatenativechatsessionoptions-1.json # mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-orchestration.workerterminaluserinput-1.json # mobile/rpc-foundation/goldens/matrix-nativechat.terminal-write-terminal.send-1.json # mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.getmissedsince-1.json # mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-1.json # mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.subscribe-1-2.json # mobile/rpc-foundation/goldens/matrix-notifications.desktop-stream-notifications.unsubscribe-1.json # mobile/rpc-foundation/goldens/matrix-notifications.display-test-screen-notifications.testpush-1.json # mobile/rpc-foundation/goldens/matrix-notifications.push-dismissal-notifications.getmissedsince-1.json # mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.registerpush-1.json # mobile/rpc-foundation/goldens/matrix-notifications.push-registration-notifications.unregisterpush-1.json # mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-direct-status.json # mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.getendpoints-1.json # mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-pairing.provisionrelay-1.json # mobile/rpc-foundation/goldens/matrix-pairing.pre-profile-relay-status.json # mobile/rpc-foundation/goldens/matrix-project-explicit-false-github.project.updateissuebyslug-1.json # mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-1.json # mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.getendpoints-2.json # mobile/rpc-foundation/goldens/matrix-relay.credential-rotation-pairing.provisionrelay-1.json # mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-1.json # mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.getendpoints-2.json # mobile/rpc-foundation/goldens/matrix-relay.direct-upgrade-pairing.provisionrelay-1.json # mobile/rpc-foundation/goldens/matrix-relay.pairing-recovery-pairing.getendpoints-1.json # mobile/rpc-foundation/goldens/matrix-session.browser-tab-create-browser.tabcreate-1.json # mobile/rpc-foundation/goldens/matrix-session.content-create-files.createfile-1.json # mobile/rpc-foundation/goldens/matrix-session.content-create-files.open-1.json # mobile/rpc-foundation/goldens/matrix-session.content-create-status.get-1.json # mobile/rpc-foundation/goldens/matrix-session.content-create-worktree.show-1.json # mobile/rpc-foundation/goldens/matrix-session.create-terminal-session.tabs.createterminal-1.json # mobile/rpc-foundation/goldens/matrix-session.create-terminal-terminal.send-1.json # mobile/rpc-foundation/goldens/matrix-session.diff-notes-worktree.show-1.json # mobile/rpc-foundation/goldens/matrix-session.diff-review-actions-worktree.set-1.json # mobile/rpc-foundation/goldens/matrix-session.diff-review-base-ref-show.json # mobile/rpc-foundation/goldens/matrix-session.diff-review-git.branchcompare-1.json # mobile/rpc-foundation/goldens/matrix-session.diff-review-git.status-1.json # mobile/rpc-foundation/goldens/matrix-session.diff-review-repo.list-1.json # mobile/rpc-foundation/goldens/matrix-session.diff-review-review-show.json # mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-files.read-1.json # mobile/rpc-foundation/goldens/matrix-session.markdown-disk-fallback-markdown.readtab-1.json # mobile/rpc-foundation/goldens/matrix-session.markdown-save-markdown.savetab-1.json # mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.readsession-1.json # mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-1-1.json # mobile/rpc-foundation/goldens/matrix-session.native-chat-page-nativechat.subscribe-2-1.json # mobile/rpc-foundation/goldens/matrix-session.native-chat-readability-repo.list-1.json # mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-orchestration.workerterminaluserinput-1.json # mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-1.json # mobile/rpc-foundation/goldens/matrix-session.native-chat-stop-terminal.send-2.json # mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.branchcompare-1.json # mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-git.status-1.json # mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-repo.list-1.json # mobile/rpc-foundation/goldens/matrix-session.pr-branch-context-worktree.show-1.json # mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prchecks-1.json # mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-github.prforbranch-1.json # mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-hostedreview.forbranch-1.json # mobile/rpc-foundation/goldens/matrix-session.pr-sidebar-worktree.show-1.json # mobile/rpc-foundation/goldens/matrix-session.pr-triage-session.tabs.createterminal-1.json # mobile/rpc-foundation/goldens/matrix-session.pr-triage-terminal.send-1.json # mobile/rpc-foundation/goldens/matrix-session.review-branch-diff-git.branchdiff-1.json # mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-1.json # mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-2.json # mobile/rpc-foundation/goldens/matrix-session.review-file-diff-git.diff-3.json # mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.discard-1.json # mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-1.json # mobile/rpc-foundation/goldens/matrix-session.review-git-mutations-git.stage-2.json # mobile/rpc-foundation/goldens/matrix-session.review-send-sheet-session.tabs.list-1.json # mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-1.json # mobile/rpc-foundation/goldens/matrix-session.startup-worktree.activate-2.json # mobile/rpc-foundation/goldens/matrix-session.tab-activation-session.tabs.activate-1.json # mobile/rpc-foundation/goldens/matrix-session.tab-activation-terminal.focus-1.json # mobile/rpc-foundation/goldens/matrix-session.tab-close-session-session.tabs.close-1.json # mobile/rpc-foundation/goldens/matrix-session.tab-close-terminal.close-1.json # mobile/rpc-foundation/goldens/matrix-session.tab-documents-markdown.readtab-1.json # mobile/rpc-foundation/goldens/matrix-session.tab-rename-terminal.rename-1.json # mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.activate-1.json # mobile/rpc-foundation/goldens/matrix-session.tab-reveal-session.tabs.list-1.json # mobile/rpc-foundation/goldens/matrix-session.tabs-stream-health-session.tabs.list-1.json # mobile/rpc-foundation/goldens/matrix-session.terminal-display-mode-terminal.setdisplaymode-1.json # mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-orchestration.workerterminaluserinput-1.json # mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.clearbuffer-1.json # mobile/rpc-foundation/goldens/matrix-session.terminal-gesture-input-terminal.send-1.json # mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-orchestration.workerterminaluserinput-1.json # mobile/rpc-foundation/goldens/matrix-session.terminal-input-send-terminal.send-1.json # mobile/rpc-foundation/goldens/matrix-session.terminal-inventory-terminal.list-1.json # mobile/rpc-foundation/goldens/matrix-session.terminal-paste-orchestration.workerterminaluserinput-1.json # mobile/rpc-foundation/goldens/matrix-session.terminal-paste-settings.get-1.json # mobile/rpc-foundation/goldens/matrix-session.terminal-paste-terminal.send-1.json # mobile/rpc-foundation/goldens/matrix-session.worktree-connection-repo.list-1.json # mobile/rpc-foundation/goldens/matrix-session.worktree-connection-settings.get-1.json # mobile/rpc-foundation/goldens/matrix-settings-agent-read-preflight.detectremoteagents-1.json # mobile/rpc-foundation/goldens/matrix-settings-agent-read-repo.list-1.json # mobile/rpc-foundation/goldens/matrix-settings-agent-read-settings.get-1.json # mobile/rpc-foundation/goldens/matrix-settings-best-effort-settings.update-1.json # mobile/rpc-foundation/goldens/matrix-settings.bot-overrides-settings.get-1.json # mobile/rpc-foundation/goldens/matrix-settings.home-providers-linear.status-1.json # mobile/rpc-foundation/goldens/matrix-settings.home-providers-preflight.check-1.json # mobile/rpc-foundation/goldens/matrix-settings.home-providers-settings.get-1.json # mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-preflight.detectagents-1.json # mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-repo.list-1.json # mobile/rpc-foundation/goldens/matrix-settings.new-tab-local-agents-settings.get-1.json # mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.getterminalquickcommands-1.json # mobile/rpc-foundation/goldens/matrix-settings.quick-commands-settings.updateterminalquickcommands-1.json # mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-host.platform-1.json # mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-repo.list-1.json # mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-settings.get-1.json # mobile/rpc-foundation/goldens/matrix-settings.repo-metadata-ssh.listtargetsummaries-1.json # mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-folderworkspace.list-1.json # mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-projectgroup.list-1.json # mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-repo.list-1.json # mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-settings.get-1.json # mobile/rpc-foundation/goldens/matrix-settings.resume-metadata-worktree.ps-1.json # mobile/rpc-foundation/goldens/matrix-settings.task-hydration-linear.status-1.json # mobile/rpc-foundation/goldens/matrix-settings.task-hydration-preflight.check-1.json # mobile/rpc-foundation/goldens/matrix-settings.task-hydration-settings.get-1.json # mobile/rpc-foundation/goldens/matrix-settings.task-hydration-status.get-1.json # mobile/rpc-foundation/goldens/matrix-settings.task-hydration-ui.get-1.json # mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-settings.get-1.json # mobile/rpc-foundation/goldens/matrix-settings.task-workspace-create-worktree.create-1.json # mobile/rpc-foundation/goldens/matrix-settings.task-workspace-settings.get-1.json # mobile/rpc-foundation/goldens/matrix-settings.workspace-context-linear.status-1.json # mobile/rpc-foundation/goldens/matrix-settings.workspace-context-preflight.check-1.json # mobile/rpc-foundation/goldens/matrix-settings.workspace-context-settings.get-1.json # mobile/rpc-foundation/goldens/matrix-settings.workspace-context-ui.get-1.json # mobile/rpc-foundation/goldens/matrix-settings.workspace-submit-settings.get-1.json # mobile/rpc-foundation/goldens/matrix-speech.dictation-chunk-speech.dictation.chunk-1.json # mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.finish-1.json # mobile/rpc-foundation/goldens/matrix-speech.dictation-session-speech.dictation.start-1.json # mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.cancel-1.json # mobile/rpc-foundation/goldens/matrix-speech.dictation-start-speech.dictation.start-1.json # mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.dictation.setup-1.json # mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.delete-1.json # mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.download-1.json # mobile/rpc-foundation/goldens/matrix-speech.setup-sheet-speech.models.list-1.json # mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.addprreviewcomment-1.json # mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.prfilecontents-1.json # mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.rerunprchecks-1.json # mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.resolvereviewthread-1.json # mobile/rpc-foundation/goldens/matrix-tasks.item-checks-files-github.setprfileviewed-1.json # mobile/rpc-foundation/goldens/matrix-tasks.item-comment-github-github.addissuecomment-1.json # mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-gitlab.addissuecomment-1.json # mobile/rpc-foundation/goldens/matrix-tasks.item-comment-gitlab-mr-gitlab.addmrcomment-1.json # mobile/rpc-foundation/goldens/matrix-tasks.item-detail-github-github.workitemdetails-1.json # mobile/rpc-foundation/goldens/matrix-tasks.item-detail-gitlab-gitlab.workitemdetails-1.json # mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.getissue-1.json # mobile/rpc-foundation/goldens/matrix-tasks.item-detail-linear-linear.issuecomments-1.json # mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listassignableusers-1.json # mobile/rpc-foundation/goldens/matrix-tasks.item-detail-metadata-github.listlabels-1.json # mobile/rpc-foundation/goldens/matrix-tasks.item-merge-gitlab-gitlab.mergemr-1.json # mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-github-github.updatepr-1.json # mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-gitlab.updateissue-1.json # mobile/rpc-foundation/goldens/matrix-tasks.item-metadata-gitlab-mr-gitlab.updatemr-1.json # mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addissuecomment-1.json # mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.addprreviewcommentreply-1.json # mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-github.mergepr-1.json # mobile/rpc-foundation/goldens/matrix-tasks.item-reply-merge-linear.updateissue-1.json # mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.prchecks-1.json # mobile/rpc-foundation/goldens/matrix-tasks.item-review-github-github.requestprreviewers-1.json # mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-github.updateissue-1.json # mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-gitlab.updateissue-1.json # mobile/rpc-foundation/goldens/matrix-tasks.item-status-gitlab-mr-gitlab.updatemrstate-1.json # mobile/rpc-foundation/goldens/matrix-tasks.linear-connect-linear.connect-1.json # mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.addissuecomment-1.json # mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.createissue-1.json # mobile/rpc-foundation/goldens/matrix-tasks.linear-item-linear.getissue-1.json # mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.listteams-1.json # mobile/rpc-foundation/goldens/matrix-tasks.linear-team-context-linear.teamstates-1.json # mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.reposlug-1.json # mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitem-1.json # mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-github.workitembyownerrepo-1.json # mobile/rpc-foundation/goldens/matrix-tasks.paste-lookup-gitlab.workitembypath-1.json # mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listaccessible-1.json # mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-1.json # mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.listviews-2.json # mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.resolveref-1.json # mobile/rpc-foundation/goldens/matrix-tasks.project-board-load-github.project.viewtable-1.json # mobile/rpc-foundation/goldens/matrix-tasks.project-repo-slugs-github.reposlug-1.json # mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.addissuecommentbyslug-1.json # mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuebyslug-1.json # mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-issue-github.project.updateissuecommentbyslug-1.json # mobile/rpc-foundation/goldens/matrix-tasks.project-row-comments-pr-github.project.updatepullrequestbyslug-1.json # mobile/rpc-foundation/goldens/matrix-tasks.project-row-detail-github.project.workitemdetailsbyslug-1.json # mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.clearitemfield-1.json # mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateissuetypebyslug-1.json # mobile/rpc-foundation/goldens/matrix-tasks.project-row-fields-github.project.updateitemfield-1.json # mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.addprreviewcomment-1.json # mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.mergepr-1.json # mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.prfilecontents-1.json # mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateissue-1.json # mobile/rpc-foundation/goldens/matrix-tasks.project-row-files-merge-github.updateprstate-1.json # mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listassignableusersbyslug-1.json # mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listissuetypesbyslug-1.json # mobile/rpc-foundation/goldens/matrix-tasks.project-row-metadata-load-github.project.listlabelsbyslug-1.json # mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.prchecks-1.json # mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.requestprreviewers-1.json # mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.rerunprchecks-1.json # mobile/rpc-foundation/goldens/matrix-tasks.project-row-review-checks-github.setprfileviewed-1.json # mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addissuecomment-1.json # mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.addprreviewcommentreply-1.json # mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.project.deleteissuecommentbyslug-1.json # mobile/rpc-foundation/goldens/matrix-tasks.project-row-threads-github.resolvereviewthread-1.json # mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.countworkitems-1.json # mobile/rpc-foundation/goldens/matrix-tasks.provider-load-github.listworkitems-1.json # mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.listteams-1.json # mobile/rpc-foundation/goldens/matrix-tasks.provider-load-linear.status-1.json # mobile/rpc-foundation/goldens/matrix-tasks.provider-load-settings.update-1.json # mobile/rpc-foundation/goldens/matrix-tasks.route-repo-list-repo.list-1.json # mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-github.listworkitems-1.json # mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-gitlab.listworkitems-1.json # mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.listissues-1.json # mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-linear.searchissues-1.json # mobile/rpc-foundation/goldens/matrix-tasks.smart-source-search-repo.searchrefs-1.json # mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-github.createissue-1.json # mobile/rpc-foundation/goldens/matrix-tasks.task-create-github-repo.update-1.json # mobile/rpc-foundation/goldens/matrix-tasks.task-create-gitlab-gitlab.createissue-1.json # mobile/rpc-foundation/goldens/matrix-tasks.task-create-linear-linear.createissue-1.json # mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-items-gitlab.listworkitems-1.json # mobile/rpc-foundation/goldens/matrix-tasks.task-list-gitlab-todos-gitlab.todos-1.json # mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.listissues-1.json # mobile/rpc-foundation/goldens/matrix-tasks.task-list-linear-linear.searchissues-1.json # mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.searchrefs-1.json # mobile/rpc-foundation/goldens/matrix-tasks.workspace-source-repo.sparsepresets-1.json # mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-repo.savesparsepreset-1.json # mobile/rpc-foundation/goldens/matrix-tasks.workspace-sparse-ssh.getstate-1.json # mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-local-preflight.detectagents-1.json # mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-preflight.detectremoteagents-1.json # mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-repo.hooks-1.json # mobile/rpc-foundation/goldens/matrix-tasks.workspace-ssh-ssh.connect-1.json # mobile/rpc-foundation/goldens/matrix-terminal.query-reply-terminal.send-1.json # mobile/rpc-foundation/goldens/matrix-terminal.raw-input-orchestration.workerterminaluserinput-1.json # mobile/rpc-foundation/goldens/matrix-terminal.raw-input-terminal.send-1.json # mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-1.json # mobile/rpc-foundation/goldens/matrix-terminal.takeover-report-orchestration.workerterminaluserinput-2.json # mobile/rpc-foundation/goldens/matrix-terminal.viewport-refit-terminal.updateviewport-1.json # mobile/rpc-foundation/goldens/matrix-transport.capability-probe-status.get-1.json # mobile/rpc-foundation/goldens/matrix-transport.host-status-gates-status.get-1.json # mobile/rpc-foundation/goldens/matrix-transport.pairing-race-direct-status.json # mobile/rpc-foundation/goldens/matrix-transport.pairing-race-relay-status.json # mobile/rpc-foundation/goldens/matrix-worktree.agent-launch-create-agent.launch-1.json # mobile/rpc-foundation/goldens/matrix-worktree.catalog-snapshot-worktree.ps-1.json # mobile/rpc-foundation/goldens/matrix-worktree.create-retry-worktree.create-1.json # mobile/rpc-foundation/goldens/matrix-worktree.home-catalog-worktree.ps-1.json # mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolvemrbase-1.json # mobile/rpc-foundation/goldens/matrix-worktree.hosted-base-worktree.resolveprbase-1.json # mobile/rpc-foundation/goldens/matrix-worktree.retired-names-worktree.listretirednames-1.json # mobile/rpc-foundation/goldens/matrix-worktree.review-link-worktree.set-1.json # mobile/rpc-foundation/goldens/matrix-worktree.runtime-capabilities-status.get-1.json # mobile/rpc-foundation/goldens/matrix-worktree.setup-hook-trust-ui.set-1.json # mobile/rpc-foundation/goldens/mobile-web-bundle-build-changed.json # mobile/rpc-foundation/goldens/mobile-web-bundle-fetch-paged.json # mobile/rpc-foundation/goldens/mobile-web-bundle-manifest-read.json # mobile/rpc-foundation/goldens/mobile-web-bundle-unavailable.json # mobile/rpc-foundation/goldens/native-chat-image-paste-single.json # mobile/rpc-foundation/goldens/native-chat-image-paste-stops-on-rejection.json # mobile/rpc-foundation/goldens/native-chat-image-paste-trailing-image.json # mobile/rpc-foundation/goldens/native-chat-image-paste-two-images.json # mobile/rpc-foundation/goldens/native-chat-image-upload-cancelled.json # mobile/rpc-foundation/goldens/native-chat-image-upload-second-fails.json # mobile/rpc-foundation/goldens/native-chat-image-upload-single.json # mobile/rpc-foundation/goldens/native-chat-image-upload-start-refused.json # mobile/rpc-foundation/goldens/native-chat-image-upload-two.json # mobile/rpc-foundation/goldens/native-chat-page-earlier.json # mobile/rpc-foundation/goldens/native-chat-readability-local-repo.json # mobile/rpc-foundation/goldens/native-chat-readability-refused.json # mobile/rpc-foundation/goldens/native-chat-readability-remote-repo.json # mobile/rpc-foundation/goldens/native-chat-session-option-pick-empty.json # mobile/rpc-foundation/goldens/native-chat-session-option-pick-refused.json # mobile/rpc-foundation/goldens/native-chat-session-option-pick-written.json # mobile/rpc-foundation/goldens/native-chat-stop-accepted.json # mobile/rpc-foundation/goldens/native-chat-stop-both-rejected.json # mobile/rpc-foundation/goldens/native-chat-stop-delivery-unknown.json # mobile/rpc-foundation/goldens/native-chat-write-accepted.json # mobile/rpc-foundation/goldens/native-chat-write-clear-line.json # mobile/rpc-foundation/goldens/native-chat-write-delivery-unknown.json # mobile/rpc-foundation/goldens/native-chat-write-rejected.json # mobile/rpc-foundation/goldens/native-chat-write-typed-command.json # mobile/rpc-foundation/goldens/new-tab-local-agents.json # mobile/rpc-foundation/goldens/new-workspace-repositories-fulfilled.json # mobile/rpc-foundation/goldens/notifications-desktop-stream-closed.json # mobile/rpc-foundation/goldens/notifications-desktop-stream-replayed.json # mobile/rpc-foundation/goldens/notifications-desktop-stream.json # mobile/rpc-foundation/goldens/notifications-display-test-accepted.json # mobile/rpc-foundation/goldens/notifications-display-test-not-registered.json # mobile/rpc-foundation/goldens/notifications-display-test-rate-limited.json # mobile/rpc-foundation/goldens/notifications-display-test-unknown-reason.json # mobile/rpc-foundation/goldens/notifications-push-gateway-rejected.json # mobile/rpc-foundation/goldens/notifications-push-registered.json # mobile/rpc-foundation/goldens/pairing-pre-profile-direct-wins-and-provisions.json # mobile/rpc-foundation/goldens/pairing-pre-profile-provision-unsupported-saves-direct-host.json # mobile/rpc-foundation/goldens/pairing-pre-profile-times-out.json # mobile/rpc-foundation/goldens/pr-branch-identity.json # mobile/rpc-foundation/goldens/pr-branch-repo-context.json # mobile/rpc-foundation/goldens/pr-comment-mutation.json # mobile/rpc-foundation/goldens/pr-comment-resolve-unconfirmed.json # mobile/rpc-foundation/goldens/pr-mutation-in-band-failure.json # mobile/rpc-foundation/goldens/pr-mutation-status.json # mobile/rpc-foundation/goldens/pr-read-fork-routing.json # mobile/rpc-foundation/goldens/pr-read-surface.json # mobile/rpc-foundation/goldens/pr-read-upstream-error.json # mobile/rpc-foundation/goldens/pr-sidebar-checks-refused.json # mobile/rpc-foundation/goldens/pr-sidebar-load.json # mobile/rpc-foundation/goldens/pr-title-mutation.json # mobile/rpc-foundation/goldens/pr-title-unconfirmed.json # mobile/rpc-foundation/goldens/pr-triage-invalid-terminal.json # mobile/rpc-foundation/goldens/pr-triage-launch.json # mobile/rpc-foundation/goldens/pr-triage-send-locked.json # mobile/rpc-foundation/goldens/probe-new-tab-both-refused.json # mobile/rpc-foundation/goldens/probe-new-tab-null-sibling-refused.json # mobile/rpc-foundation/goldens/probe-new-tab-refused-sibling-rejects.json # mobile/rpc-foundation/goldens/probe-new-tab-rejects-sibling-refused.json # mobile/rpc-foundation/goldens/push-dismissal-tray-reconciled.json # mobile/rpc-foundation/goldens/quick-commands-load-refused.json # mobile/rpc-foundation/goldens/quick-commands-loaded-and-saved.json # mobile/rpc-foundation/goldens/quick-commands-save-refused-rolls-back.json # mobile/rpc-foundation/goldens/relay-direct-upgrade-commits.json # mobile/rpc-foundation/goldens/relay-direct-upgrade-unsupported-host-declines.json # mobile/rpc-foundation/goldens/relay-pairing-recovery-invite-authorizes.json # mobile/rpc-foundation/goldens/relay-pairing-recovery-resume-committed.json # mobile/rpc-foundation/goldens/relay-rotation-installs-and-commits.json # mobile/rpc-foundation/goldens/relay-rotation-resumes-committed-pending.json # mobile/rpc-foundation/goldens/review-branch-diff-shapes.json # mobile/rpc-foundation/goldens/review-create-terminal-refused.json # mobile/rpc-foundation/goldens/review-file-diff-shapes.json # mobile/rpc-foundation/goldens/review-git-mutations-run.json # mobile/rpc-foundation/goldens/review-mark-reviewed-persists.json # mobile/rpc-foundation/goldens/review-mark-reviewed-rolls-back.json # mobile/rpc-foundation/goldens/review-open-in-session.json # mobile/rpc-foundation/goldens/review-send-notes-heals-stale-input.json # mobile/rpc-foundation/goldens/review-send-sheet-lists-terminals.json # mobile/rpc-foundation/goldens/review-stage-file.json # mobile/rpc-foundation/goldens/review-stage-refused.json # mobile/rpc-foundation/goldens/sc-base-ref-default.json # mobile/rpc-foundation/goldens/sc-base-ref-repo-fallback.json # mobile/rpc-foundation/goldens/sc-base-ref-unavailable.json # mobile/rpc-foundation/goldens/sc-base-ref-worktree-hit.json # mobile/rpc-foundation/goldens/sc-branch-diff-previewed.json # mobile/rpc-foundation/goldens/sc-changes-loaded.json # mobile/rpc-foundation/goldens/sc-commit-message-cancel-rejected.json # mobile/rpc-foundation/goldens/sc-commit-message-canceled.json # mobile/rpc-foundation/goldens/sc-commit-message-generated.json # mobile/rpc-foundation/goldens/sc-create-existing-review.json # mobile/rpc-foundation/goldens/sc-create-intent-stage-commit-push-create.json # mobile/rpc-foundation/goldens/sc-create-intent-unlisted-provider.json # mobile/rpc-foundation/goldens/sc-create-link-failure-is-non-fatal.json # mobile/rpc-foundation/goldens/sc-create-pushes-then-creates.json # mobile/rpc-foundation/goldens/sc-create-refused-empty-message.json # mobile/rpc-foundation/goldens/sc-create-rejected-empty-message.json # mobile/rpc-foundation/goldens/sc-eligibility-fetched.json # mobile/rpc-foundation/goldens/sc-history-commit-files.json # mobile/rpc-foundation/goldens/sc-history-loaded.json # mobile/rpc-foundation/goldens/sc-pr-link-hosted-review.json # mobile/rpc-foundation/goldens/sc-pr-link-read.json # mobile/rpc-foundation/goldens/sc-pr-link-set.json # mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-refusal.json # mobile/rpc-foundation/goldens/sc-prefill-unavailable-on-rejection.json # mobile/rpc-foundation/goldens/sc-prerequisite-force-with-lease.json # mobile/rpc-foundation/goldens/sc-prerequisite-publish.json # mobile/rpc-foundation/goldens/sc-prerequisite-push.json # mobile/rpc-foundation/goldens/sc-prerequisite-skipped.json # mobile/rpc-foundation/goldens/sc-reveal-first-poll.json # mobile/rpc-foundation/goldens/sc-reveal-timeout.json # mobile/rpc-foundation/goldens/sc-review-commit-inner-failure.json # mobile/rpc-foundation/goldens/sc-review-commit-refused-empty-message.json # mobile/rpc-foundation/goldens/sc-review-commit-rejected.json # mobile/rpc-foundation/goldens/sc-review-commit.json # mobile/rpc-foundation/goldens/sc-review-status-entries-not-array.json # mobile/rpc-foundation/goldens/sc-review-status-normalized.json # mobile/rpc-foundation/goldens/schedules-b3.json # mobile/rpc-foundation/goldens/schedules-settings-home-providers-fulfilled.json # mobile/rpc-foundation/goldens/schedules-settings-new-tab-ssh.json # mobile/rpc-foundation/goldens/schedules-settings-repo-metadata-fulfilled.json # mobile/rpc-foundation/goldens/schedules-settings-resume-metadata-fulfilled.json # mobile/rpc-foundation/goldens/schedules-settings-task-hydration-fulfilled.json # mobile/rpc-foundation/goldens/schedules-settings-workspace-context-fulfilled.json # mobile/rpc-foundation/goldens/session-browser-tab-created.json # mobile/rpc-foundation/goldens/session-create-browser-refused.json # mobile/rpc-foundation/goldens/session-create-browser-tab.json # mobile/rpc-foundation/goldens/session-create-markdown-name-collision.json # mobile/rpc-foundation/goldens/session-create-markdown-note.json # mobile/rpc-foundation/goldens/session-create-terminal-ignores-a-second-create-in-flight.json # mobile/rpc-foundation/goldens/session-create-terminal-launches-an-agent-quick-command.json # mobile/rpc-foundation/goldens/session-create-terminal-refused.json # mobile/rpc-foundation/goldens/session-create-terminal-replaces-active.json # mobile/rpc-foundation/goldens/session-create-terminal-runs-a-quick-command.json # mobile/rpc-foundation/goldens/session-create-terminal-with-prompt.json # mobile/rpc-foundation/goldens/session-create-terminal-without-active-tab.json # mobile/rpc-foundation/goldens/session-create-terminal-without-handle.json # mobile/rpc-foundation/goldens/session-diff-notes-load-refused.json # mobile/rpc-foundation/goldens/session-diff-notes-loaded.json # mobile/rpc-foundation/goldens/session-file-tab-read.json # mobile/rpc-foundation/goldens/session-markdown-disk-read.json # mobile/rpc-foundation/goldens/session-markdown-disk-served.json # mobile/rpc-foundation/goldens/session-markdown-save-conflict.json # mobile/rpc-foundation/goldens/session-markdown-saved.json # mobile/rpc-foundation/goldens/session-markdown-tab-disk-fallback.json # mobile/rpc-foundation/goldens/session-markdown-tab-read.json # mobile/rpc-foundation/goldens/session-markdown-tab-refused.json # mobile/rpc-foundation/goldens/session-startup-both-activation-sites.json # mobile/rpc-foundation/goldens/session-startup-floating-route-skips-activation.json # mobile/rpc-foundation/goldens/session-startup-keeps-terminals-visible-on-reconnect.json # mobile/rpc-foundation/goldens/session-startup-refused-tab-load-still-loads-terminals.json # mobile/rpc-foundation/goldens/session-tab-activation-focus-and-activate.json # mobile/rpc-foundation/goldens/session-tab-activation-refused.json # mobile/rpc-foundation/goldens/session-tab-activation-transport-error.json # mobile/rpc-foundation/goldens/session-tab-close-refused-keeps-tab.json # mobile/rpc-foundation/goldens/session-tab-close-session-tab.json # mobile/rpc-foundation/goldens/session-tab-close-terminal.json # mobile/rpc-foundation/goldens/session-tab-closed.json # mobile/rpc-foundation/goldens/session-tab-rename.json # mobile/rpc-foundation/goldens/session-tab-renamed.json # mobile/rpc-foundation/goldens/session-tabs-health-errored.json # mobile/rpc-foundation/goldens/session-tabs-health-reconciled.json # mobile/rpc-foundation/goldens/session-tabs-health-refused.json # mobile/rpc-foundation/goldens/session-tabs-health-stale-application-revision.json # mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-take-floor.json # mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-device-token.json # mobile/rpc-foundation/goldens/session-terminal-display-mode-auto-without-viewport.json # mobile/rpc-foundation/goldens/session-terminal-display-mode-drops-second-toggle.json # mobile/rpc-foundation/goldens/session-terminal-display-mode-to-desktop.json # mobile/rpc-foundation/goldens/session-terminal-list-dedupes-handles.json # mobile/rpc-foundation/goldens/session-terminal-list-empty-guarded.json # mobile/rpc-foundation/goldens/session-terminal-list-merged.json # mobile/rpc-foundation/goldens/session-terminal-list-refused.json # mobile/rpc-foundation/goldens/settings-bot-overrides-fulfilled.json # mobile/rpc-foundation/goldens/settings-bot-overrides-refresh-refused.json # mobile/rpc-foundation/goldens/settings-bot-overrides-refused.json # mobile/rpc-foundation/goldens/settings-bot-overrides-transport-error.json # mobile/rpc-foundation/goldens/settings-home-coalesced.json # mobile/rpc-foundation/goldens/settings-home-providers-fulfilled.json # mobile/rpc-foundation/goldens/settings-home-providers-refuse-after-data.json # mobile/rpc-foundation/goldens/settings-home-providers-refused.json # mobile/rpc-foundation/goldens/settings-home-providers-transport-error.json # mobile/rpc-foundation/goldens/settings-new-tab-refused.json # mobile/rpc-foundation/goldens/settings-new-tab-ssh.json # mobile/rpc-foundation/goldens/settings-new-tab-transport-error.json # mobile/rpc-foundation/goldens/settings-repo-cache-expiry.json # mobile/rpc-foundation/goldens/settings-repo-metadata-fulfilled.json # mobile/rpc-foundation/goldens/settings-repo-metadata-icons.json # mobile/rpc-foundation/goldens/settings-repo-metadata-refuse-after-data.json # mobile/rpc-foundation/goldens/settings-repo-metadata-refused.json # mobile/rpc-foundation/goldens/settings-repo-metadata-single-host.json # mobile/rpc-foundation/goldens/settings-repo-metadata-transport-error.json # mobile/rpc-foundation/goldens/settings-resume-metadata-fulfilled.json # mobile/rpc-foundation/goldens/settings-resume-metadata-refuse-after-data.json # mobile/rpc-foundation/goldens/settings-resume-metadata-refused.json # mobile/rpc-foundation/goldens/settings-resume-metadata-transport-error.json # mobile/rpc-foundation/goldens/settings-task-hydration-fulfilled.json # mobile/rpc-foundation/goldens/settings-task-hydration-refuse-after-data.json # mobile/rpc-foundation/goldens/settings-task-hydration-refused.json # mobile/rpc-foundation/goldens/settings-task-hydration-transport-error.json # mobile/rpc-foundation/goldens/settings-task-workspace-create-linear.json # mobile/rpc-foundation/goldens/settings-task-workspace-create-pr-start-point.json # mobile/rpc-foundation/goldens/settings-task-workspace-fulfilled.json # mobile/rpc-foundation/goldens/settings-task-workspace-refused.json # mobile/rpc-foundation/goldens/settings-task-workspace-transport-error.json # mobile/rpc-foundation/goldens/settings-task-write.json # mobile/rpc-foundation/goldens/settings-workspace-context-fulfilled.json # mobile/rpc-foundation/goldens/settings-workspace-context-refuse-after-data.json # mobile/rpc-foundation/goldens/settings-workspace-context-refused.json # mobile/rpc-foundation/goldens/settings-workspace-context-transport-error.json # mobile/rpc-foundation/goldens/settings-workspace-submit-fulfilled.json # mobile/rpc-foundation/goldens/settings-workspace-submit-refused.json # mobile/rpc-foundation/goldens/settings-workspace-submit-transport-error.json # mobile/rpc-foundation/goldens/speech-audio-chunk-acknowledged.json # mobile/rpc-foundation/goldens/speech-desktop-start-fulfilled.json # mobile/rpc-foundation/goldens/speech-desktop-start-recording-failed.json # mobile/rpc-foundation/goldens/speech-desktop-start-superseded.json # mobile/rpc-foundation/goldens/speech-dictation-session-cancelled.json # mobile/rpc-foundation/goldens/speech-dictation-session-transcript.json # mobile/rpc-foundation/goldens/speech-setup-sheet-denied-to-mobile.json # mobile/rpc-foundation/goldens/speech-setup-sheet-fulfilled.json # mobile/rpc-foundation/goldens/speech-setup-sheet-legacy-desktop.json # mobile/rpc-foundation/goldens/speech-setup-sheet-model-vocabulary.json # mobile/rpc-foundation/goldens/structured-agent-session-created.json # mobile/rpc-foundation/goldens/structured-launch-created.json # mobile/rpc-foundation/goldens/structured-launch-definitive-refusal.json # mobile/rpc-foundation/goldens/structured-launch-replays-dropped-create.json # mobile/rpc-foundation/goldens/structured-launch-support-refused.json # mobile/rpc-foundation/goldens/structured-launch-unsupported.json # mobile/rpc-foundation/goldens/tasks-route-repo-list.json # mobile/rpc-foundation/goldens/terminal-gesture-flush-and-clear.json # mobile/rpc-foundation/goldens/terminal-input-send-accepted.json # mobile/rpc-foundation/goldens/terminal-input-send-refused.json # mobile/rpc-foundation/goldens/terminal-live-input-accepted.json # mobile/rpc-foundation/goldens/terminal-paste-accepted.json # mobile/rpc-foundation/goldens/terminal-paste-refused.json # mobile/rpc-foundation/goldens/terminal-query-reply-accepted.json # mobile/rpc-foundation/goldens/terminal-query-reply-unsubscribed.json # mobile/rpc-foundation/goldens/terminal-raw-input-refused.json # mobile/rpc-foundation/goldens/terminal-raw-input-reported.json # mobile/rpc-foundation/goldens/terminal-takeover-report-accepted.json # mobile/rpc-foundation/goldens/terminal-takeover-report-retried.json # mobile/rpc-foundation/goldens/terminal-viewport-refit-applied.json # mobile/rpc-foundation/goldens/terminal-viewport-refit-legacy-desktop.json # mobile/rpc-foundation/goldens/terminal-worktree-connection-resolved.json # mobile/rpc-foundation/goldens/tk-create-github.json # mobile/rpc-foundation/goldens/tk-create-gitlab.json # mobile/rpc-foundation/goldens/tk-create-linear.json # mobile/rpc-foundation/goldens/tk-item-checks-files.json # mobile/rpc-foundation/goldens/tk-item-comment-github.json # mobile/rpc-foundation/goldens/tk-item-comment-gitlab-mr.json # mobile/rpc-foundation/goldens/tk-item-comment-gitlab.json # mobile/rpc-foundation/goldens/tk-item-detail-github-reactions.json # mobile/rpc-foundation/goldens/tk-item-detail-github.json # mobile/rpc-foundation/goldens/tk-item-detail-gitlab-reactions.json # mobile/rpc-foundation/goldens/tk-item-detail-gitlab.json # mobile/rpc-foundation/goldens/tk-item-detail-linear.json # mobile/rpc-foundation/goldens/tk-item-detail-metadata.json # mobile/rpc-foundation/goldens/tk-item-merge-gitlab.json # mobile/rpc-foundation/goldens/tk-item-metadata-github.json # mobile/rpc-foundation/goldens/tk-item-metadata-gitlab-mr.json # mobile/rpc-foundation/goldens/tk-item-metadata-gitlab.json # mobile/rpc-foundation/goldens/tk-item-reply-merge.json # mobile/rpc-foundation/goldens/tk-item-review-github.json # mobile/rpc-foundation/goldens/tk-item-status-gitlab-mr.json # mobile/rpc-foundation/goldens/tk-item-status-gitlab.json # mobile/rpc-foundation/goldens/tk-linear-connect.json # mobile/rpc-foundation/goldens/tk-linear-item.json # mobile/rpc-foundation/goldens/tk-linear-team-context.json # mobile/rpc-foundation/goldens/tk-list-gitlab-items.json # mobile/rpc-foundation/goldens/tk-list-gitlab-todos.json # mobile/rpc-foundation/goldens/tk-list-linear.json # mobile/rpc-foundation/goldens/tk-project-board-load.json # mobile/rpc-foundation/goldens/tk-project-repo-slugs.json # mobile/rpc-foundation/goldens/tk-project-row-comments-issue.json # mobile/rpc-foundation/goldens/tk-project-row-comments-pr.json # mobile/rpc-foundation/goldens/tk-project-row-detail.json # mobile/rpc-foundation/goldens/tk-project-row-fields.json # mobile/rpc-foundation/goldens/tk-project-row-files-merge.json # mobile/rpc-foundation/goldens/tk-project-row-metadata-load.json # mobile/rpc-foundation/goldens/tk-project-row-review-checks.json # mobile/rpc-foundation/goldens/tk-project-row-threads.json # mobile/rpc-foundation/goldens/tk-provider-load.json # mobile/rpc-foundation/goldens/transport-capability-probe-cutover-reasks-fast.json # mobile/rpc-foundation/goldens/transport-capability-probe-non-string-capabilities-drop.json # mobile/rpc-foundation/goldens/transport-capability-probe-publishes.json # mobile/rpc-foundation/goldens/transport-capability-probe-refused-backs-off.json # mobile/rpc-foundation/goldens/transport-host-status-gates-drop-keeps-capabilities.json # mobile/rpc-foundation/goldens/transport-host-status-gates-ready.json # mobile/rpc-foundation/goldens/transport-host-status-gates-refused-degrades.json # mobile/rpc-foundation/goldens/transport-pairing-race-both-refused.json # mobile/rpc-foundation/goldens/transport-pairing-race-direct-completes-first.json # mobile/rpc-foundation/goldens/transport-pairing-race-relay-completes-first.json # mobile/rpc-foundation/goldens/transport-pairing-race-relay-wins-when-direct-refused.json # mobile/rpc-foundation/goldens/tw-capabilities-advertised.json # mobile/rpc-foundation/goldens/tw-capabilities-cutover-retried.json # mobile/rpc-foundation/goldens/tw-capabilities-legacy-idempotency.json # mobile/rpc-foundation/goldens/tw-create-retry-agent-launched.json # mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-after-drop.json # mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-while-connected.json # mobile/rpc-foundation/goldens/tw-create-retry-ambiguous-without-idempotency.json # mobile/rpc-foundation/goldens/tw-create-retry-created.json # mobile/rpc-foundation/goldens/tw-create-retry-name-collision.json # mobile/rpc-foundation/goldens/tw-create-retry-unretryable-refusal.json # mobile/rpc-foundation/goldens/tw-create-retry-warning-kept.json # mobile/rpc-foundation/goldens/tw-hosted-base-resolved.json # mobile/rpc-foundation/goldens/tw-hosted-base-soft-error.json # mobile/rpc-foundation/goldens/tw-paste-lookup-resolved.json # mobile/rpc-foundation/goldens/tw-paste-lookup-slug-refused.json # mobile/rpc-foundation/goldens/tw-paste-lookup-slug-unsupported.json # mobile/rpc-foundation/goldens/tw-setup-hook-trust-always.json # mobile/rpc-foundation/goldens/tw-setup-hook-trust-approved.json # mobile/rpc-foundation/goldens/tw-smart-search-all-providers.json # mobile/rpc-foundation/goldens/tw-smart-search-gitlab-provider-error.json # mobile/rpc-foundation/goldens/tw-smart-search-linear-listed.json # mobile/rpc-foundation/goldens/tw-task-preferences-resume-write.json # mobile/rpc-foundation/goldens/tw-workspace-source-presets-refused.json # mobile/rpc-foundation/goldens/tw-workspace-source-presets.json # mobile/rpc-foundation/goldens/tw-workspace-sparse-missing-preset.json # mobile/rpc-foundation/goldens/tw-workspace-sparse-saved.json # mobile/rpc-foundation/goldens/tw-workspace-ssh-connect-refused.json # mobile/rpc-foundation/goldens/tw-workspace-ssh-connected.json # mobile/rpc-foundation/goldens/tw-workspace-ssh-local-agents.json # mobile/rpc-foundation/goldens/tw-workspace-ssh-not-ready.json # mobile/rpc-foundation/goldens/worktree-catalog-snapshot-unreadable.json # mobile/rpc-foundation/goldens/worktree-catalog-snapshot.json # mobile/rpc-foundation/goldens/worktree-home-catalog.json # mobile/rpc-foundation/goldens/worktree-retired-names.json # mobile/rpc-foundation/pilot-scenarios.json # mobile/src/transport/mobile-web-bundle-fetch.ts # mobile/src/transport/mobile-web-bundle-operations.ts
This commit is contained in:
@@ -55,6 +55,8 @@ jobs:
|
||||
CLOUD_SQL_INSTANCE: ${{ inputs.environment == 'production' && 'orca-cloud-auth-db' || 'orca-cloud-staging-auth-db' }}
|
||||
VERIFIED_DEFAULT_MAX_CONNECTIONS_TIER: db-custom-4-15360
|
||||
VERIFIED_DEFAULT_MAX_CONNECTIONS_DATABASE_VERSION: POSTGRES_17
|
||||
# SHOW max_connections on the live instance, 2026-09-16; no flag is set.
|
||||
VERIFIED_DEFAULT_MAX_CONNECTIONS: '500'
|
||||
TF_BACKEND: ${{ inputs.environment == 'production' && 'backend/production.hcl' || 'backend/staging.hcl' }}
|
||||
TF_VARS: ${{ inputs.environment == 'production' && 'environments/production.tfvars' || 'environments/staging.tfvars' }}
|
||||
TOPOLOGY_WORKLOAD_IDENTITY_PROVIDER: ${{ inputs.environment == 'production' && vars.PRODUCTION_GCP_RELAY_ASIA_TOPOLOGY_WORKLOAD_IDENTITY_PROVIDER || vars.STAGING_GCP_RELAY_ASIA_TOPOLOGY_WORKLOAD_IDENTITY_PROVIDER }}
|
||||
@@ -73,6 +75,7 @@ jobs:
|
||||
case "${TARGET_ENVIRONMENT}:${TARGET_CELL_IDS}" in
|
||||
staging:staging-gce-c4) ;;
|
||||
production:production-gce-c27,production-gce-c28,production-gce-c29) ;;
|
||||
production:production-gce-c30) ;;
|
||||
*) echo "cell-ids do not match the reviewed environment topology" >&2; exit 1 ;;
|
||||
esac
|
||||
[[ "${TARGET_IMAGE}" =~ ^us-central1-docker\.pkg\.dev/${GCP_PROJECT_ID}/orca-cloud/relay@sha256:[0-9a-f]{64}$ ]]
|
||||
@@ -118,13 +121,13 @@ jobs:
|
||||
live_max="${live_flag}"
|
||||
live_source=explicit-flag
|
||||
else
|
||||
# The verified production database uses Cloud SQL's 400-connection
|
||||
# default for this exact shape; fail closed if its shape changes.
|
||||
# No flag: the ceiling is the tier default measured for this exact
|
||||
# shape; fail closed if the shape changes.
|
||||
test "$(jq -er '.settings.tier' <<< "${instance}")" = \
|
||||
"${VERIFIED_DEFAULT_MAX_CONNECTIONS_TIER}"
|
||||
test "$(jq -er '.databaseVersion' <<< "${instance}")" = \
|
||||
"${VERIFIED_DEFAULT_MAX_CONNECTIONS_DATABASE_VERSION}"
|
||||
live_max=400
|
||||
live_max="${VERIFIED_DEFAULT_MAX_CONNECTIONS}"
|
||||
live_source=verified-shape-default
|
||||
fi
|
||||
test "${live_max}" = "${checked_max}"
|
||||
@@ -166,6 +169,38 @@ jobs:
|
||||
done
|
||||
echo "file=${file}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- id: live-images
|
||||
name: Plan every non-target cell at the image it serves
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The URL map target pulls every cell's template into the plan, and same-cap rolls
|
||||
# leave committed images behind the served ones; read only templates out of state.
|
||||
cells="${RUNNER_TEMP}/relay-committed-cells.json"
|
||||
live="${RUNNER_TEMP}/relay-live-cell-templates.json"
|
||||
overlay="${RUNNER_TEMP}/relay-asia-live-images.tfvars.json"
|
||||
committed_plan="${RUNNER_TEMP}/relay-committed-cells.tfplan"
|
||||
mapfile -t targets < "${{ steps.targets.outputs.file }}"
|
||||
# Not console: it evaluates every output against state, where a new cell has no MIG yet.
|
||||
terraform -chdir=infra/terraform plan -input=false -refresh=false -lock=false \
|
||||
-var-file="${TF_VARS}" "${targets[@]}" -out="${committed_plan}" > /dev/null
|
||||
terraform -chdir=infra/terraform show -json "${committed_plan}" \
|
||||
| jq -ce '.variables.relay_gce_cells.value | objects' > "${cells}"
|
||||
terraform -chdir=infra/terraform show -json | jq -ce '[
|
||||
.values.root_module.resources[]?
|
||||
| select(.mode == "managed" and
|
||||
.type == "google_compute_instance_template" and .name == "relay_gce_cell")
|
||||
| { index, metadata_startup_script: .values.metadata_startup_script }
|
||||
]' > "${live}"
|
||||
summary="$(node dev/scripts/relay-live-cell-image-overlay.mjs \
|
||||
--cells-json "${cells}" --live-templates-json "${live}" \
|
||||
--cell-ids "${TARGET_CELL_IDS}" --output "${overlay}")"
|
||||
echo "file=${overlay}" >> "${GITHUB_OUTPUT}"
|
||||
{
|
||||
echo "### Live images held for non-target cells"
|
||||
echo "- Cells whose committed image differs from the served one: $(jq -r '.drifted | join(", ")' <<< "${summary}")"
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
- name: Create and validate the saved topology plan
|
||||
id: plan
|
||||
shell: bash
|
||||
@@ -175,7 +210,7 @@ jobs:
|
||||
plan_json="${RUNNER_TEMP}/relay-asia-topology.json"
|
||||
mapfile -t targets < "${{ steps.targets.outputs.file }}"
|
||||
terraform -chdir=infra/terraform plan -input=false -lock-timeout=30s \
|
||||
-var-file="${TF_VARS}" \
|
||||
-var-file="${TF_VARS}" -var-file="${{ steps.live-images.outputs.file }}" \
|
||||
"${targets[@]}" -out="${plan}"
|
||||
terraform -chdir=infra/terraform show -json "${plan}" > "${plan_json}"
|
||||
committed="${RUNNER_TEMP}/relay-committed-asia-topology.json"
|
||||
@@ -221,7 +256,7 @@ jobs:
|
||||
plan="${RUNNER_TEMP}/relay-asia-topology-readback.tfplan"
|
||||
plan_json="${RUNNER_TEMP}/relay-asia-topology-readback.json"
|
||||
terraform -chdir=infra/terraform plan -input=false -lock-timeout=30s \
|
||||
-var-file="${TF_VARS}" \
|
||||
-var-file="${TF_VARS}" -var-file="${{ steps.live-images.outputs.file }}" \
|
||||
"${targets[@]}" -out="${plan}"
|
||||
terraform -chdir=infra/terraform show -json "${plan}" > "${plan_json}"
|
||||
result="$(node dev/scripts/validate-relay-asia-topology-plan.mjs \
|
||||
|
||||
@@ -273,7 +273,7 @@ jobs:
|
||||
EXPECTED_REGION=us-central1
|
||||
EXPECTED_DATABASE_POOL_MAX=
|
||||
;;
|
||||
c27|c28|c29)
|
||||
c27|c28|c29|c30)
|
||||
EXPECTED_HARD_CAP=3000
|
||||
EXPECTED_REGION=asia-east2
|
||||
EXPECTED_DATABASE_POOL_MAX=16
|
||||
|
||||
@@ -39,11 +39,11 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
evidence-run-id:
|
||||
description: Successful staging or C27 evidence workflow run ID; required for production promotion
|
||||
description: Staging evidence run ID for C27, C27 canary run ID for C28/C29; C30 takes none and proves itself by its own canary
|
||||
required: false
|
||||
type: string
|
||||
evidence-run-attempt:
|
||||
description: Exact evidence workflow run attempt; required for production promotion
|
||||
description: Exact evidence workflow run attempt; required with an evidence run ID
|
||||
required: false
|
||||
type: string
|
||||
confirmation:
|
||||
@@ -104,6 +104,8 @@ jobs:
|
||||
test -n "${DEPLOY_SERVICE_ACCOUNT}"
|
||||
[[ "${IMAGE_DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]]
|
||||
evidence_kind=none
|
||||
canary_cell=none
|
||||
artifact_name=none
|
||||
if test "${OPERATION_MODE}" = inspect; then
|
||||
test -z "${EXPECTED_SELECTOR_GENERATION}"
|
||||
test -z "${SELECTOR_ATTEMPT_ID}"
|
||||
@@ -143,28 +145,42 @@ jobs:
|
||||
esac
|
||||
fi
|
||||
if test "${TARGET_ENVIRONMENT}:${OPERATION_MODE}" = production:promote; then
|
||||
[[ "${EVIDENCE_RUN_ID}" =~ ^[1-9][0-9]*$ ]]
|
||||
[[ "${EVIDENCE_RUN_ATTEMPT}" =~ ^[1-9][0-9]*$ ]]
|
||||
case "${TARGET_CELL_IDS}" in
|
||||
production-gce-c27)
|
||||
test "${#SELECTOR_ATTEMPT_ID}" -le 119
|
||||
evidence_kind=staging
|
||||
artifact_name="relay-asia-staging-${EVIDENCE_RUN_ID}-${EVIDENCE_RUN_ATTEMPT}"
|
||||
canary_cell=production-gce-c27
|
||||
;;
|
||||
production-gce-c28,production-gce-c29)
|
||||
evidence_kind=c27
|
||||
artifact_name="relay-asia-c27-canary-${EVIDENCE_RUN_ID}-${EVIDENCE_RUN_ATTEMPT}"
|
||||
;;
|
||||
production-gce-c30)
|
||||
# No earlier proof binds C30's generation; its own canary below rolls it back on failure.
|
||||
canary_cell=production-gce-c30
|
||||
;;
|
||||
*) echo "production promotion wave is not reviewed" >&2; exit 1 ;;
|
||||
esac
|
||||
else
|
||||
fi
|
||||
if test "${evidence_kind}" = none; then
|
||||
test -z "${EVIDENCE_RUN_ID}"
|
||||
test -z "${EVIDENCE_RUN_ATTEMPT}"
|
||||
artifact_name=none
|
||||
else
|
||||
[[ "${EVIDENCE_RUN_ID}" =~ ^[1-9][0-9]*$ ]]
|
||||
[[ "${EVIDENCE_RUN_ATTEMPT}" =~ ^[1-9][0-9]*$ ]]
|
||||
fi
|
||||
canary=false
|
||||
if test "${canary_cell}" != none; then
|
||||
canary=true
|
||||
# Leaves room for the rollback attempt's -rollback suffix within 128 characters.
|
||||
test "${#SELECTOR_ATTEMPT_ID}" -le 119
|
||||
fi
|
||||
{
|
||||
echo "evidence_kind=${evidence_kind}"
|
||||
echo "artifact_name=${artifact_name}"
|
||||
echo "canary=${canary}"
|
||||
echo "canary_cell=${canary_cell}"
|
||||
echo "canary_hostname=${canary_cell##*-}"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
@@ -174,14 +190,14 @@ jobs:
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
package_json_file: cloud/package.json
|
||||
if: ${{ inputs.environment == 'production' && inputs.mode == 'promote' && inputs.cell-ids == 'production-gce-c27' }}
|
||||
if: ${{ steps.inputs.outputs.canary == 'true' }}
|
||||
|
||||
- name: Install exact C27 canary dependencies
|
||||
if: ${{ inputs.environment == 'production' && inputs.mode == 'promote' && inputs.cell-ids == 'production-gce-c27' }}
|
||||
- name: Install exact canary dependencies
|
||||
if: ${{ steps.inputs.outputs.canary == 'true' }}
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build the C27 canary Relay contract
|
||||
if: ${{ inputs.environment == 'production' && inputs.mode == 'promote' && inputs.cell-ids == 'production-gce-c27' }}
|
||||
- name: Build the canary Relay contract
|
||||
if: ${{ steps.inputs.outputs.canary == 'true' }}
|
||||
run: pnpm --filter @orca-cloud/relay-contract build
|
||||
|
||||
- name: Download immutable rollout evidence
|
||||
@@ -249,7 +265,7 @@ jobs:
|
||||
with:
|
||||
bucket: ${{ inputs.environment == 'production' && 'onorca-cloud-terraform-state' || 'onorca-cloud-staging-terraform-state' }}
|
||||
object: ${{ inputs.environment == 'production' && 'terraform/state/cloud-sql-rollout/production.lock' || 'terraform/state/cloud-sql-rollout/staging.lock' }}
|
||||
if: ${{ inputs.mode == 'configure' || (inputs.environment == 'production' && inputs.mode == 'promote' && inputs.cell-ids == 'production-gce-c27') }}
|
||||
if: ${{ inputs.mode == 'configure' || steps.inputs.outputs.canary == 'true' }}
|
||||
|
||||
- uses: hashicorp/setup-terraform@v3
|
||||
if: ${{ inputs.mode == 'configure' }}
|
||||
@@ -292,33 +308,45 @@ jobs:
|
||||
fi
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
- name: Verify C27 state and start the timed canary
|
||||
id: c27-start
|
||||
if: ${{ inputs.environment == 'production' && inputs.mode == 'promote' && inputs.cell-ids == 'production-gce-c27' }}
|
||||
- name: Verify the canary cell state and start the timed canary
|
||||
id: canary-start
|
||||
if: ${{ steps.inputs.outputs.canary == 'true' }}
|
||||
env:
|
||||
ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.auth.outputs.id_token }}
|
||||
CANARY_CELL: ${{ steps.inputs.outputs.canary_cell }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# C30's launch cells were checked general by the promotion; verify reads only C30's digest.
|
||||
case "${CANARY_CELL}" in
|
||||
production-gce-c27)
|
||||
verify_cells=production-gce-c27,production-gce-c28,production-gce-c29
|
||||
expected_states='{"production-gce-c27":"general","production-gce-c28":"migration-only","production-gce-c29":"migration-only"}'
|
||||
;;
|
||||
production-gce-c30)
|
||||
verify_cells=production-gce-c30
|
||||
expected_states='{"production-gce-c30":"general"}'
|
||||
;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
result="$(node dev/scripts/operate-relay-asia-admission.mjs \
|
||||
--environment production \
|
||||
--mode verify \
|
||||
--cell-ids production-gce-c27,production-gce-c28,production-gce-c29 \
|
||||
--cell-ids "${verify_cells}" \
|
||||
--expected-generation "${{ steps.admission-operation.outputs.generation }}" \
|
||||
--image-digest "${IMAGE_DIGEST}")"
|
||||
test "$(jq -r '.states["production-gce-c27"]' <<< "${result}")" = general
|
||||
test "$(jq -r '.states["production-gce-c28"]' <<< "${result}")" = migration-only
|
||||
test "$(jq -r '.states["production-gce-c29"]' <<< "${result}")" = migration-only
|
||||
test "$(jq -cS '.states' <<< "${result}")" = "$(jq -cS '.' <<< "${expected_states}")"
|
||||
echo "verify_cells=${verify_cells}" >> "${GITHUB_OUTPUT}"
|
||||
echo "started_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Run a real five-minute C27 control and splice canary
|
||||
id: c27-load
|
||||
if: ${{ inputs.environment == 'production' && inputs.mode == 'promote' && inputs.cell-ids == 'production-gce-c27' }}
|
||||
- name: Run a real five-minute canary control and splice
|
||||
id: canary-load
|
||||
if: ${{ steps.inputs.outputs.canary == 'true' }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
log="${RUNNER_TEMP}/relay-asia-c27-load.jsonl"
|
||||
report="${RUNNER_TEMP}/relay-asia-c27-load.json"
|
||||
log="${RUNNER_TEMP}/relay-asia-canary-load.jsonl"
|
||||
report="${RUNNER_TEMP}/relay-asia-canary-load.json"
|
||||
node dev/scripts/load-relay-controls.mjs \
|
||||
--director-origin "${DIRECTOR_ORIGIN}" \
|
||||
--auth-origin "${AUTH_ORIGIN}" \
|
||||
@@ -335,31 +363,34 @@ jobs:
|
||||
echo "ended_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Collect regional, Relay SQL, and Cloud SQL canary evidence
|
||||
if: ${{ inputs.environment == 'production' && inputs.mode == 'promote' && inputs.cell-ids == 'production-gce-c27' }}
|
||||
if: ${{ steps.inputs.outputs.canary == 'true' }}
|
||||
env:
|
||||
ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.auth.outputs.id_token }}
|
||||
CANARY_STARTED_AT: ${{ steps.c27-start.outputs.started_at }}
|
||||
CANARY_CELL: ${{ steps.inputs.outputs.canary_cell }}
|
||||
CANARY_STARTED_AT: ${{ steps.canary-start.outputs.started_at }}
|
||||
CANARY_VERIFY_CELLS: ${{ steps.canary-start.outputs.verify_cells }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
result="$(node dev/scripts/operate-relay-asia-admission.mjs \
|
||||
--environment production \
|
||||
--mode verify \
|
||||
--cell-ids production-gce-c27,production-gce-c28,production-gce-c29 \
|
||||
--cell-ids "${CANARY_VERIFY_CELLS}" \
|
||||
--expected-generation "${{ steps.admission-operation.outputs.generation }}" \
|
||||
--image-digest "${IMAGE_DIGEST}")"
|
||||
test "$(jq -r '.states["production-gce-c27"]' <<< "${result}")" = general
|
||||
ended_at="${{ steps.c27-load.outputs.ended_at }}"
|
||||
test "$(jq -r --arg cell "${CANARY_CELL}" '.states[$cell]' <<< "${result}")" = general
|
||||
ended_at="${{ steps.canary-load.outputs.ended_at }}"
|
||||
sleep 60
|
||||
output="${RUNNER_TEMP}/relay-asia-output-evidence"
|
||||
logs="${RUNNER_TEMP}/relay-asia-c27-runtime-metrics.json"
|
||||
logs="${RUNNER_TEMP}/relay-asia-canary-runtime-metrics.json"
|
||||
mkdir -p "${output}"
|
||||
gcloud logging read \
|
||||
"timestamp>=\"${CANARY_STARTED_AT}\" AND timestamp<=\"${ended_at}\" AND jsonPayload.event=\"orca_relay_runtime_metrics\"" \
|
||||
--project "${GCP_PROJECT_ID}" \
|
||||
--limit 20000 \
|
||||
--format json > "${logs}"
|
||||
node dev/scripts/relay-asia-rollout-evidence.mjs create-c27 \
|
||||
node dev/scripts/relay-asia-rollout-evidence.mjs create-canary \
|
||||
--cell-id "${CANARY_CELL}" \
|
||||
--repository "${GITHUB_REPOSITORY}" \
|
||||
--run-id "${GITHUB_RUN_ID}" \
|
||||
--run-attempt "${GITHUB_RUN_ATTEMPT}" \
|
||||
@@ -368,18 +399,18 @@ jobs:
|
||||
--selector-generation "${{ steps.admission-operation.outputs.generation }}" \
|
||||
--started-at "${CANARY_STARTED_AT}" \
|
||||
--ended-at "${ended_at}" \
|
||||
--load-report "${RUNNER_TEMP}/relay-asia-c27-load.json" \
|
||||
--load-report "${RUNNER_TEMP}/relay-asia-canary-load.json" \
|
||||
--logs-json "${logs}" \
|
||||
--output "${output}/evidence.json"
|
||||
jq -r '.metrics | to_entries[] | "- \(.key): \(.value)"' \
|
||||
"${output}/evidence.json" >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
- name: Upload immutable C27 canary evidence
|
||||
id: c27-evidence-upload
|
||||
if: ${{ inputs.environment == 'production' && inputs.mode == 'promote' && inputs.cell-ids == 'production-gce-c27' }}
|
||||
- name: Upload immutable canary evidence
|
||||
id: canary-evidence-upload
|
||||
if: ${{ steps.inputs.outputs.canary == 'true' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: relay-asia-c27-canary-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
name: relay-asia-${{ steps.inputs.outputs.canary_hostname }}-canary-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/relay-asia-output-evidence/evidence.json
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
@@ -393,17 +424,18 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
- name: Return an unproven C27 canary to migration-only
|
||||
if: ${{ always() && inputs.environment == 'production' && inputs.mode == 'promote' && inputs.cell-ids == 'production-gce-c27' && steps.admission-operation.outcome != 'skipped' && steps.c27-evidence-upload.outcome != 'success' }}
|
||||
- name: Return an unproven canary cell to migration-only
|
||||
if: ${{ always() && steps.inputs.outputs.canary == 'true' && steps.admission-operation.outcome != 'skipped' && steps.canary-evidence-upload.outcome != 'success' }}
|
||||
env:
|
||||
ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.auth.outputs.id_token }}
|
||||
CANARY_CELL: ${{ steps.inputs.outputs.canary_cell }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
promoted="$(node dev/scripts/operate-relay-asia-admission.mjs \
|
||||
--environment production \
|
||||
--mode recover-promotion \
|
||||
--cell-ids production-gce-c27 \
|
||||
--cell-ids "${CANARY_CELL}" \
|
||||
--expected-generation "${EXPECTED_SELECTOR_GENERATION}" \
|
||||
--attempt-id "${SELECTOR_ATTEMPT_ID}" \
|
||||
--image-digest "${IMAGE_DIGEST}")"
|
||||
@@ -412,11 +444,11 @@ jobs:
|
||||
result="$(node dev/scripts/operate-relay-asia-admission.mjs \
|
||||
--environment production \
|
||||
--mode rollback \
|
||||
--cell-ids production-gce-c27 \
|
||||
--cell-ids "${CANARY_CELL}" \
|
||||
--expected-generation "${promoted_generation}" \
|
||||
--attempt-id "${SELECTOR_ATTEMPT_ID}-rollback" \
|
||||
--image-digest "${IMAGE_DIGEST}")"
|
||||
test "$(jq -r '.states["production-gce-c27"]' <<< "${result}")" = migration-only
|
||||
test "$(jq -r --arg cell "${CANARY_CELL}" '.states[$cell]' <<< "${result}")" = migration-only
|
||||
|
||||
- name: Require registered migration-only cells before director configuration
|
||||
if: ${{ inputs.mode == 'configure' }}
|
||||
|
||||
@@ -178,6 +178,7 @@ Works with **any CLI agent** — if it runs in a terminal, it runs in Orca.
|
||||
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Grok logo" width="16" valign="middle" /> Grok</kbd></a>
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://dev.meta.ai/docs/muse-code"><kbd><img src="src/shared/agent-icons/muse.png" alt="Muse logo" width="16" valign="middle" /> Muse</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://mimo.xiaomi.com/coder"><kbd><img src="https://www.google.com/s2/favicons?domain=mimo.xiaomi.com&sz=64" alt="MiMo Code logo" width="16" valign="middle" /> MiMo Code</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a>
|
||||
@@ -203,6 +204,7 @@ Works with **any CLI agent** — if it runs in a terminal, it runs in Orca.
|
||||
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" alt="Mistral Vibe logo" width="16" valign="middle" /> Mistral Vibe</kbd></a>
|
||||
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" alt="Qwen Code logo" width="16" valign="middle" /> Qwen Code</kbd></a>
|
||||
<a href="https://support.atlassian.com/rovo/docs/install-and-run-rovo-dev-cli-on-your-device/"><kbd><img src="https://www.google.com/s2/favicons?domain=atlassian.com&sz=64" alt="Rovo Dev logo" width="16" valign="middle" /> Rovo Dev</kbd></a>
|
||||
<a href="https://dev.meta.ai/docs/muse-code"><kbd><img src="https://www.google.com/s2/favicons?domain=dev.meta.ai&sz=64" alt="Muse logo" width="16" valign="middle" /> Muse</kbd></a>
|
||||
<kbd>+ any CLI agent</kbd>
|
||||
</p>
|
||||
|
||||
|
||||
@@ -113,6 +113,8 @@ function report(state, final = false) {
|
||||
...readerQueueEvidence.map(({ increaseBytes }) => increaseBytes)
|
||||
),
|
||||
readerClosesByCode: state.readerClosesByCode,
|
||||
// Every cell a control connected to, so a canary can prove its load reached the target.
|
||||
assignedCellOrigins: [...state.assignedCellOrigins].sort(),
|
||||
controlHeadroom: Math.max(0, state.controls - state.active.size),
|
||||
generatorRssMiB: Number(rssMiB.toFixed(1)),
|
||||
generatorPeakRssMiB: Number(state.generatorPeakRssMiB.toFixed(1)),
|
||||
@@ -241,6 +243,7 @@ const state = {
|
||||
wedgedReaderSplicesClosed: 0,
|
||||
readerEvidence: null,
|
||||
readerClosesByCode: {},
|
||||
assignedCellOrigins: new Set(),
|
||||
generatorBaselineRssMiB,
|
||||
generatorBaselineCpu,
|
||||
generatorPeakRssMiB: generatorBaselineRssMiB,
|
||||
@@ -299,6 +302,8 @@ function observe(type, detail) {
|
||||
if (type === 'connected') {
|
||||
state.active.add(detail.index)
|
||||
state.connected++
|
||||
const cellOrigin = peers.get(detail.index)?.assignedCellUrl()
|
||||
if (cellOrigin) state.assignedCellOrigins.add(cellOrigin)
|
||||
state.peakActive = Math.max(state.peakActive, state.active.size)
|
||||
recordSteadyMinimum()
|
||||
} else if (type === 'closed') {
|
||||
|
||||
@@ -12,16 +12,30 @@ const SHAPES = {
|
||||
staging: {
|
||||
directorOrigin: 'https://relay-staging.onorca.dev',
|
||||
domain: 'relay-staging.onorca.dev',
|
||||
allCells: ['staging-gce-c4']
|
||||
allCells: ['staging-gce-c4'],
|
||||
registrationWaves: [['staging-gce-c4']],
|
||||
promotionWaves: [['staging-gce-c4']]
|
||||
},
|
||||
production: {
|
||||
directorOrigin: 'https://relay.onorca.dev',
|
||||
domain: 'relay.onorca.dev',
|
||||
allCells: ['production-gce-c27', 'production-gce-c28', 'production-gce-c29']
|
||||
allCells: ['production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30'],
|
||||
// The launch set was registered together; each later cell registers alone beside it.
|
||||
registrationWaves: [
|
||||
['production-gce-c27', 'production-gce-c28', 'production-gce-c29'],
|
||||
['production-gce-c30']
|
||||
],
|
||||
promotionWaves: [
|
||||
['production-gce-c27'],
|
||||
['production-gce-c28', 'production-gce-c29'],
|
||||
['production-gce-c30']
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function parseArguments(argv) {
|
||||
const PRODUCTION_CANARY_CELL = 'production-gce-c27'
|
||||
|
||||
export function parseRelayAsiaAdmissionArguments(argv) {
|
||||
const values = {}
|
||||
for (let index = 0; index < argv.length; index += 2) {
|
||||
const key = argv[index]
|
||||
@@ -58,13 +72,17 @@ function parseArguments(argv) {
|
||||
throw new Error('--cell-ids are invalid')
|
||||
}
|
||||
const exact = (expected) => JSON.stringify([...cells].sort()) === JSON.stringify([...expected].sort())
|
||||
const [launchWave] = shape.registrationWaves
|
||||
if (
|
||||
(['inspect', 'initialize', 'register', 'registered', 'verify'].includes(values.mode) &&
|
||||
!exact(shape.allCells)) ||
|
||||
(['promote', 'recover-promotion'].includes(values.mode) && values.environment === 'production' &&
|
||||
!exact(['production-gce-c27']) && !exact(['production-gce-c28', 'production-gce-c29'])) ||
|
||||
(['promote', 'recover-promotion'].includes(values.mode) && values.environment === 'staging' && !exact(shape.allCells)) ||
|
||||
(values.mode === 'rollback' && cells.length === 0)
|
||||
(['inspect', 'verify'].includes(values.mode) &&
|
||||
!exact(shape.allCells) && !shape.registrationWaves.some(exact)) ||
|
||||
// Generation zero predates every later cell, so the boundary freezes only the launch set.
|
||||
(values.mode === 'initialize' && !exact(launchWave)) ||
|
||||
(['register', 'registered'].includes(values.mode) && !shape.registrationWaves.some(exact)) ||
|
||||
(['promote', 'recover-promotion'].includes(values.mode) && !shape.promotionWaves.some(exact)) ||
|
||||
// Rollback takes any reviewed wave or the whole set, never a mixed partial set.
|
||||
(values.mode === 'rollback' && !exact(shape.allCells) &&
|
||||
![...shape.registrationWaves, ...shape.promotionWaves].some(exact))
|
||||
) throw new Error('--cell-ids do not match the reviewed admission wave')
|
||||
const attemptId = values['attempt-id']
|
||||
if (!['inspect', 'verify', 'registered'].includes(values.mode) &&
|
||||
@@ -418,11 +436,19 @@ export async function operateRelayAsiaAdmission(config, dependencies = {}) {
|
||||
if (
|
||||
config.mode === 'promote' &&
|
||||
config.environment === 'production' &&
|
||||
config.cells.includes('production-gce-c28') &&
|
||||
selectorCellState(current.selector, 'production-gce-c27') !== 'general'
|
||||
!config.cells.includes(PRODUCTION_CANARY_CELL) &&
|
||||
selectorCellState(current.selector, PRODUCTION_CANARY_CELL) !== 'general'
|
||||
) {
|
||||
throw new Error('Asia expansion requires the C27 canary to be general')
|
||||
}
|
||||
const [launchWave] = shape.registrationWaves
|
||||
if (
|
||||
config.mode === 'promote' &&
|
||||
!config.cells.some((cellId) => launchWave.includes(cellId)) &&
|
||||
launchWave.some((cellId) => selectorCellState(current.selector, cellId) !== 'general')
|
||||
) {
|
||||
throw new Error('a later Asia cell requires every launch cell to be general')
|
||||
}
|
||||
const result = await applyExactAdmissionSelector(
|
||||
selectorPost,
|
||||
membershipWithStates(current.selector, Object.fromEntries(
|
||||
@@ -434,7 +460,7 @@ export async function operateRelayAsiaAdmission(config, dependencies = {}) {
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
const config = parseArguments(process.argv.slice(2))
|
||||
const config = parseRelayAsiaAdmissionArguments(process.argv.slice(2))
|
||||
if (!config.token) throw new Error('ORCA_RELAY_ADMIN_ID_TOKEN is required')
|
||||
console.log(JSON.stringify(await operateRelayAsiaAdmission(config)))
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { test } from 'node:test'
|
||||
import { operateRelayAsiaAdmission } from './operate-relay-asia-admission.mjs'
|
||||
import {
|
||||
operateRelayAsiaAdmission,
|
||||
parseRelayAsiaAdmissionArguments
|
||||
} from './operate-relay-asia-admission.mjs'
|
||||
|
||||
const digest = `sha256:${'a'.repeat(64)}`
|
||||
const membershipDigest = (membership) =>
|
||||
createHash('sha256').update(JSON.stringify(membership)).digest('hex')
|
||||
|
||||
function harness(initialSelector) {
|
||||
function harness(initialSelector, runtimeDigests = {}) {
|
||||
const initialMembership = structuredClone(initialSelector.membership)
|
||||
let selector = structuredClone(initialSelector)
|
||||
const intents = new Map()
|
||||
@@ -23,7 +26,7 @@ function harness(initialSelector) {
|
||||
cellId: `production-gce-${cell}`,
|
||||
cellUrl: parsed.origin,
|
||||
region: 'asia-east2',
|
||||
imageDigest: digest,
|
||||
imageDigest: runtimeDigests[`production-gce-${cell}`] ?? digest,
|
||||
draining: false,
|
||||
connectionCapacity: { hardCap: 3_000, unobservedBound: 60 }
|
||||
}
|
||||
@@ -510,3 +513,136 @@ test('requires the C27 canary before promoting C28 and C29', async () => {
|
||||
imageDigest: digest, attemptId: 'asia_wave_before_canary', token: 'not-logged'
|
||||
}, subject), /C27 canary/)
|
||||
})
|
||||
|
||||
const launchCells = ['production-gce-c27', 'production-gce-c28', 'production-gce-c29']
|
||||
|
||||
function admissionArguments(environment, mode, cellIds) {
|
||||
return [
|
||||
'--environment', environment, '--mode', mode, '--cell-ids', cellIds,
|
||||
'--image-digest', digest, '--expected-generation', '9', '--attempt-id', 'asia_wave_9'
|
||||
]
|
||||
}
|
||||
|
||||
test('accepts only reviewed Asia admission waves', () => {
|
||||
const accepted = [
|
||||
['inspect', 'production-gce-c27,production-gce-c28,production-gce-c29'],
|
||||
['inspect', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30'],
|
||||
['inspect', 'production-gce-c30'],
|
||||
['verify', 'production-gce-c27,production-gce-c28,production-gce-c29'],
|
||||
['verify', 'production-gce-c30'],
|
||||
['initialize', 'production-gce-c27,production-gce-c28,production-gce-c29'],
|
||||
['register', 'production-gce-c27,production-gce-c28,production-gce-c29'],
|
||||
['register', 'production-gce-c30'],
|
||||
['registered', 'production-gce-c30'],
|
||||
['promote', 'production-gce-c27'],
|
||||
['promote', 'production-gce-c28,production-gce-c29'],
|
||||
['promote', 'production-gce-c30'],
|
||||
['recover-promotion', 'production-gce-c30'],
|
||||
['rollback', 'production-gce-c27'],
|
||||
['rollback', 'production-gce-c28,production-gce-c29'],
|
||||
['rollback', 'production-gce-c30'],
|
||||
['rollback', 'production-gce-c27,production-gce-c28,production-gce-c29'],
|
||||
['rollback', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30']
|
||||
]
|
||||
for (const [mode, cellIds] of accepted) {
|
||||
assert.deepEqual(
|
||||
parseRelayAsiaAdmissionArguments(admissionArguments('production', mode, cellIds)).cells,
|
||||
cellIds.split(','),
|
||||
`${mode} ${cellIds}`
|
||||
)
|
||||
}
|
||||
const rejected = [
|
||||
['initialize', 'production-gce-c30'],
|
||||
['initialize', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30'],
|
||||
['register', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30'],
|
||||
['register', 'production-gce-c29,production-gce-c30'],
|
||||
['registered', 'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30'],
|
||||
['verify', 'production-gce-c27,production-gce-c30'],
|
||||
['promote', 'production-gce-c27,production-gce-c30'],
|
||||
['promote', 'production-gce-c28,production-gce-c29,production-gce-c30'],
|
||||
['promote', 'production-gce-c31'],
|
||||
['rollback', 'production-gce-c27,production-gce-c30'],
|
||||
['rollback', 'production-gce-c28,production-gce-c29,production-gce-c30'],
|
||||
['rollback', 'production-gce-c29'],
|
||||
['register', 'staging-gce-c4']
|
||||
]
|
||||
for (const [mode, cellIds] of rejected) {
|
||||
assert.throws(
|
||||
() => parseRelayAsiaAdmissionArguments(admissionArguments('production', mode, cellIds)),
|
||||
/--cell-ids/,
|
||||
`${mode} ${cellIds}`
|
||||
)
|
||||
}
|
||||
assert.deepEqual(
|
||||
parseRelayAsiaAdmissionArguments(admissionArguments('staging', 'promote', 'staging-gce-c4')).cells,
|
||||
['staging-gce-c4']
|
||||
)
|
||||
assert.throws(
|
||||
() => parseRelayAsiaAdmissionArguments(admissionArguments('staging', 'promote', 'production-gce-c30')),
|
||||
/--cell-ids are invalid/
|
||||
)
|
||||
})
|
||||
|
||||
test('registers C30 alone beside the general launch cells', async () => {
|
||||
const subject = harness({
|
||||
generation: 9,
|
||||
membership: { existingOnly: [], migrationOnly: [], general: [...launchCells] }
|
||||
})
|
||||
const result = await operateRelayAsiaAdmission({
|
||||
environment: 'production', mode: 'register', cells: ['production-gce-c30'],
|
||||
expectedGeneration: 9, imageDigest: digest, attemptId: 'asia_register_c30', token: 'not-logged'
|
||||
}, subject)
|
||||
const request = subject.requests.find(({ path }) => path.endsWith('/add-migration-cells'))
|
||||
assert.deepEqual(request.body.cells, [{
|
||||
cellId: 'production-gce-c30', cellUrl: 'https://c30.relay.onorca.dev', region: 'asia-east2',
|
||||
capacityRequests: 6_000, connectionHardCap: 3_000, connectionUnobservedBound: 60
|
||||
}])
|
||||
assert.deepEqual(result.states, { 'production-gce-c30': 'migration-only' })
|
||||
assert.deepEqual(subject.selector().membership.general, launchCells)
|
||||
})
|
||||
|
||||
test('requires the C27 canary to be general before promoting C30', async () => {
|
||||
const selector = (general) => ({
|
||||
generation: 10,
|
||||
membership: {
|
||||
existingOnly: [],
|
||||
migrationOnly: ['production-gce-c30', ...launchCells.filter((cell) => !general.includes(cell))].sort(),
|
||||
general
|
||||
}
|
||||
})
|
||||
const config = {
|
||||
environment: 'production', mode: 'promote', cells: ['production-gce-c30'],
|
||||
expectedGeneration: 10, imageDigest: digest, attemptId: 'asia_promote_c30', token: 'not-logged'
|
||||
}
|
||||
await assert.rejects(
|
||||
operateRelayAsiaAdmission(config, harness(selector(['production-gce-c28', 'production-gce-c29']))),
|
||||
/C27 canary/
|
||||
)
|
||||
await assert.rejects(
|
||||
operateRelayAsiaAdmission(config, harness(selector(['production-gce-c27', 'production-gce-c29']))),
|
||||
/every launch cell to be general/
|
||||
)
|
||||
const subject = harness(selector([...launchCells]))
|
||||
const result = await operateRelayAsiaAdmission(config, subject)
|
||||
assert.deepEqual(result.states, { 'production-gce-c30': 'general' })
|
||||
assert.equal(subject.requests.filter(({ path }) => path === '/v1/admin/cell-status').length, 1)
|
||||
})
|
||||
|
||||
test('promotes C30 on its own digest while the launch cells serve another', async () => {
|
||||
const c30Digest = `sha256:${'b'.repeat(64)}`
|
||||
const selector = {
|
||||
generation: 10,
|
||||
membership: { existingOnly: [], migrationOnly: ['production-gce-c30'], general: [...launchCells] }
|
||||
}
|
||||
const config = {
|
||||
environment: 'production', mode: 'promote', cells: ['production-gce-c30'],
|
||||
expectedGeneration: 10, imageDigest: c30Digest, attemptId: 'asia_promote_c30', token: 'not-logged'
|
||||
}
|
||||
const digests = { 'production-gce-c30': c30Digest }
|
||||
const result = await operateRelayAsiaAdmission(config, harness(selector, digests))
|
||||
assert.deepEqual(result.states, { 'production-gce-c30': 'general' })
|
||||
await assert.rejects(
|
||||
operateRelayAsiaAdmission({ ...config, imageDigest: digest }, harness(selector, digests)),
|
||||
/production-gce-c30 runtime does not match/
|
||||
)
|
||||
})
|
||||
|
||||
@@ -15,6 +15,9 @@ function argumentsFrom(argv) {
|
||||
return values
|
||||
}
|
||||
|
||||
// Production Asia pools sit 176 ms from Cloud SQL and run at 16; staging C4 stays at 10.
|
||||
const ASIA_DATABASE_POOL_MAX = { production: 16, staging: 10 }
|
||||
|
||||
export function prepareRelayAsiaDirectorCells({ currentCells, topology, cellIds, imageDigest }) {
|
||||
if (!Array.isArray(currentCells) || !topology || Array.isArray(topology)) {
|
||||
throw new Error('director inputs are invalid')
|
||||
@@ -36,7 +39,7 @@ export function prepareRelayAsiaDirectorCells({ currentCells, topology, cellIds,
|
||||
!cell ||
|
||||
cell.region !== 'asia-east2' ||
|
||||
cell.capacity_requests !== 6_000 ||
|
||||
cell.database_pool_max !== 10 ||
|
||||
cell.database_pool_max !== ASIA_DATABASE_POOL_MAX[cellId.split('-')[0]] ||
|
||||
cell.connection_hard_cap !== 3_000 ||
|
||||
cell.connection_unobserved_bound !== 60 ||
|
||||
cell.initially_enabled !== false ||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { prepareRelayAsiaDirectorCells } from './prepare-relay-asia-director-cel
|
||||
const digest = `sha256:${'a'.repeat(64)}`
|
||||
const topologyCell = (ordinal, zone) => ({
|
||||
origin: `https://c${ordinal}.relay.onorca.dev`, region: 'asia-east2', zone,
|
||||
capacity_requests: 6_000, database_pool_max: 10,
|
||||
capacity_requests: 6_000, database_pool_max: 16,
|
||||
connection_hard_cap: 3_000, connection_unobserved_bound: 60,
|
||||
initially_enabled: false,
|
||||
image: `us-central1-docker.pkg.dev/onorca-cloud/orca-cloud/relay@${digest}`
|
||||
@@ -50,6 +50,69 @@ test('is idempotent for an exact existing Asia cell and rejects director drift',
|
||||
}), /director configuration differs/)
|
||||
})
|
||||
|
||||
test('appends C30 after the configured launch cells without touching them', () => {
|
||||
const launch = prepareRelayAsiaDirectorCells({
|
||||
currentCells: [],
|
||||
topology: {
|
||||
'production-gce-c27': topologyCell(27, 'asia-east2-a'),
|
||||
'production-gce-c28': topologyCell(28, 'asia-east2-b'),
|
||||
'production-gce-c29': topologyCell(29, 'asia-east2-c')
|
||||
},
|
||||
cellIds: 'production-gce-c27,production-gce-c28,production-gce-c29',
|
||||
imageDigest: digest
|
||||
})
|
||||
const result = prepareRelayAsiaDirectorCells({
|
||||
currentCells: launch,
|
||||
topology: { 'production-gce-c30': topologyCell(30, 'asia-east2-a') },
|
||||
cellIds: 'production-gce-c30',
|
||||
imageDigest: digest
|
||||
})
|
||||
assert.deepEqual(result.slice(0, 3), launch)
|
||||
assert.deepEqual(result[3], {
|
||||
id: 'production-gce-c30', url: 'https://c30.relay.onorca.dev', capacityRequests: 6_000,
|
||||
region: 'asia-east2', initiallyEnabled: false, connectionHardCap: 3_000,
|
||||
connectionUnobservedBound: 60
|
||||
})
|
||||
})
|
||||
|
||||
test('checks C30 against its own digest, not the launch cells\' digest', () => {
|
||||
const c30Digest = `sha256:${'b'.repeat(64)}`
|
||||
const c30 = {
|
||||
...topologyCell(30, 'asia-east2-a'),
|
||||
image: `us-central1-docker.pkg.dev/onorca-cloud/orca-cloud/relay@${c30Digest}`
|
||||
}
|
||||
const launch = [27, 28, 29].map((ordinal) => ({
|
||||
id: `production-gce-c${ordinal}`, url: `https://c${ordinal}.relay.onorca.dev`,
|
||||
capacityRequests: 6_000, region: 'asia-east2', initiallyEnabled: false,
|
||||
connectionHardCap: 3_000, connectionUnobservedBound: 60
|
||||
}))
|
||||
assert.equal(prepareRelayAsiaDirectorCells({
|
||||
currentCells: launch, topology: { 'production-gce-c30': c30 },
|
||||
cellIds: 'production-gce-c30', imageDigest: c30Digest
|
||||
}).length, 4)
|
||||
assert.throws(() => prepareRelayAsiaDirectorCells({
|
||||
currentCells: launch, topology: { 'production-gce-c30': c30 },
|
||||
cellIds: 'production-gce-c30', imageDigest: digest
|
||||
}), /does not match/)
|
||||
})
|
||||
|
||||
test('pins the Asia pool per environment', () => {
|
||||
const staging = { ...topologyCell(4, 'asia-east2-a'), database_pool_max: 10 }
|
||||
assert.equal(prepareRelayAsiaDirectorCells({
|
||||
currentCells: [], topology: { 'staging-gce-c4': staging },
|
||||
cellIds: 'staging-gce-c4', imageDigest: digest
|
||||
}).length, 1)
|
||||
assert.throws(() => prepareRelayAsiaDirectorCells({
|
||||
currentCells: [], topology: { 'staging-gce-c4': { ...staging, database_pool_max: 16 } },
|
||||
cellIds: 'staging-gce-c4', imageDigest: digest
|
||||
}), /does not match/)
|
||||
assert.throws(() => prepareRelayAsiaDirectorCells({
|
||||
currentCells: [],
|
||||
topology: { 'production-gce-c30': { ...topologyCell(30, 'asia-east2-a'), database_pool_max: 10 } },
|
||||
cellIds: 'production-gce-c30', imageDigest: digest
|
||||
}), /does not match/)
|
||||
})
|
||||
|
||||
test('rejects a mismatching topology state output', () => {
|
||||
const wrong = topologyCell(27, 'asia-east2-a')
|
||||
wrong.database_pool_max = 20
|
||||
|
||||
@@ -3,14 +3,26 @@ import { fileURLToPath } from 'node:url'
|
||||
|
||||
const BOOT_IMAGE = 'https://www.googleapis.com/compute/v1/projects/cos-cloud/global/images/cos-stable-121-18867-528-21'
|
||||
const SHAPES = {
|
||||
staging: { project: 'onorca-cloud-staging', cells: { 'staging-gce-c4': 'asia-east2-a' } },
|
||||
staging: {
|
||||
project: 'onorca-cloud-staging',
|
||||
databasePoolMax: 10,
|
||||
cells: { 'staging-gce-c4': 'asia-east2-a' },
|
||||
waves: [['staging-gce-c4']]
|
||||
},
|
||||
production: {
|
||||
project: 'onorca-cloud',
|
||||
databasePoolMax: 16,
|
||||
cells: {
|
||||
'production-gce-c27': 'asia-east2-a',
|
||||
'production-gce-c28': 'asia-east2-b',
|
||||
'production-gce-c29': 'asia-east2-c'
|
||||
}
|
||||
'production-gce-c29': 'asia-east2-c',
|
||||
'production-gce-c30': 'asia-east2-a'
|
||||
},
|
||||
// The launch set, then each later additive cell; a plan targets one wave, never live cells.
|
||||
waves: [
|
||||
['production-gce-c27', 'production-gce-c28', 'production-gce-c29'],
|
||||
['production-gce-c30']
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,8 +58,10 @@ export function prepareRelayAsiaTopologyInput({
|
||||
const shape = SHAPES[environment]
|
||||
if (!shape) throw new Error('invalid environment')
|
||||
const requested = cellIds.split(',').map((value) => value.trim()).filter(Boolean).sort()
|
||||
const expected = Object.keys(shape.cells).sort()
|
||||
if (new Set(requested).size !== requested.length || JSON.stringify(requested) !== JSON.stringify(expected)) {
|
||||
const expected = shape.waves
|
||||
.map((wave) => [...wave].sort())
|
||||
.find((wave) => JSON.stringify(wave) === JSON.stringify(requested))
|
||||
if (new Set(requested).size !== requested.length || !expected) {
|
||||
throw new Error('cell IDs do not match the reviewed Asia topology')
|
||||
}
|
||||
const prefix = `us-central1-docker.pkg.dev/${shape.project}/orca-cloud/relay@sha256:`
|
||||
@@ -76,7 +90,7 @@ export function prepareRelayAsiaTopologyInput({
|
||||
boot_disk_gb: 30,
|
||||
boot_image: BOOT_IMAGE,
|
||||
capacity_requests: 6_000,
|
||||
database_pool_max: 10,
|
||||
database_pool_max: shape.databasePoolMax,
|
||||
image,
|
||||
initially_enabled: false,
|
||||
connection_hard_cap: 3_000,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { test } from 'node:test'
|
||||
import { prepareRelayAsiaTopologyInput } from './prepare-relay-asia-topology-input.mjs'
|
||||
|
||||
@@ -9,12 +10,13 @@ const additionalRegions = { 'asia-east2': '10.42.1.0/24' }
|
||||
const productionCells = () => Object.fromEntries([
|
||||
[27, 'asia-east2-a'],
|
||||
[28, 'asia-east2-b'],
|
||||
[29, 'asia-east2-c']
|
||||
[29, 'asia-east2-c'],
|
||||
[30, 'asia-east2-a']
|
||||
].map(([ordinal, zone]) => [`production-gce-c${ordinal}`, {
|
||||
hostname: `c${ordinal}`, region: 'asia-east2', zone,
|
||||
machine_type: 'e2-standard-4', boot_disk_gb: 30,
|
||||
boot_image: 'https://www.googleapis.com/compute/v1/projects/cos-cloud/global/images/cos-stable-121-18867-528-21',
|
||||
capacity_requests: 6_000, database_pool_max: 10, image, initially_enabled: false,
|
||||
capacity_requests: 6_000, database_pool_max: 16, image, initially_enabled: false,
|
||||
connection_hard_cap: 3_000, connection_unobserved_bound: 60
|
||||
}]))
|
||||
|
||||
@@ -32,11 +34,54 @@ test('accepts the exact production topology only after it is durably committed',
|
||||
hostname: 'c27', region: 'asia-east2', zone: 'asia-east2-a',
|
||||
machine_type: 'e2-standard-4', boot_disk_gb: 30,
|
||||
boot_image: 'https://www.googleapis.com/compute/v1/projects/cos-cloud/global/images/cos-stable-121-18867-528-21',
|
||||
capacity_requests: 6_000, database_pool_max: 10, image, initially_enabled: false,
|
||||
capacity_requests: 6_000, database_pool_max: 16, image, initially_enabled: false,
|
||||
connection_hard_cap: 3_000, connection_unobserved_bound: 60
|
||||
})
|
||||
})
|
||||
|
||||
test('accepts the additive C30 wave without re-planning the launch cells', () => {
|
||||
const result = prepareRelayAsiaTopologyInput({ existingCells: productionCells(),
|
||||
existingAdditionalRegions: additionalRegions, environment: 'production',
|
||||
cellIds: 'production-gce-c30', image })
|
||||
assert.equal(result.relay_gce_cells['production-gce-c30'].zone, 'asia-east2-a')
|
||||
})
|
||||
|
||||
// Reads the committed file so a reviewed-shape constant cannot drift from what the plan reads.
|
||||
test('matches every committed production Asia cell entry', () => {
|
||||
const tfvars = readFileSync(
|
||||
new URL('../../infra/terraform/environments/production.tfvars', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const committed = {}
|
||||
for (const cellId of Object.keys(productionCells())) {
|
||||
const start = tfvars.indexOf(` "${cellId}" = {`)
|
||||
assert.notEqual(start, -1, `${cellId} is not committed`)
|
||||
const body = tfvars.slice(start, tfvars.indexOf('\n }', start))
|
||||
const cell = {}
|
||||
for (const [, key, raw] of body.matchAll(/^\s+([a-z_]+)\s+=\s+("[^"]*"|\S+)/gm)) {
|
||||
cell[key] = raw.startsWith('"') ? raw.slice(1, -1)
|
||||
: raw === 'true' || raw === 'false' ? raw === 'true' : Number(raw)
|
||||
}
|
||||
committed[cellId] = cell
|
||||
}
|
||||
// Each wave is pinned on its own: C30 launches on the director's digest, not C27's.
|
||||
for (const wave of [
|
||||
'production-gce-c27,production-gce-c28,production-gce-c29',
|
||||
'production-gce-c30'
|
||||
]) {
|
||||
const committedImage = committed[wave.split(',')[0]].image
|
||||
assert.doesNotThrow(() => prepareRelayAsiaTopologyInput({
|
||||
existingCells: committed, existingAdditionalRegions: additionalRegions,
|
||||
environment: 'production', cellIds: wave, image: committedImage
|
||||
}), wave)
|
||||
}
|
||||
assert.throws(() => prepareRelayAsiaTopologyInput({
|
||||
existingCells: committed, existingAdditionalRegions: additionalRegions,
|
||||
environment: 'production', cellIds: 'production-gce-c30',
|
||||
image
|
||||
}), /differs from the reviewed topology/)
|
||||
})
|
||||
|
||||
test('accepts the one exact committed staging Asia cell', () => {
|
||||
const stagingImage = image.replace('onorca-cloud/', 'onorca-cloud-staging/')
|
||||
const stagingCell = {
|
||||
@@ -59,10 +104,24 @@ test('accepts the one exact committed staging Asia cell', () => {
|
||||
})
|
||||
|
||||
test('rejects an uncommitted subnet or cell, partial wave, wrong image, and drift', () => {
|
||||
for (const cellIds of [
|
||||
'production-gce-c27',
|
||||
'production-gce-c27,production-gce-c30',
|
||||
'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30',
|
||||
'production-gce-c30,production-gce-c30',
|
||||
'production-gce-c31'
|
||||
]) {
|
||||
assert.throws(() => prepareRelayAsiaTopologyInput({
|
||||
existingCells: productionCells(), existingAdditionalRegions: additionalRegions,
|
||||
environment: 'production', cellIds, image
|
||||
}), /cell IDs/, cellIds)
|
||||
}
|
||||
const poolDrift = productionCells()
|
||||
poolDrift['production-gce-c30'].database_pool_max = 10
|
||||
assert.throws(() => prepareRelayAsiaTopologyInput({
|
||||
existingCells: productionCells(), existingAdditionalRegions: additionalRegions,
|
||||
environment: 'production', cellIds: 'production-gce-c27', image
|
||||
}), /cell IDs/)
|
||||
existingCells: poolDrift, existingAdditionalRegions: additionalRegions,
|
||||
environment: 'production', cellIds: 'production-gce-c30', image
|
||||
}), /differs from the reviewed topology/)
|
||||
assert.throws(() => prepareRelayAsiaTopologyInput({
|
||||
existingCells: productionCells(), existingAdditionalRegions: additionalRegions,
|
||||
environment: 'production',
|
||||
|
||||
@@ -117,7 +117,7 @@ describe('production Relay capacity cell admission', () => {
|
||||
for (const cellId of [
|
||||
'production-gce-c27', 'production-gce-c28', 'production-gce-c29',
|
||||
// Migration-only canaries: the US-only capacity rollout never touches them either.
|
||||
'production-gce-c17', 'production-gce-c18'
|
||||
'production-gce-c17', 'production-gce-c18', 'production-gce-c30'
|
||||
]) {
|
||||
const hostname = cellId.slice('production-gce-'.length)
|
||||
assert.deepEqual(parseProductionCapacityCellArguments([
|
||||
@@ -134,7 +134,7 @@ describe('production Relay capacity cell admission', () => {
|
||||
paceWindowMs: 0
|
||||
})
|
||||
}
|
||||
for (const cellId of ['production-gce-c12', 'production-gce-c30']) {
|
||||
for (const cellId of ['production-gce-c12', 'production-gce-c31']) {
|
||||
const hostname = cellId.slice('production-gce-'.length)
|
||||
assert.throws(() => parseProductionCapacityCellArguments([
|
||||
'--director-origin', 'https://relay.onorca.dev',
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { fetchAdminOnceMore } from './relay-admin-transient-retry.mjs'
|
||||
|
||||
// Every general cell that carries the rehome identity: the sixteen US cells and the
|
||||
// three asia-east2 cells that drain mis-homed hosts back the other way.
|
||||
const PRODUCTION_CELL = /^production-gce-c(?:7|8|9|10|13|14|15|16|19|20|21|22|23|24|25|26|27|28|29)$/
|
||||
// Every cell that carries the rehome identity: the sixteen US cells and the
|
||||
// four asia-east2 cells that drain mis-homed hosts back the other way.
|
||||
const PRODUCTION_CELL = /^production-gce-c(?:7|8|9|10|13|14|15|16|19|20|21|22|23|24|25|26|27|28|29|30)$/
|
||||
const DIRECTOR_ORIGIN = 'https://relay.onorca.dev'
|
||||
|
||||
export function parseRehomeTrustProbeArguments(argv, environment = process.env) {
|
||||
|
||||
@@ -113,14 +113,16 @@ test('fails when both trust-probe attempts return a transient 503', async () =>
|
||||
})
|
||||
|
||||
test('approves the asia-east2 rehome sources and still rejects unlisted cells', () => {
|
||||
for (const cellId of ['production-gce-c27', 'production-gce-c28', 'production-gce-c29']) {
|
||||
for (const cellId of [
|
||||
'production-gce-c27', 'production-gce-c28', 'production-gce-c29', 'production-gce-c30'
|
||||
]) {
|
||||
const parsed = parseRehomeTrustProbeArguments(
|
||||
argv.map((value) => (value === 'production-gce-c7' ? cellId : value)),
|
||||
environment
|
||||
)
|
||||
assert.equal(parsed.cellId, cellId)
|
||||
}
|
||||
for (const cellId of ['production-gce-c1', 'production-gce-c17', 'production-gce-c30']) {
|
||||
for (const cellId of ['production-gce-c1', 'production-gce-c17', 'production-gce-c31']) {
|
||||
assert.throws(
|
||||
() =>
|
||||
parseRehomeTrustProbeArguments(
|
||||
|
||||
@@ -90,7 +90,7 @@ test('uploads one sanitized machine-readable admission result', () => {
|
||||
assert.match(upload, /retention-days: 7/)
|
||||
assert.ok(
|
||||
workflow.indexOf('Upload sanitized admission result') >
|
||||
workflow.indexOf('Upload immutable C27 canary evidence')
|
||||
workflow.indexOf('Upload immutable canary evidence')
|
||||
)
|
||||
})
|
||||
|
||||
@@ -109,7 +109,7 @@ test('binds selector operations and director configuration to reviewed implement
|
||||
assert.doesNotMatch(workflow, /dns/i)
|
||||
})
|
||||
|
||||
test('requires immutable staged evidence and a timed C27 canary before expansion', () => {
|
||||
test('requires immutable staged evidence and a timed production canary before expansion', () => {
|
||||
assert.match(workflow, /actions: read/)
|
||||
assert.match(workflow, /actions\/download-artifact@v4/)
|
||||
assert.match(workflow, /relay-asia-staging-\$\{EVIDENCE_RUN_ID\}-\$\{EVIDENCE_RUN_ATTEMPT\}/)
|
||||
@@ -122,25 +122,26 @@ test('requires immutable staged evidence and a timed C27 canary before expansion
|
||||
assert.match(workflow, /--duration-seconds 300/)
|
||||
assert.match(workflow, /--required-lease-horizons 2/)
|
||||
assert.match(workflow, /pnpm\/action-setup@v4/)
|
||||
assert.match(workflow, /Install exact C27 canary dependencies/)
|
||||
assert.match(workflow, /Install exact canary dependencies/)
|
||||
assert.match(workflow, /pnpm install --frozen-lockfile/)
|
||||
assert.match(workflow, /pnpm --filter @orca-cloud\/relay-contract build/)
|
||||
assert.ok(
|
||||
workflow.indexOf('Build the C27 canary Relay contract') <
|
||||
workflow.indexOf('Run a real five-minute C27 control and splice canary')
|
||||
workflow.indexOf('Build the canary Relay contract') <
|
||||
workflow.indexOf('Run a real five-minute canary control and splice')
|
||||
)
|
||||
assert.match(workflow, /--load-report "\$\{RUNNER_TEMP\}\/relay-asia-c27-load\.json"/)
|
||||
assert.match(workflow, /states\["production-gce-c28"\].*= migration-only/)
|
||||
assert.match(workflow, /states\["production-gce-c29"\].*= migration-only/)
|
||||
assert.match(workflow, /relay-asia-c27-canary-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/)
|
||||
assert.match(workflow, /id: c27-evidence-upload/)
|
||||
assert.match(workflow, /Return an unproven C27 canary to migration-only/)
|
||||
assert.match(workflow, /steps\.c27-evidence-upload\.outcome != 'success'/)
|
||||
assert.match(workflow, /--load-report "\$\{RUNNER_TEMP\}\/relay-asia-canary-load\.json"/)
|
||||
assert.match(workflow, /"production-gce-c28":"migration-only","production-gce-c29":"migration-only"/)
|
||||
// C28/C29 promotion downloads C27's canary under exactly this name.
|
||||
assert.match(workflow, /relay-asia-\$\{\{ steps\.inputs\.outputs\.canary_hostname \}\}-canary-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/)
|
||||
assert.match(workflow, /echo "canary_hostname=\$\{canary_cell##\*-\}"/)
|
||||
assert.match(workflow, /id: canary-evidence-upload/)
|
||||
assert.match(workflow, /Return an unproven canary cell to migration-only/)
|
||||
assert.match(workflow, /steps\.canary-evidence-upload\.outcome != 'success'/)
|
||||
assert.match(workflow, /--mode recover-promotion[\s\S]*?--attempt-id "\$\{SELECTOR_ATTEMPT_ID\}"/)
|
||||
assert.match(workflow, /--attempt-id "\$\{SELECTOR_ATTEMPT_ID\}-rollback"/)
|
||||
assert.match(workflow, /evidence_kind=c27/)
|
||||
assert.match(workflow, /orca_relay_runtime_metrics/)
|
||||
assert.match(workflow, /relay-asia-rollout-evidence\.mjs create-c27/)
|
||||
assert.match(workflow, /relay-asia-rollout-evidence\.mjs create-canary \\\n\s+--cell-id "\$\{CANARY_CELL\}"/)
|
||||
assert.match(workflow, /retention-days: 7/)
|
||||
assert.match(workflow, /Require the exact director image before promotion/)
|
||||
assert.match(workflow, /DIRECTOR_ORIGIN.*\/v1\/admin\/runtime-status/)
|
||||
@@ -153,6 +154,57 @@ test('requires immutable staged evidence and a timed C27 canary before expansion
|
||||
assert.doesNotMatch(provenance, /--commit-sha "\$\{GITHUB_SHA\}"/)
|
||||
})
|
||||
|
||||
test('binds each production promotion wave to its exact evidence and canary', () => {
|
||||
const cases = /case "\$\{TARGET_CELL_IDS\}" in\n([\s\S]*?)\n\s*esac/.exec(workflow)?.[1]
|
||||
assert.ok(cases)
|
||||
const waves = Object.fromEntries(
|
||||
[...cases.matchAll(/^ {14}([a-z0-9,-]+)\)\n([\s\S]*?);;/gm)].map((match) => [match[1], {
|
||||
evidence: /evidence_kind=([a-z0-9]+)/.exec(match[2])?.[1] ?? 'none',
|
||||
canary: /canary_cell=([a-z0-9-]+)/.exec(match[2])?.[1] ?? 'none'
|
||||
}])
|
||||
)
|
||||
assert.deepEqual(waves, {
|
||||
'production-gce-c27': { evidence: 'staging', canary: 'production-gce-c27' },
|
||||
'production-gce-c28,production-gce-c29': { evidence: 'c27', canary: 'none' },
|
||||
'production-gce-c30': { evidence: 'none', canary: 'production-gce-c30' }
|
||||
})
|
||||
assert.match(cases, /\*\) echo "production promotion wave is not reviewed" >&2; exit 1 ;;/)
|
||||
assert.match(workflow, /if test "\$\{evidence_kind\}" = none; then\n\s+test -z "\$\{EVIDENCE_RUN_ID\}"/)
|
||||
assert.doesNotMatch(workflow, /inputs\.cell-ids == /)
|
||||
})
|
||||
|
||||
test('runs the timed canary and its automatic rollback for C27 and C30 alike', () => {
|
||||
const steps = workflow.split(/\n(?= - )/)
|
||||
const named = (name) => steps.find((step) => step.includes(`name: ${name}`))
|
||||
for (const name of [
|
||||
'Install exact canary dependencies',
|
||||
'Build the canary Relay contract',
|
||||
'Verify the canary cell state and start the timed canary',
|
||||
'Run a real five-minute canary control and splice',
|
||||
'Collect regional, Relay SQL, and Cloud SQL canary evidence',
|
||||
'Upload immutable canary evidence'
|
||||
]) {
|
||||
assert.match(named(name), /if: \$\{\{ steps\.inputs\.outputs\.canary == 'true' \}\}/, name)
|
||||
}
|
||||
assert.match(
|
||||
workflow,
|
||||
/if: \$\{\{ inputs\.mode == 'configure' \|\| steps\.inputs\.outputs\.canary == 'true' \}\}/
|
||||
)
|
||||
const rollback = named('Return an unproven canary cell to migration-only')
|
||||
assert.match(
|
||||
rollback,
|
||||
/if: \$\{\{ always\(\) && steps\.inputs\.outputs\.canary == 'true' && steps\.admission-operation\.outcome != 'skipped' && steps\.canary-evidence-upload\.outcome != 'success' \}\}/
|
||||
)
|
||||
assert.match(rollback, /CANARY_CELL: \$\{\{ steps\.inputs\.outputs\.canary_cell \}\}/)
|
||||
assert.match(rollback, /--mode recover-promotion \\\n\s+--cell-ids "\$\{CANARY_CELL\}"/)
|
||||
assert.match(rollback, /--mode rollback \\\n\s+--cell-ids "\$\{CANARY_CELL\}"/)
|
||||
assert.match(rollback, /'\.states\[\$cell\]' <<< "\$\{result\}"\)" = migration-only/)
|
||||
const start = named('Verify the canary cell state and start the timed canary')
|
||||
assert.match(start, /production-gce-c30\)\n\s+verify_cells=production-gce-c30\n\s+expected_states='\{"production-gce-c30":"general"\}'/)
|
||||
assert.match(start, /test "\$\(jq -cS '\.states' <<< "\$\{result\}"\)" = "\$\(jq -cS '\.' <<< "\$\{expected_states\}"\)"/)
|
||||
assert.match(named('Run a real five-minute canary control and splice'), /--duration-seconds 300/)
|
||||
})
|
||||
|
||||
test('creates staging evidence only after the bounded launch-path load and rollback', () => {
|
||||
assert.match(stagingProof, /runs-on: \[self-hosted, linux, x64, relay-asia-east2-load\]/)
|
||||
assert.doesNotMatch(stagingProof, /group: relay-asia-east2-load/)
|
||||
|
||||
@@ -8,12 +8,17 @@ const ADMISSION_WORKFLOW = relayWorkflowPath('operate-relay-asia-admission.yml')
|
||||
const STAGING_WORKFLOW = relayWorkflowPath('prove-relay-asia-staging.yml')
|
||||
const STAGING_CELL = 'staging-gce-c4'
|
||||
const C27 = 'production-gce-c27'
|
||||
// Each canary proves its own cell under production load; C28/C29 promotion consumes only C27's.
|
||||
const PRODUCTION_CANARIES = {
|
||||
[C27]: { kind: 'production-c27-canary', origin: 'https://c27.relay.onorca.dev' },
|
||||
'production-gce-c30': { kind: 'production-c30-canary', origin: 'https://c30.relay.onorca.dev' }
|
||||
}
|
||||
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/
|
||||
const SHA_PATTERN = /^[a-f0-9]{40}$/
|
||||
const MAX_LOG_EDGE_GAP_MS = 120_000
|
||||
const MAX_LOG_SAMPLE_GAP_MS = 120_000
|
||||
const CLOUD_SQL_LIMIT = 320
|
||||
const C27_CANARY_MINIMUM_MS = 5 * 60_000
|
||||
const CANARY_MINIMUM_MS = 5 * 60_000
|
||||
const GENERATOR_CPU_PERCENT_LIMIT = 80
|
||||
const GENERATOR_EVENT_LOOP_P99_MS_LIMIT = 100
|
||||
const GENERATOR_RSS_GROWTH_MIB_LIMIT = 512
|
||||
@@ -280,28 +285,36 @@ function cloudSqlMaximum(response, start, end) {
|
||||
return Math.max(...values)
|
||||
}
|
||||
|
||||
export function buildC27CanaryEvidence(input) {
|
||||
function productionCanary(cellId) {
|
||||
const canary = PRODUCTION_CANARIES[cellId]
|
||||
if (!canary) throw new Error('canary cell is not a reviewed production Asia canary')
|
||||
return { ...canary, label: cellId.split('-').at(-1).toUpperCase() }
|
||||
}
|
||||
|
||||
export function buildProductionCanaryEvidence(input) {
|
||||
const canary = productionCanary(input.cellId)
|
||||
const start = instant(input.startedAt, 'canary start')
|
||||
const end = instant(input.endedAt, 'canary end')
|
||||
if (end.valueOf() - start.valueOf() < C27_CANARY_MINIMUM_MS) {
|
||||
throw new Error('C27 canary window is shorter than 5 minutes')
|
||||
if (end.valueOf() - start.valueOf() < CANARY_MINIMUM_MS) {
|
||||
throw new Error(`${canary.label} canary window is shorter than 5 minutes`)
|
||||
}
|
||||
const load = object(input.loadReport, 'C27 load report')
|
||||
assertC27CanaryLoad(load)
|
||||
const metrics = runtimeMetrics(input.logs, start, end, C27)
|
||||
assertPassingRuntimeMetrics(metrics, 'C27 canary')
|
||||
const load = object(input.loadReport, `${canary.label} load report`)
|
||||
assertCanaryLoad(load, input.cellId)
|
||||
const metrics = runtimeMetrics(input.logs, start, end, input.cellId)
|
||||
assertPassingRuntimeMetrics(metrics, `${canary.label} canary`)
|
||||
metrics.cloudSqlBackendsMax = cloudSqlMaximum(input.cloudSql, start, end)
|
||||
assertPassingCanary(metrics)
|
||||
assertPassingCanary(metrics, input.cellId)
|
||||
return {
|
||||
...baseEvidence(input, 'production', [C27]),
|
||||
kind: 'production-c27-canary',
|
||||
...baseEvidence(input, 'production', [input.cellId]),
|
||||
kind: canary.kind,
|
||||
window: { startedAt: start.toISOString(), endedAt: end.toISOString() },
|
||||
load,
|
||||
metrics
|
||||
}
|
||||
}
|
||||
|
||||
function assertC27CanaryLoad(report) {
|
||||
function assertCanaryLoad(report, cellId) {
|
||||
const { label, origin } = productionCanary(cellId)
|
||||
if (
|
||||
report.event !== 'relay_load_complete' ||
|
||||
report.controls !== 1 || report.shardCount !== 1 || report.shardIndex !== 0 ||
|
||||
@@ -312,18 +325,22 @@ function assertC27CanaryLoad(report) {
|
||||
report.peakActive !== 1 || report.steadyMinimumActive !== 1 ||
|
||||
report.configuredSplices !== 1 || report.peakActiveSplices !== 1 ||
|
||||
report.completedSplices !== 1 || report.failedSplices !== 0
|
||||
) throw new Error('C27 control and splice canary did not match')
|
||||
) throw new Error(`${label} control and splice canary did not match`)
|
||||
// Placement picks the least-loaded general Asia cell, so the canary's own control must be on it.
|
||||
if (JSON.stringify(report.assignedCellOrigins) !== JSON.stringify([origin])) {
|
||||
throw new Error(`${label} canary load was not placed only on ${label}`)
|
||||
}
|
||||
for (const key of [
|
||||
'connectionFailures', 'unexpectedCloses', 'protocolErrors',
|
||||
'refreshErrors', 'socketErrors'
|
||||
]) {
|
||||
if (number(report[key], key) !== 0) throw new Error(`C27 canary ${key} must be zero`)
|
||||
if (number(report[key], key) !== 0) throw new Error(`${label} canary ${key} must be zero`)
|
||||
}
|
||||
const shutdown = object(report.shutdownEvidence, 'C27 load shutdown evidence')
|
||||
const shutdown = object(report.shutdownEvidence, `${label} load shutdown evidence`)
|
||||
if (
|
||||
shutdown.peerShutdowns !== 1 || shutdown.activeControls !== 0 ||
|
||||
shutdown.activeSplices !== 0 || shutdown.reconnectTimers !== 0
|
||||
) throw new Error('C27 load cleanup is incomplete')
|
||||
) throw new Error(`${label} load cleanup is incomplete`)
|
||||
}
|
||||
|
||||
function runtimeMetrics(logs, start, end, targetCellId) {
|
||||
@@ -406,11 +423,12 @@ function assertPassingRuntimeMetrics(metrics, label, expectedRegionFallbacks = 0
|
||||
) throw new Error(`${label} transient database pool pressure exceeded its bound`)
|
||||
}
|
||||
|
||||
function assertPassingCanary(metrics) {
|
||||
function assertPassingCanary(metrics, cellId) {
|
||||
const { label } = productionCanary(cellId)
|
||||
if (
|
||||
number(metrics.targetControlsMax, 'C27 controls') < 1 ||
|
||||
number(metrics.targetSplicesMax, 'C27 splices') < 1
|
||||
) throw new Error('C27 canary traffic did not reach C27')
|
||||
number(metrics.targetControlsMax, `${label} controls`) < 1 ||
|
||||
number(metrics.targetSplicesMax, `${label} splices`) < 1
|
||||
) throw new Error(`${label} canary traffic did not reach ${label}`)
|
||||
if (number(metrics.cloudSqlBackendsMax, 'Cloud SQL backends') >= CLOUD_SQL_LIMIT) {
|
||||
throw new Error(`Cloud SQL backends must remain below ${CLOUD_SQL_LIMIT}`)
|
||||
}
|
||||
@@ -457,13 +475,16 @@ export function verifyRolloutEvidence(evidence, run, expected) {
|
||||
if (proofTime > now || now.valueOf() - proofTime.valueOf() > maxAgeMs) {
|
||||
throw new Error('rollout evidence is stale')
|
||||
}
|
||||
if (expected.kind === 'production-c27-canary') {
|
||||
const canaryCell = Object.keys(PRODUCTION_CANARIES)
|
||||
.find((cellId) => PRODUCTION_CANARIES[cellId].kind === expected.kind)
|
||||
if (canaryCell) {
|
||||
if (!exactCells(expected.cellIds, [canaryCell])) throw new Error('evidence topology does not match')
|
||||
const start = instant(evidence.window?.startedAt, 'canary start')
|
||||
if (proofTime.valueOf() - start.valueOf() < C27_CANARY_MINIMUM_MS) {
|
||||
throw new Error('C27 canary window is shorter than 5 minutes')
|
||||
if (proofTime.valueOf() - start.valueOf() < CANARY_MINIMUM_MS) {
|
||||
throw new Error(`${productionCanary(canaryCell).label} canary window is shorter than 5 minutes`)
|
||||
}
|
||||
assertC27CanaryLoad(object(evidence.load, 'canary load'))
|
||||
assertPassingCanary(object(evidence.metrics, 'canary metrics'))
|
||||
assertCanaryLoad(object(evidence.load, 'canary load'), canaryCell)
|
||||
assertPassingCanary(object(evidence.metrics, 'canary metrics'), canaryCell)
|
||||
}
|
||||
return evidence
|
||||
}
|
||||
@@ -511,11 +532,11 @@ async function main(argv) {
|
||||
}), null, 2)}\n`)
|
||||
return
|
||||
}
|
||||
if (command === 'create-c27') {
|
||||
if (command === 'create-canary') {
|
||||
const startedAt = required(values, 'started-at')
|
||||
const endedAt = required(values, 'ended-at')
|
||||
writeFileSync(output, `${JSON.stringify(buildC27CanaryEvidence({
|
||||
...commonInput(values), startedAt, endedAt,
|
||||
writeFileSync(output, `${JSON.stringify(buildProductionCanaryEvidence({
|
||||
...commonInput(values), cellId: required(values, 'cell-id'), startedAt, endedAt,
|
||||
loadReport: JSON.parse(readFileSync(required(values, 'load-report'), 'utf8')),
|
||||
logs: JSON.parse(readFileSync(required(values, 'logs-json'), 'utf8')),
|
||||
cloudSql: await readCloudSqlBackends('production', startedAt, endedAt)
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { RELAY_GITHUB_REPOSITORY, relayWorkflowPath } from './relay-repository.mjs'
|
||||
import {
|
||||
buildC27CanaryEvidence,
|
||||
buildProductionCanaryEvidence,
|
||||
buildStagingEvidence,
|
||||
verifyRolloutEvidence
|
||||
} from './relay-asia-rollout-evidence.mjs'
|
||||
@@ -115,18 +115,19 @@ function stagingInput(overrides = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function canaryInput(overrides = {}) {
|
||||
function canaryInput(overrides = {}, cellId = 'production-gce-c27') {
|
||||
const load = loadReport({ controls: 1, shardIndex: 0, splices: 1 })
|
||||
Object.assign(load, {
|
||||
shardCount: 1,
|
||||
configuredSteadySeconds: 300,
|
||||
configuredSpliceHoldSeconds: 60,
|
||||
relayAsiaLoadPrincipalCount: 1
|
||||
relayAsiaLoadPrincipalCount: 1,
|
||||
assignedCellOrigins: [`https://${cellId.split('-').at(-1)}.relay.onorca.dev`]
|
||||
})
|
||||
return {
|
||||
...sourceInput(), startedAt: start.toISOString(), endedAt: canaryEnd.toISOString(),
|
||||
...sourceInput(), cellId, startedAt: start.toISOString(), endedAt: canaryEnd.toISOString(),
|
||||
loadReport: load,
|
||||
logs: completeLogs('production-gce-c27', canaryEnd), cloudSql: cloudSql(319, canaryEnd),
|
||||
logs: completeLogs(cellId, canaryEnd), cloudSql: cloudSql(319, canaryEnd),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
@@ -295,7 +296,7 @@ test('rejects mismatched staging provenance, digest, topology, or age', () => {
|
||||
})
|
||||
|
||||
test('builds and verifies a passing continuous 5-minute C27 canary', () => {
|
||||
const evidence = buildC27CanaryEvidence(canaryInput())
|
||||
const evidence = buildProductionCanaryEvidence(canaryInput())
|
||||
assert.equal(evidence.load.completedSplices, 1)
|
||||
assert.equal(evidence.metrics.asiaSelections, 6)
|
||||
assert.equal(evidence.metrics.cloudSqlBackendsMax, 319)
|
||||
@@ -306,14 +307,14 @@ test('builds and verifies a passing continuous 5-minute C27 canary', () => {
|
||||
})
|
||||
|
||||
test('rejects short, sparse, or unrelated-cell-only C27 coverage', () => {
|
||||
assert.throws(() => buildC27CanaryEvidence(canaryInput({
|
||||
assert.throws(() => buildProductionCanaryEvidence(canaryInput({
|
||||
endedAt: new Date(canaryEnd.valueOf() - 1).toISOString()
|
||||
})), /shorter than 5 minutes/)
|
||||
const sparse = completeLogs('production-gce-c27', canaryEnd).filter((entry) =>
|
||||
entry.timestamp === start.toISOString() || entry.timestamp === canaryEnd.toISOString()
|
||||
)
|
||||
assert.throws(() => buildC27CanaryEvidence(canaryInput({ logs: sparse })), /sampling gap/)
|
||||
assert.throws(() => buildC27CanaryEvidence(canaryInput({
|
||||
assert.throws(() => buildProductionCanaryEvidence(canaryInput({ logs: sparse })), /sampling gap/)
|
||||
assert.throws(() => buildProductionCanaryEvidence(canaryInput({
|
||||
logs: completeLogs('production-gce-c26', canaryEnd)
|
||||
})), /production-gce-c27 metrics has no samples/)
|
||||
})
|
||||
@@ -321,10 +322,10 @@ test('rejects short, sparse, or unrelated-cell-only C27 coverage', () => {
|
||||
test('rejects a C27 canary without a real control and splice', () => {
|
||||
const input = canaryInput()
|
||||
input.loadReport.completedSplices = 0
|
||||
assert.throws(() => buildC27CanaryEvidence(input), /did not match/)
|
||||
assert.throws(() => buildProductionCanaryEvidence(input), /did not match/)
|
||||
const shortHold = canaryInput()
|
||||
shortHold.loadReport.configuredSpliceHoldSeconds = 59
|
||||
assert.throws(() => buildC27CanaryEvidence(shortHold), /did not match/)
|
||||
assert.throws(() => buildProductionCanaryEvidence(shortHold), /did not match/)
|
||||
})
|
||||
|
||||
for (const [label, mutation, message] of [
|
||||
@@ -344,14 +345,74 @@ for (const [label, mutation, message] of [
|
||||
test(`rejects C27 evidence with ${label}`, () => {
|
||||
const input = canaryInput()
|
||||
mutation(input)
|
||||
assert.throws(() => buildC27CanaryEvidence(input), message)
|
||||
assert.throws(() => buildProductionCanaryEvidence(input), message)
|
||||
})
|
||||
}
|
||||
|
||||
test('rejects C27 evidence from a different selector generation', () => {
|
||||
const evidence = buildC27CanaryEvidence(canaryInput())
|
||||
const evidence = buildProductionCanaryEvidence(canaryInput())
|
||||
assert.throws(() => verifyRolloutEvidence(
|
||||
evidence, workflowRun(evidence),
|
||||
verifyExpected('production-c27-canary', { selectorGeneration: 10 })
|
||||
), /selector generation/)
|
||||
})
|
||||
|
||||
test('rejects a C27 canary whose control was placed on another cell', () => {
|
||||
for (const origins of [
|
||||
['https://c28.relay.onorca.dev'],
|
||||
['https://c27.relay.onorca.dev', 'https://c28.relay.onorca.dev'],
|
||||
[],
|
||||
undefined
|
||||
]) {
|
||||
const input = canaryInput()
|
||||
input.loadReport.assignedCellOrigins = origins
|
||||
assert.throws(() => buildProductionCanaryEvidence(input), /C27 canary load was not placed only on C27/)
|
||||
}
|
||||
})
|
||||
|
||||
const c30 = 'production-gce-c30'
|
||||
|
||||
test('builds and verifies a C30 canary from C30 runtime metrics and placement', () => {
|
||||
const evidence = buildProductionCanaryEvidence(canaryInput({}, c30))
|
||||
assert.equal(evidence.kind, 'production-c30-canary')
|
||||
assert.deepEqual(evidence.topology, { cellIds: [c30], selectorGeneration: 9 })
|
||||
assert.equal(verifyRolloutEvidence(
|
||||
evidence, workflowRun(evidence),
|
||||
verifyExpected('production-c30-canary', { cellIds: [c30], selectorGeneration: 9 })
|
||||
), evidence)
|
||||
assert.throws(() => verifyRolloutEvidence(
|
||||
evidence, workflowRun(evidence),
|
||||
verifyExpected('production-c30-canary', { cellIds: [c30], selectorGeneration: 10 })
|
||||
), /selector generation/)
|
||||
assert.throws(() => verifyRolloutEvidence(
|
||||
evidence, workflowRun(evidence), verifyExpected('production-c27-canary', { selectorGeneration: 9 })
|
||||
), /evidence kind is invalid/)
|
||||
})
|
||||
|
||||
test('rejects a C30 canary that C30 did not serve', () => {
|
||||
const onLaunchCell = canaryInput({}, c30)
|
||||
onLaunchCell.loadReport.assignedCellOrigins = ['https://c27.relay.onorca.dev']
|
||||
assert.throws(() => buildProductionCanaryEvidence(onLaunchCell), /C30 canary load was not placed only on C30/)
|
||||
assert.throws(() => buildProductionCanaryEvidence(canaryInput({
|
||||
logs: completeLogs('production-gce-c27', canaryEnd)
|
||||
}, c30)), /production-gce-c30 metrics has no samples/)
|
||||
const idle = canaryInput({}, c30)
|
||||
idle.logs.filter((entry) => entry.jsonPayload.role === 'cell')
|
||||
.forEach((entry) => { entry.jsonPayload.splices = 0 })
|
||||
assert.throws(() => buildProductionCanaryEvidence(idle), /C30 canary traffic did not reach C30/)
|
||||
const poolPressure = canaryInput({}, c30)
|
||||
poolPressure.logs.at(-1).jsonPayload.databasePoolWaiting = 1
|
||||
assert.throws(() => buildProductionCanaryEvidence(poolPressure), /C30 canary databasePoolWaitingMax/)
|
||||
assert.throws(() => buildProductionCanaryEvidence(canaryInput({
|
||||
endedAt: new Date(canaryEnd.valueOf() - 1).toISOString()
|
||||
}, c30)), /C30 canary window is shorter than 5 minutes/)
|
||||
})
|
||||
|
||||
test('accepts canaries only for the reviewed production Asia cells', () => {
|
||||
for (const cellId of ['production-gce-c28', 'production-gce-c29', 'staging-gce-c4', undefined]) {
|
||||
assert.throws(
|
||||
() => buildProductionCanaryEvidence({ ...canaryInput(), cellId }),
|
||||
/not a reviewed production Asia canary/
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -35,6 +35,20 @@ test('uses only its exact workflow-bound topology identity', () => {
|
||||
assert.match(iam, /assertion\.environment == '\$\{var\.environment\}'/)
|
||||
})
|
||||
|
||||
test('accepts only the reviewed Asia topology waves', () => {
|
||||
const cases = /case "\$\{TARGET_ENVIRONMENT\}:\$\{TARGET_CELL_IDS\}" in\n([\s\S]*?)\n\s*esac/
|
||||
.exec(workflow)?.[1]
|
||||
assert.ok(cases)
|
||||
assert.deepEqual(
|
||||
[...cases.matchAll(/^\s*([a-z]+:[a-z0-9,-]+)\) ;;$/gm)].map((match) => match[1]),
|
||||
[
|
||||
'staging:staging-gce-c4',
|
||||
'production:production-gce-c27,production-gce-c28,production-gce-c29',
|
||||
'production:production-gce-c30'
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
test('plans only additive Asia topology and applies the saved plan', () => {
|
||||
assert.doesNotMatch(workflow, /manage_artifact_dns/)
|
||||
for (const target of [
|
||||
@@ -52,8 +66,13 @@ test('plans only additive Asia topology and applies the saved plan', () => {
|
||||
workflow,
|
||||
/\.variables\.relay_gce_additional_region_subnetwork_cidrs\.value/
|
||||
)
|
||||
assert.doesNotMatch(workflow, /terraform -chdir=infra\/terraform console/)
|
||||
assert.equal((workflow.match(/-var-file="\$\{TF_VARS\}"/g) ?? []).length, 2)
|
||||
// Console evaluates every output against state and fails while a declared cell has no MIG.
|
||||
assert.doesNotMatch(workflow, /terraform[^\n]*console/)
|
||||
assert.equal(
|
||||
(workflow.match(/-var-file="\$\{TF_VARS\}" -var-file="\$\{\{ steps\.live-images\.outputs\.file \}\}"/g) ?? []).length,
|
||||
2
|
||||
)
|
||||
assert.equal((workflow.match(/-var-file=/g) ?? []).length, 5)
|
||||
assert.doesNotMatch(workflow, /terraform[^\n]*apply[^\n]*-target/)
|
||||
assert.doesNotMatch(workflow, /google_(?:sql|cloudflare|dns|certificate_manager)/)
|
||||
})
|
||||
@@ -71,6 +90,9 @@ test('checks the connection budget and production live ceiling before planning',
|
||||
assert.match(workflow, /select\(\.name == "max_connections"\)/)
|
||||
assert.match(workflow, /VERIFIED_DEFAULT_MAX_CONNECTIONS_TIER: db-custom-4-15360/)
|
||||
assert.match(workflow, /VERIFIED_DEFAULT_MAX_CONNECTIONS_DATABASE_VERSION: POSTGRES_17/)
|
||||
assert.match(workflow, /VERIFIED_DEFAULT_MAX_CONNECTIONS: '500'/)
|
||||
assert.match(workflow, /live_max="\$\{VERIFIED_DEFAULT_MAX_CONNECTIONS\}"/)
|
||||
assert.doesNotMatch(workflow, /live_max=\d/)
|
||||
assert.match(workflow, /live_source=verified-shape-default/)
|
||||
assert.match(workflow, /test "\$\(jq -er '\.settings\.tier'/)
|
||||
assert.match(workflow, /test "\$\(jq -er '\.databaseVersion'/)
|
||||
@@ -122,3 +144,34 @@ test('the custom role cannot delete topology or mutate SQL and DNS', () => {
|
||||
/resource "google_storage_bucket_iam_member" "github_relay_asia_topology_state_list"[\s\S]*?role\s+= google_project_iam_custom_role\.github_relay_asia_topology_state_list\[0\]\.id/
|
||||
)
|
||||
})
|
||||
|
||||
test('plans every non-target cell at its served image, read from state templates only', () => {
|
||||
const step = /- id: live-images\n[\s\S]*?\n\n/.exec(workflow)?.[0]
|
||||
assert.ok(step)
|
||||
assert.match(step, /terraform -chdir=infra\/terraform show -json \| jq -ce '\[/)
|
||||
assert.match(step, /\.type == "google_compute_instance_template" and \.name == "relay_gce_cell"/)
|
||||
assert.match(step, /\{ index, metadata_startup_script: \.values\.metadata_startup_script \}/)
|
||||
assert.match(step, /relay-live-cell-image-overlay\.mjs/)
|
||||
assert.match(step, /--cell-ids "\$\{TARGET_CELL_IDS\}"/)
|
||||
// The committed map comes from a read-only plan over the same targets, never from console.
|
||||
assert.match(step, /mapfile -t targets < "\$\{\{ steps\.targets\.outputs\.file \}\}"/)
|
||||
assert.match(
|
||||
step,
|
||||
/terraform -chdir=infra\/terraform plan -input=false -refresh=false -lock=false \\\n\s+-var-file="\$\{TF_VARS\}" "\$\{targets\[@\]\}" -out="\$\{committed_plan\}" > \/dev\/null/
|
||||
)
|
||||
assert.match(step, /show -json "\$\{committed_plan\}" \\\n\s+\| jq -ce '\.variables\.relay_gce_cells\.value \| objects' > "\$\{cells\}"/)
|
||||
assert.equal((step.match(/terraform -chdir=infra\/terraform (?:plan|apply)/g) ?? []).length, 1)
|
||||
assert.ok(workflow.indexOf('- id: targets') < workflow.indexOf('- id: live-images'))
|
||||
assert.ok(workflow.indexOf('- id: live-images') < workflow.indexOf('- name: Create and validate the saved topology plan'))
|
||||
})
|
||||
|
||||
test('the deployments output tolerates a cell declared before its topology apply', () => {
|
||||
const outputs = readFileSync(new URL('../../infra/terraform/outputs.tf', import.meta.url), 'utf8')
|
||||
const start = outputs.indexOf('output "relay_gce_cell_deployments" {')
|
||||
const block = outputs.slice(start, outputs.indexOf('\n}\n', start))
|
||||
const lookups = [...block.matchAll(/^\s+\w+\s+=\s+(.*\.relay_gce_cell\[cell_id\].*)$/gm)].map((match) => match[1])
|
||||
assert.equal(lookups.length, 6)
|
||||
for (const lookup of lookups) {
|
||||
assert.match(lookup, /^try\(google_compute_\w+\.relay_gce_cell\[cell_id\]\.\w+, null\)$/)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -7,12 +7,12 @@ import {
|
||||
} from './relay-cloud-sql-connection-budget.mjs'
|
||||
|
||||
test('production shared consumers keep allowance and reserve below the ceiling', () => {
|
||||
// cells: 20 pools at 10 (200) + the three asia-east2 pools at 16 (48).
|
||||
// cells: 20 pools at 10 (200) + the four asia-east2 pools at 16 (64).
|
||||
const report = readRelayCloudSqlConnectionBudget()
|
||||
|
||||
assert.deepEqual(report.consumers, { cells: 248, directors: 15, auth: 20, api: 50 })
|
||||
assert.deepEqual(report.asia, { cells: 3, poolMax: 16 })
|
||||
assert.equal(report.configuredMaximum, 333)
|
||||
assert.deepEqual(report.consumers, { cells: 264, directors: 15, auth: 20, api: 50 })
|
||||
assert.deepEqual(report.asia, { cells: 4, poolMax: 16 })
|
||||
assert.equal(report.configuredMaximum, 349)
|
||||
assert.equal(report.rolloutOverlap.relayDirectorCandidate, 30)
|
||||
assert.equal(report.rolloutOverlap.apiCandidate, 65)
|
||||
assert.equal(report.rolloutOverlap.authCandidate, 35)
|
||||
@@ -22,10 +22,10 @@ test('production shared consumers keep allowance and reserve below the ceiling',
|
||||
assert.equal(report.maintenanceAdminAllowance, 5)
|
||||
assert.equal(report.explicitReserve, 10)
|
||||
assert.equal(report.usableCeiling, 490)
|
||||
assert.equal(report.operatingMaximum, 403)
|
||||
assert.equal(report.remainingWithinUsableCeiling, 87)
|
||||
assert.equal(report.budgetedTotal, 413)
|
||||
assert.equal(report.unallocated, 87)
|
||||
assert.equal(report.operatingMaximum, 419)
|
||||
assert.equal(report.remainingWithinUsableCeiling, 71)
|
||||
assert.equal(report.budgetedTotal, 429)
|
||||
assert.equal(report.unallocated, 71)
|
||||
assert.equal(report.withinBudget, true)
|
||||
})
|
||||
|
||||
@@ -81,6 +81,33 @@ test('excludes fenced cell pools and reads per-cell pool overrides', () => {
|
||||
assert.equal(report.budgetedTotal, 47)
|
||||
})
|
||||
|
||||
test('refuses an Asia cell whose pool differs from its siblings', () => {
|
||||
const budget = (c30PoolMax) => readRelayCloudSqlConnectionBudget({
|
||||
appConsumers: { authInstances: 1, authPoolMax: 10, apiInstances: 1, apiPoolMax: 5, maxConnections: 500 },
|
||||
sources: {
|
||||
productionTfvars: `
|
||||
relay_max_instances = 1
|
||||
relay_gce_fenced_cells = []
|
||||
relay_gce_cells = {
|
||||
${['c27', 'c28', 'c29', 'c30'].map((hostname) => ` "production-gce-${hostname}" = {
|
||||
region = "asia-east2"
|
||||
database_pool_max = ${hostname === 'c30' ? c30PoolMax : 16}
|
||||
}`).join('\n')}
|
||||
}
|
||||
`,
|
||||
terraformVariables: [
|
||||
'variable "relay_director_database_pool_max" { default = 3 }',
|
||||
'variable "push_max_instances" { default = 1 }',
|
||||
'variable "push_database_pool_max" { default = 2 }'
|
||||
].join('\n'),
|
||||
relayConfig: 'export const RELAY_DATABASE_POOL_MAX = 10'
|
||||
},
|
||||
maxConnections: 500
|
||||
})
|
||||
assert.deepEqual(budget(16).asia, { cells: 4, poolMax: 16 })
|
||||
assert.throws(() => budget(10), /Asia Relay cells must use one checked pool maximum/)
|
||||
})
|
||||
|
||||
test('dedicated push scaling does not consume shared capacity', () => {
|
||||
const report = readRelayCloudSqlConnectionBudget({
|
||||
proposedAsiaCellCount: 1,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
// Committed images lag what same-cap rolls serve, and a URL map target pulls every cell's
|
||||
// template into the plan, so a non-target cell must be planned at the image it serves.
|
||||
const IMAGE_PATTERN = /^[a-z0-9.-]+\/[a-z0-9-]+\/[a-z0-9-]+\/relay@sha256:[a-f0-9]{64}$/
|
||||
|
||||
function liveRelayImage(script, committedImage, cellId) {
|
||||
const repository = committedImage.split('@')[0]
|
||||
const pulled = [...script.matchAll(/^docker pull '([^']+)'$/gm)]
|
||||
.map((match) => match[1])
|
||||
.filter((image) => image.split('@')[0] === repository)
|
||||
const digest = /^\s*printf 'ORCA_RELAY_IMAGE_DIGEST=%s\\n' '(sha256:[a-f0-9]{64})'$/m.exec(script)?.[1]
|
||||
if (pulled.length !== 1 || !IMAGE_PATTERN.test(pulled[0]) || pulled[0].split('@')[1] !== digest) {
|
||||
throw new Error(`${cellId} live template has no single pinned Relay image`)
|
||||
}
|
||||
return pulled[0]
|
||||
}
|
||||
|
||||
export function overlayRelayLiveCellImages({ committedCells, liveTemplates, targetCellIds }) {
|
||||
if (!committedCells || Array.isArray(committedCells) || typeof committedCells !== 'object') {
|
||||
throw new Error('committed Relay cells must be an object')
|
||||
}
|
||||
if (!Array.isArray(liveTemplates)) throw new Error('live templates must be an array')
|
||||
const targets = new Set(targetCellIds)
|
||||
for (const cellId of targets) {
|
||||
if (!committedCells[cellId]) throw new Error(`${cellId} is not a committed Relay cell`)
|
||||
}
|
||||
const scripts = new Map()
|
||||
for (const template of liveTemplates) {
|
||||
if (typeof template?.index !== 'string' || typeof template.metadata_startup_script !== 'string') {
|
||||
throw new Error('live template entry is malformed')
|
||||
}
|
||||
if (scripts.has(template.index)) throw new Error(`${template.index} has more than one live template`)
|
||||
scripts.set(template.index, template.metadata_startup_script)
|
||||
}
|
||||
const cells = {}
|
||||
for (const [cellId, cell] of Object.entries(committedCells)) {
|
||||
if (targets.has(cellId)) {
|
||||
cells[cellId] = cell
|
||||
continue
|
||||
}
|
||||
const script = scripts.get(cellId)
|
||||
// A declared cell with no live template would be created here, outside the reviewed wave.
|
||||
if (script === undefined) throw new Error(`${cellId} is not a target and has no live template`)
|
||||
cells[cellId] = { ...cell, image: liveRelayImage(script, cell.image, cellId) }
|
||||
}
|
||||
return { relay_gce_cells: cells }
|
||||
}
|
||||
|
||||
function argumentsFrom(argv) {
|
||||
const values = {}
|
||||
for (let index = 0; index < argv.length; index += 2) {
|
||||
const key = argv[index]
|
||||
const value = argv[index + 1]
|
||||
if (!key?.startsWith('--') || value === undefined) throw new Error('invalid arguments')
|
||||
values[key.slice(2)] = value
|
||||
}
|
||||
for (const key of ['cells-json', 'live-templates-json', 'cell-ids', 'output']) {
|
||||
if (!values[key]) throw new Error(`missing --${key}`)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
function readJsonFile(path, label) {
|
||||
const text = readFileSync(path, 'utf8')
|
||||
if (!text.trim()) throw new Error(`${label} is empty`)
|
||||
return JSON.parse(text)
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
const values = argumentsFrom(process.argv.slice(2))
|
||||
const committedCells = readJsonFile(values['cells-json'], 'committed Relay cells')
|
||||
const overlay = overlayRelayLiveCellImages({
|
||||
committedCells,
|
||||
liveTemplates: readJsonFile(values['live-templates-json'], 'live Relay templates'),
|
||||
targetCellIds: values['cell-ids'].split(',').map((value) => value.trim()).filter(Boolean)
|
||||
})
|
||||
writeFileSync(values.output, `${JSON.stringify(overlay)}\n`)
|
||||
const drifted = Object.keys(committedCells).filter(
|
||||
(cellId) => overlay.relay_gce_cells[cellId].image !== committedCells[cellId].image
|
||||
)
|
||||
console.log(JSON.stringify({ cells: Object.keys(committedCells).length, drifted }))
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { test } from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { overlayRelayLiveCellImages } from './relay-live-cell-image-overlay.mjs'
|
||||
|
||||
const repository = 'us-central1-docker.pkg.dev/onorca-cloud/orca-cloud/relay'
|
||||
const committed = `${repository}@sha256:${'a'.repeat(64)}`
|
||||
const served = `${repository}@sha256:${'b'.repeat(64)}`
|
||||
const template = readFileSync(
|
||||
new URL('../../infra/terraform/relay-gce-startup.sh.tftpl', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
// Renders only the two lines the overlay reads, from the real template, so a template edit fails here.
|
||||
function startupScript(image) {
|
||||
return template
|
||||
.replaceAll('${trimprefix(regex("@sha256:[a-f0-9]{64}$", relay_image), "@")}', image.split('@')[1])
|
||||
.replaceAll('${relay_image}', image)
|
||||
.replaceAll('${cloud_sql_proxy_image}', `gcr.io/cloud-sql-connectors/cloud-sql-proxy@sha256:${'c'.repeat(64)}`)
|
||||
}
|
||||
|
||||
const cell = (image) => ({ hostname: 'x', region: 'asia-east2', zone: 'asia-east2-a', image, connection_hard_cap: 3000 })
|
||||
const committedCells = {
|
||||
'production-gce-c1': cell(committed),
|
||||
'production-gce-c27': cell(committed),
|
||||
'production-gce-c30': cell(committed)
|
||||
}
|
||||
const live = [
|
||||
{ index: 'production-gce-c1', metadata_startup_script: startupScript(committed) },
|
||||
{ index: 'production-gce-c27', metadata_startup_script: startupScript(served) }
|
||||
]
|
||||
|
||||
test('plans every non-target cell at its served image while the target has no template yet', () => {
|
||||
const overlay = overlayRelayLiveCellImages({
|
||||
committedCells, liveTemplates: live, targetCellIds: ['production-gce-c30']
|
||||
})
|
||||
assert.deepEqual(overlay, {
|
||||
relay_gce_cells: {
|
||||
'production-gce-c1': cell(committed),
|
||||
'production-gce-c27': cell(served),
|
||||
'production-gce-c30': cell(committed)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('leaves a target cell at its committed image even once it has a live template', () => {
|
||||
const overlay = overlayRelayLiveCellImages({
|
||||
committedCells,
|
||||
liveTemplates: [...live, { index: 'production-gce-c30', metadata_startup_script: startupScript(served) }],
|
||||
targetCellIds: ['production-gce-c30']
|
||||
})
|
||||
assert.equal(overlay.relay_gce_cells['production-gce-c30'].image, committed)
|
||||
})
|
||||
|
||||
test('refuses a non-target cell that has no live template', () => {
|
||||
assert.throws(() => overlayRelayLiveCellImages({
|
||||
committedCells, liveTemplates: live.slice(1), targetCellIds: ['production-gce-c30']
|
||||
}), /production-gce-c1 is not a target and has no live template/)
|
||||
})
|
||||
|
||||
test('refuses a live template without one pinned Relay image', () => {
|
||||
const digestMismatch = startupScript(served)
|
||||
.replace(`%s\\n' 'sha256:${'b'.repeat(64)}'`, `%s\\n' 'sha256:${'d'.repeat(64)}'`)
|
||||
for (const script of [
|
||||
digestMismatch,
|
||||
startupScript(served).replace(`docker pull '${served}'`, ''),
|
||||
`${startupScript(served)}\ndocker pull '${committed}'`,
|
||||
startupScript(`${repository}:latest`)
|
||||
]) {
|
||||
assert.throws(() => overlayRelayLiveCellImages({
|
||||
committedCells,
|
||||
liveTemplates: [live[0], { index: 'production-gce-c27', metadata_startup_script: script }],
|
||||
targetCellIds: ['production-gce-c30']
|
||||
}), /production-gce-c27 live template has no single pinned Relay image/)
|
||||
}
|
||||
})
|
||||
|
||||
test('refuses duplicate live templates and an undeclared target', () => {
|
||||
assert.throws(() => overlayRelayLiveCellImages({
|
||||
committedCells, liveTemplates: [...live, live[1]], targetCellIds: ['production-gce-c30']
|
||||
}), /more than one live template/)
|
||||
assert.throws(() => overlayRelayLiveCellImages({
|
||||
committedCells, liveTemplates: live, targetCellIds: ['production-gce-c31']
|
||||
}), /production-gce-c31 is not a committed Relay cell/)
|
||||
})
|
||||
|
||||
test('builds the overlay from plan variables when the target cell has no template in state yet', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'relay-live-cell-image-overlay-'))
|
||||
try {
|
||||
const cells = join(dir, 'cells.json')
|
||||
const templates = join(dir, 'templates.json')
|
||||
const output = join(dir, 'overlay.tfvars.json')
|
||||
// Plan variables carry the map as written: optional attributes the tfvars omit stay absent.
|
||||
const { connection_hard_cap: _omitted, ...c30 } = cell(committed)
|
||||
writeFileSync(cells, JSON.stringify({ ...committedCells, 'production-gce-c30': c30 }))
|
||||
writeFileSync(templates, JSON.stringify(live))
|
||||
assert.ok(!live.some(({ index }) => index === 'production-gce-c30'))
|
||||
const run = (cellsPath) => spawnSync(process.execPath, [
|
||||
fileURLToPath(new URL('./relay-live-cell-image-overlay.mjs', import.meta.url)),
|
||||
'--cells-json', cellsPath, '--live-templates-json', templates,
|
||||
'--cell-ids', 'production-gce-c30', '--output', output
|
||||
], { encoding: 'utf8' })
|
||||
const result = run(cells)
|
||||
assert.equal(result.status, 0, result.stderr)
|
||||
assert.deepEqual(JSON.parse(result.stdout), { cells: 3, drifted: ['production-gce-c27'] })
|
||||
assert.deepEqual(JSON.parse(readFileSync(output, 'utf8')).relay_gce_cells['production-gce-c30'], c30)
|
||||
const empty = join(dir, 'empty.json')
|
||||
writeFileSync(empty, '')
|
||||
const failed = run(empty)
|
||||
assert.notEqual(failed.status, 0)
|
||||
assert.match(failed.stderr, /committed Relay cells is empty/)
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -4,7 +4,10 @@ import { requireSameEvidenceCode } from './relay-evidence-code-provenance.mjs'
|
||||
|
||||
// Migration-only by policy: zero hosts and no reservation, so a wave rolls one without
|
||||
// displacing anybody. It enters and must leave migration-only, never general.
|
||||
export const SAME_CAP_MIGRATION_ONLY_CELLS = ['production-gce-c17', 'production-gce-c18']
|
||||
// C30 stays here until its Asia canary promotes it; that follow-up moves it to the general list.
|
||||
export const SAME_CAP_MIGRATION_ONLY_CELLS = [
|
||||
'production-gce-c17', 'production-gce-c18', 'production-gce-c30'
|
||||
]
|
||||
|
||||
export const SAME_CAP_CELLS = [
|
||||
'production-gce-c7', 'production-gce-c8', 'production-gce-c9', 'production-gce-c10',
|
||||
|
||||
@@ -48,12 +48,19 @@ test('requires one canary or a bounded reviewed batch', () => {
|
||||
rollbackDigest,
|
||||
confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} production-gce-c28`
|
||||
}).cells, ['production-gce-c28'])
|
||||
assert.throws(() => validateSameCapWave({
|
||||
assert.deepEqual(validateSameCapWave({
|
||||
mode: 'canary-apply',
|
||||
cellIds: 'production-gce-c30',
|
||||
targetDigest,
|
||||
rollbackDigest,
|
||||
confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} production-gce-c30`
|
||||
}).cells, ['production-gce-c30'])
|
||||
assert.throws(() => validateSameCapWave({
|
||||
mode: 'canary-apply',
|
||||
cellIds: 'production-gce-c31',
|
||||
targetDigest,
|
||||
rollbackDigest,
|
||||
confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} production-gce-c31`
|
||||
}), /cells/)
|
||||
})
|
||||
|
||||
@@ -120,6 +127,17 @@ test('rolls the migration-only cells but never mixes the two classes in one wave
|
||||
confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} ${cellIds}`,
|
||||
canaryRunId: '42'
|
||||
}).cells, ['production-gce-c17', 'production-gce-c18'])
|
||||
// Until its canary promotes it, a same-cap restore must hand C30 back isolated, never activated.
|
||||
assert.equal(entryAdmission('production-gce-c30'), 'migration-only')
|
||||
const asiaMixed = 'production-gce-c29,production-gce-c30'
|
||||
assert.throws(() => validateSameCapWave({
|
||||
mode: 'batch-apply',
|
||||
cellIds: asiaMixed,
|
||||
targetDigest,
|
||||
rollbackDigest,
|
||||
confirmation: `ROLL_RELAY_SAME_CAP ${targetDigest} ${asiaMixed}`,
|
||||
canaryRunId: '42'
|
||||
}), /all general or all migration-only/)
|
||||
// A mixed wave has no single selector delta for its later cells to offset from.
|
||||
const mixed = 'production-gce-c7,production-gce-c17'
|
||||
assert.throws(() => validateSameCapWave({
|
||||
|
||||
@@ -20,7 +20,7 @@ test('same-cap wrapper is reusable, canary-bound, and sequential', () => {
|
||||
assert.match(wrapper, /needs: \[gate, cell_2\]/)
|
||||
assert.match(wrapper, /needs: \[gate, cell_3\]/)
|
||||
assert.match(job, /on:\n workflow_call:/)
|
||||
assert.match(job, /c27\|c28\|c29/)
|
||||
assert.match(job, /c27\|c28\|c29\|c30\)/)
|
||||
assert.match(job, /EXPECTED_HARD_CAP=3000/)
|
||||
assert.match(job, /EXPECTED_REGION=asia-east2/)
|
||||
assert.match(job, /--hard-cap "\$\{EXPECTED_HARD_CAP\}"/)
|
||||
|
||||
@@ -292,7 +292,7 @@ describe('same-cap roll scripts accept every same-cap cell', () => {
|
||||
assert.equal(String(cellShape(cellId).cap), tfvarsHardCap(cellId), cellId)
|
||||
}
|
||||
assert.equal(resolveCellShape('production-gce-c12').status, 1)
|
||||
assert.equal(resolveCellShape('production-gce-c30').status, 1)
|
||||
assert.equal(resolveCellShape('production-gce-c31').status, 1)
|
||||
})
|
||||
|
||||
it('passes the same-cap allowlist on every canary invocation the job runs', () => {
|
||||
@@ -367,9 +367,10 @@ describe('same-cap roll scripts accept every same-cap cell', () => {
|
||||
const trusted = SAME_CAP_CELLS.filter((cell) => REHOME_SOURCE_CELLS.has(cell))
|
||||
// Only a declared rehome source may roll at a trusted protocol at all; the job refuses
|
||||
// the rest before it plans, and the next test covers them at protocol 0.
|
||||
// C30 is already a rehome source but stays migration-only until its Asia canary promotes it.
|
||||
assert.deepEqual(
|
||||
SAME_CAP_CELLS.filter((cell) => !REHOME_SOURCE_CELLS.has(cell)),
|
||||
SAME_CAP_MIGRATION_ONLY_CELLS
|
||||
SAME_CAP_MIGRATION_ONLY_CELLS.filter((cell) => cell !== 'production-gce-c30')
|
||||
)
|
||||
for (const [cellId, protocol] of trusted.flatMap((cell) => [[cell, 1], [cell, 3]])) {
|
||||
const { cap, pool } = cellShape(cellId)
|
||||
|
||||
@@ -18,7 +18,8 @@ export const ENTRY_LIMIT = 20000
|
||||
export const BASELINE_OFFSET_HOURS = [24, 48]
|
||||
|
||||
// The asia-east2 cells share a 16-connection pool at 176 ms RTT, which is where pool pressure
|
||||
// shows up first for the whole fleet.
|
||||
// shows up first for the whole fleet. A cell joins only once it serves: zero samples read as
|
||||
// unverified, so listing a not-yet-general cell would turn every verdict into WARN.
|
||||
export const FLEET_POOL_CELL_IDS = [
|
||||
'production-gce-c27',
|
||||
'production-gce-c28',
|
||||
|
||||
@@ -38,6 +38,8 @@ const launchDigest = '5aedbca5c86de24c8b4d4bf7e3b444b76c712f281ede916cb9d90f70ca
|
||||
// Scoped to the Asia cells by name: the production capacity cells now serve this digest too,
|
||||
// so a file-wide count no longer isolates Asia.
|
||||
const asiaCells = ['production-gce-c27', 'production-gce-c28', 'production-gce-c29']
|
||||
// C30 launches after the launch cells rolled, so it pins the director's digest instead.
|
||||
const c30Digest = '4158d8a2e18e9caec439d257f0c1e45d92ffea8c0262f057b2f08c76a134bcf0'
|
||||
|
||||
function cellBlock(tfvars, cellId) {
|
||||
const start = tfvars.indexOf(`"${cellId}"`)
|
||||
@@ -48,12 +50,13 @@ function cellBlock(tfvars, cellId) {
|
||||
const productionCell = (cellId) => cellBlock(productionTfvars, cellId)
|
||||
|
||||
// Scoped to C4 by name: staging C3 serves this digest too since its 2026-09-03 re-pin.
|
||||
test('pins staging C4 and all production Asia cells to the same launch image', () => {
|
||||
test('pins staging C4 and the launch Asia cells to one image, and C30 to the director image', () => {
|
||||
assert.match(cellBlock(stagingTfvars, 'staging-gce-c4'), new RegExp(`relay@sha256:${launchDigest}"`))
|
||||
for (const cellId of asiaCells) {
|
||||
assert.match(productionCell(cellId), new RegExp(`relay@sha256:${launchDigest}"`), cellId)
|
||||
}
|
||||
assert.match(recoveryWorkflow, new RegExp(`TARGET_IMAGE_DIGEST: sha256:${launchDigest}`))
|
||||
assert.match(productionCell('production-gce-c30'), new RegExp(`relay@sha256:${c30Digest}"`))
|
||||
})
|
||||
|
||||
test('refreshes only empty staging C4 through the trusted capacity identity', () => {
|
||||
|
||||
@@ -14,20 +14,28 @@ const CELL_SHAPES = {
|
||||
production: {
|
||||
domain: 'relay.onorca.dev',
|
||||
project: 'onorca-cloud',
|
||||
databasePoolMax: '16',
|
||||
cells: {
|
||||
'production-gce-c27': 'asia-east2-a',
|
||||
'production-gce-c28': 'asia-east2-b',
|
||||
'production-gce-c29': 'asia-east2-c'
|
||||
}
|
||||
'production-gce-c29': 'asia-east2-c',
|
||||
'production-gce-c30': 'asia-east2-a'
|
||||
},
|
||||
waves: [
|
||||
['production-gce-c27', 'production-gce-c28', 'production-gce-c29'],
|
||||
['production-gce-c30']
|
||||
]
|
||||
},
|
||||
staging: {
|
||||
domain: 'relay-staging.onorca.dev',
|
||||
project: 'onorca-cloud-staging',
|
||||
cells: { 'staging-gce-c4': 'asia-east2-a' }
|
||||
databasePoolMax: '10',
|
||||
cells: { 'staging-gce-c4': 'asia-east2-a' },
|
||||
waves: [['staging-gce-c4']]
|
||||
}
|
||||
}
|
||||
|
||||
function parseArguments(argv) {
|
||||
export function parseRelayAsiaTopologyPlanArguments(argv) {
|
||||
const values = {}
|
||||
for (let index = 0; index < argv.length; index += 2) {
|
||||
const key = argv[index]
|
||||
@@ -40,8 +48,11 @@ function parseArguments(argv) {
|
||||
}
|
||||
if (!(values.environment in CELL_SHAPES)) throw new Error('--environment is invalid')
|
||||
const cells = values['cell-ids'].split(',').map((value) => value.trim()).filter(Boolean)
|
||||
const expectedCells = Object.keys(CELL_SHAPES[values.environment].cells)
|
||||
if (new Set(cells).size !== cells.length || JSON.stringify(cells.sort()) !== JSON.stringify(expectedCells.sort())) {
|
||||
const sorted = JSON.stringify([...cells].sort())
|
||||
if (
|
||||
new Set(cells).size !== cells.length ||
|
||||
!CELL_SHAPES[values.environment].waves.some((wave) => JSON.stringify([...wave].sort()) === sorted)
|
||||
) {
|
||||
throw new Error('--cell-ids must be the exact reviewed Asia topology set')
|
||||
}
|
||||
if (values.region !== REGION) throw new Error('--region must be asia-east2')
|
||||
@@ -90,7 +101,8 @@ function requireCellTemplate(change, config, cellId) {
|
||||
(after?.network_interface?.[0]?.access_config?.length ?? 0) !== 0 ||
|
||||
startupValue(script, 'ORCA_RELAY_REGION') !== REGION ||
|
||||
startupValue(script, 'ORCA_RELAY_CELL_CAPACITY') !== '6000' ||
|
||||
startupValue(script, 'ORCA_RELAY_DATABASE_POOL_MAX') !== '10' ||
|
||||
startupValue(script, 'ORCA_RELAY_DATABASE_POOL_MAX') !==
|
||||
CELL_SHAPES[config.environment].databasePoolMax ||
|
||||
startupValue(script, 'ORCA_RELAY_CELL_CONNECTION_HARD_CAP') !== '3000' ||
|
||||
startupValue(script, 'ORCA_RELAY_CELL_CONNECTION_UNOBSERVED_BOUND') !== '60' ||
|
||||
startupValue(script, 'ORCA_RELAY_IMAGE_DIGEST') !== config.image.split('@')[1] ||
|
||||
@@ -288,6 +300,12 @@ export function validateRelayAsiaTopologyPlan(plan, config) {
|
||||
(action) => action === 'no-op' || action === 'read'
|
||||
))
|
||||
for (const change of changes) {
|
||||
const cellId = /^google_compute_(?:instance_template|instance_group_manager|backend_service)\.relay_gce_cell\["([^"]+)"\]$/
|
||||
.exec(change.address)?.[1]
|
||||
// Usually a stale committed image; the workflow overlays live images so this stays empty.
|
||||
if (cellId && !config.cells.includes(cellId)) {
|
||||
throw new Error(`${change.address} changes a live cell outside the planned wave`)
|
||||
}
|
||||
const allowedActions = required.get(change.address)
|
||||
if (!allowedActions || !allowedActions.some((expected) => sameActions(change, expected))) {
|
||||
throw new Error(`${change.address} has an unreviewed topology action`)
|
||||
@@ -297,7 +315,7 @@ export function validateRelayAsiaTopologyPlan(plan, config) {
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
const config = parseArguments(process.argv.slice(2))
|
||||
const config = parseRelayAsiaTopologyPlanArguments(process.argv.slice(2))
|
||||
const plan = JSON.parse(readFileSync(config.planJson, 'utf8'))
|
||||
console.log(JSON.stringify(validateRelayAsiaTopologyPlan(plan, config)))
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
RELAY_CELL_BACKEND_TIMEOUT_SECONDS,
|
||||
RELAY_CELL_CONNECTION_DRAIN_SECONDS,
|
||||
RELAY_CELL_LOG_SAMPLE_RATE,
|
||||
parseRelayAsiaTopologyPlanArguments,
|
||||
validateRelayAsiaTopologyPlan
|
||||
} from './validate-relay-asia-topology-plan.mjs'
|
||||
|
||||
@@ -90,6 +91,121 @@ test('accepts the exact additive staging Asia topology', () => {
|
||||
})
|
||||
})
|
||||
|
||||
const productionImage = image.replace('onorca-cloud-staging/', 'onorca-cloud/')
|
||||
const productionConfig = {
|
||||
environment: 'production', cells: ['production-gce-c30'], image: productionImage
|
||||
}
|
||||
|
||||
// C30 joins a live Asia region: the network is a no-op and the existing C27 route is preserved.
|
||||
function productionC30Plan() {
|
||||
const plan = JSON.parse(JSON.stringify(resources)
|
||||
.replaceAll('onorca-cloud-staging/', 'onorca-cloud/')
|
||||
.replaceAll('orca-cloud-staging-relay-gce', 'orca-cloud-relay-gce')
|
||||
.replaceAll('staging-gce-c4', 'production-gce-c30')
|
||||
.replaceAll('relay-gce-c4', 'relay-gce-c30')
|
||||
.replaceAll('cell-c4', 'cell-c30')
|
||||
.replaceAll('c4.relay-staging.onorca.dev', 'c30.relay.onorca.dev')
|
||||
.replaceAll("'10'", "'16'"))
|
||||
for (const network of plan.slice(0, 3)) {
|
||||
network.change.actions = ['no-op']
|
||||
network.change.before = structuredClone(network.change.after)
|
||||
}
|
||||
const existingHost = { hosts: ['c27.relay.onorca.dev'], path_matcher: 'cell-c27' }
|
||||
const existingMatcher = {
|
||||
name: 'cell-c27',
|
||||
default_service: 'projects/p/global/backendServices/orca-cloud-relay-gce-c27'
|
||||
}
|
||||
const urlMap = plan.at(-1).change
|
||||
urlMap.before = { host_rule: [existingHost], path_matcher: [existingMatcher], fingerprint: 'old' }
|
||||
urlMap.after.host_rule.unshift(structuredClone(existingHost))
|
||||
urlMap.after.path_matcher.unshift(structuredClone(existingMatcher))
|
||||
return plan
|
||||
}
|
||||
|
||||
test('accepts the additive production C30 wave at the 16-connection Asia pool', () => {
|
||||
assert.deepEqual(
|
||||
validateRelayAsiaTopologyPlan({ resource_changes: productionC30Plan() }, productionConfig),
|
||||
{ environment: 'production', cells: ['production-gce-c30'], changes: 4 }
|
||||
)
|
||||
const staleShape = productionC30Plan()
|
||||
staleShape[3].change.after.metadata_startup_script =
|
||||
staleShape[3].change.after.metadata_startup_script.replace("'16'", "'10'")
|
||||
assert.throws(
|
||||
() => validateRelayAsiaTopologyPlan({ resource_changes: staleShape }, productionConfig),
|
||||
/reviewed Asia cell shape/
|
||||
)
|
||||
const wrongZone = productionC30Plan()
|
||||
wrongZone[4].change.after.zone = 'asia-east2-b'
|
||||
assert.throws(
|
||||
() => validateRelayAsiaTopologyPlan({ resource_changes: wrongZone }, productionConfig),
|
||||
/fixed-one Asia MIG shape/
|
||||
)
|
||||
const liveCellTouched = productionC30Plan()
|
||||
liveCellTouched.push(create('google_compute_instance_template.relay_gce_cell["production-gce-c27"]'))
|
||||
assert.throws(
|
||||
() => validateRelayAsiaTopologyPlan({ resource_changes: liveCellTouched }, productionConfig),
|
||||
/outside the planned wave/
|
||||
)
|
||||
})
|
||||
|
||||
// The URL map pulls every cell's backend, MIG and template into a targeted plan.
|
||||
const liveCellResources = ['instance_template', 'instance_group_manager', 'backend_service']
|
||||
.flatMap((kind) => ['production-gce-c1', 'production-gce-c27', 'production-gce-c28', 'production-gce-c29']
|
||||
.map((cellId) => `google_compute_${kind}.relay_gce_cell["${cellId}"]`))
|
||||
|
||||
test('accepts live cells the URL map pulls in only while they stay unchanged', () => {
|
||||
const plan = productionC30Plan()
|
||||
for (const address of liveCellResources) plan.push({ address, change: { actions: ['no-op'] } })
|
||||
assert.equal(
|
||||
validateRelayAsiaTopologyPlan({ resource_changes: plan }, productionConfig).changes,
|
||||
4
|
||||
)
|
||||
for (const address of liveCellResources) {
|
||||
for (const action of [['update'], ['delete'], ['create', 'delete'], ['delete', 'create']]) {
|
||||
const drifted = productionC30Plan()
|
||||
drifted.push({ address, change: { actions: action } })
|
||||
assert.throws(
|
||||
() => validateRelayAsiaTopologyPlan({ resource_changes: drifted }, productionConfig),
|
||||
new RegExp(`${address.replaceAll(/[.[\]]/g, '\\$&')} changes a live cell outside the planned wave`)
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('accepts only a reviewed Asia topology wave', () => {
|
||||
const argv = (environment, cellIds, planImage) => [
|
||||
'--plan-json', 'plan.json', '--environment', environment, '--cell-ids', cellIds,
|
||||
'--region', 'asia-east2', '--image', planImage
|
||||
]
|
||||
for (const cellIds of [
|
||||
'production-gce-c27,production-gce-c28,production-gce-c29',
|
||||
'production-gce-c29,production-gce-c27,production-gce-c28',
|
||||
'production-gce-c30'
|
||||
]) {
|
||||
assert.doesNotThrow(
|
||||
() => parseRelayAsiaTopologyPlanArguments(argv('production', cellIds, productionImage)),
|
||||
cellIds
|
||||
)
|
||||
}
|
||||
for (const cellIds of [
|
||||
'production-gce-c27',
|
||||
'production-gce-c27,production-gce-c30',
|
||||
'production-gce-c27,production-gce-c28,production-gce-c29,production-gce-c30',
|
||||
'production-gce-c30,production-gce-c30',
|
||||
'production-gce-c31'
|
||||
]) {
|
||||
assert.throws(
|
||||
() => parseRelayAsiaTopologyPlanArguments(argv('production', cellIds, productionImage)),
|
||||
/exact reviewed Asia topology set/,
|
||||
cellIds
|
||||
)
|
||||
}
|
||||
assert.throws(
|
||||
() => parseRelayAsiaTopologyPlanArguments(argv('staging', 'production-gce-c30', image)),
|
||||
/exact reviewed Asia topology set/
|
||||
)
|
||||
})
|
||||
|
||||
test('accepts an idempotent empty plan', () => {
|
||||
const noChanges = structuredClone(resources).map((resource) => ({
|
||||
...resource,
|
||||
|
||||
@@ -330,7 +330,8 @@ without its segment is a compile error in relay-contract, not a silent gap.
|
||||
latest-sum over 24 healthy hours: mean ~100, 1-minute spikes to 216, with
|
||||
10 minutes over the old bar of 160 — enough to freeze roughly one in ten
|
||||
15-minute pre-drain gates on baseline noise. 250 cleared the healthy peaks
|
||||
measured then and still fired well before the verified 400-connection ceiling;
|
||||
measured then and still fired well before the 400-connection ceiling assumed at
|
||||
the time (the live instance measured 500 on 2026-09-16);
|
||||
pool waiters and pool wait latency keep their strict thresholds. Superseded by
|
||||
the 2026-09-17 entry above, which re-measured a grown baseline against the
|
||||
490-connection budget.
|
||||
|
||||
@@ -165,6 +165,28 @@ or moving a cell and rejects intervening drift. Then
|
||||
atomically register the new cells as migration-only, binding every mutation to
|
||||
the exact live selector generation and a durable attempt ID. Deploy and verify
|
||||
the director configuration only after registration, then promote C27 alone before C28/C29.
|
||||
|
||||
The production Asia set is C27-C30. C27-C29 launched as one wave; C30 is an additive wave of its
|
||||
own at the same shape. Its plan names C30's template, MIG, and backend plus the shared URL map, and
|
||||
the URL map pulls every existing cell's backend, MIG, and template into the plan. Committed images
|
||||
lag what same-cap rolls serve, so the workflow first reads each non-target cell's served image out
|
||||
of its live template in state and plans that cell at it. It reads the committed cell map from a
|
||||
no-refresh, unlocked plan over the same targets, not `terraform console`. Console evaluates every
|
||||
output against state, so `relay_gce_cell_deployments` wraps each per-cell resource lookup in
|
||||
`try`: until C30's topology apply, console succeeds and that output shows C30 with null MIG,
|
||||
backend, and template fields. The validator then rejects any change to a
|
||||
cell outside the wave, so the plan must read as C30's three creations plus the URL map update.
|
||||
Before the apply dispatch, run the plan mode and read its `Plan:` line; any other drift, such as a
|
||||
cell whose startup script changed since its last roll, fails the plan and must be rolled first.
|
||||
Register C30 alone as migration-only, configure the director with `cell-ids` set to C30 while
|
||||
regional rehome is paused, then promote it alone. Promotion requires C27-C29 to be general and takes
|
||||
no input evidence. It runs the same five-minute production control and splice canary C27 ran, on
|
||||
C30: the evidence must show the canary control was placed on C30, read C30's own runtime metrics,
|
||||
and bind the selector generation, and any failure returns C30 to migration-only. Until then the
|
||||
same-cap job lists C30 as a migration-only cell, so a same-cap roll hands it back isolated rather
|
||||
than activating it. The follow-up after promotion adds C30 to the shadow gate's fleet pool list and
|
||||
moves it to the same-cap general list together. Any later Asia cell follows the same pattern as its
|
||||
own reviewed wave.
|
||||
Rollback returns
|
||||
Asia cells to migration-only; it does not destroy the network or use
|
||||
existing-only. The production topology dispatch remains unavailable until the
|
||||
|
||||
@@ -323,7 +323,8 @@ only additive regional resources; cells select the subnet from their declared
|
||||
region. Every cell also declares an explicit database pool maximum in startup
|
||||
metadata and deployment outputs. The initial Asia shape is `e2-standard-4`,
|
||||
3,000 physical connections, 60 unobserved connections, 6,000 request units,
|
||||
and a database pool maximum of 10.
|
||||
and a database pool maximum of 10. Production Asia pools now run at 16 and
|
||||
staging C4 stays at 10; the topology and director validators pin both.
|
||||
|
||||
Provision the complete identical Asia wave in one `Deploy Relay Asia Topology`
|
||||
saved plan. Its validator permits only the additive subnet/router/NAT, reviewed
|
||||
@@ -331,7 +332,10 @@ cell templates/MIGs/backends, and exact shared URL-map host additions. It
|
||||
rejects deletes, replacements, loss of an existing host route, US-resource
|
||||
changes, and unrelated drift. Do not add production C27-C29 until the
|
||||
compatible image has been published and each entry can pin its immutable
|
||||
digest.
|
||||
digest. A later cell, such as C30, is its own reviewed wave. The shared URL map
|
||||
pulls every live cell into its plan, so the workflow plans each live cell at the
|
||||
image its state template already serves, and the validator rejects any change
|
||||
to a cell outside the wave.
|
||||
|
||||
Topology creation intentionally does not apply the director resource. Once all
|
||||
MIGs and backends are healthy, register every new cell atomically as
|
||||
|
||||
@@ -393,6 +393,20 @@ relay_gce_cells = {
|
||||
connection_hard_cap = 3000
|
||||
connection_unobserved_bound = 60
|
||||
}
|
||||
"production-gce-c30" = {
|
||||
hostname = "c30"
|
||||
region = "asia-east2"
|
||||
zone = "asia-east2-a"
|
||||
machine_type = "e2-standard-4"
|
||||
boot_disk_gb = 30
|
||||
boot_image = "https://www.googleapis.com/compute/v1/projects/cos-cloud/global/images/cos-stable-121-18867-528-21"
|
||||
capacity_requests = 6000
|
||||
database_pool_max = 16 # 176 ms from us-central1 Postgres saturates 10 (94-156 waiters).
|
||||
image = "us-central1-docker.pkg.dev/onorca-cloud/orca-cloud/relay@sha256:4158d8a2e18e9caec439d257f0c1e45d92ffea8c0262f057b2f08c76a134bcf0"
|
||||
initially_enabled = false
|
||||
connection_hard_cap = 3000
|
||||
connection_unobserved_bound = 60
|
||||
}
|
||||
}
|
||||
|
||||
relay_region_rehome_source_cell_ids = [
|
||||
@@ -415,7 +429,8 @@ relay_region_rehome_source_cell_ids = [
|
||||
# Asia cells carry the same trust so mis-homed hosts can be drained back off them.
|
||||
"production-gce-c27",
|
||||
"production-gce-c28",
|
||||
"production-gce-c29"
|
||||
"production-gce-c29",
|
||||
"production-gce-c30"
|
||||
]
|
||||
|
||||
# Slack #orca-relay-alerts, created out of band on 2026-08-05. Declared here because an apply
|
||||
|
||||
@@ -158,17 +158,18 @@ output "relay_gce_cell_backend_services" {
|
||||
}
|
||||
|
||||
output "relay_gce_cell_deployments" {
|
||||
# try: a cell declared before its topology apply has no resources, and console evaluates this output.
|
||||
value = {
|
||||
for cell_id, cell in var.relay_gce_cells : cell_id => {
|
||||
origin = local.relay_gce_cell_urls[cell_id]
|
||||
region = cell.region
|
||||
zone = cell.zone
|
||||
mig_name = google_compute_instance_group_manager.relay_gce_cell[cell_id].name
|
||||
instance_group = google_compute_instance_group_manager.relay_gce_cell[cell_id].instance_group
|
||||
backend_name = google_compute_backend_service.relay_gce_cell[cell_id].name
|
||||
backend_id = google_compute_backend_service.relay_gce_cell[cell_id].id
|
||||
mig_name = try(google_compute_instance_group_manager.relay_gce_cell[cell_id].name, null)
|
||||
instance_group = try(google_compute_instance_group_manager.relay_gce_cell[cell_id].instance_group, null)
|
||||
backend_name = try(google_compute_backend_service.relay_gce_cell[cell_id].name, null)
|
||||
backend_id = try(google_compute_backend_service.relay_gce_cell[cell_id].id, null)
|
||||
url_map_name = google_compute_url_map.relay_gce[0].name
|
||||
generation_identity = google_compute_instance_template.relay_gce_cell[cell_id].self_link
|
||||
generation_identity = try(google_compute_instance_template.relay_gce_cell[cell_id].self_link, null)
|
||||
image = cell.image
|
||||
capacity_requests = cell.capacity_requests
|
||||
database_pool_max = cell.database_pool_max
|
||||
@@ -177,7 +178,7 @@ output "relay_gce_cell_deployments" {
|
||||
initially_enabled = cell.initially_enabled
|
||||
fenced = contains(var.relay_gce_fenced_cells, cell_id)
|
||||
desired_target_size = local.relay_gce_cell_target_sizes[cell_id]
|
||||
target_size = google_compute_instance_group_manager.relay_gce_cell[cell_id].target_size
|
||||
target_size = try(google_compute_instance_group_manager.relay_gce_cell[cell_id].target_size, null)
|
||||
}
|
||||
}
|
||||
description = "Non-secret candidate deployment topology consumed by the GCE preflight workflow."
|
||||
|
||||
@@ -406,8 +406,14 @@ const MERMAID_PACKAGE = 'node_modules/mermaid/'
|
||||
*
|
||||
* modules 4212 -> 4213 (+1)
|
||||
* local modules 1026 -> 1027 (+1)
|
||||
*
|
||||
* Muse then joined the mobile agent catalog with its bundled icon, one more local input to the
|
||||
* shared agent picker.
|
||||
*
|
||||
* modules 4213 -> 4214 (+1)
|
||||
* local modules 1027 -> 1028 (+1)
|
||||
*/
|
||||
const SESSION_ROUTE_MODULES = 4213
|
||||
const SESSION_ROUTE_MODULES = 4214
|
||||
|
||||
/** What the page enters this route through once the route is a switch with a `.web.tsx` sibling. */
|
||||
const ROUTE_ENTRY = [
|
||||
|
||||
@@ -131,6 +131,9 @@
|
||||
"../src/main/in-flight-run-dedupe.ts",
|
||||
"../src/main/kimi/hook-service.ts",
|
||||
"../src/main/kimi/kimi-hook-config-toml.ts",
|
||||
"../src/main/muse/hook-config-json.ts",
|
||||
"../src/main/muse/hook-service.ts",
|
||||
"../src/main/muse/hook-settings.ts",
|
||||
"../src/main/openclaude/hook-service.ts",
|
||||
"../src/main/rolling-file-backup.ts",
|
||||
"../src/main/startup/hydrate-shell-path.ts",
|
||||
|
||||
@@ -178,6 +178,7 @@ Funciona con **cualquier agente CLI** — si corre en una terminal, corre en Orc
|
||||
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Grok logo" width="16" valign="middle" /> Grok</kbd></a>
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://dev.meta.ai/docs/muse-code"><kbd><img src="../../src/shared/agent-icons/muse.png" alt="Muse logo" width="16" valign="middle" /> Muse</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a>
|
||||
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a>
|
||||
|
||||
@@ -182,6 +182,7 @@ Fonctionne avec **n'importe quel agent CLI** — s'il tourne dans un terminal, i
|
||||
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Logo Grok" width="16" valign="middle" /> Grok</kbd></a>
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Logo Cursor" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="Logo GitHub Copilot" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://dev.meta.ai/docs/muse-code"><kbd><img src="../../src/shared/agent-icons/muse.png" alt="Logo Muse" width="16" valign="middle" /> Muse</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="Logo OpenCode" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://mimo.xiaomi.com/coder"><kbd><img src="https://www.google.com/s2/favicons?domain=mimo.xiaomi.com&sz=64" alt="Logo MiMo Code" width="16" valign="middle" /> MiMo Code</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Logo Amp" width="16" valign="middle" /> Amp</kbd></a>
|
||||
|
||||
@@ -178,6 +178,7 @@ PR、Issue、プロジェクトボードをアプリ内で閲覧 — 任意の
|
||||
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Grok logo" width="16" valign="middle" /> Grok</kbd></a>
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://dev.meta.ai/docs/muse-code"><kbd><img src="../../src/shared/agent-icons/muse.png" alt="Muse logo" width="16" valign="middle" /> Muse</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a>
|
||||
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a>
|
||||
|
||||
@@ -178,6 +178,7 @@ diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내
|
||||
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Grok logo" width="16" valign="middle" /> Grok</kbd></a>
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://dev.meta.ai/docs/muse-code"><kbd><img src="../../src/shared/agent-icons/muse.png" alt="Muse logo" width="16" valign="middle" /> Muse</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://mimo.xiaomi.com/coder"><kbd><img src="https://www.google.com/s2/favicons?domain=mimo.xiaomi.com&sz=64" alt="MiMo Code logo" width="16" valign="middle" /> MiMo Code</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a>
|
||||
|
||||
@@ -178,6 +178,7 @@ Funciona com **qualquer agente CLI** — se roda em um terminal, roda no Orca.
|
||||
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Logotipo do Grok" width="16" valign="middle" /> Grok</kbd></a>
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Logotipo do Cursor" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="Logotipo do GitHub Copilot" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://dev.meta.ai/docs/muse-code"><kbd><img src="../../src/shared/agent-icons/muse.png" alt="Logotipo do Muse" width="16" valign="middle" /> Muse</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="Logotipo do OpenCode" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://mimo.xiaomi.com/coder"><kbd><img src="https://www.google.com/s2/favicons?domain=mimo.xiaomi.com&sz=64" alt="Logotipo do MiMo Code" width="16" valign="middle" /> MiMo Code</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Logotipo do Amp" width="16" valign="middle" /> Amp</kbd></a>
|
||||
|
||||
@@ -178,6 +178,7 @@ VS Code 的编辑器,处处自动保存 — 把文件或图片直接拖入智
|
||||
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" alt="Grok logo" width="16" valign="middle" /> Grok</kbd></a>
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://dev.meta.ai/docs/muse-code"><kbd><img src="../../src/shared/agent-icons/muse.png" alt="Muse logo" width="16" valign="middle" /> Muse</kbd></a>
|
||||
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a>
|
||||
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a>
|
||||
|
||||
@@ -27,7 +27,7 @@ Remote workspaces can browse local history, but resume actions only run from loc
|
||||
|
||||
The view-options menu (next to the search box) controls which agents are scanned, plus sort and grouping:
|
||||
|
||||
- **Agents** — toggle individual CLIs on or off (Claude, Codex, Hermes, Pi, OMP, Prime Agent, Cursor, Gemini, Antigravity, Rovo Dev, Copilot, OpenCode, Grok, OpenClaw, Devin, Droid, Kimi). Disabled agents are skipped during the scan. Use **Select all** / **Clear** in the Agents header to flip every agent at once — **Clear** leaves none selected so you can turn on only the CLI you care about without unchecking a long list. An empty selection shows **No agents selected** instead of the usual empty-filter message.
|
||||
- **Agents** — toggle individual CLIs on or off (Claude, Codex, Hermes, Pi, OMP, Prime Agent, Cursor, Gemini, Antigravity, Rovo Dev, Copilot, OpenCode, Grok, OpenClaw, Devin, Droid, Kimi, Muse). Disabled agents are skipped during the scan. Use **Select all** / **Clear** in the Agents header to flip every agent at once — **Clear** leaves none selected so you can turn on only the CLI you care about without unchecking a long list. An empty selection shows **No agents selected** instead of the usual empty-filter message.
|
||||
- **Sort** — `Last updated` or `Created`.
|
||||
- **Group** — `Project`, `Folder` (one heading per `cwd`), or `Agent` (one heading per CLI).
|
||||
- **Hide empty sessions** — drop sessions with zero recorded messages.
|
||||
|
||||
@@ -16,7 +16,7 @@ Orca works with **any CLI agent** — the agent combobox just launches a process
|
||||
|
||||
## Permissions default
|
||||
|
||||
For new launches, Orca pre-fills each supported CLI's permission-bypass flag — `--dangerously-skip-permissions` for Claude, `--dangerously-bypass-approvals-and-sandbox` for Codex, `--yolo` for Gemini / Cursor / Crush / Kimi / Rovo Dev / Hermes / GitHub Copilot / Command Code, plus the equivalent flag for every other agent that exposes one. These flags allow an agent to act without confirming every shell command; review the trust boundary before using them.
|
||||
For new launches, Orca pre-fills each supported CLI's permission-bypass flag — `--dangerously-skip-permissions` for Claude, `--dangerously-bypass-approvals-and-sandbox` for Codex, `--yolo` for Gemini / Cursor / Crush / Kimi / Muse / Rovo Dev / Hermes / GitHub Copilot / Command Code, plus the equivalent flag for every other agent that exposes one. These flags allow an agent to act without confirming every shell command; review the trust boundary before using them.
|
||||
|
||||
Use **Settings → Agents → Agent Permissions** when you want to switch all uncustomized agents between **Yolo** and **Manual** launches. If you already overrode a specific agent's launch arguments or environment, Orca leaves that agent alone so the global switch doesn't erase your custom command.
|
||||
|
||||
@@ -48,6 +48,7 @@ To restore prompts for one agent only, edit that agent's default arguments or en
|
||||
| Codebuff | Auto-setup | [Codebuff](https://www.codebuff.com/docs/help/quick-start) |
|
||||
| Freebuff | Auto-setup | [Freebuff](https://freebuff.com/cli) |
|
||||
| Command Code | Auto-setup, status | [Command Code](https://commandcode.ai/docs/quickstart) |
|
||||
| Muse | macOS/Linux; trusts the workspace at launch | [Meta](https://dev.meta.ai/docs/muse-code) |
|
||||
| Continue | Auto-setup | [Continue](https://docs.continue.dev/guides/cli) |
|
||||
| Cursor CLI | Deep integration | [Cursor](https://cursor.com/cli) |
|
||||
| Devin | Auto-setup | [Devin](https://devin.ai/cli) |
|
||||
|
||||
@@ -40,5 +40,6 @@ export const MOBILE_AGENT_ICON_ASSETS: Partial<Record<TuiAgent, ImageSourcePropT
|
||||
rovo: require('../../../src/shared/agent-icons/rovo.png'),
|
||||
hermes: require('../../../src/shared/agent-icons/hermes.png'),
|
||||
devin: require('../../../src/shared/agent-icons/devin.png'),
|
||||
muse: require('../../../src/shared/agent-icons/muse.png'),
|
||||
openclaw: require('../../../src/shared/agent-icons/openclaw.png')
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export const MOBILE_TUI_AGENT_FAVICON_DOMAINS: Partial<Record<TuiAgent, string>>
|
||||
'mimo-code': 'mimo.xiaomi.com',
|
||||
ante: 'antigma.ai',
|
||||
trae: 'www.trae.cn',
|
||||
muse: 'dev.meta.ai',
|
||||
omp: 'omp.sh',
|
||||
'prime-agent': 'primeintellect.ai',
|
||||
gemini: 'gemini.google.com',
|
||||
|
||||
@@ -5,17 +5,17 @@
|
||||
"name": "computer-use",
|
||||
"sourcePath": "skills/computer-use",
|
||||
"releaseRevision": 9,
|
||||
"packageDigest": "ff60c0d0fcb142047fbb83477b829459cfb57d9e6f7fb715644e2b13aa441bf1",
|
||||
"gitTreeSha": "335986bf5b78557d5d6973eea183eeb2dc527eba",
|
||||
"packageDigest": "425634e3ebf27690cc613eaf17b6337b36769153ec6bca85dc4ebf65d4d6b8b4",
|
||||
"gitTreeSha": "b59f27370a41225c22127e91da0c91cd2519f217",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 2050,
|
||||
"size": 2211,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "244b06656c849aaaa79c29a6053fd4c2be98933ca10bd1edaeb727ce73ede793",
|
||||
"textNormalizedSha256": "244b06656c849aaaa79c29a6053fd4c2be98933ca10bd1edaeb727ce73ede793",
|
||||
"identitySha256": "244b06656c849aaaa79c29a6053fd4c2be98933ca10bd1edaeb727ce73ede793"
|
||||
"exactSha256": "2839933fd35216845461466403e6061488005f6df57126d0cd0f769ea45753f3",
|
||||
"textNormalizedSha256": "2839933fd35216845461466403e6061488005f6df57126d0cd0f769ea45753f3",
|
||||
"identitySha256": "2839933fd35216845461466403e6061488005f6df57126d0cd0f769ea45753f3"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -23,17 +23,17 @@
|
||||
"name": "linear-tickets",
|
||||
"sourcePath": "skills/linear-tickets",
|
||||
"releaseRevision": 11,
|
||||
"packageDigest": "2c8a0bae253341fd3147e3fc0b41ab1a298df31f6768be46eee31b7da9a4b059",
|
||||
"gitTreeSha": "01b3a89c1c3209f8b2de1ae05014937b0cfc58b2",
|
||||
"packageDigest": "1eab442d048b79ab0b836adf57663ac384988dd79172ec5f14193eb05e037715",
|
||||
"gitTreeSha": "30d9b40144d4a9a07ce12f0d1a9261fd9cf9649b",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 2070,
|
||||
"size": 2231,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "af2d33d98c21e22726a6a36a3e41b1967770c4df6691530d02409d8aca861c15",
|
||||
"textNormalizedSha256": "af2d33d98c21e22726a6a36a3e41b1967770c4df6691530d02409d8aca861c15",
|
||||
"identitySha256": "af2d33d98c21e22726a6a36a3e41b1967770c4df6691530d02409d8aca861c15"
|
||||
"exactSha256": "e181f86073da65c492361469d504fe15e7ae61bc99990bc41bea47aa13455619",
|
||||
"textNormalizedSha256": "e181f86073da65c492361469d504fe15e7ae61bc99990bc41bea47aa13455619",
|
||||
"identitySha256": "e181f86073da65c492361469d504fe15e7ae61bc99990bc41bea47aa13455619"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -41,17 +41,17 @@
|
||||
"name": "orca-cli",
|
||||
"sourcePath": "skills/orca-cli",
|
||||
"releaseRevision": 37,
|
||||
"packageDigest": "15b5fd49198b080322a55545932acfb2e8351c88746fac468df73ea999693260",
|
||||
"gitTreeSha": "ae1a86f92d7bf38f4dc161cc0c57e15a74f54832",
|
||||
"packageDigest": "0736bcbbb69ed18f9a36a58ad2eda47b6db55b30509953cf5ba5f0032058c535",
|
||||
"gitTreeSha": "572f7952ac451a30a9c2451b472a639b125ee584",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 2211,
|
||||
"size": 2372,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "6522a3355993b377abe7d7a5c8f6a00f9726e1a6a6b9aa9c015651e8d0f5e6b8",
|
||||
"textNormalizedSha256": "6522a3355993b377abe7d7a5c8f6a00f9726e1a6a6b9aa9c015651e8d0f5e6b8",
|
||||
"identitySha256": "6522a3355993b377abe7d7a5c8f6a00f9726e1a6a6b9aa9c015651e8d0f5e6b8"
|
||||
"exactSha256": "aa76f86505010096e8ea9edda1705a78aae7af45e54485f3fe0460664c1d9e4a",
|
||||
"textNormalizedSha256": "aa76f86505010096e8ea9edda1705a78aae7af45e54485f3fe0460664c1d9e4a",
|
||||
"identitySha256": "aa76f86505010096e8ea9edda1705a78aae7af45e54485f3fe0460664c1d9e4a"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -59,17 +59,17 @@
|
||||
"name": "orca-emulator",
|
||||
"sourcePath": "skills/orca-emulator",
|
||||
"releaseRevision": 8,
|
||||
"packageDigest": "54a3b8e534d3e9cb63fab11bfd3690908b21385398da06c618b6fd63851317c5",
|
||||
"gitTreeSha": "bd23a74f2c55b393fe288f9e2806d0ebc028a513",
|
||||
"packageDigest": "1dc42e5addc613abd85eba639d4ac36d9c7b3bc6f7186f0a2d54d10dae3f06d3",
|
||||
"gitTreeSha": "110f6ab59bd73428d7de28bd4761d1fc332efa20",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 2176,
|
||||
"size": 2337,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "654746c72c0fa4aaa540c3aa7450413404450c195bcdeaf0aaa27fa114d0f058",
|
||||
"textNormalizedSha256": "654746c72c0fa4aaa540c3aa7450413404450c195bcdeaf0aaa27fa114d0f058",
|
||||
"identitySha256": "654746c72c0fa4aaa540c3aa7450413404450c195bcdeaf0aaa27fa114d0f058"
|
||||
"exactSha256": "3da7191179e46cb0e1a6a936f1eb54254f6e98ec38849e1c95f0e11073076b48",
|
||||
"textNormalizedSha256": "3da7191179e46cb0e1a6a936f1eb54254f6e98ec38849e1c95f0e11073076b48",
|
||||
"identitySha256": "3da7191179e46cb0e1a6a936f1eb54254f6e98ec38849e1c95f0e11073076b48"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -77,17 +77,17 @@
|
||||
"name": "orca-emulator-android",
|
||||
"sourcePath": "skills/orca-emulator-android",
|
||||
"releaseRevision": 6,
|
||||
"packageDigest": "bf670be58d2650274943b32b1abcdc58b135b0ad81f96aaee491f47af32fe2f5",
|
||||
"gitTreeSha": "2dd0b64d4e5ef4748b5fb30fb7bdf0aa13f51084",
|
||||
"packageDigest": "c348091d953427fc9800a1d49d94054866348008766879b24ef742cb613dc3d9",
|
||||
"gitTreeSha": "437ed5e35698ee6421386a5a08fe7f8c7cf1a2a7",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 2073,
|
||||
"size": 2234,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "ae242330d98c9335160fbd4356894e15633d63c02da4edfd7109ab1845368002",
|
||||
"textNormalizedSha256": "ae242330d98c9335160fbd4356894e15633d63c02da4edfd7109ab1845368002",
|
||||
"identitySha256": "ae242330d98c9335160fbd4356894e15633d63c02da4edfd7109ab1845368002"
|
||||
"exactSha256": "3ade4e6f8f2717ca899fd841e61f116963a406e9527015b803854c16d012e278",
|
||||
"textNormalizedSha256": "3ade4e6f8f2717ca899fd841e61f116963a406e9527015b803854c16d012e278",
|
||||
"identitySha256": "3ade4e6f8f2717ca899fd841e61f116963a406e9527015b803854c16d012e278"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -95,17 +95,17 @@
|
||||
"name": "orca-linear",
|
||||
"sourcePath": "skills/orca-linear",
|
||||
"releaseRevision": 9,
|
||||
"packageDigest": "86c7e2b1d2712cea280ceac45b2cefcb98591cb25fa46539cc9e159338caa1bb",
|
||||
"gitTreeSha": "2b0b3b3d422f0d9cdb88574e955c345ed4370ea8",
|
||||
"packageDigest": "95f52429823e887317046f23b671d05408a947c296e5b2e44e9173dfc1d5aa4e",
|
||||
"gitTreeSha": "8e747165847651926ca54804b012689ebcbd9432",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 1927,
|
||||
"size": 2088,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "85ee0d4d3cfadfec301e3a852c8ff959a1f366ac51b9c312cf463f050e427e72",
|
||||
"textNormalizedSha256": "85ee0d4d3cfadfec301e3a852c8ff959a1f366ac51b9c312cf463f050e427e72",
|
||||
"identitySha256": "85ee0d4d3cfadfec301e3a852c8ff959a1f366ac51b9c312cf463f050e427e72"
|
||||
"exactSha256": "8da23ef96470906315cace8ff84f8056255ee37d1b2d5db84d8b7b3270a0ba0f",
|
||||
"textNormalizedSha256": "8da23ef96470906315cace8ff84f8056255ee37d1b2d5db84d8b7b3270a0ba0f",
|
||||
"identitySha256": "8da23ef96470906315cace8ff84f8056255ee37d1b2d5db84d8b7b3270a0ba0f"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -113,17 +113,17 @@
|
||||
"name": "orca-per-workspace-env",
|
||||
"sourcePath": "skills/orca-per-workspace-env",
|
||||
"releaseRevision": 6,
|
||||
"packageDigest": "b41563e217d38af2ded7d88ea099a9f996a5280f3333e771a2867a0e3f680055",
|
||||
"gitTreeSha": "49103d96472ad790758f14cfc3ed5c69434a6f1b",
|
||||
"packageDigest": "103f0671da111c9ad3def6c46da8d432e656878b840e6cc742219f4a3a4dbceb",
|
||||
"gitTreeSha": "58dfb5dc3ad287a6a42625f9d15c7c0c3dfd02ec",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 2096,
|
||||
"size": 2257,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "d3a23c0d3e87c6024f710145e0cc6e5c7eb37eda1386c27d074f2538a92b7d1c",
|
||||
"textNormalizedSha256": "d3a23c0d3e87c6024f710145e0cc6e5c7eb37eda1386c27d074f2538a92b7d1c",
|
||||
"identitySha256": "d3a23c0d3e87c6024f710145e0cc6e5c7eb37eda1386c27d074f2538a92b7d1c"
|
||||
"exactSha256": "c005d126a13d2913351472f07690f1c1a660d4f286e2a5f21864aee294f436ce",
|
||||
"textNormalizedSha256": "c005d126a13d2913351472f07690f1c1a660d4f286e2a5f21864aee294f436ce",
|
||||
"identitySha256": "c005d126a13d2913351472f07690f1c1a660d4f286e2a5f21864aee294f436ce"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -131,17 +131,17 @@
|
||||
"name": "orchestration",
|
||||
"sourcePath": "skills/orchestration",
|
||||
"releaseRevision": 29,
|
||||
"packageDigest": "00d30c68d91693b6210e43c1fca9ba8b8711e31b4d76d1fcbeaa6257209d569f",
|
||||
"gitTreeSha": "23bc2ff6bb9f6e7d17f41734709ac951dbe3ee80",
|
||||
"packageDigest": "195f26431ecfb6df41b941b22958a2330b110bac7b7e97004e0b35fe586e41d9",
|
||||
"gitTreeSha": "b0cd1d58b0c317cf7c3726fef7e6b1197c405246",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 3510,
|
||||
"size": 3671,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "36bc32f022447418b566db35f9ffcff75113eb023e37d06fd1bd609c5dd173ef",
|
||||
"textNormalizedSha256": "36bc32f022447418b566db35f9ffcff75113eb023e37d06fd1bd609c5dd173ef",
|
||||
"identitySha256": "36bc32f022447418b566db35f9ffcff75113eb023e37d06fd1bd609c5dd173ef"
|
||||
"exactSha256": "cd1b364bf35781bad06bf75ab1766afa8d6cec69cb2060529691d89871a2098a",
|
||||
"textNormalizedSha256": "cd1b364bf35781bad06bf75ab1766afa8d6cec69cb2060529691d89871a2098a",
|
||||
"identitySha256": "cd1b364bf35781bad06bf75ab1766afa8d6cec69cb2060529691d89871a2098a"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -580,17 +580,17 @@
|
||||
},
|
||||
{
|
||||
"releaseRevision": 37,
|
||||
"packageDigest": "15b5fd49198b080322a55545932acfb2e8351c88746fac468df73ea999693260",
|
||||
"gitTreeSha": "ae1a86f92d7bf38f4dc161cc0c57e15a74f54832",
|
||||
"packageDigest": "0736bcbbb69ed18f9a36a58ad2eda47b6db55b30509953cf5ba5f0032058c535",
|
||||
"gitTreeSha": "572f7952ac451a30a9c2451b472a639b125ee584",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 2211,
|
||||
"size": 2372,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "6522a3355993b377abe7d7a5c8f6a00f9726e1a6a6b9aa9c015651e8d0f5e6b8",
|
||||
"textNormalizedSha256": "6522a3355993b377abe7d7a5c8f6a00f9726e1a6a6b9aa9c015651e8d0f5e6b8",
|
||||
"identitySha256": "6522a3355993b377abe7d7a5c8f6a00f9726e1a6a6b9aa9c015651e8d0f5e6b8"
|
||||
"exactSha256": "aa76f86505010096e8ea9edda1705a78aae7af45e54485f3fe0460664c1d9e4a",
|
||||
"textNormalizedSha256": "aa76f86505010096e8ea9edda1705a78aae7af45e54485f3fe0460664c1d9e4a",
|
||||
"identitySha256": "aa76f86505010096e8ea9edda1705a78aae7af45e54485f3fe0460664c1d9e4a"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1046,17 +1046,17 @@
|
||||
},
|
||||
{
|
||||
"releaseRevision": 29,
|
||||
"packageDigest": "00d30c68d91693b6210e43c1fca9ba8b8711e31b4d76d1fcbeaa6257209d569f",
|
||||
"gitTreeSha": "23bc2ff6bb9f6e7d17f41734709ac951dbe3ee80",
|
||||
"packageDigest": "195f26431ecfb6df41b941b22958a2330b110bac7b7e97004e0b35fe586e41d9",
|
||||
"gitTreeSha": "b0cd1d58b0c317cf7c3726fef7e6b1197c405246",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 3510,
|
||||
"size": 3671,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "36bc32f022447418b566db35f9ffcff75113eb023e37d06fd1bd609c5dd173ef",
|
||||
"textNormalizedSha256": "36bc32f022447418b566db35f9ffcff75113eb023e37d06fd1bd609c5dd173ef",
|
||||
"identitySha256": "36bc32f022447418b566db35f9ffcff75113eb023e37d06fd1bd609c5dd173ef"
|
||||
"exactSha256": "cd1b364bf35781bad06bf75ab1766afa8d6cec69cb2060529691d89871a2098a",
|
||||
"textNormalizedSha256": "cd1b364bf35781bad06bf75ab1766afa8d6cec69cb2060529691d89871a2098a",
|
||||
"identitySha256": "cd1b364bf35781bad06bf75ab1766afa8d6cec69cb2060529691d89871a2098a"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1210,17 +1210,17 @@
|
||||
},
|
||||
{
|
||||
"releaseRevision": 9,
|
||||
"packageDigest": "ff60c0d0fcb142047fbb83477b829459cfb57d9e6f7fb715644e2b13aa441bf1",
|
||||
"gitTreeSha": "335986bf5b78557d5d6973eea183eeb2dc527eba",
|
||||
"packageDigest": "425634e3ebf27690cc613eaf17b6337b36769153ec6bca85dc4ebf65d4d6b8b4",
|
||||
"gitTreeSha": "b59f27370a41225c22127e91da0c91cd2519f217",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 2050,
|
||||
"size": 2211,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "244b06656c849aaaa79c29a6053fd4c2be98933ca10bd1edaeb727ce73ede793",
|
||||
"textNormalizedSha256": "244b06656c849aaaa79c29a6053fd4c2be98933ca10bd1edaeb727ce73ede793",
|
||||
"identitySha256": "244b06656c849aaaa79c29a6053fd4c2be98933ca10bd1edaeb727ce73ede793"
|
||||
"exactSha256": "2839933fd35216845461466403e6061488005f6df57126d0cd0f769ea45753f3",
|
||||
"textNormalizedSha256": "2839933fd35216845461466403e6061488005f6df57126d0cd0f769ea45753f3",
|
||||
"identitySha256": "2839933fd35216845461466403e6061488005f6df57126d0cd0f769ea45753f3"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1340,17 +1340,17 @@
|
||||
},
|
||||
{
|
||||
"releaseRevision": 8,
|
||||
"packageDigest": "54a3b8e534d3e9cb63fab11bfd3690908b21385398da06c618b6fd63851317c5",
|
||||
"gitTreeSha": "bd23a74f2c55b393fe288f9e2806d0ebc028a513",
|
||||
"packageDigest": "1dc42e5addc613abd85eba639d4ac36d9c7b3bc6f7186f0a2d54d10dae3f06d3",
|
||||
"gitTreeSha": "110f6ab59bd73428d7de28bd4761d1fc332efa20",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 2176,
|
||||
"size": 2337,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "654746c72c0fa4aaa540c3aa7450413404450c195bcdeaf0aaa27fa114d0f058",
|
||||
"textNormalizedSha256": "654746c72c0fa4aaa540c3aa7450413404450c195bcdeaf0aaa27fa114d0f058",
|
||||
"identitySha256": "654746c72c0fa4aaa540c3aa7450413404450c195bcdeaf0aaa27fa114d0f058"
|
||||
"exactSha256": "3da7191179e46cb0e1a6a936f1eb54254f6e98ec38849e1c95f0e11073076b48",
|
||||
"textNormalizedSha256": "3da7191179e46cb0e1a6a936f1eb54254f6e98ec38849e1c95f0e11073076b48",
|
||||
"identitySha256": "3da7191179e46cb0e1a6a936f1eb54254f6e98ec38849e1c95f0e11073076b48"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1518,17 +1518,17 @@
|
||||
},
|
||||
{
|
||||
"releaseRevision": 11,
|
||||
"packageDigest": "2c8a0bae253341fd3147e3fc0b41ab1a298df31f6768be46eee31b7da9a4b059",
|
||||
"gitTreeSha": "01b3a89c1c3209f8b2de1ae05014937b0cfc58b2",
|
||||
"packageDigest": "1eab442d048b79ab0b836adf57663ac384988dd79172ec5f14193eb05e037715",
|
||||
"gitTreeSha": "30d9b40144d4a9a07ce12f0d1a9261fd9cf9649b",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 2070,
|
||||
"size": 2231,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "af2d33d98c21e22726a6a36a3e41b1967770c4df6691530d02409d8aca861c15",
|
||||
"textNormalizedSha256": "af2d33d98c21e22726a6a36a3e41b1967770c4df6691530d02409d8aca861c15",
|
||||
"identitySha256": "af2d33d98c21e22726a6a36a3e41b1967770c4df6691530d02409d8aca861c15"
|
||||
"exactSha256": "e181f86073da65c492361469d504fe15e7ae61bc99990bc41bea47aa13455619",
|
||||
"textNormalizedSha256": "e181f86073da65c492361469d504fe15e7ae61bc99990bc41bea47aa13455619",
|
||||
"identitySha256": "e181f86073da65c492361469d504fe15e7ae61bc99990bc41bea47aa13455619"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1664,17 +1664,17 @@
|
||||
},
|
||||
{
|
||||
"releaseRevision": 9,
|
||||
"packageDigest": "86c7e2b1d2712cea280ceac45b2cefcb98591cb25fa46539cc9e159338caa1bb",
|
||||
"gitTreeSha": "2b0b3b3d422f0d9cdb88574e955c345ed4370ea8",
|
||||
"packageDigest": "95f52429823e887317046f23b671d05408a947c296e5b2e44e9173dfc1d5aa4e",
|
||||
"gitTreeSha": "8e747165847651926ca54804b012689ebcbd9432",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 1927,
|
||||
"size": 2088,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "85ee0d4d3cfadfec301e3a852c8ff959a1f366ac51b9c312cf463f050e427e72",
|
||||
"textNormalizedSha256": "85ee0d4d3cfadfec301e3a852c8ff959a1f366ac51b9c312cf463f050e427e72",
|
||||
"identitySha256": "85ee0d4d3cfadfec301e3a852c8ff959a1f366ac51b9c312cf463f050e427e72"
|
||||
"exactSha256": "8da23ef96470906315cace8ff84f8056255ee37d1b2d5db84d8b7b3270a0ba0f",
|
||||
"textNormalizedSha256": "8da23ef96470906315cace8ff84f8056255ee37d1b2d5db84d8b7b3270a0ba0f",
|
||||
"identitySha256": "8da23ef96470906315cace8ff84f8056255ee37d1b2d5db84d8b7b3270a0ba0f"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1762,17 +1762,17 @@
|
||||
},
|
||||
{
|
||||
"releaseRevision": 6,
|
||||
"packageDigest": "bf670be58d2650274943b32b1abcdc58b135b0ad81f96aaee491f47af32fe2f5",
|
||||
"gitTreeSha": "2dd0b64d4e5ef4748b5fb30fb7bdf0aa13f51084",
|
||||
"packageDigest": "c348091d953427fc9800a1d49d94054866348008766879b24ef742cb613dc3d9",
|
||||
"gitTreeSha": "437ed5e35698ee6421386a5a08fe7f8c7cf1a2a7",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 2073,
|
||||
"size": 2234,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "ae242330d98c9335160fbd4356894e15633d63c02da4edfd7109ab1845368002",
|
||||
"textNormalizedSha256": "ae242330d98c9335160fbd4356894e15633d63c02da4edfd7109ab1845368002",
|
||||
"identitySha256": "ae242330d98c9335160fbd4356894e15633d63c02da4edfd7109ab1845368002"
|
||||
"exactSha256": "3ade4e6f8f2717ca899fd841e61f116963a406e9527015b803854c16d012e278",
|
||||
"textNormalizedSha256": "3ade4e6f8f2717ca899fd841e61f116963a406e9527015b803854c16d012e278",
|
||||
"identitySha256": "3ade4e6f8f2717ca899fd841e61f116963a406e9527015b803854c16d012e278"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1860,17 +1860,17 @@
|
||||
},
|
||||
{
|
||||
"releaseRevision": 6,
|
||||
"packageDigest": "b41563e217d38af2ded7d88ea099a9f996a5280f3333e771a2867a0e3f680055",
|
||||
"gitTreeSha": "49103d96472ad790758f14cfc3ed5c69434a6f1b",
|
||||
"packageDigest": "103f0671da111c9ad3def6c46da8d432e656878b840e6cc742219f4a3a4dbceb",
|
||||
"gitTreeSha": "58dfb5dc3ad287a6a42625f9d15c7c0c3dfd02ec",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 2096,
|
||||
"size": 2257,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "d3a23c0d3e87c6024f710145e0cc6e5c7eb37eda1386c27d074f2538a92b7d1c",
|
||||
"textNormalizedSha256": "d3a23c0d3e87c6024f710145e0cc6e5c7eb37eda1386c27d074f2538a92b7d1c",
|
||||
"identitySha256": "d3a23c0d3e87c6024f710145e0cc6e5c7eb37eda1386c27d074f2538a92b7d1c"
|
||||
"exactSha256": "c005d126a13d2913351472f07690f1c1a660d4f286e2a5f21864aee294f436ce",
|
||||
"textNormalizedSha256": "c005d126a13d2913351472f07690f1c1a660d4f286e2a5f21864aee294f436ce",
|
||||
"identitySha256": "c005d126a13d2913351472f07690f1c1a660d4f286e2a5f21864aee294f436ce"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -25,5 +25,7 @@ to another executable, which could silently target a different Orca build.
|
||||
|
||||
Prefer `--json`. Use the selected executable's `--help` for commands or flags the guide does
|
||||
not cover. If a command reports that Orca is not running, start it with `ORCA open --json`
|
||||
and retry. If `skills get` is unknown, explain that updating Orca restores the guide; use
|
||||
`--help` for read-only discovery and do not guess unsupported commands.
|
||||
and retry. If it fails with `runtime_access_denied`, your sandbox blocked the connection:
|
||||
re-run it with escalated permissions, and do not run `ORCA open` or restart Orca. If
|
||||
`skills get` is unknown, explain that updating Orca restores the guide; use `--help` for
|
||||
read-only discovery and do not guess unsupported commands.
|
||||
|
||||
@@ -40,5 +40,7 @@ ORCA skills get computer-use
|
||||
|
||||
Prefer `--json`. Use the selected executable's `--help` for commands or flags the guide does
|
||||
not cover. If a command reports that Orca is not running, start it with `ORCA open --json`
|
||||
and retry. If `skills get` is unknown, explain that updating Orca restores the guide; use
|
||||
`--help` for read-only discovery and do not guess unsupported commands.
|
||||
and retry. If it fails with `runtime_access_denied`, your sandbox blocked the connection:
|
||||
re-run it with escalated permissions, and do not run `ORCA open` or restart Orca. If
|
||||
`skills get` is unknown, explain that updating Orca restores the guide; use `--help` for
|
||||
read-only discovery and do not guess unsupported commands.
|
||||
|
||||
@@ -41,5 +41,7 @@ ORCA skills get linear-tickets
|
||||
|
||||
Prefer `--json`. Use the selected executable's `--help` for commands or flags the guide does
|
||||
not cover. If a command reports that Orca is not running, start it with `ORCA open --json`
|
||||
and retry. If `skills get` is unknown, explain that updating Orca restores the guide; use
|
||||
`--help` for read-only discovery and do not guess unsupported commands.
|
||||
and retry. If it fails with `runtime_access_denied`, your sandbox blocked the connection:
|
||||
re-run it with escalated permissions, and do not run `ORCA open` or restart Orca. If
|
||||
`skills get` is unknown, explain that updating Orca restores the guide; use `--help` for
|
||||
read-only discovery and do not guess unsupported commands.
|
||||
|
||||
@@ -41,5 +41,7 @@ ORCA skills get orca-cli
|
||||
|
||||
Prefer `--json`. Use the selected executable's `--help` for commands or flags the guide does
|
||||
not cover. If a command reports that Orca is not running, start it with `ORCA open --json`
|
||||
and retry. If `skills get` is unknown, explain that updating Orca restores the guide; use
|
||||
`--help` for read-only discovery and do not guess unsupported commands.
|
||||
and retry. If it fails with `runtime_access_denied`, your sandbox blocked the connection:
|
||||
re-run it with escalated permissions, and do not run `ORCA open` or restart Orca. If
|
||||
`skills get` is unknown, explain that updating Orca restores the guide; use `--help` for
|
||||
read-only discovery and do not guess unsupported commands.
|
||||
|
||||
@@ -41,5 +41,7 @@ ORCA skills get orca-emulator-android
|
||||
|
||||
Prefer `--json`. Use the selected executable's `--help` for commands or flags the guide does
|
||||
not cover. If a command reports that Orca is not running, start it with `ORCA open --json`
|
||||
and retry. If `skills get` is unknown, explain that updating Orca restores the guide; use
|
||||
`--help` for read-only discovery and do not guess unsupported commands.
|
||||
and retry. If it fails with `runtime_access_denied`, your sandbox blocked the connection:
|
||||
re-run it with escalated permissions, and do not run `ORCA open` or restart Orca. If
|
||||
`skills get` is unknown, explain that updating Orca restores the guide; use `--help` for
|
||||
read-only discovery and do not guess unsupported commands.
|
||||
|
||||
@@ -44,5 +44,7 @@ ORCA skills get orca-emulator
|
||||
|
||||
Prefer `--json`. Use the selected executable's `--help` for commands or flags the guide does
|
||||
not cover. If a command reports that Orca is not running, start it with `ORCA open --json`
|
||||
and retry. If `skills get` is unknown, explain that updating Orca restores the guide; use
|
||||
`--help` for read-only discovery and do not guess unsupported commands.
|
||||
and retry. If it fails with `runtime_access_denied`, your sandbox blocked the connection:
|
||||
re-run it with escalated permissions, and do not run `ORCA open` or restart Orca. If
|
||||
`skills get` is unknown, explain that updating Orca restores the guide; use `--help` for
|
||||
read-only discovery and do not guess unsupported commands.
|
||||
|
||||
@@ -39,5 +39,7 @@ ORCA skills get orca-linear
|
||||
|
||||
Prefer `--json`. Use the selected executable's `--help` for commands or flags the guide does
|
||||
not cover. If a command reports that Orca is not running, start it with `ORCA open --json`
|
||||
and retry. If `skills get` is unknown, explain that updating Orca restores the guide; use
|
||||
`--help` for read-only discovery and do not guess unsupported commands.
|
||||
and retry. If it fails with `runtime_access_denied`, your sandbox blocked the connection:
|
||||
re-run it with escalated permissions, and do not run `ORCA open` or restart Orca. If
|
||||
`skills get` is unknown, explain that updating Orca restores the guide; use `--help` for
|
||||
read-only discovery and do not guess unsupported commands.
|
||||
|
||||
@@ -40,5 +40,7 @@ ORCA skills get orca-per-workspace-env
|
||||
|
||||
Prefer `--json`. Use the selected executable's `--help` for commands or flags the guide does
|
||||
not cover. If a command reports that Orca is not running, start it with `ORCA open --json`
|
||||
and retry. If `skills get` is unknown, explain that updating Orca restores the guide; use
|
||||
`--help` for read-only discovery and do not guess unsupported commands.
|
||||
and retry. If it fails with `runtime_access_denied`, your sandbox blocked the connection:
|
||||
re-run it with escalated permissions, and do not run `ORCA open` or restart Orca. If
|
||||
`skills get` is unknown, explain that updating Orca restores the guide; use `--help` for
|
||||
read-only discovery and do not guess unsupported commands.
|
||||
|
||||
@@ -60,5 +60,7 @@ reference that gate names with
|
||||
|
||||
Prefer `--json`. Use the selected executable's `--help` for commands or flags the guide does
|
||||
not cover. If a command reports that Orca is not running, start it with `ORCA open --json`
|
||||
and retry. If `skills get` is unknown, explain that updating Orca restores the guide; use
|
||||
`--help` for read-only discovery and do not guess unsupported commands.
|
||||
and retry. If it fails with `runtime_access_denied`, your sandbox blocked the connection:
|
||||
re-run it with escalated permissions, and do not run `ORCA open` or restart Orca. If
|
||||
`skills get` is unknown, explain that updating Orca restores the guide; use `--help` for
|
||||
read-only discovery and do not guess unsupported commands.
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeMetadata } from '../../shared/runtime-bootstrap'
|
||||
import { formatCliError, reportCliError } from '../cli-error'
|
||||
import { RuntimeClient } from './client'
|
||||
import { launchOrcaApp } from './launch'
|
||||
import { getCliStatus } from './status'
|
||||
import { sendRequest } from './transport'
|
||||
|
||||
const { connect, tryReadMetadata } = vi.hoisted(() => ({
|
||||
connect: vi.fn(),
|
||||
tryReadMetadata: vi.fn()
|
||||
}))
|
||||
vi.mock('node:net', () => ({ createConnection: connect }))
|
||||
vi.mock('./metadata', () => ({ tryReadMetadata, readMetadata: tryReadMetadata }))
|
||||
vi.mock('./launch', () => ({ launchOrcaApp: vi.fn() }))
|
||||
vi.mock('./runtime-remote-pairing', () => ({ resolveRemotePairing: () => null }))
|
||||
|
||||
const metadata: RuntimeMetadata = {
|
||||
runtimeId: 'runtime-test',
|
||||
pid: 12345,
|
||||
transports: [{ kind: 'unix', endpoint: '/private-runtime.sock' }],
|
||||
authToken: 'private-runtime-token',
|
||||
startedAt: 1
|
||||
}
|
||||
|
||||
class TestSocket extends EventEmitter {
|
||||
setEncoding = vi.fn()
|
||||
end = vi.fn()
|
||||
destroy = vi.fn()
|
||||
write = vi.fn()
|
||||
}
|
||||
|
||||
const RESTART_OR_ABSENT_ADVICE =
|
||||
/Restart Orca and try again|Orca is not running|Run 'orca open' first/
|
||||
|
||||
let socket: TestSocket
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('CODEX_SANDBOX', '')
|
||||
socket = new TestSocket()
|
||||
connect.mockReturnValue(socket)
|
||||
tryReadMetadata.mockReturnValue(metadata)
|
||||
mockKill()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
vi.restoreAllMocks()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Node emits 'error' then 'close' for a refused or denied connect.
|
||||
function failConnect(code: string): void {
|
||||
socket.emit('error', Object.assign(new Error(`connect ${code} /private-runtime.sock`), { code }))
|
||||
socket.emit('close')
|
||||
}
|
||||
|
||||
function mockKill(code?: string): void {
|
||||
vi.spyOn(process, 'kill').mockImplementation(() => {
|
||||
if (code) {
|
||||
throw Object.assign(new Error(`kill ${code}`), { code })
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
async function deniedRequest(code: string): Promise<unknown> {
|
||||
const pending = sendRequest(metadata, 'status.get', undefined, 1000)
|
||||
failConnect(code)
|
||||
return pending.catch((failure: unknown) => failure)
|
||||
}
|
||||
|
||||
describe('runtime access denied', () => {
|
||||
it.each(['EPERM', 'EACCES'])('classifies a %s connect without restart advice', async (code) => {
|
||||
const error = await deniedRequest(code)
|
||||
|
||||
expect(error).toMatchObject({ code: 'runtime_access_denied', data: { systemCode: code } })
|
||||
expect(socket.write).not.toHaveBeenCalled()
|
||||
const human = formatCliError(error)
|
||||
expect(human).toContain(
|
||||
`Permission denied connecting to Orca (${code}). Orca may be running normally`
|
||||
)
|
||||
expect(human).toContain("Next step: Do not restart Orca or run 'orca open'")
|
||||
expect(human).not.toMatch(RESTART_OR_ABSENT_ADVICE)
|
||||
expect(human).not.toContain('private-runtime')
|
||||
})
|
||||
|
||||
it('names the Codex sandbox only when CODEX_SANDBOX is set', async () => {
|
||||
vi.stubEnv('CODEX_SANDBOX', 'seatbelt')
|
||||
const human = formatCliError(await deniedRequest('EPERM'))
|
||||
|
||||
expect(human).toContain('The Codex sandbox blocked this command from connecting to Orca')
|
||||
expect(human).toContain('escalated permissions, outside the Codex sandbox')
|
||||
expect(human).not.toMatch(RESTART_OR_ABSENT_ADVICE)
|
||||
})
|
||||
|
||||
it('keeps ordinary connect failures as runtime_unavailable', async () => {
|
||||
expect(await deniedRequest('ECONNREFUSED')).toMatchObject({ code: 'runtime_unavailable' })
|
||||
})
|
||||
|
||||
it.each([undefined, 'EPERM'])(
|
||||
'fails status instead of guessing a state (kill %s)',
|
||||
async (killCode) => {
|
||||
mockKill(killCode)
|
||||
const pending = getCliStatus('/test')
|
||||
failConnect('EPERM')
|
||||
|
||||
await expect(pending).rejects.toMatchObject({ code: 'runtime_access_denied' })
|
||||
}
|
||||
)
|
||||
|
||||
// Why: a dead Orca leaves its socket behind, and the sandbox denies it before ECONNREFUSED.
|
||||
it('gives not-running advice when the denied endpoint belongs to a dead pid', async () => {
|
||||
mockKill('ESRCH')
|
||||
const human = formatCliError(await deniedRequest('EPERM'))
|
||||
|
||||
expect(human).toContain("Orca is not running. Run 'orca open' first.")
|
||||
expect(human).not.toContain('Do not restart')
|
||||
const pending = getCliStatus('/test')
|
||||
failConnect('EPERM')
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
result: { app: { running: false }, runtime: { state: 'stale_bootstrap' } }
|
||||
})
|
||||
})
|
||||
|
||||
it('does not launch or poll Orca when the initial status is denied', async () => {
|
||||
const pending = new RuntimeClient('/test', 1000, null, null).openOrca()
|
||||
failConnect('EPERM')
|
||||
|
||||
await expect(pending).rejects.toMatchObject({ code: 'runtime_access_denied' })
|
||||
expect(launchOrcaApp).not.toHaveBeenCalled()
|
||||
expect(connect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reports status --json as an ok:false envelope with the recovery data', async () => {
|
||||
const pending = getCliStatus('/test')
|
||||
failConnect('EPERM')
|
||||
const error = await pending.catch((failure: unknown) => failure)
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
reportCliError(error, true, { commandPath: ['status'] })
|
||||
|
||||
expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'runtime_access_denied',
|
||||
data: { systemCode: 'EPERM', nextSteps: expect.any(Array) }
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { RuntimeClientError } from './types'
|
||||
import { isProcessRunning } from './runtime-pid-liveness'
|
||||
|
||||
// Why: the errno decides the classification; CODEX_SANDBOX only picks the wording.
|
||||
export function runtimeAccessDeniedError(
|
||||
socketError: Error,
|
||||
pid: number
|
||||
): RuntimeClientError | null {
|
||||
const systemCode = 'code' in socketError ? socketError.code : undefined
|
||||
// Why: a sandbox still sees ESRCH, so a dead Orca's leftover socket gets not-running advice.
|
||||
if ((systemCode !== 'EPERM' && systemCode !== 'EACCES') || !isProcessRunning(pid)) {
|
||||
return null
|
||||
}
|
||||
const codexSandbox = Boolean(process.env.CODEX_SANDBOX)
|
||||
const message = codexSandbox
|
||||
? `The Codex sandbox blocked this command from connecting to Orca (${systemCode}). Orca may be running normally.`
|
||||
: `Permission denied connecting to Orca (${systemCode}). Orca may be running normally; this command's sandbox or OS permissions block the connection.`
|
||||
const retryStep = codexSandbox
|
||||
? 'Re-run this command with escalated permissions, outside the Codex sandbox.'
|
||||
: 'Re-run this command outside its sandbox, or as a user allowed to reach the Orca runtime.'
|
||||
return new RuntimeClientError('runtime_access_denied', message, {
|
||||
systemCode,
|
||||
nextSteps: [
|
||||
retryStep,
|
||||
"Do not restart Orca or run 'orca open'; a restart cannot grant this command access."
|
||||
]
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export function isProcessRunning(pid: number | null | undefined): boolean {
|
||||
if (!pid || pid <= 0) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch (error) {
|
||||
// Why: only ESRCH proves the pid is gone. EPERM means it exists under another uid, and
|
||||
// reporting that as `stale_bootstrap` calls a live Orca dead.
|
||||
return !(error instanceof Error && 'code' in error && error.code === 'ESRCH')
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
projectRemoteAppStatus,
|
||||
resolveDesktopWindowStatus
|
||||
} from '../../shared/cli-app-status-projection'
|
||||
import { RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types'
|
||||
import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types'
|
||||
import { isProcessRunning } from './runtime-pid-liveness'
|
||||
|
||||
export { projectRemoteAppStatus, resolveDesktopWindowStatus }
|
||||
|
||||
@@ -68,7 +69,11 @@ export async function getCliStatus(
|
||||
state: graphState
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
} catch (error) {
|
||||
// Why: a denied caller cannot tell a live Orca from a dead one, so report the denial, not a state.
|
||||
if (error instanceof RuntimeClientError && error.code === 'runtime_access_denied') {
|
||||
throw error
|
||||
}
|
||||
const running = isProcessRunning(metadata.pid)
|
||||
return buildCliStatusResponse({
|
||||
app: {
|
||||
@@ -98,17 +103,3 @@ function buildCliStatusResponse(result: CliStatusResult): RuntimeRpcSuccess<CliS
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isProcessRunning(pid: number | null | undefined): boolean {
|
||||
if (!pid || pid <= 0) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch (error) {
|
||||
// Why: only ESRCH proves the pid is gone. EPERM means it exists under another uid, and
|
||||
// reporting that as `stale_bootstrap` calls a live Orca dead.
|
||||
return !(error instanceof Error && 'code' in error && error.code === 'ESRCH')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-enve
|
||||
import { isKeepaliveFrame, RuntimeRpcEnvelopeSchema } from './envelope-schema'
|
||||
import { RuntimeClientError, type RuntimeRpcResponse } from './types'
|
||||
import { MAX_TIMER_DELAY_MS, isSafeTimerDelayMs } from '../../shared/timer-delay'
|
||||
import { runtimeAccessDeniedError } from './runtime-access-denied'
|
||||
|
||||
export async function sendRequest<TResult>(
|
||||
metadata: RuntimeMetadata,
|
||||
@@ -68,13 +69,16 @@ export async function sendRequest<TResult>(
|
||||
}
|
||||
|
||||
socket.setEncoding('utf8')
|
||||
socket.once('error', () => {
|
||||
socket.once('error', (error) => {
|
||||
finish({
|
||||
ok: false,
|
||||
error: new RuntimeClientError(
|
||||
'runtime_unavailable',
|
||||
'Could not connect to the running Orca app. Restart Orca and try again.'
|
||||
)
|
||||
// Why: a sandbox denying the socket/pipe is not a dead app, so restart advice would mislead.
|
||||
error:
|
||||
runtimeAccessDeniedError(error, metadata.pid) ??
|
||||
new RuntimeClientError(
|
||||
'runtime_unavailable',
|
||||
'Could not connect to the running Orca app. Restart Orca and try again.'
|
||||
)
|
||||
})
|
||||
})
|
||||
// Why: a clean peer close (FIN, no 'error') before a terminal frame never
|
||||
|
||||
@@ -13,6 +13,7 @@ import { geminiHookService } from '../gemini/hook-service'
|
||||
import { grokHookService } from '../grok/hook-service'
|
||||
import { hermesHookService } from '../hermes/hook-service'
|
||||
import { kimiHookService } from '../kimi/hook-service'
|
||||
import { museHookService } from '../muse/hook-service'
|
||||
import { openClaudeHookService } from '../openclaude/hook-service'
|
||||
|
||||
// Why (#16441): Codex's installer awaits a codex app-server trust-grant session
|
||||
@@ -50,7 +51,8 @@ export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[]
|
||||
['copilot', () => copilotHookService.install()],
|
||||
['hermes', () => hermesHookService.install()],
|
||||
['devin', () => devinHookService.install()],
|
||||
['kimi', () => kimiHookService.install()]
|
||||
['kimi', () => kimiHookService.install()],
|
||||
['muse', () => museHookService.install()]
|
||||
]
|
||||
|
||||
// Why: covers the shared launcher/statusline scripts under ~/.orca/agent-hooks — the files a
|
||||
@@ -71,7 +73,8 @@ export const MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS: readonly ManagedAgentHookScri
|
||||
['grok', () => grokHookService.refreshManagedScripts()],
|
||||
['copilot', () => copilotHookService.refreshManagedScripts()],
|
||||
['devin', () => devinHookService.refreshManagedScripts()],
|
||||
['kimi', () => kimiHookService.refreshManagedScripts()]
|
||||
['kimi', () => kimiHookService.refreshManagedScripts()],
|
||||
['muse', () => museHookService.refreshManagedScripts()]
|
||||
]
|
||||
|
||||
export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [
|
||||
@@ -88,7 +91,8 @@ export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [
|
||||
['copilot', () => copilotHookService.remove()],
|
||||
['hermes', () => hermesHookService.remove()],
|
||||
['devin', () => devinHookService.remove()],
|
||||
['kimi', () => kimiHookService.remove()]
|
||||
['kimi', () => kimiHookService.remove()],
|
||||
['muse', () => museHookService.remove()]
|
||||
]
|
||||
|
||||
export const MANAGED_AGENT_HOOK_ASYNC_REMOVERS: readonly ManagedAgentHookAsyncRemover[] = [
|
||||
@@ -109,5 +113,6 @@ export const MANAGED_AGENT_HOOK_STATUS_READERS: readonly ManagedAgentHookStatusR
|
||||
['copilot', () => copilotHookService.getStatus()],
|
||||
['hermes', () => hermesHookService.getStatus()],
|
||||
['devin', () => devinHookService.getStatus()],
|
||||
['kimi', () => kimiHookService.getStatus()]
|
||||
['kimi', () => kimiHookService.getStatus()],
|
||||
['muse', () => museHookService.getStatus()]
|
||||
]
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from '../copilot/copilot-managed-hook-definitions'
|
||||
import { getDevinManagedCommand, getDevinRemoteManagedCommand } from '../devin/hook-settings'
|
||||
import { getGrokManagedCommand } from '../grok/grok-hook-script'
|
||||
import { getMuseManagedCommand, getMuseRemoteManagedCommand } from '../muse/hook-settings'
|
||||
import {
|
||||
wrapPosixHookCommand,
|
||||
wrapWindowsCmdHookCommand,
|
||||
@@ -141,6 +142,13 @@ const buildersByAgent = new Map<string, CommandBuilders>([
|
||||
local: (path) => [wrapPosixHookCommand(path.replaceAll('\\', '/'))],
|
||||
remote: (path) => [wrapPosixHookCommand(path)]
|
||||
}
|
||||
],
|
||||
[
|
||||
'muse',
|
||||
{
|
||||
local: (path) => [getMuseManagedCommand(path)],
|
||||
remote: (path) => [getMuseRemoteManagedCommand(path)]
|
||||
}
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
@@ -45,13 +45,13 @@ describe('managed-hook local filesystem', () => {
|
||||
const cold = await installRemoteManagedAgentHooks(filesystem, home, options)
|
||||
const warm = await installRemoteManagedAgentHooks(filesystem, home, options)
|
||||
|
||||
expect(cold).toHaveLength(14)
|
||||
expect(cold).toHaveLength(REMOTE_MANAGED_HOOK_INSTALLER_AGENTS.length)
|
||||
expect(cold.filter((result) => result.state === 'error')).toEqual([])
|
||||
expect(warm).toHaveLength(14)
|
||||
expect(warm).toHaveLength(REMOTE_MANAGED_HOOK_INSTALLER_AGENTS.length)
|
||||
expect(warm.filter((result) => result.state === 'error')).toEqual([])
|
||||
const files = await listFiles(home)
|
||||
expect(files.filter((path) => path.endsWith('.tmp'))).toEqual([])
|
||||
const scripts = files.filter((path) => path.includes(join('.orca', 'agent-hooks')))
|
||||
const scripts = files.filter((path) => /\.(?:sh|cmd)$/.test(path))
|
||||
expect(scripts.length).toBeGreaterThanOrEqual(10)
|
||||
if (process.platform !== 'win32') {
|
||||
for (const script of scripts) {
|
||||
@@ -71,7 +71,7 @@ describe('managed-hook local filesystem', () => {
|
||||
agents: REMOTE_MANAGED_HOOK_INSTALLER_AGENTS
|
||||
})
|
||||
|
||||
expect(results).toHaveLength(14)
|
||||
expect(results).toHaveLength(REMOTE_MANAGED_HOOK_INSTALLER_AGENTS.length)
|
||||
expect(results.find((result) => result.agent === 'claude')?.state).toBe('error')
|
||||
expect(results.find((result) => result.agent === 'openclaude')?.state).toBe('installed')
|
||||
expect(results.find((result) => result.agent === 'kimi')?.state).toBe('installed')
|
||||
|
||||
@@ -22,6 +22,7 @@ import { CopilotHookService, copilotHookService } from '../copilot/hook-service'
|
||||
import { HermesHookService, hermesHookService } from '../hermes/hook-service'
|
||||
import { DevinHookService, devinHookService } from '../devin/hook-service'
|
||||
import { KimiHookService, kimiHookService } from '../kimi/hook-service'
|
||||
import { museHookService } from '../muse/hook-service'
|
||||
import { openClaudeHookService } from '../openclaude/hook-service'
|
||||
import { MANAGED_AGENT_HOOK_INSTALLERS } from './managed-agent-hook-controls'
|
||||
import {
|
||||
@@ -57,10 +58,7 @@ function createFakeSftp(initialFiles: Record<string, string> = {}): {
|
||||
modes: new Map(),
|
||||
failRenameTo: new Set()
|
||||
}
|
||||
const noEntryError = (path: string): { code: number; message: string } => ({
|
||||
code: 2,
|
||||
message: `ENOENT ${path}`
|
||||
})
|
||||
const noEntryError = (path: string) => ({ code: 2, message: `ENOENT ${path}` })
|
||||
const fakeStats = (mode: number): { mode: number } => ({ mode })
|
||||
|
||||
const sftp = {
|
||||
@@ -709,7 +707,8 @@ describe('remote hook service installers', () => {
|
||||
['copilot', copilotHookService],
|
||||
['hermes', hermesHookService],
|
||||
['devin', devinHookService],
|
||||
['kimi', kimiHookService]
|
||||
['kimi', kimiHookService],
|
||||
['muse', museHookService]
|
||||
])
|
||||
|
||||
// Guard against a service silently missing from the map above as new agents land.
|
||||
|
||||
@@ -13,6 +13,7 @@ import { droidHookService } from '../droid/hook-service'
|
||||
import { grokHookService } from '../grok/hook-service'
|
||||
import { hermesHookService } from '../hermes/hook-service'
|
||||
import { kimiHookService } from '../kimi/hook-service'
|
||||
import { museHookService } from '../muse/hook-service'
|
||||
import { openClaudeHookService } from '../openclaude/hook-service'
|
||||
|
||||
export type RemoteManagedHookInstallOptions = {
|
||||
@@ -72,7 +73,8 @@ const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [
|
||||
['droid', (sftp, remoteHome) => droidHookService.installRemote(sftp, remoteHome)],
|
||||
['hermes', (sftp, remoteHome) => hermesHookService.installRemote(sftp, remoteHome)],
|
||||
['devin', (sftp, remoteHome) => devinHookService.installRemote(sftp, remoteHome)],
|
||||
['kimi', (sftp, remoteHome) => kimiHookService.installRemote(sftp, remoteHome)]
|
||||
['kimi', (sftp, remoteHome) => kimiHookService.installRemote(sftp, remoteHome)],
|
||||
['muse', (sftp, remoteHome) => museHookService.installRemote(sftp, remoteHome)]
|
||||
]
|
||||
|
||||
/** Agents wired into the remote (SSH) hook installer. Exported so an invariant
|
||||
|
||||
@@ -80,12 +80,13 @@ describe('AgentHookServer listener replay', () => {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
const order: string[] = []
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: spies on protected AgentHookServer methods that exist on the instance.
|
||||
const internal = server as unknown as {
|
||||
scheduleAssistantMessageRetry: (...args: unknown[]) => void
|
||||
scheduleCodexSubagentPoll: (...args: unknown[]) => void
|
||||
scheduleTranscriptPoll: (...args: unknown[]) => void
|
||||
}
|
||||
const originalAssistantRetry = internal.scheduleAssistantMessageRetry.bind(server)
|
||||
const originalCodexRetry = internal.scheduleCodexSubagentPoll.bind(server)
|
||||
const originalTranscriptPoll = internal.scheduleTranscriptPoll.bind(server)
|
||||
const assistantRetry = vi
|
||||
.spyOn(internal, 'scheduleAssistantMessageRetry')
|
||||
.mockImplementation((...args) => {
|
||||
@@ -93,10 +94,10 @@ describe('AgentHookServer listener replay', () => {
|
||||
originalAssistantRetry(...args)
|
||||
})
|
||||
const codexRetry = vi
|
||||
.spyOn(internal, 'scheduleCodexSubagentPoll')
|
||||
.spyOn(internal, 'scheduleTranscriptPoll')
|
||||
.mockImplementation((...args) => {
|
||||
order.push('codex-retry')
|
||||
originalCodexRetry(...args)
|
||||
originalTranscriptPoll(...args)
|
||||
})
|
||||
const unsubscribeStatus = server.subscribeStatusChanges(() => order.push('status-change'))
|
||||
server.setListener(() => {
|
||||
@@ -131,12 +132,13 @@ describe('AgentHookServer listener replay', () => {
|
||||
it('fails open after a throwing callback with cache retained and retries skipped', async () => {
|
||||
const server = new AgentHookServer()
|
||||
await server.start({ env: 'production' })
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: spies on protected AgentHookServer methods that exist on the instance.
|
||||
const internal = server as unknown as {
|
||||
scheduleAssistantMessageRetry: (...args: unknown[]) => void
|
||||
scheduleCodexSubagentPoll: (...args: unknown[]) => void
|
||||
scheduleTranscriptPoll: (...args: unknown[]) => void
|
||||
}
|
||||
const assistantRetry = vi.spyOn(internal, 'scheduleAssistantMessageRetry')
|
||||
const codexRetry = vi.spyOn(internal, 'scheduleCodexSubagentPoll')
|
||||
const codexRetry = vi.spyOn(internal, 'scheduleTranscriptPoll')
|
||||
server.setListener(() => {
|
||||
throw new Error('listener failed')
|
||||
})
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { AgentHookServer } from './server'
|
||||
import type { EnrichedAgentHookEventPayload } from './server/server-types'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
|
||||
const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111')
|
||||
const SESSION_ID = '01a0caa3-0e77-7d41-bad7-46283a45633d'
|
||||
const PROMPT_ID = '01a0caa3-a25a-7810-8229-4de04b2e7ca3'
|
||||
const QUESTION = { id: 'fav_color', question: 'What is your favorite color?' }
|
||||
|
||||
function sessionLogLine(event: Record<string, unknown>): string {
|
||||
return `${JSON.stringify({ payload: { kind: 'run', event } })}\n`
|
||||
}
|
||||
|
||||
function createSessionLog(dataHome: string): string {
|
||||
const date = new Date(Number.parseInt(SESSION_ID.replace(/-/g, '').slice(0, 12), 16))
|
||||
const dir = join(
|
||||
dataHome,
|
||||
'muse',
|
||||
'sessions',
|
||||
String(date.getFullYear()),
|
||||
String(date.getMonth() + 1).padStart(2, '0'),
|
||||
String(date.getDate()).padStart(2, '0'),
|
||||
SESSION_ID
|
||||
)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
const logPath = join(dir, 'session.jsonl')
|
||||
writeFileSync(logPath, '')
|
||||
return logPath
|
||||
}
|
||||
|
||||
describe('AgentHookServer Muse session log polling', () => {
|
||||
const dirs: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
for (const dir of dirs) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
dirs.length = 0
|
||||
})
|
||||
|
||||
// Why: Muse fires no hook for request_user_input, so only the poll can surface the wait and its answer.
|
||||
it('flips the pane to waiting for a logged question and back once it settles', async () => {
|
||||
const dataHome = mkdtempSync(join(tmpdir(), 'agent-hook-muse-poll-'))
|
||||
dirs.push(dataHome)
|
||||
vi.stubEnv('XDG_DATA_HOME', dataHome)
|
||||
const logPath = createSessionLog(dataHome)
|
||||
const server = new AgentHookServer()
|
||||
const published: EnrichedAgentHookEventPayload[] = []
|
||||
server.setListener((event) => published.push(event))
|
||||
await server.start({ env: 'production' })
|
||||
try {
|
||||
const env = server.buildPtyEnv()
|
||||
const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/muse`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
|
||||
},
|
||||
body: JSON.stringify({
|
||||
paneKey: PANE_KEY,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: {
|
||||
hook_event_name: 'UserPromptSubmit',
|
||||
prompt: 'ask my favorite color',
|
||||
session_id: SESSION_ID,
|
||||
turn_id: 'e495d1a5-59aa-47b4-8efb-a1bd75509afc',
|
||||
cwd: '/tmp/ws',
|
||||
transcript_path: null,
|
||||
model: 'muse-spark-1.3',
|
||||
permission_mode: 'default',
|
||||
model_provider: 'meta'
|
||||
}
|
||||
})
|
||||
})
|
||||
expect(response.status).toBe(204)
|
||||
expect(server.getStatusSnapshot()[0]?.state).toBe('working')
|
||||
expect(published.at(-1)?.hasExplicitPrompt).toBe(true)
|
||||
|
||||
appendFileSync(
|
||||
logPath,
|
||||
sessionLogLine({
|
||||
kind: 'user_input_prompt_requested',
|
||||
prompt_id: PROMPT_ID,
|
||||
tool_name: 'request_user_input',
|
||||
questions: [QUESTION]
|
||||
})
|
||||
)
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: 'waiting',
|
||||
interactivePrompt: JSON.stringify({ questions: [QUESTION] })
|
||||
})
|
||||
},
|
||||
{ timeout: 3_000, interval: 50 }
|
||||
)
|
||||
const waiting = published.at(-1)
|
||||
expect(waiting?.payload.state).toBe('waiting')
|
||||
expect(waiting?.hasExplicitPrompt).toBeUndefined()
|
||||
|
||||
appendFileSync(
|
||||
logPath,
|
||||
sessionLogLine({ kind: 'user_input_prompt_settled', prompt_id: PROMPT_ID })
|
||||
)
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
expect(server.getStatusSnapshot()[0]?.state).toBe('working')
|
||||
},
|
||||
{ timeout: 3_000, interval: 50 }
|
||||
)
|
||||
expect(published.at(-1)?.hasExplicitPrompt).toBeUndefined()
|
||||
expect(server.getStatusSnapshot()[0]?.interactivePrompt).toBeUndefined()
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -40,7 +40,8 @@ const NEW_TURN_EVENT: Record<AgentHookSource, string | null> = {
|
||||
opencode: 'SessionStart',
|
||||
opencode2: 'SessionStart',
|
||||
'mimo-code': null,
|
||||
'command-code': null
|
||||
'command-code': null,
|
||||
muse: 'UserPromptSubmit'
|
||||
}
|
||||
|
||||
function reviveRetiredPane(source: unknown, hookEventName: string): boolean {
|
||||
|
||||
@@ -226,7 +226,7 @@ export abstract class AgentHookServerAuthorityAliases extends AgentHookServerAut
|
||||
this.promptSentDedupeByPaneKey.set(toPaneKey, promptDedupe)
|
||||
}
|
||||
this.clearAssistantMessageRetry(previousOwnerPaneKey)
|
||||
this.clearCodexSubagentPoll(previousOwnerPaneKey)
|
||||
this.clearTranscriptPoll(previousOwnerPaneKey)
|
||||
// Why: the live process keeps posting the physical source key after detach; persist a chain-safe mapping to the current owner.
|
||||
this.legacyPaneKeyAliases.set(physicalPaneKey, {
|
||||
stablePaneKey: toPaneKey,
|
||||
|
||||
@@ -52,7 +52,7 @@ export abstract class AgentHookServerAuthorityFences extends AgentHookServerAuth
|
||||
this.markPaneClosedForAgentStatus(key)
|
||||
this.restartedStatusLaunchTokenHashByPaneKey.delete(key)
|
||||
this.clearAssistantMessageRetry(key)
|
||||
this.clearCodexSubagentPoll(key)
|
||||
this.clearTranscriptPoll(key)
|
||||
clearPaneCacheState(this.state, key)
|
||||
this.activeHookTurnCompletedAtByPaneKey.delete(key)
|
||||
this.runtimeObservedStatusPaneKeys.delete(key)
|
||||
|
||||
@@ -235,7 +235,7 @@ export abstract class AgentHookServerCleanup extends AgentHookServerAuthorityFen
|
||||
this.persistedAuthorityCommitmentsByPaneKey.delete(resolvedPaneKey)
|
||||
}
|
||||
this.clearAssistantMessageRetry(resolvedPaneKey)
|
||||
this.clearCodexSubagentPoll(resolvedPaneKey)
|
||||
this.clearTranscriptPoll(resolvedPaneKey)
|
||||
this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey)
|
||||
this.currentAuthorityObservations.delete(resolvedPaneKey)
|
||||
if (existing.payload.state === 'done') {
|
||||
|
||||
@@ -132,7 +132,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
|
||||
const enriched = this.applyNormalizedStatus(event, normalized.onAccepted)
|
||||
if (enriched) {
|
||||
this.scheduleAssistantMessageRetry(source, aliasedBody, enriched)
|
||||
this.scheduleCodexSubagentPoll(source, aliasedBody, enriched)
|
||||
this.scheduleTranscriptPoll(source, aliasedBody, enriched)
|
||||
}
|
||||
}
|
||||
res.writeHead(204)
|
||||
@@ -204,7 +204,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerRuntimeEnv
|
||||
clearTimeout(timer)
|
||||
}
|
||||
this.assistantMessageRetryTimers.clear()
|
||||
this.clearAllCodexSubagentPolls()
|
||||
this.clearAllTranscriptPolls()
|
||||
this.endpointDir = null
|
||||
this.endpointFilePathCache = null
|
||||
this.endpointFileWritten = false
|
||||
|
||||
@@ -213,9 +213,9 @@ export abstract class AgentHookServerState {
|
||||
): EnrichedAgentHookEventPayload | undefined
|
||||
protected abstract emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void
|
||||
protected abstract clearAssistantMessageRetry(paneKey: string): void
|
||||
protected abstract clearCodexSubagentPoll(paneKey: string): void
|
||||
protected abstract clearAllCodexSubagentPolls(): void
|
||||
protected abstract scheduleCodexSubagentPoll(
|
||||
protected abstract clearTranscriptPoll(paneKey: string): void
|
||||
protected abstract clearAllTranscriptPolls(): void
|
||||
protected abstract scheduleTranscriptPoll(
|
||||
source: AgentHookSource,
|
||||
body: unknown,
|
||||
original: EnrichedAgentHookEventPayload
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { hasCodexTranscriptSubagents } from '../../../shared/agent-hook-listener/providers/codex-state'
|
||||
import { normalizeHookPayload } from '../../../shared/agent-hook-listener'
|
||||
import {
|
||||
hasPendingAgentResultText,
|
||||
preparePendingGrokResultDiscovery
|
||||
} from '../../../shared/agent-hook-listener/grok-result-discovery'
|
||||
import type { AgentHookSource } from '../../../shared/agent-hook-relay'
|
||||
import {
|
||||
shouldPollHookTranscript,
|
||||
transcriptPollUpdate
|
||||
} from '../../../shared/agent-hook-listener/transcript-poll-policy'
|
||||
import { CodexSubagentPollScheduler } from '../../../shared/codex-subagent-poll-scheduler'
|
||||
import type { EnrichedAgentHookEventPayload } from './server-types'
|
||||
import {
|
||||
@@ -14,20 +17,20 @@ import {
|
||||
} from './server-constants'
|
||||
import { AgentHookServerStatusUpdate } from './server-status-update'
|
||||
|
||||
type CodexSubagentPoll = {
|
||||
type TranscriptPoll = {
|
||||
source: AgentHookSource
|
||||
body: unknown
|
||||
original: EnrichedAgentHookEventPayload
|
||||
}
|
||||
|
||||
export abstract class AgentHookServerStatusRetries extends AgentHookServerStatusUpdate {
|
||||
private readonly codexSubagentPollScheduler = new CodexSubagentPollScheduler<CodexSubagentPoll>(
|
||||
private readonly transcriptPollScheduler = new CodexSubagentPollScheduler<TranscriptPoll>(
|
||||
CODEX_SUBAGENT_POLL_MS,
|
||||
(paneKey, poll) => this.runCodexSubagentPoll(paneKey, poll)
|
||||
(paneKey, poll) => this.runTranscriptPoll(paneKey, poll)
|
||||
)
|
||||
|
||||
protected clearAllCodexSubagentPolls(): void {
|
||||
this.codexSubagentPollScheduler.clearAll()
|
||||
protected clearAllTranscriptPolls(): void {
|
||||
this.transcriptPollScheduler.clearAll()
|
||||
}
|
||||
|
||||
protected clearAssistantMessageRetry(paneKey: string): void {
|
||||
@@ -39,27 +42,27 @@ export abstract class AgentHookServerStatusRetries extends AgentHookServerStatus
|
||||
this.assistantMessageRetryTimers.delete(paneKey)
|
||||
}
|
||||
|
||||
protected clearCodexSubagentPoll(paneKey: string): void {
|
||||
this.codexSubagentPollScheduler.clear(paneKey)
|
||||
protected clearTranscriptPoll(paneKey: string): void {
|
||||
this.transcriptPollScheduler.clear(paneKey)
|
||||
}
|
||||
|
||||
protected scheduleCodexSubagentPoll(
|
||||
protected scheduleTranscriptPoll(
|
||||
source: AgentHookSource,
|
||||
body: unknown,
|
||||
original: EnrichedAgentHookEventPayload
|
||||
): void {
|
||||
// Why: a nested non-codex CLI inherits ORCA_PANE_KEY, so clearing here would silently end a live codex poll.
|
||||
if (source !== 'codex') {
|
||||
// Why: a nested CLI of another kind inherits ORCA_PANE_KEY, so clearing here would silently end a live poll.
|
||||
if (source !== 'codex' && source !== 'muse') {
|
||||
return
|
||||
}
|
||||
this.codexSubagentPollScheduler.clear(original.paneKey)
|
||||
if (!hasCodexTranscriptSubagents(this.state, original.paneKey)) {
|
||||
this.transcriptPollScheduler.clear(original.paneKey)
|
||||
if (!shouldPollHookTranscript(this.state, source, original)) {
|
||||
return
|
||||
}
|
||||
this.codexSubagentPollScheduler.schedule(original.paneKey, { source, body, original })
|
||||
this.transcriptPollScheduler.schedule(original.paneKey, { source, body, original })
|
||||
}
|
||||
|
||||
private runCodexSubagentPoll(paneKey: string, poll: CodexSubagentPoll): void {
|
||||
private runTranscriptPoll(paneKey: string, poll: TranscriptPoll): void {
|
||||
const { source, body, original } = poll
|
||||
// Keep the identity check at callback time: a newer event supersedes this
|
||||
// payload even when its pane still has transcript children.
|
||||
@@ -74,11 +77,10 @@ export abstract class AgentHookServerStatusRetries extends AgentHookServerStatus
|
||||
if (!normalized) {
|
||||
return
|
||||
}
|
||||
const subagentsChanged =
|
||||
JSON.stringify(normalized.payload.subagents) !== JSON.stringify(original.payload.subagents)
|
||||
const next = subagentsChanged ? this.applyNormalizedStatus(normalized) : original
|
||||
const update = transcriptPollUpdate(source, original, normalized)
|
||||
const next = update ? this.applyNormalizedStatus(update) : original
|
||||
if (next) {
|
||||
this.scheduleCodexSubagentPoll(source, body, next)
|
||||
this.scheduleTranscriptPoll(source, body, next)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup {
|
||||
statusChanged = true
|
||||
}
|
||||
this.clearAssistantMessageRetry(paneKey)
|
||||
this.clearCodexSubagentPoll(paneKey)
|
||||
this.clearTranscriptPoll(paneKey)
|
||||
clearPaneCacheState(this.state, paneKey)
|
||||
this.activeHookTurnCompletedAtByPaneKey.delete(paneKey)
|
||||
this.runtimeObservedStatusPaneKeys.delete(paneKey)
|
||||
@@ -109,7 +109,7 @@ export abstract class AgentHookServerTabCleanup extends AgentHookServerCleanup {
|
||||
| undefined
|
||||
const hadStatus = previousStatus !== undefined
|
||||
this.clearAssistantMessageRetry(resolvedPaneKey)
|
||||
this.clearCodexSubagentPoll(resolvedPaneKey)
|
||||
this.clearTranscriptPoll(resolvedPaneKey)
|
||||
clearPaneCacheState(this.state, resolvedPaneKey)
|
||||
this.activeHookTurnCompletedAtByPaneKey.delete(resolvedPaneKey)
|
||||
this.currentAuthorityObservations.delete(resolvedPaneKey)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
|
||||
import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures'
|
||||
|
||||
describe('scanRemoteAiVaultSessions muse', () => {
|
||||
it('discovers Muse transcripts under the remote XDG sessions root', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
const sessionDir = '/home/ada/.local/share/muse/sessions/2026/07/04/muse-remote'
|
||||
provider.addFile(
|
||||
`${sessionDir}/session.jsonl`,
|
||||
jsonLines([
|
||||
{
|
||||
record_type: 'event',
|
||||
payload_type: 'runtime.session.metadata',
|
||||
recorded_at: 1780000000000000,
|
||||
payload: { kind: 'metadata', record: { workspace_root: '/home/ada/repo' } }
|
||||
},
|
||||
{
|
||||
record_type: 'event',
|
||||
payload_type: 'runtime.user_intent.accepted',
|
||||
recorded_at: 1780000001000000,
|
||||
payload: {
|
||||
intent_id: 'intent-remote',
|
||||
refill_blocks: [{ kind: 'text', text: 'Remote muse title' }]
|
||||
}
|
||||
},
|
||||
{
|
||||
record_type: 'event',
|
||||
payload_type: 'runtime.session',
|
||||
recorded_at: 1780000002000000,
|
||||
payload: {
|
||||
kind: 'run',
|
||||
run_id: 'run-remote',
|
||||
event: {
|
||||
kind: 'model_completed',
|
||||
model: 'muse-spark-remote',
|
||||
usage: { input_tokens: 3, output_tokens: 4 }
|
||||
}
|
||||
}
|
||||
}
|
||||
]),
|
||||
40
|
||||
)
|
||||
// Sidecars next to the transcript must not list as sessions.
|
||||
provider.addFile(`${sessionDir}/cli-abc.log`, 'log output', 41)
|
||||
|
||||
const result = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: 'ssh:dev-box',
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
executionHostId: 'ssh:dev-box',
|
||||
executionHostPlatform: 'linux',
|
||||
agent: 'muse',
|
||||
sessionId: 'muse-remote',
|
||||
title: 'Remote muse title',
|
||||
model: 'muse-spark-remote',
|
||||
filePath: `${sessionDir}/session.jsonl`
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import { parseMessageGraphSessionContent } from './session-scanner-graph-parsers'
|
||||
import {
|
||||
parseMuseSessionContent,
|
||||
parseMuseSessionRemoteContent
|
||||
} from './session-scanner-muse-parser'
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
import { normalizeAgentSessionsDir } from './session-scanner-values'
|
||||
import type { RemoteSessionContent } from './remote-session-content-lines'
|
||||
import type { RemoteParserOptions } from './remote-session-scanner-types'
|
||||
|
||||
export function parseMuseRemoteContent(
|
||||
file: FileWithMtime,
|
||||
content: RemoteSessionContent,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSession | null> {
|
||||
if (typeof content === 'string') {
|
||||
return Promise.resolve(parseMuseSessionContent(file, content, platform, options))
|
||||
}
|
||||
return parseMuseSessionRemoteContent(file, content, platform, options, signal)
|
||||
}
|
||||
|
||||
export function piParser(
|
||||
file: FileWithMtime,
|
||||
content: RemoteSessionContent,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseMessageGraphSessionContent('pi', file, content, platform, options, signal)
|
||||
}
|
||||
|
||||
export function ompParser(
|
||||
file: FileWithMtime,
|
||||
content: RemoteSessionContent,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseMessageGraphSessionContent('omp', file, content, platform, options, signal)
|
||||
}
|
||||
|
||||
export function primeAgentParser(
|
||||
file: FileWithMtime,
|
||||
content: RemoteSessionContent,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseMessageGraphSessionContent('prime-agent', file, content, platform, options, signal)
|
||||
}
|
||||
|
||||
export function openClawParser(
|
||||
file: FileWithMtime,
|
||||
content: RemoteSessionContent,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseMessageGraphSessionContent('openclaw', file, content, platform, options, signal)
|
||||
}
|
||||
|
||||
export function remotePathSegments(path: string): string[] {
|
||||
return path.replace(/\\/g, '/').split('/').filter(Boolean)
|
||||
}
|
||||
|
||||
export function remotePiSessionsSegments(): string[] {
|
||||
return normalizeAgentSessionsDir('/.pi/agent/sessions', '.pi').split('/').filter(Boolean)
|
||||
}
|
||||
|
||||
export function remoteOmpSessionsSegments(): string[] {
|
||||
return normalizeAgentSessionsDir('/.omp/agent/sessions', '.omp').split('/').filter(Boolean)
|
||||
}
|
||||
|
||||
// Remote roots are POSIX regardless of the client platform.
|
||||
export function remotePrimeAgentSessionsSegments(): string[] {
|
||||
return ['.prime', 'agent', 'sessions']
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import { parseAntigravitySessionContent } from './session-scanner-antigravity-pa
|
||||
import { isAntigravityTranscriptPath } from './session-scanner-antigravity-paths'
|
||||
import { parseCodexSessionContent } from './session-scanner-codex-parser'
|
||||
import { parseDroidSessionContent } from './session-scanner-droid-parser'
|
||||
import { parseMessageGraphSessionContent } from './session-scanner-graph-parsers'
|
||||
import { parseClaudeSessionContent } from './session-scanner-primary-parsers'
|
||||
import { parseGeminiSessionContent } from './session-scanner-gemini-parsers'
|
||||
import { parseCopilotSessionContent } from './session-scanner-copilot-parser'
|
||||
@@ -15,8 +14,18 @@ import { parseCursorSessionContent } from './session-scanner-cursor-parser'
|
||||
import { parseHermesSessionContent } from './session-scanner-hermes-parser'
|
||||
import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts'
|
||||
import { partitionOmpSubagentTranscriptPaths } from './session-scanner-omp-subagent-transcripts'
|
||||
import {
|
||||
ompParser,
|
||||
openClawParser,
|
||||
parseMuseRemoteContent,
|
||||
piParser,
|
||||
primeAgentParser,
|
||||
remoteOmpSessionsSegments,
|
||||
remotePathSegments,
|
||||
remotePiSessionsSegments,
|
||||
remotePrimeAgentSessionsSegments
|
||||
} from './remote-session-scanner-source-parsers'
|
||||
import type { FileWithMtime } from './session-scanner-types'
|
||||
import { normalizeAgentSessionsDir } from './session-scanner-values'
|
||||
import { remoteCodexIndexedTitleReader } from './remote-session-scanner-codex-index'
|
||||
import { remoteClineSource } from './remote-session-scanner-cline-source'
|
||||
import { remoteDevinSource } from './remote-session-scanner-devin-source'
|
||||
@@ -106,6 +115,16 @@ export function remoteSessionSources(
|
||||
remotePrimeAgentSessionsSegments(),
|
||||
primeAgentParser
|
||||
),
|
||||
jsonlSource(
|
||||
'muse',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
['.local', 'share', 'muse', 'sessions'],
|
||||
parseMuseRemoteContent,
|
||||
// Why: each session dir holds session.jsonl plus .log/.sqlite3 sidecars;
|
||||
// match only the transcript (same predicate as local discovery).
|
||||
(path) => remotePathSegments(path).at(-1) === 'session.jsonl'
|
||||
),
|
||||
jsonlSource(
|
||||
'droid',
|
||||
remoteHome,
|
||||
@@ -260,62 +279,3 @@ function parserOptions(context: RemoteScannerContext): RemoteParserOptions {
|
||||
executionHostPlatform: context.hostPlatform.os
|
||||
}
|
||||
}
|
||||
|
||||
function piParser(
|
||||
file: FileWithMtime,
|
||||
content: RemoteSessionContent,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseMessageGraphSessionContent('pi', file, content, platform, options, signal)
|
||||
}
|
||||
|
||||
function ompParser(
|
||||
file: FileWithMtime,
|
||||
content: RemoteSessionContent,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseMessageGraphSessionContent('omp', file, content, platform, options, signal)
|
||||
}
|
||||
|
||||
function primeAgentParser(
|
||||
file: FileWithMtime,
|
||||
content: RemoteSessionContent,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseMessageGraphSessionContent('prime-agent', file, content, platform, options, signal)
|
||||
}
|
||||
|
||||
function openClawParser(
|
||||
file: FileWithMtime,
|
||||
content: RemoteSessionContent,
|
||||
platform: NodeJS.Platform,
|
||||
options: RemoteParserOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseMessageGraphSessionContent('openclaw', file, content, platform, options, signal)
|
||||
}
|
||||
|
||||
function remotePathSegments(path: string): string[] {
|
||||
return path.replace(/\\/g, '/').split('/').filter(Boolean)
|
||||
}
|
||||
|
||||
function remotePiSessionsSegments(): string[] {
|
||||
return normalizeAgentSessionsDir('/.pi/agent/sessions', '.pi').split('/').filter(Boolean)
|
||||
}
|
||||
|
||||
function remoteOmpSessionsSegments(): string[] {
|
||||
return normalizeAgentSessionsDir('/.omp/agent/sessions', '.omp').split('/').filter(Boolean)
|
||||
}
|
||||
|
||||
// Why: remote roots are posix regardless of the client platform, so these stay literal
|
||||
// rather than round-tripping through a local-platform path join that would emit
|
||||
// backslashes on a Windows client and collapse into a single bogus segment.
|
||||
function remotePrimeAgentSessionsSegments(): string[] {
|
||||
return ['.prime', 'agent', 'sessions']
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { parseClineSessionFile } from './session-scanner-cline-parser'
|
||||
import { parseGrokSessionFile } from './session-scanner-grok-parser'
|
||||
import { parseMessageGraphSessionFile, parseRovoSessionFile } from './session-scanner-graph-parsers'
|
||||
import { parseKimiSessionFile } from './session-scanner-kimi-parser'
|
||||
import { parseMuseSessionFile } from './session-scanner-muse-parser'
|
||||
import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths'
|
||||
import {
|
||||
captureOpenCodeSqliteSessionViaWorker,
|
||||
@@ -138,5 +139,7 @@ export async function parseAgentSessionFile(
|
||||
return parseDevinSessionFile(candidate.file, platform, messages)
|
||||
case 'kimi':
|
||||
return parseKimiSessionFile(candidate.file, platform, messages)
|
||||
case 'muse':
|
||||
return parseMuseSessionFile(candidate.file, platform, messages)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { cursorChatMetaPath } from './session-scanner-cursor-chat-meta'
|
||||
import { devinSessionsDbDependencyPath } from './session-scanner-devin-db'
|
||||
import { resolveKimiSessionsDir } from './session-scanner-kimi-paths'
|
||||
import { resolveMuseSessionsDir } from './session-scanner-muse-paths'
|
||||
import { OMP_SESSION_ARTIFACT_DIR_PATTERN } from './session-scanner-omp-subagent-transcripts'
|
||||
import {
|
||||
claudeProjectsRootDirs,
|
||||
@@ -285,6 +286,20 @@ export const AI_VAULT_AGENT_SOURCES: AiVaultAgentSourceTable = {
|
||||
// only those (not the sibling agents/*/wire.jsonl transcripts).
|
||||
filePredicate: (filePath) =>
|
||||
basename(filePath) === 'state.json' && basename(dirname(filePath)).startsWith('session_')
|
||||
},
|
||||
muse: {
|
||||
rootDirs: (options, wslHomeDirs) =>
|
||||
sessionRootDirs(resolveMuseSessionsDir(options.museSessionsDir), wslHomeDirs, [
|
||||
'.local',
|
||||
'share',
|
||||
'muse',
|
||||
'sessions'
|
||||
]),
|
||||
extensions: ['.jsonl'],
|
||||
// Why: each Muse session is <root>/YYYY/MM/DD/<uuid>/session.jsonl;
|
||||
// match only those (not sibling .log/.sqlite3 sidecars or the .msp-view
|
||||
// materialized projection).
|
||||
filePredicate: (filePath) => basename(filePath) === 'session.jsonl'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -218,6 +218,7 @@ describe('scanAiVaultSessions Codex worker sessions', () => {
|
||||
droidSessionsDir: join(root, 'droid-sessions'),
|
||||
droidProjectsDir: join(root, 'droid-projects'),
|
||||
kimiSessionsDir: join(root, 'kimi-sessions'),
|
||||
museSessionsDir: join(root, 'muse-sessions'),
|
||||
platform: 'darwin'
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { isolatedScanRoots, writeOpenCode2SqliteFixture } from './session-scanner-test-fixtures'
|
||||
import {
|
||||
isolatedScanRoots,
|
||||
writeMuseScannerFixture,
|
||||
writeOpenCode2SqliteFixture
|
||||
} from './session-scanner-test-fixtures'
|
||||
import { writeDocumentAgentFixtures } from './session-scanner-document-agent-fixtures'
|
||||
import { writeLogAgentFixtures } from './session-scanner-log-agent-fixtures'
|
||||
|
||||
@@ -28,6 +32,7 @@ export async function writeEveryAgentVault(root: string): Promise<EveryAgentVaul
|
||||
const antigravitySessionId = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
|
||||
const { ompSessionFile, primeAgentSessionFile } = await writeLogAgentFixtures(roots)
|
||||
await writeDocumentAgentFixtures(root, roots, antigravitySessionId)
|
||||
await writeMuseScannerFixture(roots.museSessionsDir)
|
||||
roots.opencodeDbPaths = [await writeOpenCode2SqliteFixture(root)]
|
||||
return { roots, antigravitySessionId, ompSessionFile, primeAgentSessionFile }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import { unwrapMuseLogRecords } from '../../shared/muse-session-log'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import {
|
||||
remoteSessionContentLines,
|
||||
type RemoteSessionContent
|
||||
} from './remote-session-content-lines'
|
||||
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
|
||||
import type { TranscriptMessageSink } from './session-transcript-consumers'
|
||||
import {
|
||||
addPreviewContent,
|
||||
createAccumulator,
|
||||
finalizeSession,
|
||||
updateTimeline
|
||||
} from './session-scanner-accumulator'
|
||||
import { museSessionIdFromFilePath } from './session-scanner-muse-paths'
|
||||
import {
|
||||
arrayValue,
|
||||
asRecord,
|
||||
extractString,
|
||||
normalizeTitleText,
|
||||
numberValue,
|
||||
parseJsonObject,
|
||||
timestampMs
|
||||
} from './session-scanner-values'
|
||||
|
||||
type ParserSessionOptions = {
|
||||
executionHostId?: ExecutionHostId
|
||||
executionHostPlatform?: NodeJS.Platform | null
|
||||
}
|
||||
|
||||
type MuseRecord = {
|
||||
recordType: string | null
|
||||
payloadType: string | null
|
||||
recordedAtMs: number | null
|
||||
payload: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
// Why: `recorded_at` is microseconds since epoch; the shared timeline helpers
|
||||
// take milliseconds (or ISO strings), so convert here. Values below the
|
||||
// microsecond floor fall through to the shared parser (seconds/ISO).
|
||||
function museTimestampMs(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value >= 1e14) {
|
||||
return Math.floor(value / 1000)
|
||||
}
|
||||
const parsed = timestampMs(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
function unwrapMuseRecords(line: string): MuseRecord[] {
|
||||
const envelope = parseJsonObject(line)
|
||||
if (!envelope) {
|
||||
return []
|
||||
}
|
||||
// Why: retention markers (`retained_marker: omitted_live_only`) stand in for
|
||||
// ephemeral records excluded from the retained log — no payload to fold.
|
||||
return unwrapMuseLogRecords(envelope).map((record) => ({
|
||||
recordType: extractString(record.record_type),
|
||||
payloadType: extractString(record.payload_type),
|
||||
recordedAtMs: museTimestampMs(record.recorded_at),
|
||||
payload: asRecord(record.payload)
|
||||
}))
|
||||
}
|
||||
|
||||
function firstTextBlock(value: unknown): string | null {
|
||||
for (const block of arrayValue(value)) {
|
||||
const text = extractString(asRecord(block)?.text)
|
||||
if (text) {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Why: user intent arrives either as `refill_blocks` text blocks or nested
|
||||
// `model_messages[].content[]` blocks; both shapes carry the same prompt.
|
||||
function userIntentText(payload: Record<string, unknown>): string | null {
|
||||
return (
|
||||
firstTextBlock(payload.refill_blocks) ??
|
||||
(() => {
|
||||
for (const message of arrayValue(payload.model_messages)) {
|
||||
const text = firstTextBlock(asRecord(message)?.content)
|
||||
if (text) {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return null
|
||||
})()
|
||||
)
|
||||
}
|
||||
|
||||
function foldUserTurn(
|
||||
accumulator: SessionAccumulator,
|
||||
text: string | null,
|
||||
timestampMs: number | null,
|
||||
dedupe: { text: string | null; ms: number | null },
|
||||
// Why: each turn emits both `runtime.user_intent.accepted` and a `run ::
|
||||
// started` carrying the same prompt ~ms apart — folding both double-counts
|
||||
// turns and evicts real rows from the 5-message preview window. The intent
|
||||
// record always folds; `run.started` is the fallback for logs missing intent
|
||||
// records, so only it dedupes (a deliberately repeated prompt still counts).
|
||||
skipIfDuplicate: boolean
|
||||
): void {
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
skipIfDuplicate &&
|
||||
dedupe.text === text &&
|
||||
dedupe.ms !== null &&
|
||||
timestampMs !== null &&
|
||||
Math.abs(timestampMs - dedupe.ms) < 60_000
|
||||
) {
|
||||
return
|
||||
}
|
||||
dedupe.text = text
|
||||
dedupe.ms = timestampMs
|
||||
accumulator.messageCount++
|
||||
const titleCandidate = normalizeTitleText(text)
|
||||
if (titleCandidate) {
|
||||
accumulator.title ??= titleCandidate
|
||||
}
|
||||
addPreviewContent(accumulator, 'user', text, timestampMs ?? undefined)
|
||||
}
|
||||
|
||||
function foldMuseRecord(
|
||||
accumulator: SessionAccumulator,
|
||||
record: MuseRecord,
|
||||
dedupe: { text: string | null; ms: number | null }
|
||||
): void {
|
||||
if (record.recordedAtMs !== null) {
|
||||
updateTimeline(accumulator, record.recordedAtMs)
|
||||
}
|
||||
const payload = record.payload
|
||||
if (!payload) {
|
||||
return
|
||||
}
|
||||
switch (record.payloadType) {
|
||||
case 'runtime.session.metadata': {
|
||||
// Why: the representative cwd is the session's start directory; later
|
||||
// drift must not move history grouping or the resume `cd` prefix.
|
||||
accumulator.cwd ??= extractString(asRecord(payload.record)?.workspace_root)
|
||||
break
|
||||
}
|
||||
case 'runtime.session.route_facts': {
|
||||
// Newer Muse logs carry the execution cwd in route facts as well as
|
||||
// metadata; retain it as a fallback for partially written sessions.
|
||||
accumulator.cwd ??= extractString(asRecord(payload.record)?.cwd)
|
||||
break
|
||||
}
|
||||
case 'session.workspace_branch.observed': {
|
||||
const reference = asRecord(asRecord(payload.record)?.reference)
|
||||
accumulator.branch ??= extractString(reference?.name)
|
||||
break
|
||||
}
|
||||
case 'run.model.configured': {
|
||||
accumulator.model ??= extractString(asRecord(payload.record)?.model_id)
|
||||
break
|
||||
}
|
||||
case 'runtime.user_intent.accepted': {
|
||||
foldUserTurn(accumulator, userIntentText(payload), record.recordedAtMs, dedupe, false)
|
||||
break
|
||||
}
|
||||
case 'runtime.session': {
|
||||
foldSessionEvent(accumulator, payload, record.recordedAtMs, dedupe)
|
||||
break
|
||||
}
|
||||
case null:
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function foldSessionEvent(
|
||||
accumulator: SessionAccumulator,
|
||||
payload: Record<string, unknown>,
|
||||
timestampMs: number | null,
|
||||
dedupe: { text: string | null; ms: number | null }
|
||||
): void {
|
||||
const event = asRecord(payload.event)
|
||||
if (!event) {
|
||||
return
|
||||
}
|
||||
switch (event.kind) {
|
||||
case 'started': {
|
||||
foldUserTurn(accumulator, extractString(event.prompt), timestampMs, dedupe, true)
|
||||
break
|
||||
}
|
||||
case 'assistant_message_committed': {
|
||||
const text = extractString(event.text)
|
||||
if (text) {
|
||||
accumulator.messageCount++
|
||||
addPreviewContent(accumulator, 'assistant', text, timestampMs ?? undefined)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'model_completed': {
|
||||
const usage = asRecord(event.usage)
|
||||
accumulator.totalTokens +=
|
||||
numberValue(usage?.input_tokens) + numberValue(usage?.output_tokens)
|
||||
accumulator.model ??= extractString(event.model)
|
||||
break
|
||||
}
|
||||
case null:
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
type MuseDedupeState = { text: string | null; ms: number | null }
|
||||
|
||||
function foldMuseContent(accumulator: SessionAccumulator, content: string): void {
|
||||
foldMuseLines(accumulator, content.split('\n'))
|
||||
}
|
||||
|
||||
function foldMuseLines(
|
||||
accumulator: SessionAccumulator,
|
||||
lines: Iterable<string>,
|
||||
dedupe: MuseDedupeState = { text: null, ms: null }
|
||||
): void {
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) {
|
||||
continue
|
||||
}
|
||||
for (const record of unwrapMuseRecords(line)) {
|
||||
foldMuseRecord(accumulator, record, dedupe)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses remote transcript chunks without requiring the scanner to buffer the file. */
|
||||
export async function parseMuseSessionRemoteContent(
|
||||
file: FileWithMtime,
|
||||
content: RemoteSessionContent,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: ParserSessionOptions = {},
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'muse',
|
||||
file,
|
||||
sessionId: museSessionIdFromFilePath(file.path)
|
||||
})
|
||||
const lines = remoteSessionContentLines(content, signal)
|
||||
const dedupe: MuseDedupeState = { text: null, ms: null }
|
||||
for await (const line of lines) {
|
||||
foldMuseLines(accumulator, [line], dedupe)
|
||||
}
|
||||
return finalizeSession(accumulator, platform, options)
|
||||
}
|
||||
|
||||
export async function parseMuseSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
messages?: TranscriptMessageSink
|
||||
): Promise<AiVaultSession | null> {
|
||||
return parseMuseSessionContent(
|
||||
file,
|
||||
await wslGatedReadFile(file.path, 'utf-8', 'scan'),
|
||||
platform,
|
||||
{},
|
||||
messages
|
||||
)
|
||||
}
|
||||
|
||||
export function parseMuseSessionContent(
|
||||
file: FileWithMtime,
|
||||
content: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: ParserSessionOptions = {},
|
||||
messages?: TranscriptMessageSink
|
||||
): AiVaultSession | null {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'muse',
|
||||
file,
|
||||
sessionId: museSessionIdFromFilePath(file.path),
|
||||
messages
|
||||
})
|
||||
foldMuseContent(accumulator, content)
|
||||
return finalizeSession(accumulator, platform, options)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { basename, dirname } from 'node:path'
|
||||
|
||||
export { resolveMuseSessionsDir } from '../../shared/muse-session-log'
|
||||
|
||||
// Layout: <root>/YYYY/MM/DD/<uuid>/session.jsonl — the session id is the
|
||||
// parent directory name (the basename is always the fixed `session.jsonl`).
|
||||
export function museSessionIdFromFilePath(filePath: string): string {
|
||||
return basename(dirname(filePath))
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { scanAiVaultSessions } from './session-scanner'
|
||||
import { parseMuseSessionContent } from './session-scanner-muse-parser'
|
||||
import {
|
||||
isolatedScanRoots,
|
||||
jsonLines,
|
||||
writeMuseScannerFixture
|
||||
} from './session-scanner-test-fixtures'
|
||||
import type { TranscriptMessage } from './session-transcript-consumers'
|
||||
|
||||
let tempRoots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
|
||||
tempRoots = []
|
||||
})
|
||||
|
||||
describe('scanAiVaultSessions muse', () => {
|
||||
it('indexes Muse envelopes with title, model, tokens, and resume command', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-muse-'))
|
||||
tempRoots.push(root)
|
||||
const roots = isolatedScanRoots(root)
|
||||
const sessionFile = await writeMuseScannerFixture(roots.museSessionsDir)
|
||||
|
||||
const result = await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 })
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
const session = result.sessions[0]
|
||||
expect(session.agent).toBe('muse')
|
||||
expect(session.sessionId).toBe('muse-session')
|
||||
expect(session.title).toBe('Muse vault title')
|
||||
expect(session.cwd).toBe('/tmp/muse')
|
||||
expect(session.model).toBe('muse-spark-test')
|
||||
expect(session.totalTokens).toBe(15)
|
||||
expect(session.messageCount).toBe(2)
|
||||
expect(session.filePath).toBe(sessionFile)
|
||||
expect(session.resumeCommand).toBe("cd '/tmp/muse' && muse resume 'muse-session'")
|
||||
})
|
||||
|
||||
it('publishes user and assistant turns to transcript consumers', () => {
|
||||
const messages: TranscriptMessage[] = []
|
||||
const session = parseMuseSessionContent(
|
||||
{
|
||||
path: '/tmp/muse-sessions/2026/05/01/muse-capture/session.jsonl',
|
||||
mtimeMs: 1780000003000,
|
||||
modifiedAt: '2026-05-01T10:00:03.000Z'
|
||||
},
|
||||
jsonLines([
|
||||
{
|
||||
record_type: 'event',
|
||||
payload_type: 'runtime.user_intent.accepted',
|
||||
recorded_at: 1780000000000000,
|
||||
payload: { refill_blocks: [{ kind: 'text', text: 'Capture this prompt' }] }
|
||||
},
|
||||
{
|
||||
record_type: 'event',
|
||||
payload_type: 'runtime.session',
|
||||
recorded_at: 1780000001000000,
|
||||
payload: {
|
||||
kind: 'run',
|
||||
event: { kind: 'assistant_message_committed', text: 'Captured reply' }
|
||||
}
|
||||
}
|
||||
]),
|
||||
'darwin',
|
||||
{},
|
||||
{ active: true, push: (message) => messages.push(message) }
|
||||
)
|
||||
|
||||
expect(session?.messageCount).toBe(2)
|
||||
expect(messages).toEqual([
|
||||
{ role: 'user', text: 'Capture this prompt', timestamp: '2026-05-28T20:26:40.000Z' },
|
||||
{ role: 'assistant', text: 'Captured reply', timestamp: '2026-05-28T20:26:41.000Z' }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -53,6 +53,7 @@ function isolatedScanRoots(root: string) {
|
||||
droidSessionsDir: join(root, 'droid-sessions'),
|
||||
droidProjectsDir: join(root, 'droid-projects'),
|
||||
kimiSessionsDir: join(root, 'kimi-sessions'),
|
||||
museSessionsDir: join(root, 'muse-sessions'),
|
||||
ompSessionsDir: join(root, 'omp-sessions'),
|
||||
primeAgentSessionsDir: join(root, 'prime-agent-sessions')
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ function resumableStateFactoryFor(
|
||||
case 'hermes':
|
||||
case 'cline':
|
||||
case 'kimi':
|
||||
case 'muse':
|
||||
case 'opencode':
|
||||
case 'opencode2':
|
||||
case 'rovo':
|
||||
|
||||
@@ -75,7 +75,8 @@ export function isolatedScanRoots(root: string) {
|
||||
droidSessionsDir: join(root, 'droid-sessions'),
|
||||
droidProjectsDir: join(root, 'droid-projects'),
|
||||
clineSessionsDir: join(root, 'cline-sessions'),
|
||||
kimiSessionsDir: join(root, 'kimi-sessions')
|
||||
kimiSessionsDir: join(root, 'kimi-sessions'),
|
||||
museSessionsDir: join(root, 'muse-sessions')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,3 +191,76 @@ export function writeAntigravityScannerFixture(
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
// Muse sessions are date-sharded <root>/YYYY/MM/DD/<uuid>/session.jsonl
|
||||
// envelopes mixing bare records, retained_frame envelopes, and
|
||||
// omitted_live_only retention markers (verified against muse 1.0.3).
|
||||
export async function writeMuseScannerFixture(sessionsDir: string): Promise<string> {
|
||||
const sessionFile = join(sessionsDir, '2026', '05', '01', 'muse-session', 'session.jsonl')
|
||||
const bare = (payloadType: string, payload: unknown, recordedAt: number) => ({
|
||||
record_type: 'event',
|
||||
payload_type: payloadType,
|
||||
recorded_at: recordedAt,
|
||||
payload
|
||||
})
|
||||
await writeJsonlFile(sessionFile, [
|
||||
bare(
|
||||
'runtime.session.metadata',
|
||||
{ kind: 'metadata', record: { workspace_root: '/tmp/muse', provider_id: 'meta' } },
|
||||
1780000000000000
|
||||
),
|
||||
bare(
|
||||
'runtime.user_intent.accepted',
|
||||
{ intent_id: 'intent-1', refill_blocks: [{ kind: 'text', text: 'Muse vault title' }] },
|
||||
1780000001000000
|
||||
),
|
||||
// Why: every turn also emits `run :: started` carrying the same prompt —
|
||||
// the parser must fold it once (messageCount stays 2 below).
|
||||
bare(
|
||||
'runtime.session',
|
||||
{ kind: 'run', run_id: 'run-1', event: { kind: 'started', prompt: 'Muse vault title' } },
|
||||
1780000001000007
|
||||
),
|
||||
{
|
||||
retained_frame: true,
|
||||
frame_schema_version: 1,
|
||||
outer_log_ordinal: 3,
|
||||
transaction_id: 'txn-1',
|
||||
children: [
|
||||
{
|
||||
child_index: 0,
|
||||
record_json: JSON.stringify(
|
||||
bare(
|
||||
'runtime.session',
|
||||
{
|
||||
kind: 'run',
|
||||
run_id: 'run-1',
|
||||
event: { kind: 'assistant_message_committed', text: 'Muse answer' }
|
||||
},
|
||||
1780000002000000
|
||||
)
|
||||
)
|
||||
}
|
||||
]
|
||||
},
|
||||
bare(
|
||||
'runtime.session',
|
||||
{
|
||||
kind: 'run',
|
||||
run_id: 'run-1',
|
||||
event: {
|
||||
kind: 'model_completed',
|
||||
model: 'muse-spark-test',
|
||||
usage: { input_tokens: 10, output_tokens: 5 }
|
||||
}
|
||||
},
|
||||
1780000003000000
|
||||
),
|
||||
{
|
||||
retained_marker: 'omitted_live_only',
|
||||
schema_version: 1,
|
||||
stream: { kind: 'session', id: 'muse-session' }
|
||||
}
|
||||
])
|
||||
return sessionFile
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ export type AiVaultScanOptions = {
|
||||
droidProjectsDir?: string
|
||||
clineSessionsDir?: string
|
||||
kimiSessionsDir?: string
|
||||
museSessionsDir?: string
|
||||
limit?: number
|
||||
unlimited?: boolean
|
||||
limitPerAgent?: number
|
||||
|
||||
@@ -4,7 +4,11 @@ import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types'
|
||||
import { scanAiVaultSessions } from './session-scanner'
|
||||
import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures'
|
||||
import {
|
||||
isolatedScanRoots,
|
||||
jsonLines,
|
||||
writeMuseScannerFixture
|
||||
} from './session-scanner-test-fixtures'
|
||||
import { writeEveryAgentVault } from './session-scanner-every-agent-fixture'
|
||||
|
||||
// Why: the SQLite worker bundle does not exist in the test runtime; route the
|
||||
@@ -394,6 +398,7 @@ describe('scanAiVaultSessions', () => {
|
||||
tempRoots.push(root)
|
||||
const { roots, antigravitySessionId, ompSessionFile, primeAgentSessionFile } =
|
||||
await writeEveryAgentVault(root)
|
||||
await writeMuseScannerFixture(roots.museSessionsDir)
|
||||
|
||||
const result = await scanAiVaultSessions({ ...roots, platform: 'darwin', limit: 20 })
|
||||
|
||||
@@ -443,6 +448,7 @@ describe('scanAiVaultSessions', () => {
|
||||
expect(commandByAgent.get('cline')).toBe("cd '/tmp/cline' && cline --id 'cline-session'")
|
||||
expect(commandByAgent.get('devin')).toBe("cd '/tmp/devin' && devin --resume 'devin-session'")
|
||||
expect(commandByAgent.get('droid')).toBe("cd '/tmp/droid' && droid --resume 'droid-session'")
|
||||
expect(commandByAgent.get('muse')).toBe("cd '/tmp/muse' && muse resume 'muse-session'")
|
||||
expect(commandByAgent.get('kimi')).toBe(
|
||||
"cd '/tmp/kimi' && kimi --session 'session_kimi-session'"
|
||||
)
|
||||
|
||||
@@ -2,16 +2,24 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ParsedDaemonPid } from './daemon-pid-file-parse'
|
||||
import { validate } from '../telemetry/validator'
|
||||
|
||||
const { trackMock, opendirMock, existsSyncMock, readFileSyncMock, getVersionMock } = vi.hoisted(
|
||||
() => ({
|
||||
trackMock: vi.fn(),
|
||||
opendirMock: vi.fn(),
|
||||
existsSyncMock: vi.fn(() => true),
|
||||
readFileSyncMock: vi.fn(),
|
||||
getVersionMock: vi.fn(() => '1.4.191')
|
||||
})
|
||||
)
|
||||
const {
|
||||
trackMock,
|
||||
opendirMock,
|
||||
existsSyncMock,
|
||||
readFileSyncMock,
|
||||
getVersionMock,
|
||||
codeIdentityMock
|
||||
} = vi.hoisted(() => ({
|
||||
trackMock: vi.fn(),
|
||||
opendirMock: vi.fn(),
|
||||
existsSyncMock: vi.fn(() => true),
|
||||
readFileSyncMock: vi.fn(),
|
||||
getVersionMock: vi.fn(() => '1.4.191'),
|
||||
codeIdentityMock: vi.fn(async () => 'parked')
|
||||
}))
|
||||
vi.mock('../telemetry/client', () => ({ track: trackMock }))
|
||||
// Never spawn codesign from a unit test; the probe has its own suite.
|
||||
vi.mock('./daemon-mac-code-identity', () => ({ getDaemonMacCodeIdentity: codeIdentityMock }))
|
||||
vi.mock('node:fs', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
existsSync: existsSyncMock,
|
||||
@@ -35,7 +43,7 @@ import {
|
||||
hasDaemonPtyCwdDenialDiverged,
|
||||
reportDaemonPtyCwdVerdict,
|
||||
trackDaemonAdopted,
|
||||
trackDaemonPtyCwdDenied
|
||||
trackDaemonPtyCwdVerdict
|
||||
} from './daemon-adoption-telemetry-event'
|
||||
import {
|
||||
getDaemonFolderAccessMismatch,
|
||||
@@ -67,7 +75,11 @@ const stalePidRecord: ParsedDaemonPid = {
|
||||
'/Users/alice/Library/Caches/com.stablyai.orca.ShipIt/u/Orca.app/Contents/MacOS/Orca',
|
||||
cgroupUnit: null
|
||||
}
|
||||
const origin = { app_version_match: 'different', spawner_path_class: 'updater-cache' } as const
|
||||
const origin = {
|
||||
app_version_match: 'different',
|
||||
code_identity: 'parked',
|
||||
spawner_path_class: 'updater-cache'
|
||||
} as const
|
||||
const PID_PATH = '/fake/daemon.pid'
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -76,6 +88,7 @@ beforeEach(() => {
|
||||
opendirMock.mockReset().mockReturnValue(readableDir())
|
||||
existsSyncMock.mockReset().mockReturnValue(true)
|
||||
readFileSyncMock.mockReset().mockReturnValue(JSON.stringify(stalePidRecord))
|
||||
codeIdentityMock.mockClear()
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
})
|
||||
|
||||
@@ -84,22 +97,24 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('classifyDaemonAdoptionOrigin', () => {
|
||||
it('compares the recorded app version and classifies the spawner path', () => {
|
||||
expect(classifyDaemonAdoptionOrigin(stalePidRecord)).toEqual(origin)
|
||||
expect(classifyDaemonAdoptionOrigin({ ...stalePidRecord, appVersion: '1.4.191' })).toEqual({
|
||||
app_version_match: 'same',
|
||||
spawner_path_class: 'updater-cache'
|
||||
})
|
||||
expect(classifyDaemonAdoptionOrigin(null)).toEqual({
|
||||
it('compares the recorded app version, the spawner path, and the daemon pid code identity', async () => {
|
||||
expect(await classifyDaemonAdoptionOrigin(stalePidRecord)).toEqual(origin)
|
||||
expect(codeIdentityMock).toHaveBeenCalledWith(stalePidRecord.pid)
|
||||
expect(
|
||||
await classifyDaemonAdoptionOrigin({ ...stalePidRecord, appVersion: '1.4.191' })
|
||||
).toEqual({ ...origin, app_version_match: 'same' })
|
||||
expect(await classifyDaemonAdoptionOrigin(null)).toEqual({
|
||||
app_version_match: 'unknown',
|
||||
code_identity: 'parked',
|
||||
spawner_path_class: 'unknown'
|
||||
})
|
||||
expect(codeIdentityMock).toHaveBeenLastCalledWith(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('trackDaemonAdopted', () => {
|
||||
it('emits a validator-accepted payload', () => {
|
||||
trackDaemonAdopted(stalePidRecord, 'intact', 7)
|
||||
it('emits a validator-accepted payload', async () => {
|
||||
await trackDaemonAdopted(stalePidRecord, 'intact', 7)
|
||||
expect(trackMock).toHaveBeenCalledTimes(1)
|
||||
const [name, props] = trackMock.mock.calls[0]
|
||||
expect(name).toBe('daemon_adopted')
|
||||
@@ -111,11 +126,11 @@ describe('trackDaemonAdopted', () => {
|
||||
expect(validate('daemon_adopted', props).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('swallows a throwing telemetry client', () => {
|
||||
it('swallows a throwing telemetry client', async () => {
|
||||
trackMock.mockImplementationOnce(() => {
|
||||
throw new Error('posthog exploded')
|
||||
})
|
||||
expect(() => trackDaemonAdopted(null, 'unknown', null)).not.toThrow()
|
||||
await expect(trackDaemonAdopted(null, 'unknown', null)).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -151,17 +166,20 @@ describe('hasDaemonPtyCwdDenialDiverged', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('trackDaemonPtyCwdDenied', () => {
|
||||
it('emits a validator-accepted payload', () => {
|
||||
trackDaemonPtyCwdDenied(DENIED_CWD, PID_PATH)
|
||||
expect(trackMock).toHaveBeenCalledTimes(1)
|
||||
const [name, props] = trackMock.mock.calls[0]
|
||||
expect(name).toBe('daemon_pty_cwd_denied')
|
||||
expect(props).toEqual({ cwd_class: 'documents', ...origin })
|
||||
expect(validate('daemon_pty_cwd_denied', props).ok).toBe(true)
|
||||
})
|
||||
describe('trackDaemonPtyCwdVerdict', () => {
|
||||
it.each(['daemon_pty_cwd_denied', 'daemon_pty_cwd_readable'] as const)(
|
||||
'emits a validator-accepted %s payload',
|
||||
async (event) => {
|
||||
await trackDaemonPtyCwdVerdict(event, DENIED_CWD, PID_PATH)
|
||||
expect(trackMock).toHaveBeenCalledTimes(1)
|
||||
const [name, props] = trackMock.mock.calls[0]
|
||||
expect(name).toBe(event)
|
||||
expect(props).toEqual({ cwd_class: 'documents', ...origin })
|
||||
expect(validate(event, props).ok).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it('attributes the denial to the daemon recorded right now, not a startup snapshot', () => {
|
||||
it('attributes the denial to the daemon recorded right now, not a startup snapshot', async () => {
|
||||
readFileSyncMock.mockReturnValue(
|
||||
JSON.stringify({
|
||||
...stalePidRecord,
|
||||
@@ -169,32 +187,66 @@ describe('trackDaemonPtyCwdDenied', () => {
|
||||
spawnerExecPath: '/Applications/Orca.app/Contents/MacOS/Orca'
|
||||
})
|
||||
)
|
||||
trackDaemonPtyCwdDenied(DENIED_CWD, PID_PATH)
|
||||
await trackDaemonPtyCwdVerdict('daemon_pty_cwd_denied', DENIED_CWD, PID_PATH)
|
||||
expect(readFileSyncMock).toHaveBeenCalledWith(PID_PATH, 'utf8')
|
||||
expect(trackMock.mock.calls[0][1]).toEqual({
|
||||
cwd_class: 'documents',
|
||||
app_version_match: 'same',
|
||||
code_identity: 'parked',
|
||||
spawner_path_class: 'applications'
|
||||
})
|
||||
})
|
||||
|
||||
it('swallows a throwing app environment or pid-record read instead of failing the spawn', () => {
|
||||
it('swallows a throwing app environment or pid-record read instead of failing the spawn', async () => {
|
||||
getVersionMock.mockImplementationOnce(() => {
|
||||
throw new Error('AppEnvironment not initialized')
|
||||
})
|
||||
expect(() => trackDaemonPtyCwdDenied(DENIED_CWD, PID_PATH)).not.toThrow()
|
||||
await expect(
|
||||
trackDaemonPtyCwdVerdict('daemon_pty_cwd_denied', DENIED_CWD, PID_PATH)
|
||||
).resolves.toBeUndefined()
|
||||
expect(trackMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('swallows a throwing telemetry client', () => {
|
||||
it('swallows a throwing telemetry client', async () => {
|
||||
trackMock.mockImplementationOnce(() => {
|
||||
throw new Error('posthog exploded')
|
||||
})
|
||||
expect(() => trackDaemonPtyCwdDenied(DENIED_CWD, PID_PATH)).not.toThrow()
|
||||
await expect(
|
||||
trackDaemonPtyCwdVerdict('daemon_pty_cwd_denied', DENIED_CWD, PID_PATH)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('reportDaemonPtyCwdVerdict', () => {
|
||||
it('reports a readable TCC-gated cwd as the control, without an app-side read', async () => {
|
||||
await reportDaemonPtyCwdVerdict({
|
||||
cwd: DENIED_CWD,
|
||||
cwdReadableByDaemon: true,
|
||||
pidPath: PID_PATH,
|
||||
daemonIdentity: DAEMON
|
||||
})
|
||||
|
||||
expect(opendirMock).not.toHaveBeenCalled()
|
||||
expect(trackMock.mock.calls.map(([name]) => name)).toEqual(['daemon_pty_cwd_readable'])
|
||||
})
|
||||
|
||||
it('reports every readable spawn, but only in a TCC-gated folder on macOS', async () => {
|
||||
const readable = (cwd: string) =>
|
||||
reportDaemonPtyCwdVerdict({
|
||||
cwd,
|
||||
cwdReadableByDaemon: true,
|
||||
pidPath: PID_PATH,
|
||||
daemonIdentity: DAEMON
|
||||
})
|
||||
await readable(DENIED_CWD)
|
||||
await readable(DENIED_CWD)
|
||||
await readable('/Users/alice/code/repo')
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('linux')
|
||||
await readable(DENIED_CWD)
|
||||
|
||||
expect(trackMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('emits the event and records the notice evidence on one directory read', async () => {
|
||||
await reportDaemonPtyCwdVerdict({
|
||||
cwd: DENIED_CWD,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// App-side emitters for `daemon_adopted` and `daemon_pty_cwd_denied` (#17696). Both sit on the
|
||||
// daemon launch / PTY spawn path, so every failure dies here — telemetry can never cost a terminal.
|
||||
// App-side emitters for `daemon_adopted`, `daemon_pty_cwd_denied`, and `daemon_pty_cwd_readable`
|
||||
// (#17696). All sit on the daemon launch / PTY spawn path, so every failure dies here — telemetry
|
||||
// can never cost a terminal.
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
@@ -7,6 +8,7 @@ import { getAppEnvironment } from '../../shared/app-environment'
|
||||
import {
|
||||
classifyDaemonPtyCwd,
|
||||
classifyDaemonSpawnerPath,
|
||||
isMacTccFolderClass,
|
||||
type DaemonAdoptedAppVersionMatch,
|
||||
type DaemonSpawnerPathClass
|
||||
} from '../../shared/daemon-adoption-telemetry'
|
||||
@@ -14,6 +16,7 @@ import { bucketDaemonLiveSessionCount } from '../../shared/daemon-lifecycle-tele
|
||||
import type { EventProps } from '../../shared/telemetry-events'
|
||||
import { track } from '../telemetry/client'
|
||||
import { readDaemonPidRecord } from './daemon-endpoint-incarnation'
|
||||
import { getDaemonMacCodeIdentity } from './daemon-mac-code-identity'
|
||||
import { enumerateDirectoryOnce } from './directory-enumeration-probe'
|
||||
import type { ParsedDaemonPid } from './daemon-pid-file-parse'
|
||||
import type { MacDaemonTccAttributionHealth } from './daemon-tcc-attribution'
|
||||
@@ -25,13 +28,13 @@ import {
|
||||
|
||||
export type DaemonAdoptionOrigin = Pick<
|
||||
EventProps<'daemon_pty_cwd_denied'>,
|
||||
'app_version_match' | 'spawner_path_class'
|
||||
'app_version_match' | 'code_identity' | 'spawner_path_class'
|
||||
>
|
||||
|
||||
/** Classifies the adopted daemon's pid record against the running app; enum-only by construction. */
|
||||
export function classifyDaemonAdoptionOrigin(
|
||||
export async function classifyDaemonAdoptionOrigin(
|
||||
pidRecord: ParsedDaemonPid | null
|
||||
): DaemonAdoptionOrigin {
|
||||
): Promise<DaemonAdoptionOrigin> {
|
||||
const appVersionMatch: DaemonAdoptedAppVersionMatch = !pidRecord?.appVersion
|
||||
? 'unknown'
|
||||
: pidRecord.appVersion === getAppEnvironment().getVersion()
|
||||
@@ -41,18 +44,22 @@ export function classifyDaemonAdoptionOrigin(
|
||||
pidRecord?.spawnerExecPath ?? null,
|
||||
existsSync
|
||||
)
|
||||
return { app_version_match: appVersionMatch, spawner_path_class: spawnerPathClass }
|
||||
return {
|
||||
app_version_match: appVersionMatch,
|
||||
code_identity: await getDaemonMacCodeIdentity(pidRecord?.pid),
|
||||
spawner_path_class: spawnerPathClass
|
||||
}
|
||||
}
|
||||
|
||||
// Adopted a daemon that a previous app launch forked (macOS only; that is where attribution matters).
|
||||
export function trackDaemonAdopted(
|
||||
export async function trackDaemonAdopted(
|
||||
pidRecord: ParsedDaemonPid | null,
|
||||
tccAttribution: MacDaemonTccAttributionHealth,
|
||||
liveSessionCount: number | null
|
||||
): void {
|
||||
): Promise<void> {
|
||||
try {
|
||||
track('daemon_adopted', {
|
||||
...classifyDaemonAdoptionOrigin(pidRecord),
|
||||
...(await classifyDaemonAdoptionOrigin(pidRecord)),
|
||||
tcc_attribution: tccAttribution,
|
||||
live_session_count_bucket: bucketDaemonLiveSessionCount(liveSessionCount)
|
||||
})
|
||||
@@ -80,14 +87,18 @@ export async function hasDaemonPtyCwdDenialDiverged(
|
||||
}
|
||||
}
|
||||
|
||||
/** Emits `daemon_pty_cwd_denied` for a cwd `hasDaemonPtyCwdDenialDiverged` already proved diverged. */
|
||||
export function trackDaemonPtyCwdDenied(cwd: string, pidPath: string | null): void {
|
||||
/** Emits a spawn's cwd verdict; `readable` is the control that gives `code_identity` a false-positive rate. */
|
||||
export async function trackDaemonPtyCwdVerdict(
|
||||
event: 'daemon_pty_cwd_denied' | 'daemon_pty_cwd_readable',
|
||||
cwd: string,
|
||||
pidPath: string | null
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Why read now, not the adapter's startup snapshot: a respawn swaps the daemon under a
|
||||
// long-lived adapter, and the denial must be attributed to the daemon that just spawned.
|
||||
track('daemon_pty_cwd_denied', {
|
||||
// long-lived adapter, and the verdict must be attributed to the daemon that just spawned.
|
||||
track(event, {
|
||||
cwd_class: classifyDaemonPtyCwd(cwd, homedir()),
|
||||
...classifyDaemonAdoptionOrigin(readDaemonPidRecord(pidPath))
|
||||
...(await classifyDaemonAdoptionOrigin(readDaemonPidRecord(pidPath)))
|
||||
})
|
||||
} catch {
|
||||
// Telemetry is best-effort; a dropped event must not reach the caller.
|
||||
@@ -115,13 +126,21 @@ export async function reportDaemonPtyCwdVerdict(args: {
|
||||
}
|
||||
if (args.cwdReadableByDaemon === true) {
|
||||
clearDaemonFolderAccessMismatch(args.daemonIdentity, cwd)
|
||||
// TCC-gated folders only: elsewhere a readable cwd says nothing about the theory.
|
||||
if (
|
||||
process.platform === 'darwin' &&
|
||||
isMacTccFolderClass(classifyDaemonPtyCwd(cwd, homedir()))
|
||||
) {
|
||||
await trackDaemonPtyCwdVerdict('daemon_pty_cwd_readable', cwd, args.pidPath)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!(await hasDaemonPtyCwdDenialDiverged(cwd, args.cwdReadableByDaemon))) {
|
||||
return
|
||||
}
|
||||
trackDaemonPtyCwdDenied(cwd, args.pidPath)
|
||||
// Notice first: the event now waits on a codesign probe, and the user-facing notice must not.
|
||||
recordDaemonFolderAccessMismatch(args.daemonIdentity, cwd)
|
||||
await trackDaemonPtyCwdVerdict('daemon_pty_cwd_denied', cwd, args.pidPath)
|
||||
} catch {
|
||||
// Best-effort evidence; a spawn must not fail because the notice could not be recorded.
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { runProcessMock } = vi.hoisted(() => ({ runProcessMock: vi.fn() }))
|
||||
vi.mock('../../shared/child-process/run-process', () => ({ runProcess: runProcessMock }))
|
||||
|
||||
import { classifyCodesignDisplayOutput, getDaemonMacCodeIdentity } from './daemon-mac-code-identity'
|
||||
|
||||
const HELPER_PATH =
|
||||
'/Applications/Orca.app/Contents/Frameworks/Orca Helper.app/Contents/MacOS/Orca Helper'
|
||||
const PARKED_PATH =
|
||||
'/private/var/folders/x/T/com.stablyai.orca.ShipIt.abc/Orca.app/Contents/MacOS/Orca'
|
||||
|
||||
function codesignReturns(stderr: string, code: number | null, timedOut = false): void {
|
||||
runProcessMock.mockResolvedValue({ code, stdout: '', stderr, timedOut })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
runProcessMock.mockReset()
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('classifyCodesignDisplayOutput', () => {
|
||||
it('resolves the executable path codesign reports for a live process', () => {
|
||||
expect(
|
||||
classifyCodesignDisplayOutput(
|
||||
`Executable=${HELPER_PATH}\nIdentifier=com.stablyai.orca.helper\nFormat=pid diskrep\n`,
|
||||
0
|
||||
)
|
||||
).toBe('resolved')
|
||||
})
|
||||
|
||||
it('separates the Squirrel staging copy from the installed bundle', () => {
|
||||
expect(classifyCodesignDisplayOutput(`Executable=${PARKED_PATH}\n`, 0)).toBe('parked')
|
||||
expect(
|
||||
classifyCodesignDisplayOutput(
|
||||
'Executable=/Users/a/Library/Caches/com.stablyai.orca.ShipIt/u/Orca.app/Contents/MacOS/Orca\n',
|
||||
0
|
||||
)
|
||||
).toBe('parked')
|
||||
})
|
||||
|
||||
it('treats the unlinked-executable diagnostic as unresolvable', () => {
|
||||
expect(classifyCodesignDisplayOutput('+3337: No such file or directory\n', 1)).toBe(
|
||||
'unresolvable'
|
||||
)
|
||||
})
|
||||
|
||||
it('fails open on a dead pid, an exiting pid, unsigned code, or an unexpected failure', () => {
|
||||
expect(classifyCodesignDisplayOutput('+999999: No such process\n', 1)).toBe('probe-failed')
|
||||
// errSecCSNoSuchCode: proc_pidpath resolved, the pid is just on its way out.
|
||||
expect(
|
||||
classifyCodesignDisplayOutput('+3337: host has no guest with the requested attributes\n', 1)
|
||||
).toBe('probe-failed')
|
||||
expect(classifyCodesignDisplayOutput('/opt/tool: code object is not signed at all\n', 1)).toBe(
|
||||
'probe-failed'
|
||||
)
|
||||
expect(classifyCodesignDisplayOutput('', null)).toBe('probe-failed')
|
||||
})
|
||||
|
||||
it('does not read an unlinked diagnostic out of a successful display', () => {
|
||||
expect(
|
||||
classifyCodesignDisplayOutput(`Executable=${HELPER_PATH}\nNo such file or directory\n`, 0)
|
||||
).toBe('resolved')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDaemonMacCodeIdentity', () => {
|
||||
it('asks codesign to display the running pid and reads its stderr', async () => {
|
||||
codesignReturns(`Executable=${HELPER_PATH}\n`, 0)
|
||||
await expect(getDaemonMacCodeIdentity(3337)).resolves.toBe('resolved')
|
||||
expect(runProcessMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
program: '/usr/bin/codesign',
|
||||
args: ['--display', '--verbose=1', '+3337']
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('reports unresolvable when codesign cannot map the pid to on-disk code', async () => {
|
||||
codesignReturns('+3337: No such file or directory\n', 1)
|
||||
await expect(getDaemonMacCodeIdentity(3337)).resolves.toBe('unresolvable')
|
||||
})
|
||||
|
||||
it('reprobes on every ask rather than reporting an earlier verdict', async () => {
|
||||
codesignReturns(`Executable=${HELPER_PATH}\n`, 0)
|
||||
await getDaemonMacCodeIdentity(3337)
|
||||
await getDaemonMacCodeIdentity(3337)
|
||||
expect(runProcessMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
codesignReturns('+3337: No such file or directory\n', 1)
|
||||
await expect(getDaemonMacCodeIdentity(3337)).resolves.toBe('unresolvable')
|
||||
})
|
||||
|
||||
it('discards a timed-out probe even when it printed a path first', async () => {
|
||||
codesignReturns('Executable=/x\n', null, true)
|
||||
await expect(getDaemonMacCodeIdentity(3337)).resolves.toBe('probe-failed')
|
||||
})
|
||||
|
||||
it('coalesces concurrent asks about one pid into a single probe', async () => {
|
||||
codesignReturns(`Executable=${HELPER_PATH}\n`, 0)
|
||||
await expect(
|
||||
Promise.all([getDaemonMacCodeIdentity(3337), getDaemonMacCodeIdentity(3337)])
|
||||
).resolves.toEqual(['resolved', 'resolved'])
|
||||
expect(runProcessMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('fails open when codesign cannot be spawned, off macOS, or without a pid', async () => {
|
||||
runProcessMock.mockRejectedValue(new Error('spawn ENOENT'))
|
||||
await expect(getDaemonMacCodeIdentity(3337)).resolves.toBe('probe-failed')
|
||||
|
||||
runProcessMock.mockClear()
|
||||
await expect(getDaemonMacCodeIdentity(0)).resolves.toBe('probe-failed')
|
||||
await expect(getDaemonMacCodeIdentity(null)).resolves.toBe('probe-failed')
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('linux')
|
||||
await expect(getDaemonMacCodeIdentity(3337)).resolves.toBe('probe-failed')
|
||||
expect(runProcessMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
// Adapted from David Bebawy's PR #21826: `codesign --display +<pid>` is the probe that answers
|
||||
// where a running pid's executable lives now. Measurement only; nothing reads the verdict.
|
||||
|
||||
import { runProcess } from '../../shared/child-process/run-process'
|
||||
import type { DaemonCodeIdentity } from '../../shared/daemon-adoption-telemetry'
|
||||
|
||||
const CODESIGN_TIMEOUT_MS = 3_000
|
||||
|
||||
// An unlinked executable prints no `Executable=` and exits 1 with this (Darwin 25.5). The exiting-
|
||||
// pid error ('host has no guest') means the path did resolve, so it must not match.
|
||||
const UNLINKED_EXECUTABLE_PATTERN = /No such file or directory/
|
||||
// Squirrel parks the outgoing bundle under a `…ShipIt…` directory in $TMPDIR or ~/Library/Caches.
|
||||
const PARKED_BUNDLE_PATTERN = /\/[^/]*ShipIt[^/]*\//
|
||||
|
||||
export function classifyCodesignDisplayOutput(
|
||||
output: string,
|
||||
code: number | null
|
||||
): DaemonCodeIdentity {
|
||||
for (const line of output.split(/\r?\n/)) {
|
||||
if (line.startsWith('Executable=')) {
|
||||
const executablePath = line.slice('Executable='.length).trim()
|
||||
if (executablePath.length > 0) {
|
||||
return PARKED_BUNDLE_PATTERN.test(executablePath) ? 'parked' : 'resolved'
|
||||
}
|
||||
}
|
||||
}
|
||||
return code !== 0 && UNLINKED_EXECUTABLE_PATTERN.test(output) ? 'unresolvable' : 'probe-failed'
|
||||
}
|
||||
|
||||
async function probe(pid: number): Promise<DaemonCodeIdentity> {
|
||||
try {
|
||||
const result = await runProcess({
|
||||
program: '/usr/bin/codesign',
|
||||
args: ['--display', '--verbose=1', `+${pid}`],
|
||||
timeoutMs: CODESIGN_TIMEOUT_MS,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
// A killed codesign can still have printed a path; that half-written display proves nothing.
|
||||
if (result.timedOut) {
|
||||
return 'probe-failed'
|
||||
}
|
||||
// codesign writes both the display fields and its diagnostics to stderr.
|
||||
return classifyCodesignDisplayOutput(`${result.stderr}\n${result.stdout}`, result.code)
|
||||
} catch {
|
||||
return 'probe-failed'
|
||||
}
|
||||
}
|
||||
|
||||
// Concurrent asks about one pid (a burst of spawns) share a probe; nothing outlives it.
|
||||
let inFlight: { pid: number; pending: Promise<DaemonCodeIdentity> } | null = null
|
||||
|
||||
/** Read fresh on every ask: a parked bundle can be deleted mid-run, flipping `parked` to `unresolvable`. */
|
||||
export function getDaemonMacCodeIdentity(
|
||||
pid: number | null | undefined
|
||||
): Promise<DaemonCodeIdentity> {
|
||||
if (process.platform !== 'darwin' || !pid || !Number.isSafeInteger(pid) || pid <= 0) {
|
||||
return Promise.resolve('probe-failed')
|
||||
}
|
||||
if (inFlight?.pid !== pid) {
|
||||
const entry = { pid, pending: probe(pid) }
|
||||
inFlight = entry
|
||||
void entry.pending.then(() => {
|
||||
if (inFlight === entry) {
|
||||
inFlight = null
|
||||
}
|
||||
})
|
||||
}
|
||||
return inFlight.pending
|
||||
}
|
||||
@@ -180,7 +180,7 @@ async function reportDaemonAdoption(
|
||||
() => null
|
||||
)
|
||||
])
|
||||
trackDaemonAdopted(
|
||||
await trackDaemonAdopted(
|
||||
readDaemonPidRecord(getDaemonPidPath(runtimeDir)),
|
||||
tccAttribution,
|
||||
liveSessionCount
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { handlers, store, resetFilesystemIpcMocks } from './filesystem-test-harness'
|
||||
|
||||
const { listQuickOpenFilesMock } = vi.hoisted(() => ({ listQuickOpenFilesMock: vi.fn() }))
|
||||
|
||||
vi.mock('electron', async () => (await import('./filesystem-test-harness')).electronMock)
|
||||
vi.mock('fs/promises', async () => (await import('./filesystem-test-harness')).fsPromisesMock)
|
||||
vi.mock(
|
||||
'../wsl-unc-delete',
|
||||
async () => (await import('./filesystem-test-harness')).wslUncDeleteMock
|
||||
)
|
||||
vi.mock(
|
||||
'../crash-reporting/crash-breadcrumb-store',
|
||||
async () => (await import('./filesystem-test-harness')).crashBreadcrumbMock
|
||||
)
|
||||
vi.mock(
|
||||
'../local-downloaded-folder-promotion',
|
||||
async () => (await import('./filesystem-test-harness')).folderPromotionMock
|
||||
)
|
||||
vi.mock(
|
||||
'../git/status',
|
||||
async () => (await import('./filesystem-test-harness')).gitStatusModuleMock
|
||||
)
|
||||
vi.mock(
|
||||
'../git/check-ignored-paths',
|
||||
async () => (await import('./filesystem-test-harness')).gitIgnoredPathsMock
|
||||
)
|
||||
vi.mock('../git/worktree', async () => (await import('./filesystem-test-harness')).gitWorktreeMock)
|
||||
vi.mock(
|
||||
'../providers/ssh-filesystem-dispatch',
|
||||
async () => (await import('./filesystem-test-harness')).sshFilesystemDispatchMock
|
||||
)
|
||||
vi.mock(
|
||||
'../providers/ssh-git-dispatch',
|
||||
async () => (await import('./filesystem-test-harness')).sshGitDispatchMock
|
||||
)
|
||||
vi.mock(
|
||||
'../text-generation/commit-message-text-generation',
|
||||
async () => (await import('./filesystem-test-harness')).textGenerationModuleMock
|
||||
)
|
||||
vi.mock(
|
||||
'../text-generation/pull-request-context',
|
||||
async () => (await import('./filesystem-test-harness')).pullRequestContextMock
|
||||
)
|
||||
vi.mock(
|
||||
'../source-control/pull-request-template',
|
||||
async () => (await import('./filesystem-test-harness')).pullRequestTemplateMock
|
||||
)
|
||||
vi.mock(
|
||||
'../source-control/pull-request-linked-issue',
|
||||
async () => (await import('./filesystem-test-harness')).pullRequestLinkedIssueMock
|
||||
)
|
||||
vi.mock('./filesystem-list-files', () => ({ listQuickOpenFiles: listQuickOpenFilesMock }))
|
||||
|
||||
import { registerFilesystemHandlers } from './filesystem'
|
||||
|
||||
function registerHandlers(): void {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: handlers under test only read repos and settings from the harness store.
|
||||
registerFilesystemHandlers(store as never)
|
||||
}
|
||||
|
||||
describe('fs:listFiles local name filter', () => {
|
||||
beforeEach(() => {
|
||||
resetFilesystemIpcMocks()
|
||||
listQuickOpenFilesMock.mockReset().mockResolvedValue([])
|
||||
})
|
||||
|
||||
it('filters the local scan with the Explorer word rule before the cap', async () => {
|
||||
registerHandlers()
|
||||
|
||||
await handlers.get('fs:listFiles')!(null, {
|
||||
rootPath: '/repo',
|
||||
maxResults: 20_001,
|
||||
nameFilter: ' App Delegate '
|
||||
})
|
||||
|
||||
const [rootPath, , , , maxResults, , pathFilter] = listQuickOpenFilesMock.mock.calls[0]
|
||||
expect([rootPath, maxResults]).toEqual(['/repo', 20_001])
|
||||
expect(pathFilter('ios/Notion Web Clipper/AppDelegate.swift')).toBe(true)
|
||||
expect(pathFilter('ios/App.swift')).toBe(false)
|
||||
})
|
||||
|
||||
it('lists unfiltered when the name filter is blank', async () => {
|
||||
registerHandlers()
|
||||
|
||||
await handlers.get('fs:listFiles')!(null, { rootPath: '/repo', nameFilter: ' ' })
|
||||
|
||||
expect(listQuickOpenFilesMock.mock.calls[0][6]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('refuses oversized name filters at the IPC boundary', async () => {
|
||||
registerHandlers()
|
||||
|
||||
await expect(
|
||||
handlers.get('fs:listFiles')!(null, { rootPath: '/repo', nameFilter: 'x'.repeat(4096) })
|
||||
).resolves.toEqual([])
|
||||
expect(listQuickOpenFilesMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,172 @@
|
||||
import { execFile as execFileCallback, spawn, type SpawnOptions } from 'node:child_process'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Store } from '../persistence'
|
||||
import type * as GitRunner from '../git/runner'
|
||||
import type * as GitFallback from './filesystem-list-files-git-fallback'
|
||||
import {
|
||||
pathMatchesFileNameFilterTokens,
|
||||
splitFileNameFilterTokens
|
||||
} from '../../shared/file-name-filter-tokens'
|
||||
|
||||
const { wslAwareSpawnMock, listFilesWithGitSpy } = vi.hoisted(() => ({
|
||||
wslAwareSpawnMock: vi.fn(),
|
||||
listFilesWithGitSpy: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../git/runner', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof GitRunner>()),
|
||||
wslAwareSpawn: wslAwareSpawnMock
|
||||
}))
|
||||
|
||||
vi.mock('./filesystem-list-files-git-fallback', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof GitFallback>()
|
||||
listFilesWithGitSpy.mockImplementation(actual.listFilesWithGit)
|
||||
return { ...actual, listFilesWithGit: listFilesWithGitSpy }
|
||||
})
|
||||
|
||||
import { listQuickOpenFiles } from './filesystem-list-files'
|
||||
|
||||
const execFile = promisify(execFileCallback)
|
||||
|
||||
function makeStore(repoPath: string): Store {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: listing only reads registered repos and settings.
|
||||
return {
|
||||
getRepos: () => [
|
||||
{ id: 'repo-1', path: repoPath, displayName: 'repo', badgeColor: '#000', addedAt: 0 }
|
||||
],
|
||||
getSettings: () => ({})
|
||||
} as unknown as Store
|
||||
}
|
||||
|
||||
function nameFilter(query: string): (relativePath: string) => boolean {
|
||||
const tokens = splitFileNameFilterTokens(query)
|
||||
return (relativePath) => pathMatchesFileNameFilterTokens(relativePath, tokens)
|
||||
}
|
||||
|
||||
function spawnMissingRipgrep(): void {
|
||||
wslAwareSpawnMock.mockImplementation(
|
||||
(_command: string, _args: string[], options: SpawnOptions & { cwd?: string }) =>
|
||||
spawn('orca-definitely-missing-rg', [], { cwd: options.cwd, stdio: options.stdio })
|
||||
)
|
||||
}
|
||||
|
||||
function fakeRipgrep(output: string, killSignal: NodeJS.Signals | null = null): EventEmitter {
|
||||
const child = new EventEmitter()
|
||||
const stdout = Object.assign(new EventEmitter(), { setEncoding: vi.fn() })
|
||||
Object.assign(child, {
|
||||
stdout,
|
||||
stderr: new EventEmitter(),
|
||||
kill: vi.fn(),
|
||||
exitCode: null,
|
||||
signalCode: null,
|
||||
pid: 1
|
||||
})
|
||||
setTimeout(() => {
|
||||
stdout.emit('data', output)
|
||||
child.emit('close', killSignal ? null : 0, killSignal)
|
||||
}, 0)
|
||||
return child
|
||||
}
|
||||
|
||||
describe('listQuickOpenFiles name filter', () => {
|
||||
let tempDir: string | null = null
|
||||
|
||||
afterEach(async () => {
|
||||
if (tempDir) {
|
||||
await rm(tempDir, { recursive: true, force: true })
|
||||
tempDir = null
|
||||
}
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('counts only matches against the ripgrep cap', async () => {
|
||||
wslAwareSpawnMock
|
||||
.mockImplementationOnce(() => fakeRipgrep('a.ts\nb.ts\nc.ts\nios/AppDelegate.swift\n'))
|
||||
.mockImplementationOnce(() => fakeRipgrep(''))
|
||||
|
||||
const files = await listQuickOpenFiles(
|
||||
'/repo',
|
||||
makeStore('/repo'),
|
||||
undefined,
|
||||
undefined,
|
||||
2,
|
||||
undefined,
|
||||
nameFilter('app delegate')
|
||||
)
|
||||
|
||||
expect(files).toEqual(['ios/AppDelegate.swift'])
|
||||
})
|
||||
|
||||
it('filters the whole git listing when ripgrep is missing', async () => {
|
||||
spawnMissingRipgrep()
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'orca-name-filter-'))
|
||||
const repoPath = join(tempDir, 'repo')
|
||||
await execFile('git', ['init', '-q', repoPath])
|
||||
for (const relPath of ['a.ts', 'b.ts', 'zz/Notion Web Clipper/AppDelegate.swift']) {
|
||||
await mkdir(dirname(join(repoPath, relPath)), { recursive: true })
|
||||
await writeFile(join(repoPath, relPath), 'x')
|
||||
}
|
||||
await execFile('git', ['add', '.'], { cwd: repoPath })
|
||||
const store = makeStore(repoPath)
|
||||
|
||||
await expect(listQuickOpenFiles(repoPath, store, undefined, undefined, 2)).resolves.toEqual([
|
||||
'a.ts',
|
||||
'b.ts'
|
||||
])
|
||||
await expect(
|
||||
listQuickOpenFiles(repoPath, store, undefined, undefined, 2, undefined, nameFilter('appdel'))
|
||||
).resolves.toEqual(['zz/Notion Web Clipper/AppDelegate.swift'])
|
||||
})
|
||||
|
||||
it('rejects an over-budget filtered walk so the renderer keeps its capped listing', async () => {
|
||||
spawnMissingRipgrep()
|
||||
listFilesWithGitSpy.mockRejectedValueOnce(new Error('File listing exceeded 20001 files'))
|
||||
|
||||
await expect(
|
||||
listQuickOpenFiles(
|
||||
'/folder',
|
||||
makeStore('/folder'),
|
||||
undefined,
|
||||
undefined,
|
||||
5,
|
||||
undefined,
|
||||
nameFilter('target')
|
||||
)
|
||||
).rejects.toThrow()
|
||||
expect(listFilesWithGitSpy).toHaveBeenCalledTimes(1)
|
||||
expect(listFilesWithGitSpy.mock.calls[0][4]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps primary matches when the ignored-file pass fails during a filtered scan', async () => {
|
||||
wslAwareSpawnMock
|
||||
.mockImplementationOnce(() => fakeRipgrep('ios/AppDelegate.swift\n'))
|
||||
.mockImplementationOnce(() => fakeRipgrep('', 'SIGKILL'))
|
||||
|
||||
await expect(
|
||||
listQuickOpenFiles(
|
||||
'/repo',
|
||||
makeStore('/repo'),
|
||||
undefined,
|
||||
undefined,
|
||||
5,
|
||||
undefined,
|
||||
nameFilter('appdelegate')
|
||||
)
|
||||
).resolves.toEqual(['ios/AppDelegate.swift'])
|
||||
})
|
||||
|
||||
it('still rejects an ignored-pass failure for unfiltered listings', async () => {
|
||||
wslAwareSpawnMock
|
||||
.mockImplementationOnce(() => fakeRipgrep('a.ts\n'))
|
||||
.mockImplementationOnce(() => fakeRipgrep('', 'SIGKILL'))
|
||||
|
||||
await expect(
|
||||
listQuickOpenFiles('/repo', makeStore('/repo'), undefined, undefined, 5)
|
||||
).rejects.toThrow('rg killed by SIGKILL')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { isQuickOpenReaddirBudgetError } from '../../shared/quick-open-readdir-walk'
|
||||
import { buildInstallRgMessage } from '../../shared/quick-open-install-rg'
|
||||
import { limitQuickOpenFilesBySerializedBytes } from '../../shared/quick-open-transport-budget'
|
||||
import { listFilesWithGit } from './filesystem-list-files-git-fallback'
|
||||
|
||||
/** Quick Open listing through git/readdir when ripgrep is unavailable. */
|
||||
export async function listFilesWithoutRipgrep(args: {
|
||||
rootPath: string
|
||||
excludePathPrefixes: readonly string[]
|
||||
localGitOptions: { wslDistro?: string }
|
||||
signal?: AbortSignal
|
||||
maxResults?: number
|
||||
maxSerializedBytes?: number
|
||||
pathFilter?: (relativePath: string) => boolean
|
||||
}): Promise<string[]> {
|
||||
const { rootPath, excludePathPrefixes, localGitOptions, signal, maxResults, pathFilter } = args
|
||||
try {
|
||||
// Why: these fallbacks cap scanned files, not matches, so a filtered listing scans everything.
|
||||
// An over-budget readdir walk rejects, and the renderer falls back to its capped listing.
|
||||
const files = pathFilter
|
||||
? (await listFilesWithGit(rootPath, excludePathPrefixes, localGitOptions, signal))
|
||||
.filter(pathFilter)
|
||||
.slice(0, maxResults)
|
||||
: await listFilesWithGit(rootPath, excludePathPrefixes, localGitOptions, signal, maxResults)
|
||||
return args.maxSerializedBytes === undefined
|
||||
? files
|
||||
: limitQuickOpenFilesBySerializedBytes(files, args.maxSerializedBytes)
|
||||
} catch (err) {
|
||||
if (!isQuickOpenReaddirBudgetError(err)) {
|
||||
throw err
|
||||
}
|
||||
throw new Error(await buildInstallRgMessage(err))
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user