mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
Merge pull request #241 from nwparker/cloud/w9-eval
feat(cloud): acceptance — phone UI read-asleep (sim), cloud run.ports headless, nonconcurrent per #233
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"findings": [
|
||||
"<b>Wake delivery (target p95 < 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."
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user