From 5db739ce4494f8e733539e9d3eb6ef06827f23dc Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 03:33:03 -0800 Subject: [PATCH 01/13] =?UTF-8?q?feat(run-actions):=20design=20review=20(W?= =?UTF-8?q?10-4,=20DS-24,=20DS-30)=20=E2=80=94=20accessible=20names,=20run?= =?UTF-8?q?ning=20tooltip,=20live=20region,=20shared=20focus=20outline,=20?= =?UTF-8?q?Finished,=20port=20submenu,=20rescan=20on=20stop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Menu items name their action (Run/Run again/Restart/Stop/Show output ). - Running tooltip 'Show output' + '⌥⌘↩ restarts it'; primary label states running/stopping/ last run failed; chevron 'Run actions, N running', min-w-6. - Polite live region announces ' stopped/failed/finished'. - Shared focus outline; name in text-foreground; hollow dot once a run ended. - Restart and Stop disabled while Stopping…; exit 0 reads 'Finished'. - Ports rescan as soon as a run ends; each port has Open in browser · Share… (Share opens the Ports panel, where forwarding and share links live). - Fixtures: failed, exited. --- .../run-actions/RunActionButton.tsx | 69 ++++++- .../RunActionButtonView.fixture.tsx | 35 +++- .../run-actions/RunActionButtonView.test.tsx | 74 ++++++++ .../run-actions/RunActionButtonView.tsx | 168 +++++++++++++++--- src/renderer/src/i18n/locales/en.json | 20 ++- 5 files changed, 332 insertions(+), 34 deletions(-) create mode 100644 src/renderer/src/components/run-actions/RunActionButtonView.test.tsx diff --git a/src/renderer/src/components/run-actions/RunActionButton.tsx b/src/renderer/src/components/run-actions/RunActionButton.tsx index e803e5c5e41..b99e916f72f 100644 --- a/src/renderer/src/components/run-actions/RunActionButton.tsx +++ b/src/renderer/src/components/run-actions/RunActionButton.tsx @@ -1,8 +1,13 @@ -import React, { useEffect, useMemo, useState } from 'react' +import React, { useEffect, useMemo, useRef, useState } from 'react' import { useAppStore } from '@/store' import { useShortcutLabel } from '@/hooks/useShortcutLabel' import { useWorktreeRuntimeTarget } from '@/runtime/rpc/targets/use-worktree-runtime-target' -import { workspacePortScanKeyForTarget } from '@/lib/workspace-ports/workspace-port-actions' +import { + openWorkspacePortInBrowser, + refreshWorkspacePortScanAfterStop, + workspacePortScanKeyForTarget +} from '@/lib/workspace-ports/workspace-port-actions' +import { useTranslate } from '@/i18n/use-translate' import { getWorkspacePortsByWorktreeId } from '@/lib/workspace-ports/workspace-port-groups' import { loadWorktreeRunActions, @@ -61,6 +66,53 @@ function focusRunTab(run: RunActionRun): void { state.setActiveTab(findLiveRunTabId(state, run.worktreeId, run.tabId) ?? run.tabId) } +/** "Web stopped" / "Web failed" when a run ends; the view reads it out through a polite live region. */ +function useRunEndAnnouncement(runs: Record): string | null { + const t = useTranslate() + const previous = useRef>(runs) + const [message, setMessage] = useState(null) + useEffect(() => { + for (const run of Object.values(runs)) { + const before = previous.current[run.actionId]?.status + if (before !== run.status && (before === 'running' || before === 'stopping')) { + const name = { name: run.actionName } + if (run.status === 'stopped') { + setMessage(t('runActions.announceStopped', '{{name}} stopped', name)) + } else if (run.status === 'failed') { + setMessage(t('runActions.announceFailed', '{{name}} failed', name)) + } else if (run.status === 'exited') { + setMessage(t('runActions.announceFinished', '{{name}} finished', name)) + } + } + } + previous.current = runs + }, [runs, t]) + return message +} + +/** A run that just ended freed its ports: rescan now so the menu and Ports panel don't show them. */ +function useRescanPortsWhenRunsEnd(worktreeId: string, runs: Record): void { + const target = useWorktreeRuntimeTarget(worktreeId) + const liveCount = Object.values(runs).filter( + (run) => run.status === 'running' || run.status === 'stopping' + ).length + const previousLive = useRef(liveCount) + useEffect(() => { + const ended = liveCount < previousLive.current + previousLive.current = liveCount + if (!ended || !target) { + return + } + const state = useAppStore.getState() + void refreshWorkspacePortScanAfterStop({ + runtimeTarget: target, + replaceWorkspacePortScans: state.replaceWorkspacePortScans, + getWorkspacePortScansByKey: () => useAppStore.getState().workspacePortScansByKey, + setWorkspacePortScanRefreshing: state.setWorkspacePortScanRefreshing + }).catch(() => {}) + }, [liveCount, target]) +} + function openPortsPanel(): void { const state = useAppStore.getState() state.setRightSidebarOpen(true) @@ -73,6 +125,9 @@ export function RunActionButton({ worktreeId }: { worktreeId: string }): React.J const runs = useWorktreeRunActionRuns(worktreeId) const ports = useWorktreePorts(worktreeId) const shortcut = useShortcutLabel('workspace.run') + const announcement = useRunEndAnnouncement(runs) + useRescanPortsWhenRunsEnd(worktreeId, runs) + const target = useWorktreeRuntimeTarget(worktreeId) const primary = useMemo( () => (config ? pickPrimaryRunAction(worktreeId, config.actions) : null), // Runs change which action is primary (the last one started). @@ -92,7 +147,17 @@ export function RunActionButton({ worktreeId }: { worktreeId: string }): React.J onRun={(action) => void startRunAction(worktreeId, action, config)} onStop={(action) => void stopRunAction(worktreeId, action.id)} onShowOutput={focusRunTab} + announcement={announcement} onOpenPorts={openPortsPanel} + onOpenPortInBrowser={(port) => { + const state = useAppStore.getState() + void openWorkspacePortInBrowser({ + port, + runtimeTarget: target, + createBrowserTab: state.createBrowserTab, + setRemoteBrowserPageHandle: state.setRemoteBrowserPageHandle + }) + }} /> ) } diff --git a/src/renderer/src/components/run-actions/RunActionButtonView.fixture.tsx b/src/renderer/src/components/run-actions/RunActionButtonView.fixture.tsx index 340679655f2..5c57ac738f4 100644 --- a/src/renderer/src/components/run-actions/RunActionButtonView.fixture.tsx +++ b/src/renderer/src/components/run-actions/RunActionButtonView.fixture.tsx @@ -55,7 +55,8 @@ const base: RunActionButtonViewProps = { onRun: noop, onStop: noop, onShowOutput: noop, - onOpenPorts: noop + onOpenPorts: noop, + onOpenPortInBrowser: noop } function Bar({ children }: { children: React.ReactNode }): React.JSX.Element { @@ -67,7 +68,7 @@ function Bar({ children }: { children: React.ReactNode }): React.JSX.Element { } function openMenu(frame: HTMLElement): void { - const trigger = frame.querySelector('button[aria-label="Run actions"]') + const trigger = frame.querySelector('button[aria-label^="Run actions"]') trigger?.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0 })) } @@ -121,3 +122,33 @@ export const singleLegacyAction = defineFixture({ ) }) + +export const failed = defineFixture({ + width: 420, + render: () => ( + + + + ), + interact: openMenu +}) + +export const exited = defineFixture({ + width: 420, + render: () => ( + + + + ), + interact: openMenu +}) diff --git a/src/renderer/src/components/run-actions/RunActionButtonView.test.tsx b/src/renderer/src/components/run-actions/RunActionButtonView.test.tsx new file mode 100644 index 00000000000..d09a2cba0f7 --- /dev/null +++ b/src/renderer/src/components/run-actions/RunActionButtonView.test.tsx @@ -0,0 +1,74 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import type { RunActionRun } from '@/lib/run-actions/run-actions-store' +import type { OrcaRunAction } from '../../../../shared/orca-yaml-hook-types' +import { RunActionButtonView, type RunActionButtonViewProps } from './RunActionButtonView' + +const noop = (): void => {} +const web: OrcaRunAction = { id: 'web', name: 'Web', command: 'pnpm dev', default: true } +const worker: OrcaRunAction = { id: 'worker', name: 'Worker', command: 'pnpm worker' } + +function run(patch: Partial): RunActionRun { + return { + worktreeId: 'r::/w', + repoId: 'r', + actionId: 'web', + actionName: 'Web', + tabId: 't', + status: 'running', + exitCode: null, + startedAt: 0, + ...patch + } +} + +function renderView(props: Partial): void { + render( + + + + ) +} + +afterEach(cleanup) + +describe('RunActionButtonView accessible names', () => { + it('names the idle primary action and the plain menu', () => { + renderView({}) + expect(screen.getByRole('button', { name: 'Run Web' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Run actions' })).toBeTruthy() + }) + + it('says the action is running, and how many run, without relying on the dot color', () => { + renderView({ runs: { web: run({}), worker: run({ actionId: 'worker' }) } }) + expect(screen.getByRole('button', { name: 'Web is running. Show output' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Run actions, 2 running' })).toBeTruthy() + }) + + it('names a stopping and a failed primary action', () => { + renderView({ runs: { web: run({ status: 'stopping' }) } }) + expect(screen.getByRole('button', { name: 'Web is stopping. Show output' })).toBeTruthy() + cleanup() + renderView({ runs: { web: run({ status: 'failed', exitCode: 1 }) } }) + expect(screen.getByRole('button', { name: 'Run Web, last run failed' })).toBeTruthy() + }) + + it('announces a finished run politely', () => { + renderView({ announcement: 'Web stopped' }) + expect(screen.getByRole('status').textContent).toBe('Web stopped') + }) +}) diff --git a/src/renderer/src/components/run-actions/RunActionButtonView.tsx b/src/renderer/src/components/run-actions/RunActionButtonView.tsx index 1774aae74f5..94593a66bda 100644 --- a/src/renderer/src/components/run-actions/RunActionButtonView.tsx +++ b/src/renderer/src/components/run-actions/RunActionButtonView.tsx @@ -1,5 +1,14 @@ import React from 'react' -import { ChevronDown, Play, Plug, RotateCw, Square, SquareTerminal } from 'lucide-react' +import { + ChevronDown, + ExternalLink, + Play, + Plug, + RotateCw, + Share2, + Square, + SquareTerminal +} from 'lucide-react' import { useTranslate } from '@/i18n/use-translate' import { cn } from '@/lib/utils' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' @@ -9,6 +18,9 @@ import { DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import type { RunActionRun, RunActionStatus } from '@/lib/run-actions/run-actions-store' @@ -19,6 +31,11 @@ export function isRunLive(status: RunActionStatus | undefined): boolean { return status === 'running' || status === 'stopping' } +// The shared focus outline (as in Button), so keyboard focus reads the same across the titlebar. +const FOCUS_OUTLINE = + 'outline-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-focus-ring' + +/** Shape carries state as well as color: filled while running or failed, hollow once it ended. */ function StatusDot({ status }: { status: RunActionStatus | undefined }): React.JSX.Element | null { if (!status) { return null @@ -26,12 +43,13 @@ function StatusDot({ status }: { status: RunActionStatus | undefined }): React.J return ( ) @@ -48,7 +66,7 @@ function useStatusLabel(): (run: RunActionRun | undefined) => string { case 'stopped': return t('runActions.status.stopped', 'Stopped') case 'exited': - return t('runActions.status.exited', 'Exited') + return t('runActions.status.finished', 'Finished') case 'failed': return run.exitCode === null ? t('runActions.status.failed', 'Failed') @@ -65,10 +83,13 @@ export type RunActionButtonViewProps = { runs: Readonly> ports: readonly WorkspacePort[] shortcut: string | null + /** Polite announcement of the latest run ending ("Web stopped"), or null. */ + announcement?: string | null onRun: (action: OrcaRunAction) => void onStop: (action: OrcaRunAction) => void onShowOutput: (run: RunActionRun) => void onOpenPorts: () => void + onOpenPortInBrowser: (port: WorkspacePort) => void } function ActionRow({ @@ -84,11 +105,13 @@ function ActionRow({ const t = useTranslate() const statusLabel = useStatusLabel() const live = isRunLive(run?.status) + const stopping = run?.status === 'stopping' + const name = { name: action.name } return ( <>
- {action.name} + {action.name} {statusLabel(run)}
{live ? ( <> - onRun(action)}> + {/* Both wait while the run settles, so a second press can't race the first. */} + onRun(action)} + > {t('runActions.restart', 'Restart')} - onStop(action)}> + onStop(action)} + > {t('runActions.stop', 'Stop')} ) : ( - onRun(action)}> + onRun(action)} + > {run ? t('runActions.runAgain', 'Run again') : t('runActions.run', 'Run')} )} {run ? ( - onShowOutput(run)}> + onShowOutput(run)} + > {t('runActions.showOutput', 'Show output')} @@ -124,6 +166,39 @@ function ActionRow({ ) } +function PortItem({ + port, + onOpenPorts, + onOpenPortInBrowser +}: { port: WorkspacePort } & Pick< + RunActionButtonViewProps, + 'onOpenPorts' | 'onOpenPortInBrowser' +>): React.JSX.Element { + const t = useTranslate() + return ( + + + + :{port.port} + + {port.processName ?? ''} + + + + onOpenPortInBrowser(port)}> + + {t('runActions.openInBrowser', 'Open in browser')} + + {/* Forwarding and share links live in the Ports panel, next to this port. */} + + + {t('runActions.shareInPorts', 'Share…')} + + + + ) +} + /** * Titlebar control for run actions. The main button starts the primary action (or shows its * terminal while it runs); the menu holds every action, Stop/Restart, and the ports it opened. @@ -134,18 +209,26 @@ export function RunActionButtonView({ runs, ports, shortcut, + announcement = null, onRun, onStop, onShowOutput, - onOpenPorts + onOpenPorts, + onOpenPortInBrowser }: RunActionButtonViewProps): React.JSX.Element { const t = useTranslate() const primaryRun = runs[primary.id] const primaryLive = isRunLive(primaryRun?.status) - const anyLive = Object.values(runs).some((run) => isRunLive(run.status)) - const primaryLabel = primaryLive - ? t('runActions.showRunning', 'Show {{name}}', { name: primary.name }) - : t('runActions.runNamed', 'Run {{name}}', { name: primary.name }) + const liveCount = Object.values(runs).filter((run) => isRunLive(run.status)).length + const name = { name: primary.name } + const primaryLabel = + primaryRun?.status === 'running' + ? t('runActions.runningShowOutput', '{{name}} is running. Show output', name) + : primaryRun?.status === 'stopping' + ? t('runActions.stoppingShowOutput', '{{name}} is stopping. Show output', name) + : primaryRun?.status === 'failed' + ? t('runActions.runLastFailed', 'Run {{name}}, last run failed', name) + : t('runActions.runNamed', 'Run {{name}}', name) const onPrimaryClick = (): void => { if (primaryRun && primaryLive) { onShowOutput(primaryRun) @@ -163,26 +246,51 @@ export function RunActionButtonView({ - {shortcut ? `${primaryLabel} (${shortcut})` : primaryLabel} + {primaryLive ? ( +
+ {t('runActions.showOutputTooltip', 'Show {{name}} output', name)} + {shortcut ? ( + + {t('runActions.shortcutRestarts', '{{shortcut}} restarts it', { shortcut })} + + ) : null} +
+ ) : shortcut ? ( + `${t('runActions.runNamed', 'Run {{name}}', name)} (${shortcut})` + ) : ( + t('runActions.runNamed', 'Run {{name}}', name) + )}
@@ -206,18 +314,22 @@ export function RunActionButtonView({ {t('runActions.noPorts', 'No servers listening in this workspace yet.')}
) : ( - ports.slice(0, 6).map((port) => ( - - - :{port.port} - - {port.processName ?? ''} - - - )) + ports + .slice(0, 6) + .map((port) => ( + + )) )} + + {announcement ?? ''} + ) } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2cf5bcb958b..c1c4f61c515 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -27073,7 +27073,8 @@ "exited": "Exited", "failed": "Failed", "failedWithCode": "Failed (exit {{code}})", - "notRunning": "Not running" + "notRunning": "Not running", + "finished": "Finished" }, "restart": "Restart", "stop": "Stop", @@ -27087,6 +27088,21 @@ "noPorts": "No servers listening in this workspace yet.", "tabTitle": "Run: {{name}}", "noneTitle": "No run actions for this workspace", - "noneBody": "Add a command under scripts.run in orca.yaml, for example a dev server on $ORCA_PORT." + "noneBody": "Add a command under scripts.run in orca.yaml, for example a dev server on $ORCA_PORT.", + "announceStopped": "{{name}} stopped", + "announceFailed": "{{name}} failed", + "announceFinished": "{{name}} finished", + "restartNamed": "Restart {{name}}", + "stopNamed": "Stop {{name}}", + "runAgainNamed": "Run {{name}} again", + "showOutputNamed": "Show {{name}} output", + "openInBrowser": "Open in browser", + "shareInPorts": "Share…", + "runningShowOutput": "{{name}} is running. Show output", + "stoppingShowOutput": "{{name}} is stopping. Show output", + "runLastFailed": "Run {{name}}, last run failed", + "showOutputTooltip": "Show {{name}} output", + "shortcutRestarts": "{{shortcut}} restarts it", + "menuRunning": "Run actions, {{count}} running" } } From 7fa23b1b571067b55fa1368a53bec90c123e1ea3 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 03:37:31 -0800 Subject: [PATCH 02/13] chore(i18n): drop retired run-action keys; runtime catalog --- src/renderer/src/i18n/en-runtime-required.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index c62981bd29f..f3d7985be21 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2320,6 +2320,12 @@ "localOwnedCount_other": "{{count}} Orca PTYs on this computer" } }, + "runActions": { + "showRunning": "Show {{name}}", + "status": { + "exited": "Exited" + } + }, "runtime": { "remoteServerPairingErrors": { "alreadyPairedAddAgain": "This server is already paired as \"{{name}}\". To update its connection, add it again under Settings > Remote Orca Servers.", From 6b2ff928254562693633ebfb52f041c002575ab3 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 03:40:24 -0800 Subject: [PATCH 03/13] =?UTF-8?q?fix(cloud):=20Copilot's=20token=20stand-i?= =?UTF-8?q?n=20looks=20like=20a=20GitHub=20token=20(Copilot=20ignored=20or?= =?UTF-8?q?ca-ph-=E2=80=A6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dry routine run on dev: Copilot CLI answered 'No authentication information found' with COPILOT_GITHUB_TOKEN=orca-ph-…; it only takes GitHub-shaped tokens (github_pat_/gho_/ghu_; classic ghp_ refused). The stand-in for that one credential is now github_pat_orcaph_, still swapped by exact match on api.github.com (Copilot endpoints only) and the Copilot API. --- cloud/apps/sandbox-control/src/provider-env.ts | 6 ++++++ cloud/apps/sandbox-control/src/secret-egress.ts | 5 +++-- .../sandbox-control/test/agent-credentials-more.test.ts | 6 +++++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/cloud/apps/sandbox-control/src/provider-env.ts b/cloud/apps/sandbox-control/src/provider-env.ts index 2c766282f92..cec8ecbf2aa 100644 --- a/cloud/apps/sandbox-control/src/provider-env.ts +++ b/cloud/apps/sandbox-control/src/provider-env.ts @@ -101,6 +101,11 @@ export type CredentialPolicy = { * headers that set state either); anything else is refused. */ keepResponseFields?: readonly string[] + /** + * The placeholder's shape when the client checks it (default `orca-ph--`). Copilot + * CLI ignores a COPILOT_GITHUB_TOKEN that doesn't look like a GitHub token. + */ + placeholderFormat?: (sandboxId: string) => string /** More ways the client may send the key in a header (gh-style `token `). */ altHeaderFormats?: Record string)[]> } @@ -169,6 +174,7 @@ const CREDENTIALS: readonly CredentialSpec[] = [ defaultHosts: ['api.github.com', ...COPILOT_API_HOSTS], headers: { authorization: bearer }, altHeaderFormats: { authorization: [(value: string) => `token ${value}`] }, + placeholderFormat: (sandboxId: string) => `github_pat_orcaph_${sandboxId}`, allowUnclaimedHosts: [...COPILOT_API_HOSTS], // On GitHub's API the user's token checks Copilot access and nothing else: the rest of the API // (repos, gists, ...) stays out of reach of whatever runs in the workspace. diff --git a/cloud/apps/sandbox-control/src/secret-egress.ts b/cloud/apps/sandbox-control/src/secret-egress.ts index 19a9553b8b0..8003c6f973e 100644 --- a/cloud/apps/sandbox-control/src/secret-egress.ts +++ b/cloud/apps/sandbox-control/src/secret-egress.ts @@ -5,7 +5,7 @@ // Codex) hang under Vercel transform rules, so these hosts always take the forwardURL path through // /v1/egress/proxy, the same path api.github.com uses. Placeholders are not secrets: the proxy // authenticates the sandbox by Vercel's OIDC token, the placeholder only marks which header to swap. -import { SWAPPED_CREDENTIALS, credentialTarget, credentialTargets, type CredentialTarget, type ValueScopes } from './provider-env' +import { SWAPPED_CREDENTIALS, credentialShape, credentialTarget, credentialTargets, type CredentialTarget, type ValueScopes } from './provider-env' import type { SandboxRow } from './store' export type SecretEgressHost = CredentialTarget @@ -14,7 +14,8 @@ export type SecretEgressHost = CredentialTarget const LEGACY_HOSTS: Record = { 'api.anthropic.com': 'ANTHROPIC_API_KEY', 'api.openai.com': 'OPENAI_API_KEY' } export function secretPlaceholder(row: Pick, secret: string): string { - return `orca-ph-${secret.toLowerCase().replaceAll('_', '-')}-${row.id}` + const format = credentialShape(secret)?.placeholderFormat + return format ? format(row.id) : `orca-ph-${secret.toLowerCase().replaceAll('_', '-')}-${row.id}` } /** `SECRET@host` entries recorded on the row at boot (or refresh) as placeholder-routed. */ diff --git a/cloud/apps/sandbox-control/test/agent-credentials-more.test.ts b/cloud/apps/sandbox-control/test/agent-credentials-more.test.ts index 9eb50a5b13a..b342c939f58 100644 --- a/cloud/apps/sandbox-control/test/agent-credentials-more.test.ts +++ b/cloud/apps/sandbox-control/test/agent-credentials-more.test.ts @@ -196,7 +196,11 @@ describe('egress swap on a sandbox with the new keys', () => { const { h, id, send, upstream } = await setup() const mia = await h.token('mia', undefined, 'sandbox') expect((await h.call('PUT', '/v1/secrets/personal/COPILOT_GITHUB_TOKEN', { body: { value: COPILOT_KEY }, bearer: mia })).status).toBe(200) - const ph = `orca-ph-copilot-github-token-${id}` + // Copilot CLI ignores a token that doesn't look like a GitHub token, so the stand-in looks like one. + const ph = `github_pat_orcaph_${id}` + const vmEnv = h.vercel.commands.filter((c) => c.env.ORCA_SECRET_ENV).at(-1)!.env.ORCA_SECRET_ENV! + const line = vmEnv.split('\n').find((l) => l.startsWith('COPILOT_GITHUB_TOKEN='))! + expect(Buffer.from(line.slice(line.indexOf('=') + 1), 'base64').toString('utf8')).toBe(ph) const policy = [...h.vercel.boxes.values()][0]!.policy const values = policy.allow['api.github.com']!.flatMap((rule) => ('match' in rule && rule.match?.headers ? [rule.match.headers[0]!.value.exact] : [])) expect(values).toEqual(expect.arrayContaining([`Bearer ${ph}`, `token ${ph}`, `Bearer orca-ph-${id}`])) From f90e3f4b5108ba5b732bab8e6cc7da89ec30e6b8 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 03:45:22 -0800 Subject: [PATCH 04/13] chore(i18n): remove retired run-action keys from the nested catalog --- src/renderer/src/i18n/en-runtime-required.json | 6 ------ src/renderer/src/i18n/locales/en.json | 2 -- 2 files changed, 8 deletions(-) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index f3d7985be21..c62981bd29f 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2320,12 +2320,6 @@ "localOwnedCount_other": "{{count}} Orca PTYs on this computer" } }, - "runActions": { - "showRunning": "Show {{name}}", - "status": { - "exited": "Exited" - } - }, "runtime": { "remoteServerPairingErrors": { "alreadyPairedAddAgain": "This server is already paired as \"{{name}}\". To update its connection, add it again under Settings > Remote Orca Servers.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c1c4f61c515..3d0e5fbec7f 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -27070,7 +27070,6 @@ "running": "Running", "stopping": "Stopping…", "stopped": "Stopped", - "exited": "Exited", "failed": "Failed", "failedWithCode": "Failed (exit {{code}})", "notRunning": "Not running", @@ -27081,7 +27080,6 @@ "runAgain": "Run again", "run": "Run", "showOutput": "Show output", - "showRunning": "Show {{name}}", "runNamed": "Run {{name}}", "menu": "Run actions", "ports": "Ports", From dc150fa8a5dcff585bf15ee7b51b03bcb17f0cfd Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 04:09:36 -0800 Subject: [PATCH 05/13] fix(run-actions): a split pane in the run tab can't end the run; autoRunAfterSetup fires on first open when the host ran setup in an environment build - Command-finished events carry the pane's leaf; a run learns its pane while its tab has one, ignores other panes, and Stop interrupts the run's own pane. - Workspaces whose setup ran in the environment build (hooks report environmentProvisioned) start the autoRunAfterSetup action the first time they are opened, once per workspace. --- .../run-actions/RunActionButton.tsx | 6 ++ .../parked-terminal-command-status.ts | 8 +- .../agent-identity/pane-agent-identity.ts | 4 +- .../hooks/terminal-command-finished-event.ts | 7 +- .../src/lib/run-actions/run-action-config.ts | 11 ++- .../run-actions/run-action-controller.test.ts | 72 +++++++++++++++- .../lib/run-actions/run-action-controller.ts | 5 +- .../src/lib/run-actions/run-action-events.ts | 82 +++++++++++++++++-- .../src/lib/run-actions/run-action-tabs.ts | 41 ++++++++++ .../src/lib/run-actions/run-actions-store.ts | 10 ++- 10 files changed, 229 insertions(+), 17 deletions(-) diff --git a/src/renderer/src/components/run-actions/RunActionButton.tsx b/src/renderer/src/components/run-actions/RunActionButton.tsx index e803e5c5e41..9183a7c3afe 100644 --- a/src/renderer/src/components/run-actions/RunActionButton.tsx +++ b/src/renderer/src/components/run-actions/RunActionButton.tsx @@ -10,6 +10,7 @@ import { type WorktreeRunActions } from '@/lib/run-actions/run-action-config' import { startRunAction, stopRunAction } from '@/lib/run-actions/run-action-controller' +import { autoRunProvisionedWorkspaceOnce } from '@/lib/run-actions/run-action-events' import { useWorktreeRunActionRuns, type RunActionRun } from '@/lib/run-actions/run-actions-store' import type { WorkspacePort } from '../../../../shared/workspace-ports' import { RunActionButtonView } from './RunActionButtonView' @@ -79,6 +80,11 @@ export function RunActionButton({ worktreeId }: { worktreeId: string }): React.J // eslint-disable-next-line react-hooks/exhaustive-deps [config, worktreeId, runs] ) + useEffect(() => { + if (config?.setupProvisioned) { + void autoRunProvisionedWorkspaceOnce(worktreeId, config) + } + }, [config, worktreeId]) if (!config || !primary) { return null } diff --git a/src/renderer/src/components/terminal-pane/parking/output-watchers/parked-terminal-command-status.ts b/src/renderer/src/components/terminal-pane/parking/output-watchers/parked-terminal-command-status.ts index 06d1e7b42dd..6e526a0edc0 100644 --- a/src/renderer/src/components/terminal-pane/parking/output-watchers/parked-terminal-command-status.ts +++ b/src/renderer/src/components/terminal-pane/parking/output-watchers/parked-terminal-command-status.ts @@ -1,4 +1,5 @@ /** Keep store-level command status while parked; foreground confirmation and input-intent inference remain with mounted panes. */ +import { parsePaneKey } from '../../../../../../shared/workspace-layout/stable-pane-id' import type { AgentStatusEntry } from '../../../../../../shared/agent-status/contracts/agent-status-types' import { resolvePaneAgentOwner } from '../../../../../../shared/agent-status/identity/pane-agent-owner' import { parseAppSshPtyId } from '../../../../../../shared/ssh-pty-id' @@ -173,7 +174,12 @@ export function createParkedTerminalCommandStatusPolicy(options: { return } // Commands can change HEAD/index; nudge git UI immediately rather than wait for polling. - dispatchTerminalCommandFinishedEvent(worktreeId, bestEffortExitCode, tabId) + dispatchTerminalCommandFinishedEvent( + worktreeId, + bestEffortExitCode, + tabId, + parsePaneKey(paneKey)?.leafId + ) void retireForegroundAgentUnlessConfirmed() // Mirror SSH’s unprobed same-turn drop; local PTYs require mounted foreground confirmation against nested-shell 133;D. if (parseAppSshPtyId(ptyId) === null) { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/agent-identity/pane-agent-identity.ts b/src/renderer/src/components/terminal-pane/pty-connection/agent-identity/pane-agent-identity.ts index b718f5f481f..8844dc92092 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/agent-identity/pane-agent-identity.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/agent-identity/pane-agent-identity.ts @@ -1,3 +1,4 @@ +import { parsePaneKey } from '../../../../../../shared/workspace-layout/stable-pane-id' import { agentTypeToIconAgent } from '../../../../../../shared/agent-status/presentation/agent-type-icon-agent' import { dismissRetainedProgramResult } from '@/lib/program-status/result-retention' import { isRetainedProgramResult } from '@/lib/program-status/row' @@ -217,7 +218,8 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { dispatchTerminalCommandFinishedEvent( session.deps.worktreeId, bestEffortExitCode, - session.deps.tabId + session.deps.tabId, + parsePaneKey(session.cacheKey)?.leafId ) const state = useAppStore.getState() const entry = state.agentStatusByPaneKey[session.cacheKey] diff --git a/src/renderer/src/hooks/terminal-command-finished-event.ts b/src/renderer/src/hooks/terminal-command-finished-event.ts index 28abe1726df..5b965ee4d80 100644 --- a/src/renderer/src/hooks/terminal-command-finished-event.ts +++ b/src/renderer/src/hooks/terminal-command-finished-event.ts @@ -6,13 +6,16 @@ export type TerminalCommandFinishedEventDetail = { exitCode: number | null /** The pane's tab, when the emitter knows it; consumers tracking one tab's command need it. */ tabId?: string + /** The pane's stable leaf: a split's commands are not the tab's first pane's. */ + leafId?: string } // Window events let decoupled consumers observe per-pane OSC completion without accessing terminal internals. export function dispatchTerminalCommandFinishedEvent( worktreeId: string, exitCode: number | null, - tabId?: string + tabId?: string, + leafId?: string ): void { // Why: unit tests and non-DOM renderer shims may expose only the preload API. if (typeof window.dispatchEvent !== 'function') { @@ -21,7 +24,7 @@ export function dispatchTerminalCommandFinishedEvent( window.dispatchEvent( new CustomEvent(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, { - detail: { worktreeId, exitCode, ...(tabId ? { tabId } : {}) } + detail: { worktreeId, exitCode, ...(tabId ? { tabId } : {}), ...(leafId ? { leafId } : {}) } }) ) } diff --git a/src/renderer/src/lib/run-actions/run-action-config.ts b/src/renderer/src/lib/run-actions/run-action-config.ts index be7d6b2ad80..0ac4ee3ef3f 100644 --- a/src/renderer/src/lib/run-actions/run-action-config.ts +++ b/src/renderer/src/lib/run-actions/run-action-config.ts @@ -21,6 +21,8 @@ export type WorktreeRunActions = { scripts: OrcaRunScripts /** Ids defined by the shared orca.yaml (they need the user's approval before running). */ sharedIds: ReadonlySet + /** The host prepared the checkout itself (setup ran in an environment build, not a Setup tab). */ + setupProvisioned?: true } export type RunActionTarget = { @@ -96,13 +98,20 @@ export async function loadWorktreeRunActions( } const repo = state.repos.find((candidate) => candidate.id === target.repoId) let shared: OrcaHooks['scripts'] | undefined + let setupProvisioned = false try { const result = await checkRuntimeHooks(target.hostId, target.repoId, options) shared = result.status === 'error' ? undefined : (result.hooks?.scripts ?? undefined) + setupProvisioned = result.environmentProvisioned === true } catch { shared = undefined } - return mergeRunActions(shared, repo?.hookSettings?.scripts, target.isLocal ? 'local' : 'remote') + const merged = mergeRunActions( + shared, + repo?.hookSettings?.scripts, + target.isLocal ? 'local' : 'remote' + ) + return setupProvisioned ? { ...merged, setupProvisioned: true } : merged } /** The action the Run button and shortcut act on: the last one still running here, else the default. */ diff --git a/src/renderer/src/lib/run-actions/run-action-controller.test.ts b/src/renderer/src/lib/run-actions/run-action-controller.test.ts index 1d1d6d27bba..9f828ebd204 100644 --- a/src/renderer/src/lib/run-actions/run-action-controller.test.ts +++ b/src/renderer/src/lib/run-actions/run-action-controller.test.ts @@ -10,6 +10,7 @@ type MockState = { { id: string; entityId: string; contentType: string; groupId: string }[] > ptyIdsByTabId: Record + terminalLayoutsByTabId: Record createTab: ReturnType closeTab: ReturnType setTabCustomTitle: ReturnType @@ -69,7 +70,8 @@ vi.stubGlobal('window', new EventTarget()) const { mergeRunActions, loadWorktreeRunActions } = await import('./run-action-config') const { startRunAction, stopRunAction, relaunchInterruptedRunActions, armRunActionRelaunch } = await import('./run-action-controller') -const { installRunActionEvents, armAutoRunAfterSetup } = await import('./run-action-events') +const { installRunActionEvents, armAutoRunAfterSetup, autoRunProvisionedWorkspaceOnce } = + await import('./run-action-events') const { getRunActionRun, resetRunActionRunsForTests } = await import('./run-actions-store') const { dispatchTerminalCommandFinishedEvent } = await import('@/hooks/terminal-command-finished-event') @@ -90,6 +92,7 @@ function makeState(): MockState { tabsByWorktree: {}, unifiedTabsByWorktree: {}, ptyIdsByTabId: {}, + terminalLayoutsByTabId: {}, createTab: vi.fn((worktreeId: string) => { const tab = { id: `tab-${++nextTab}` } state.tabsByWorktree[worktreeId] = [...(state.tabsByWorktree[worktreeId] ?? []), tab] @@ -322,6 +325,73 @@ describe('run actions', () => { expect(await startRunAction(WT_B, web, config([web]))).toBe(false) }) + it('a command finishing in a split of the run tab does not end the run', async () => { + await startRunAction(WT_A, web, config([web])) + const { tabId } = getRunActionRun(WT_A, 'web')! + const L1 = '11111111-1111-4111-8111-111111111111' + const L2 = '22222222-2222-4222-8222-222222222222' + const notifyLayouts = (): void => { + for (const listener of mocks.subscribers) { + listener(state, { tabsByWorktree: state.tabsByWorktree, terminalLayoutsByTabId: {} }) + } + } + state.terminalLayoutsByTabId = { [tabId]: { root: { type: 'leaf', leafId: L1 } } } + notifyLayouts() + expect(getRunActionRun(WT_A, 'web')?.leafId).toBe(L1) + state.terminalLayoutsByTabId = { + [tabId]: { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: L1 }, + second: { type: 'leaf', leafId: L2 } + } + } + } + notifyLayouts() + dispatchTerminalCommandFinishedEvent(WT_A, 0, tabId, L2) + expect(getRunActionRun(WT_A, 'web')?.status).toBe('running') + dispatchTerminalCommandFinishedEvent(WT_A, 1, tabId, L1) + expect(getRunActionRun(WT_A, 'web')).toMatchObject({ status: 'failed', exitCode: 1 }) + }) + + it('ignores a pane it cannot attribute while the tab has several panes', async () => { + await startRunAction(WT_A, web, config([web])) + const { tabId } = getRunActionRun(WT_A, 'web')! + state.terminalLayoutsByTabId = { + [tabId]: { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: '11111111-1111-4111-8111-111111111111' }, + second: { type: 'leaf', leafId: '22222222-2222-4222-8222-222222222222' } + } + } + } + dispatchTerminalCommandFinishedEvent(WT_A, 0, tabId, '22222222-2222-4222-8222-222222222222') + expect(getRunActionRun(WT_A, 'web')?.status).toBe('running') + }) + + it('auto-runs once on first open when the host ran setup in the environment build', async () => { + const storage = new Map() + vi.stubGlobal('localStorage', { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => void storage.set(key, value) + }) + try { + const provisioned = { ...config([web]), setupProvisioned: true as const } + provisioned.scripts = { ...provisioned.scripts, autoRunAfterSetup: true } + expect(await autoRunProvisionedWorkspaceOnce(WT_A, provisioned)).toBe(true) + expect(getRunActionRun(WT_A, 'web')?.status).toBe('running') + dispatchTerminalCommandFinishedEvent(WT_A, 0, getRunActionRun(WT_A, 'web')!.tabId) + expect(await autoRunProvisionedWorkspaceOnce(WT_A, provisioned)).toBe(false) + const notProvisioned = { ...provisioned, setupProvisioned: undefined } + expect(await autoRunProvisionedWorkspaceOnce(WT_B, notProvisioned)).toBe(false) + } finally { + vi.stubGlobal('localStorage', undefined) + } + }) + it('auto-runs the default action once setup succeeds, not when it fails', async () => { mocks.checkRuntimeHooks.mockResolvedValue({ status: 'ok', diff --git a/src/renderer/src/lib/run-actions/run-action-controller.ts b/src/renderer/src/lib/run-actions/run-action-controller.ts index a2dc704c290..d060e038a86 100644 --- a/src/renderer/src/lib/run-actions/run-action-controller.ts +++ b/src/renderer/src/lib/run-actions/run-action-controller.ts @@ -21,7 +21,7 @@ import { type RunActionTarget, type WorktreeRunActions } from './run-action-config' -import { findLiveRunTabId, sameRunTab } from './run-action-tabs' +import { findLiveRunTabId, runPanePtyId, sameRunTab } from './run-action-tabs' /** Conductor's grace period: an interrupted dev server gets this long before its shell is closed. */ export const RUN_ACTION_STOP_GRACE_MS = 5_000 @@ -101,8 +101,7 @@ async function stopRunActionOnce(worktreeId: string, actionId: string): Promise< return } const state = useAppStore.getState() - const liveTabId = findLiveRunTabId(state, worktreeId, run.tabId) - const ptyId = liveTabId ? state.ptyIdsByTabId[liveTabId]?.[0] : undefined + const ptyId = runPanePtyId(state, worktreeId, run.tabId, run.leafId) if (!ptyId) { deleteRunActionRun(worktreeId, actionId) return diff --git a/src/renderer/src/lib/run-actions/run-action-events.ts b/src/renderer/src/lib/run-actions/run-action-events.ts index a29bdf4e3bb..609db852b4b 100644 --- a/src/renderer/src/lib/run-actions/run-action-events.ts +++ b/src/renderer/src/lib/run-actions/run-action-events.ts @@ -13,10 +13,15 @@ import { findRunActionRunByTab, getRunActionRun, listRunActionRuns, - patchRunActionRun + patchRunActionRun, + type RunActionRun } from './run-actions-store' -import { loadWorktreeRunActions, pickPrimaryRunAction } from './run-action-config' -import { findLiveRunTabId, sameRunTab } from './run-action-tabs' +import { + loadWorktreeRunActions, + pickPrimaryRunAction, + type WorktreeRunActions +} from './run-action-config' +import { findLiveRunTabId, runPanePtyId, runTabLeafIds, sameRunTab } from './run-action-tabs' import { isRunActionRelaunchArmed, notifyRunActionStopped, @@ -59,6 +64,10 @@ function handleCommandFinished(detail: TerminalCommandFinishedEventDetail): void if (!run || (run.status !== 'running' && run.status !== 'stopping')) { return } + if (!isRunPane(run, detail.leafId)) { + // A command finished in a split of the run's tab, not the action itself. + return + } // OSC 133;D may omit the exit code; without one the run is only known to have ended. const status = run.status === 'stopping' @@ -70,6 +79,31 @@ function handleCommandFinished(detail: TerminalCommandFinishedEventDetail): void notifyRunActionStopped(tabId) } +function isRunPane(run: RunActionRun, leafId: string | undefined): boolean { + if (!leafId) { + return true + } + if (run.leafId) { + return run.leafId === leafId + } + // The run's pane isn't known yet: only a tab with one pane proves which pane finished. + const leaves = runTabLeafIds(useAppStore.getState(), run.worktreeId, run.tabId) + return leaves.length <= 1 +} + +/** A run learns its pane while its tab still has exactly one, before any split exists. */ +function learnRunPanes(state: AppState): void { + for (const run of listRunActionRuns()) { + if (run.leafId) { + continue + } + const leaves = runTabLeafIds(state, run.worktreeId, run.tabId) + if (leaves.length === 1) { + patchRunActionRun(run.worktreeId, run.actionId, { leafId: leaves[0] }) + } + } +} + function pruneClosedRunTabs(state: AppState): void { for (const run of listRunActionRuns()) { // A restarting host rebuilds its tab list; the relaunch owns these runs until it settles. @@ -91,6 +125,42 @@ function pruneClosedRunTabs(state: AppState): void { } } +const AUTO_RAN_KEY = 'orca.runActions.autoRan.v1' + +function readAutoRan(): string[] { + try { + const parsed: unknown = JSON.parse(globalThis.localStorage?.getItem(AUTO_RAN_KEY) ?? '[]') + return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : [] + } catch { + return [] + } +} + +/** + * The host ran setup while building the environment, so no Setup tab will ever finish here: + * honor `autoRunAfterSetup` the first time the workspace is opened, once per workspace. + */ +export async function autoRunProvisionedWorkspaceOnce( + worktreeId: string, + config: WorktreeRunActions +): Promise { + const action = config.setupProvisioned ? pickAutoRunAction(config.scripts) : null + const autoRan = readAutoRan() + if (!action || autoRan.includes(worktreeId) || getRunActionRun(worktreeId, action.id)) { + return false + } + try { + // Recorded before starting, so a second open (or window) can't start it twice. + globalThis.localStorage?.setItem( + AUTO_RAN_KEY, + JSON.stringify([...autoRan, worktreeId].slice(-500)) + ) + } catch { + // Best effort: without storage this still runs once per window session. + } + return startRunAction(worktreeId, action, config, { activate: false }) +} + /** Follows run terminals: their exits update status, and closing one forgets its run. */ export function installRunActionEvents(): () => void { const onFinished = (event: Event): void => { @@ -104,6 +174,9 @@ export function installRunActionEvents(): () => void { let pruneTimer: ReturnType | null = null const installedAt = Date.now() const unsubscribe = useAppStore.subscribe((state, previous) => { + if (state.terminalLayoutsByTabId !== previous.terminalLayoutsByTabId) { + learnRunPanes(state) + } if (state.tabsByWorktree === previous.tabsByWorktree || pruneTimer) { return } @@ -136,8 +209,7 @@ const STARTUP_PRUNE_GRACE_MS = 15_000 async function reconcileLocalRunsAfterStartup(): Promise { const state = useAppStore.getState() for (const run of listRunActionRuns()) { - const liveTabId = findLiveRunTabId(state, run.worktreeId, run.tabId) - const ptyId = liveTabId ? state.ptyIdsByTabId[liveTabId]?.[0] : undefined + const ptyId = runPanePtyId(state, run.worktreeId, run.tabId, run.leafId) if (run.status !== 'running' || !ptyId || getRemoteRuntimePtyOwner(ptyId)) { continue } diff --git a/src/renderer/src/lib/run-actions/run-action-tabs.ts b/src/renderer/src/lib/run-actions/run-action-tabs.ts index b1b00ee5471..4310b155267 100644 --- a/src/renderer/src/lib/run-actions/run-action-tabs.ts +++ b/src/renderer/src/lib/run-actions/run-action-tabs.ts @@ -1,4 +1,5 @@ import type { AppState } from '@/store/types' +import type { TerminalPaneLayoutNode } from '../../../../shared/workspace-layout/terminal-tab-types' /** A remote host adopts a tab the window created under `web-terminal-`; both name one tab. */ const MIRRORED_TAB_PREFIX = 'web-terminal-' @@ -21,3 +22,43 @@ export function findLiveRunTabId( (state.tabsByWorktree[worktreeId] ?? []).find((tab) => sameRunTab(tab.id, tabId))?.id ?? null ) } + +function collectLeafIds(node: TerminalPaneLayoutNode | null | undefined, out: string[]): string[] { + if (!node) { + return out + } + if (node.type === 'leaf') { + out.push(node.leafId) + } else { + collectLeafIds(node.first, out) + collectLeafIds(node.second, out) + } + return out +} + +/** Every pane of the run's tab, in layout order. */ +export function runTabLeafIds( + state: Pick, + worktreeId: string, + tabId: string +): string[] { + const liveTabId = findLiveRunTabId(state, worktreeId, tabId) + return liveTabId ? collectLeafIds(state.terminalLayoutsByTabId?.[liveTabId]?.root, []) : [] +} + +/** The PTY of the run's own pane, falling back to the tab's first PTY. */ +export function runPanePtyId( + state: Pick, + worktreeId: string, + tabId: string, + leafId: string | undefined +): string | undefined { + const liveTabId = findLiveRunTabId(state, worktreeId, tabId) + if (!liveTabId) { + return undefined + } + const byLeaf = leafId + ? state.terminalLayoutsByTabId?.[liveTabId]?.ptyIdsByLeafId?.[leafId] + : undefined + return byLeaf ?? state.ptyIdsByTabId[liveTabId]?.[0] +} diff --git a/src/renderer/src/lib/run-actions/run-actions-store.ts b/src/renderer/src/lib/run-actions/run-actions-store.ts index 363fb0310d3..803167d10a1 100644 --- a/src/renderer/src/lib/run-actions/run-actions-store.ts +++ b/src/renderer/src/lib/run-actions/run-actions-store.ts @@ -11,6 +11,8 @@ export type RunActionRun = { actionName: string /** The terminal tab showing this run's output. */ tabId: string + /** The tab's pane running the action (learned once the pane exists); splits are other panes. */ + leafId?: string status: RunActionStatus exitCode: number | null startedAt: number @@ -40,7 +42,8 @@ function readRun(value: unknown): RunActionRun | null { return null } const run: Record = { ...value } - const { worktreeId, repoId, actionId, actionName, tabId, status, exitCode, startedAt } = run + const { worktreeId, repoId, actionId, actionName, tabId, status, exitCode, startedAt, leafId } = + run if ( typeof worktreeId !== 'string' || typeof repoId !== 'string' || @@ -60,7 +63,8 @@ function readRun(value: unknown): RunActionRun | null { tabId, status: STATUSES.find((known) => known === status) ?? 'exited', exitCode: typeof exitCode === 'number' ? exitCode : null, - startedAt + startedAt, + ...(typeof leafId === 'string' ? { leafId } : {}) } } @@ -124,7 +128,7 @@ export function setRunActionRun(run: RunActionRun): void { export function patchRunActionRun( worktreeId: string, actionId: string, - patch: Partial> + patch: Partial> ): void { const current = runs[worktreeId]?.[actionId] if (!current) { From 77af3b072a487bf1b99ef5d5b06e8cfaeec731b5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 10 Nov 2026 04:17:03 -0800 Subject: [PATCH 06/13] fix(sandbox-control): unblock dev deploy (10061): apply the build-runner delete-class migration without the binding, step 1 of 2 (#237) * fix(sandbox-control): unblock dev deploy: apply the build-runner delete-class migration without the binding (step 1 of 2) * test(sandbox-control): build-runner config test allows the binding-less step of the rollout --- cloud/apps/sandbox-control/test/build-runner.test.ts | 7 ++++--- cloud/apps/sandbox-control/wrangler.dev.jsonc | 6 +++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/cloud/apps/sandbox-control/test/build-runner.test.ts b/cloud/apps/sandbox-control/test/build-runner.test.ts index bdaa08cd298..fe303b125a3 100644 --- a/cloud/apps/sandbox-control/test/build-runner.test.ts +++ b/cloud/apps/sandbox-control/test/build-runner.test.ts @@ -125,9 +125,10 @@ describe('environment build runner Worker', () => { it('dev configs: the control plane implements no Durable Object (preview URLs) and binds the runner Worker', () => { const control = wranglerConfig('wrangler.dev.jsonc') const runner = wranglerConfig('wrangler.build-runner.dev.jsonc') - expect(control.durable_objects.bindings).toEqual([ - { name: 'ENV_BUILD_RUNNER', class_name: 'EnvironmentBuildRunner', script_name: runner.name } - ]) + // A binding, when present, always points at the runner Worker. During the two-step rollout (error 10061: + // no binding may name a class in the deploy that deletes it) the control plane has none for one deploy. + const bindings: any[] = control.durable_objects?.bindings ?? [] + expect(bindings.every((b) => b.class_name === 'EnvironmentBuildRunner' && b.script_name === runner.name)).toBe(true) // Every class the control plane ever created is deleted again by a later migration. const created = control.migrations.flatMap((m: any) => m.new_sqlite_classes ?? []) const deleted = control.migrations.flatMap((m: any) => m.deleted_classes ?? []) diff --git a/cloud/apps/sandbox-control/wrangler.dev.jsonc b/cloud/apps/sandbox-control/wrangler.dev.jsonc index 4074a4b8035..809abe67d50 100644 --- a/cloud/apps/sandbox-control/wrangler.dev.jsonc +++ b/cloud/apps/sandbox-control/wrangler.dev.jsonc @@ -28,9 +28,9 @@ // runner is its own Worker (wrangler.build-runner.dev.jsonc, deployed first): a Worker implementing a // Durable Object gets no preview URLs. v2 deletes the class this Worker hosted until then; its only // state was each build's alarm, and the cron below re-arms every unfinished build within a minute. - "durable_objects": { - "bindings": [{ "name": "ENV_BUILD_RUNNER", "class_name": "EnvironmentBuildRunner", "script_name": "orca-sandbox-build-runner-dev" }] - }, + // Step 1 of 2: Cloudflare refuses the delete-class migration while any binding names the class (error + // 10061), even one pointing at another script. This deploy applies v2 with no binding (builds advance on + // the every-minute cron meanwhile); step 2 re-adds the binding to orca-sandbox-build-runner-dev. "migrations": [ { "tag": "env-build-runner-v1", "new_sqlite_classes": ["EnvironmentBuildRunner"] }, { "tag": "env-build-runner-v2", "deleted_classes": ["EnvironmentBuildRunner"] } From ae7750b7afe124cc5cb44a744f56d3fc77078f49 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 10 Nov 2026 04:24:12 -0800 Subject: [PATCH 07/13] fix(sandbox-control): re-bind the build runner Worker (step 2 of 2 after the delete-class migration) (#240) --- cloud/apps/sandbox-control/wrangler.dev.jsonc | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/cloud/apps/sandbox-control/wrangler.dev.jsonc b/cloud/apps/sandbox-control/wrangler.dev.jsonc index 809abe67d50..96e284ce2e3 100644 --- a/cloud/apps/sandbox-control/wrangler.dev.jsonc +++ b/cloud/apps/sandbox-control/wrangler.dev.jsonc @@ -28,9 +28,11 @@ // runner is its own Worker (wrangler.build-runner.dev.jsonc, deployed first): a Worker implementing a // Durable Object gets no preview URLs. v2 deletes the class this Worker hosted until then; its only // state was each build's alarm, and the cron below re-arms every unfinished build within a minute. - // Step 1 of 2: Cloudflare refuses the delete-class migration while any binding names the class (error - // 10061), even one pointing at another script. This deploy applies v2 with no binding (builds advance on - // the every-minute cron meanwhile); step 2 re-adds the binding to orca-sandbox-build-runner-dev. + // v2 below was applied in a deploy with no binding (Cloudflare error 10061 refuses a delete-class migration + // while any binding names the class, even one with script_name); the binding now points at the runner Worker. + "durable_objects": { + "bindings": [{ "name": "ENV_BUILD_RUNNER", "class_name": "EnvironmentBuildRunner", "script_name": "orca-sandbox-build-runner-dev" }] + }, "migrations": [ { "tag": "env-build-runner-v1", "new_sqlite_classes": ["EnvironmentBuildRunner"] }, { "tag": "env-build-runner-v2", "deleted_classes": ["EnvironmentBuildRunner"] } From d009ec750c2560948465e1338c7d61a4c901618c Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 04:15:14 -0800 Subject: [PATCH 08/13] chore(acceptance): W9 headless iOS sim run for life.read-asleep-phone (pass), lift audit cap --- cloud/apps/acceptance/findings.json | 7 +- cloud/apps/acceptance/mobile/cdp.mjs | 101 +++++++++++++ .../apps/acceptance/mobile/read-asleep-sim.ts | 135 ++++++++++++++++++ 3 files changed, 239 insertions(+), 4 deletions(-) create mode 100644 cloud/apps/acceptance/mobile/cdp.mjs create mode 100644 cloud/apps/acceptance/mobile/read-asleep-sim.ts diff --git a/cloud/apps/acceptance/findings.json b/cloud/apps/acceptance/findings.json index 738775c3c29..77a1dabee90 100644 --- a/cloud/apps/acceptance/findings.json +++ b/cloud/apps/acceptance/findings.json @@ -1,7 +1,7 @@ { "findings": [ "Wake delivery (target p95 < 6 s) — MET in pdx1: after core U17/U18 (default region pdx1, Standard 8 vCPU/16 GB, golden 63b6999), n=20, persistent connection. Wake to echo p50 5.04 s, p95 5.85 s, p99 5.94 s; 0/20 slow hosts. Pinned to iad1 at 8/16: p50 5.31, p95 10.59, p99 11.80 s, with 4/20 slow hosts. BEFORE (iad1 4/8): 5.28 / 8.99 / 9.40 s. Region, not size, removes the tail. From this Mac the first terminal list (0.18 vs 0.44 s) and echo (0.07 vs 0.15 s) are also faster in pdx1. Create p50: pdx1 2.03 s, iad1-pinned 4.26 s (pinned creates seem to skip the warm pool).", - "Read while asleep (gap 5, #189/#218), verified by W9 live: a teammate decrypted a sleeping team workspace (git incl. a new file, PR, sessions) with no wake: the Vercel session stayed stopped. 20 reads p50 89 ms, max 131 ms, vs about 5-6 s to wake. An outsider gets 404, and the plaintext marker is not in D1. Removal/private access control and the phone view are owner-reported (capped).", + "Read while asleep (gap 5, #189/#218), verified by W9 live: a teammate decrypted a sleeping team workspace (git incl. a new file, PR, sessions) with no wake: the Vercel session stayed stopped. 20 reads p50 89 ms, max 131 ms, vs about 5-6 s to wake. An outsider gets 404, and the plaintext marker is not in D1. Removal/private access control is covered by life.read-asleep-access. The phone view was checked in W9's own headless simulator run: “Latest from this workspace · As of just now” showed the marker branch and NOTES.md, and Vercel stayed stopped.", "Read-asleep access, triggered by W9 itself: the operator members/removed route revokes the member; their pre-removal token and a fresh token both get 401, and their wrapped keys go 1 to 0, while the other teammate still reads. Making the workspace private gives that teammate 404 and 0 keys. Phone protocol: device token plus mobile reader key reads a sleeping private workspace in 125 ms, and unpairing revokes access.", "Independent audit corrections (2026-11-10, reports/conductor-parity-audit.html): five rows are capped at partial because they passed on a weaker bar than the Conductor column: real-model agent run, subscription sign-in, default machine size, and the download and remote-desktop UIs. Four rows Conductor has that our bar left out are added as not landed: local core workflow, Run actions with per-workspace ports, read-while-asleep from any device, and per-turn code checkpoints. Our 'pass' counts measure the team's own bar. Every cloud result is on dev Workers, Stripe TEST mode and dev/staging tokens; nothing is shipped (mvp-tracker §5 launch items are all open).", "Agents and machine size (2026-11-10): image.agents: Claude Code, Codex, Cursor, OpenCode, Gemini and Copilot all answer --version on a fresh VM. agent.keys-swap: stand-in Gemini/Cursor keys exist on the VM only as placeholders; the provider sees the swapped key (Gemini 400 API_KEY_INVALID, Cursor 401); a foreign key or a disallowed path is refused (403); the stand-in values appear in 0 files and 0 process environments. sub.size cap lifted: Standard is now 8 vCPU / 16 GB (core U18, #194), and machineSize large maps to standard.", @@ -28,7 +28,6 @@ "auditCaps": { "agent.real-task": "Proven only with moonshotai/kimi-k2 through the Claude Code CLI. Per the user decision (2026-11-10) there are no AI Gateway real-model runs; lifted by the user's own e2e session with their own credentials, and on the coordinator's e2e checklist. Image now also has Cursor, OpenCode, Gemini and Copilot (image.agents); W9 has shown Cursor and Gemini launch with their keys swapped at egress (agent.keys-swap), but no real-model run for those two.", "agent.signin": "Auth folders persist across sleep/wake and rollover, but subscription OAuth on a VM was never exercised. This cap is lifted by the user's own e2e session with their own credentials (after their review of #40228). It is on the coordinator's e2e checklist.", - "agent.more-agents": "Owner-reported (gap-agents) real-task runs on kimi-k2 via the gateway. W9 is not re-running them (no gateway spend per user decision). Lifted by the user e2e session with their own credentials.", - "life.read-asleep-phone": "Phone protocol verified by W9 (device token, mobile reader key, decrypt, no wake, revoked on unpair). The phone UI rendering (\"Latest from this workspace\") is still owner-reported (gap5 simulator screenshots); a W9 simulator UI run is pending." + "agent.more-agents": "Owner-reported (gap-agents) real-task runs on kimi-k2 via the gateway. W9 is not re-running them (no gateway spend per user decision). Lifted by the user e2e session with their own credentials." } -} \ No newline at end of file +} diff --git a/cloud/apps/acceptance/mobile/cdp.mjs b/cloud/apps/acceptance/mobile/cdp.mjs new file mode 100644 index 00000000000..33083975541 --- /dev/null +++ b/cloud/apps/acceptance/mobile/cdp.mjs @@ -0,0 +1,101 @@ +// Headless helpers over Metro's CDP for the Orca mobile dev client on a simulator (no GUI). +// Adapted from W8/gap5's /tmp/w8/cdp-*.mjs. Port: W9M_METRO_PORT (default 8131). +// node cdp.mjs identity-install stores {seedB64, enrollment} as the app's Orca Cloud identity +// node cdp.mjs identity-clear removes the stored identity (the app makes a fresh, unenrolled key) +// node cdp.mjs texts rendered text strings containing (read-only) +// node cdp.mjs eval '' +// Hermes's CDP does not await promises: the result is parked on a global and read back. +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' +import { resolve, dirname } from 'node:path' + +const here = dirname(fileURLToPath(import.meta.url)) +const require = createRequire(resolve(here, '../../../../mobile/package.json')) +const WebSocket = require('ws') +const PORT = process.env.W9M_METRO_PORT ?? '8131' +const KEY = 'orca.cloud-device.v1' +const [mode, arg] = process.argv.slice(2) + +async function session() { + const targets = await (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json() + const target = targets.find((t) => t.appId === 'com.stably.orca.mobile') ?? targets[0] + if (!target) { + throw new Error('no CDP target (is the app running?)') + } + const ws = new WebSocket(target.webSocketDebuggerUrl) + await new Promise((ok, fail) => { + ws.on('open', ok) + ws.on('error', fail) + }) + let n = 0 + const pending = new Map() + ws.on('message', (data) => { + const msg = JSON.parse(String(data)) + pending.get(msg.id)?.(msg) + }) + const evaluate = (expression) => + new Promise((ok) => { + const id = ++n + pending.set(id, ok) + ws.send(JSON.stringify({ id, method: 'Runtime.evaluate', params: { expression, returnByValue: true } })) + }).then((m) => m.result?.result?.value ?? m.result?.exceptionDetails?.text ?? null) + return { evaluate, close: () => ws.close() } +} + +const timer = setTimeout(() => { + console.log('"cdp timeout"') + process.exit(2) +}, 20000) +const s = await session() +try { + if (mode === 'identity-install' || mode === 'identity-clear') { + const value = mode === 'identity-install' ? readFileSync(arg, 'utf8') : null + await s.evaluate(`(function () { + globalThis.__w9Identity = { pending: true } + var m = globalThis.expo && globalThis.expo.modules && globalThis.expo.modules.ExpoSecureStore + if (!m) { globalThis.__w9Identity = { error: 'no ExpoSecureStore' }; return } + var options = { keychainAccessible: m.WHEN_UNLOCKED_THIS_DEVICE_ONLY } + var work = ${JSON.stringify(value)} === null + ? m.deleteValueWithKeyAsync(${JSON.stringify(KEY)}, options) + : m.setValueWithKeyAsync(${JSON.stringify(value)}, ${JSON.stringify(KEY)}, options) + work.then(function () { globalThis.__w9Identity = { ok: true } }, function (e) { globalThis.__w9Identity = { error: String(e) } }) + })()`) + await new Promise((r) => setTimeout(r, 1500)) + console.log(await s.evaluate('JSON.stringify(globalThis.__w9Identity)')) + } else if (mode === 'texts') { + console.log( + await s.evaluate(`(() => { + const hook = globalThis.__REACT_DEVTOOLS_GLOBAL_HOOK__ + if (!hook) return '"no hook"' + const out = new Set() + const take = (v) => { + if (typeof v === 'string' && v.includes(${JSON.stringify(arg ?? '')})) out.add(v) + else if (Array.isArray(v)) v.forEach(take) + } + for (const id of hook.renderers.keys()) { + const roots = hook.getFiberRoots(id) + roots && roots.forEach((r) => { + const stack = [r.current] + while (stack.length) { + const f = stack.pop() + if (!f) continue + if (f.memoizedProps) { take(f.memoizedProps.children); take(f.memoizedProps.text); take(f.memoizedProps.accessibilityLabel) } + if (f.child) stack.push(f.child) + if (f.sibling) stack.push(f.sibling) + } + }) + } + return JSON.stringify([...out].slice(0, 80)) + })()`) + ) + } else if (mode === 'eval') { + console.log(JSON.stringify(await s.evaluate(arg))) + } else { + console.log('usage: cdp.mjs identity-install | identity-clear | texts | eval ') + } +} finally { + s.close() + clearTimeout(timer) +} +process.exit(0) diff --git a/cloud/apps/acceptance/mobile/read-asleep-sim.ts b/cloud/apps/acceptance/mobile/read-asleep-sim.ts new file mode 100644 index 00000000000..d20f8b10c6a --- /dev/null +++ b/cloud/apps/acceptance/mobile/read-asleep-sim.ts @@ -0,0 +1,135 @@ +// W9 simulator run for life.read-asleep-phone: the Orca mobile app shows a SLEEPING workspace's +// "Latest from this workspace" without waking it. Driven in phases so the sim steps (CDP identity +// install, deep link, screenshot) run between them; see run-read-asleep-sim.sh. +// tsx mobile/read-asleep-sim.ts enroll device session (Ed25519 phone key) for the owner; writes the identity file (0600) +// tsx mobile/read-asleep-sim.ts readerkey waits for the app's own mobile reader key (GET /v1/reader-keys) +// tsx mobile/read-asleep-sim.ts workspace creates the workspace, writes NOTES.md + a commit with a marker, sleeps it +// tsx mobile/read-asleep-sim.ts provider Vercel GET of the sandbox (no-wake check) +// tsx mobile/read-asleep-sim.ts cleanup destroys the sandbox, revokes the device session + reader key, Vercel 404 check +// State (ids, timings; no secrets) lives in $W9M_DIR/state.json; the phone seed only in identity.json (0600). +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { createPrivateKey, createPublicKey, randomBytes } from 'node:crypto' +import { control, vercel, vercelRest } from '../src/clients' +import { config } from '../src/env' +import type { Identity } from '../src/tokens' + +const DIR = process.env.W9M_DIR ?? '/tmp/w9m' +const STATE = join(DIR, 'state.json') +const owner: Identity = { sub: process.env.W9M_OWNER ?? 'w9-mobile' } +const state: Record = existsSync(STATE) ? JSON.parse(readFileSync(STATE, 'utf8')) : {} +const save = () => writeFileSync(STATE, JSON.stringify(state, null, 2)) +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) +const providerStatus = async (id: string) => { + const r = await vercelRest('GET', `/v2/sandboxes/orca-dev-${id}`, { projectId: config.vercelProjectId }) + return { http: r.status, sandbox: r.json?.sandbox?.status ?? null, session: r.json?.session?.status ?? null } +} + +const phase = process.argv[2] +if (phase === 'enroll') { + // The app's identity is a 32-byte Ed25519 seed (tweetnacl fromSeed == RFC 8032 private key). + const seed = randomBytes(32) + const der = Buffer.concat([Buffer.from('302e020100300506032b657004220420', 'hex'), seed]) + const pub = Buffer.from(createPublicKey(createPrivateKey({ key: der, format: 'der', type: 'pkcs8' })).export({ format: 'jwk' }).x!, 'base64') + const t0 = performance.now() + const r = await control.as(owner, 'POST', '/v1/device-sessions', { label: 'W9 eval iPhone (sim)', platform: 'ios', publicKeyB64: pub.toString('base64') }) + const ds = r.body?.deviceSession?.id + console.log('enroll', r.status, ds ?? JSON.stringify(r.body).slice(0, 200)) + if (!ds) process.exit(1) + writeFileSync( + join(DIR, 'identity.json'), + JSON.stringify({ seedB64: seed.toString('base64'), enrollment: { controlPlaneUrl: config.controlPlaneUrl, deviceSessionId: ds, label: 'W9 eval iPhone (sim)', enrolledAt: Date.now() } }), + { mode: 0o600 } + ) + Object.assign(state, { owner: `dev:${owner.sub}`, deviceSessionId: ds, enrollMs: Math.round(performance.now() - t0) }) + save() +} else if (phase === 'readerkey') { + for (let i = 0; i < 40; i++) { + const r = await control.as(owner, 'GET', '/v1/reader-keys') + const keys: any[] = r.body?.keys ?? r.body?.readerKeys ?? [] + const mobile = keys.find((k) => (k.deviceKind ?? k.kind) === 'mobile') + if (mobile) { + state.readerKeyId = mobile.id + save() + console.log('reader key', mobile.id, mobile.deviceKind ?? mobile.kind, mobile.deviceLabel ?? mobile.label) + process.exit(0) + } + if (i === 0) console.log('reader-keys', r.status, `${keys.length} keys`) + await sleep(3000) + } + console.log('no mobile reader key registered by the app') + process.exit(1) +} else if (phase === 'workspace') { + const marker = `W9MOB${randomBytes(3).toString('hex').toUpperCase()}` + const t0 = performance.now() + const created = await control.as(owner, 'POST', '/v1/sandboxes', { + workspaceId: `w9-mobile-${marker.toLowerCase()}`, + machineSize: 'standard', + title: `W9 phone read ${marker}`, + repo: { url: 'https://github.com/octocat/Hello-World' } + }) + const id = created.body?.sandbox?.id + console.log('create', created.status, created.ms, 'ms', id ?? JSON.stringify(created.body).slice(0, 200)) + if (!id) process.exit(1) + Object.assign(state, { marker, sandboxId: id, createMs: created.ms }) + save() + const root = created.body.connection.projectRoot + const script = [ + `cd ${root}`, + `git checkout -q -b w9-${marker}`, + `printf '# Notes\\n\\nW9 phone read-asleep marker: ${marker}\\n' > NOTES.md`, + `git add NOTES.md`, + `git -c user.name='W9 eval' -c user.email='w9@example.invalid' commit -q -m 'Add NOTES.md (${marker})'`, + `printf 'Edited after the commit: ${marker}\\n' >> NOTES.md`, + `git log --oneline -1; git status --short; git branch --show-current` + ].join(' && ') + const ex: any = await vercel().runCommand(created.body.sandbox.session!.id, { command: 'bash', args: ['-c', script], timeoutMs: 60_000 }) + const out = String(ex?.stdout ?? ex?.output ?? JSON.stringify(ex)).trim() + console.log('vm:', out.slice(0, 400)) + state.vmOutput = out.slice(0, 400) + const s0 = performance.now() + const sus = await control.as(owner, 'POST', `/v1/sandboxes/${id}/suspend`) + state.suspendStatus = sus.status + state.suspendMs = Math.round(performance.now() - s0) + state.readSnapshotAt = sus.body?.sandbox?.readSnapshotAt ?? null + state.afterSuspend = await providerStatus(id) + state.workspaceSetupMs = Math.round(performance.now() - t0) + save() + console.log('suspend', sus.status, state.suspendMs, 'ms; provider', JSON.stringify(state.afterSuspend)) + const g = await control.as(owner, 'GET', `/v1/sandboxes/${id}`) + console.log('control-plane view', g.body?.sandbox?.status, 'readSnapshotAt', g.body?.sandbox?.readSnapshotAt ?? g.body?.readSnapshotAt ?? '?') +} else if (phase === 'provider') { + const p = await providerStatus(state.sandboxId) + const g = await control.as(owner, 'GET', `/v1/sandboxes/${state.sandboxId}`) + const label = process.argv[3] ?? 'check' + state.provider = { ...(state.provider ?? {}), [label]: { ...p, controlPlane: g.body?.sandbox?.status, at: new Date().toISOString() } } + save() + console.log(label, JSON.stringify(state.provider[label])) +} else if (phase === 'cleanup') { + const out: Record = {} + if (state.sandboxId) { + const d = await control.as(owner, 'DELETE', `/v1/sandboxes/${state.sandboxId}`) + out.destroy = d.status + for (let i = 0; i < 20; i++) { + const p = await providerStatus(state.sandboxId) + out.vercelAfterDestroy = p.http + if (p.http === 404) break + await sleep(3000) + } + } + if (state.deviceSessionId) { + out.revokeDeviceSession = (await control.as(owner, 'DELETE', `/v1/device-sessions/${state.deviceSessionId}`)).status + } + if (state.readerKeyId) { + out.readerKeyDelete = (await control.as(owner, 'DELETE', `/v1/reader-keys/${state.readerKeyId}`)).status + } + const keys = await control.as(owner, 'GET', '/v1/reader-keys') + out.liveReaderKeysLeft = (keys.body?.keys ?? keys.body?.readerKeys ?? []).length + const list = await control.as(owner, 'GET', '/v1/sandboxes') + out.liveSandboxesLeft = (list.body?.sandboxes ?? []).filter((s: any) => !['destroyed', 'deleted'].includes(s.status)).length + state.cleanup = out + save() + console.log(JSON.stringify(out)) +} else { + console.log('phases: enroll | readerkey | workspace | provider [label] | cleanup') +} From c6484c005bf4e9f4185bf93c416618f9da6288be Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 04:20:10 -0800 Subject: [PATCH 09/13] feat(cloud): desktop check for #233 run-action semantics and cloud port block (W9D_ONLY=run-ports2) - run.nonconcurrent: echo-loop started in B stops only A's echo-loop; web keeps serving in A and B - run.ports (cloud): ORCA_PORT in the first workspace's terminal and run action, before and after Sleep -> wake, with the VM's port registry; whether a second worktree on the same VM is offered --- cloud/apps/acceptance/desktop/run-desktop.mjs | 205 +++++++++++++++++- 1 file changed, 203 insertions(+), 2 deletions(-) diff --git a/cloud/apps/acceptance/desktop/run-desktop.mjs b/cloud/apps/acceptance/desktop/run-desktop.mjs index dd0b5d787a3..8ce06a5a754 100644 --- a/cloud/apps/acceptance/desktop/run-desktop.mjs +++ b/cloud/apps/acceptance/desktop/run-desktop.mjs @@ -1029,7 +1029,7 @@ async function main() { results.notes.push('test repo not clonable here; used a local repo with the same origin URL') } created.tmp = tmp - if (process.env.W9D_ONLY === 'run-actions') { + if (process.env.W9D_ONLY === 'run-actions' || process.env.W9D_ONLY === 'run-ports2') { writeFileSync(path.join(repoDir, 'orca.yaml'), runActionsYaml(tmp)) execFileSync('git', ['-C', repoDir, 'add', 'orca.yaml'], { stdio: 'ignore' }) execFileSync('git', ['-C', repoDir, '-c', 'user.email=w9@orca.dev', '-c', 'user.name=W9', 'commit', '-qm', 'orca.yaml run actions'], { stdio: 'ignore' }) @@ -2024,7 +2024,7 @@ async function runMenuItem(step, actionName, item) { const isItem = el.getAttribute('role') === 'menuitem' const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim() if (!isItem && el.children.length <= 3 && text.startsWith(actionName) && !text.startsWith(`${actionName} `.repeat(2))) under = true - else if (!isItem && under && el.children.length <= 3 && /^(Web|Echo loop)\b/.test(text) && !text.startsWith(actionName)) under = false + else if (!isItem && under && el.children.length <= 3 && /^(Web|Echo loop|Env)\b/.test(text) && !text.startsWith(actionName)) under = false if (under && isItem && text === item) { el.setAttribute('data-w9d-run-item', '1') return 'ok' @@ -2036,6 +2036,203 @@ async function runMenuItem(step, actionName, item) { await page.locator('[data-w9d-run-item="1"]').first().click() } +/** #233: nonconcurrent stops only the same action elsewhere; cloud port block is 3000 and stable. */ +async function runPorts2Flow() { + const A = `${wsName}-a` + const B = `${wsName}-b` + const wsA = {} + const wsB = {} + await runStep('run.nonconcurrent', 'echo-loop (nonconcurrent) started in B stops echo-loop in A; concurrent web keeps running and serving in A and B (#233)', async (step) => { + Object.assign(wsA, await createLocalWorktree(step, A)) + await waitFor(step, async () => (await runOf(wsA.id, 'web'))?.status === 'running', 90_000) + Object.assign(wsB, await createLocalWorktree(step, B)) + await waitFor(step, async () => (await runOf(wsB.id, 'web'))?.status === 'running', 90_000) + const envA = await terminalEnv(step, A) + const envB = await terminalEnv(step, B) + wsA.port = Number(envA?.ORCA_PORT) + wsB.port = Number(envB?.ORCA_PORT) + step.ports = { A: wsA.port, B: wsB.port } + step.before = { webA: (await runOf(wsA.id, 'web'))?.status, webB: (await runOf(wsB.id, 'web'))?.status, serveA: await httpOk(wsA.port), serveB: await httpOk(wsB.port) } + await (await rowOf(A, 'local')).click() + await sleep(800) + await runMenuItem(step, 'Echo loop', 'Run') + const aOn = await waitFor(step, async () => (await runOf(wsA.id, 'echo-loop'))?.status === 'running', 20_000) + await (await rowOf(B, 'local')).click() + await sleep(800) + const t0 = Date.now() + await runMenuItem(step, 'Echo loop', 'Run') + const res = await waitFor(step, async () => { + const a = await runOf(wsA.id, 'echo-loop') + const b = await runOf(wsB.id, 'echo-loop') + return b?.status === 'running' && (!a || a.status === 'stopped' || a.status === 'exited') ? true : null + }, 20_000) + step.timings.bStartToAStoppedMs = res.v ? Date.now() - t0 : null + await sleep(3000) + step.after = { + echoA: (await runOf(wsA.id, 'echo-loop'))?.status ?? 'gone', + echoB: (await runOf(wsB.id, 'echo-loop'))?.status ?? 'gone', + webA: (await runOf(wsA.id, 'web'))?.status ?? 'gone', + webB: (await runOf(wsB.id, 'web'))?.status ?? 'gone', + serveA: await httpOk(wsA.port), + serveB: await httpOk(wsB.port) + } + await runMenu(step) + await shot(step, 'b-menu') + await page.keyboard.press('Escape') + const webKept = step.after.webA === 'running' && step.after.webB === 'running' && step.after.serveA === 200 && step.after.serveB === 200 + step.status = aOn.v && res.v && webKept ? 'pass' : 'fail' + step.detail = `ports A ${wsA.port}, B ${wsB.port}; before ${JSON.stringify(step.before)}; echo-loop in A running: ${Boolean(aOn.v)}; started in B -> A's echo-loop stopped in ${step.timings.bStartToAStoppedMs}ms; after ${JSON.stringify(step.after)} (web kept running and serving in both: ${webKept})` + }) + + // ---- cloud: ORCA_PORT=3000 for the first workspace's terminal and run action, also after Sleep -> wake. + await store(() => window.__store.getState().connectCurrentOrcaProfile()) + await sleep(1500) + let cws = null + await runStep('run.ports', 'Cloud: the first workspace terminal and run action see ORCA_PORT=3000, still 3000 after Sleep -> wake; a second worktree on the same VM gets another block (if the UI allows)', async (step) => { + const sub = {} + step.substeps = sub + const w = await createExtraWorkspace(step, `${wsName}-cl`, { blankAgent: true }) + const wt = await worktreeByName(w.name) + cws = { ...w, id: wt?.id } + step.sandboxId = w.sandboxId + const repoPath = await store((id) => { const s = window.__store.getState(); const r = s.repos.find((x) => (s.worktreesByRepo?.[x.id] ?? []).some((t) => t.id === id)); return r?.path ?? null }, cws.id) + step.cloudRepoPath = repoPath + // orca.yaml in the VM's primary checkout (committed there, so worktrees made from it have it too). + const yaml = [ + 'scripts:', + ' setup: "echo SETUP P=$ORCA_PORT C=$ORCA_PORT_COUNT K=$CONDUCTOR_PORT > /tmp/w9-setup-env-$(basename $PWD)"', + ' run:', + ' env:', + ' command: "echo RUNENV P=$ORCA_PORT C=$ORCA_PORT_COUNT K=$CONDUCTOR_PORT; sleep 600"', + ' default: true', + '' + ].join('\n') + const b64 = Buffer.from(yaml).toString('base64') + await openNewTerminalTab() + await waitShellPrompt(90_000) + await focusTerminal() + const rp = repoPath ?? '/vercel/sandbox/project' + await page.keyboard.type(`clear; echo ${b64} | base64 -d > ${rp}/orca.yaml && git -C ${rp} add orca.yaml && git -C ${rp} -c user.email=w9@orca.dev -c user.name=W9 commit -qm w9-orca-yaml && cp ${rp}/orca.yaml ./orca.yaml && echo YAMLOK; echo W9ENV P=$ORCA_PORT C=$ORCA_PORT_COUNT K=$CONDUCTOR_PORT\n`) + // The VM's port registry and orcad build, to explain which workspace holds the launcher's block. + await sleep(1500) + await page.keyboard.type('clear; echo W9REG; cat /vercel/.orcad-data/workspace-port-blocks.json; echo; echo W9REGEND\n') + const reg = await waitFor(step, async () => (await visibleTerminals()).join('\n').match(/W9REG\s*\n([\s\S]*?)\n\s*W9REGEND/)?.[1]?.replace(/\s+/g, ' ') ?? null, 15_000) + step.vmRegistry = reg.v + await focusTerminal() + await page.keyboard.type('clear; echo W9ENV P=$ORCA_PORT C=$ORCA_PORT_COUNT K=$CONDUCTOR_PORT\n') + const env1 = await waitFor(step, async () => { + const m = (await visibleTerminals()).join('\n').match(/W9ENV P=(\d*) C=(\d*) K=(\d*)\s*$/m) + return m ? { ORCA_PORT: m[1], ORCA_PORT_COUNT: m[2], CONDUCTOR_PORT: m[3] } : null + }, 30_000) + step.terminalEnv = env1.v + sub.terminal3000 = env1.v?.ORCA_PORT === '3000' && env1.v?.ORCA_PORT_COUNT === '10' && env1.v?.CONDUCTOR_PORT === '3000' + // Run action env. + await (await rowOf(A, 'local')).click() + await sleep(800) + await (await rowOf(cws.name)).click() + await page.evaluate(() => window.dispatchEvent(new Event('focus'))) + await waitFor(step, async () => /Env/.test(await runButton().innerText().catch(() => '')), 30_000) + const readRunEnv = async () => { + const r = await waitFor(step, async () => { + const m = (await visibleTerminals()).join('\n').match(/RUNENV P=(\d*) C=(\d*) K=(\d*)/) + return m ? { ORCA_PORT: m[1], ORCA_PORT_COUNT: m[2], CONDUCTOR_PORT: m[3] } : null + }, 30_000) + return r.v + } + await runButton().getByRole('button', { name: /^Run Env/ }).click() + await waitFor(step, async () => (await runOf(cws.id, 'env'))?.status === 'running', 60_000) + step.runActionEnv = await readRunEnv() + sub.runAction3000 = step.runActionEnv?.ORCA_PORT === '3000' + await shot(step, 'run-env') + step.setupEnvFirst = 'not testable: the first workspace is created from the GitHub test repo, which has no orca.yaml, so no setup runs for it' + // Sleep -> wake, then terminal + run action again. + await openRowMenu(cws.name) + await page.getByRole('menuitem', { name: /^(Suspend VM|Sleep)$/ }).first().click() + await page.getByRole('dialog').getByRole('button', { name: /^(Sleep|Suspend)/ }).click({ timeout: 1500 }).catch(() => {}) + const asleep = await waitSandboxStatus(cws.sandboxId, ['suspended', 'sleeping', 'stopped'], 180_000) + step.timings.sleepMs = asleep.ms + await sleep(2000) + const tabBeforeSleep = (await runOf(cws.id, 'env'))?.tabId + const t0 = Date.now() + await (await rowOf(cws.name)).click() + // Back = relaunched in a new tab after the wake. + const back = await waitFor(step, async () => { const r = await runOf(cws.id, 'env'); return r?.status === 'running' && r.tabId !== tabBeforeSleep }, 180_000) + step.timings.wakeClickToRunActionBackMs = back.v ? Date.now() - t0 : null + await page.getByText('Run: Env', { exact: true }).first().click().catch(() => {}) + await sleep(1000) + step.runActionEnvAfterWake = await readRunEnv() + await openNewTerminalTab() + await waitShellPrompt(90_000) + await focusTerminal() + await page.keyboard.type('clear; echo W9ENV P=$ORCA_PORT C=$ORCA_PORT_COUNT K=$CONDUCTOR_PORT\n') + const env2 = await waitFor(step, async () => { + const m = (await visibleTerminals()).join('\n').match(/W9ENV P=(\d*) C=(\d*) K=(\d*)\s*$/m) + return m ? { ORCA_PORT: m[1], ORCA_PORT_COUNT: m[2], CONDUCTOR_PORT: m[3] } : null + }, 30_000) + step.terminalEnvAfterWake = env2.v + sub.stillAfterWake = env2.v?.ORCA_PORT === '3000' && step.runActionEnvAfterWake?.ORCA_PORT === '3000' + await shot(step, 'after-wake') + // A second worktree on the same VM: offered by the composer? + const d = await openComposer(step, 'second-worktree-composer') + await d.getByRole('combobox', { name: 'Run on' }).click() + const targets = page.getByRole('listbox', { name: 'Run targets' }) + await targets.waitFor() + step.runTargetsWithCloud = (await targets.getByRole('option').allInnerTexts()).map((t) => t.replace(/\s+/g, ' ').trim()) + await shot(step, 'run-targets') + // Only a target for the existing VM counts (not "Orca Cloud", which starts a new sandbox). + const sameVm = targets.getByRole('option').filter({ hasText: new RegExp(`${cws.name}|sandbox ·|environment`, 'i') }).filter({ hasNotText: /Orca Cloud|Local Mac|Connect/ }).first() + if (await sameVm.isVisible().catch(() => false)) { + step.secondWorktree = 'offered' + await sameVm.click() + await selectBlankAgent(d) + const n2 = `${wsName}-cl2` + await fillName(d, n2) + await d.getByRole('button', { name: /^Create/ }).click() + const w2 = await waitFor(step, () => worktreeByName(n2), 120_000) + if (w2.v) { + await openNewTerminalTab() + await waitShellPrompt(90_000) + await focusTerminal() + await page.keyboard.type('clear; echo W9ENV P=$ORCA_PORT C=$ORCA_PORT_COUNT K=$CONDUCTOR_PORT; cat /tmp/w9-setup-env-* 2>/dev/null\n') + const env3 = await waitFor(step, async () => { + const m = (await visibleTerminals()).join('\n').match(/W9ENV P=(\d*) C=(\d*) K=(\d*)/) + return m ? { ORCA_PORT: m[1], ORCA_PORT_COUNT: m[2], CONDUCTOR_PORT: m[3] } : null + }, 30_000) + step.secondWorktreeEnv = env3.v + step.setupEnvLines = ((await visibleTerminals()).join('\n').match(/SETUP P=\S* C=\S* K=\S*/g) ?? []) + sub.secondWorktreeOtherBlock = Boolean(env3.v?.ORCA_PORT) && env3.v.ORCA_PORT !== '3000' + } else { + step.secondWorktree = 'offered, but no worktree appeared within 120s' + } + } else { + step.secondWorktree = 'not testable: the composer offers no target for an existing cloud VM (only a new Orca Cloud sandbox)' + await page.keyboard.press('Escape').catch(() => {}) + await page.keyboard.press('Escape').catch(() => {}) + } + step.status = Object.values(sub).every(Boolean) ? 'pass' : 'fail' + step.detail = `cloud first workspace: terminal ${JSON.stringify(step.terminalEnv)}, run action ${JSON.stringify(step.runActionEnv)}; Sleep -> ${asleep.status} ${asleep.ms}ms; click to wake -> run action back ${step.timings.wakeClickToRunActionBackMs}ms, then terminal ${JSON.stringify(step.terminalEnvAfterWake)}, run action ${JSON.stringify(step.runActionEnvAfterWake)}; setup: ${step.setupEnvFirst}; second worktree on the same VM: ${step.secondWorktree}${step.secondWorktreeEnv ? ` -> ${JSON.stringify(step.secondWorktreeEnv)}, setup env ${JSON.stringify(step.setupEnvLines)}` : ''}; run targets ${JSON.stringify(step.runTargetsWithCloud)}; ${JSON.stringify(sub)}` + }) + + if (cws) { + await runStep('life.destroy', 'Delete the cloud workspace', async (step) => { + step.sandboxId = cws.sandboxId + await page.keyboard.press('Escape').catch(() => {}) + const o = await uiDelete(step, cws.name, 'delete', 120_000) + const w = await waitSandboxStatus(cws.sandboxId, ['destroyed'], 120_000) + step.status = o.kind === 'removed' && w.status === 'destroyed' ? 'pass' : 'fail' + step.detail = `UI Delete -> ${o.kind} in ${o.ms}ms; sandbox -> ${w.status}` + }) + } + for (const n of [A, B]) { + if (await rowShown(n, 'local')) { + await openRowMenu(n, 'local').catch(() => {}) + await page.getByRole('menuitem', { name: /^Delete/ }).click().catch(() => {}) + await page.getByRole('dialog').getByRole('button', { name: /^(Force )?Delete( Workspace)?$/ }).first().click().catch(() => {}) + await waitRowGone(n, 'local', 30_000).catch(() => {}) + } + } +} + async function runActionsFlow() { const A = `${wsName}-a` const B = `${wsName}-b` @@ -2309,6 +2506,10 @@ async function runActionsFlow() { * and a normal Delete. */ async function targetedFlow() { + if (process.env.W9D_ONLY === 'run-ports2') { + await runPorts2Flow() + return + } if (process.env.W9D_ONLY === 'run-actions') { await runActionsFlow() return From 2a0790deef146c657c3fe2906d2f8fb65a7a53d3 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 04:20:49 -0800 Subject: [PATCH 10/13] docs(cloud): run.ports cloud fail is desktop/golden skew pending #233 golden --- cloud/apps/acceptance/findings.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cloud/apps/acceptance/findings.json b/cloud/apps/acceptance/findings.json index 77a1dabee90..144e7547032 100644 --- a/cloud/apps/acceptance/findings.json +++ b/cloud/apps/acceptance/findings.json @@ -1,6 +1,7 @@ { "findings": [ "Wake delivery (target p95 < 6 s) — MET in pdx1: after core U17/U18 (default region pdx1, Standard 8 vCPU/16 GB, golden 63b6999), n=20, persistent connection. Wake to echo p50 5.04 s, p95 5.85 s, p99 5.94 s; 0/20 slow hosts. Pinned to iad1 at 8/16: p50 5.31, p95 10.59, p99 11.80 s, with 4/20 slow hosts. BEFORE (iad1 4/8): 5.28 / 8.99 / 9.40 s. Region, not size, removes the tail. From this Mac the first terminal list (0.18 vs 0.44 s) and echo (0.07 vs 0.15 s) are also faster in pdx1. Create p50: pdx1 2.03 s, iad1-pinned 4.26 s (pinned creates seem to skip the warm pool).", + "run.ports (cloud) currently FAILS (2026-11-10): with the #233 desktop (ceea524) against the active goldens, which predate #233 (pdx1 2d99d9b, iad1 3f2b8ea), the first cloud workspace gets a hash port block (22740, stable across sleep/wake) instead of the launcher's 3000. On be26e2f both sides matched and it was 3000, so this is desktop/VM version skew. W9 re-runs automatically once a golden with ceea524 is active. Version skew between desktop and golden image is itself a risk to watch at launch. run.nonconcurrent passes with #233: only the same action stops (471 ms); the concurrent web keeps serving.", "Read while asleep (gap 5, #189/#218), verified by W9 live: a teammate decrypted a sleeping team workspace (git incl. a new file, PR, sessions) with no wake: the Vercel session stayed stopped. 20 reads p50 89 ms, max 131 ms, vs about 5-6 s to wake. An outsider gets 404, and the plaintext marker is not in D1. Removal/private access control is covered by life.read-asleep-access. The phone view was checked in W9's own headless simulator run: “Latest from this workspace · As of just now” showed the marker branch and NOTES.md, and Vercel stayed stopped.", "Read-asleep access, triggered by W9 itself: the operator members/removed route revokes the member; their pre-removal token and a fresh token both get 401, and their wrapped keys go 1 to 0, while the other teammate still reads. Making the workspace private gives that teammate 404 and 0 keys. Phone protocol: device token plus mobile reader key reads a sleeping private workspace in 125 ms, and unpairing revokes access.", "Independent audit corrections (2026-11-10, reports/conductor-parity-audit.html): five rows are capped at partial because they passed on a weaker bar than the Conductor column: real-model agent run, subscription sign-in, default machine size, and the download and remote-desktop UIs. Four rows Conductor has that our bar left out are added as not landed: local core workflow, Run actions with per-workspace ports, read-while-asleep from any device, and per-turn code checkpoints. Our 'pass' counts measure the team's own bar. Every cloud result is on dev Workers, Stripe TEST mode and dev/staging tokens; nothing is shipped (mvp-tracker §5 launch items are all open).", @@ -30,4 +31,4 @@ "agent.signin": "Auth folders persist across sleep/wake and rollover, but subscription OAuth on a VM was never exercised. This cap is lifted by the user's own e2e session with their own credentials (after their review of #40228). It is on the coordinator's e2e checklist.", "agent.more-agents": "Owner-reported (gap-agents) real-task runs on kimi-k2 via the gateway. W9 is not re-running them (no gateway spend per user decision). Lifted by the user e2e session with their own credentials." } -} +} \ No newline at end of file From 8b2701636c648b4689622045c33b4874c17e9f17 Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 04:26:16 -0800 Subject: [PATCH 11/13] feat(cloud): acceptance headless cloud run.ports (first workspace 3000, second worktree block, sleep/wake) --- cloud/apps/acceptance/src/checks/lifecycle.ts | 51 ++++++++++++++++++- cloud/apps/acceptance/src/golden-status.ts | 13 ++++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/cloud/apps/acceptance/src/checks/lifecycle.ts b/cloud/apps/acceptance/src/checks/lifecycle.ts index 75b36de0f89..3eb294d4602 100644 --- a/cloud/apps/acceptance/src/checks/lifecycle.ts +++ b/cloud/apps/acceptance/src/checks/lifecycle.ts @@ -8,7 +8,7 @@ import { join } from 'node:path' import { control } from '../clients' import { config } from '../env' import { log, type Check, type Ctx } from '../context' -import { orca, openTerminal } from '../orca-cli' +import { orca, openTerminal, typeAndSee } from '../orca-cli' import { sandboxToken } from '../tokens' const ARCHIVE = { 'x-orca-sandbox-features': 'archive' } @@ -318,6 +318,55 @@ export const lifecycleChecks: Record = { } }, + + 'run.ports': async (ctx) => { + // Cloud half of per-workspace ports (#216/#233), headless over the pairing: the VM's first workspace + // gets the launcher block (3000), a second worktree on the same VM gets its own registry block, both + // are persisted and survive sleep/wake. The local half (distinct blocks, stable across app restart) + // was proven in the real app on m4air (desktop-20261110-033734). + const box = await ctx.sandboxes.create(ctx.solo, { workspaceId: `w9-ports-${ctx.run.file.runId}`, repo: { url: config.testRepo } }) + if (box.status !== 200) { + return { status: 'fail', detail: `create ${box.status}` } + } + const id = box.body.sandbox.id + let code = box.body.connection.pairingCode as string + const root = box.body.connection.projectRoot as string + try { + const first = await openTerminal(code, root) + const worktree: string | undefined = first.term.json?.result?.terminal?.worktreeId + const repoId = worktree?.split('::')[0] + const second = await orca(code, ['worktree', 'create', '--repo', `id:${repoId}`, '--name', 'w9-second']) + const secondPath: string | undefined = second.json?.result?.worktree?.path + const portsIn = async (path: string, label: string) => { + const t = await orca(code, ['terminal', 'create', '--worktree', `path:${path}`, '--title', label]) + const handle = t.json?.result?.terminal?.handle + if (!handle) { + return `no-terminal(${t.stderr.slice(0, 80)})` + } + const seen = await typeAndSee(code, handle, `echo PORTS_\${ORCA_PORT}_\${ORCA_PORT_COUNT}_\${CONDUCTOR_PORT}_END`, '_END') + return /PORTS_(\d+)_(\d+)_(\d+)_END/.exec(seen.output)?.slice(1).join('/') ?? `unseen(${seen.error})` + } + const a1 = await portsIn(root, 'first') + const b1 = secondPath ? await portsIn(secondPath, 'second') : `no second worktree (${second.stderr.slice(0, 120) || JSON.stringify(second.json?.error ?? '').slice(0, 120)})` + const registry = (await ctx.exec((await ctx.sandboxes.get(id)).body.sandbox.session!.id, 'cat /vercel/.orcad-data/workspace-port-blocks.json 2>/dev/null')).stdout.trim() + await ctx.sandboxes.suspend(id) + const w = await ctx.sandboxes.resume(id) + code = w.body.connection.pairingCode + const a2 = await portsIn(root, 'first-after') + const b2 = secondPath ? await portsIn(secondPath, 'second-after') : 'n/a' + const aPort = Number(a1.split('/')[0]) + const bPort = Number(b1.split('/')[0]) + const ok = + aPort === 3000 && a1.endsWith('/10/3000') && bPort >= 20000 && bPort <= 31999 && bPort !== aPort && a1 === a2 && b1 === b2 + return { + status: ok ? 'pass' : 'fail', + detail: `cloud VM ports (ORCA_PORT/COUNT/CONDUCTOR_PORT): first workspace ${a1}, second worktree ${b1}; after sleep/wake ${a2} and ${b2}; registry ${registry.slice(0, 200)}. Local blocks (distinct, stable across restart) proven on m4air desktop-20261110-033734.`, + evidence: [log('port registry', registry)] + } + } finally { + await ctx.sandboxes.destroy(id) + } + }, 'acc.ssh': async (ctx) => { const b = await refresh(ctx) const { publicKey } = generateKeyPairSync('ed25519') diff --git a/cloud/apps/acceptance/src/golden-status.ts b/cloud/apps/acceptance/src/golden-status.ts index fc9cc9b6cb0..ac01e674d6a 100644 --- a/cloud/apps/acceptance/src/golden-status.ts +++ b/cloud/apps/acceptance/src/golden-status.ts @@ -32,7 +32,18 @@ function contains(commit: string, ancestor: string): boolean { } for (;;) { - const a = active() + let a: ReturnType = null + try { + a = active() + } catch (error) { + // Transient Cloudflare API/auth errors: keep waiting rather than dying. + console.log(JSON.stringify({ at: new Date().toISOString(), error: String(error).split('\n')[0]!.slice(0, 160) })) + if (!wait) { + break + } + await new Promise((r) => setTimeout(r, 120_000)) + continue + } const ok = a?.commit && required ? contains(a.commit, required) : null console.log(JSON.stringify({ at: new Date().toISOString(), ...a, containsRequired: ok })) if (!wait || ok) { From ab106b93cc093520d905f0e7e16caf631d70557c Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 04:29:00 -0800 Subject: [PATCH 12/13] feat(cloud): cloud run.ports passes on the #233 golden (3000 + second worktree block, stable across sleep) --- cloud/apps/acceptance/findings.json | 2 +- cloud/apps/acceptance/src/checks/lifecycle.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cloud/apps/acceptance/findings.json b/cloud/apps/acceptance/findings.json index 144e7547032..366d33600b2 100644 --- a/cloud/apps/acceptance/findings.json +++ b/cloud/apps/acceptance/findings.json @@ -1,7 +1,7 @@ { "findings": [ "Wake delivery (target p95 < 6 s) — MET in pdx1: after core U17/U18 (default region pdx1, Standard 8 vCPU/16 GB, golden 63b6999), n=20, persistent connection. Wake to echo p50 5.04 s, p95 5.85 s, p99 5.94 s; 0/20 slow hosts. Pinned to iad1 at 8/16: p50 5.31, p95 10.59, p99 11.80 s, with 4/20 slow hosts. BEFORE (iad1 4/8): 5.28 / 8.99 / 9.40 s. Region, not size, removes the tail. From this Mac the first terminal list (0.18 vs 0.44 s) and echo (0.07 vs 0.15 s) are also faster in pdx1. Create p50: pdx1 2.03 s, iad1-pinned 4.26 s (pinned creates seem to skip the warm pool).", - "run.ports (cloud) currently FAILS (2026-11-10): with the #233 desktop (ceea524) against the active goldens, which predate #233 (pdx1 2d99d9b, iad1 3f2b8ea), the first cloud workspace gets a hash port block (22740, stable across sleep/wake) instead of the launcher's 3000. On be26e2f both sides matched and it was 3000, so this is desktop/VM version skew. W9 re-runs automatically once a golden with ceea524 is active. Version skew between desktop and golden image is itself a risk to watch at launch. run.nonconcurrent passes with #233: only the same action stops (471 ms); the concurrent web keeps serving.", + "run.ports (cloud) passes on a #233 golden (iad1 d323e4a): first workspace 3000/10/3000, a second worktree on the same VM 26600/10/26600, both unchanged after sleep/wake, both persisted in the VM registry. pdx1 still runs 2d99d9b (pre-#233) and gives a hash block until its golden rotates. Desktop/golden version skew is a launch risk to watch. run.nonconcurrent passes with #233 (only the same action stops, 471 ms).", "Read while asleep (gap 5, #189/#218), verified by W9 live: a teammate decrypted a sleeping team workspace (git incl. a new file, PR, sessions) with no wake: the Vercel session stayed stopped. 20 reads p50 89 ms, max 131 ms, vs about 5-6 s to wake. An outsider gets 404, and the plaintext marker is not in D1. Removal/private access control is covered by life.read-asleep-access. The phone view was checked in W9's own headless simulator run: “Latest from this workspace · As of just now” showed the marker branch and NOTES.md, and Vercel stayed stopped.", "Read-asleep access, triggered by W9 itself: the operator members/removed route revokes the member; their pre-removal token and a fresh token both get 401, and their wrapped keys go 1 to 0, while the other teammate still reads. Making the workspace private gives that teammate 404 and 0 keys. Phone protocol: device token plus mobile reader key reads a sleeping private workspace in 125 ms, and unpairing revokes access.", "Independent audit corrections (2026-11-10, reports/conductor-parity-audit.html): five rows are capped at partial because they passed on a weaker bar than the Conductor column: real-model agent run, subscription sign-in, default machine size, and the download and remote-desktop UIs. Four rows Conductor has that our bar left out are added as not landed: local core workflow, Run actions with per-workspace ports, read-while-asleep from any device, and per-turn code checkpoints. Our 'pass' counts measure the team's own bar. Every cloud result is on dev Workers, Stripe TEST mode and dev/staging tokens; nothing is shipped (mvp-tracker §5 launch items are all open).", diff --git a/cloud/apps/acceptance/src/checks/lifecycle.ts b/cloud/apps/acceptance/src/checks/lifecycle.ts index 3eb294d4602..ddb423f80c7 100644 --- a/cloud/apps/acceptance/src/checks/lifecycle.ts +++ b/cloud/apps/acceptance/src/checks/lifecycle.ts @@ -324,7 +324,7 @@ export const lifecycleChecks: Record = { // gets the launcher block (3000), a second worktree on the same VM gets its own registry block, both // are persisted and survive sleep/wake. The local half (distinct blocks, stable across app restart) // was proven in the real app on m4air (desktop-20261110-033734). - const box = await ctx.sandboxes.create(ctx.solo, { workspaceId: `w9-ports-${ctx.run.file.runId}`, repo: { url: config.testRepo } }) + const box = await ctx.sandboxes.create(ctx.solo, { workspaceId: `w9-ports-${ctx.run.file.runId}`, repo: { url: config.testRepo }, ...(process.env.ORCA_ACCEPTANCE_REGION ? { region: process.env.ORCA_ACCEPTANCE_REGION } : {}) }) if (box.status !== 200) { return { status: 'fail', detail: `create ${box.status}` } } From dd34314dc040036053b8aaf9c5ea895c10a4342c Mon Sep 17 00:00:00 2001 From: Neil Date: Tue, 10 Nov 2026 04:00:05 -0800 Subject: [PATCH 13/13] feat(billing): bill overage by the minute, put sandboxes to sleep at the cap (U22, U24) - Overage stays $1.00 per sandbox hour (ORCA_CLOUD_OVERAGE_CENTS_PER_HOUR) and is now billed by the minute: a per-minute metered TEST price (unit_amount_decimal = rate / 60 to 12 decimals, transform_quantity divide_by 60, round up), created the same way by stripe-test-setup.ts. capSeconds rounds the cap down to whole minutes and spentCents mirrors the invoice, so the cap math matches Stripe. The meter still counts seconds. - Opting in re-prices an overage item still on an older price (e.g. $0.75 / h) instead of adding a second item on the same meter, and removes any extra stale one. - Alerts at 75%, 90% and 100% of included hours (warnAtPercent). - New top-level entitlement.suspendRunning: payment grace expired, or at the limit (out of hours with overage off, or at the spend cap). The start check and usage answers carry it, so the control plane puts running sandboxes to sleep (snapshot kept, never deleted). Payment fields and /v1/internal/suspensions stay payment-only, so no data-retention clock starts at a cap. - Copy (desktop billing panel, plan card, build sheet, phone, CLI): "billed by the minute" after the rate; at the limit "Running sandboxes go to sleep and new ones can't start. Nothing is deleted, and your files are safe." --- cloud/apps/billing/scripts/e2e-test-mode.ts | 56 ++++++-- .../apps/billing/scripts/stripe-test-setup.ts | 18 +-- cloud/apps/billing/src/entitlements.ts | 16 ++- cloud/apps/billing/src/index.ts | 2 +- cloud/apps/billing/src/overage.ts | 53 ++++++-- cloud/apps/billing/src/plans.ts | 32 ++++- cloud/apps/billing/src/stripe.ts | 21 ++- cloud/apps/billing/src/usage.ts | 5 +- .../apps/billing/test/billing-config.test.ts | 42 +++++- cloud/apps/billing/test/billing-flow.test.ts | 127 +++++++++++++++++- cloud/apps/billing/test/stripe-fake.ts | 38 +++++- cloud/apps/billing/wrangler.dev.jsonc | 3 +- .../test/lifecycle-flow.test.ts | 15 +++ .../src/cloud/cloud-entitlement-copy.test.ts | 8 +- mobile/src/cloud/cloud-entitlement-copy.ts | 19 ++- mobile/src/i18n/locales/en.json | 9 +- src/cli/cloud/cloud-api-errors.ts | 6 +- src/cli/cloud/cloud-glossary.test.ts | 6 +- src/cli/cloud/index-cloud-commands.test.ts | 2 +- .../index-worktree-create-on-cloud.test.ts | 4 +- .../billing/PlanBillingSection.fixture.tsx | 21 +++ .../components/billing/PlanBillingUsage.tsx | 12 +- .../components/billing/plan-billing-copy.ts | 16 +-- .../components/billing/plans/TeamPlanCard.tsx | 4 +- .../vm-build-failure-copy.ts | 16 +-- src/renderer/src/i18n/locales/en.json | 24 ++-- src/shared/orca-billing.ts | 2 +- 27 files changed, 466 insertions(+), 111 deletions(-) diff --git a/cloud/apps/billing/scripts/e2e-test-mode.ts b/cloud/apps/billing/scripts/e2e-test-mode.ts index 7290c4b1cfe..40e06ebea7f 100644 --- a/cloud/apps/billing/scripts/e2e-test-mode.ts +++ b/cloud/apps/billing/scripts/e2e-test-mode.ts @@ -5,22 +5,24 @@ // tsx scripts/e2e-test-mode.ts run # drive the flows below // // Env for `run`: BILLING_URL, STRIPE_SECRET_KEY (sk_test_/rk_test_ only), CONTROL_PLANE_TOKEN, -// E2E_JWK_PRIVATE (path), E2E_CATALOG (path to the stripe-test-setup.ts JSON), E2E_ISSUER, +// E2E_JWK_PRIVATE (path), E2E_CATALOG (path to the stripe-test-setup.ts JSON; an optional +// STRIPE_PRICE_OVERAGE_PREVIOUS, an older metered overage price, checks opt-in re-prices it), E2E_ISSUER, // E2E_D1_ARGS (wrangler d1 execute target, e.g. "BILLING_DB --local"). // // E2E_MEMBERSHIP_TOKEN (optional) enables the peak-seat checks; E2E_GRACE_SECONDS (default 96 h) // is the Worker's payment grace (short: watched expiring; long: past_due_since backdated in D1). // -// Flows: Checkout (2 seats, test card, headless browser) -> overage opt-in -> usage past the -// included hours -> meter events in Stripe -> spend cap blocks starts -> peak seats (add charged -// now, removal deferred, re-add free) -> annual plan + overage -> seat true-down + test-clock +// Flows: Checkout (2 seats, test card, headless browser) -> overage opt-in (per-minute price) -> +// 75/90/100% warnings -> usage past the included hours -> meter events in Stripe -> spend cap +// blocks starts and puts running sandboxes to sleep -> peak seats (add charged +// now, removal deferred, re-add free) -> annual plan + overage (an older overage item is re-priced, not doubled) -> seat true-down + test-clock // renewal -> invoice.payment_failed -> grace -> suspend -> pay -> resume -> cancel -> 3DS Checkout // (challenge passed; 3DS-then-decline and insufficient funds create nothing) -> cleanup. // Prints PASS/FAIL per check and the ids. import { execFileSync } from 'node:child_process' import { readFileSync, writeFileSync } from 'node:fs' import { SignJWT, exportJWK, generateKeyPair, importJWK } from 'jose' -import { DEFAULT_PLANS } from '../src/plans' +import { DEFAULT_PLANS, OVERAGE_SECONDS_PER_BILLED_UNIT, capSeconds, overageUnitAmountDecimal } from '../src/plans' import { encodeForm } from '../src/stripe' const HOUR = 3600 @@ -250,13 +252,26 @@ const subA = await stripe<{ items: { data: Array<{ price: { id: string } }> } }> check('overage opt-in adds the metered item ($25 default cap)', optIn.status === 200 && optIn.body.overage?.active === true && optIn.body.overage?.spendCapCents === 2500 && subA.items.data.some((item) => item.price.id === catalog.STRIPE_PRICE_OVERAGE), optIn.body) -// ---- 3. Usage past included hours -> meter events -------------------------------------------- +const overagePrice = await stripe<{ unit_amount_decimal: string; transform_quantity: { divide_by: number; round: string }; recurring: { usage_type: string; meter: string } }>( + 'GET', `/v1/prices/${catalog.STRIPE_PRICE_OVERAGE}`) +check(`overage price is billed by the minute (${overageUnitAmountDecimal(DEFAULT_PLANS.team.overageCentsPerHour)} cents per minute, divide_by 60, round up)`, + overagePrice.unit_amount_decimal === overageUnitAmountDecimal(DEFAULT_PLANS.team.overageCentsPerHour) && + overagePrice.transform_quantity?.divide_by === OVERAGE_SECONDS_PER_BILLED_UNIT && overagePrice.transform_quantity.round === 'up' && + overagePrice.recurring.usage_type === 'metered' && overagePrice.recurring.meter === catalog.meter, overagePrice) + +// ---- 3. Usage past included hours -> warnings -> meter events -------------------------------- const meterStart = Math.floor(Date.now() / 1000 / 60) * 60 - 2 * 24 * HOUR -for (let i = 0; i < 5; i++) { +for (let i = 0; i < 4; i++) { await reportUsage(accountA, 24) } +// 96 of 120 h (80%), then 108 h (90%), then 120 h: alerts at 75%, 90% and 100%. +check('75% included-hours warning', (await entitlement(orgA)).usageWarningPercent === 75) +await reportUsage(accountA, 12) +check('90% included-hours warning', (await entitlement(orgA)).usageWarningPercent === 90) +await reportUsage(accountA, 12) const crossing = await reportUsage(accountA, 4) -check('usage past 120 h: 4 h overage recorded, starts still allowed', crossing.overageSeconds === 4 * HOUR && crossing.canStartSandbox === true, crossing) +check('usage past 120 h: 4 h overage recorded, starts still allowed, nothing put to sleep', crossing.overageSeconds === 4 * HOUR && + crossing.canStartSandbox === true && crossing.suspendRunning === false, crossing) const warned = await entitlement(orgA) check('100% included-hours warning', warned.usageWarningPercent === 100, warned.usageWarningPercent) @@ -264,13 +279,17 @@ check('100% included-hours warning', warned.usageWarningPercent === 100, warned. await reportUsage(accountA, 20) await reportUsage(accountA, 20) const capped = await startCheck(accountA) -check(`spend cap ($25 = ${(2500 / DEFAULT_PLANS.team.overageCentsPerHour).toFixed(1)} overage h) reached -> new starts blocked`, capped.allow === false && capped.reason === 'overage_cap_reached', capped) +check(`spend cap ($25 = ${(2500 / DEFAULT_PLANS.team.overageCentsPerHour).toFixed(1)} overage h) reached -> new starts blocked, running sandboxes put to sleep`, + capped.allow === false && capped.reason === 'overage_cap_reached' && capped.suspendRunning === true, capped) const spent = await entitlement(orgA) -check('metered spend stops at the cap', spent.overage?.spentCents <= 2500 && spent.overage?.spentCents >= 2490, spent.overage) +check('metered spend stops at the cap (whole minutes, never over it)', spent.overage?.spentCents <= 2500 && spent.overage?.spentCents >= 2490 && + spent.suspendRunning === true && spent.payment?.suspendRunning === false, { overage: spent.overage, suspendRunning: spent.suspendRunning }) +const capSuspensions = await billing('GET', '/v1/internal/suspensions', null).then((r) => r.body) +check('a spend cap is not a payment suspension (no data-retention clock)', !capSuspensions.accounts?.some((a: any) => a.accountId === accountA), capSuspensions) const meterEnd = Math.ceil(Date.now() / 1000 / 60) * 60 + 60 // The $25 default cap in overage seconds at the plan's overage rate (single source: plans.ts). -const CAP_SECONDS = Math.floor((2500 / DEFAULT_PLANS.team.overageCentsPerHour) * HOUR) +const CAP_SECONDS = capSeconds(DEFAULT_PLANS.team, 2500) const summaries = await until( 'meter event summaries', () => stripe<{ data: Array<{ aggregated_value: number }> }>('GET', `/v1/billing/meters/${catalog.meter}/event_summaries`, { @@ -328,8 +347,12 @@ ids.annualCustomer = customerB.id linkAccount(`org:${orgB}`, customerB.id) await stripe('POST', '/v1/payment_methods/pm_card_visa/attach', { customer: customerB.id }) .then((pm: any) => stripe('POST', `/v1/customers/${customerB.id}`, { invoice_settings: { default_payment_method: pm.id } })) -const annualSub = await stripe<{ id: string }>('POST', '/v1/subscriptions', { - customer: customerB.id, items: [{ price: catalog.STRIPE_PRICE_TEAM_ANNUAL, quantity: 3 }], metadata: { account_id: `org:${orgB}` } +// An older overage price already on the subscription (opted in before the price changed). +const previousOverage = catalog.STRIPE_PRICE_OVERAGE_PREVIOUS +const annualSub = await stripe<{ id: string; items: { data: Array<{ id: string; price: { id: string } }> } }>('POST', '/v1/subscriptions', { + customer: customerB.id, + items: [{ price: catalog.STRIPE_PRICE_TEAM_ANNUAL, quantity: 3 }, ...(previousOverage ? [{ price: previousOverage }] : [])], + metadata: { account_id: `org:${orgB}` } }) ids.annualSubscription = annualSub.id const annualEnt = await until('annual active', () => entitlement(orgB), (e) => e.status === 'active') @@ -337,6 +360,13 @@ check('annual Team: 3 seats, 180 h monthly allowance window', annualEnt.interval annualEnt.usageWindow && annualEnt.usageWindow.end - annualEnt.usageWindow.start <= 31 * 24 * HOUR * 1000, annualEnt) const annualOptIn = await billing('POST', '/v1/billing/overage', orgB, { enabled: true }) check('annual plan accepts the monthly metered overage item', annualOptIn.status === 200 && annualOptIn.body.overage?.active === true, annualOptIn) +if (previousOverage) { + const oldItem = annualSub.items.data.find((item) => item.price.id === previousOverage)?.id + const items = (await stripe<{ items: { data: Array<{ id: string; price: { id: string } }> } }>('GET', `/v1/subscriptions/${annualSub.id}`)).items.data + const metered = items.filter((item) => item.price.id !== catalog.STRIPE_PRICE_TEAM_ANNUAL) + check('opt-in moves the older overage item to the current price (no second item)', metered.length === 1 && + metered[0]!.id === oldItem && metered[0]!.price.id === catalog.STRIPE_PRICE_OVERAGE, metered) +} // ---- 7. Test clock: renewal, then invoice.payment_failed -------------------------------------- const orgC = `e2e-${runId}-c` diff --git a/cloud/apps/billing/scripts/stripe-test-setup.ts b/cloud/apps/billing/scripts/stripe-test-setup.ts index b5d5846a691..4b4c5efbee0 100644 --- a/cloud/apps/billing/scripts/stripe-test-setup.ts +++ b/cloud/apps/billing/scripts/stripe-test-setup.ts @@ -8,7 +8,7 @@ // Prices (cents) default to the agreed Team pricing; override with // STRIPE_SETUP_PRICES='{"teamMonthly":5000,"teamAnnual":48000,"overagePerHour":100}' // Idempotency keys include the prices, so a re-run within 24h returns the same objects. -import { DEFAULT_PLANS } from '../src/plans' +import { DEFAULT_PLANS, OVERAGE_SECONDS_PER_BILLED_UNIT, overageUnitAmountDecimal } from '../src/plans' import { encodeForm } from '../src/stripe' const key = process.env.STRIPE_SECRET_KEY ?? '' @@ -82,20 +82,22 @@ const meter = await post( 'meter' ) const overageProduct = await post('/v1/products', { name: 'Orca sandbox overage', unit_label: 'std hour' }, 'product:overage') -// Per sandbox hour (one size, 1x; see MACHINE_SIZE_WEIGHT); the meter counts seconds and the -// invoice divides by 3600, rounding up. +// Per sandbox hour (one size, 1x; see MACHINE_SIZE_WEIGHT), billed by the minute: the meter counts +// seconds, the invoice divides the period's total by 60 (rounding up) and charges the hourly rate +// / 60 per minute (unit_amount_decimal, 12 decimals; overageUnitAmountDecimal in plans.ts). const overage = await post( '/v1/prices', { product: overageProduct.id, currency: 'usd', - unit_amount: prices.overagePerHour, + unit_amount_decimal: overageUnitAmountDecimal(prices.overagePerHour), recurring: { interval: 'month', usage_type: 'metered', meter: meter.id }, - transform_quantity: { divide_by: 3600, round: 'up' }, - lookup_key: 'orca_sandbox_overage_hourly', - transfer_lookup_key: true + transform_quantity: { divide_by: OVERAGE_SECONDS_PER_BILLED_UNIT, round: 'up' }, + lookup_key: 'orca_sandbox_overage_per_minute', + transfer_lookup_key: true, + nickname: 'Sandbox overage, billed by the minute' }, - `price:overage:${suffix}` + `price:overage:minute:${suffix}` ) const portal = await post( diff --git a/cloud/apps/billing/src/entitlements.ts b/cloud/apps/billing/src/entitlements.ts index 5b7663d3094..3fc2808cb62 100644 --- a/cloud/apps/billing/src/entitlements.ts +++ b/cloud/apps/billing/src/entitlements.ts @@ -6,6 +6,7 @@ import { allowanceWindow, capSeconds, includedSeconds, + overageCents, planCatalog, type BillingInterval, type MachineSize, @@ -44,7 +45,7 @@ export type Entitlement = { includedSeconds: number usedSeconds: number remainingIncludedSeconds: number - /** Highest configured warning level reached (e.g. 80, 100), or null. */ + /** Highest configured warning level reached (75, 90 or 100), or null. */ usageWarningPercent: number | null overageSeconds: number overage: { @@ -64,6 +65,12 @@ export type Entitlement = { } /** Failed payment: new starts stop at once; running sandboxes must be suspended after the grace. */ payment: { pastDueSince: number | null; graceEndsAt: number | null; suspendRunning: boolean } + /** + * The account's running sandboxes must go to sleep now (snapshot kept, never deleted): payment + * grace expired, or the account is at its limit (out of included hours with overage off, or at + * its overage spend cap). The control plane acts on it from the start check and usage reports. + */ + suspendRunning: boolean canStartSandbox: boolean reason: DenyReason | null } @@ -110,6 +117,7 @@ export async function computeEntitlement( idleSleepMinutes: team.idleSleepMinutes }, payment: { pastDueSince: null, graceEndsAt: null, suspendRunning: false }, + suspendRunning: false, canStartSandbox: false, reason: 'no_subscription' } @@ -166,7 +174,8 @@ export async function computeEntitlement( overage: { ...overageBase, active: overageActive, - spentCents: Math.round((metered / 3600) * team.overageCentsPerHour) + // What Stripe will invoice: whole minutes at the per-minute price. + spentCents: overageCents(team, metered) }, limits: { maxConcurrentSandboxes: team.concurrentSandboxesPerSeat * Math.max(1, subscription.seats), @@ -175,6 +184,7 @@ export async function computeEntitlement( idleSleepMinutes: team.idleSleepMinutes }, payment: { pastDueSince, graceEndsAt, suspendRunning: graceExpired }, + suspendRunning: graceExpired || reason === 'included_hours_exhausted' || reason === 'overage_cap_reached', canStartSandbox: reason === null, reason } @@ -189,7 +199,7 @@ export function checkStart( entitlement: Entitlement, request: { machineSize: MachineSize; runningSandboxes: number; runningSandboxesForUser?: number } ): { allow: boolean; reason: DenyReason | null; suspendRunning: boolean } { - const suspendRunning = entitlement.payment.suspendRunning + const suspendRunning = entitlement.suspendRunning if (!entitlement.canStartSandbox) { return { allow: false, reason: entitlement.reason, suspendRunning } } diff --git a/cloud/apps/billing/src/index.ts b/cloud/apps/billing/src/index.ts index 23d596c93d2..1d7b46f876c 100644 --- a/cloud/apps/billing/src/index.ts +++ b/cloud/apps/billing/src/index.ts @@ -192,7 +192,7 @@ export async function handleRequest(request: Request, env: Env, ctx: ExecutionCo ...result, canStartSandbox: entitlement.canStartSandbox, reason: entitlement.reason, - suspendRunning: entitlement.payment.suspendRunning + suspendRunning: entitlement.suspendRunning }) } diff --git a/cloud/apps/billing/src/overage.ts b/cloud/apps/billing/src/overage.ts index 69c03b17de5..590e00f1d1f 100644 --- a/cloud/apps/billing/src/overage.ts +++ b/cloud/apps/billing/src/overage.ts @@ -1,13 +1,15 @@ // Overage is opt-in per account (off by default) with a monthly spend cap admins can raise. -// Opting in adds the metered overage price to the Stripe subscription (once); opting out stops +// Opting in puts exactly one metered overage item, at STRIPE_PRICE_OVERAGE, on the Stripe +// subscription: an item still on an older overage price is moved to the current one rather than a +// second added (two items on the same meter would bill the same seconds twice). Opting out stops // metering immediately but leaves the item, so usage already reported is still invoiced. import { z } from 'zod' import type { BillingCaller } from './auth' import { OPEN_STATUSES } from './checkout' import type { Env } from './env' -import { planCatalog } from './plans' -import type { StripeClient } from './stripe' -import { accountSettings, currentSubscription, setOverageSettings, upsertSubscription } from './store' +import { planCatalog, planForPrice } from './plans' +import type { StripeClient, StripeSubscription } from './stripe' +import { accountSettings, currentSubscription, setOverageSettings, upsertSubscription, type SubscriptionRow } from './store' export const OverageRequestSchema = z .object({ @@ -37,10 +39,11 @@ export async function updateOverage( if (!subscription || !OPEN_STATUSES.has(subscription.status)) { return { ok: false, status: 409, error: 'no_subscription' } } - if (request.enabled && !subscription.overage_item_id) { - // Idempotent per subscription+price, so a retried opt-in cannot add a second item. - const item = await stripe.addSubscriptionItem({ subscription: subscription.stripe_subscription_id, price: env.STRIPE_PRICE_OVERAGE }) - await upsertSubscription(env.BILLING_DB, { ...subscription, overage_enabled: 1, overage_item_id: item.id, updated_at: now }) + if (request.enabled) { + const itemId = await ensureOverageItem(env, stripe, subscription) + if (itemId !== subscription.overage_item_id) { + await upsertSubscription(env.BILLING_DB, { ...subscription, overage_enabled: 1, overage_item_id: itemId, updated_at: now }) + } } // An omitted cap keeps the current one; null restores the plan default. const spendCapCents = @@ -50,3 +53,37 @@ export async function updateOverage( await setOverageSettings(env.BILLING_DB, caller.accountId, { optIn: request.enabled, spendCapCents }, now) return { ok: true } } + +/** A metered item that is not a seat price and not the current overage price: an older overage price. */ +function isStaleOverageItem(env: Env, item: StripeSubscription['items']['data'][number]): boolean { + return ( + item.price.id !== env.STRIPE_PRICE_OVERAGE && + planForPrice(env, item.price.id) === null && + item.price.recurring?.usage_type === 'metered' + ) +} + +/** The subscription's one overage item at the current price, creating or re-pricing it as needed. */ +async function ensureOverageItem(env: Env, stripe: StripeClient, subscription: SubscriptionRow): Promise { + // Stripe is the source of truth for the items: the stored id can be missing or on an old price. + const live = await stripe.retrieveSubscription(subscription.stripe_subscription_id) + const current = live.items.data.find((item) => item.price.id === env.STRIPE_PRICE_OVERAGE) + const stale = live.items.data.filter((item) => isStaleOverageItem(env, item)) + if (current) { + // Never leave a second metered item on the same meter. + for (const item of stale) { + await stripe.deleteSubscriptionItem(item.id) + } + return current.id + } + const [replace, ...extra] = stale + if (replace) { + await stripe.setSubscriptionItemPrice({ item: replace.id, price: env.STRIPE_PRICE_OVERAGE }) + for (const item of extra) { + await stripe.deleteSubscriptionItem(item.id) + } + return replace.id + } + // Idempotent per subscription+price, so a retried opt-in cannot add a second item. + return (await stripe.addSubscriptionItem({ subscription: subscription.stripe_subscription_id, price: env.STRIPE_PRICE_OVERAGE })).id +} diff --git a/cloud/apps/billing/src/plans.ts b/cloud/apps/billing/src/plans.ts index 75507f01f65..0b4182e49c1 100644 --- a/cloud/apps/billing/src/plans.ts +++ b/cloud/apps/billing/src/plans.ts @@ -74,11 +74,13 @@ export const DEFAULT_PLANS: PlanCatalog = { // Same constant the control plane enforces and serves (GET /v1/config); never a literal here. idleSleepMinutes: ORCA_CLOUD_IDLE_SLEEP_MINUTES, // Shared with the control plane's workspace cost; entitlements, the spend-cap math, the desktop and - // the e2e read it from here. STRIPE_PRICE_OVERAGE must be a metered price with this unit_amount. + // the e2e read it from here. STRIPE_PRICE_OVERAGE must be the metered per-minute price for this rate + // (unit_amount_decimal = overageUnitAmountDecimal(rate), transform_quantity divide_by 60, round up). overageCentsPerHour: ORCA_CLOUD_OVERAGE_CENTS_PER_HOUR, defaultSpendCapCents: 2500, maxSpendCapCents: 500_000, - warnAtPercent: [80, 100], + // Owners and admins are alerted at 75%, 90% and 100% of the team's sandbox hours. + warnAtPercent: [75, 90, 100], paymentGraceHours: 96, seatTrueDownHours: 6 }, @@ -164,9 +166,31 @@ export function includedSeconds(team: TeamPlan, seats: number): number { return Math.round(team.includedHoursPerSeat * 3600) * Math.max(1, seats) } -/** The spend cap expressed as overage seconds (what the meter counts). */ +// Overage is billed by the minute: the meter counts seconds, and the Stripe price divides the +// period's total by 60 (rounding up) and charges overageCentsPerHour / 60 per minute. Metering in +// seconds keeps every segment exact; only the invoice rounds, once per period, to a whole minute. +export const OVERAGE_SECONDS_PER_BILLED_UNIT = 60 +/** Stripe allows at most 12 decimal places in unit_amount_decimal. */ +const STRIPE_DECIMAL_PLACES = 12 + +/** The per-minute Stripe price (cents, as Stripe's unit_amount_decimal string) for an hourly rate. */ +export function overageUnitAmountDecimal(centsPerHour: number): string { + return (centsPerHour / (3600 / OVERAGE_SECONDS_PER_BILLED_UNIT)).toFixed(STRIPE_DECIMAL_PLACES) +} + +/** What Stripe invoices for `seconds` of overage: whole minutes (rounded up) at the per-minute rate. */ +export function overageCents(team: TeamPlan, seconds: number): number { + const minutes = Math.ceil(Math.max(0, seconds) / OVERAGE_SECONDS_PER_BILLED_UNIT) + return Math.round(minutes * Number(overageUnitAmountDecimal(team.overageCentsPerHour))) +} + +/** + * The spend cap expressed as overage seconds (what the meter counts): the whole minutes the cap + * pays for, so the invoice never goes over the cap after Stripe rounds up to the minute. + */ export function capSeconds(team: TeamPlan, spendCapCents: number): number { - return Math.floor((spendCapCents / team.overageCentsPerHour) * 3600) + const minutes = Math.floor((spendCapCents * (3600 / OVERAGE_SECONDS_PER_BILLED_UNIT)) / team.overageCentsPerHour) + return minutes * OVERAGE_SECONDS_PER_BILLED_UNIT } function addUtcMonths(ms: number, months: number): number { diff --git a/cloud/apps/billing/src/stripe.ts b/cloud/apps/billing/src/stripe.ts index e76f8ad1f7e..8d493a6fd58 100644 --- a/cloud/apps/billing/src/stripe.ts +++ b/cloud/apps/billing/src/stripe.ts @@ -73,7 +73,7 @@ export function createStripeClient(env: Env, fetchImpl: typeof fetch = fetch) { const base = env.STRIPE_API_BASE ?? 'https://api.stripe.com' const version = env.STRIPE_API_VERSION ?? DEFAULT_STRIPE_API_VERSION - async function request(method: 'GET' | 'POST', path: string, params?: FormRecord, idempotencyKey?: string) { + async function request(method: 'GET' | 'POST' | 'DELETE', path: string, params?: FormRecord, idempotencyKey?: string) { assertKeyMode(env) const headers: Record = { authorization: `Bearer ${env.STRIPE_SECRET_KEY}`, @@ -152,6 +152,23 @@ export function createStripeClient(env: Env, fetchImpl: typeof fetch = fetch) { `overage-item:${args.subscription}:${args.price}` ) }, + /** Moves an item to another price in place (an older overage price to the current one). */ + setSubscriptionItemPrice(args: { item: string; price: string }) { + return request<{ id: string; price: { id: string } }>( + 'POST', + `/v1/subscription_items/${encodeURIComponent(args.item)}`, + { price: args.price, proration_behavior: 'none' }, + `overage-item-price:${args.item}:${args.price}` + ) + }, + /** DELETE is idempotent in Stripe, so no idempotency key (Stripe ignores one there). */ + deleteSubscriptionItem(item: string) { + return request<{ id: string; deleted: boolean }>( + 'DELETE', + `/v1/subscription_items/${encodeURIComponent(item)}`, + { proration_behavior: 'none' } + ) + }, setSubscriptionItemQuantity(args: { item: string quantity: number @@ -202,7 +219,7 @@ export type StripeSubscription = { quantity?: number current_period_start?: number current_period_end?: number - price: { id: string } + price: { id: string; recurring?: { usage_type?: string } | null } }> } } diff --git a/cloud/apps/billing/src/usage.ts b/cloud/apps/billing/src/usage.ts index 2591a9698b7..015e0b6fc71 100644 --- a/cloud/apps/billing/src/usage.ts +++ b/cloud/apps/billing/src/usage.ts @@ -1,8 +1,9 @@ // Sandbox usage ingestion (control plane -> billing) and the Stripe meter outbox. // Only overage reaches Stripe, and only for accounts that opted in, up to their monthly spend cap: // included hours are tracked here (pooled across seats, monthly even on annual plans, which tiered -// Stripe prices cannot express); the overage price is a flat per-hour metered price on a -// "standard seconds" meter with transform_quantity divide_by=3600. +// Stripe prices cannot express); the overage price is a flat per-hour rate billed by the minute: a +// metered per-minute price on a "standard seconds" meter with transform_quantity divide_by=60. +// Metering stays in seconds (exact per segment); Stripe rounds the period total up to a minute. import { z } from 'zod' import type { Env } from './env' import { MACHINE_SIZE_WEIGHT, MACHINE_SIZES, capSeconds, includedSeconds, planCatalog } from './plans' diff --git a/cloud/apps/billing/test/billing-config.test.ts b/cloud/apps/billing/test/billing-config.test.ts index 7fffd02109d..1b5f3835a5e 100644 --- a/cloud/apps/billing/test/billing-config.test.ts +++ b/cloud/apps/billing/test/billing-config.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { allowanceWindow, capSeconds, DEFAULT_PLANS } from '../src/plans' +import { allowanceWindow, capSeconds, DEFAULT_PLANS, overageCents, overageUnitAmountDecimal } from '../src/plans' +import { ORCA_CLOUD_OVERAGE_CENTS_PER_HOUR } from '../../../../src/shared/hosted-sandbox/hosted-sandbox-wire' import { createHarness } from './harness' const HOUR = 3600 @@ -85,4 +86,43 @@ describe('allowance windows and caps', () => { expect(DEFAULT_PLANS.team.overageCentsPerHour).toBe(100) expect(capSeconds(DEFAULT_PLANS.team, 2500)).toBe(25 * HOUR) }) + + it('alerts at 75, 90 and 100% of the included hours', () => { + expect(DEFAULT_PLANS.team.warnAtPercent).toEqual([75, 90, 100]) + }) +}) + +describe('per-minute overage price', () => { + const team = DEFAULT_PLANS.team + + it('derives the Stripe per-minute unit_amount_decimal from the shared hourly rate', () => { + expect(team.overageCentsPerHour).toBe(ORCA_CLOUD_OVERAGE_CENTS_PER_HOUR) + // $1.00 per hour = 1.666666666667 cents per minute (Stripe's 12-decimal maximum). + expect(overageUnitAmountDecimal(100)).toBe('1.666666666667') + expect(overageUnitAmountDecimal(75)).toBe('1.250000000000') + expect(overageUnitAmountDecimal(team.overageCentsPerHour).split('.')[1]).toHaveLength(12) + }) + + it('charges whole minutes, rounding up, like the price (transform_quantity divide_by 60, round up)', () => { + expect(overageCents(team, 0)).toBe(0) + expect(overageCents(team, 1)).toBe(2) // one started minute: 1.67 cents + expect(overageCents(team, 60)).toBe(2) + expect(overageCents(team, 61)).toBe(3) // two minutes: 3.33 cents + expect(overageCents(team, 30 * 60)).toBe(50) + expect(overageCents(team, HOUR)).toBe(100) + expect(overageCents(team, 4 * HOUR)).toBe(400) + }) + + it('caps on whole minutes, so the invoice never passes the spend cap', () => { + for (const cap of [0, 1, 99, 100, 101, 2500, 2501, 12_345, 500_000]) { + const seconds = capSeconds(team, cap) + expect(seconds % 60).toBe(0) + expect(overageCents(team, seconds)).toBeLessThanOrEqual(cap) + // ...and one more minute would pass it. + expect(overageCents(team, seconds + 60)).toBeGreaterThan(cap) + } + // $25.01 doesn't pay for a 1,501st minute ($25.0167); $25.02 does. + expect(capSeconds(team, 2501)).toBe(25 * HOUR) + expect(capSeconds(team, 2502)).toBe(25 * HOUR + 60) + }) }) diff --git a/cloud/apps/billing/test/billing-flow.test.ts b/cloud/apps/billing/test/billing-flow.test.ts index baeb58d52bb..7638d4f5d6c 100644 --- a/cloud/apps/billing/test/billing-flow.test.ts +++ b/cloud/apps/billing/test/billing-flow.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { checkoutCompleted, invoiceEvent, stripeEvent } from './stripe-fake' -import { createHarness } from './harness' +import { CONTROL_TOKEN, createHarness } from './harness' const HOUR = 3600 const PERIOD_START = Math.floor(Date.now() / 1000) - 5 * 24 * HOUR @@ -148,7 +148,7 @@ describe('webhooks', () => { it('tracks seat and interval changes made in the portal', async () => { const { h, auth, subscription } = await subscribedTeam() subscription.items.data[0]!.quantity = 5 - subscription.items.data[0]!.price = { id: 'price_team_year' } + subscription.items.data[0]!.price = { id: 'price_team_year', recurring: { usage_type: 'licensed' } } subscription.items.data[0]!.current_period_end = PERIOD_START + 365 * 24 * HOUR await h.webhook(stripeEvent('customer.subscription.updated', subscription)) const entitlement = await h.call('GET', '/v1/billing/entitlements', { headers: auth }) @@ -224,10 +224,24 @@ describe('entitlements, overage and usage', () => { expect(entitlement.body.offers.team).toMatchObject({ monthlyPriceLabel: '$50 per seat per month', annualPriceLabel: '$480 per seat, billed yearly' }) expect(entitlement.body.offers.enterprise).toMatchObject({ label: 'Enterprise', priceLabel: 'Custom' }) - for (let i = 0; i < 4; i++) { - await h.controlPlane('/v1/internal/usage', usage(`w-${i}`, 22)) + // Alerts at 75%, 90% and 100% of the 120 pooled hours (the highest level reached). Segments + // are at most a day, so the hours arrive in pieces: 10 so far. + const warning = async () => (await h.call('GET', '/v1/billing/entitlements', { headers: auth })).body.usageWarningPercent + const add = async (id: string, hours: number[]) => { + for (const [i, value] of hours.entries()) { + await h.controlPlane('/v1/internal/usage', usage(`${id}-${i}`, value)) + } } - expect((await h.call('GET', '/v1/billing/entitlements', { headers: auth })).body.usageWarningPercent).toBe(80) + await add('a', [20, 20, 20, 19]) // 89 h + expect(await warning()).toBe(null) + await add('b', [1]) // 90 h = 75% + expect(await warning()).toBe(75) + await add('c', [17]) // 107 h + expect(await warning()).toBe(75) + await add('d', [1]) // 108 h = 90% + expect(await warning()).toBe(90) + await add('e', [12]) // 120 h + expect(await warning()).toBe(100) }) it('is idempotent per usageId and counts every size 1x (legacy large is the same 8 vCPU / 16 GB box)', async () => { @@ -293,6 +307,109 @@ describe('entitlements, overage and usage', () => { expect((await check()).body).toMatchObject({ allow: true }) }) + it('puts running sandboxes to sleep (suspendRunning) when out of hours with overage off', async () => { + const { h, auth } = await subscribedTeam() + const check = () => h.controlPlane('/v1/internal/entitlements/check', { accountId: 'org:o1', machineSize: 'standard', runningSandboxes: 0 }) + for (let i = 0; i < 4; i++) { + expect((await h.controlPlane('/v1/internal/usage', usage(`u-${i}`, 24))).body).toMatchObject({ suspendRunning: false }) + } + expect((await check()).body).toMatchObject({ allow: true, suspendRunning: false }) + // The report that uses the last included hour tells the control plane to put the rest to sleep. + expect((await h.controlPlane('/v1/internal/usage', usage('u-4', 24))).body).toMatchObject({ + canStartSandbox: false, + reason: 'included_hours_exhausted', + suspendRunning: true + }) + expect((await check()).body).toMatchObject({ allow: false, reason: 'included_hours_exhausted', suspendRunning: true }) + const entitlement = (await h.call('GET', '/v1/billing/entitlements', { headers: auth })).body + // Not a payment problem: the payment fields (and the desktop's payment notice) stay clear. + expect(entitlement).toMatchObject({ suspendRunning: true, payment: { pastDueSince: null, suspendRunning: false } }) + // A cap is not a cancellation: no payment suspension, so no data-retention clock. + expect((await h.call('GET', '/v1/internal/suspensions', { headers: { authorization: `Bearer ${CONTROL_TOKEN}` } })).body).toEqual({ accounts: [] }) + + // Turning overage on lifts it. + await h.call('POST', '/v1/billing/overage', { body: { enabled: true }, headers: auth }) + expect((await check()).body).toMatchObject({ allow: true, suspendRunning: false }) + }) + + it('puts running sandboxes to sleep at the spend cap, and lifts it when the cap is raised', async () => { + const { h, auth } = await subscribedTeam({ overage: true, spendCapCents: 300 }) + const check = () => h.controlPlane('/v1/internal/entitlements/check', { accountId: 'org:o1', machineSize: 'standard', runningSandboxes: 0 }) + for (let i = 0; i < 5; i++) { + await h.controlPlane('/v1/internal/usage', usage(`u-${i}`, 24)) + } + expect((await h.controlPlane('/v1/internal/usage', usage('o-1', 2))).body).toMatchObject({ suspendRunning: false }) + expect((await check()).body).toMatchObject({ allow: true, suspendRunning: false }) + expect((await h.controlPlane('/v1/internal/usage', usage('o-2', 1))).body).toMatchObject({ + reason: 'overage_cap_reached', + suspendRunning: true + }) + expect((await check()).body).toMatchObject({ allow: false, reason: 'overage_cap_reached', suspendRunning: true }) + const entitlement = (await h.call('GET', '/v1/billing/entitlements', { headers: auth })).body + expect(entitlement).toMatchObject({ suspendRunning: true, payment: { suspendRunning: false }, overage: { spentCents: 300 } }) + await h.call('POST', '/v1/billing/overage', { body: { enabled: true, spendCapCents: 2500 }, headers: auth }) + expect((await check()).body).toMatchObject({ allow: true, suspendRunning: false }) + }) + + it('on opt-in, moves an item on an older overage price to the current price instead of adding a second', async () => { + const h = await createHarness() + const token = await h.desktopToken({ sub: 'u1', org: 'o1', orgRole: 'owner', email: 'a@example.com' }) + const auth = { authorization: `Bearer ${token}` } + await h.call('POST', '/v1/billing/checkout', { body: { plan: 'team', seats: 2 }, headers: auth }) + const customer = [...h.stripe.customers.keys()][0]! + // Opted in back when overage was $0.75 an hour, then opted out (the item stays). + const subscription = h.stripe.putSubscription({ + customer, + accountId: 'org:o1', + basePrice: 'price_team_month', + overagePrice: 'price_overage_075_hourly', + quantity: 2, + periodStart: PERIOD_START, + periodEnd: PERIOD_END + }) + await h.webhook(checkoutCompleted({ customer, subscription: subscription.id, accountId: 'org:o1' })) + const oldItem = subscription.items.data[1]!.id + // Off by default: no overage, and the old item is not treated as the current one. + expect((await h.call('GET', '/v1/billing/entitlements', { headers: auth })).body.overage).toMatchObject({ optedIn: false, active: false }) + + expect((await h.call('POST', '/v1/billing/overage', { body: { enabled: true }, headers: auth })).status).toBe(200) + expect(subscription.items.data.map((item) => [item.id, item.price.id])).toEqual([ + [subscription.items.data[0]!.id, 'price_team_month'], + [oldItem, 'price_overage'] + ]) + expect(h.stripe.calls.some((call) => call.path === '/v1/subscription_items')).toBe(false) + const swap = h.stripe.calls.find((call) => call.path === `/v1/subscription_items/${oldItem}`)! + expect(swap.form.get('price')).toBe('price_overage') + expect(swap.form.get('proration_behavior')).toBe('none') + expect((await h.call('GET', '/v1/billing/entitlements', { headers: auth })).body.overage).toMatchObject({ optedIn: true, active: true }) + // The item's webhook keeps the id, and a second opt-in changes nothing. + await h.webhook(stripeEvent('customer.subscription.updated', subscription)) + await h.call('POST', '/v1/billing/overage', { body: { enabled: true, spendCapCents: 5000 }, headers: auth }) + expect(subscription.items.data).toHaveLength(2) + expect(h.raw.prepare('SELECT overage_item_id FROM subscriptions').get()).toEqual({ overage_item_id: oldItem }) + }) + + it('on opt-in, removes a stale overage item that sits next to the current one', async () => { + const h = await createHarness() + const token = await h.desktopToken({ sub: 'u1', org: 'o1', orgRole: 'owner', email: 'a@example.com' }) + const auth = { authorization: `Bearer ${token}` } + await h.call('POST', '/v1/billing/checkout', { body: { plan: 'team', seats: 2 }, headers: auth }) + const customer = [...h.stripe.customers.keys()][0]! + const subscription = h.stripe.putSubscription({ + customer, + accountId: 'org:o1', + basePrice: 'price_team_month', + overagePrice: ['price_overage_075_hourly', 'price_overage'], + quantity: 2, + periodStart: PERIOD_START, + periodEnd: PERIOD_END + }) + await h.webhook(checkoutCompleted({ customer, subscription: subscription.id, accountId: 'org:o1' })) + await h.call('POST', '/v1/billing/overage', { body: { enabled: true }, headers: auth }) + expect(subscription.items.data.map((item) => item.price.id)).toEqual(['price_team_month', 'price_overage']) + expect(h.stripe.calls.filter((call) => call.method === 'DELETE')).toHaveLength(1) + }) + it('opting out stops metering immediately and blocks starts past included hours', async () => { const { h, auth } = await subscribedTeam({ overage: true }) await h.call('POST', '/v1/billing/overage', { body: { enabled: false }, headers: auth }) diff --git a/cloud/apps/billing/test/stripe-fake.ts b/cloud/apps/billing/test/stripe-fake.ts index ae46ba2dd52..8c6209990b0 100644 --- a/cloud/apps/billing/test/stripe-fake.ts +++ b/cloud/apps/billing/test/stripe-fake.ts @@ -11,7 +11,15 @@ export type FakeSubscription = { cancel_at_period_end: boolean livemode: boolean metadata: Record - items: { object: 'list'; data: Array<{ id: string; quantity?: number; current_period_start: number; current_period_end: number; price: { id: string } }> } + items: { object: 'list'; data: FakeItem[] } +} + +type FakeItem = { + id: string + quantity?: number + current_period_start: number + current_period_end: number + price: { id: string; recurring: { usage_type: 'licensed' | 'metered' } } } export type RecordedCall = { method: string; path: string; form: URLSearchParams; headers: Headers } @@ -88,6 +96,10 @@ export function createFakeStripe() { const itemId = decodeURIComponent(url.pathname.slice('/v1/subscription_items/'.length)) for (const subscription of subscriptions.values()) { const item = subscription.items.data.find((entry) => entry.id === itemId) + if (item && form.has('price')) { + item.price = { id: form.get('price')!, recurring: { usage_type: 'metered' } } + return respond({ id: item.id, price: item.price }) + } if (item) { item.quantity = Number(form.get('quantity')) return respond({ id: item.id, quantity: item.quantity }) @@ -95,15 +107,26 @@ export function createFakeStripe() { } return Response.json({ error: { code: 'resource_missing', message: 'No such item' } }, { status: 404 }) } + if (method === 'DELETE' && url.pathname.startsWith('/v1/subscription_items/')) { + const itemId = decodeURIComponent(url.pathname.slice('/v1/subscription_items/'.length)) + for (const subscription of subscriptions.values()) { + const index = subscription.items.data.findIndex((entry) => entry.id === itemId) + if (index >= 0) { + subscription.items.data.splice(index, 1) + return Response.json({ id: itemId, object: 'subscription_item', deleted: true }) + } + } + return Response.json({ error: { code: 'resource_missing', message: 'No such item' } }, { status: 404 }) + } if (method === 'POST' && url.pathname === '/v1/subscription_items') { const subscription = subscriptions.get(form.get('subscription') ?? '') if (!subscription) { return Response.json({ error: { code: 'resource_missing', message: 'No such subscription' } }, { status: 404 }) } const base = subscription.items.data[0]! - const item = { + const item: FakeItem = { id: `si_test_${nextId++}`, - price: { id: form.get('price') ?? '' }, + price: { id: form.get('price') ?? '', recurring: { usage_type: 'metered' } }, current_period_start: base.current_period_start, current_period_end: base.current_period_end } @@ -135,7 +158,8 @@ export function createFakeStripe() { customer: string accountId: string basePrice: string - overagePrice?: string + /** Metered overage items already on the subscription (e.g. an older overage price). */ + overagePrice?: string | string[] quantity?: number status?: string periodStart: number @@ -145,10 +169,10 @@ export function createFakeStripe() { const id = `sub_test_${nextId++}` const period = { current_period_start: args.periodStart, current_period_end: args.periodEnd } const data: FakeSubscription['items']['data'] = [ - { id: `si_test_${nextId++}`, quantity: args.quantity ?? 1, price: { id: args.basePrice }, ...period } + { id: `si_test_${nextId++}`, quantity: args.quantity ?? 1, price: { id: args.basePrice, recurring: { usage_type: 'licensed' } }, ...period } ] - if (args.overagePrice) { - data.push({ id: `si_test_${nextId++}`, price: { id: args.overagePrice }, ...period }) + for (const price of [args.overagePrice ?? []].flat()) { + data.push({ id: `si_test_${nextId++}`, price: { id: price, recurring: { usage_type: 'metered' } }, ...period }) } const subscription: FakeSubscription = { id, diff --git a/cloud/apps/billing/wrangler.dev.jsonc b/cloud/apps/billing/wrangler.dev.jsonc index d8f90af061e..9eab816db28 100644 --- a/cloud/apps/billing/wrangler.dev.jsonc +++ b/cloud/apps/billing/wrangler.dev.jsonc @@ -27,7 +27,8 @@ // Stripe TEST MODE objects in acct_1Or7xeKb73TzRkjk (scripts/stripe-test-setup.ts). "STRIPE_PRICE_TEAM_MONTHLY": "price_1UZmKpKb73TzRkjk33aiB2th", "STRIPE_PRICE_TEAM_ANNUAL": "price_1UZmKpKb73TzRkjkS7qv85NI", - "STRIPE_PRICE_OVERAGE": "price_1UaBI9Kb73TzRkjkdxshOPZZ", + // $1.00 per sandbox hour, billed by the minute (unit_amount_decimal 1.666666666667, divide_by 60). + "STRIPE_PRICE_OVERAGE": "price_1UaDDzKb73TzRkjkznGWUEoC", "STRIPE_PORTAL_CONFIGURATION": "bpc_1UZmKvKb73TzRkjk59rIuHbx", "BILLING_RETURN_URL": "https://orca-billing-dev.lovecast.workers.dev/return", "BILLING_JWT_AUDIENCE": "orca-billing", diff --git a/cloud/apps/sandbox-control/test/lifecycle-flow.test.ts b/cloud/apps/sandbox-control/test/lifecycle-flow.test.ts index 7ccdbb818a9..d7a9045ca88 100644 --- a/cloud/apps/sandbox-control/test/lifecycle-flow.test.ts +++ b/cloud/apps/sandbox-control/test/lifecycle-flow.test.ts @@ -251,6 +251,21 @@ describe('sandbox control plane cron', () => { expect((await h.call('GET', `/v1/sandboxes/${a}`)).body.sandbox.status).toBe('suspended') }) + it('at the spend cap, puts every running sandbox of the account to sleep and deletes nothing', async () => { + const h = await createHarness() + const a = (await h.call('POST', '/v1/sandboxes', { body: create })).body.sandbox.id + const b = (await h.call('POST', '/v1/sandboxes', { body: { ...create, workspaceId: 'ws-2' } })).body.sandbox.id + h.billing.usageAnswer = { canStartSandbox: false, reason: 'overage_cap_reached', suspendRunning: true } + h.advance(10 * MIN) + await h.reconcile() + for (const id of [a, b]) { + expect((await h.call('GET', `/v1/sandboxes/${id}`)).body.sandbox.status).toBe('suspended') + } + // Asleep, not deleted: the VMs (and their disks) are kept, and no data-retention clock starts. + expect(h.vercel.boxes.size).toBe(2) + expect(h.raw.prepare(`SELECT COUNT(*) AS n FROM account_holds WHERE reason <> 'active'`).get()).toEqual({ n: 0 }) + }) + it('refuses machine sizes billing does not sell', async () => { const h = await createHarness() expect((await h.call('POST', '/v1/sandboxes', { body: { ...create, machineSize: 'xlarge' } })).status).toBe(400) diff --git a/mobile/src/cloud/cloud-entitlement-copy.test.ts b/mobile/src/cloud/cloud-entitlement-copy.test.ts index 362641a6dde..199ca63e10a 100644 --- a/mobile/src/cloud/cloud-entitlement-copy.test.ts +++ b/mobile/src/cloud/cloud-entitlement-copy.test.ts @@ -21,13 +21,15 @@ describe('entitlementDeniedMessage', () => { expect( entitlementDeniedMessage(denied('included_hours_exhausted'), 'admin', 'existing', t) ).toBe( - 'Out of sandbox hours. Your files are safe. Turn on overage in Orca on your computer, then try again.' + 'Out of sandbox hours. Running sandboxes go to sleep and new ones can’t start. Nothing is deleted, and your files are safe. Turn on overage in Orca on your computer, then try again.' ) expect( entitlementDeniedMessage(denied('included_hours_exhausted'), 'member', 'existing', t) - ).toBe('Out of sandbox hours. Your files are safe. Ask an owner or admin to add hours.') + ).toBe( + 'Out of sandbox hours. Running sandboxes go to sleep and new ones can’t start. Nothing is deleted, and your files are safe. Ask an owner or admin to add hours.' + ) expect(entitlementDeniedMessage(denied('overage_cap_reached'), 'owner', 'create', t)).toBe( - 'Spend cap reached. Nothing was created, and you weren’t charged. Raise the spend cap in Orca on your computer, then try again.' + 'Spend cap reached. Running sandboxes go to sleep and new ones can’t start. Nothing is deleted, and your files are safe. Raise the spend cap in Orca on your computer, then try again.' ) expect(entitlementDeniedMessage(denied('payment_grace_expired'), 'member', 'create', t)).toBe( 'Payment failed, so new sandboxes can’t start. Nothing was created, and you weren’t charged. Ask an owner or admin to update the payment method.' diff --git a/mobile/src/cloud/cloud-entitlement-copy.ts b/mobile/src/cloud/cloud-entitlement-copy.ts index c015fbd7165..a280fc44e18 100644 --- a/mobile/src/cloud/cloud-entitlement-copy.ts +++ b/mobile/src/cloud/cloud-entitlement-copy.ts @@ -70,9 +70,15 @@ function problem( ): string { switch (denial) { case 'hours': - return t('cloud.denied.hours', 'Out of sandbox hours.') + return t( + 'cloud.denied.hours2', + 'Out of sandbox hours. Running sandboxes go to sleep and new ones can’t start.' + ) case 'cap': - return t('cloud.denied.cap', 'Spend cap reached.') + return t( + 'cloud.denied.cap2', + 'Spend cap reached. Running sandboxes go to sleep and new ones can’t start.' + ) case 'payment': return t('cloud.denied.payment', 'Payment failed, so new sandboxes can’t start.') case 'plan': @@ -175,9 +181,12 @@ export function entitlementDeniedMessage( return t('cloud.denied.message', '{{problem}} {{safety}} {{next}}', { problem: problem(denial, when, owner, t), safety: - when === 'create' - ? t('cloud.denied.safety.create', 'Nothing was created, and you weren’t charged.') - : t('cloud.denied.safety.existing', 'Your files are safe.'), + // Out of hours or at the cap, running sandboxes go to sleep, so say what that keeps. + denial === 'hours' || denial === 'cap' + ? t('cloud.denied.safety.asleep', 'Nothing is deleted, and your files are safe.') + : when === 'create' + ? t('cloud.denied.safety.create', 'Nothing was created, and you weren’t charged.') + : t('cloud.denied.safety.existing', 'Your files are safe.'), next: nextStep(denial, manager, when, owner, t) }) } diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json index a79e144ec2c..f83f371ccf4 100644 --- a/mobile/src/i18n/locales/en.json +++ b/mobile/src/i18n/locales/en.json @@ -5128,8 +5128,6 @@ "title": "Connect Orca Cloud" }, "denied": { - "hours": "Out of sandbox hours.", - "cap": "Spend cap reached.", "payment": "Payment failed, so new sandboxes can’t start.", "plan": "Orca Cloud needs a Team plan.", "teamLimit": "Your team is at its limit of running sandboxes (3 per seat).", @@ -5156,8 +5154,11 @@ "message": "{{problem}} {{safety}} {{next}}", "safety": { "create": "Nothing was created, and you weren’t charged.", - "existing": "Your files are safe." - } + "existing": "Your files are safe.", + "asleep": "Nothing is deleted, and your files are safe." + }, + "hours2": "Out of sandbox hours. Running sandboxes go to sleep and new ones can’t start.", + "cap2": "Spend cap reached. Running sandboxes go to sleep and new ones can’t start." }, "wake": { "slowQueued": "Taking longer than usual. Your files are safe. Your prompt sends as soon as it’s awake.", diff --git a/src/cli/cloud/cloud-api-errors.ts b/src/cli/cloud/cloud-api-errors.ts index 419617341a9..0c1d27e95c4 100644 --- a/src/cli/cloud/cloud-api-errors.ts +++ b/src/cli/cloud/cloud-api-errors.ts @@ -143,6 +143,8 @@ export function cloudApiError( ) } const safety = creating ? NOTHING_CREATED : FILES_SAFE + // Out of hours or at the spend cap, billing puts the team's running sandboxes to sleep (never deletes). + const GO_TO_SLEEP = "Running sandboxes go to sleep and new ones can't start." if ( code === 'entitlement_denied' && (reason === 'concurrency_limit' || reason === 'member_concurrency_limit') @@ -170,14 +172,14 @@ export function cloudApiError( if (reason === 'included_hours_exhausted') { return new RuntimeClientError( 'cloud_out_of_hours', - `Your team is out of sandbox hours. ${safety}`, + `Your team is out of sandbox hours. ${GO_TO_SLEEP} ${safety}`, data(['Ask an owner or admin to add hours.']) ) } if (reason === 'overage_cap_reached') { return new RuntimeClientError( 'cloud_spend_cap_reached', - `Your team reached its monthly spend cap. ${safety}`, + `Your team reached its monthly spend cap. ${GO_TO_SLEEP} ${safety}`, data(['Ask an owner or admin to raise the spend cap.']) ) } diff --git a/src/cli/cloud/cloud-glossary.test.ts b/src/cli/cloud/cloud-glossary.test.ts index 44dfddb58d6..02f0506910a 100644 --- a/src/cli/cloud/cloud-glossary.test.ts +++ b/src/cli/cloud/cloud-glossary.test.ts @@ -145,7 +145,7 @@ describe('glossary error copy', () => { ) expect(hours.code).toBe('cloud_out_of_hours') expect(hours.message).toBe( - "Your team is out of sandbox hours. Nothing was created, and you weren't charged." + "Your team is out of sandbox hours. Running sandboxes go to sleep and new ones can't start. Nothing was created, and you weren't charged." ) expect(hours.data).toMatchObject({ nextSteps: ['Ask an owner or admin to add hours.'] }) const cap = cloudApiError( @@ -164,7 +164,9 @@ describe('glossary error copy', () => { { error: 'entitlement_denied', reason: 'included_hours_exhausted' }, { credentials: user } ).message - ).toBe('Your team is out of sandbox hours. Your files are safe.') + ).toBe( + "Your team is out of sandbox hours. Running sandboxes go to sleep and new ones can't start. Your files are safe." + ) }) it('uses the exact 409, admin and rate-limit copy, with codes kept in data', () => { diff --git a/src/cli/cloud/index-cloud-commands.test.ts b/src/cli/cloud/index-cloud-commands.test.ts index f5807db00ff..b6d47003f3a 100644 --- a/src/cli/cloud/index-cloud-commands.test.ts +++ b/src/cli/cloud/index-cloud-commands.test.ts @@ -209,7 +209,7 @@ describe('orca cloud via main()', () => { ) await main(['cloud', 'workspace', 'create', '--name', 'x'], home) expect(err.join('\n')).toContain( - "Your team is out of sandbox hours. Nothing was created, and you weren't charged." + "Your team is out of sandbox hours. Running sandboxes go to sleep and new ones can't start. Nothing was created, and you weren't charged." ) expect(err.join('\n')).toContain('Ask an owner or admin to add hours.') expect(err.join('\n')).not.toMatch(/entitlement_denied|included_hours_exhausted|files are safe/) diff --git a/src/cli/cloud/index-worktree-create-on-cloud.test.ts b/src/cli/cloud/index-worktree-create-on-cloud.test.ts index 8f326c53f3f..c3bdfa9f858 100644 --- a/src/cli/cloud/index-worktree-create-on-cloud.test.ts +++ b/src/cli/cloud/index-worktree-create-on-cloud.test.ts @@ -147,12 +147,12 @@ describe('orca worktree create --on cloud via main()', () => { [ { error: 'entitlement_denied', reason: 'included_hours_exhausted' }, 'cloud_out_of_hours', - "Your team is out of sandbox hours. Nothing was created, and you weren't charged." + "Your team is out of sandbox hours. Running sandboxes go to sleep and new ones can't start. Nothing was created, and you weren't charged." ], [ { error: 'entitlement_denied', reason: 'overage_cap_reached' }, 'cloud_spend_cap_reached', - "Your team reached its monthly spend cap. Nothing was created, and you weren't charged." + "Your team reached its monthly spend cap. Running sandboxes go to sleep and new ones can't start. Nothing was created, and you weren't charged." ], [ { error: 'conflict', reason: 'concurrent_transition' }, diff --git a/src/renderer/src/components/billing/PlanBillingSection.fixture.tsx b/src/renderer/src/components/billing/PlanBillingSection.fixture.tsx index 930dab3c37f..e453da5969b 100644 --- a/src/renderer/src/components/billing/PlanBillingSection.fixture.tsx +++ b/src/renderer/src/components/billing/PlanBillingSection.fixture.tsx @@ -77,6 +77,27 @@ export const overageCapReached = defineFixture({ ) }) +// Overage is off by default: out of included hours, running sandboxes go to sleep. +export const hoursUsedOverageOff = defineFixture({ + width: 600, + render: () => ( + + ) +}) + export const pastDueMember = defineFixture({ width: 600, render: () => ( diff --git a/src/renderer/src/components/billing/PlanBillingUsage.tsx b/src/renderer/src/components/billing/PlanBillingUsage.tsx index 662c5e94884..1a1ca165028 100644 --- a/src/renderer/src/components/billing/PlanBillingUsage.tsx +++ b/src/renderer/src/components/billing/PlanBillingUsage.tsx @@ -138,16 +138,16 @@ export function OverageControls({ const changed = capValid && capDollars.trim() !== savedDollars const paymentFailed = entitlement.payment.pastDueSince !== null const description = t( - 'auto.components.billing.overageDescription6', - 'Optional overage at {{rate}} per sandbox hour, up to a monthly spend cap you set ({{cap}} by default).', + 'auto.components.billing.overageDescription7', + 'Optional overage at {{rate}} per sandbox hour, billed by the minute, up to a monthly spend cap you set ({{cap}} by default).', { rate: formatRateCents(overage.centsPerHour, locale), cap: formatCents(entitlement.offers?.team.defaultSpendCapCents ?? 2500, locale) } ) const descriptionOn = t( - 'auto.components.billing.overageDescriptionOn3', - 'Optional overage at {{rate}} per sandbox hour, up to a monthly spend cap you set.', + 'auto.components.billing.overageDescriptionOn4', + 'Optional overage at {{rate}} per sandbox hour, billed by the minute, up to a monthly spend cap you set.', { rate: formatRateCents(overage.centsPerHour, locale) } ) const capPercent = @@ -303,8 +303,8 @@ export function OverageControls({ {capValid && entitlement.reason === 'overage_cap_reached' ? (

{t( - 'auto.components.billing.capReachedHint', - 'Raise the cap to keep starting sandboxes. Your files are safe.' + 'auto.components.billing.capReachedHint2', + 'Raise the cap to start sandboxes again. Your sandboxes are asleep, and your files are safe.' )}

) : null} diff --git a/src/renderer/src/components/billing/plan-billing-copy.ts b/src/renderer/src/components/billing/plan-billing-copy.ts index 8563da88b66..df35f015606 100644 --- a/src/renderer/src/components/billing/plan-billing-copy.ts +++ b/src/renderer/src/components/billing/plan-billing-copy.ts @@ -48,22 +48,22 @@ export function denyReasonCopy( case 'included_hours_exhausted': return canManage ? t( - 'auto.components.billing.reasonExhausted3', - 'Out of sandbox hours for this month. Turn on overage to keep starting sandboxes. Your files are safe.' + 'auto.components.billing.reasonExhausted4', + 'Out of sandbox hours for this month. Running sandboxes go to sleep and new ones can’t start. Nothing is deleted, and your files are safe. Turn on overage to start sandboxes again.' ) : t( - 'auto.components.billing.reasonExhaustedMember2', - 'Out of sandbox hours for this month. Ask an owner or admin to add hours. Your files are safe.' + 'auto.components.billing.reasonExhaustedMember3', + 'Out of sandbox hours for this month. Running sandboxes go to sleep and new ones can’t start. Nothing is deleted, and your files are safe. Ask an owner or admin to add hours.' ) case 'overage_cap_reached': return canManage ? t( - 'auto.components.billing.reasonCapAdmin2', - 'Spend cap reached. Raise the monthly spend cap to keep starting sandboxes. Your files are safe.' + 'auto.components.billing.reasonCapAdmin3', + 'Spend cap reached. Running sandboxes go to sleep and new ones can’t start. Nothing is deleted, and your files are safe. Raise the monthly spend cap to start sandboxes again.' ) : t( - 'auto.components.billing.reasonCapMember3', - 'Spend cap reached. Ask an owner or admin to raise the spend cap. Your files are safe.' + 'auto.components.billing.reasonCapMember4', + 'Spend cap reached. Running sandboxes go to sleep and new ones can’t start. Nothing is deleted, and your files are safe. Ask an owner or admin to raise the spend cap.' ) } } diff --git a/src/renderer/src/components/billing/plans/TeamPlanCard.tsx b/src/renderer/src/components/billing/plans/TeamPlanCard.tsx index b4cd0843ff7..d4f9d177058 100644 --- a/src/renderer/src/components/billing/plans/TeamPlanCard.tsx +++ b/src/renderer/src/components/billing/plans/TeamPlanCard.tsx @@ -70,8 +70,8 @@ export function TeamPlanCard({ ), t('auto.components.billing.plans.featureReady', 'Ready in seconds, nothing to set up or host'), t( - 'auto.components.billing.plans.featureOverage6', - 'Optional overage at {{rate}} per sandbox hour, up to a monthly spend cap you set ({{cap}} by default)', + 'auto.components.billing.plans.featureOverage7', + 'Optional overage at {{rate}} per sandbox hour, billed by the minute, up to a monthly spend cap you set ({{cap}} by default)', { rate: formatRateCents(team.overageCentsPerHour, locale), cap: formatCents(team.defaultSpendCapCents, locale) diff --git a/src/renderer/src/components/worktree-creation/vm-build-failure-copy.ts b/src/renderer/src/components/worktree-creation/vm-build-failure-copy.ts index 8a55cc84ea8..306d33527ab 100644 --- a/src/renderer/src/components/worktree-creation/vm-build-failure-copy.ts +++ b/src/renderer/src/components/worktree-creation/vm-build-failure-copy.ts @@ -92,12 +92,12 @@ export function vmBuildFailureCopy( ), body: canManageBilling ? t( - 'auto.components.worktree.creation.VmBuildFailureCard.outOfHoursBody', - 'Your team has used this month’s sandbox hours. Turn on overage to keep starting sandboxes. Nothing was created, and you weren’t charged.' + 'auto.components.worktree.creation.VmBuildFailureCard.outOfHoursBody2', + 'Your team has used this month’s sandbox hours, so sandboxes can’t start or keep running. Nothing was created, and you weren’t charged. Turn on overage to start sandboxes again.' ) : t( - 'auto.components.worktree.creation.VmBuildFailureCard.outOfHoursBodyMember2', - 'Your team has used this month’s sandbox hours. Nothing was created, and you weren’t charged. Ask an owner or admin to add hours.' + 'auto.components.worktree.creation.VmBuildFailureCard.outOfHoursBodyMember3', + 'Your team has used this month’s sandbox hours, so sandboxes can’t start or keep running. Nothing was created, and you weren’t charged. Ask an owner or admin to add hours.' ) } case 'spendCap': @@ -108,12 +108,12 @@ export function vmBuildFailureCopy( ), body: canManageBilling ? t( - 'auto.components.worktree.creation.VmBuildFailureCard.spendCapReachedBody', - 'Your team has reached this month’s spend cap. Raise it to keep starting sandboxes. Nothing was created, and you weren’t charged.' + 'auto.components.worktree.creation.VmBuildFailureCard.spendCapReachedBody2', + 'Your team has reached this month’s spend cap, so sandboxes can’t start or keep running. Nothing was created, and you weren’t charged. Raise the cap to start sandboxes again.' ) : t( - 'auto.components.worktree.creation.VmBuildFailureCard.spendCapReachedBodyMember3', - 'Your team has reached this month’s spend cap. Nothing was created, and you weren’t charged. Ask an owner or admin to raise the spend cap.' + 'auto.components.worktree.creation.VmBuildFailureCard.spendCapReachedBodyMember4', + 'Your team has reached this month’s spend cap, so sandboxes can’t start or keep running. Nothing was created, and you weren’t charged. Ask an owner or admin to raise the spend cap.' ) } case 'payment': diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 9c21ee239a7..ba817e06427 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -4195,9 +4195,9 @@ "connectGitHub": "Connect GitHub", "planBody2": "Cloud workspaces are part of the Team plan. Nothing was created, and you weren’t charged.", "outOfHoursTitle": "Out of sandbox hours", - "outOfHoursBody": "Your team has used this month’s sandbox hours. Turn on overage to keep starting sandboxes. Nothing was created, and you weren’t charged.", + "outOfHoursBody2": "Your team has used this month’s sandbox hours, so sandboxes can’t start or keep running. Nothing was created, and you weren’t charged. Turn on overage to start sandboxes again.", "spendCapReachedTitle": "Spend cap reached", - "spendCapReachedBody": "Your team has reached this month’s spend cap. Raise it to keep starting sandboxes. Nothing was created, and you weren’t charged.", + "spendCapReachedBody2": "Your team has reached this month’s spend cap, so sandboxes can’t start or keep running. Nothing was created, and you weren’t charged. Raise the cap to start sandboxes again.", "paymentFailedTitle": "Payment failed", "paymentFailedBody": "New sandboxes can’t start until the payment goes through. Nothing was created, and you weren’t charged.", "teamLimitTitle": "You’re at your team’s limit of running sandboxes", @@ -4217,8 +4217,8 @@ "howToPushToGitHub": "How to push to GitHub", "useThisComputer": "Use this computer instead", "planBodyMember3": "Nothing was created, and you weren’t charged. Ask an owner or admin to choose a Team plan.", - "outOfHoursBodyMember2": "Your team has used this month’s sandbox hours. Nothing was created, and you weren’t charged. Ask an owner or admin to add hours.", - "spendCapReachedBodyMember3": "Your team has reached this month’s spend cap. Nothing was created, and you weren’t charged. Ask an owner or admin to raise the spend cap.", + "outOfHoursBodyMember3": "Your team has used this month’s sandbox hours, so sandboxes can’t start or keep running. Nothing was created, and you weren’t charged. Ask an owner or admin to add hours.", + "spendCapReachedBodyMember4": "Your team has reached this month’s spend cap, so sandboxes can’t start or keep running. Nothing was created, and you weren’t charged. Ask an owner or admin to raise the spend cap.", "paymentFailedBodyMember2": "New sandboxes can’t start until the payment goes through. Nothing was created, and you weren’t charged. Ask an owner or admin to update the payment method." }, "VmBuildLog": { @@ -20623,7 +20623,7 @@ "was": "Was", "featureHours4": "{{value0}} sandbox hours per seat each month, pooled across your team", "featureSpec": "Every sandbox gets {{value0}} vCPU · {{value1}} GB", - "featureOverage6": "Optional overage at {{rate}} per sandbox hour, up to a monthly spend cap you set ({{cap}} by default)", + "featureOverage7": "Optional overage at {{rate}} per sandbox hour, billed by the minute, up to a monthly spend cap you set ({{cap}} by default)", "footTerms4": "Prices in USD, before tax. Overage is off until an owner or admin turns it on. Cancel anytime; your plan runs to the end of the billing period.", "lockedBody2": "Only an owner or admin of your team can choose a plan. Ask one of them to open Plan & billing in Orca.", "vpSecretsBody4": "API keys you add to Orca are never written to the sandbox. Agents and terminals get them as environment variables.", @@ -20694,30 +20694,30 @@ "upsellLineNoPrice": "Orca Cloud runs agents in hosted sandboxes for your whole team.", "askAdmin2": "Ask an owner or admin to change the plan.", "capInvalid": "Enter a whole dollar amount.", - "capReachedHint": "Raise the cap to keep starting sandboxes. Your files are safe.", + "capReachedHint2": "Raise the cap to start sandboxes again. Your sandboxes are asleep, and your files are safe.", "capSaving": "Saving…", "capUsedLabel": "Overage spend cap used", "enterpriseContactSales": "Contact sales", "openForbidden2": "Only an owner or admin can manage billing.", "openSignedOut2": "Sign in to Orca again to manage your team.", - "overageDescription6": "Optional overage at {{rate}} per sandbox hour, up to a monthly spend cap you set ({{cap}} by default).", + "overageDescription7": "Optional overage at {{rate}} per sandbox hour, billed by the minute, up to a monthly spend cap you set ({{cap}} by default).", "overageMemberHint": "Owners and admins can change overage.", "overageOffRead": "Off", "overageOnRead": "On", "overagePaymentFailed": "Update the payment method before changing overage.", "paymentAsleepBody": "New sandboxes can’t start and running sandboxes are asleep. They can wake again once the payment goes through. Your files are safe.", "paymentGraceBody2": "New sandboxes can’t start. Running sandboxes go to sleep on {{value0}} unless the payment goes through.", - "reasonCapAdmin2": "Spend cap reached. Raise the monthly spend cap to keep starting sandboxes. Your files are safe.", - "reasonExhausted3": "Out of sandbox hours for this month. Turn on overage to keep starting sandboxes. Your files are safe.", - "reasonExhaustedMember2": "Out of sandbox hours for this month. Ask an owner or admin to add hours. Your files are safe.", + "reasonCapAdmin3": "Spend cap reached. Running sandboxes go to sleep and new ones can’t start. Nothing is deleted, and your files are safe. Raise the monthly spend cap to start sandboxes again.", + "reasonExhausted4": "Out of sandbox hours for this month. Running sandboxes go to sleep and new ones can’t start. Nothing is deleted, and your files are safe. Turn on overage to start sandboxes again.", + "reasonExhaustedMember3": "Out of sandbox hours for this month. Running sandboxes go to sleep and new ones can’t start. Nothing is deleted, and your files are safe. Ask an owner or admin to add hours.", "reasonInactiveMember": "Your team’s plan is not active. Ask an owner or admin to choose a plan.", "reasonPastDueMember": "Your team’s last payment failed. Ask an owner or admin to update the payment method.", "resetsAndRenews": "Resets and renews {{value0}}", "resetsEnds": "Hours reset {{value0}} · Plan ends {{value1}}", "resetsRenews": "Hours reset {{value0}} · Renews {{value1}}", "sandboxHoursUsedMonth": "{{value0}} of {{value1}} sandbox hours used this month", - "overageDescriptionOn3": "Optional overage at {{rate}} per sandbox hour, up to a monthly spend cap you set.", - "reasonCapMember3": "Spend cap reached. Ask an owner or admin to raise the spend cap. Your files are safe.", + "overageDescriptionOn4": "Optional overage at {{rate}} per sandbox hour, billed by the minute, up to a monthly spend cap you set.", + "reasonCapMember4": "Spend cap reached. Running sandboxes go to sleep and new ones can’t start. Nothing is deleted, and your files are safe. Ask an owner or admin to raise the spend cap.", "reasonGraceExpired3": "Payment failed. New sandboxes can’t start and running sandboxes are asleep. Update your payment method. Your files are safe.", "reasonGraceExpiredMember2": "Payment failed. New sandboxes can’t start and running sandboxes are asleep. Ask an owner or admin to update the payment method. Your files are safe.", "reasonNoPlan2": "Orca Cloud needs a Team plan.", diff --git a/src/shared/orca-billing.ts b/src/shared/orca-billing.ts index 74f544bfd27..f12e089b8c2 100644 --- a/src/shared/orca-billing.ts +++ b/src/shared/orca-billing.ts @@ -99,7 +99,7 @@ export type OrcaBillingEntitlement = { includedSeconds: number usedSeconds: number remainingIncludedSeconds: number - /** Highest included-hours warning level reached (80, 100), or null. */ + /** Highest included-hours warning level reached (75, 90 or 100), or null. */ usageWarningPercent: number | null overageSeconds: number overage: {