Merge remote-tracking branch 'private/cloud/integration' into cloud/w6-a9-recipe

# Conflicts:
#	src/renderer/src/i18n/locales/en.json
This commit is contained in:
Neil
2026-11-10 04:43:10 -08:00
51 changed files with 1551 additions and 177 deletions
+203 -2
View File
@@ -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
+3 -3
View File
@@ -1,7 +1,8 @@
{
"findings": [
"<b>Wake delivery (target p95 &lt; 6 s) — MET in pdx1:</b> 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).",
"<b>Read while asleep (gap 5, #189/#218), verified by W9 live:</b> 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).",
"<b>run.ports (cloud) passes on a #233 golden (iad1 d323e4a):</b> 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).",
"<b>Read while asleep (gap 5, #189/#218), verified by W9 live:</b> 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.",
"<b>Read-asleep access, triggered by W9 itself:</b> 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.",
"<b>Independent audit corrections (2026-11-10, reports/conductor-parity-audit.html):</b> 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).",
"<b>Agents and machine size (2026-11-10):</b> 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 +29,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."
}
}
+101
View File
@@ -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 <file> 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 <needle> rendered text strings containing <needle> (read-only)
// node cdp.mjs eval '<expr>'
// 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 <file> | identity-clear | texts <needle> | eval <expr>')
}
} finally {
s.close()
clearTimeout(timer)
}
process.exit(0)
@@ -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<string, any> = 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<string, unknown> = {}
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')
}
+50 -1
View File
@@ -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<string, Check> = {
}
},
'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 }, ...(process.env.ORCA_ACCEPTANCE_REGION ? { region: process.env.ORCA_ACCEPTANCE_REGION } : {}) })
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')
+12 -1
View File
@@ -32,7 +32,18 @@ function contains(commit: string, ancestor: string): boolean {
}
for (;;) {
const a = active()
let a: ReturnType<typeof active> = 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) {
+43 -13
View File
@@ -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`
@@ -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<StripeObject & { event_name: string }>(
'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(
+13 -3
View File
@@ -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 }
}
+1 -1
View File
@@ -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
})
}
+45 -8
View File
@@ -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<string> {
// 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
}
+28 -4
View File
@@ -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 {
+19 -2
View File
@@ -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<T>(method: 'GET' | 'POST', path: string, params?: FormRecord, idempotencyKey?: string) {
async function request<T>(method: 'GET' | 'POST' | 'DELETE', path: string, params?: FormRecord, idempotencyKey?: string) {
assertKeyMode(env)
const headers: Record<string, string> = {
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 }
}>
}
}
+3 -2
View File
@@ -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'
+41 -1
View File
@@ -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)
})
})
+122 -5
View File
@@ -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 })
+31 -7
View File
@@ -11,7 +11,15 @@ export type FakeSubscription = {
cancel_at_period_end: boolean
livemode: boolean
metadata: Record<string, string>
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,
+2 -1
View File
@@ -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",
@@ -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-<secret>-<sandbox>`). 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 <value>`). */
altHeaderFormats?: Record<string, readonly ((value: string) => 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.
@@ -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<string, string> = { 'api.anthropic.com': 'ANTHROPIC_API_KEY', 'api.openai.com': 'OPENAI_API_KEY' }
export function secretPlaceholder(row: Pick<SandboxRow, 'id'>, 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. */
@@ -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}`]))
@@ -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 ?? [])
@@ -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)
@@ -28,6 +28,8 @@
// 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.
// 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" }]
},
@@ -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.'
+14 -5
View File
@@ -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)
})
}
+5 -4
View File
@@ -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.",
+4 -2
View File
@@ -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.'])
)
}
+4 -2
View File
@@ -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', () => {
+1 -1
View File
@@ -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/)
@@ -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' },
@@ -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: () => (
<PlanBillingView
{...handlers}
result={{
status: 'ok',
entitlement: {
...sampleTeam,
usedSeconds: sampleTeam.includedSeconds,
remainingIncludedSeconds: 0,
usageWarningPercent: 100,
canStartSandbox: false,
reason: 'included_hours_exhausted'
}
}}
/>
)
})
export const pastDueMember = defineFixture({
width: 600,
render: () => (
@@ -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' ? (
<p className="basis-full text-xs text-muted-foreground">
{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.'
)}
</p>
) : null}
@@ -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.'
)
}
}
@@ -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)
@@ -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,
@@ -10,6 +15,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'
@@ -61,6 +67,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, RunActionRun>): string | null {
const t = useTranslate()
const previous = useRef<Record<string, RunActionRun>>(runs)
const [message, setMessage] = useState<string | null>(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<string, RunActionRun>): 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,12 +126,20 @@ 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).
// 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
}
@@ -92,7 +153,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
})
}}
/>
)
}
@@ -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<HTMLButtonElement>('button[aria-label="Run actions"]')
const trigger = frame.querySelector<HTMLButtonElement>('button[aria-label^="Run actions"]')
trigger?.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, button: 0 }))
}
@@ -121,3 +122,33 @@ export const singleLegacyAction = defineFixture({
</Bar>
)
})
export const failed = defineFixture({
width: 420,
render: () => (
<Bar>
<RunActionButtonView
{...base}
runs={{ web: run(web, { status: 'failed', exitCode: 1 }) }}
announcement="Web failed"
/>
</Bar>
),
interact: openMenu
})
export const exited = defineFixture({
width: 420,
render: () => (
<Bar>
<RunActionButtonView
{...base}
runs={{
web: run(web, { status: 'exited', exitCode: 0 }),
worker: run(worker, { status: 'stopped' })
}}
/>
</Bar>
),
interact: openMenu
})
@@ -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>): RunActionRun {
return {
worktreeId: 'r::/w',
repoId: 'r',
actionId: 'web',
actionName: 'Web',
tabId: 't',
status: 'running',
exitCode: null,
startedAt: 0,
...patch
}
}
function renderView(props: Partial<RunActionButtonViewProps>): void {
render(
<TooltipProvider>
<RunActionButtonView
actions={[web, worker]}
primary={web}
runs={{}}
ports={[]}
shortcut="⌥⌘↩"
onRun={noop}
onStop={noop}
onShowOutput={noop}
onOpenPorts={noop}
onOpenPortInBrowser={noop}
{...props}
/>
</TooltipProvider>
)
}
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')
})
})
@@ -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 (
<span
aria-hidden
data-run-status={status}
className={cn(
'inline-block size-1.5 shrink-0 rounded-full',
status === 'running' && 'bg-status-success',
status === 'stopping' && 'bg-status-warning motion-safe:animate-pulse',
status === 'failed' && 'bg-destructive',
(status === 'exited' || status === 'stopped') && 'bg-muted-foreground/50'
(status === 'exited' || status === 'stopped') && 'border border-muted-foreground'
)}
/>
)
@@ -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<Record<string, RunActionRun>>
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 (
<>
<div className="flex items-center gap-2 px-2 pt-2 text-xs font-medium">
<StatusDot status={run?.status} />
<span className="min-w-0 flex-1 truncate">{action.name}</span>
<span className="min-w-0 flex-1 truncate text-foreground">{action.name}</span>
<span className="shrink-0 font-normal text-muted-foreground">{statusLabel(run)}</span>
</div>
<div
@@ -99,23 +122,42 @@ function ActionRow({
</div>
{live ? (
<>
<DropdownMenuItem onSelect={() => onRun(action)}>
{/* Both wait while the run settles, so a second press can't race the first. */}
<DropdownMenuItem
disabled={stopping}
aria-label={t('runActions.restartNamed', 'Restart {{name}}', name)}
onSelect={() => onRun(action)}
>
<RotateCw className="size-3.5" />
{t('runActions.restart', 'Restart')}
</DropdownMenuItem>
<DropdownMenuItem disabled={run?.status === 'stopping'} onSelect={() => onStop(action)}>
<DropdownMenuItem
disabled={stopping}
aria-label={t('runActions.stopNamed', 'Stop {{name}}', name)}
onSelect={() => onStop(action)}
>
<Square className="size-3.5" />
{t('runActions.stop', 'Stop')}
</DropdownMenuItem>
</>
) : (
<DropdownMenuItem onSelect={() => onRun(action)}>
<DropdownMenuItem
aria-label={
run
? t('runActions.runAgainNamed', 'Run {{name}} again', name)
: t('runActions.runNamed', 'Run {{name}}', name)
}
onSelect={() => onRun(action)}
>
<Play className="size-3.5" />
{run ? t('runActions.runAgain', 'Run again') : t('runActions.run', 'Run')}
</DropdownMenuItem>
)}
{run ? (
<DropdownMenuItem onSelect={() => onShowOutput(run)}>
<DropdownMenuItem
aria-label={t('runActions.showOutputNamed', 'Show {{name}} output', name)}
onSelect={() => onShowOutput(run)}
>
<SquareTerminal className="size-3.5" />
{t('runActions.showOutput', 'Show output')}
</DropdownMenuItem>
@@ -124,6 +166,39 @@ function ActionRow({
)
}
function PortItem({
port,
onOpenPorts,
onOpenPortInBrowser
}: { port: WorkspacePort } & Pick<
RunActionButtonViewProps,
'onOpenPorts' | 'onOpenPortInBrowser'
>): React.JSX.Element {
const t = useTranslate()
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Plug className="size-3.5" />
<span className="font-mono">:{port.port}</span>
<span className="min-w-0 flex-1 truncate text-muted-foreground">
{port.processName ?? ''}
</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onSelect={() => onOpenPortInBrowser(port)}>
<ExternalLink className="size-3.5" />
{t('runActions.openInBrowser', 'Open in browser')}
</DropdownMenuItem>
{/* Forwarding and share links live in the Ports panel, next to this port. */}
<DropdownMenuItem onSelect={onOpenPorts}>
<Share2 className="size-3.5" />
{t('runActions.shareInPorts', 'Share…')}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
)
}
/**
* 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({
<TooltipTrigger asChild>
<button
type="button"
className="flex h-full items-center gap-1.5 rounded-l-md px-2 hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className={cn(
'flex h-full items-center gap-1.5 rounded-l-md px-2 hover:bg-accent hover:text-foreground',
FOCUS_OUTLINE
)}
onClick={onPrimaryClick}
aria-label={primaryLabel}
>
{primaryLive ? <StatusDot status={primaryRun?.status} /> : <Play className="size-3" />}
{primaryRun ? <StatusDot status={primaryRun.status} /> : <Play className="size-3" />}
<span className="max-w-32 truncate">{primary.name}</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{shortcut ? `${primaryLabel} (${shortcut})` : primaryLabel}
{primaryLive ? (
<div className="flex flex-col">
<span>{t('runActions.showOutputTooltip', 'Show {{name}} output', name)}</span>
{shortcut ? (
<span className="text-muted-foreground">
{t('runActions.shortcutRestarts', '{{shortcut}} restarts it', { shortcut })}
</span>
) : null}
</div>
) : shortcut ? (
`${t('runActions.runNamed', 'Run {{name}}', name)} (${shortcut})`
) : (
t('runActions.runNamed', 'Run {{name}}', name)
)}
</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-full items-center gap-1 rounded-r-md border-l border-border/60 px-1 hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
aria-label={t('runActions.menu', 'Run actions')}
className={cn(
'flex h-full min-w-6 items-center justify-center gap-1 rounded-r-md border-l border-border/60 px-1 hover:bg-accent hover:text-foreground',
FOCUS_OUTLINE
)}
aria-label={
liveCount > 0
? t('runActions.menuRunning', 'Run actions, {{count}} running', {
count: liveCount
})
: t('runActions.menu', 'Run actions')
}
>
{anyLive && !primaryLive ? <StatusDot status="running" /> : null}
{liveCount > 0 && !primaryLive ? <StatusDot status="running" /> : null}
<ChevronDown className="size-3" />
</button>
</DropdownMenuTrigger>
@@ -206,18 +314,22 @@ export function RunActionButtonView({
{t('runActions.noPorts', 'No servers listening in this workspace yet.')}
</div>
) : (
ports.slice(0, 6).map((port) => (
<DropdownMenuItem key={port.id} onSelect={onOpenPorts}>
<Plug className="size-3.5" />
<span className="font-mono">:{port.port}</span>
<span className="min-w-0 flex-1 truncate text-muted-foreground">
{port.processName ?? ''}
</span>
</DropdownMenuItem>
))
ports
.slice(0, 6)
.map((port) => (
<PortItem
key={port.id}
port={port}
onOpenPorts={onOpenPorts}
onOpenPortInBrowser={onOpenPortInBrowser}
/>
))
)}
</DropdownMenuContent>
</DropdownMenu>
<span role="status" aria-live="polite" className="sr-only">
{announcement ?? ''}
</span>
</div>
)
}
@@ -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) {
@@ -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]
@@ -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':
@@ -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<TerminalCommandFinishedEventDetail>(ORCA_TERMINAL_COMMAND_FINISHED_EVENT, {
detail: { worktreeId, exitCode, ...(tabId ? { tabId } : {}) }
detail: { worktreeId, exitCode, ...(tabId ? { tabId } : {}), ...(leafId ? { leafId } : {}) }
})
)
}
+30 -16
View File
@@ -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": {
@@ -20624,7 +20624,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.",
@@ -20695,30 +20695,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.",
@@ -27086,24 +27086,38 @@
"running": "Running",
"stopping": "Stopping…",
"stopped": "Stopped",
"exited": "Exited",
"failed": "Failed",
"failedWithCode": "Failed (exit {{code}})",
"notRunning": "Not running"
"notRunning": "Not running",
"finished": "Finished"
},
"restart": "Restart",
"stop": "Stop",
"runAgain": "Run again",
"run": "Run",
"showOutput": "Show output",
"showRunning": "Show {{name}}",
"runNamed": "Run {{name}}",
"menu": "Run actions",
"ports": "Ports",
"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"
},
"agentHostPanel": {
"recipeEnv": "This recipe's create script gets this computer's environment.",
@@ -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<string>
/** 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. */
@@ -10,6 +10,7 @@ type MockState = {
{ id: string; entityId: string; contentType: string; groupId: string }[]
>
ptyIdsByTabId: Record<string, string[]>
terminalLayoutsByTabId: Record<string, unknown>
createTab: ReturnType<typeof vi.fn>
closeTab: ReturnType<typeof vi.fn>
setTabCustomTitle: ReturnType<typeof vi.fn>
@@ -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<string, string>()
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',
@@ -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
@@ -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<boolean> {
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<typeof setTimeout> | 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<void> {
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
}
@@ -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-<id>`; 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<AppState, 'tabsByWorktree' | 'terminalLayoutsByTabId'>,
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<AppState, 'tabsByWorktree' | 'terminalLayoutsByTabId' | 'ptyIdsByTabId'>,
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]
}
@@ -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<string, unknown> = { ...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<Pick<RunActionRun, 'status' | 'exitCode'>>
patch: Partial<Pick<RunActionRun, 'status' | 'exitCode' | 'leafId'>>
): void {
const current = runs[worktreeId]?.[actionId]
if (!current) {
+1 -1
View File
@@ -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: {