mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
Merge remote-tracking branch 'private/cloud/integration' into cloud/mfa-enterprise-row
# Conflicts: # src/renderer/src/components/team-mfa/team-mfa-copy.ts # src/renderer/src/i18n/locales/en.json
This commit is contained in:
@@ -129,7 +129,7 @@ type AccountState = {
|
||||
|
||||
export { entitlementsMode, type EntitlementsMode } from './entitlements-mode'
|
||||
|
||||
const LIVE_STATUSES = new Set(['active', 'trialing'])
|
||||
export const LIVE_STATUSES = new Set(['active', 'trialing'])
|
||||
|
||||
export function subscriptionWindow(subscription: SubscriptionRow, at: number): { start: number; end: number } {
|
||||
return allowanceWindow({ start: subscription.current_period_start, end: subscription.current_period_end }, at)
|
||||
|
||||
@@ -5,13 +5,19 @@
|
||||
// live grants in every mode but 'off' (where nothing projects grants, so nothing is on). The
|
||||
// legacy computation has no features, so there is nothing for shadow mode to compare.
|
||||
//
|
||||
// POST /v1/internal/entitlements/features {accountId} -> { features: { audit_log, sso, scim, mfa_enforcement } }
|
||||
// POST /v1/internal/entitlements/features {accountId}
|
||||
// -> { features: { audit_log, sso, scim, mfa_enforcement }, subscribed }
|
||||
// `subscribed` (hasPaidSubscription) is what auth counts as Orca Cloud access for the account, so a
|
||||
// paying team is never sent to "Choose a Team plan" because it isn't on the per-account rollout.
|
||||
// Callers: the control plane (CONTROL_PLANE_TOKEN) and auth (MEMBERSHIP_SYNC_TOKEN). Both fail closed.
|
||||
import { z } from 'zod'
|
||||
import { FEATURE_KEYS, type FeatureFlags } from './entitlement-keys'
|
||||
import { entitlementsMode } from './entitlements-mode'
|
||||
import type { Env } from './env'
|
||||
import { LIVE_STATUSES } from './entitlements'
|
||||
import { grantsForSubject, liveAt } from './grants'
|
||||
import { currentSubscription } from './store'
|
||||
import { PAYMENT_FAILED_STATUSES } from './webhook'
|
||||
|
||||
export const FeaturesRequestSchema = z.object({ accountId: z.string().min(1).max(160).regex(/^[A-Za-z0-9_.:-]+$/) }).strict()
|
||||
|
||||
@@ -35,3 +41,15 @@ export async function featuresFor(env: Pick<Env, 'BILLING_DB' | 'ENTITLEMENTS_MO
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the account holds a paid subscription that is live (active, trialing) or in payment
|
||||
* recovery (past_due, unpaid): a paying customer, whatever its hours or payment state, whose
|
||||
* problems the entitlement itself reports. Canceled, incomplete and expired subscriptions, the
|
||||
* no-card trial and no subscription at all are false. Independent of ENTITLEMENTS_MODE: it reads
|
||||
* the subscription row Stripe's webhooks keep, as the legacy entitlement does.
|
||||
*/
|
||||
export async function hasPaidSubscription(db: D1Database, accountId: string): Promise<boolean> {
|
||||
const subscription = await currentSubscription(db, accountId)
|
||||
return subscription !== null && (LIVE_STATUSES.has(subscription.status) || PAYMENT_FAILED_STATUSES.has(subscription.status))
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
// POST /v1/stripe/webhook signed events
|
||||
// Sandbox control plane (CONTROL_PLANE_TOKEN):
|
||||
// POST /v1/internal/entitlements/check {accountId, machineSize, runningSandboxes}
|
||||
// POST /v1/internal/entitlements/features {accountId} -> {features: {audit_log, sso, scim, mfa_enforcement}} plan feature flags (control plane or auth)
|
||||
// POST /v1/internal/entitlements/features {accountId} -> {features: {audit_log, sso, scim, mfa_enforcement}, subscribed} plan feature flags and paid-subscription access (control plane or auth)
|
||||
// POST /v1/internal/usage {usageId, accountId, sandboxId, machineSize, startedAt, endedAt}
|
||||
// GET /v1/internal/suspensions accounts whose payment grace expired (suspend running)
|
||||
// POST /v1/internal/trial/github {accountId, githubUserId?, githubOwnerId?} GitHub binding -> trial abuse keys
|
||||
@@ -58,7 +58,7 @@ import { MACHINE_SIZES, PlanConfigError, planCatalog, publicOffers } from './pla
|
||||
import { OverageRequestSchema, updateOverage } from './overage'
|
||||
import { LiveModeRefusedError, StripeError, createStripeClient, type StripeClient } from './stripe'
|
||||
import { UsageReportSchema, flushMeterReports, recordUsage, validateSegment } from './usage'
|
||||
import { FeaturesRequestSchema, featuresFor } from './features'
|
||||
import { FeaturesRequestSchema, featuresFor, hasPaidSubscription } from './features'
|
||||
import { handleStripeWebhook } from './webhook'
|
||||
import { UsageHistoryQuerySchema, usageHistory } from './usage-history'
|
||||
import { renderReturnPage, returnOutcome } from './return-page'
|
||||
@@ -526,7 +526,11 @@ export async function handleRequest(request: Request, env: Env, ctx: ExecutionCo
|
||||
if (!body.success) {
|
||||
return json({ error: 'invalid_request' }, 400)
|
||||
}
|
||||
return json({ features: await featuresFor(env, body.data.accountId, now) })
|
||||
const [features, subscribed] = await Promise.all([
|
||||
featuresFor(env, body.data.accountId, now),
|
||||
hasPaidSubscription(env.BILLING_DB, body.data.accountId)
|
||||
])
|
||||
return json({ features, subscribed })
|
||||
}
|
||||
|
||||
case 'POST /v1/internal/usage': {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { featuresFor } from '../src/features'
|
||||
import { grantContactSalesPlan, grantsForSource, revokeManualGrant } from '../src/grants'
|
||||
import { repoCatalog } from './catalog-fixture'
|
||||
import { createHarness } from './harness'
|
||||
import { checkoutCompleted } from './stripe-fake'
|
||||
|
||||
const ALL_OFF = { audit_log: false, sso: false, scim: false, mfa_enforcement: false }
|
||||
const ALL_ON = { audit_log: true, sso: true, scim: true, mfa_enforcement: true }
|
||||
@@ -50,11 +51,11 @@ describe('catalog', () => {
|
||||
describe.each(['shadow', 'on'])('features (ENTITLEMENTS_MODE=%s)', (mode) => {
|
||||
it('are off for a team without Enterprise and on for one granted the plan', async () => {
|
||||
const h = await createHarness({ ENTITLEMENTS_MODE: mode })
|
||||
expect((await features(h)).body).toEqual({ features: ALL_OFF })
|
||||
expect((await features(h)).body).toEqual({ features: ALL_OFF, subscribed: false })
|
||||
expect(await enterprise(h)).toEqual({ changed: true, planVersion: 2 })
|
||||
expect((await features(h)).body).toEqual({ features: ALL_ON })
|
||||
expect((await features(h)).body).toEqual({ features: ALL_ON, subscribed: false })
|
||||
// Another team is unaffected.
|
||||
expect((await features(h, 'org:o2')).body).toEqual({ features: ALL_OFF })
|
||||
expect((await features(h, 'org:o2')).body).toEqual({ features: ALL_OFF, subscribed: false })
|
||||
})
|
||||
|
||||
it('the grant names the real plan and version, and stops when it ends or is revoked', async () => {
|
||||
@@ -76,7 +77,7 @@ describe('features', () => {
|
||||
const h = await createHarness({ ENTITLEMENTS_MODE: 'shadow' })
|
||||
await enterprise(h)
|
||||
h.env.ENTITLEMENTS_MODE = 'off'
|
||||
expect((await features(h)).body).toEqual({ features: ALL_OFF })
|
||||
expect((await features(h)).body).toEqual({ features: ALL_OFF, subscribed: false })
|
||||
})
|
||||
|
||||
it('only a contact-sales plan can be granted this way', async () => {
|
||||
@@ -121,3 +122,43 @@ describe('features', () => {
|
||||
expect(body.grants.find((grant: { key: string }) => grant.key === 'audit_log')).toMatchObject({ value: true, source: 'manual', plan: { id: 'enterprise', version: 2 }, state: 'active' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('subscribed', () => {
|
||||
const PERIOD_START = Math.floor(Date.now() / 1000) - 5 * 24 * 3600
|
||||
const PERIOD_END = PERIOD_START + 30 * 24 * 3600
|
||||
|
||||
// A paid Team subscription for org:o1, through checkout and Stripe's webhook as in production.
|
||||
async function paidTeam(mode = 'shadow') {
|
||||
const h = await createHarness({ ENTITLEMENTS_MODE: mode })
|
||||
const token = await h.desktopToken({ sub: 'u1', org: 'o1', orgRole: 'owner', email: 'a@example.com' })
|
||||
const checkout = await h.call('POST', '/v1/billing/checkout', { body: { plan: 'team', seats: 2 }, headers: { authorization: `Bearer ${token}` } })
|
||||
expect(checkout.status).toBe(200)
|
||||
const customer = [...h.stripe.customers.keys()][0]!
|
||||
const subscription = h.stripe.putSubscription({ customer, accountId: 'org:o1', basePrice: 'price_team_month', quantity: 2, periodStart: PERIOD_START, periodEnd: PERIOD_END })
|
||||
await h.webhook(checkoutCompleted({ customer, subscription: subscription.id, accountId: 'org:o1' }))
|
||||
return h
|
||||
}
|
||||
const setStatus = (h: Awaited<ReturnType<typeof createHarness>>, status: string) => h.raw.prepare('UPDATE subscriptions SET status = ? WHERE account_id = ?').run(status, 'org:o1')
|
||||
|
||||
it('is true for a team paying for a plan, whatever its hours, and false for everyone else', async () => {
|
||||
for (const mode of ['off', 'shadow', 'on']) {
|
||||
const h = await paidTeam(mode)
|
||||
expect((await features(h)).body).toMatchObject({ subscribed: true })
|
||||
// Another team, and the owner's personal account, have no subscription.
|
||||
expect((await features(h, 'org:o2')).body).toMatchObject({ subscribed: false })
|
||||
expect((await features(h, 'user:u1')).body).toMatchObject({ subscribed: false })
|
||||
}
|
||||
})
|
||||
|
||||
it('stays true while a payment is being retried, and ends with the subscription', async () => {
|
||||
const h = await paidTeam()
|
||||
for (const status of ['trialing', 'past_due', 'unpaid']) {
|
||||
setStatus(h, status)
|
||||
expect((await features(h)).body, status).toMatchObject({ subscribed: true })
|
||||
}
|
||||
for (const status of ['canceled', 'incomplete', 'incomplete_expired', 'paused']) {
|
||||
setStatus(h, status)
|
||||
expect((await features(h)).body, status).toMatchObject({ subscribed: false })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -188,12 +188,18 @@ function repoOwner(accountId: string, repo: string): string {
|
||||
return `${accountId}#${repo}`
|
||||
}
|
||||
|
||||
/** The owners whose secrets a sandbox session receives. */
|
||||
export function ownersForSandbox(row: Pick<SandboxRow, 'account_id' | 'user_id' | 'repo_url'>) {
|
||||
/**
|
||||
* The owners whose secrets a sandbox session receives.
|
||||
* SEC-5: a workspace shared with teammates runs their shells too, so anything in its env is theirs to
|
||||
* read (and a placeholder is theirs to spend). It gets the team's and the repository's secrets only,
|
||||
* never its owner's personal ones, the same rule as personal accounts (D13). Fails closed: only a row
|
||||
* known to be private counts as unshared.
|
||||
*/
|
||||
export function ownersForSandbox(row: Pick<SandboxRow, 'account_id' | 'user_id' | 'repo_url' | 'visibility'>) {
|
||||
const repo = normalizeRepo(row.repo_url)
|
||||
return {
|
||||
org: ORG_ACCOUNT.test(row.account_id) ? row.account_id : null,
|
||||
personal: personalOwner(row.user_id, row.account_id.startsWith('dev:')),
|
||||
personal: row.visibility === 'private' ? personalOwner(row.user_id, row.account_id.startsWith('dev:')) : null,
|
||||
repo: repo ? repoOwner(row.account_id, repo) : null
|
||||
}
|
||||
}
|
||||
@@ -352,16 +358,22 @@ export type ResolvedSecrets = { env: Record<string, string>; scopes: Record<stri
|
||||
export async function resolveSandboxSecrets(
|
||||
db: D1Database,
|
||||
keyring: Keyring,
|
||||
row: Pick<SandboxRow, 'id' | 'account_id' | 'user_id' | 'repo_url'>
|
||||
row: Pick<SandboxRow, 'id' | 'account_id' | 'user_id' | 'repo_url' | 'visibility'>
|
||||
): Promise<ResolvedSecrets> {
|
||||
const owners = ownersForSandbox(row)
|
||||
const pairs: [SecretScope, string][] = [['personal', owners.personal]]
|
||||
const pairs: [SecretScope, string][] = []
|
||||
if (owners.personal) {
|
||||
pairs.push(['personal', owners.personal])
|
||||
}
|
||||
if (owners.org) {
|
||||
pairs.push(['org', owners.org])
|
||||
}
|
||||
if (owners.repo) {
|
||||
pairs.push(['repo', owners.repo])
|
||||
}
|
||||
if (pairs.length === 0) {
|
||||
return { env: {}, scopes: {}, skipped: 0 }
|
||||
}
|
||||
const where = pairs.map(() => '(scope = ? AND owner_id = ?)').join(' OR ')
|
||||
const { results } = await db
|
||||
.prepare(`SELECT * FROM secrets WHERE ${where}`)
|
||||
|
||||
@@ -29,7 +29,7 @@ export type ProviderEnv = { env: Record<string, string>; scopes: Record<string,
|
||||
export async function resolvedProviderEnv(
|
||||
db: D1Database,
|
||||
keyring: Keyring,
|
||||
row: Pick<SandboxRow, 'id' | 'account_id' | 'user_id' | 'repo_url'>
|
||||
row: Pick<SandboxRow, 'id' | 'account_id' | 'user_id' | 'repo_url' | 'visibility'>
|
||||
): Promise<ProviderEnv> {
|
||||
const { env: stored, scopes } = await resolveSandboxSecrets(db, keyring, row)
|
||||
// The proxy only needs the keys; dropping those of agents not on 'keep' happens at request time
|
||||
@@ -45,7 +45,8 @@ const PROVIDER_ENV_CACHE_MS = 60_000
|
||||
const providerEnvCache = new Map<string, { at: number; value: Promise<ProviderEnv> }>()
|
||||
|
||||
export function cachedProviderEnv(db: D1Database, keyring: Keyring, row: SandboxRow, now: number): Promise<ProviderEnv> {
|
||||
const key = `${row.id}:${row.session_id ?? ''}`
|
||||
// Visibility is part of the key: sharing a workspace stops its owner's personal keys at once (SEC-5).
|
||||
const key = `${row.id}:${row.session_id ?? ''}:${row.visibility}`
|
||||
const hit = providerEnvCache.get(key)
|
||||
if (hit && now - hit.at < PROVIDER_ENV_CACHE_MS) {
|
||||
return hit.value
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
// SEC-5: a workspace shared with teammates runs their shells too, so nothing personal to its owner may
|
||||
// be in its env: not a variable's value, and not a placeholder that would spend the owner's model key.
|
||||
import { SignJWT, createLocalJWKSet, exportJWK, generateKeyPair } from 'jose'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ENV_COMMAND, START_COMMAND } from '../src/sandbox-vm-commands'
|
||||
import { ownersForSandbox } from '../src/secrets'
|
||||
import { createHarness } from './harness'
|
||||
|
||||
const KEK = Buffer.alloc(32, 7).toString('base64')
|
||||
const PROXY_URL = 'https://sandbox.test/v1/egress/proxy'
|
||||
// Fake values only.
|
||||
const TEAM_KEY = 'sk-ant-api03-TEAM-fake-0000-aaaa'
|
||||
const PERSONAL_OPENAI = 'sk-proj-fake-personal-openai-2222'
|
||||
const PERSONAL_VALUES = [PERSONAL_OPENAI, 'personal-db-password', 'personal-foo-value', 'personal-aws-secret-value']
|
||||
|
||||
type Harness = Awaited<ReturnType<typeof createHarness>>
|
||||
|
||||
const logs: string[] = []
|
||||
beforeEach(() => {
|
||||
logs.length = 0
|
||||
for (const method of ['log', 'info', 'warn', 'error', 'debug'] as const) {
|
||||
vi.spyOn(console, method).mockImplementation((...args: unknown[]) => {
|
||||
logs.push(args.map((arg) => (typeof arg === 'string' ? arg : JSON.stringify(arg))).join(' '))
|
||||
})
|
||||
}
|
||||
})
|
||||
afterEach(() => {
|
||||
for (const value of [...PERSONAL_VALUES, TEAM_KEY]) {
|
||||
expect(logs.join('\n')).not.toContain(value)
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function decodeEnv(encoded: string | undefined): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
(encoded ?? '')
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const at = line.indexOf('=')
|
||||
return [line.slice(0, at), Buffer.from(line.slice(at + 1), 'base64').toString('utf8')]
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** The env the VM got at its last boot, or from its last live refresh. */
|
||||
function vmEnv(h: Harness, command: string = START_COMMAND): Record<string, string> {
|
||||
return decodeEnv(h.vercel.commands.filter((c) => c.command === command).at(-1)!.env.ORCA_SECRET_ENV)
|
||||
}
|
||||
|
||||
/** Everything the control plane ever sent the VM, as one string: no personal value may be in it. */
|
||||
function everythingSent(h: Harness, from = 0): string {
|
||||
return JSON.stringify(
|
||||
h.vercel.commands.slice(from).map((c) => ({ ...c, env: { ...c.env, ORCA_SECRET_ENV: decodeEnv(c.env.ORCA_SECRET_ENV) } }))
|
||||
)
|
||||
}
|
||||
|
||||
async function seeded(overrides: Record<string, string> = {}, deps: Parameters<typeof createHarness>[1] = {}) {
|
||||
const h = await createHarness({ SECRETS_KEK: KEK, ...overrides }, deps)
|
||||
const admin = await h.token('ada', 'acme', 'sandbox', { orgRole: 'admin' })
|
||||
const mia = await h.token('mia', 'acme', 'sandbox', { orgRole: 'member' })
|
||||
const put = (scope: string, name: string, value: string, bearer: string, repo?: string) =>
|
||||
h.call('PUT', `/v1/secrets/${scope}/${name}${repo ? `?repo=${encodeURIComponent(repo)}` : ''}`, { body: { value }, bearer })
|
||||
await put('org', 'ANTHROPIC_API_KEY', TEAM_KEY, admin)
|
||||
await put('org', 'FOO', 'team-foo-value', admin)
|
||||
await put('repo', 'BAR', 'repo-bar-value', admin, 'acme/app')
|
||||
await put('personal', 'OPENAI_API_KEY', PERSONAL_OPENAI, mia)
|
||||
await put('personal', 'DB_PASSWORD', 'personal-db-password', mia)
|
||||
await put('personal', 'FOO', 'personal-foo-value', mia)
|
||||
await put('personal', 'AWS_SECRET_ACCESS_KEY', 'personal-aws-secret-value', mia)
|
||||
const create = (visibility?: 'team' | 'private') =>
|
||||
h.call('POST', '/v1/sandboxes', {
|
||||
body: { workspaceId: 'ws-1', machineSize: 'standard', repo: { url: 'https://github.com/acme/app' }, ...(visibility ? { visibility } : {}) },
|
||||
bearer: mia
|
||||
})
|
||||
const share = (id: string, visibility: 'team' | 'private') =>
|
||||
h.call('PATCH', `/v1/team/workspaces/${id}`, { body: { visibility }, bearer: mia })
|
||||
return { h, mia, admin, put, create, share }
|
||||
}
|
||||
|
||||
const TEAM_AND_REPO = { ANTHROPIC_API_KEY: TEAM_KEY, BAR: 'repo-bar-value', FOO: 'team-foo-value' }
|
||||
|
||||
describe('personal secrets in shared workspaces (SEC-5)', () => {
|
||||
it('resolves no personal owner for a shared row, and fails closed on an unknown visibility', () => {
|
||||
const row = { account_id: 'org:acme', user_id: 'mia', repo_url: 'https://github.com/acme/app' }
|
||||
expect(ownersForSandbox({ ...row, visibility: 'private' }).personal).toBe('user:mia')
|
||||
expect(ownersForSandbox({ ...row, visibility: 'team' })).toEqual({ org: 'org:acme', personal: null, repo: 'org:acme#github.com/acme/app' })
|
||||
expect(ownersForSandbox({ ...row, visibility: undefined as never }).personal).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the owner’s personal secrets in their own private workspace', async () => {
|
||||
const { h, create } = await seeded()
|
||||
expect((await create()).status).toBe(200)
|
||||
expect(vmEnv(h)).toEqual({
|
||||
...TEAM_AND_REPO,
|
||||
AWS_SECRET_ACCESS_KEY: 'personal-aws-secret-value',
|
||||
DB_PASSWORD: 'personal-db-password',
|
||||
FOO: 'personal-foo-value',
|
||||
OPENAI_API_KEY: PERSONAL_OPENAI
|
||||
})
|
||||
})
|
||||
|
||||
it('starts a shared workspace with team and repository secrets only; the team value replaces a personal override', async () => {
|
||||
const { h, create } = await seeded()
|
||||
expect((await create('team')).status).toBe(200)
|
||||
expect(vmEnv(h)).toEqual(TEAM_AND_REPO)
|
||||
for (const value of PERSONAL_VALUES) {
|
||||
expect(everythingSent(h)).not.toContain(value)
|
||||
}
|
||||
})
|
||||
|
||||
it('drops them from a running workspace the moment it is shared, and brings them back when it is private again', async () => {
|
||||
const { h, create, share, mia } = await seeded()
|
||||
const id: string = (await create()).body.sandbox.id
|
||||
expect(vmEnv(h).DB_PASSWORD).toBe('personal-db-password')
|
||||
const before = h.vercel.commands.length
|
||||
|
||||
expect((await share(id, 'team')).status).toBe(200)
|
||||
expect(vmEnv(h, ENV_COMMAND)).toEqual(TEAM_AND_REPO)
|
||||
for (const value of PERSONAL_VALUES) {
|
||||
expect(everythingSent(h, before)).not.toContain(value)
|
||||
}
|
||||
// A wake while shared resolves the same way.
|
||||
await h.call('POST', `/v1/sandboxes/${id}/suspend`, { bearer: mia })
|
||||
await h.call('POST', `/v1/sandboxes/${id}/resume`, { bearer: mia })
|
||||
expect(vmEnv(h)).toEqual(TEAM_AND_REPO)
|
||||
|
||||
expect((await share(id, 'private')).status).toBe(200)
|
||||
expect(vmEnv(h, ENV_COMMAND)).toMatchObject({ DB_PASSWORD: 'personal-db-password', FOO: 'personal-foo-value' })
|
||||
})
|
||||
|
||||
it('does not pick up a personal secret the owner saves while the workspace is shared', async () => {
|
||||
const { h, create, put, mia } = await seeded()
|
||||
await create('team')
|
||||
await put('personal', 'LATER', 'personal-db-password', mia)
|
||||
const refreshes = h.vercel.commands.filter((c) => c.command === ENV_COMMAND)
|
||||
for (const refresh of refreshes) {
|
||||
expect(decodeEnv(refresh.env.ORCA_SECRET_ENV)).toEqual(TEAM_AND_REPO)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('personal model keys in shared workspaces with egress placeholders (SEC-5)', () => {
|
||||
async function placeholderSetup() {
|
||||
const { privateKey, publicKey } = await generateKeyPair('RS256')
|
||||
const jwks = { keys: [{ ...(await exportJWK(publicKey)), kid: 'v1', alg: 'RS256' }] }
|
||||
const upstream: { url: string; headers: Headers }[] = []
|
||||
const ctx = await seeded(
|
||||
{ SECRETS_EGRESS_PLACEHOLDERS: 'on', EGRESS_PROXY_URL: PROXY_URL },
|
||||
{
|
||||
egressKeysForIssuer: () => createLocalJWKSet(jwks),
|
||||
credentials: { authorizationFor: async () => 'Bearer ghs_fake' },
|
||||
egressFetch: async (input, init) => {
|
||||
upstream.push({ url: String(input), headers: new Headers(init?.headers) })
|
||||
return new Response('ok', { status: 200 })
|
||||
}
|
||||
}
|
||||
)
|
||||
async function proxied(sandboxId: string, host: string, headers: Record<string, string>) {
|
||||
const token = await new SignJWT({ team_id: 'team_orca', project_id: 'prj_orca', sandbox_name: `orca-test-${sandboxId}` })
|
||||
.setProtectedHeader({ alg: 'RS256', kid: 'v1' })
|
||||
.setIssuer('https://oidc.vercel.com/team_orca')
|
||||
.setAudience(PROXY_URL)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('5m')
|
||||
.sign(privateKey)
|
||||
const response = await ctx.h.send(
|
||||
new Request(`${PROXY_URL}/v1/messages`, {
|
||||
method: 'POST',
|
||||
body: '{"model":"x"}',
|
||||
headers: {
|
||||
'vercel-forwarded-host': host,
|
||||
'vercel-forwarded-scheme': 'https',
|
||||
'vercel-forwarded-port': '443',
|
||||
'vercel-forwarded-path': '/v1/messages',
|
||||
'vercel-sandbox-oidc-token': token,
|
||||
...headers
|
||||
}
|
||||
})
|
||||
)
|
||||
return response.status
|
||||
}
|
||||
return { ...ctx, proxied, upstream }
|
||||
}
|
||||
|
||||
it('gives a shared workspace no placeholder and no route for the owner’s personal key; the team key still works', async () => {
|
||||
const { h, create, proxied, upstream } = await placeholderSetup()
|
||||
const id: string = (await create('team')).body.sandbox.id
|
||||
const env = vmEnv(h)
|
||||
expect(env.ANTHROPIC_API_KEY).toBe(`orca-ph-anthropic-api-key-${id}`)
|
||||
expect(env).not.toHaveProperty('OPENAI_API_KEY')
|
||||
expect(env).not.toHaveProperty('DB_PASSWORD')
|
||||
const [box] = [...h.vercel.boxes.values()]
|
||||
// No forward rule for the personal key: the host is plain allowlisted, nothing is swapped there.
|
||||
expect(box!.policy.allow['api.openai.com']).toEqual([])
|
||||
expect(await proxied(id, 'api.openai.com', { authorization: `Bearer orca-ph-openai-api-key-${id}` })).toBe(403)
|
||||
expect(await proxied(id, 'api.anthropic.com', { 'x-api-key': `orca-ph-anthropic-api-key-${id}` })).toBe(200)
|
||||
expect(upstream).toHaveLength(1)
|
||||
expect(upstream[0]!.headers.get('x-api-key')).toBe(TEAM_KEY)
|
||||
})
|
||||
|
||||
it('stops swapping the owner’s personal key at the proxy as soon as a running workspace is shared', async () => {
|
||||
const { h, create, share, proxied, upstream } = await placeholderSetup()
|
||||
const id: string = (await create()).body.sandbox.id
|
||||
const placeholder = `orca-ph-openai-api-key-${id}`
|
||||
expect(vmEnv(h).OPENAI_API_KEY).toBe(placeholder)
|
||||
expect(await proxied(id, 'api.openai.com', { authorization: `Bearer ${placeholder}` })).toBe(200)
|
||||
expect(upstream.at(-1)!.headers.get('authorization')).toBe(`Bearer ${PERSONAL_OPENAI}`)
|
||||
|
||||
expect((await share(id, 'team')).status).toBe(200)
|
||||
expect(vmEnv(h, ENV_COMMAND)).not.toHaveProperty('OPENAI_API_KEY')
|
||||
// A process still holding the old placeholder gets nothing for it.
|
||||
expect(await proxied(id, 'api.openai.com', { authorization: `Bearer ${placeholder}` })).toBe(403)
|
||||
expect(upstream).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -73,11 +73,6 @@ export async function createCloudWorkspaceFromGitHub(
|
||||
onProgress: (progress) => io.onLog?.(hostedProgressLine(progress))
|
||||
}
|
||||
)
|
||||
if (io.signal?.aborted) {
|
||||
// Cancelled just as it answered: nothing will open it, so it must not keep running (and billing).
|
||||
await client.destroy(ready.sandbox.id).catch(() => undefined)
|
||||
return { ok: false, code: 'failed', message: 'Cancelled.' }
|
||||
}
|
||||
return { ok: true, value: { sandboxId: ready.sandbox.id } }
|
||||
} catch (error) {
|
||||
const result = hostedCreateFailure(error)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { parseGitHubInstallRights } from './github-repo-ids'
|
||||
|
||||
it("reads gh's admin flag and owner type, and nothing else", () => {
|
||||
expect(parseGitHubInstallRights('true\nOrganization\n')).toEqual({
|
||||
admin: true,
|
||||
ownerType: 'Organization'
|
||||
})
|
||||
expect(parseGitHubInstallRights('false\nUser')).toEqual({ admin: false, ownerType: 'User' })
|
||||
// No permissions block (gh not signed in to this host) or an unknown owner type: can't tell.
|
||||
expect(parseGitHubInstallRights('null\nUser')).toBeNull()
|
||||
expect(parseGitHubInstallRights('true\nEnterprise')).toBeNull()
|
||||
expect(parseGitHubInstallRights('')).toBeNull()
|
||||
})
|
||||
@@ -3,6 +3,7 @@
|
||||
// private repository's id isn't readable anonymously, and the control plane never sees a user token.
|
||||
// Best effort: no gh, not signed in, or slow means the plain install page instead.
|
||||
import { ghExecFileAsync } from '../git/command-runner/hosted-cli/gh-exec-file'
|
||||
import type { GitHubInstallRights } from '../../shared/hosted-sandbox/cloud-workspace-api'
|
||||
|
||||
export type GitHubRepoIds = { repoId: number; ownerId: number }
|
||||
|
||||
@@ -60,3 +61,54 @@ export function lookupGitHubRepoIds(repoUrl: string): Promise<GitHubRepoIds | nu
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
// Rights can change (an org adds an owner), so hits expire like the repo-access answer.
|
||||
const RIGHTS_CACHE_MS = 5 * 60_000
|
||||
const rightsCache = new Map<string, { at: number; value: Promise<GitHubInstallRights | null> }>()
|
||||
|
||||
/** `.permissions.admin, .owner.type` from `gh api repos/<slug>`; null when gh couldn't tell. */
|
||||
export function parseGitHubInstallRights(stdout: string): GitHubInstallRights | null {
|
||||
const [admin, ownerType] = stdout.trim().split(/\s+/)
|
||||
if (admin !== 'true' && admin !== 'false') {
|
||||
return null
|
||||
}
|
||||
return ownerType === 'User' || ownerType === 'Organization'
|
||||
? { admin: admin === 'true', ownerType }
|
||||
: null
|
||||
}
|
||||
|
||||
/**
|
||||
* Can the person install the Orca GitHub App on this repository's owner? GitHub lets an owner of a
|
||||
* personal account, an org owner, or a repository admin install it; anyone else (octocat's public
|
||||
* Hello-World, say) can't, and an org member's attempt becomes a request for an owner.
|
||||
*/
|
||||
export function lookupGitHubInstallRights(
|
||||
repoUrl: string,
|
||||
now = Date.now()
|
||||
): Promise<GitHubInstallRights | null> {
|
||||
const slug = githubRepoSlug(repoUrl)
|
||||
if (!slug) {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
const key = slug.toLowerCase()
|
||||
const cached = rightsCache.get(key)
|
||||
if (cached && now - cached.at < RIGHTS_CACHE_MS) {
|
||||
return cached.value
|
||||
}
|
||||
const value = Promise.race([
|
||||
ghExecFileAsync(['api', `repos/${slug}`, '--jq', '.permissions.admin, .owner.type'], {
|
||||
host: 'github.com',
|
||||
idempotent: true
|
||||
}).then(({ stdout }) => parseGitHubInstallRights(stdout)),
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), LOOKUP_TIMEOUT_MS))
|
||||
]).catch(() => null)
|
||||
const entry = { at: now, value }
|
||||
// Misses aren't kept: a later gh sign-in is picked up on the next ask.
|
||||
void value.then((rights) => {
|
||||
if (!rights && rightsCache.get(key) === entry) {
|
||||
rightsCache.delete(key)
|
||||
}
|
||||
})
|
||||
rightsCache.set(key, entry)
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -219,3 +219,30 @@ it('reads the config once, and again after a failed read', async () => {
|
||||
await handlers.get(CH.getConfig)?.()
|
||||
expect(config).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it("reads the person's install rights with their gh login, for GitHub repositories only", async () => {
|
||||
const installRights = vi.fn(async () => ({ admin: false, ownerType: 'User' as const }))
|
||||
registerHostedAccountHandlers(
|
||||
{
|
||||
config: async () => ({ idleSleepMinutes: 15 }),
|
||||
connectGitHub: async () => ({ installUrl: REPO, authorizeUrl: REPO }),
|
||||
githubRepoAccess: async () => ({ access: 'public', owner: 'octocat', repo: REPO }),
|
||||
githubRepositories: async () => ({ repositories: [], nextCursor: null }),
|
||||
create: unusedCreate,
|
||||
...githubUser
|
||||
},
|
||||
{ repoIds: async () => null, installRights }
|
||||
)
|
||||
const rights = handlers.get(CH.gitHubInstallRights)
|
||||
expect(await rights?.({}, { repoUrl: 'https://github.com/octocat/Hello-World.git' })).toEqual({
|
||||
ok: true,
|
||||
value: { admin: false, ownerType: 'User' }
|
||||
})
|
||||
expect(installRights).toHaveBeenCalledWith('https://github.com/octocat/Hello-World')
|
||||
expect(await rights?.({}, { repoUrl: 'https://gitlab.com/acme/web' })).toMatchObject({
|
||||
ok: false
|
||||
})
|
||||
// gh failing reads as "can't tell", never as an error the notice has to handle.
|
||||
installRights.mockRejectedValueOnce(new Error('gh: not logged in'))
|
||||
expect(await rights?.({}, { repoUrl: REPO })).toEqual({ ok: true, value: null })
|
||||
})
|
||||
|
||||
@@ -5,7 +5,13 @@ import { ORCA_CLOUD_WORKSPACE_CHANNELS as CH } from '../../shared/hosted-sandbox
|
||||
import type { HostedSandboxConfigResponse } from '../../shared/hosted-sandbox/hosted-sandbox-wire'
|
||||
import type { HostedSandboxControlPlaneClient } from './hosted-sandbox-control-plane-client'
|
||||
import { failure } from './hosted-workspace-runtime'
|
||||
import { githubRepoSlug, lookupGitHubRepoIds, type GitHubRepoIds } from './github-repo-ids'
|
||||
import {
|
||||
githubRepoSlug,
|
||||
lookupGitHubInstallRights,
|
||||
lookupGitHubRepoIds,
|
||||
type GitHubRepoIds
|
||||
} from './github-repo-ids'
|
||||
import type { GitHubInstallRights } from '../../shared/hosted-sandbox/cloud-workspace-api'
|
||||
import {
|
||||
CLOUD_CREATE_IDEMPOTENCY_KEY,
|
||||
CLOUD_CREATE_PROVISION_ID,
|
||||
@@ -24,6 +30,7 @@ export const HOSTED_ACCOUNT_CHANNELS = [
|
||||
CH.getConfig,
|
||||
CH.connectGitHub,
|
||||
CH.getRepoAccess,
|
||||
CH.gitHubInstallRights,
|
||||
CH.listGitHubRepositories,
|
||||
CH.createFromGitHub,
|
||||
...HOSTED_GITHUB_USER_CHANNELS
|
||||
@@ -59,9 +66,13 @@ export function registerHostedAccountHandlers(
|
||||
| 'connectGitHubUser'
|
||||
| 'disconnectGitHubUser'
|
||||
>,
|
||||
options: { repoIds?: (repoUrl: string) => Promise<GitHubRepoIds | null> } = {}
|
||||
options: {
|
||||
repoIds?: (repoUrl: string) => Promise<GitHubRepoIds | null>
|
||||
installRights?: (repoUrl: string) => Promise<GitHubInstallRights | null>
|
||||
} = {}
|
||||
): void {
|
||||
const repoIds = options.repoIds ?? lookupGitHubRepoIds
|
||||
const installRights = options.installRights ?? lookupGitHubInstallRights
|
||||
registerHostedGitHubUserHandlers(client)
|
||||
// The Worker re-measures every 5 minutes; match it so each new build sheet sees a fresh median.
|
||||
let config: { at: number; value: Promise<HostedSandboxConfigResponse | null> } | null = null
|
||||
@@ -128,6 +139,15 @@ export function registerHostedAccountHandlers(
|
||||
}
|
||||
})
|
||||
|
||||
// Read with the person's own gh login; the control plane never sees a user token.
|
||||
ipcMain.handle(CH.gitHubInstallRights, async (_event, args?: RepoArgs) => {
|
||||
const target = repoTarget(args)
|
||||
if (!target) {
|
||||
return { ok: false, code: 'failed', message: 'Not a GitHub repository.' }
|
||||
}
|
||||
return { ok: true, value: await installRights(target.repo).catch(() => null) }
|
||||
})
|
||||
|
||||
ipcMain.handle(CH.listGitHubRepositories, async (_event, args?: { cursor?: unknown }) => {
|
||||
try {
|
||||
const cursor = typeof args?.cursor === 'string' ? args.cursor : null
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// GH-CREATE-CANCEL: a create cancelled in flight never leaves a sandbox running, for the owned
|
||||
// create and the GitHub-only create alike (both use createSandboxWithProgress).
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { EnvironmentProviderError } from '../environment-providers/environment-provider-driver'
|
||||
import { HostedEntitlementDeniedError } from './hosted-environment-provider'
|
||||
import { reclaimCancelledCreate } from './hosted-sandbox-create-reclaim'
|
||||
import { createSandboxWithProgress } from './hosted-sandbox-driver'
|
||||
|
||||
const READY = {
|
||||
sandbox: {
|
||||
id: 'sbx_1',
|
||||
workspaceId: 'web-1',
|
||||
status: 'running' as const,
|
||||
machineSize: 'standard' as const,
|
||||
region: 'iad1',
|
||||
session: null,
|
||||
createdAt: 1
|
||||
},
|
||||
connection: { type: 'orca-server' as const, pairingCode: 'code', projectRoot: '/work' }
|
||||
}
|
||||
const BODY = { workspaceId: 'web-1', machineSize: 'standard' as const }
|
||||
const quiet = { waitMs: 0, log: () => {} }
|
||||
|
||||
function client() {
|
||||
return {
|
||||
create: vi.fn(async () => READY),
|
||||
bootPlan: vi.fn(async () => 'clone' as const),
|
||||
cloneStatus: vi.fn(async () => ({ state: 'done' as const })),
|
||||
destroy: vi.fn(async (id: string) => ({ id, status: 'destroyed' as const }))
|
||||
}
|
||||
}
|
||||
|
||||
describe('reclaimCancelledCreate', () => {
|
||||
it('replays the create by its key and deletes the sandbox it gets back', async () => {
|
||||
const api = client()
|
||||
expect(await reclaimCancelledCreate(api, BODY, 'create:k1', quiet)).toBe(true)
|
||||
expect(api.create).toHaveBeenCalledWith(BODY, undefined, 'create:k1')
|
||||
expect(api.destroy).toHaveBeenCalledWith('sbx_1')
|
||||
})
|
||||
|
||||
it('waits while the first create is still in progress (409), then deletes', async () => {
|
||||
const api = client()
|
||||
api.create
|
||||
.mockRejectedValueOnce(new EnvironmentProviderError('conflict', 'idempotency_in_progress'))
|
||||
.mockRejectedValueOnce(new EnvironmentProviderError('unavailable', 'try later'))
|
||||
expect(await reclaimCancelledCreate(api, BODY, 'create:k1', quiet)).toBe(true)
|
||||
expect(api.create).toHaveBeenCalledTimes(3)
|
||||
expect(api.destroy).toHaveBeenCalledWith('sbx_1')
|
||||
})
|
||||
|
||||
it('a refused replay means the cancelled create made nothing either', async () => {
|
||||
const api = client()
|
||||
api.create.mockRejectedValueOnce(new HostedEntitlementDeniedError('denied', 'no_subscription'))
|
||||
expect(await reclaimCancelledCreate(api, BODY, 'create:k1', quiet)).toBe(true)
|
||||
expect(api.destroy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('gives up (false) when it can never tell', async () => {
|
||||
const api = client()
|
||||
api.create.mockRejectedValue(new EnvironmentProviderError('unavailable', 'down'))
|
||||
expect(await reclaimCancelledCreate(api, BODY, 'create:k1', { ...quiet, attempts: 3 })).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createSandboxWithProgress cancel', () => {
|
||||
const spec = { workspaceId: 'web-1', machineSize: 'standard', idempotencyKey: 'create:k1' }
|
||||
|
||||
it('cancelled while the create was in flight: reclaims by key, then reports the cancel', async () => {
|
||||
const api = client()
|
||||
const controller = new AbortController()
|
||||
api.create.mockImplementationOnce(async () => {
|
||||
controller.abort()
|
||||
throw new EnvironmentProviderError('aborted', 'Cancelled.')
|
||||
})
|
||||
await expect(
|
||||
createSandboxWithProgress(api, spec, { signal: controller.signal })
|
||||
).rejects.toMatchObject({ code: 'aborted' })
|
||||
// The replay (no signal, same key) found the sandbox and deleted it.
|
||||
expect(api.create).toHaveBeenLastCalledWith(expect.anything(), undefined, 'create:k1')
|
||||
expect(api.destroy).toHaveBeenCalledWith('sbx_1')
|
||||
})
|
||||
|
||||
it('cancelled just as the create answered: deletes the sandbox and reports the cancel', async () => {
|
||||
const api = client()
|
||||
const controller = new AbortController()
|
||||
api.create.mockImplementationOnce(async () => {
|
||||
controller.abort()
|
||||
return READY
|
||||
})
|
||||
await expect(
|
||||
createSandboxWithProgress(api, spec, { signal: controller.signal })
|
||||
).rejects.toMatchObject({ code: 'aborted' })
|
||||
expect(api.destroy).toHaveBeenCalledWith('sbx_1')
|
||||
expect(api.create).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('not cancelled: no replay, no delete', async () => {
|
||||
const api = client()
|
||||
await expect(createSandboxWithProgress(api, spec, {})).resolves.toBe(READY)
|
||||
expect(api.create).toHaveBeenCalledTimes(1)
|
||||
expect(api.destroy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
// A create cancelled while its request was in flight may still have made a sandbox on the control
|
||||
// plane, and a sandbox bills (GH-CREATE-CANCEL). The control plane keys creates by Idempotency-Key
|
||||
// and body: sending the same create again hands back the sandbox the first one made (or makes one, if
|
||||
// the first never arrived), and that sandbox is deleted. Shared by the owned create (the recipe) and
|
||||
// the GitHub-only create, which both go through createSandboxWithProgress.
|
||||
import { isEnvironmentProviderError } from '../environment-providers/environment-provider-driver'
|
||||
import type { HostedSandboxControlPlaneClient } from './hosted-sandbox-control-plane-client'
|
||||
|
||||
type CreateBody = Parameters<HostedSandboxControlPlaneClient['create']>[0]
|
||||
|
||||
const RECLAIM_ATTEMPTS = 8
|
||||
const RECLAIM_WAIT_MS = 5_000
|
||||
|
||||
/** The first create may still be running (409 in progress) or the service briefly unreachable. */
|
||||
function worthRetrying(error: unknown): boolean {
|
||||
return (
|
||||
isEnvironmentProviderError(error, 'conflict') ||
|
||||
isEnvironmentProviderError(error, 'unavailable') ||
|
||||
isEnvironmentProviderError(error, 'timeout') ||
|
||||
!isEnvironmentProviderError(error)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes whatever the cancelled create made. True once it is gone (or the control plane refused
|
||||
* the create, so nothing was made); false when it still couldn't tell after every attempt.
|
||||
*/
|
||||
export async function reclaimCancelledCreate(
|
||||
client: Pick<HostedSandboxControlPlaneClient, 'create' | 'destroy'>,
|
||||
body: CreateBody,
|
||||
idempotencyKey: string,
|
||||
{
|
||||
attempts = RECLAIM_ATTEMPTS,
|
||||
waitMs = RECLAIM_WAIT_MS,
|
||||
log = (message: string) => console.warn(message)
|
||||
}: { attempts?: number; waitMs?: number; log?: (message: string) => void } = {}
|
||||
): Promise<boolean> {
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
let sandboxId: string
|
||||
try {
|
||||
// No signal: this request is the cleanup and must not be cancelled with the create.
|
||||
sandboxId = (await client.create(body, undefined, idempotencyKey)).sandbox.id
|
||||
} catch (error) {
|
||||
if (!worthRetrying(error)) {
|
||||
// Refused (billing, sign-in, bad request): the cancelled create made nothing either.
|
||||
return true
|
||||
}
|
||||
log(
|
||||
`[orca-cloud] cancelled create: replay ${attempt}/${attempts} failed (${isEnvironmentProviderError(error) ? error.code : 'error'})`
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, waitMs))
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await client.destroy(sandboxId)
|
||||
log(`[orca-cloud] cancelled create: deleted sandbox ${sandboxId}`)
|
||||
return true
|
||||
} catch {
|
||||
await new Promise((resolve) => setTimeout(resolve, waitMs))
|
||||
}
|
||||
}
|
||||
log('[orca-cloud] cancelled create: could not confirm the sandbox was deleted')
|
||||
return false
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
} from './hosted-environment-provider'
|
||||
import type { HostedSandboxControlPlaneClient } from './hosted-sandbox-control-plane-client'
|
||||
import { takeSuspendReason } from './idle-sleep-requests'
|
||||
import { reclaimCancelledCreate } from './hosted-sandbox-create-reclaim'
|
||||
|
||||
export const HOSTED_SANDBOX_DRIVER_ID = 'orca-cloud'
|
||||
|
||||
@@ -144,21 +145,34 @@ export async function createSandboxWithProgress(
|
||||
)
|
||||
.catch(() => undefined)
|
||||
}
|
||||
const ready = await client.create(
|
||||
{
|
||||
workspaceId: spec.workspaceId,
|
||||
machineSize,
|
||||
// Named rows: Home shows the workspace's name, never an untitled sandbox (PC-8).
|
||||
...(spec.workspaceName?.trim() ? { title: spec.workspaceName.trim().slice(0, 120) } : {}),
|
||||
...(spec.region ? { region: spec.region } : {}),
|
||||
...(spec.repo ? { repo: spec.repo } : {})
|
||||
},
|
||||
ctx?.signal,
|
||||
spec.idempotencyKey
|
||||
)
|
||||
const body = {
|
||||
workspaceId: spec.workspaceId,
|
||||
machineSize,
|
||||
// Named rows: Home shows the workspace's name, never an untitled sandbox (PC-8).
|
||||
...(spec.workspaceName?.trim() ? { title: spec.workspaceName.trim().slice(0, 120) } : {}),
|
||||
...(spec.region ? { region: spec.region } : {}),
|
||||
...(spec.repo ? { repo: spec.repo } : {})
|
||||
}
|
||||
let ready: HostedSandboxReadyResponse
|
||||
try {
|
||||
ready = await client.create(body, ctx?.signal, spec.idempotencyKey)
|
||||
} catch (error) {
|
||||
// GH-CREATE-CANCEL: cancelled in flight, the control plane may still have made it: find it by
|
||||
// its key and delete it before reporting the cancel.
|
||||
if (ctx?.signal?.aborted && spec.idempotencyKey) {
|
||||
await reclaimCancelledCreate(client, body, spec.idempotencyKey)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (ready.clone) {
|
||||
// A cancelled, failed or stalled clone deletes the sandbox itself.
|
||||
await waitForClone(client, ready.sandbox.id, ctx, { mode: ready.clone.mode ?? 'clone' })
|
||||
}
|
||||
if (ctx?.signal?.aborted) {
|
||||
// Cancelled just as it answered: nothing will use it, so it must not keep running (and billing).
|
||||
await client.destroy(ready.sandbox.id).catch(() => undefined)
|
||||
throw new EnvironmentProviderError('aborted', 'Cancelled.')
|
||||
}
|
||||
ctx?.onProgress?.({ phase: 'connecting' })
|
||||
return ready
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export const orcaCloudWorkspaceApi = {
|
||||
gitHubUserConnectLink: () => ipcRenderer.invoke(CH.gitHubUserConnectLink),
|
||||
disconnectGitHubUser: () => ipcRenderer.invoke(CH.disconnectGitHubUser),
|
||||
getRepoAccess: (args) => ipcRenderer.invoke(CH.getRepoAccess, args),
|
||||
gitHubInstallRights: (args) => ipcRenderer.invoke(CH.gitHubInstallRights, args),
|
||||
listGitHubRepositories: (args) => ipcRenderer.invoke(CH.listGitHubRepositories, args),
|
||||
createFromGitHub: (args) => ipcRenderer.invoke(CH.createFromGitHub, args),
|
||||
onWakeProgress: (callback) => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button'
|
||||
import type { ExternalLinkSource } from '@/components/open-external/copy-external-link'
|
||||
import { CloudStripPasteForm } from './CloudStripPasteForm'
|
||||
import { StripPrimaryButton, stripPrimaryLink } from './CloudStripPrimaryButton'
|
||||
import { CloudStripSettingsLink } from './CloudStripSettingsLink'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -103,6 +104,8 @@ export type CloudStripActions = {
|
||||
* setup's sign-in page, known only to main). Actions carrying a `url` copy it without this.
|
||||
*/
|
||||
linkFor?: (action: CloudStripPrimary) => ExternalLinkSource | null
|
||||
/** CHAT-RENEW-INPLACE: the model's `settingsLink` ("Renew in Settings"). */
|
||||
openSettings?: () => void
|
||||
}
|
||||
|
||||
/** GA-P3t: the strip's severity look; anything that isn't a warning or an error reads as info. */
|
||||
@@ -336,6 +339,9 @@ export function CloudAgentSignInStrip({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
{model.settingsLink && !stacked ? (
|
||||
<CloudStripSettingsLink label={model.settingsLink} onOpen={actions.openSettings} />
|
||||
) : null}
|
||||
{/* Renew and Restart are the whole action and the strip leaves on its own; Escape still cancels. */}
|
||||
{model.noClose ||
|
||||
(!model.dismiss &&
|
||||
@@ -378,6 +384,13 @@ export function CloudAgentSignInStrip({
|
||||
{model.primary.label}
|
||||
</StripPrimaryButton>
|
||||
) : null}
|
||||
{model.settingsLink ? (
|
||||
<CloudStripSettingsLink
|
||||
ownLine
|
||||
label={model.settingsLink}
|
||||
onOpen={actions.openSettings}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<span role="status" className="sr-only">
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// CHAT-RENEW-INPLACE: the in-place renewal, step by step, on all three surfaces (a launch held on the
|
||||
// expired sign-in, a running terminal, a structured chat). design-squad's per-step copy.
|
||||
import React from 'react'
|
||||
import { defineFixture } from '../../component-fixtures/component-fixture'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { OrcaCloudAgentSetup } from '../../../../shared/orca-cloud-agents/orca-cloud-agents-wire'
|
||||
import {
|
||||
CloudAgentSignInStrip,
|
||||
CloudStripLayer,
|
||||
cloudStripWidth,
|
||||
type CloudStripActions
|
||||
} from './CloudAgentSignInStrip'
|
||||
import type { CloudStripModel } from './cloud-agent-strip-model'
|
||||
import { cloudAgentStripModel } from './cloud-agent-strip-model'
|
||||
import { cloudAgentAwaitingStartStripModel } from './cloud-agent-setup-strip-model'
|
||||
import { cloudChatRunningStripModel, cloudRunningStripModel } from './cloud-running-strip-model'
|
||||
import { cloudRenewInPlaceModel } from './cloud-renew-in-place'
|
||||
|
||||
const noop = (): void => {}
|
||||
const ACTIONS: CloudStripActions = {
|
||||
primary: noop,
|
||||
submitCode: noop,
|
||||
pasteCode: noop,
|
||||
useCode: noop,
|
||||
useApiKey: noop,
|
||||
cancel: noop,
|
||||
openSettings: noop
|
||||
}
|
||||
|
||||
type Surface = 'held' | 'terminal' | 'chat'
|
||||
type Step = 'expired' | 'browser' | 'checking' | 'rejected' | 'renewed'
|
||||
|
||||
const SETUP: Record<Exclude<Step, 'renewed'>, OrcaCloudAgentSetup> = {
|
||||
expired: { phase: 'idle' },
|
||||
browser: { phase: 'browser', startedAt: 0, canReopen: true, acceptsCode: true },
|
||||
checking: {
|
||||
phase: 'browser',
|
||||
startedAt: 0,
|
||||
canReopen: true,
|
||||
acceptsCode: true,
|
||||
code: 'checking'
|
||||
},
|
||||
rejected: { phase: 'browser', startedAt: 0, canReopen: true, acceptsCode: true, code: 'rejected' }
|
||||
}
|
||||
|
||||
function expiredModel(surface: Surface): CloudStripModel {
|
||||
const strip = { phase: 'expired' as const, agent: 'claude' as const }
|
||||
return surface === 'held'
|
||||
? cloudAgentStripModel({
|
||||
agent: 'claude',
|
||||
reason: 'accountExpired',
|
||||
view: null,
|
||||
hostLabel: '',
|
||||
t: translate
|
||||
})
|
||||
: surface === 'chat'
|
||||
? cloudChatRunningStripModel(strip, translate)
|
||||
: cloudRunningStripModel(strip, translate)
|
||||
}
|
||||
|
||||
function stepModel(surface: Surface, step: Step): CloudStripModel {
|
||||
if (step !== 'renewed') {
|
||||
return cloudRenewInPlaceModel({
|
||||
agent: 'claude',
|
||||
expired: expiredModel(surface),
|
||||
setup: SETUP[step],
|
||||
t: translate
|
||||
})
|
||||
}
|
||||
const renewed = { phase: 'renewed' as const, agent: 'claude' as const, resumable: true }
|
||||
// Step 5 is each surface's own: held waits for Start (team-lead B), the terminal offers Restart
|
||||
// (RA-11), the chat's next message uses the renewed sign-in.
|
||||
return surface === 'held'
|
||||
? cloudAgentAwaitingStartStripModel({
|
||||
agent: 'claude',
|
||||
awaiting: 'renewed',
|
||||
queuedPrompt: true,
|
||||
t: translate
|
||||
})
|
||||
: surface === 'chat'
|
||||
? cloudChatRunningStripModel(renewed, translate)
|
||||
: cloudRunningStripModel(renewed, translate)
|
||||
}
|
||||
|
||||
function Frame({
|
||||
surface,
|
||||
step,
|
||||
width
|
||||
}: {
|
||||
surface: Surface
|
||||
step: Step
|
||||
width: number
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className="bg-background p-4">
|
||||
<div
|
||||
className="relative h-[220px] overflow-hidden rounded-md border border-border bg-background"
|
||||
style={{ width }}
|
||||
>
|
||||
<CloudStripLayer>
|
||||
<CloudAgentSignInStrip
|
||||
model={stepModel(surface, step)}
|
||||
prompt={surface === 'held' ? 'Fix the flaky auth test and open a PR' : null}
|
||||
width={cloudStripWidth(width)}
|
||||
actions={ACTIONS}
|
||||
/>
|
||||
</CloudStripLayer>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const fixture = (surface: Surface, step: Step, width = 900) =>
|
||||
defineFixture({
|
||||
width: width + 40,
|
||||
render: () => <Frame surface={surface} step={step} width={width} />
|
||||
})
|
||||
|
||||
export const heldExpired = fixture('held', 'expired')
|
||||
export const heldBrowser = fixture('held', 'browser')
|
||||
export const heldChecking = fixture('held', 'checking')
|
||||
export const heldRejected = fixture('held', 'rejected')
|
||||
export const heldRenewed = fixture('held', 'renewed')
|
||||
export const terminalExpired = fixture('terminal', 'expired')
|
||||
export const terminalBrowser = fixture('terminal', 'browser')
|
||||
export const terminalChecking = fixture('terminal', 'checking')
|
||||
export const terminalRejected = fixture('terminal', 'rejected')
|
||||
export const terminalRenewed = fixture('terminal', 'renewed')
|
||||
export const chatExpired = fixture('chat', 'expired')
|
||||
export const chatBrowser = fixture('chat', 'browser')
|
||||
export const chatChecking = fixture('chat', 'checking')
|
||||
export const chatRejected = fixture('chat', 'rejected')
|
||||
export const chatRenewed = fixture('chat', 'renewed')
|
||||
/** Narrow: the code field stacks under the text, and "Renew in Settings" gets its own line. */
|
||||
export const chatBrowserNarrow = fixture('chat', 'browser', 440)
|
||||
export const terminalRejectedNarrow = fixture('terminal', 'rejected', 440)
|
||||
@@ -49,7 +49,14 @@ vi.mock('@/lib/orca-cloud/orca-cloud-environment-id', () => ({
|
||||
}))
|
||||
vi.mock('@/lib/orca-cloud/orca-cloud-agent-accounts-store', () => ({
|
||||
orcaCloudAgentAccountsLoadedAt: () => 0,
|
||||
useOrcaCloudAgentAccounts: () => ({ load: { kind: 'loading' }, keyKinds: {} })
|
||||
useOrcaCloudAgentAccounts: () => ({
|
||||
load: { kind: 'loading' },
|
||||
keyKinds: {},
|
||||
setups: { claude: { phase: 'idle' }, codex: { phase: 'idle' } }
|
||||
}),
|
||||
runOrcaCloudAgentSetups: () => undefined,
|
||||
stopOrcaCloudAgentSetups: () => undefined,
|
||||
reloadOrcaCloudAgentAccounts: async () => undefined
|
||||
}))
|
||||
vi.mock('@/lib/orca-cloud/cloud-running-agent-signout', () => ({
|
||||
clearCloudRunningSignout: () => undefined,
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
cloudRestartFreshModel,
|
||||
cloudRunningStripModel
|
||||
} from './cloud-running-strip-model'
|
||||
import { openOrcaCloudAccountsSettings, withRenewInPlace } from './cloud-renew-in-place'
|
||||
|
||||
const NO_ACTIONS: CloudStripActions = {
|
||||
primary: () => undefined,
|
||||
@@ -52,12 +53,6 @@ const NO_ACTIONS: CloudStripActions = {
|
||||
cancel: () => undefined
|
||||
}
|
||||
|
||||
function openAccounts(): void {
|
||||
const store = useAppStore.getState()
|
||||
store.openSettingsTarget({ pane: 'accounts', repoId: null, sectionId: 'orca-cloud' })
|
||||
store.openSettingsPage()
|
||||
}
|
||||
|
||||
export function CloudRunningStripPortal({
|
||||
pane,
|
||||
tabId,
|
||||
@@ -192,7 +187,7 @@ export function CloudRunningStripPortal({
|
||||
const actions: CloudStripActions = {
|
||||
primary: (action) => {
|
||||
if (action.kind === 'renew') {
|
||||
openAccounts()
|
||||
openOrcaCloudAccountsSettings()
|
||||
} else if (action.kind === 'restart' && strip.phase === 'renewed') {
|
||||
restart({ agent: strip.agent, resumable: strip.resumable })
|
||||
}
|
||||
@@ -204,6 +199,19 @@ export function CloudRunningStripPortal({
|
||||
// State 2's ×; state 1 has none (Renew is the action) and Escape leaves it showing.
|
||||
cancel: () => (strip.phase === 'renewed' ? clearCloudRunningSignout(paneKey) : undefined)
|
||||
}
|
||||
// CHAT-RENEW-INPLACE: "sign-in expired · Renew" renews right here; renewed keeps RA-11's Restart.
|
||||
const model = cloudRunningStripModel(strip, t)
|
||||
const shown =
|
||||
strip.phase === 'expired'
|
||||
? withRenewInPlace({
|
||||
agent: strip.agent,
|
||||
setup: accounts.setups[strip.agent],
|
||||
model,
|
||||
actions,
|
||||
key: paneKey,
|
||||
t
|
||||
})
|
||||
: { model, actions }
|
||||
return (
|
||||
<CloudPaneBannerPortal
|
||||
pane={pane}
|
||||
@@ -212,10 +220,10 @@ export function CloudRunningStripPortal({
|
||||
stripLayer
|
||||
>
|
||||
<CloudAgentSignInStrip
|
||||
model={cloudRunningStripModel(strip, t)}
|
||||
model={shown.model}
|
||||
prompt={null}
|
||||
width={cloudStripWidth(pane.container.clientWidth)}
|
||||
actions={actions}
|
||||
actions={shown.actions}
|
||||
/>
|
||||
</CloudPaneBannerPortal>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react'
|
||||
import { cn } from '@/lib/class-names'
|
||||
|
||||
/**
|
||||
* CHAT-RENEW-INPLACE: the strip's quiet "Renew in Settings" link, at the end of the actions row, or on
|
||||
* its own line under the buttons in the narrow stack (design-squad).
|
||||
*/
|
||||
export function CloudStripSettingsLink({
|
||||
label,
|
||||
onOpen,
|
||||
ownLine = false
|
||||
}: {
|
||||
label: string
|
||||
onOpen?: () => void
|
||||
ownLine?: boolean
|
||||
}): React.JSX.Element | null {
|
||||
if (!onOpen) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-strip-settings-link
|
||||
onClick={onOpen}
|
||||
className={cn(
|
||||
// The quiet-link rule: underlined at rest (muted), stronger on hover, a visible focus ring.
|
||||
'shrink-0 cursor-pointer text-xs text-muted-foreground underline decoration-muted-foreground/50 underline-offset-2 hover:text-foreground hover:decoration-foreground',
|
||||
'rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
ownLine && 'basis-full text-left'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -22,16 +22,19 @@ function tabStillOpen(tabId: string): boolean {
|
||||
export function CloudStructuredChatBanners({
|
||||
tabId,
|
||||
paneKey,
|
||||
statusKey
|
||||
statusKey,
|
||||
agent
|
||||
}: {
|
||||
tabId: string
|
||||
/** The launch-hold key (from the session id). */
|
||||
paneKey: string
|
||||
/** The agent-status key (tab and session): the running strip (B2) reads the agent through it. */
|
||||
statusKey: string
|
||||
/** The chat's agent, for an idle chat with no status yet. */
|
||||
agent?: string
|
||||
}): React.JSX.Element {
|
||||
const { key, active, strip } = useCloudHeldStrip(paneKey, null, { chat: true })
|
||||
const running = useCloudChatRunningStrip({ tabId, statusKey, holdKey: paneKey })
|
||||
const running = useCloudChatRunningStrip({ tabId, statusKey, holdKey: paneKey, agent })
|
||||
const shown = strip ?? running
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
|
||||
@@ -5,11 +5,11 @@ import { cloudAgentStripModel } from './cloud-agent-strip-model'
|
||||
const base = { agent: 'claude' as const, hostLabel: 'the sandbox', t: translate }
|
||||
|
||||
describe('cloudAgentStripModel', () => {
|
||||
it('account recovery (S4-c): Renew, account-level wording, no per-workspace words', () => {
|
||||
it('account recovery (S4-c), held: Renew, "Renew to start Claude." (CHAT-RENEW-INPLACE copy)', () => {
|
||||
const model = cloudAgentStripModel({ ...base, reason: 'accountExpired', view: null })
|
||||
expect(model).toMatchObject({
|
||||
title: 'Your Claude sign-in expired',
|
||||
detail: 'Sign in once to fix every workspace.',
|
||||
detail: 'Renew to start Claude.',
|
||||
primary: { label: 'Renew', action: { kind: 'renew' } }
|
||||
})
|
||||
})
|
||||
|
||||
@@ -57,6 +57,11 @@ export type CloudStripModel = {
|
||||
otherWays: boolean
|
||||
/** Already on a code: ⋯ offers the paste fallback and Cancel instead of "Use a code". */
|
||||
codeStep?: boolean
|
||||
/**
|
||||
* CHAT-RENEW-INPLACE: a quiet text link at the end of the actions row ("Renew in Settings"); in
|
||||
* the narrow stack it gets its own line under the buttons.
|
||||
*/
|
||||
settingsLink?: string
|
||||
/** RA-11 state 2: a dismiss × with this label (the strip otherwise drops its × on Renew). */
|
||||
dismiss?: string
|
||||
/** RA-11 "Restarting…": nothing to close; the strip leaves with the result. */
|
||||
@@ -121,15 +126,15 @@ export function cloudAgentStripModel(args: {
|
||||
return {
|
||||
status: 'warning',
|
||||
title: t('agentSignInStrip.expired', 'Your {{agent}} sign-in expired', { agent }),
|
||||
detail: t('agentSignInStrip.expiredDetail', 'Sign in once to fix every workspace.'),
|
||||
detail: t('agentSignInStrip.expiredHeldDetail', 'Renew to start {{agent}}.', { agent }),
|
||||
description: null,
|
||||
primary: { label: t('agentSignInStrip.renew', 'Renew'), action: { kind: 'renew' } },
|
||||
code: null,
|
||||
paste: null,
|
||||
otherWays: false,
|
||||
announcement: t(
|
||||
'agentSignInStrip.expiredAnnounce',
|
||||
'Your {{agent}} sign-in expired. Renew it once to fix every workspace.',
|
||||
'agentSignInStrip.expiredHeldAnnounce',
|
||||
'Your {{agent}} sign-in expired. Renew to start {{agent}}.',
|
||||
{ agent }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// CHAT-BANNERS B2: RA-11's running strip on a structured chat. Expired shows Renew; renewed confirms
|
||||
// with no Restart (the chat's next message uses the renewed sign-in) and its × dismisses; a held
|
||||
// launch's strip wins over it.
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
|
||||
@@ -21,7 +21,8 @@ const stub = vi.hoisted(() => {
|
||||
cleared: string[]
|
||||
calls: { paneKey: string; turnEndedFallback?: boolean }[]
|
||||
openedSettings: number
|
||||
} = { strip: null, held: false, cleared: [], calls: [], openedSettings: 0 }
|
||||
renewsRun: string[][]
|
||||
} = { strip: null, held: false, cleared: [], calls: [], openedSettings: 0, renewsRun: [] }
|
||||
return state
|
||||
})
|
||||
|
||||
@@ -47,6 +48,9 @@ vi.mock('@/lib/orca-cloud/orca-cloud-environment-id', () => ({
|
||||
}))
|
||||
vi.mock('@/lib/orca-cloud/orca-cloud-agent-accounts-store', () => ({
|
||||
orcaCloudAgentAccountsLoadedAt: () => 1,
|
||||
runOrcaCloudAgentSetups: (agents: string[]) => stub.renewsRun.push(agents),
|
||||
stopOrcaCloudAgentSetups: () => undefined,
|
||||
reloadOrcaCloudAgentAccounts: async () => undefined,
|
||||
useOrcaCloudAgentAccounts: () => ({
|
||||
load: { kind: 'loading' },
|
||||
keyKinds: {},
|
||||
@@ -94,6 +98,7 @@ vi.stubGlobal(
|
||||
)
|
||||
|
||||
const { CloudStructuredChatBanners } = await import('./CloudStructuredChatBanners')
|
||||
const { useCloudChatSendBlock } = await import('./use-cloud-chat-running-strip')
|
||||
|
||||
const mount = () =>
|
||||
render(
|
||||
@@ -117,7 +122,9 @@ describe('the running strip on a structured chat (B2)', () => {
|
||||
mount()
|
||||
expect(screen.getAllByText(/Claude sign-in expired/).length).toBeGreaterThan(0)
|
||||
fireEvent.click(screen.getByRole('button', { name: /Renew/ }))
|
||||
expect(stub.openedSettings).toBe(1)
|
||||
// CHAT-RENEW-INPLACE: Renew runs the renewal right here, never Settings.
|
||||
expect(stub.renewsRun).toEqual([['claude']])
|
||||
expect(stub.openedSettings).toBe(0)
|
||||
expect(stub.calls.at(-1)).toEqual({ paneKey: STATUS_KEY, turnEndedFallback: true })
|
||||
})
|
||||
|
||||
@@ -139,3 +146,54 @@ describe('the running strip on a structured chat (B2)', () => {
|
||||
expect(screen.queryByRole('button', { name: /Renew/ })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('an idle chat (no message yet, no status entry)', () => {
|
||||
const IDLE_TAB = 'structured-agent-session-claude_idle'
|
||||
const idle = () =>
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<CloudStructuredChatBanners
|
||||
tabId={TAB_ID}
|
||||
paneKey={HOLD_KEY}
|
||||
statusKey={`${IDLE_TAB}:no-status`}
|
||||
agent="claude"
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
it("expired account: the strip shows, decided from the chat's own agent", () => {
|
||||
stub.strip = { phase: 'expired', agent: 'claude' }
|
||||
idle()
|
||||
expect(screen.getAllByText(/Claude sign-in expired/).length).toBeGreaterThan(0)
|
||||
expect(stub.calls.at(-1)?.paneKey).toBe(`${IDLE_TAB}:no-status`)
|
||||
})
|
||||
|
||||
it('covered account: no strip', () => {
|
||||
idle()
|
||||
expect(screen.queryByRole('region')).toBeNull()
|
||||
})
|
||||
|
||||
it('a held launch shows the held strip instead', () => {
|
||||
stub.strip = { phase: 'expired', agent: 'claude' }
|
||||
stub.held = true
|
||||
idle()
|
||||
expect(screen.getByRole('button', { name: 'Set up once' })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Send while the account is expired', () => {
|
||||
const args = { tabId: TAB_ID, statusKey: STATUS_KEY, holdKey: HOLD_KEY, agent: 'claude' }
|
||||
|
||||
it('expired: Send is held with the Renew hint (typing is not touched)', () => {
|
||||
stub.strip = { phase: 'expired', agent: 'claude' }
|
||||
const { result } = renderHook(() => useCloudChatSendBlock(args))
|
||||
expect(result.current).toBe('Renew Claude sign-in above to send')
|
||||
})
|
||||
|
||||
it('renewed or covered: Send is free again', () => {
|
||||
stub.strip = { phase: 'renewed', agent: 'claude', resumable: true }
|
||||
expect(renderHook(() => useCloudChatSendBlock(args)).result.current).toBeUndefined()
|
||||
stub.strip = null
|
||||
expect(renderHook(() => useCloudChatSendBlock(args)).result.current).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// CHAT-RENEW-INPLACE: every expired strip renews in place, with design-squad's per-step copy.
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { OrcaCloudAgentSetup } from '../../../../shared/orca-cloud-agents/orca-cloud-agents-wire'
|
||||
import type { CloudStripActions } from './CloudAgentSignInStrip'
|
||||
import type { CloudStripModel } from './cloud-agent-strip-model'
|
||||
import { translate as t } from '@/i18n/i18n'
|
||||
|
||||
const store = vi.hoisted(() => ({ run: vi.fn(), stop: vi.fn() }))
|
||||
vi.mock('@/lib/orca-cloud/orca-cloud-agent-accounts-store', () => ({
|
||||
runOrcaCloudAgentSetups: store.run,
|
||||
stopOrcaCloudAgentSetups: store.stop,
|
||||
reloadOrcaCloudAgentAccounts: async () => undefined
|
||||
}))
|
||||
vi.mock('@/lib/orca-cloud/cloud-agent-launch-hold', () => ({
|
||||
releaseCloudAgentHold: vi.fn(),
|
||||
startCloudAgentHold: vi.fn()
|
||||
}))
|
||||
vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } }))
|
||||
vi.mock('../agent-sign-in/AgentSignInPanePortals', () => ({
|
||||
agentSignInCardActions: () => ({ copyText: vi.fn(), openLink: vi.fn() })
|
||||
}))
|
||||
const api = vi.hoisted(() => ({ reopen: vi.fn(async () => undefined), submitClaudeCode: vi.fn() }))
|
||||
vi.stubGlobal('window', { api: { orcaCloudAgents: api } })
|
||||
|
||||
const { cloudRenewInPlaceActions, cloudRenewInPlaceModel } = await import('./cloud-renew-in-place')
|
||||
|
||||
const EXPIRED: CloudStripModel = {
|
||||
status: 'warning',
|
||||
title: 'Your Claude sign-in expired',
|
||||
detail: 'Renew to keep working. Your conversation is kept.',
|
||||
description: null,
|
||||
primary: { label: 'Renew', action: { kind: 'renew' } },
|
||||
code: null,
|
||||
paste: null,
|
||||
otherWays: false,
|
||||
announcement: 'Your Claude sign-in expired.'
|
||||
}
|
||||
|
||||
const model = (setup: OrcaCloudAgentSetup) =>
|
||||
cloudRenewInPlaceModel({ agent: 'claude', expired: EXPIRED, setup, t })
|
||||
|
||||
describe('cloudRenewInPlaceModel (design-squad per-step copy)', () => {
|
||||
it("1 expired: the surface's own strip, [Renew], no Settings link", () => {
|
||||
expect(model({ phase: 'idle' })).toBe(EXPIRED)
|
||||
expect(model({ phase: 'saved' })).toBe(EXPIRED)
|
||||
})
|
||||
|
||||
it('2 browser: same title, paste code, [Finish renewing] [Open again], "Renew in Settings"', () => {
|
||||
const shown = model({ phase: 'browser', startedAt: 0, canReopen: true, acceptsCode: true })
|
||||
expect(shown.title).toBe('Your Claude sign-in expired')
|
||||
expect(shown.detail).toBe('Finish in your browser, then paste the code here.')
|
||||
expect(shown.paste?.setupCode).toEqual({
|
||||
placeholder: 'Paste code',
|
||||
submitLabel: 'Finish renewing'
|
||||
})
|
||||
expect(shown.primary).toMatchObject({ label: 'Open again', action: { kind: 'reopenSetup' } })
|
||||
expect(shown.settingsLink).toBe('Renew in Settings')
|
||||
expect(JSON.stringify(shown)).not.toContain('Finish setup')
|
||||
})
|
||||
|
||||
it('3 checking: the field and its button disabled', () => {
|
||||
const shown = model({
|
||||
phase: 'browser',
|
||||
startedAt: 0,
|
||||
canReopen: true,
|
||||
acceptsCode: true,
|
||||
code: 'checking'
|
||||
})
|
||||
expect(shown.paste).toMatchObject({ submitting: true })
|
||||
expect(shown.paste?.setupCode?.submitLabel).toBe('Checking the code…')
|
||||
})
|
||||
|
||||
it('4 rejected: "Code didn\'t work", the newest-code detail, [Open again] and the Settings link', () => {
|
||||
const shown = model({
|
||||
phase: 'browser',
|
||||
startedAt: 0,
|
||||
canReopen: true,
|
||||
acceptsCode: true,
|
||||
code: 'rejected'
|
||||
})
|
||||
expect(shown.title).toBe('Code didn’t work')
|
||||
expect(shown.detail).toBe('Paste the newest code from the sign-in page, or open it again.')
|
||||
expect(shown.paste?.refusal).toBeTruthy()
|
||||
expect(shown.primary?.action.kind).toBe('reopenSetup')
|
||||
expect(shown.settingsLink).toBe('Renew in Settings')
|
||||
})
|
||||
|
||||
it('a setup that failed outright starts again from Renew', () => {
|
||||
const shown = model({ phase: 'failed', error: 'denied' })
|
||||
expect(shown.status).toBe('error')
|
||||
expect(shown.primary?.action.kind).toBe('renew')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cloudRenewInPlaceActions', () => {
|
||||
const surface = (): CloudStripActions => ({
|
||||
primary: vi.fn(),
|
||||
submitCode: vi.fn(),
|
||||
pasteCode: vi.fn(),
|
||||
useCode: vi.fn(),
|
||||
useApiKey: vi.fn(),
|
||||
cancel: vi.fn()
|
||||
})
|
||||
const actions = (setup: OrcaCloudAgentSetup, own = surface(), openSettings = vi.fn()) => ({
|
||||
own,
|
||||
openSettings,
|
||||
built: cloudRenewInPlaceActions({
|
||||
agent: 'claude',
|
||||
setup,
|
||||
surface: own,
|
||||
openSettings,
|
||||
copyText: vi.fn(),
|
||||
openLink: vi.fn()
|
||||
})
|
||||
})
|
||||
beforeEach(() => {
|
||||
store.run.mockClear()
|
||||
store.stop.mockClear()
|
||||
api.reopen.mockClear()
|
||||
})
|
||||
|
||||
it('Renew runs the setup here (never the Settings page or the sign-in sheet)', () => {
|
||||
const { built, own, openSettings } = actions({ phase: 'idle' })
|
||||
built.primary({ kind: 'renew' })
|
||||
expect(store.run).toHaveBeenCalledExactlyOnceWith(['claude'])
|
||||
expect(own.primary).not.toHaveBeenCalled()
|
||||
expect(openSettings).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Open again reopens the setup page; "Renew in Settings" opens Settings', () => {
|
||||
const { built, openSettings } = actions({ phase: 'browser', startedAt: 0, canReopen: true })
|
||||
built.primary({ kind: 'reopenSetup' })
|
||||
expect(api.reopen).toHaveBeenCalledExactlyOnceWith({ agent: 'claude' })
|
||||
built.openSettings?.()
|
||||
expect(openSettings).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('6: × mid-flow stops the setup (back to step 1) and never dismisses the strip', () => {
|
||||
const { built, own } = actions({ phase: 'browser', startedAt: 0, canReopen: true })
|
||||
built.cancel()
|
||||
expect(store.stop).toHaveBeenCalledExactlyOnceWith('claude')
|
||||
expect(own.cancel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('outside a renewal, the surface keeps its own × and Restart', () => {
|
||||
const { built, own } = actions({ phase: 'idle' })
|
||||
built.cancel()
|
||||
built.primary({ kind: 'restart' })
|
||||
expect(store.stop).not.toHaveBeenCalled()
|
||||
expect(own.cancel).toHaveBeenCalledOnce()
|
||||
expect(own.primary).toHaveBeenCalledExactlyOnceWith({ kind: 'restart' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
// CHAT-RENEW-INPLACE (Option A, design-squad per-step copy): every "sign-in expired" strip — a held
|
||||
// launch, a running terminal, a structured chat — renews in place. Renew runs the same Orca Cloud
|
||||
// setup as "Set up once" right in the strip (re-running it replaces the account's expired sign-in),
|
||||
// instead of sending the person to Settings. One model and one set of actions for every surface, so
|
||||
// the terminal and the chat can't drift apart.
|
||||
// 1 expired: [Renew] 2 browser: paste code, [Finish renewing] [Open again] ×
|
||||
// 3 checking: field and buttons disabled 4 rejected: "Code didn't work", [Open again]
|
||||
// 5 renewed: the surface's own renewed state (held: Start; terminal: Restart; chat: next message)
|
||||
// × mid-flow stops the setup and goes back to step 1; it never dismisses the strip.
|
||||
import type { Translate } from '@/i18n/use-translate'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
runOrcaCloudAgentSetups,
|
||||
stopOrcaCloudAgentSetups
|
||||
} from '@/lib/orca-cloud/orca-cloud-agent-accounts-store'
|
||||
import type { OrcaCloudAgentSetup } from '../../../../shared/orca-cloud-agents/orca-cloud-agents-wire'
|
||||
import type { HostAgentSignInAgent } from '../../../../shared/host-agent-sign-in-status'
|
||||
import type { CloudStripActions } from './CloudAgentSignInStrip'
|
||||
import type { CloudStripModel } from './cloud-agent-strip-model'
|
||||
import { cloudAgentSetupStripModel } from './cloud-agent-setup-strip-model'
|
||||
import { cloudSetupStripActions } from './cloud-setup-strip-actions'
|
||||
import { agentSignInCardActions } from '../agent-sign-in/AgentSignInPanePortals'
|
||||
|
||||
/** The renewal's own setup is under way (steps 2-4): the strip shows it instead of step 1. */
|
||||
export function renewInPlaceRunning(setup: OrcaCloudAgentSetup): boolean {
|
||||
return setup.phase !== 'idle' && setup.phase !== 'saved'
|
||||
}
|
||||
|
||||
/**
|
||||
* Steps 1-4 over a surface's "expired" model (step 1 keeps the surface's own detail: running "Renew
|
||||
* to keep working…", held "Renew to start {{agent}}."). Step 5 is each surface's renewed state.
|
||||
*/
|
||||
export function cloudRenewInPlaceModel(args: {
|
||||
agent: HostAgentSignInAgent
|
||||
expired: CloudStripModel
|
||||
setup: OrcaCloudAgentSetup
|
||||
t: Translate
|
||||
}): CloudStripModel {
|
||||
const { expired, setup, t } = args
|
||||
if (!renewInPlaceRunning(setup)) {
|
||||
return expired
|
||||
}
|
||||
const flow = cloudAgentSetupStripModel({ agent: args.agent, setup, ready: false, t })
|
||||
const settingsLink = t('agentSignInStrip.renewInSettings', 'Renew in Settings')
|
||||
// A failed setup keeps its own words; a refused code says "Code didn't work" (as #484).
|
||||
const keepsTitle =
|
||||
setup.phase === 'failed' || (setup.phase === 'browser' && setup.code === 'rejected')
|
||||
const finishRenewing = t('agentSignInStrip.finishRenewing', 'Finish renewing')
|
||||
const paste = flow.paste
|
||||
? {
|
||||
...flow.paste,
|
||||
// Never the setup's "Finish setup" (design-squad).
|
||||
...(flow.paste.setupCode
|
||||
? {
|
||||
setupCode: {
|
||||
...flow.paste.setupCode,
|
||||
submitLabel:
|
||||
setup.phase === 'browser' && setup.code === 'checking'
|
||||
? flow.paste.setupCode.submitLabel
|
||||
: finishRenewing
|
||||
}
|
||||
}
|
||||
: {}),
|
||||
...(flow.paste.submitLabel ? { submitLabel: finishRenewing } : {})
|
||||
}
|
||||
: null
|
||||
return {
|
||||
...flow,
|
||||
title: keepsTitle ? flow.title : expired.title,
|
||||
paste,
|
||||
// A setup that failed outright starts again from Renew.
|
||||
primary:
|
||||
setup.phase === 'failed' && flow.primary?.action.kind === 'setUpOnce'
|
||||
? { ...flow.primary, action: { kind: 'renew' } }
|
||||
: flow.primary,
|
||||
...(setup.phase === 'browser' || setup.phase === 'paste' ? { settingsLink } : {}),
|
||||
announcement: keepsTitle ? flow.announcement : `${expired.title}. ${flow.announcement}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renew starts the setup here; Open again, the code and the paste go to it; × stops it (back to
|
||||
* step 1). With no renewal running, everything else is the surface's own (its × or Restart).
|
||||
*/
|
||||
export function cloudRenewInPlaceActions(args: {
|
||||
agent: HostAgentSignInAgent
|
||||
setup: OrcaCloudAgentSetup
|
||||
/** The surface's own actions, used outside the renewal (Restart, a renewed strip's ×). */
|
||||
surface: CloudStripActions
|
||||
/** "Renew in Settings": the same renewal in Settings › Accounts › Orca Cloud. */
|
||||
openSettings: () => void
|
||||
copyText: (text: string) => void
|
||||
openLink: (url: string) => void
|
||||
}): CloudStripActions {
|
||||
const running = renewInPlaceRunning(args.setup)
|
||||
const flow = cloudSetupStripActions({
|
||||
agent: args.agent,
|
||||
// Renewing holds nothing itself: Start (step 5) belongs to the surface.
|
||||
holdKey: '',
|
||||
setupRunning: running,
|
||||
copyText: args.copyText,
|
||||
openLink: args.openLink,
|
||||
useApiKey: args.surface.useApiKey,
|
||||
keepHoldOnCancel: true
|
||||
})
|
||||
return {
|
||||
...args.surface,
|
||||
primary: (action) => {
|
||||
if (action.kind === 'renew') {
|
||||
runOrcaCloudAgentSetups([args.agent])
|
||||
} else if (action.kind === 'reopenSetup' || action.kind === 'copyAndOpen') {
|
||||
flow.primary(action)
|
||||
} else {
|
||||
args.surface.primary(action)
|
||||
}
|
||||
},
|
||||
submitCode: running ? flow.submitCode : args.surface.submitCode,
|
||||
submitSetupCode: flow.submitSetupCode,
|
||||
// "Copy link" beside Open again: the renewal's sign-in page, as for "Set up once".
|
||||
linkFor: running ? flow.linkFor : args.surface.linkFor,
|
||||
pasteCode: running ? flow.pasteCode : args.surface.pasteCode,
|
||||
openSettings: args.openSettings,
|
||||
cancel: () => (running ? stopOrcaCloudAgentSetups(args.agent) : args.surface.cancel())
|
||||
}
|
||||
}
|
||||
|
||||
/** "Renew in Settings": Settings › Accounts › Orca Cloud, where the same renewal runs. */
|
||||
export function openOrcaCloudAccountsSettings(): void {
|
||||
const store = useAppStore.getState()
|
||||
store.openSettingsTarget({ pane: 'accounts', repoId: null, sectionId: 'orca-cloud' })
|
||||
store.openSettingsPage()
|
||||
}
|
||||
|
||||
/** A surface's expired strip, renewing in place: its model (steps 1-4) and actions together. */
|
||||
export function withRenewInPlace(args: {
|
||||
agent: HostAgentSignInAgent
|
||||
setup: OrcaCloudAgentSetup
|
||||
model: CloudStripModel
|
||||
actions: CloudStripActions
|
||||
/** The pane or chat key, for the sign-in page's copy and open. */
|
||||
key: string
|
||||
t: Translate
|
||||
}): { model: CloudStripModel; actions: CloudStripActions } {
|
||||
const page = agentSignInCardActions(args.key)
|
||||
return {
|
||||
model: cloudRenewInPlaceModel({
|
||||
agent: args.agent,
|
||||
expired: args.model,
|
||||
setup: args.setup,
|
||||
t: args.t
|
||||
}),
|
||||
actions: cloudRenewInPlaceActions({
|
||||
agent: args.agent,
|
||||
setup: args.setup,
|
||||
surface: args.actions,
|
||||
openSettings: openOrcaCloudAccountsSettings,
|
||||
copyText: page.copyText,
|
||||
openLink: page.openLink
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export function cloudSetupStripActions(args: {
|
||||
keepHoldOnCancel?: boolean
|
||||
}): CloudStripActions {
|
||||
const { agent, holdKey } = args
|
||||
const api = window.api.orcaCloudAgents
|
||||
const api = window.api?.orcaCloudAgents
|
||||
return {
|
||||
primary: (action) => {
|
||||
if (action.kind === 'setUpOnce') {
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
// with the renewed sign-in.
|
||||
import { useAppStore } from '@/store'
|
||||
import { useTranslate } from '@/i18n/use-translate'
|
||||
import type { HostAgentSignInAgent } from '../../../../shared/host-agent-sign-in-status'
|
||||
import {
|
||||
HOST_AGENT_SIGN_IN_AGENT_NAME,
|
||||
type HostAgentSignInAgent
|
||||
} from '../../../../shared/host-agent-sign-in-status'
|
||||
import { parseExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { useCloudAgentHold } from '@/lib/orca-cloud/cloud-agent-launch-hold'
|
||||
import {
|
||||
@@ -15,6 +18,7 @@ import { isOrcaCloudEnvironmentId } from '@/lib/orca-cloud/orca-cloud-environmen
|
||||
import {
|
||||
clearCloudRunningSignout,
|
||||
cloudRunningStrip,
|
||||
type CloudRunningStrip,
|
||||
useCloudRunningAccountReads,
|
||||
useCloudRunningSignoutVersion,
|
||||
useWorkspaceUsesAccount
|
||||
@@ -22,12 +26,7 @@ import {
|
||||
import type { CloudStripActions } from './CloudAgentSignInStrip'
|
||||
import type { CloudHeldStrip } from './use-cloud-held-strip'
|
||||
import { cloudChatRunningStripModel } from './cloud-running-strip-model'
|
||||
|
||||
function openAccounts(): void {
|
||||
const store = useAppStore.getState()
|
||||
store.openSettingsTarget({ pane: 'accounts', repoId: null, sectionId: 'orca-cloud' })
|
||||
store.openSettingsPage()
|
||||
}
|
||||
import { openOrcaCloudAccountsSettings, withRenewInPlace } from './cloud-renew-in-place'
|
||||
|
||||
/** The Orca Cloud environment a structured chat's tab runs on, or null. */
|
||||
function useChatEnvironmentId(tabId: string): string | null {
|
||||
@@ -43,27 +42,33 @@ function useChatEnvironmentId(tabId: string): string | null {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* statusKey: the chat's agent-status key (its tab and session); holdKey: its launch-hold key. A held
|
||||
* launch shows the held strip instead.
|
||||
*/
|
||||
export function useCloudChatRunningStrip(args: {
|
||||
type ChatAccountArgs = {
|
||||
tabId: string
|
||||
statusKey: string
|
||||
holdKey: string
|
||||
}): CloudHeldStrip | null {
|
||||
const t = useTranslate()
|
||||
/** The chat's own agent: an idle chat (no message yet) has no status entry to name it. */
|
||||
agent?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The RA-11 decision for a structured chat: expired / renewed / none. An idle chat with no status
|
||||
* entry is decided from its own agent, so the strip shows as soon as the account expires, before a
|
||||
* first message could fail. A held launch wins (null here).
|
||||
*/
|
||||
export function useCloudChatAccountStrip(args: ChatAccountArgs): CloudRunningStrip | null {
|
||||
const revision = useCloudRunningSignoutVersion()
|
||||
const environmentId = useChatEnvironmentId(args.tabId)
|
||||
const cloud = environmentId !== null && isOrcaCloudEnvironmentId(environmentId)
|
||||
const entry = useAppStore((state) =>
|
||||
const status = useAppStore((state) =>
|
||||
cloud ? (state.agentStatusByPaneKey[args.statusKey] ?? null) : null
|
||||
)
|
||||
const agentType = status?.agentType ?? args.agent
|
||||
const agent: HostAgentSignInAgent | null =
|
||||
entry?.agentType === 'claude' ? 'claude' : entry?.agentType === 'codex' ? 'codex' : null
|
||||
const running = cloud && agent !== null
|
||||
useCloudRunningAccountReads(running)
|
||||
const accounts = useOrcaCloudAgentAccounts(running)
|
||||
agentType === 'claude' ? 'claude' : agentType === 'codex' ? 'codex' : null
|
||||
const watched = cloud && agent !== null
|
||||
// Focus and the 30 s poll keep the account read fresh for any open cloud chat of an account agent.
|
||||
useCloudRunningAccountReads(watched)
|
||||
const accounts = useOrcaCloudAgentAccounts(watched)
|
||||
// D13: only a workspace that runs the agent on the person's own account (never shared or keyed).
|
||||
const credential =
|
||||
agent && accounts.load.kind === 'ready' ? accounts.load.status.credentials[agent] : null
|
||||
@@ -73,18 +78,19 @@ export function useCloudChatRunningStrip(args: {
|
||||
credential.state !== 'expiring' &&
|
||||
credential.state !== 'none'
|
||||
const usesAccount = useWorkspaceUsesAccount(
|
||||
running ? environmentId : null,
|
||||
watched ? environmentId : null,
|
||||
agent,
|
||||
expired,
|
||||
orcaCloudAgentAccountsLoadedAt()
|
||||
)
|
||||
const held = useCloudAgentHold(args.holdKey) !== null
|
||||
if (!running) {
|
||||
if (!watched || !agent) {
|
||||
return null
|
||||
}
|
||||
const strip = cloudRunningStrip({
|
||||
return cloudRunningStrip({
|
||||
paneKey: args.statusKey,
|
||||
entry,
|
||||
// An idle chat (no message yet) has no status entry: decided from its own agent.
|
||||
entry: status ?? { agentType: agent, state: 'waiting' },
|
||||
accounts,
|
||||
held,
|
||||
now: orcaCloudAgentAccountsLoadedAt(),
|
||||
@@ -92,11 +98,18 @@ export function useCloudChatRunningStrip(args: {
|
||||
revision,
|
||||
turnEndedFallback: true
|
||||
})
|
||||
}
|
||||
|
||||
/** The strip a structured chat shows for its account while its agent runs or waits idle. */
|
||||
export function useCloudChatRunningStrip(args: ChatAccountArgs): CloudHeldStrip | null {
|
||||
const t = useTranslate()
|
||||
const strip = useCloudChatAccountStrip(args)
|
||||
const accounts = useOrcaCloudAgentAccounts(strip !== null)
|
||||
if (!strip) {
|
||||
return null
|
||||
}
|
||||
const actions: CloudStripActions = {
|
||||
primary: (action) => (action.kind === 'renew' ? openAccounts() : undefined),
|
||||
primary: (action) => (action.kind === 'renew' ? openOrcaCloudAccountsSettings() : undefined),
|
||||
submitCode: () => undefined,
|
||||
pasteCode: () => undefined,
|
||||
useCode: () => undefined,
|
||||
@@ -104,5 +117,32 @@ export function useCloudChatRunningStrip(args: {
|
||||
// Expired has no ×; renewed's × dismisses.
|
||||
cancel: () => (strip.phase === 'renewed' ? clearCloudRunningSignout(args.statusKey) : undefined)
|
||||
}
|
||||
return { model: cloudChatRunningStripModel(strip, t), prompt: null, actions }
|
||||
const model = cloudChatRunningStripModel(strip, t)
|
||||
// CHAT-RENEW-INPLACE: the chat's "sign-in expired · Renew" renews right here.
|
||||
const shown =
|
||||
strip.phase === 'expired'
|
||||
? withRenewInPlace({
|
||||
agent: strip.agent,
|
||||
setup: accounts.setups[strip.agent],
|
||||
model,
|
||||
actions,
|
||||
key: args.statusKey,
|
||||
t
|
||||
})
|
||||
: { model, actions }
|
||||
return { ...shown, prompt: null }
|
||||
}
|
||||
|
||||
/**
|
||||
* While the chat's account is expired, typing stays open and only Send is held, with this hint
|
||||
* (the person's draft is never lost); Renew lifts it at once.
|
||||
*/
|
||||
export function useCloudChatSendBlock(args: ChatAccountArgs): string | undefined {
|
||||
const t = useTranslate()
|
||||
const strip = useCloudChatAccountStrip(args)
|
||||
return strip?.phase === 'expired'
|
||||
? t('orcaCloud.chatSend.renewAbove', 'Renew {{agent}} sign-in above to send', {
|
||||
agent: HOST_AGENT_SIGN_IN_AGENT_NAME[strip.agent]
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@ import {
|
||||
cloudAgentSetupStripModel
|
||||
} from './cloud-agent-setup-strip-model'
|
||||
import { cloudSetupStripActions } from './cloud-setup-strip-actions'
|
||||
import {
|
||||
cloudRenewInPlaceActions,
|
||||
cloudRenewInPlaceModel,
|
||||
openOrcaCloudAccountsSettings
|
||||
} from './cloud-renew-in-place'
|
||||
|
||||
export type CloudHeldStrip = {
|
||||
model: CloudStripModel
|
||||
@@ -70,7 +75,7 @@ export function useCloudHeldStrip(
|
||||
const setupHold = hold?.reason === 'notSetUp' || awaiting !== null
|
||||
const setup = accounts.setups[agent]
|
||||
// HELD-STALE-COVERED: the setup just finished here: "is set up · Start" (the account, read fresh).
|
||||
const model =
|
||||
const shownBeforeRenew =
|
||||
awaiting === 'setUp'
|
||||
? cloudAgentSetupStripModel({ agent, setup: accounts.setups[agent], ready: true, t })
|
||||
: awaiting
|
||||
@@ -95,6 +100,11 @@ export function useCloudHeldStrip(
|
||||
hostLabel: cloudGate?.hostLabel ?? t('agentSignInStrip.sandbox', 'the sandbox'),
|
||||
t
|
||||
})
|
||||
// CHAT-RENEW-INPLACE: a launch held on an expired sign-in renews right here (steps 1-4).
|
||||
const renewing = hold?.reason === 'accountExpired'
|
||||
const model = renewing
|
||||
? cloudRenewInPlaceModel({ agent, expired: shownBeforeRenew, setup, t })
|
||||
: shownBeforeRenew
|
||||
const setupRunning = setup.phase !== 'idle' && setup.phase !== 'saved' && setup.phase !== 'failed'
|
||||
const holdActions: CloudStripActions | null = setupHold
|
||||
? cloudSetupStripActions({
|
||||
@@ -107,7 +117,7 @@ export function useCloudHeldStrip(
|
||||
keepHoldOnCancel: chat
|
||||
})
|
||||
: null
|
||||
const actions: CloudStripActions = holdActions ?? {
|
||||
const gateOrRecovery: CloudStripActions = {
|
||||
primary: (action) => {
|
||||
// RA-11's restart belongs to the running-pane strip; a held launch never offers it.
|
||||
if (
|
||||
@@ -152,9 +162,21 @@ export function useCloudHeldStrip(
|
||||
}
|
||||
}
|
||||
}
|
||||
const actions: CloudStripActions =
|
||||
holdActions ??
|
||||
(renewing
|
||||
? cloudRenewInPlaceActions({
|
||||
agent,
|
||||
setup,
|
||||
surface: gateOrRecovery,
|
||||
openSettings: openOrcaCloudAccountsSettings,
|
||||
copyText: gateActions.copyText,
|
||||
openLink: gateActions.openLink
|
||||
})
|
||||
: gateOrRecovery)
|
||||
// A held chat's strip has nothing to cancel unless a setup is running: no × (Escape is a no-op).
|
||||
const shownModel =
|
||||
chat && hold && !(setupHold && setupRunning) ? { ...model, noClose: true } : model
|
||||
chat && hold && !((setupHold || renewing) && setupRunning) ? { ...model, noClose: true } : model
|
||||
return {
|
||||
key,
|
||||
active,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { Dialog } from '@/components/ui/dialog'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { TeamDialogContent } from './TeamDialogHost'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
// TEAM-MODAL-CENTER: the Team dialog (opened from the account menu, Create a team, or after
|
||||
// Checkout; all go through this one surface) is centred in the window like every other dialog.
|
||||
it('centres the Team dialog in the window instead of pinning it near the top', () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<Dialog open>
|
||||
<TeamDialogContent>
|
||||
<p>Team</p>
|
||||
</TeamDialogContent>
|
||||
</Dialog>
|
||||
</TooltipProvider>
|
||||
)
|
||||
const dialog = document.querySelector<HTMLElement>('[data-team-dialog]')!
|
||||
// Portaled to the document, not into the menu that opened it.
|
||||
expect(dialog.closest('[data-slot="dropdown-menu-content"]')).toBeNull()
|
||||
const classes = dialog.className.split(/\s+/)
|
||||
expect(classes).toContain('fixed')
|
||||
expect(classes).toContain('top-[50%]')
|
||||
expect(classes).toContain('left-[50%]')
|
||||
expect(classes).toContain('translate-y-[-50%]')
|
||||
expect(classes).toContain('translate-x-[-50%]')
|
||||
expect(classes).not.toContain('top-[6vh]')
|
||||
expect(classes).not.toContain('translate-y-0')
|
||||
})
|
||||
@@ -234,6 +234,13 @@ export function TeamPageBody({
|
||||
openTeammateProfile(userId)
|
||||
}}
|
||||
onDismissArrival={clearTeamPageArrival}
|
||||
onOpenEnterprise={() => {
|
||||
// SSO, SCIM and the audit log live in Settings › Enterprise (#614); the dialog points there.
|
||||
closeTeamPage()
|
||||
const store = useAppStore.getState()
|
||||
store.openSettingsTarget({ pane: 'cloud-enterprise', repoId: null })
|
||||
store.openSettingsPage()
|
||||
}}
|
||||
inline={inline}
|
||||
dangerZone={
|
||||
org && teamDelete.check ? (
|
||||
@@ -251,13 +258,18 @@ export function TeamPageBody({
|
||||
)
|
||||
}
|
||||
|
||||
/** The Team page's dialog surface: top-anchored so loading and checkout arrivals never jump. */
|
||||
/**
|
||||
* The Team page's dialog surface, centred in the window like every other dialog, whichever entry
|
||||
* point opened it (account menu, Create a team, the post-checkout hand-off). It used to be pinned
|
||||
* 6vh from the top (`top-[6vh] translate-y-0`), so it sat high on screen (TEAM-MODAL-CENTER).
|
||||
*/
|
||||
export function TeamDialogContent({ children }: { children: React.ReactNode }): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
return (
|
||||
<DialogContent
|
||||
scrollable
|
||||
className="top-[6vh] max-h-[88vh] translate-y-0 sm:max-w-2xl"
|
||||
data-team-dialog=""
|
||||
className="max-h-[88vh] sm:max-w-2xl"
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
if (event.currentTarget instanceof HTMLElement) {
|
||||
|
||||
@@ -89,7 +89,8 @@ const base: TeamPageViewProps = {
|
||||
onSeePlans: noop,
|
||||
onManageBilling: noop,
|
||||
onCreateWorkspace: noop,
|
||||
onDismissArrival: noop
|
||||
onDismissArrival: noop,
|
||||
onOpenEnterprise: noop
|
||||
}
|
||||
|
||||
function Page(props: Partial<TeamPageViewProps>): React.JSX.Element {
|
||||
@@ -104,6 +105,21 @@ function Page(props: Partial<TeamPageViewProps>): React.JSX.Element {
|
||||
|
||||
export const ownerOnTeam = defineFixture({ width, render: () => <Page /> })
|
||||
|
||||
/** TEAM-MODAL-CENTER: in a tall window the dialog sits in the middle, not pinned near the top. */
|
||||
export const centeredInWindow = defineFixture({
|
||||
width,
|
||||
render: () => (
|
||||
<div className="h-[1000px]">
|
||||
<Page
|
||||
roster={{
|
||||
status: 'ok',
|
||||
roster: { ...roster, members: roster.members.slice(0, 2), pendingInvites: [] }
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export const justUpgraded = defineFixture({
|
||||
width,
|
||||
render: () => <Page arrival={{ kind: 'checkout-success', seats: 4 }} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import type { OrcaOrgMembersRoster } from '../../../../../shared/orca-profiles'
|
||||
import { sampleTeam } from '../plan-billing-fixtures'
|
||||
@@ -61,3 +61,40 @@ it('keeps the header to the name and counts: no role sentence, no Plan & billing
|
||||
// Seat counts and prices live only in Plan & billing.
|
||||
expect(screen.queryByText(/open seat/)).toBeNull()
|
||||
})
|
||||
|
||||
it('points owners and admins to Settings › Enterprise with one plain button', () => {
|
||||
const onOpenEnterprise = vi.fn()
|
||||
for (const viewerRole of ['owner', 'admin'] as const) {
|
||||
render(
|
||||
<TeamPageView
|
||||
{...props}
|
||||
roster={{ status: 'ok', roster: { ...roster, viewerRole } }}
|
||||
onOpenEnterprise={onOpenEnterprise}
|
||||
/>,
|
||||
{ wrapper: TooltipProvider }
|
||||
)
|
||||
const row = screen.getByRole('region', {
|
||||
name: 'Single sign-on, directory sync and the audit log'
|
||||
})
|
||||
// No Copy link split: the row has exactly one control.
|
||||
expect(row.querySelectorAll('button')).toHaveLength(1)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open Enterprise settings' }))
|
||||
cleanup()
|
||||
}
|
||||
expect(onOpenEnterprise).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('hides the Enterprise row from plain members', () => {
|
||||
render(
|
||||
<TeamPageView
|
||||
{...props}
|
||||
roster={{
|
||||
status: 'ok',
|
||||
roster: { ...roster, viewerRole: 'member', canManageMembers: false }
|
||||
}}
|
||||
onOpenEnterprise={() => {}}
|
||||
/>,
|
||||
{ wrapper: TooltipProvider }
|
||||
)
|
||||
expect(screen.queryByText('Single sign-on, directory sync and the audit log')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { KeyRound, RefreshCw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { InlineError } from '@/components/ui/inline-error'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
@@ -57,6 +57,8 @@ export type TeamPageViewProps = {
|
||||
inline?: { onOpenPlanBilling: () => void }
|
||||
/** Owners in Settings › Team: "Delete <team>", after "Leave <team>" (TEAM-DELETE). */
|
||||
dangerZone?: React.ReactNode
|
||||
/** Opens Settings › Enterprise (SSO, SCIM, audit log). Shown to owners and admins only. */
|
||||
onOpenEnterprise?: () => void
|
||||
}
|
||||
|
||||
function SkeletonRows(): React.JSX.Element {
|
||||
@@ -110,6 +112,10 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element {
|
||||
const rows = roster ? teamRows(roster, props.viewerUserId) : []
|
||||
const selfRow = rows.find((row) => row.self)
|
||||
const viewerRole = roster?.viewerRole ?? 'member'
|
||||
// Matches who sees Settings › Enterprise: team owners and admins (Enterprise or not).
|
||||
const showEnterprise =
|
||||
Boolean(props.onOpenEnterprise) &&
|
||||
(roster?.viewerRole === 'owner' || roster?.viewerRole === 'admin')
|
||||
const canLeave =
|
||||
Boolean(selfRow) &&
|
||||
(viewerRole !== 'owner' || (roster ? otherOwnerCount(roster, props.viewerUserId) > 0 : false))
|
||||
@@ -278,8 +284,33 @@ export function TeamPageView(props: TeamPageViewProps): React.JSX.Element {
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{props.dangerZone ? (
|
||||
{showEnterprise || props.dangerZone ? (
|
||||
<div className="divide-y divide-border/60 rounded-xl border border-border">
|
||||
{showEnterprise ? (
|
||||
// One quiet pointer to Settings › Enterprise, which owns SSO, SCIM and the audit log
|
||||
// (and shows the upsell to teams without Enterprise). Same audience as that section.
|
||||
<section
|
||||
aria-labelledby="team-enterprise-title"
|
||||
className="flex flex-wrap items-center gap-3 p-4"
|
||||
>
|
||||
<h3
|
||||
id="team-enterprise-title"
|
||||
className="flex min-w-[14rem] flex-1 items-center gap-2 text-sm font-medium"
|
||||
>
|
||||
<KeyRound aria-hidden className="size-4 shrink-0 text-muted-foreground" />
|
||||
{t(
|
||||
'auto.components.billing.team.enterpriseRowTitle',
|
||||
'Single sign-on, directory sync and the audit log'
|
||||
)}
|
||||
</h3>
|
||||
<Button type="button" variant="outline" size="sm" onClick={props.onOpenEnterprise}>
|
||||
{t(
|
||||
'auto.components.billing.team.openEnterpriseSettings',
|
||||
'Open Enterprise settings'
|
||||
)}
|
||||
</Button>
|
||||
</section>
|
||||
) : null}
|
||||
{props.dangerZone}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -124,12 +124,11 @@ describe('Open cloud workspace…', () => {
|
||||
expect(getCloudWorkspacePickerRequest()).not.toBeNull()
|
||||
})
|
||||
|
||||
it('with everything in the sidebar, says so instead of inviting a first workspace', async () => {
|
||||
it('with everything in the sidebar, says there is nothing else to open', async () => {
|
||||
listResult = { ok: true, value: [MINE] }
|
||||
renderPicker()
|
||||
act(() => openCloudWorkspacePicker())
|
||||
expect(await screen.findByText('Your cloud workspaces are already in the sidebar')).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: 'New cloud workspace' })).toBeNull()
|
||||
expect(await screen.findByText('Nothing else to open')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a list that could not be read offers Try again', async () => {
|
||||
|
||||
@@ -58,19 +58,27 @@ it('at the running limit, Show cloud workspaces opens the picker on your running
|
||||
closeCloudWorkspacePicker()
|
||||
})
|
||||
|
||||
it('with none at all, invites the first cloud workspace with one button (JA-30)', () => {
|
||||
it('with none at all, says so and offers New cloud workspace as the primary action', () => {
|
||||
const onCreate = vi.fn()
|
||||
render(<PickerEmptyState reason={{ kind: 'none' }} onCreate={onCreate} />)
|
||||
expect(screen.getByText('Start your first cloud workspace')).toBeTruthy()
|
||||
expect(screen.getByText('No cloud workspaces yet')).toBeTruthy()
|
||||
expect(screen.getByText('Start one and it shows up in your sidebar.')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New cloud workspace' }))
|
||||
expect(onCreate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('every other empty list keeps its own copy and offers no create button', () => {
|
||||
it('with everything in the sidebar, says there is nothing else and keeps a secondary New', () => {
|
||||
render(<PickerEmptyState reason={{ kind: 'all-in-sidebar' }} onCreate={vi.fn()} />)
|
||||
expect(screen.getByText('Your cloud workspaces are already in the sidebar')).toBeTruthy()
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
cleanup()
|
||||
expect(screen.getByText('Nothing else to open')).toBeTruthy()
|
||||
expect(
|
||||
screen.getByText('Every cloud workspace you can open is already in your sidebar.')
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'New cloud workspace' }).getAttribute('data-variant')
|
||||
).toBe('outline')
|
||||
})
|
||||
|
||||
it('a search with no match keeps its own copy and offers no create button', () => {
|
||||
render(<PickerEmptyState reason={{ kind: 'search' }} onCreate={vi.fn()} />)
|
||||
expect(screen.getByText('Nothing matches your search')).toBeTruthy()
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
|
||||
@@ -104,7 +104,7 @@ export function PickerEmptyState({
|
||||
onCreate
|
||||
}: {
|
||||
reason: PickerEmptyReason
|
||||
/** Offered only when there are none at all: start one. */
|
||||
/** Start one: primary with none at all, secondary when all are in the sidebar. */
|
||||
onCreate?: () => void
|
||||
}): React.JSX.Element {
|
||||
const t = useTranslate()
|
||||
@@ -128,13 +128,10 @@ export function PickerEmptyState({
|
||||
]
|
||||
case 'all-in-sidebar':
|
||||
return [
|
||||
t('components.cloudWorkspaces.picker.emptyNothingElseTitle', 'Nothing else to open'),
|
||||
t(
|
||||
'components.cloudWorkspaces.picker.emptyAllInSidebarTitle',
|
||||
'Your cloud workspaces are already in the sidebar'
|
||||
),
|
||||
t(
|
||||
'components.cloudWorkspaces.picker.emptyAllInSidebarBody',
|
||||
'Workspaces teammates share, and ones you start on another computer, show up here.'
|
||||
'components.cloudWorkspaces.picker.emptyNothingElseBody',
|
||||
'Every cloud workspace you can open is already in your sidebar.'
|
||||
)
|
||||
]
|
||||
case 'none-running':
|
||||
@@ -159,10 +156,10 @@ export function PickerEmptyState({
|
||||
]
|
||||
case 'none':
|
||||
return [
|
||||
t('components.cloudWorkspaces.emptyFirstTitle', 'Start your first cloud workspace'),
|
||||
t('components.cloudWorkspaces.picker.emptyNoneTitle', 'No cloud workspaces yet'),
|
||||
t(
|
||||
'components.cloudWorkspaces.emptyStartBody',
|
||||
'It runs in a sandbox, so your agents keep working even when this computer is off.'
|
||||
'components.cloudWorkspaces.picker.emptyNoneBody',
|
||||
'Start one and it shows up in your sidebar.'
|
||||
)
|
||||
]
|
||||
}
|
||||
@@ -171,8 +168,15 @@ export function PickerEmptyState({
|
||||
<div className="flex flex-col items-center gap-1 px-6 py-8 text-center">
|
||||
<p className="text-sm font-medium text-foreground">{title}</p>
|
||||
<p className="max-w-sm text-xs text-muted-foreground">{body}</p>
|
||||
{reason.kind === 'none' && onCreate ? (
|
||||
<Button type="button" size="sm" className="mt-3" onClick={onCreate}>
|
||||
{(reason.kind === 'none' || reason.kind === 'all-in-sidebar') && onCreate ? (
|
||||
// Primary when there are none at all; secondary when everything is already in the sidebar.
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={reason.kind === 'none' ? 'default' : 'outline'}
|
||||
className="mt-3"
|
||||
onClick={onCreate}
|
||||
>
|
||||
<Cloud className="size-3.5" />
|
||||
{t('components.cloudWorkspaces.newCloudWorkspace', 'New cloud workspace')}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// A cloud workspace's share link: the org slug + W-number link when known, else the sandbox-id form.
|
||||
import { toast } from 'sonner'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { cloudWorkspaceShareLink } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire'
|
||||
import { cloudWorkspaceIdentity, rememberCloudWorkspaces } from './cloud-workspace-identity'
|
||||
|
||||
// How long Copy link waits on the control plane before settling for the sandbox-id link.
|
||||
const LINK_LOOKUP_MS = 3_000
|
||||
|
||||
async function lookUpLink(id: string): Promise<string | null> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
const fetched = await Promise.race([
|
||||
window.api.cloudWorkspaces.get(id),
|
||||
new Promise<null>((resolve) => {
|
||||
timer = setTimeout(() => resolve(null), LINK_LOOKUP_MS)
|
||||
})
|
||||
])
|
||||
if (!fetched?.ok) {
|
||||
return null
|
||||
}
|
||||
rememberCloudWorkspaces([fetched.value])
|
||||
return fetched.value.link ?? null
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The org slug + W-number link when the control plane gave one, else the sandbox id form, which
|
||||
* opens for anyone with access. Never rejects: a device that didn't create the workspace (paired,
|
||||
* offline, signed out) still gets a working link.
|
||||
*/
|
||||
export async function shareLinkFor(id: string, chatId?: string | null): Promise<string> {
|
||||
const link = cloudWorkspaceIdentity(id)?.link ?? (await lookUpLink(id))
|
||||
return cloudWorkspaceShareLink({ id, ...(link ? { link } : {}) }, chatId)
|
||||
}
|
||||
|
||||
export async function copyCloudWorkspaceLink(
|
||||
id: string,
|
||||
chatId?: string | null,
|
||||
options: { quiet?: boolean } = {}
|
||||
): Promise<boolean> {
|
||||
const link = await shareLinkFor(id, chatId)
|
||||
try {
|
||||
// Why: main-process clipboard, like every other renderer copy. navigator.clipboard needs a
|
||||
// focused document with live user activation; after the link lookup (or from the menu or a
|
||||
// shortcut) Electron rejects it with "Document is not focused".
|
||||
await window.api.ui.writeClipboardText(link)
|
||||
} catch (error) {
|
||||
console.warn('[cloud-workspaces] copy link failed', error)
|
||||
toast.error(
|
||||
translate('auto.components.cloudWorkspaces.copyFailed', "Couldn't copy the link. Try again.")
|
||||
)
|
||||
return false
|
||||
}
|
||||
if (options.quiet) {
|
||||
return true
|
||||
}
|
||||
toast.success(
|
||||
chatId
|
||||
? translate('auto.components.cloudWorkspaces.chatLinkCopied', 'Chat link copied')
|
||||
: translate('auto.components.cloudWorkspaces.workspaceLinkCopied', 'Workspace link copied')
|
||||
)
|
||||
return true
|
||||
}
|
||||
@@ -11,8 +11,6 @@ import { activateAndRevealWorktree } from '@/lib/workspace/activation/worktree-a
|
||||
import { refreshRuntimeProjectWorktreesAndLineage } from '@/hooks/runtime-project-refresh-scheduler'
|
||||
import { RuntimeRpcCallError, unwrapRuntimeRpcResult } from '@/runtime/rpc/calls/runtime-rpc-result'
|
||||
import type { CloudWorkspace } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire'
|
||||
import { cloudWorkspaceShareLink } from '../../../../shared/cloud-workspaces/cloud-workspaces-wire'
|
||||
import { cloudWorkspaceIdentity, rememberCloudWorkspaces } from './cloud-workspace-identity'
|
||||
import {
|
||||
getWorktreeExecutionHostId,
|
||||
toRuntimeExecutionHostId
|
||||
@@ -66,7 +64,20 @@ function isPairingRejected(error: unknown): boolean {
|
||||
return error instanceof Error && /\b(unauthori[sz]ed|device_revoked)\b/i.test(error.message)
|
||||
}
|
||||
|
||||
async function connectAndReveal(environmentId: string, name: string): Promise<OpenOutcome> {
|
||||
export type OpenCloudWorkspaceOptions = {
|
||||
/**
|
||||
* The server this open connects to, before any of its rows load. A GitHub-only create's sheet
|
||||
* hides that server's rows until it hands off to them, so its pending row never doubles up.
|
||||
*/
|
||||
onHost?: (hostId: string) => void
|
||||
}
|
||||
|
||||
async function connectAndReveal(
|
||||
environmentId: string,
|
||||
name: string,
|
||||
options: OpenCloudWorkspaceOptions = {}
|
||||
): Promise<OpenOutcome> {
|
||||
options.onHost?.(toRuntimeExecutionHostId(environmentId))
|
||||
const environments = await window.api.runtimeEnvironments.list()
|
||||
useAppStore.getState().setRuntimeEnvironments(environments)
|
||||
const environment = environments.find((e) => e.id === environmentId)
|
||||
@@ -147,7 +158,11 @@ async function wakeIfAsleep(workspace: CloudWorkspace): Promise<OpenOutcome | nu
|
||||
}
|
||||
}
|
||||
|
||||
async function pairAndReveal(workspace: CloudWorkspace, name: string): Promise<OpenOutcome> {
|
||||
async function pairAndReveal(
|
||||
workspace: CloudWorkspace,
|
||||
name: string,
|
||||
options: OpenCloudWorkspaceOptions
|
||||
): Promise<OpenOutcome> {
|
||||
const api = window.api.cloudWorkspaces
|
||||
const binding = (await api.environmentBindings())[workspace.id]
|
||||
if (binding) {
|
||||
@@ -161,7 +176,7 @@ async function pairAndReveal(workspace: CloudWorkspace, name: string): Promise<O
|
||||
return refused
|
||||
}
|
||||
try {
|
||||
return await connectAndReveal(binding.environmentId, name)
|
||||
return await connectAndReveal(binding.environmentId, name, options)
|
||||
} catch (error) {
|
||||
// Only a rejected pairing is fixed by pairing again; anything else is surfaced as is.
|
||||
if (!isPairingRejected(error)) {
|
||||
@@ -213,10 +228,13 @@ async function pairAndReveal(workspace: CloudWorkspace, name: string): Promise<O
|
||||
}
|
||||
// Messages to this workspace now carry the sender line if it is shared with the team.
|
||||
void refreshCloudMessageSenders().catch(() => undefined)
|
||||
return await connectAndReveal(added.environment.id, name)
|
||||
return await connectAndReveal(added.environment.id, name, options)
|
||||
}
|
||||
|
||||
async function openOnce(workspace: CloudWorkspace): Promise<OpenOutcome> {
|
||||
async function openOnce(
|
||||
workspace: CloudWorkspace,
|
||||
options: OpenCloudWorkspaceOptions
|
||||
): Promise<OpenOutcome> {
|
||||
const name = cloudWorkspaceTitle(workspace)
|
||||
try {
|
||||
if (workspace.access === 'owner') {
|
||||
@@ -244,7 +262,7 @@ async function openOnce(workspace: CloudWorkspace): Promise<OpenOutcome> {
|
||||
)
|
||||
}
|
||||
}
|
||||
return await pairAndReveal(workspace, name)
|
||||
return await pairAndReveal(workspace, name, options)
|
||||
} catch (error) {
|
||||
// Raw transport/RPC messages are not user copy; keep them for diagnostics only.
|
||||
console.warn('[cloud-workspaces] open failed', error)
|
||||
@@ -262,75 +280,18 @@ async function openOnce(workspace: CloudWorkspace): Promise<OpenOutcome> {
|
||||
// instead of minting parallel grants.
|
||||
const opening = new Map<string, Promise<OpenOutcome>>()
|
||||
|
||||
export function openCloudWorkspace(workspace: CloudWorkspace): Promise<OpenOutcome> {
|
||||
export function openCloudWorkspace(
|
||||
workspace: CloudWorkspace,
|
||||
options: OpenCloudWorkspaceOptions = {}
|
||||
): Promise<OpenOutcome> {
|
||||
const existing = opening.get(workspace.id)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const attempt = openOnce(workspace).finally(() => opening.delete(workspace.id))
|
||||
const attempt = openOnce(workspace, options).finally(() => opening.delete(workspace.id))
|
||||
opening.set(workspace.id, attempt)
|
||||
return attempt
|
||||
}
|
||||
|
||||
// How long Copy link waits on the control plane before settling for the sandbox-id link.
|
||||
const LINK_LOOKUP_MS = 3_000
|
||||
|
||||
async function lookUpLink(id: string): Promise<string | null> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
const fetched = await Promise.race([
|
||||
window.api.cloudWorkspaces.get(id),
|
||||
new Promise<null>((resolve) => {
|
||||
timer = setTimeout(() => resolve(null), LINK_LOOKUP_MS)
|
||||
})
|
||||
])
|
||||
if (!fetched?.ok) {
|
||||
return null
|
||||
}
|
||||
rememberCloudWorkspaces([fetched.value])
|
||||
return fetched.value.link ?? null
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The org slug + W-number link when the control plane gave one, else the sandbox id form, which
|
||||
* opens for anyone with access. Never rejects: a device that didn't create the workspace (paired,
|
||||
* offline, signed out) still gets a working link.
|
||||
*/
|
||||
export async function shareLinkFor(id: string, chatId?: string | null): Promise<string> {
|
||||
const link = cloudWorkspaceIdentity(id)?.link ?? (await lookUpLink(id))
|
||||
return cloudWorkspaceShareLink({ id, ...(link ? { link } : {}) }, chatId)
|
||||
}
|
||||
|
||||
export async function copyCloudWorkspaceLink(
|
||||
id: string,
|
||||
chatId?: string | null,
|
||||
options: { quiet?: boolean } = {}
|
||||
): Promise<boolean> {
|
||||
const link = await shareLinkFor(id, chatId)
|
||||
try {
|
||||
// Why: main-process clipboard, like every other renderer copy. navigator.clipboard needs a
|
||||
// focused document with live user activation; after the link lookup (or from the menu or a
|
||||
// shortcut) Electron rejects it with "Document is not focused".
|
||||
await window.api.ui.writeClipboardText(link)
|
||||
} catch (error) {
|
||||
console.warn('[cloud-workspaces] copy link failed', error)
|
||||
toast.error(
|
||||
translate('auto.components.cloudWorkspaces.copyFailed', "Couldn't copy the link. Try again.")
|
||||
)
|
||||
return false
|
||||
}
|
||||
if (options.quiet) {
|
||||
return true
|
||||
}
|
||||
toast.success(
|
||||
chatId
|
||||
? translate('auto.components.cloudWorkspaces.chatLinkCopied', 'Chat link copied')
|
||||
: translate('auto.components.cloudWorkspaces.workspaceLinkCopied', 'Workspace link copied')
|
||||
)
|
||||
return true
|
||||
}
|
||||
// Copy link lives beside it; re-exported so callers keep one import.
|
||||
export { copyCloudWorkspaceLink, shareLinkFor } from './cloud-workspace-share-link'
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { toast } from 'sonner'
|
||||
import type { Translate } from '@/i18n/use-translate'
|
||||
|
||||
/**
|
||||
* "Shared with your team", with the one thing that changes for the owner (SEC-5): their personal
|
||||
* secrets stop being injected once teammates can open the workspace.
|
||||
*/
|
||||
export function toastSharedWithTeam(t: Translate): void {
|
||||
toast.success(t('auto.components.cloudWorkspaces.sharedWithYourTeam', 'Shared with your team'), {
|
||||
description: t(
|
||||
'auto.components.cloudWorkspaces.sharedPersonalSecretsNote',
|
||||
'Your personal cloud secrets aren’t used in shared workspaces. Shared and repository secrets still are.'
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import type { RowActions } from './CloudWorkspaceRowMenu'
|
||||
import { CloudWorkspaceConfirmDialog, type PendingConfirm } from './CloudWorkspaceConfirmDialog'
|
||||
import { CloudReadSnapshotSheet } from './CloudReadSnapshotSheet'
|
||||
import { useCloudReadPanel } from './use-cloud-read-panel'
|
||||
import { toastSharedWithTeam } from './shared-with-team-toast'
|
||||
|
||||
/** Names for every member id the rows show, and whether you are a team owner or admin. */
|
||||
export function useMemberDirectory(
|
||||
@@ -130,9 +131,7 @@ export function useCloudWorkspaceRowActions({
|
||||
void refreshCloudMessageSenders().catch(() => undefined)
|
||||
}
|
||||
if (change.visibility === 'team') {
|
||||
toast.success(
|
||||
t('auto.components.cloudWorkspaces.sharedWithYourTeam', 'Shared with your team')
|
||||
)
|
||||
toastSharedWithTeam(t)
|
||||
} else if (change.visibility === 'private') {
|
||||
toast.success(
|
||||
t(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useCloudChatHoldHint } from '../../agent-sign-in-strip/use-cloud-chat-hold-hint'
|
||||
import { structuredCloudHoldKey } from '@/lib/orca-cloud/cloud-structured-launch-hold'
|
||||
import { useCloudChatSendBlock } from '../../agent-sign-in-strip/use-cloud-chat-running-strip'
|
||||
import { structuredAgentSessionPaneKey } from '../../../../../shared/agent-session/view/structured-agent-session-projection'
|
||||
import { useTranslate, type Translate } from '@/i18n/use-translate'
|
||||
import {
|
||||
managedRuntimeInstallCard,
|
||||
@@ -33,7 +35,10 @@ const RESERVED: NativeChatInstallSlotState = { kind: 'reserved' }
|
||||
|
||||
/** Reserve unknown readiness with an editable composer and held sends; missing runtimes show installation, while read failures leave it alone. */
|
||||
export function useNativeChatInstallSlot(
|
||||
props: Pick<NativeChatStructuredViewProps, 'target' | 'agent' | 'isVisible' | 'sessionId'>,
|
||||
props: Pick<
|
||||
NativeChatStructuredViewProps,
|
||||
'target' | 'agent' | 'isVisible' | 'sessionId' | 'tabId'
|
||||
>,
|
||||
agentLabel: string,
|
||||
layout: ManagedRuntimeInstallLayout
|
||||
): {
|
||||
@@ -49,7 +54,15 @@ export function useNativeChatInstallSlot(
|
||||
hostProgram
|
||||
)
|
||||
// CHAT-BANNERS: an Orca Cloud launch held on the chat's strip has no agent to send to yet.
|
||||
const cloudHoldHint = useCloudChatHoldHint(structuredCloudHoldKey(props.sessionId), '')
|
||||
const cloudHoldKey = structuredCloudHoldKey(props.sessionId)
|
||||
const cloudHoldHint = useCloudChatHoldHint(cloudHoldKey, '')
|
||||
// While its Orca Cloud account is expired, the chat takes typing but holds Send (draft kept).
|
||||
const cloudSendBlock = useCloudChatSendBlock({
|
||||
tabId: props.tabId,
|
||||
statusKey: structuredAgentSessionPaneKey(props.tabId, props.sessionId),
|
||||
holdKey: cloudHoldKey,
|
||||
agent: props.agent
|
||||
})
|
||||
const unavailableHint =
|
||||
cloudHoldHint ??
|
||||
(slot.kind === 'install'
|
||||
@@ -57,7 +70,8 @@ export function useNativeChatInstallSlot(
|
||||
: undefined)
|
||||
return {
|
||||
slot,
|
||||
sendBlockedReason: unavailableHint ?? installSlotSendBlockedReason(slot, agentLabel, t),
|
||||
sendBlockedReason:
|
||||
unavailableHint ?? cloudSendBlock ?? installSlotSendBlockedReason(slot, agentLabel, t),
|
||||
unavailableHint
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +152,7 @@ export function nativeChatPaneHeader(
|
||||
tabId={view.tabId}
|
||||
paneKey={structuredCloudHoldKey(view.sessionId)}
|
||||
statusKey={structuredAgentSessionPaneKey(view.tabId, view.sessionId)}
|
||||
agent={view.agent}
|
||||
/>
|
||||
)
|
||||
const header =
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineFixture } from '../../component-fixtures/component-fixture'
|
||||
import type { CloudSetupNotice } from '@/runtime/ephemeral-vm/ephemeral-vm-environment-check-triage'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { CloudPushAccess } from '@/lib/orca-cloud/cloud-push-access'
|
||||
import { cloudSetupNoticesView } from './CloudSetupNotices'
|
||||
import { CloudToastCard, type CloudToastView } from './cloud-toast-stack'
|
||||
|
||||
@@ -34,12 +35,58 @@ const FETCH: CloudSetupNotice = {
|
||||
}
|
||||
}
|
||||
|
||||
function notices(list: readonly CloudSetupNotice[], installing = false, checkingFetch = false) {
|
||||
const DISK_LOW: CloudSetupNotice = {
|
||||
id: 'disk.free',
|
||||
kind: 'disk',
|
||||
check: {
|
||||
id: 'disk.free',
|
||||
category: 'disk',
|
||||
status: 'fail',
|
||||
message: 'Only 0.8 GB free.',
|
||||
code: 'diskVeryLow',
|
||||
params: { free: '0.8' }
|
||||
}
|
||||
}
|
||||
|
||||
const TOOL_MISSING: CloudSetupNotice = {
|
||||
id: 'tool.pnpm',
|
||||
kind: 'tool',
|
||||
check: {
|
||||
id: 'tool.pnpm',
|
||||
category: 'tools',
|
||||
status: 'fail',
|
||||
message: 'pnpm is not on PATH, but pnpm-lock.yaml needs it.',
|
||||
code: 'toolMissing',
|
||||
params: { command: 'pnpm', reason: 'pnpm-lock.yaml' }
|
||||
}
|
||||
}
|
||||
|
||||
function notices(
|
||||
list: readonly CloudSetupNotice[],
|
||||
options: {
|
||||
installing?: boolean
|
||||
checkingFetch?: boolean
|
||||
access?: CloudPushAccess
|
||||
repoUrl?: string
|
||||
} = {}
|
||||
) {
|
||||
return cloudSetupNoticesView(
|
||||
{ repoUrl: 'https://github.com/stablyai/orca', environmentId: 'env' },
|
||||
{ repoUrl: options.repoUrl ?? 'https://github.com/stablyai/orca', environmentId: 'env' },
|
||||
list,
|
||||
translate,
|
||||
{ installing, install: noop, runSetup: noop, dismiss: noop, recheckFetch: noop, checkingFetch }
|
||||
{
|
||||
push: {
|
||||
access: options.access ?? 'install',
|
||||
installing: options.installing ?? false,
|
||||
install: noop,
|
||||
copyRequest: noop,
|
||||
recheck: noop
|
||||
},
|
||||
runSetup: noop,
|
||||
dismiss: noop,
|
||||
recheckFetch: noop,
|
||||
checkingFetch: options.checkingFetch ?? false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -70,10 +117,23 @@ function cards(views: readonly CloudToastView[]) {
|
||||
|
||||
/** The push-access case Neil hit: public repo, App installed only on his personal account. */
|
||||
export const pushOnly = cards([notices([PUSH])])
|
||||
export const pushWaitingForGitHub = cards([notices([PUSH], true)])
|
||||
export const pushWaitingForGitHub = cards([notices([PUSH], { installing: true })])
|
||||
/** ENV-NOTICE-ACTIONS: someone else's public repository: no Install, a request to copy for an owner. */
|
||||
export const pushNotInstallable = cards([
|
||||
notices([PUSH], {
|
||||
access: 'cantInstall',
|
||||
repoUrl: 'https://github.com/octocat/Hello-World'
|
||||
})
|
||||
])
|
||||
/** An owner was already asked to approve the App: copy a request to nudge them. */
|
||||
export const pushRequested = cards([notices([PUSH], { access: 'requested' })])
|
||||
/** Plain words, no button: a sandbox's disk can't be grown from Orca. */
|
||||
export const diskLow = cards([notices([DISK_LOW])])
|
||||
/** Plain words naming the tool and what needs it; Orca can't install it, so no button. */
|
||||
export const toolMissing = cards([notices([TOOL_MISSING])])
|
||||
export const setupOnly = cards([notices([SETUP])])
|
||||
/** One family (DS-q): the action-required prompt and the warning, same chrome. */
|
||||
export const agentAndPush = cards([AGENT, notices([PUSH, SETUP])])
|
||||
/** ENV-ORIGIN-TOAST: origin still unreachable after two confirming checks; Check again re-runs it. */
|
||||
export const fetchFailed = cards([notices([FETCH])])
|
||||
export const fetchChecking = cards([notices([FETCH], false, true)])
|
||||
export const fetchChecking = cards([notices([FETCH], { checkingFetch: true })])
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { stack } = vi.hoisted(() => ({ stack: { show: vi.fn(), hide: vi.fn() } }))
|
||||
vi.mock('./cloud-toast-stack', () => ({ showCloudToast: stack.show, hideCloudToast: stack.hide }))
|
||||
const { recheck } = vi.hoisted(() => ({ recheck: vi.fn(async () => 'unreachable' as const) }))
|
||||
vi.mock('@/lib/orca-cloud/cloud-fetch-notice', () => ({ recheckCloudFetch: recheck }))
|
||||
const { pushAccess, copyRequest } = vi.hoisted(() => ({
|
||||
pushAccess: vi.fn(async (): Promise<string> => 'install'),
|
||||
copyRequest: vi.fn(async () => true)
|
||||
}))
|
||||
vi.mock('@/lib/orca-cloud/cloud-push-access', () => ({
|
||||
fetchCloudPushAccess: pushAccess,
|
||||
copyOwnerPushRequest: copyRequest
|
||||
}))
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: (select: (state: unknown) => unknown) =>
|
||||
select({ ephemeralVmRuntimesByWorkspaceId: { w1: { runtimeEnvironmentId: 'env-1' } } })
|
||||
}))
|
||||
|
||||
import { CloudSetupNoticesDock, cloudSetupToastId } from './CloudSetupNotices'
|
||||
import { CloudSetupNoticesDock, cloudSetupNoticeLine, cloudSetupToastId } from './CloudSetupNotices'
|
||||
import type { CloudSetupNotice } from '@/runtime/ephemeral-vm/ephemeral-vm-environment-check-triage'
|
||||
import type { CloudPushAccess } from '@/lib/orca-cloud/cloud-push-access'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
dismissCloudSetupNotice,
|
||||
resetCloudSetupNotices,
|
||||
@@ -26,7 +37,7 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('CloudSetupNoticesDock (DS-o, DS-q)', () => {
|
||||
it('is one warning in the shared cloud toast stack, and leaves with its workspace', () => {
|
||||
it('is one warning in the shared cloud toast stack, and leaves with its workspace', async () => {
|
||||
setCloudSetupNotices({
|
||||
environmentId: 'env-1',
|
||||
path: '/w',
|
||||
@@ -42,6 +53,8 @@ describe('CloudSetupNoticesDock (DS-o, DS-q)', () => {
|
||||
]
|
||||
})
|
||||
const view = render(<CloudSetupNoticesDock workspaceId="w1" />)
|
||||
// The access answer arrives after the first render.
|
||||
await act(async () => {})
|
||||
const [id, shown] = stack.show.mock.calls.at(-1) ?? []
|
||||
expect(id).toBe(cloudSetupToastId('env-1'))
|
||||
expect(shown.priority).toBe('warning')
|
||||
@@ -92,4 +105,120 @@ describe('CloudSetupNoticesDock (DS-o, DS-q)', () => {
|
||||
shown.lines[0].action.onClick()
|
||||
expect(recheck).toHaveBeenCalledWith('env-1', expect.any(Function))
|
||||
})
|
||||
|
||||
it('an owner already asked: the line offers a request to copy for them, never Install', async () => {
|
||||
pushAccess.mockResolvedValueOnce('requested')
|
||||
setCloudSetupNotices({
|
||||
environmentId: 'env-1',
|
||||
path: '/w',
|
||||
repoId: 'r',
|
||||
hostId: null,
|
||||
repoUrl: 'https://github.com/acme/web',
|
||||
notices: [PUSH]
|
||||
})
|
||||
render(<CloudSetupNoticesDock workspaceId="w1" />)
|
||||
await act(async () => {})
|
||||
const [, shown] = stack.show.mock.calls.at(-1) ?? []
|
||||
expect(shown.lines[0].action).toMatchObject({ label: 'Copy request for an owner' })
|
||||
await act(async () => shown.lines[0].action.onClick())
|
||||
expect(copyRequest).toHaveBeenCalledWith('https://github.com/acme/web')
|
||||
const [, copied] = stack.show.mock.calls.at(-1) ?? []
|
||||
expect(copied.lines[0].action).toMatchObject({ label: 'Copied' })
|
||||
})
|
||||
})
|
||||
|
||||
const PUSH: CloudSetupNotice = {
|
||||
id: 'git.push',
|
||||
kind: 'push',
|
||||
check: { id: 'git.push', category: 'git', status: 'fail', message: 'refused' }
|
||||
}
|
||||
|
||||
const copyRequestHandler = vi.fn()
|
||||
|
||||
function line(notice: CloudSetupNotice, access: CloudPushAccess = 'install', repoUrl?: string) {
|
||||
return cloudSetupNoticeLine(
|
||||
notice,
|
||||
{ repoUrl: repoUrl ?? 'https://github.com/stablyai/orca' },
|
||||
translate,
|
||||
{
|
||||
push: {
|
||||
access,
|
||||
install: vi.fn(),
|
||||
installing: false,
|
||||
copyRequest: copyRequestHandler,
|
||||
recheck: vi.fn()
|
||||
},
|
||||
runSetup: vi.fn()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
describe('cloudSetupNoticeLine (ENV-NOTICE-ACTIONS)', () => {
|
||||
it('low disk: plain words and what to expect, with no button (the disk can’t be grown here)', () => {
|
||||
const shown = line({
|
||||
id: 'disk.free',
|
||||
kind: 'disk',
|
||||
check: {
|
||||
id: 'disk.free',
|
||||
category: 'disk',
|
||||
status: 'fail',
|
||||
message: 'Only 0.8 GB free.',
|
||||
code: 'diskVeryLow',
|
||||
params: { free: '0.8' }
|
||||
}
|
||||
})
|
||||
expect(shown.text).toBe('Only 0.8 GB of disk space is free in this sandbox.')
|
||||
expect(shown.description).toBe(
|
||||
'Installs and builds may fail. Delete caches or build output you don’t need.'
|
||||
)
|
||||
expect(shown.action).toBeNull()
|
||||
})
|
||||
|
||||
it('a missing tool: names it and what needs it, never the doctor’s label', () => {
|
||||
const shown = line({
|
||||
id: 'tool.pnpm',
|
||||
kind: 'tool',
|
||||
check: {
|
||||
id: 'tool.pnpm',
|
||||
category: 'tools',
|
||||
status: 'fail',
|
||||
message: 'pnpm is not on PATH, but pnpm-lock.yaml needs it.',
|
||||
code: 'toolMissing',
|
||||
params: { command: 'pnpm', reason: 'pnpm-lock.yaml' }
|
||||
}
|
||||
})
|
||||
expect(shown.text).toBe('pnpm isn’t installed in this sandbox, but pnpm-lock.yaml needs it.')
|
||||
expect(shown.description).toContain('setup script')
|
||||
expect(shown.action).toBeNull()
|
||||
// An older server without params: the id still names the tool.
|
||||
const older = line({
|
||||
id: 'tool.cargo',
|
||||
kind: 'tool',
|
||||
check: { id: 'tool.cargo', category: 'tools', status: 'fail', message: 'cargo missing' }
|
||||
})
|
||||
expect(older.text).toBe('cargo isn’t installed in this sandbox.')
|
||||
})
|
||||
|
||||
it('push: Install only where installing is possible, and without a Copy link', () => {
|
||||
const install = line(PUSH, 'install')
|
||||
expect(install.action).toMatchObject({ label: 'Install on stablyai' })
|
||||
// No Copy link: the action type has no link field (App installs never get one).
|
||||
|
||||
const blocked = line(PUSH, 'cantInstall', 'https://github.com/octocat/Hello-World')
|
||||
expect(blocked.text).toBe('Orca Cloud can read octocat/Hello-World but can’t push to it.')
|
||||
expect(blocked.description).toBe(
|
||||
'Pushing needs the Orca GitHub App on octocat. Ask an owner of octocat to install it.'
|
||||
)
|
||||
// No Install: only an owner can. The same Copy request as the requested state.
|
||||
expect(blocked.action).toMatchObject({ label: 'Copy request for an owner' })
|
||||
expect(blocked.action?.label).not.toContain('Install')
|
||||
blocked.action?.run()
|
||||
expect(copyRequestHandler).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(line(PUSH, 'requested').action).toMatchObject({ label: 'Copy request for an owner' })
|
||||
expect(line(PUSH, 'granted').action).toMatchObject({ label: 'Check again' })
|
||||
for (const access of ['checking', 'unknown'] as const) {
|
||||
expect(line(PUSH, access).action).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,31 +1,23 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { GitPullRequestArrow, Loader2, Play, RefreshCw } from 'lucide-react'
|
||||
import { Loader2, Play, RefreshCw } from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useTranslate, type Translate } from '@/i18n/use-translate'
|
||||
import { environmentCheckCopy } from '@/components/worktree-creation/environment-check-copy'
|
||||
import type { CloudSetupNotice } from '@/runtime/ephemeral-vm/ephemeral-vm-environment-check-triage'
|
||||
import {
|
||||
missingToolCommand,
|
||||
type CloudSetupNotice
|
||||
} from '@/runtime/ephemeral-vm/ephemeral-vm-environment-check-triage'
|
||||
import {
|
||||
dismissCloudSetupNotice,
|
||||
useCloudSetupNoticesSnapshot,
|
||||
visibleCloudSetupNotices,
|
||||
type CloudSetupNoticesRecord
|
||||
} from '@/lib/orca-cloud/cloud-setup-notices-store'
|
||||
import {
|
||||
openOrcaGitHubAppInstall,
|
||||
recheckCloudPushAccess,
|
||||
repoOwner,
|
||||
repoSlug,
|
||||
runCloudSetupScript
|
||||
} from '@/lib/orca-cloud/cloud-setup-notice-actions'
|
||||
import { repoSlug, runCloudSetupScript } from '@/lib/orca-cloud/cloud-setup-notice-actions'
|
||||
import { recheckCloudFetch } from '@/lib/orca-cloud/cloud-fetch-notice'
|
||||
import { runVmEnvironmentCheck } from '@/runtime/ephemeral-vm/ephemeral-vm-environment-check'
|
||||
import { hideCloudToast, showCloudToast, type CloudToastView } from './cloud-toast-stack'
|
||||
|
||||
// After Install: GitHub's webhook usually lands within seconds, then the owner may take a while.
|
||||
const FAST_POLL_MS = 2_000
|
||||
const FAST_POLL_WINDOW_MS = 3 * 60_000
|
||||
const SLOW_POLL_MS = 10_000
|
||||
const GIVE_UP_MS = 30 * 60_000
|
||||
import { cloudPushNoticeLine, type CloudPushLineHandlers } from './cloud-push-notice-line'
|
||||
import { useCloudPushNotice } from './use-cloud-push-notice'
|
||||
|
||||
export type CloudSetupNoticeLine = {
|
||||
text: string
|
||||
@@ -39,46 +31,22 @@ export type CloudSetupNoticeLine = {
|
||||
} | null
|
||||
}
|
||||
|
||||
export type CloudSetupNoticeHandlers = {
|
||||
push: CloudPushLineHandlers
|
||||
runSetup: () => void
|
||||
recheckFetch?: () => void
|
||||
checkingFetch?: boolean
|
||||
}
|
||||
|
||||
export function cloudSetupNoticeLine(
|
||||
notice: CloudSetupNotice,
|
||||
record: Pick<CloudSetupNoticesRecord, 'repoUrl'>,
|
||||
t: Translate,
|
||||
handlers: {
|
||||
install: () => void
|
||||
runSetup: () => void
|
||||
installing: boolean
|
||||
recheckFetch?: () => void
|
||||
checkingFetch?: boolean
|
||||
}
|
||||
handlers: CloudSetupNoticeHandlers
|
||||
): CloudSetupNoticeLine {
|
||||
switch (notice.kind) {
|
||||
case 'push':
|
||||
if (record.repoUrl) {
|
||||
const owner = repoOwner(record.repoUrl)
|
||||
return {
|
||||
text: t(
|
||||
'orcaCloud.setupNotices.pushNeedsApp',
|
||||
'Orca Cloud can read {{repo}} but can’t push yet. Install the Orca GitHub App on {{owner}} to push.',
|
||||
{ repo: repoSlug(record.repoUrl), owner }
|
||||
),
|
||||
action: {
|
||||
label: handlers.installing
|
||||
? t('orcaCloud.setupNotices.waitingForGitHub', 'Waiting for GitHub…')
|
||||
: t('orcaCloud.setupNotices.installOn', 'Install on {{owner}}', { owner }),
|
||||
icon: handlers.installing ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<GitPullRequestArrow />
|
||||
),
|
||||
run: handlers.install,
|
||||
pending: handlers.installing
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
text: t('orcaCloud.setupNotices.pushRefused', 'Orca Cloud can’t push from this workspace.'),
|
||||
action: null
|
||||
}
|
||||
return cloudPushNoticeLine(record.repoUrl, t, handlers.push)
|
||||
case 'fetch':
|
||||
// ENV-ORIGIN-TOAST: shown only after a confirming check also failed; Check again re-runs it.
|
||||
return {
|
||||
@@ -116,81 +84,49 @@ export function cloudSetupNoticeLine(
|
||||
run: handlers.runSetup
|
||||
}
|
||||
}
|
||||
case 'check':
|
||||
return { text: environmentCheckCopy(notice.check, t, true).message, action: null }
|
||||
}
|
||||
}
|
||||
|
||||
/** Polls repo access after Install until the push works, GitHub refuses it, or it gives up. */
|
||||
function usePushRecheck(
|
||||
environmentId: string | undefined,
|
||||
hasPush: boolean
|
||||
): {
|
||||
installing: boolean
|
||||
startInstall: (repoUrl: string) => void
|
||||
} {
|
||||
const [installStartedAt, setInstallStartedAt] = useState<number | null>(null)
|
||||
const [tick, setTick] = useState(0)
|
||||
const busy = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!environmentId || !hasPush || installStartedAt === null) {
|
||||
return
|
||||
}
|
||||
const elapsed = Date.now() - installStartedAt
|
||||
if (elapsed > GIVE_UP_MS) {
|
||||
setInstallStartedAt(null)
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
if (busy.current) {
|
||||
setTick((n) => n + 1)
|
||||
return
|
||||
}
|
||||
busy.current = true
|
||||
void recheckCloudPushAccess(environmentId)
|
||||
.then((result) => {
|
||||
if (result !== 'waiting') {
|
||||
setInstallStartedAt(null)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
busy.current = false
|
||||
setTick((n) => n + 1)
|
||||
})
|
||||
},
|
||||
elapsed < FAST_POLL_WINDOW_MS ? FAST_POLL_MS : SLOW_POLL_MS
|
||||
)
|
||||
return () => clearTimeout(timer)
|
||||
}, [environmentId, hasPush, installStartedAt, tick])
|
||||
|
||||
// Installed somewhere else (the browser, another device): check again when Orca is focused.
|
||||
useEffect(() => {
|
||||
if (!environmentId || !hasPush) {
|
||||
return
|
||||
}
|
||||
const onFocus = (): void => {
|
||||
if (!busy.current) {
|
||||
busy.current = true
|
||||
void recheckCloudPushAccess(environmentId).finally(() => {
|
||||
busy.current = false
|
||||
})
|
||||
case 'tool': {
|
||||
// Orca can't install it for the person; the words say where it goes.
|
||||
const command = missingToolCommand(notice.check)
|
||||
const reason = notice.check.params?.reason
|
||||
return {
|
||||
text: reason
|
||||
? t(
|
||||
'orcaCloud.setupNotices.toolMissing',
|
||||
'{{command}} isn’t installed in this sandbox, but {{reason}} needs it.',
|
||||
{ command, reason }
|
||||
)
|
||||
: t(
|
||||
'orcaCloud.setupNotices.toolMissingNoReason',
|
||||
'{{command}} isn’t installed in this sandbox.',
|
||||
{ command }
|
||||
),
|
||||
description: t(
|
||||
'orcaCloud.setupNotices.toolMissingMeaning',
|
||||
'Install it from a terminal here, or add it to your project’s setup script so new workspaces have it.'
|
||||
),
|
||||
action: null
|
||||
}
|
||||
}
|
||||
window.addEventListener('focus', onFocus)
|
||||
return () => window.removeEventListener('focus', onFocus)
|
||||
}, [environmentId, hasPush])
|
||||
|
||||
return {
|
||||
installing: installStartedAt !== null,
|
||||
startInstall: (repoUrl) => {
|
||||
void openOrcaGitHubAppInstall(repoUrl).then((opened) => {
|
||||
if (opened) {
|
||||
setInstallStartedAt(Date.now())
|
||||
}
|
||||
})
|
||||
}
|
||||
case 'disk':
|
||||
// A sandbox's disk can't be grown from Orca, so no button: only what to expect.
|
||||
return {
|
||||
text: t(
|
||||
'orcaCloud.setupNotices.diskLow',
|
||||
'Only {{free}} GB of disk space is free in this sandbox.',
|
||||
{ free: notice.check.params?.free ?? '' }
|
||||
),
|
||||
description:
|
||||
notice.check.code === 'diskVeryLow'
|
||||
? t(
|
||||
'orcaCloud.setupNotices.diskVeryLowMeaning',
|
||||
'Installs and builds may fail. Delete caches or build output you don’t need.'
|
||||
)
|
||||
: t(
|
||||
'orcaCloud.setupNotices.diskLowMeaning',
|
||||
'Large dependency installs may run out of space.'
|
||||
),
|
||||
action: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,8 +159,7 @@ export function cloudSetupNoticesView(
|
||||
notices: readonly CloudSetupNotice[],
|
||||
t: Translate,
|
||||
handlers: {
|
||||
installing: boolean
|
||||
install: () => void
|
||||
push: CloudPushLineHandlers
|
||||
runSetup: (noticeId: string) => void
|
||||
dismiss: (noticeId: string) => void
|
||||
recheckFetch?: () => void
|
||||
@@ -236,9 +171,8 @@ export function cloudSetupNoticesView(
|
||||
label: t('orcaCloud.setupNotices.label', 'Workspace setup'),
|
||||
lines: notices.map((notice) => {
|
||||
const line = cloudSetupNoticeLine(notice, record, t, {
|
||||
install: handlers.install,
|
||||
push: handlers.push,
|
||||
runSetup: () => handlers.runSetup(notice.id),
|
||||
installing: handlers.installing,
|
||||
...(handlers.recheckFetch ? { recheckFetch: handlers.recheckFetch } : {}),
|
||||
...(handlers.checkingFetch ? { checkingFetch: true } : {})
|
||||
})
|
||||
@@ -276,8 +210,9 @@ export function CloudSetupNoticesDock({ workspaceId }: { workspaceId: string }):
|
||||
const snapshot = useCloudSetupNoticesSnapshot()
|
||||
const record = environmentId ? snapshot.records[environmentId] : undefined
|
||||
const notices = visibleCloudSetupNotices(snapshot, environmentId)
|
||||
const push = usePushRecheck(
|
||||
const push = useCloudPushNotice(
|
||||
environmentId,
|
||||
record?.repoUrl ?? null,
|
||||
notices.some((notice) => notice.kind === 'push')
|
||||
)
|
||||
const fetch = useFetchRecheck(environmentId)
|
||||
@@ -296,8 +231,7 @@ export function CloudSetupNoticesDock({ workspaceId }: { workspaceId: string }):
|
||||
showCloudToast(
|
||||
toastId,
|
||||
cloudSetupNoticesView(record, notices, t, {
|
||||
installing: push.installing,
|
||||
install: () => record.repoUrl && push.startInstall(record.repoUrl),
|
||||
push,
|
||||
runSetup: (noticeId) => void runCloudSetupScript(workspaceId, record, noticeId),
|
||||
dismiss: (noticeId) => dismissCloudSetupNotice(record.environmentId, noticeId),
|
||||
recheckFetch: fetch.recheck,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu'
|
||||
import { useTranslate } from '@/i18n/use-translate'
|
||||
import { toastSharedWithTeam } from '@/components/cloud-workspaces/shared-with-team-toast'
|
||||
import { isCloudSyncAvailable, requestCloudSyncFocus } from '@/lib/orca-cloud/cloud-sync-store'
|
||||
import {
|
||||
ConfirmationDialogContext,
|
||||
@@ -178,14 +179,16 @@ async function setVisibility(
|
||||
)
|
||||
return
|
||||
}
|
||||
toast.success(
|
||||
visibility === 'team'
|
||||
? t('auto.components.cloudWorkspaces.sharedWithYourTeam', 'Shared with your team')
|
||||
: t(
|
||||
'auto.components.cloudWorkspaces.nowPrivate',
|
||||
'Now private. Teammates’ devices were disconnected.'
|
||||
)
|
||||
)
|
||||
if (visibility === 'team') {
|
||||
toastSharedWithTeam(t)
|
||||
} else {
|
||||
toast.success(
|
||||
t(
|
||||
'auto.components.cloudWorkspaces.nowPrivate',
|
||||
'Now private. Teammates’ devices were disconnected.'
|
||||
)
|
||||
)
|
||||
}
|
||||
reload()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// The "can't push" line (ENV-NOTICE-ACTIONS): Install only where installing can work, a request to
|
||||
// copy for an owner once one was asked, Check again where the App already reaches the repository,
|
||||
// and, where only an owner can install, a request to copy for them with no Install button.
|
||||
import { Check, Copy, GitPullRequestArrow, Loader2, RefreshCw } from 'lucide-react'
|
||||
import type { Translate } from '@/i18n/use-translate'
|
||||
import { repoOwner, repoSlug } from '@/lib/orca-cloud/cloud-setup-notice-actions'
|
||||
import type { CloudPushAccess } from '@/lib/orca-cloud/cloud-push-access'
|
||||
import type { CloudSetupNoticeLine } from './CloudSetupNotices'
|
||||
|
||||
export type CloudPushLineHandlers = {
|
||||
access: CloudPushAccess
|
||||
install: () => void
|
||||
installing: boolean
|
||||
copyRequest: () => void
|
||||
copied?: boolean
|
||||
recheck: () => void
|
||||
rechecking?: boolean
|
||||
}
|
||||
|
||||
/** Copy a request for an owner (pushRequestMessage), reading "Copied" for a moment after. */
|
||||
function copyRequestAction(
|
||||
h: CloudPushLineHandlers,
|
||||
t: Translate
|
||||
): NonNullable<CloudSetupNoticeLine['action']> {
|
||||
return {
|
||||
label: h.copied
|
||||
? t('orcaCloud.setupNotices.copied', 'Copied')
|
||||
: t('orcaCloud.setupNotices.copyRequest', 'Copy request for an owner'),
|
||||
icon: h.copied ? <Check /> : <Copy />,
|
||||
run: h.copyRequest
|
||||
}
|
||||
}
|
||||
|
||||
export function cloudPushNoticeLine(
|
||||
repoUrl: string | null,
|
||||
t: Translate,
|
||||
h: CloudPushLineHandlers
|
||||
): CloudSetupNoticeLine {
|
||||
if (!repoUrl) {
|
||||
return {
|
||||
text: t('orcaCloud.setupNotices.pushRefused', 'Orca Cloud can’t push from this workspace.'),
|
||||
action: null
|
||||
}
|
||||
}
|
||||
const repo = repoSlug(repoUrl)
|
||||
const owner = repoOwner(repoUrl)
|
||||
switch (h.access) {
|
||||
case 'install':
|
||||
return {
|
||||
text: t(
|
||||
'orcaCloud.setupNotices.pushNeedsApp',
|
||||
'Orca Cloud can read {{repo}} but can’t push yet. Install the Orca GitHub App on {{owner}} to push.',
|
||||
{ repo, owner }
|
||||
),
|
||||
// The App's install page, not a sign-in: no Copy link beside it (AUTH-ONLY-COPY-LINK).
|
||||
action: {
|
||||
label: h.installing
|
||||
? t('orcaCloud.setupNotices.waitingForGitHub', 'Waiting for GitHub…')
|
||||
: t('orcaCloud.setupNotices.installOn', 'Install on {{owner}}', { owner }),
|
||||
icon: h.installing ? <Loader2 className="animate-spin" /> : <GitPullRequestArrow />,
|
||||
run: h.install,
|
||||
pending: h.installing
|
||||
}
|
||||
}
|
||||
case 'requested':
|
||||
return {
|
||||
text: t(
|
||||
'orcaCloud.setupNotices.pushRequested',
|
||||
'Orca Cloud can read {{repo}} but can’t push yet. An owner of {{owner}} has been asked to approve the Orca GitHub App.',
|
||||
{ repo, owner }
|
||||
),
|
||||
action: copyRequestAction(h, t)
|
||||
}
|
||||
case 'cantInstall':
|
||||
return {
|
||||
text: t(
|
||||
'orcaCloud.setupNotices.pushCantInstall',
|
||||
'Orca Cloud can read {{repo}} but can’t push to it.',
|
||||
{ repo }
|
||||
),
|
||||
description: t(
|
||||
'orcaCloud.setupNotices.pushCantInstallMeaning',
|
||||
'Pushing needs the Orca GitHub App on {{owner}}. Ask an owner of {{owner}} to install it.',
|
||||
{ owner }
|
||||
),
|
||||
// No Install: only an owner can. The same request an owner would get once asked.
|
||||
action: copyRequestAction(h, t)
|
||||
}
|
||||
case 'granted':
|
||||
return {
|
||||
text: t('orcaCloud.setupNotices.pushNotYet', 'Orca Cloud can’t push to {{repo}} yet.', {
|
||||
repo
|
||||
}),
|
||||
// The App reaches it, so a fresh sandbox's credential may just have been late.
|
||||
action: {
|
||||
label: h.rechecking
|
||||
? t('orcaCloud.setupNotices.checking', 'Checking…')
|
||||
: t('orcaCloud.setupNotices.checkAgain', 'Check again'),
|
||||
icon: h.rechecking ? <Loader2 className="animate-spin" /> : <RefreshCw />,
|
||||
run: h.recheck,
|
||||
...(h.rechecking ? { pending: true } : {})
|
||||
}
|
||||
}
|
||||
case 'checking':
|
||||
case 'unknown':
|
||||
return {
|
||||
text: t('orcaCloud.setupNotices.pushNotYet', 'Orca Cloud can’t push to {{repo}} yet.', {
|
||||
repo
|
||||
}),
|
||||
action: null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// The "can't push" notice's state: what installing can do for this person (cloud-push-access), the
|
||||
// re-checks after Install, Copy request's brief "Copied", and Check again where the App already
|
||||
// reaches the repository.
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
openOrcaGitHubAppInstall,
|
||||
recheckCloudPushAccess
|
||||
} from '@/lib/orca-cloud/cloud-setup-notice-actions'
|
||||
import {
|
||||
copyOwnerPushRequest,
|
||||
fetchCloudPushAccess,
|
||||
type CloudPushAccess
|
||||
} from '@/lib/orca-cloud/cloud-push-access'
|
||||
|
||||
// After Install: GitHub's webhook usually lands within seconds, then the owner may take a while.
|
||||
const FAST_POLL_MS = 2_000
|
||||
const FAST_POLL_WINDOW_MS = 3 * 60_000
|
||||
const SLOW_POLL_MS = 10_000
|
||||
const GIVE_UP_MS = 30 * 60_000
|
||||
/** How long "Copied" stays on the request button. */
|
||||
export const COPIED_MS = 1_500
|
||||
|
||||
export type CloudPushNoticeState = {
|
||||
access: CloudPushAccess
|
||||
installing: boolean
|
||||
install: () => void
|
||||
copyRequest: () => void
|
||||
copied: boolean
|
||||
recheck: () => void
|
||||
rechecking: boolean
|
||||
}
|
||||
|
||||
/** Polls repo access after Install until the push works, GitHub refuses it, or it gives up. */
|
||||
function usePushRecheck(
|
||||
environmentId: string | undefined,
|
||||
hasPush: boolean
|
||||
): { installing: boolean; startInstall: (repoUrl: string) => void } {
|
||||
const [installStartedAt, setInstallStartedAt] = useState<number | null>(null)
|
||||
const [tick, setTick] = useState(0)
|
||||
const busy = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!environmentId || !hasPush || installStartedAt === null) {
|
||||
return
|
||||
}
|
||||
const elapsed = Date.now() - installStartedAt
|
||||
if (elapsed > GIVE_UP_MS) {
|
||||
setInstallStartedAt(null)
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
if (busy.current) {
|
||||
setTick((n) => n + 1)
|
||||
return
|
||||
}
|
||||
busy.current = true
|
||||
void recheckCloudPushAccess(environmentId)
|
||||
.then((result) => {
|
||||
if (result !== 'waiting') {
|
||||
setInstallStartedAt(null)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
busy.current = false
|
||||
setTick((n) => n + 1)
|
||||
})
|
||||
},
|
||||
elapsed < FAST_POLL_WINDOW_MS ? FAST_POLL_MS : SLOW_POLL_MS
|
||||
)
|
||||
return () => clearTimeout(timer)
|
||||
}, [environmentId, hasPush, installStartedAt, tick])
|
||||
|
||||
// Installed somewhere else (the browser, another device): check again when Orca is focused.
|
||||
useEffect(() => {
|
||||
if (!environmentId || !hasPush) {
|
||||
return
|
||||
}
|
||||
const onFocus = (): void => {
|
||||
if (!busy.current) {
|
||||
busy.current = true
|
||||
void recheckCloudPushAccess(environmentId).finally(() => {
|
||||
busy.current = false
|
||||
})
|
||||
}
|
||||
}
|
||||
window.addEventListener('focus', onFocus)
|
||||
return () => window.removeEventListener('focus', onFocus)
|
||||
}, [environmentId, hasPush])
|
||||
|
||||
return {
|
||||
installing: installStartedAt !== null,
|
||||
startInstall: (repoUrl) => {
|
||||
void openOrcaGitHubAppInstall(repoUrl).then((opened) => {
|
||||
if (opened) {
|
||||
setInstallStartedAt(Date.now())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What the person can do about the refused push, asked again whenever Orca regains focus. */
|
||||
function usePushAccess(repoUrl: string | null, hasPush: boolean): CloudPushAccess {
|
||||
const [answer, setAnswer] = useState<{ repoUrl: string; access: CloudPushAccess } | null>(null)
|
||||
const [tick, setTick] = useState(0)
|
||||
useEffect(() => {
|
||||
if (!hasPush || !repoUrl) {
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
void fetchCloudPushAccess(repoUrl, { force: tick > 0 }).then((access) => {
|
||||
if (!cancelled) {
|
||||
setAnswer({ repoUrl, access })
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [hasPush, repoUrl, tick])
|
||||
useEffect(() => {
|
||||
if (!hasPush || !repoUrl) {
|
||||
return
|
||||
}
|
||||
// An install finished in the browser, or an owner approved the request.
|
||||
const onFocus = (): void => setTick((n) => n + 1)
|
||||
window.addEventListener('focus', onFocus)
|
||||
return () => window.removeEventListener('focus', onFocus)
|
||||
}, [hasPush, repoUrl])
|
||||
return answer && answer.repoUrl === repoUrl ? answer.access : 'checking'
|
||||
}
|
||||
|
||||
export function useCloudPushNotice(
|
||||
environmentId: string | undefined,
|
||||
repoUrl: string | null,
|
||||
hasPush: boolean
|
||||
): CloudPushNoticeState {
|
||||
const access = usePushAccess(repoUrl, hasPush)
|
||||
const push = usePushRecheck(environmentId, hasPush)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const copiedTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
useEffect(() => () => clearTimeout(copiedTimer.current), [])
|
||||
const [rechecking, setRechecking] = useState(false)
|
||||
const recheckBusy = useRef(false)
|
||||
return {
|
||||
access,
|
||||
installing: push.installing,
|
||||
install: () => {
|
||||
if (repoUrl) {
|
||||
push.startInstall(repoUrl)
|
||||
}
|
||||
},
|
||||
copyRequest: () => {
|
||||
if (!repoUrl) {
|
||||
return
|
||||
}
|
||||
void copyOwnerPushRequest(repoUrl).then((ok) => {
|
||||
if (ok) {
|
||||
setCopied(true)
|
||||
clearTimeout(copiedTimer.current)
|
||||
copiedTimer.current = setTimeout(() => setCopied(false), COPIED_MS)
|
||||
}
|
||||
})
|
||||
},
|
||||
copied,
|
||||
recheck: () => {
|
||||
if (!environmentId || recheckBusy.current) {
|
||||
return
|
||||
}
|
||||
recheckBusy.current = true
|
||||
setRechecking(true)
|
||||
// A push that works now drops the line (recheckCloudPushAccess); a refusal keeps it.
|
||||
void recheckCloudPushAccess(environmentId).finally(() => {
|
||||
recheckBusy.current = false
|
||||
setRechecking(false)
|
||||
})
|
||||
},
|
||||
rechecking
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,7 @@ export const ACCOUNT_MFA_WATCH_MAX_MS = 5 * 60_000
|
||||
|
||||
export type AccountMfaState =
|
||||
/** Not read yet, every read failed, or this deployment has no MFA status: the row is hidden. */
|
||||
| { kind: 'unknown' }
|
||||
| ({ kind: 'known' } & OrcaAccountMfa)
|
||||
{ kind: 'unknown' } | ({ kind: 'known' } & OrcaAccountMfa)
|
||||
|
||||
export type AccountMfa = {
|
||||
state: AccountMfaState
|
||||
|
||||
@@ -199,7 +199,7 @@ function list(
|
||||
initialMode={mode}
|
||||
initialSaved={options.saved}
|
||||
onSetUpAgent={noop}
|
||||
description="Only you get these, in every Orca Cloud workspace you start."
|
||||
description="Only you get these, in cloud workspaces you haven’t shared. Shared workspaces don’t get them."
|
||||
/>
|
||||
</Frame>
|
||||
)
|
||||
|
||||
@@ -201,8 +201,8 @@ export function CloudSecretsPane({
|
||||
) : null
|
||||
}
|
||||
description={t(
|
||||
'cloudSecrets.personal.description',
|
||||
'Only you get these, in every Orca Cloud workspace you start.'
|
||||
'cloudSecrets.personal.descriptionUnshared',
|
||||
'Only you get these, in cloud workspaces you haven’t shared. Shared workspaces don’t get them.'
|
||||
)}
|
||||
emptyTitle={t('cloudSecrets.personal.emptyTitle', 'No personal secrets yet')}
|
||||
emptyDetail={t(
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
// GH-CREATE-NO-ROW: a create with no project here yet (a GitHub-only Orca Cloud create) has a
|
||||
// pending row atop the list when grouped by project, as in every other grouping; a create with a
|
||||
// project still sits atop its own project.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { repo, worktree } from './worktree-list-groups-test-fixtures'
|
||||
import { buildRows } from './build-rows.test-fixture'
|
||||
|
||||
function rows(groupBy: 'repo' | 'none', pending: { creationId: string; repoId: string }[]) {
|
||||
return buildRows(
|
||||
groupBy,
|
||||
[{ ...worktree, lastActivityAt: 1 }],
|
||||
new Map([[repo.id, repo]]),
|
||||
null,
|
||||
new Set(),
|
||||
undefined,
|
||||
undefined,
|
||||
'manual',
|
||||
{},
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
[],
|
||||
new Set(),
|
||||
new Map(),
|
||||
new Map(),
|
||||
pending
|
||||
)
|
||||
}
|
||||
|
||||
describe('pending row for a create with no project yet', () => {
|
||||
it('by project: sits atop the list, before the first project header', () => {
|
||||
const list = rows('repo', [{ creationId: 'gh-1', repoId: '' }])
|
||||
expect(list[0]).toMatchObject({ type: 'pending-creation', creationId: 'gh-1' })
|
||||
expect(list.filter((row) => row.type === 'pending-creation')).toHaveLength(1)
|
||||
// No blank project header for it (it used to get one, keyed "repo:").
|
||||
expect(list.filter((row) => row.type === 'header')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('by project: a create with a project still sits atop that project only', () => {
|
||||
const list = rows('repo', [{ creationId: 'local-1', repoId: repo.id }])
|
||||
const header = list.findIndex((row) => row.type === 'header')
|
||||
expect(header).toBeGreaterThanOrEqual(0)
|
||||
expect(list.findIndex((row) => row.type === 'pending-creation')).toBeGreaterThan(header)
|
||||
expect(list.filter((row) => row.type === 'pending-creation')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('ungrouped: unchanged, every pending row on top', () => {
|
||||
const list = rows('none', [{ creationId: 'gh-1', repoId: '' }])
|
||||
expect(list[0]).toMatchObject({ type: 'pending-creation', creationId: 'gh-1' })
|
||||
})
|
||||
})
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
// @vitest-environment happy-dom
|
||||
// A cloud create's billing refusal is a precondition, not an error: its sidebar status reads in
|
||||
// muted text (the glyph carries the state); a real failure stays red.
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import { githubCreateRequest } from '@/lib/orca-cloud/start-cloud-workspace-from-github'
|
||||
import type { PendingWorktreeCreation } from '@/lib/workspace/creation/pending-worktree-creation'
|
||||
import { PendingWorktreeRow } from './PendingWorktreeRow'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function failed(
|
||||
errorCode: string | undefined,
|
||||
log: string,
|
||||
error: string
|
||||
): PendingWorktreeCreation {
|
||||
return {
|
||||
creationId: 'c',
|
||||
phase: 'provisioning-vm',
|
||||
status: 'error',
|
||||
startedAt: 1,
|
||||
indeterminate: false,
|
||||
loaderVisible: true,
|
||||
error,
|
||||
...(errorCode ? { errorCode } : {}),
|
||||
provisioningLog: log,
|
||||
request: githubCreateRequest({
|
||||
repoUrl: 'https://github.com/acme/web',
|
||||
fullName: 'acme/web',
|
||||
prompt: 'Fix login',
|
||||
agent: null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function statusLine(entry: PendingWorktreeCreation): HTMLElement {
|
||||
useAppStore.setState({ pendingWorktreeCreations: { c: entry }, repos: [] })
|
||||
render(<PendingWorktreeRow creationId="c" repoName={null} />)
|
||||
const line = screen.getByText('Fix login').nextElementSibling
|
||||
if (!(line instanceof HTMLElement)) {
|
||||
throw new Error('no status line')
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
describe('pending row status colour', () => {
|
||||
it('billing refusal: muted', () => {
|
||||
const line = statusLine(
|
||||
failed(
|
||||
'cloudPlanRequired',
|
||||
'[orca-cloud] provisioning\nOrca Cloud: included_hours_exhausted',
|
||||
'Orca Cloud workspaces need a plan with cloud hours.'
|
||||
)
|
||||
)
|
||||
expect(line.className).toContain('text-muted-foreground')
|
||||
expect(line.className).not.toContain('text-destructive')
|
||||
})
|
||||
|
||||
it('a real failure: red', () => {
|
||||
const line = statusLine(failed(undefined, '[orca-cloud] provisioning', 'fetch failed'))
|
||||
expect(line.className).toContain('text-destructive')
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useTranslate, type Translate } from '@/i18n/use-translate'
|
||||
import { vmBuildFailureSummary } from '@/components/worktree-creation/vm-build-failure-toast'
|
||||
import {
|
||||
classifyVmBuildFailure,
|
||||
isBillingFailure
|
||||
} from '@/components/worktree-creation/vm-build-failure'
|
||||
import type { Repo } from '../../../../../../shared/repo-types'
|
||||
|
||||
function statusLabel(
|
||||
@@ -67,6 +71,11 @@ export function PendingWorktreeRow({
|
||||
|
||||
const isError = entry.status === 'error'
|
||||
const isOrcaCloud = entry.request.ephemeralVmRecipe?.provider === 'orca-cloud'
|
||||
// A billing refusal is a precondition, not an error: muted text; the glyph carries the state.
|
||||
const refused =
|
||||
isError &&
|
||||
isOrcaCloud &&
|
||||
isBillingFailure(classifyVmBuildFailure({ entry, error: entry.error ?? null, cloud: true }))
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -104,7 +113,7 @@ export function PendingWorktreeRow({
|
||||
<span
|
||||
className={cn(
|
||||
'block truncate text-[11px] tabular-nums',
|
||||
isError ? 'text-destructive/90' : 'text-muted-foreground'
|
||||
isError && !refused ? 'text-destructive/90' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{statusLabel(entry, repos, t)}
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
// GH-CREATE-NO-ROW: a GitHub-only Orca Cloud create has a sidebar row while it builds, atop the list
|
||||
// (it has no project here yet), with the cloud glyph from the start; the paired workspace's rows
|
||||
// replace it on hand-off.
|
||||
import { getDefaultSettings } from '../../../../../../shared/constants'
|
||||
import { defineFixture } from '../../../../component-fixtures/component-fixture'
|
||||
import { githubCreateRequest } from '@/lib/orca-cloud/start-cloud-workspace-from-github'
|
||||
import type { PendingWorktreeCreation } from '@/lib/workspace/creation/pending-worktree-creation'
|
||||
import { PendingWorktreeRow } from './PendingWorktreeRow'
|
||||
|
||||
const T0 = 1_700_000_000_000
|
||||
|
||||
function creation(
|
||||
creationId: string,
|
||||
patch: Partial<PendingWorktreeCreation> = {}
|
||||
): PendingWorktreeCreation {
|
||||
return {
|
||||
creationId,
|
||||
phase: 'provisioning-vm',
|
||||
status: 'creating',
|
||||
startedAt: T0,
|
||||
indeterminate: false,
|
||||
loaderVisible: true,
|
||||
provisioningLog: '[orca-cloud] provisioning\n[orca-cloud] plan-clone',
|
||||
provisioningLogStamps: [T0, T0 + 400],
|
||||
request: githubCreateRequest({
|
||||
repoUrl: 'https://github.com/nwparker/orca-sandbox-push-test',
|
||||
fullName: 'nwparker/orca-sandbox-push-test',
|
||||
prompt: 'Add a push test',
|
||||
agent: 'claude',
|
||||
composerRepoId: 'repo'
|
||||
}),
|
||||
...patch
|
||||
}
|
||||
}
|
||||
|
||||
const rows = {
|
||||
building: creation('building'),
|
||||
opening: creation('opening', { phase: 'creating' }),
|
||||
failed: creation('failed', {
|
||||
status: 'error',
|
||||
error: 'Orca Cloud workspaces need a plan with cloud hours.',
|
||||
errorCode: 'cloudPlanRequired',
|
||||
provisioningLog: '[orca-cloud] provisioning\nOrca Cloud: included_hours_exhausted'
|
||||
})
|
||||
}
|
||||
|
||||
/** The sidebar's top while three GitHub-only creates are in flight: building, opening, refused. */
|
||||
export const githubOnlyPendingRows = defineFixture({
|
||||
width: 320,
|
||||
store: {
|
||||
settings: getDefaultSettings('/home/fixture'),
|
||||
pendingWorktreeCreations: rows,
|
||||
activePendingCreationId: 'building'
|
||||
},
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-1.5 bg-worktree-sidebar p-2 text-worktree-sidebar-foreground">
|
||||
{Object.keys(rows).map((creationId) => (
|
||||
<PendingWorktreeRow key={creationId} creationId={creationId} repoName={null} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -21370,6 +21370,8 @@
|
||||
"perSeatMonthValue": "{{value0}}/mo",
|
||||
"seatRulesShort": "New members above your paid seats are charged now, prorated. Removed seats stay paid until renewal.",
|
||||
"inviteSeatRule": "Each invite uses a seat once accepted.",
|
||||
"enterpriseRowTitle": "Single sign-on, directory sync and the audit log",
|
||||
"openEnterpriseSettings": "Open Enterprise settings",
|
||||
"mfaSso": "2FA via SSO"
|
||||
},
|
||||
"manageBilling": "Manage billing",
|
||||
@@ -21713,7 +21715,8 @@
|
||||
"errorTrialSandboxLimit_one": "Your trial can run {{count}} sandbox at once. Put it to sleep or delete it, then try again.",
|
||||
"errorTrialSandboxLimit_other": "Your trial can run {{count}} sandboxes at once. Put one to sleep or delete it, then try again.",
|
||||
"errorTeamSandboxLimitNoSeat_one": "Your team can run {{count}} sandbox at once. Put it to sleep or delete it, then try again.",
|
||||
"errorTeamSandboxLimitNoSeat_other": "Your team can run {{count}} sandboxes at once. Put one to sleep or delete it, then try again."
|
||||
"errorTeamSandboxLimitNoSeat_other": "Your team can run {{count}} sandboxes at once. Put one to sleep or delete it, then try again.",
|
||||
"sharedPersonalSecretsNote": "Your personal cloud secrets aren’t used in shared workspaces. Shared and repository secrets still are."
|
||||
},
|
||||
"orcaCloud": {
|
||||
"QueuedPrompt": {
|
||||
@@ -24499,8 +24502,6 @@
|
||||
},
|
||||
"cloudWorkspaces": {
|
||||
"newCloudWorkspace": "New cloud workspace",
|
||||
"emptyStartBody": "It runs in a sandbox, so your agents keep working even when this computer is off.",
|
||||
"emptyFirstTitle": "Start your first cloud workspace",
|
||||
"picker": {
|
||||
"title": "Open cloud workspace",
|
||||
"description": "Cloud workspaces that aren’t in your sidebar: shared by teammates, or started on another computer.",
|
||||
@@ -24516,8 +24517,6 @@
|
||||
"inSidebarNote_other": "{{count}} more are already in your sidebar.",
|
||||
"emptySearchTitle": "Nothing matches your search",
|
||||
"emptySearchBody": "Try a title, repository, branch, owner or W-number.",
|
||||
"emptyAllInSidebarTitle": "Your cloud workspaces are already in the sidebar",
|
||||
"emptyAllInSidebarBody": "Workspaces teammates share, and ones you start on another computer, show up here.",
|
||||
"emptyRunningTitle": "None of yours are running",
|
||||
"emptyRunningBody": "Teammates’ running workspaces can count toward the limit too. Try again in a moment.",
|
||||
"emptyTeammateTitle": "{{name}} has no cloud workspaces you can see",
|
||||
@@ -24529,7 +24528,11 @@
|
||||
"sharedWorkspace": "shared workspace",
|
||||
"teammate": "teammate",
|
||||
"orcaCloud": "orca cloud"
|
||||
}
|
||||
},
|
||||
"emptyNothingElseTitle": "Nothing else to open",
|
||||
"emptyNothingElseBody": "Every cloud workspace you can open is already in your sidebar.",
|
||||
"emptyNoneTitle": "No cloud workspaces yet",
|
||||
"emptyNoneBody": "Start one and it shows up in your sidebar."
|
||||
}
|
||||
},
|
||||
"worktreeCreation": {
|
||||
@@ -28226,9 +28229,9 @@
|
||||
},
|
||||
"setUpAgent": "Set up an agent",
|
||||
"personal": {
|
||||
"description": "Only you get these, in every Orca Cloud workspace you start.",
|
||||
"emptyTitle": "No personal secrets yet",
|
||||
"emptyDetail": "Add one or paste a .env file. New terminals and agents in your cloud workspaces get them as environment variables."
|
||||
"emptyDetail": "Add one or paste a .env file. New terminals and agents in your cloud workspaces get them as environment variables.",
|
||||
"descriptionUnshared": "Only you get these, in cloud workspaces you haven’t shared. Shared workspaces don’t get them."
|
||||
},
|
||||
"org": {
|
||||
"description": "Everyone on your team gets these in their Orca Cloud workspaces.",
|
||||
@@ -28462,6 +28465,9 @@
|
||||
"setUpNotStarted": "{{agent}} is set up. Open the workspace to start it.",
|
||||
"notStartedRow": "{{agent}} hasn’t started"
|
||||
},
|
||||
"chatSend": {
|
||||
"renewAbove": "Renew {{agent}} sign-in above to send"
|
||||
},
|
||||
"chatSignIn": {
|
||||
"renewAbove": "Your {{agent}} sign-in for Orca Cloud didn’t work. Renew it above, then send your message again."
|
||||
},
|
||||
@@ -28512,7 +28518,21 @@
|
||||
"fetchFailedNoRepo": "This workspace can’t reach its Git remote right now.",
|
||||
"checking": "Checking…",
|
||||
"checkAgain": "Check again",
|
||||
"fetchFailedMeaning": "Pulls and pushes won’t work until it can."
|
||||
"fetchFailedMeaning": "Pulls and pushes won’t work until it can.",
|
||||
"toolMissing": "{{command}} isn’t installed in this sandbox, but {{reason}} needs it.",
|
||||
"toolMissingNoReason": "{{command}} isn’t installed in this sandbox.",
|
||||
"toolMissingMeaning": "Install it from a terminal here, or add it to your project’s setup script so new workspaces have it.",
|
||||
"diskLow": "Only {{free}} GB of disk space is free in this sandbox.",
|
||||
"diskVeryLowMeaning": "Installs and builds may fail. Delete caches or build output you don’t need.",
|
||||
"diskLowMeaning": "Large dependency installs may run out of space.",
|
||||
"pushRequested": "Orca Cloud can read {{repo}} but can’t push yet. An owner of {{owner}} has been asked to approve the Orca GitHub App.",
|
||||
"copied": "Copied",
|
||||
"copyRequest": "Copy request for an owner",
|
||||
"pushCantInstall": "Orca Cloud can read {{repo}} but can’t push to it.",
|
||||
"pushCantInstallMeaning": "Pushing needs the Orca GitHub App on {{owner}}. Ask an owner of {{owner}} to install it.",
|
||||
"pushNotYet": "Orca Cloud can’t push to {{repo}} yet.",
|
||||
"pushRequestMessage": "Could you give the Orca GitHub App access to {{repo}}? Orca Cloud needs it to push from workspaces on this repository.",
|
||||
"pushRequestCopyFailed": "Couldn’t copy it."
|
||||
},
|
||||
"toast": {
|
||||
"dismiss": "Dismiss"
|
||||
@@ -29064,6 +29084,10 @@
|
||||
"nothingNeedsAttention": "Nothing needs attention."
|
||||
},
|
||||
"agentSignInStrip": {
|
||||
"renewInSettings": "Renew in Settings",
|
||||
"finishRenewing": "Finish renewing",
|
||||
"expiredHeldDetail": "Renew to start {{agent}}.",
|
||||
"expiredHeldAnnounce": "Your {{agent}} sign-in expired. Renew to start {{agent}}.",
|
||||
"rowNeedsSignIn": "Needs sign-in",
|
||||
"rowNeedsSignInTooltip": "Set up once for Orca Cloud",
|
||||
"rowExpired": "Sign-in expired",
|
||||
@@ -29083,9 +29107,7 @@
|
||||
"useMySignIn": "Use my {{agent}} sign-in",
|
||||
"signIn": "Sign in",
|
||||
"expired": "Your {{agent}} sign-in expired",
|
||||
"expiredDetail": "Sign in once to fix every workspace.",
|
||||
"renew": "Renew",
|
||||
"expiredAnnounce": "Your {{agent}} sign-in expired. Renew it once to fix every workspace.",
|
||||
"waiting": "Approve on {{site}}",
|
||||
"openAgain": "Open again",
|
||||
"waitingAnnounce": "Waiting for you to approve in your browser.",
|
||||
|
||||
@@ -79,16 +79,35 @@ describe('cloud agent launch hold', () => {
|
||||
expect(cloudAgentLaunchNeedsHold('claude')).toBe(true)
|
||||
})
|
||||
|
||||
it('holds an expired launch until Renew fixes the account, then every held pane goes', async () => {
|
||||
it('CHAT-RENEW-INPLACE (B): a renewal never launches; every held pane waits for its own Start', async () => {
|
||||
setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'expired' }))
|
||||
const first = holdCloudAgentLaunch({ paneKey: 'tab:a', agent: 'claude', prompt: 'fix it' })
|
||||
const second = holdCloudAgentLaunch({ paneKey: 'tab:b', agent: 'claude', prompt: null })
|
||||
let firstOutcome = 'pending'
|
||||
let secondOutcome = 'pending'
|
||||
void holdCloudAgentLaunch({ paneKey: 'tab:a', agent: 'claude', prompt: 'fix it' }).then(
|
||||
(o) => (firstOutcome = o)
|
||||
)
|
||||
void holdCloudAgentLaunch({ paneKey: 'tab:b', agent: 'claude', prompt: null }).then(
|
||||
(o) => (secondOutcome = o)
|
||||
)
|
||||
await vi.waitFor(() => expect(readCloudAgentHold('tab:b')).not.toBeNull())
|
||||
expect(readCloudAgentHold('tab:a')).toMatchObject({ prompt: 'fix it' })
|
||||
setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'active' }))
|
||||
await expect(first).resolves.toBe('proceed')
|
||||
await expect(second).resolves.toBe('proceed')
|
||||
expect(readCloudAgentHold('tab:a')).toBeNull()
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
for (const key of ['tab:a', 'tab:b']) {
|
||||
expect(readCloudAgentHold(key)).toMatchObject({
|
||||
reason: 'awaitingStart',
|
||||
awaiting: 'renewed'
|
||||
})
|
||||
}
|
||||
expect([firstOutcome, secondOutcome]).toEqual(['pending', 'pending'])
|
||||
// Start in one tab launches that one; the other keeps waiting.
|
||||
releaseCloudAgentHold('tab:a', 'proceed')
|
||||
await vi.waitFor(() => expect(firstOutcome).toBe('proceed'))
|
||||
expect(secondOutcome).toBe('pending')
|
||||
expect(readCloudAgentHold('tab:b')).toMatchObject({ awaiting: 'renewed' })
|
||||
releaseCloudAgentHold('tab:b', 'cancel')
|
||||
})
|
||||
|
||||
it('cancel releases only that pane', async () => {
|
||||
|
||||
@@ -112,11 +112,7 @@ type HoldRequest = Omit<CloudAgentHold, 'reason' | 'awaiting'> & {
|
||||
restored?: boolean
|
||||
}
|
||||
|
||||
type Entry = CloudAgentHold & {
|
||||
release: (outcome: 'proceed' | 'cancel') => void
|
||||
/** Renewed or covered, it waits for the person's Start instead of starting (restored, or setup). */
|
||||
askFirst: boolean
|
||||
}
|
||||
type Entry = CloudAgentHold & { release: (outcome: 'proceed' | 'cancel') => void }
|
||||
|
||||
const holds = new Map<string, Entry>()
|
||||
/** Each live hold's outcome, so a second launch for the same key joins it instead of holding twice. */
|
||||
@@ -298,8 +294,7 @@ async function placeCloudAgentHold({
|
||||
...hold,
|
||||
reason,
|
||||
...(awaiting ? { awaiting } : {}),
|
||||
release: finish,
|
||||
askFirst: Boolean(restored) || reason !== 'accountExpired'
|
||||
release: finish
|
||||
})
|
||||
notify()
|
||||
})
|
||||
@@ -315,14 +310,7 @@ function redecide(entry: Entry, accounts: OrcaCloudAgentAccountsState): void {
|
||||
const now = cloudAgentHoldReason(entry.agent, accounts)
|
||||
if (now !== null) {
|
||||
if (now !== entry.reason) {
|
||||
// A removed sign-in becomes the one-time setup, which never starts unasked (PC-3b), even
|
||||
// for a hold placed on an expired sign-in in this session.
|
||||
holds.set(entry.paneKey, {
|
||||
...entry,
|
||||
reason: now,
|
||||
awaiting: undefined,
|
||||
askFirst: entry.askFirst || now === 'notSetUp'
|
||||
})
|
||||
holds.set(entry.paneKey, { ...entry, reason: now, awaiting: undefined })
|
||||
notify()
|
||||
}
|
||||
return
|
||||
@@ -339,11 +327,9 @@ function redecide(entry: Entry, accounts: OrcaCloudAgentAccountsState): void {
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!entry.askFirst) {
|
||||
// Renewed in this session: every pane held on recovery starts.
|
||||
entry.release('proceed')
|
||||
return
|
||||
}
|
||||
// CHAT-RENEW-INPLACE (team-lead, B): covered again, every held launch waits for its Start, in
|
||||
// this session or restored, in every tab: "{{agent}} sign-in renewed · Start {{agent}}" after a
|
||||
// renewal, "is set up · Start" after the one-time setup. Nothing launches unasked.
|
||||
const awaiting = entry.reason === 'notSetUp' ? 'setUp' : 'renewed'
|
||||
holds.set(entry.paneKey, { ...entry, reason: 'awaitingStart', awaiting })
|
||||
notify()
|
||||
@@ -373,12 +359,7 @@ export async function startCloudAgentHold(paneKey: string): Promise<'started' |
|
||||
const coverage = orcaCloudAgentCoverageFromAccounts(entry.agent, accounts)?.kind
|
||||
if (now !== null || (coverage !== 'account' && coverage !== 'key')) {
|
||||
if (now !== null && now !== entry.reason) {
|
||||
holds.set(paneKey, {
|
||||
...entry,
|
||||
reason: now,
|
||||
awaiting: undefined,
|
||||
askFirst: entry.askFirst || now === 'notSetUp'
|
||||
})
|
||||
holds.set(paneKey, { ...entry, reason: now, awaiting: undefined })
|
||||
notify()
|
||||
}
|
||||
return 'held'
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Allowlist matching for the cloud glossary lint. Entries match by file + term + a snippet of the
|
||||
// string, never by line number, so unrelated edits that shift lines don't make entries stale.
|
||||
|
||||
export type GlossaryHit = { file: string; line: number; term: string; text: string }
|
||||
export type AllowEntry = { path: string; term: string; match: string; reason: string }
|
||||
|
||||
export function isAllowed(hit: GlossaryHit, entry: AllowEntry): boolean {
|
||||
return (
|
||||
hit.file === entry.path &&
|
||||
hit.term === entry.term &&
|
||||
entry.match.length > 0 &&
|
||||
hit.text.includes(entry.match)
|
||||
)
|
||||
}
|
||||
|
||||
/** Entries that no longer match any hit, or that have no reason. */
|
||||
export function staleAllowEntries(hits: GlossaryHit[], allow: AllowEntry[]): AllowEntry[] {
|
||||
return allow.filter(
|
||||
(entry) => !entry.reason?.trim() || !hits.some((hit) => isAllowed(hit, entry))
|
||||
)
|
||||
}
|
||||
@@ -1,128 +1,128 @@
|
||||
[
|
||||
{
|
||||
"path": "src/renderer/src/components/cloud-environment/EnvironmentForm.tsx",
|
||||
"line": 127,
|
||||
"term": "container",
|
||||
"match": "Reads Conductor and Dev Container setup from a local project.",
|
||||
"reason": "Dev Containers spec name"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/src/components/settings/virtual-machines/runtimes/CloudVmSetupGuide.tsx",
|
||||
"line": 20,
|
||||
"term": "VM",
|
||||
"match": "Cloud VMs",
|
||||
"reason": "BYOC recipe VMs (experimental), not Orca Cloud"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/src/components/worktree-creation/VmBuildFailureActions.tsx",
|
||||
"line": 372,
|
||||
"term": "VM",
|
||||
"match": "Delete VM",
|
||||
"reason": "recipe-VM branch; cloud branch says sandbox"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/src/components/worktree-creation/VmBuildFailureCard.tsx",
|
||||
"line": 138,
|
||||
"term": "VM",
|
||||
"match": "The VM is running until you choose.",
|
||||
"reason": "recipe-VM branch; cloud branch says sandbox"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/src/components/worktree-creation/VmDeleteConfirmDialog.tsx",
|
||||
"line": 38,
|
||||
"term": "VM",
|
||||
"match": "Delete this VM?",
|
||||
"reason": "recipe-VM branch; cloud branch says sandbox"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/src/components/worktree-creation/VmDeleteConfirmDialog.tsx",
|
||||
"line": 49,
|
||||
"term": "VM",
|
||||
"match": "The VM and anything on it are destroyed, and the workspace isn’t created.",
|
||||
"reason": "recipe-VM branch; cloud branch says sandbox"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/src/components/worktree-creation/VmDeleteConfirmDialog.tsx",
|
||||
"line": 63,
|
||||
"term": "VM",
|
||||
"match": "Delete VM",
|
||||
"reason": "recipe-VM branch; cloud branch says sandbox"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/src/components/worktree-creation/vm-build-failure-copy.ts",
|
||||
"line": 55,
|
||||
"term": "VM",
|
||||
"match": "The VM is up, but some environment checks need attention",
|
||||
"reason": "recipe-VM branch; cloud branch says sandbox"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/src/components/worktree-creation/vm-build-failure-copy.ts",
|
||||
"line": 59,
|
||||
"term": "VM",
|
||||
"match": "The VM is up, but {{count}} environment checks failed",
|
||||
"reason": "recipe-VM branch; cloud branch says sandbox"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/src/components/worktree-creation/vm-build-failure-copy.ts",
|
||||
"line": 62,
|
||||
"term": "VM",
|
||||
"match": "The VM is up, but {{count}} environment check failed",
|
||||
"reason": "recipe-VM branch; cloud branch says sandbox"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/src/components/worktree-creation/vm-build-recipe-failure-copy.ts",
|
||||
"line": 15,
|
||||
"term": "VM",
|
||||
"match": "The VM recipe failed",
|
||||
"reason": "recipe-VM branch; cloud branch says sandbox"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/src/components/worktree-creation/vm-build-recipe-failure-copy.ts",
|
||||
"line": 19,
|
||||
"term": "VM",
|
||||
"match": "Your VM recipe stopped with an error before the VM was ready.",
|
||||
"reason": "recipe-VM branch; cloud branch says sandbox"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/src/components/worktree-creation/vm-build-recipe-failure-copy.ts",
|
||||
"line": 25,
|
||||
"term": "VM",
|
||||
"match": "Couldn’t create the workspace on the VM",
|
||||
"reason": "recipe-VM branch; cloud branch says sandbox"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/src/components/worktree-creation/vm-build-recipe-failure-copy.ts",
|
||||
"line": 29,
|
||||
"term": "VM",
|
||||
"match": "The VM started, but creating the workspace on it stopped with an error.",
|
||||
"reason": "recipe-VM branch; cloud branch says sandbox"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/src/components/worktree-creation/vm-build-sheet-copy.ts",
|
||||
"line": 10,
|
||||
"term": "VM",
|
||||
"match": "Provision VM",
|
||||
"reason": "recipe-VM branch; cloud branch says sandbox"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/src/components/worktree-creation/vm-build-sheet-copy.ts",
|
||||
"line": 69,
|
||||
"term": "VM",
|
||||
"match": "Building your VM workspace",
|
||||
"reason": "recipe-VM branch; cloud branch says sandbox"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/src/components/worktree-creation/vm-build-sheet-copy.ts",
|
||||
"line": 74,
|
||||
"term": "VM",
|
||||
"match": "Your VM workspace is ready",
|
||||
"reason": "recipe-VM branch; cloud branch says sandbox"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/src/components/worktree-creation/vm-build-sheet-copy.ts",
|
||||
"line": 79,
|
||||
"term": "VM",
|
||||
"match": "Your VM workspace needs attention",
|
||||
"reason": "recipe-VM branch; cloud branch says sandbox"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/src/components/worktree-creation/vm-build-sheet-copy.ts",
|
||||
"line": 84,
|
||||
"term": "VM",
|
||||
"match": "Couldn’t build your VM workspace",
|
||||
"reason": "recipe-VM branch; cloud branch says sandbox"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/src/components/orca-cloud/use-cloud-terminal-error-override.ts",
|
||||
"line": 13,
|
||||
"term": "VM",
|
||||
"match": "This workspace's VM is asleep",
|
||||
"reason": "match pattern for older builds' saved terminal errors; never rendered"
|
||||
},
|
||||
{
|
||||
"path": "src/renderer/src/components/orca-cloud/use-cloud-terminal-error-override.ts",
|
||||
"line": 14,
|
||||
"term": "VM",
|
||||
"match": "This workspace's VM could not wake up",
|
||||
"reason": "match pattern for older builds' saved terminal errors; never rendered"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -7,6 +7,12 @@ import ts from 'typescript-api'
|
||||
import { globSync } from 'glob'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import allowlist from './cloud-glossary-allowlist.json'
|
||||
import {
|
||||
isAllowed,
|
||||
staleAllowEntries,
|
||||
type AllowEntry,
|
||||
type GlossaryHit
|
||||
} from './cloud-glossary-allowlist-match'
|
||||
|
||||
const ROOT = path.resolve(__dirname, '../../../../..')
|
||||
|
||||
@@ -88,8 +94,7 @@ const TEXT_ATTRIBUTES = new Set([
|
||||
])
|
||||
const TRANSLATE_CALLEES = new Set(['t', 'translate'])
|
||||
|
||||
type Hit = { file: string; line: number; term: string; text: string; rule: Rule }
|
||||
type AllowEntry = { path: string; line: number; term: string; reason: string }
|
||||
type Hit = GlossaryHit & { rule: Rule }
|
||||
|
||||
function stringValue(node: ts.Node): string | null {
|
||||
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
||||
@@ -240,8 +245,6 @@ function findHits(): Hit[] {
|
||||
}
|
||||
|
||||
const ALLOW: AllowEntry[] = allowlist
|
||||
const allowKey = (e: { path: string; line: number; term: string }): string =>
|
||||
`${e.path}:${e.line}:${e.term}`
|
||||
|
||||
describe('cloud glossary lint', () => {
|
||||
const hits = findHits()
|
||||
@@ -251,17 +254,16 @@ describe('cloud glossary lint', () => {
|
||||
})
|
||||
|
||||
it('every allowlist entry has a reason and still matches a string', () => {
|
||||
const live = new Set(hits.map((h) => allowKey({ path: h.file, line: h.line, term: h.term })))
|
||||
const problems = ALLOW.filter((e) => !e.reason?.trim() || !live.has(allowKey(e))).map(
|
||||
(e) => `stale or unexplained allowlist entry: ${allowKey(e)}`
|
||||
const problems = staleAllowEntries(hits, ALLOW).map(
|
||||
(e) =>
|
||||
`stale or unexplained allowlist entry: ${e.path} "${e.term}" match ${JSON.stringify(e.match)}`
|
||||
)
|
||||
expect(problems).toEqual([])
|
||||
})
|
||||
|
||||
it('cloud strings use glossary words', () => {
|
||||
const allowed = new Set(ALLOW.map(allowKey))
|
||||
const failures = hits
|
||||
.filter((h) => !allowed.has(allowKey({ path: h.file, line: h.line, term: h.term })))
|
||||
.filter((h) => !ALLOW.some((e) => isAllowed(h, e)))
|
||||
.map(
|
||||
(h) =>
|
||||
`cloud glossary: "${h.term}" at ${h.file}:${h.line}; use "${h.rule.use}" (glossary row: ${h.rule.row}). ` +
|
||||
@@ -270,3 +272,43 @@ describe('cloud glossary lint', () => {
|
||||
expect(failures).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('allowlist matching', () => {
|
||||
const file = 'src/renderer/src/components/worktree-creation/vm-build-sheet-copy.ts'
|
||||
const entry: AllowEntry = {
|
||||
path: file,
|
||||
term: 'VM',
|
||||
match: 'Your VM workspace is ready',
|
||||
reason: 'recipe-VM branch'
|
||||
}
|
||||
const hit = (over: Partial<GlossaryHit> = {}): GlossaryHit => ({
|
||||
file,
|
||||
line: 74,
|
||||
term: 'VM',
|
||||
text: 'Your VM workspace is ready',
|
||||
...over
|
||||
})
|
||||
|
||||
it('still allows the same string after it moves to another line', () => {
|
||||
expect(isAllowed(hit({ line: 74 }), entry)).toBe(true)
|
||||
expect(isAllowed(hit({ line: 112 }), entry)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not allow a different string with the same term in the same file', () => {
|
||||
expect(isAllowed(hit({ text: 'Delete this VM?' }), entry)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not allow the same snippet in a different file', () => {
|
||||
expect(isAllowed(hit({ file: 'src/renderer/src/lib/other-cloud.ts' }), entry)).toBe(false)
|
||||
})
|
||||
|
||||
it('reports an entry with no matching hit as stale', () => {
|
||||
expect(staleAllowEntries([hit({ text: 'Delete this VM?' })], [entry])).toEqual([entry])
|
||||
expect(staleAllowEntries([hit()], [entry])).toEqual([])
|
||||
})
|
||||
|
||||
it('reports an entry with an empty reason', () => {
|
||||
const unexplained = { ...entry, reason: ' ' }
|
||||
expect(staleAllowEntries([hit()], [unexplained])).toEqual([unexplained])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -291,7 +291,7 @@ describe('after a restart', () => {
|
||||
})
|
||||
|
||||
describe('in session (not restored)', () => {
|
||||
it('in-session (not restored), an expired hold still starts on Renew as before', async () => {
|
||||
it('CHAT-RENEW-INPLACE (B): in session, a renewed hold waits for Start: no create until Start', async () => {
|
||||
mocks.coverage = 'expired'
|
||||
setTabs([TAB])
|
||||
const create = vi.fn(async (args: CreateWebRuntimeSessionTerminalOnHostArgs) =>
|
||||
@@ -307,6 +307,13 @@ describe('in session (not restored)', () => {
|
||||
for (const listener of mocks.listeners) {
|
||||
listener()
|
||||
}
|
||||
await flush()
|
||||
expect(readCloudAgentHold(cloudTabHoldKey(TAB))).toMatchObject({
|
||||
reason: 'awaitingStart',
|
||||
awaiting: 'renewed'
|
||||
})
|
||||
expect(create).not.toHaveBeenCalledWith(expect.objectContaining({ agent: 'claude' }))
|
||||
releaseCloudAgentHold(cloudTabHoldKey(TAB), 'proceed')
|
||||
await launch
|
||||
expect(create).toHaveBeenLastCalledWith(expect.objectContaining({ agent: 'claude' }))
|
||||
})
|
||||
@@ -332,7 +339,7 @@ describe('in session (not restored)', () => {
|
||||
expect(create).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('a paused (kill switch) agent never starts the expired hold; a real renewal does', async () => {
|
||||
it('a paused (kill switch) agent never starts the expired hold; a real renewal waits for Start (B)', async () => {
|
||||
mocks.coverage = 'expired'
|
||||
setTabs([TAB])
|
||||
const create = vi.fn(async (args: CreateWebRuntimeSessionTerminalOnHostArgs) =>
|
||||
@@ -354,6 +361,10 @@ describe('in session (not restored)', () => {
|
||||
for (const listener of mocks.listeners) {
|
||||
listener()
|
||||
}
|
||||
await flush()
|
||||
expect(readCloudAgentHold(cloudTabHoldKey(TAB))).toMatchObject({ awaiting: 'renewed' })
|
||||
expect(create).toHaveBeenCalledTimes(1)
|
||||
releaseCloudAgentHold(cloudTabHoldKey(TAB), 'proceed')
|
||||
await launch
|
||||
expect(create).toHaveBeenLastCalledWith(expect.objectContaining({ agent: 'claude' }))
|
||||
})
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { cloudPushAccess } from './cloud-push-access'
|
||||
|
||||
const admin = { admin: true, ownerType: 'Organization' } as const
|
||||
const orgMember = { admin: false, ownerType: 'Organization' } as const
|
||||
const notOwner = { admin: false, ownerType: 'User' } as const
|
||||
|
||||
describe('cloudPushAccess (ENV-NOTICE-ACTIONS)', () => {
|
||||
it("someone else's public repository (octocat/Hello-World) can't be installed on", () => {
|
||||
expect(cloudPushAccess('public', notOwner)).toBe('cantInstall')
|
||||
expect(cloudPushAccess('public', orgMember)).toBe('cantInstall')
|
||||
// gh couldn't tell: no Install unless it's known to work.
|
||||
expect(cloudPushAccess('public', null)).toBe('cantInstall')
|
||||
})
|
||||
|
||||
it('an admin of the owner can install, public or not', () => {
|
||||
expect(cloudPushAccess('public', admin)).toBe('install')
|
||||
expect(cloudPushAccess('public', { admin: true, ownerType: 'User' })).toBe('install')
|
||||
expect(cloudPushAccess('missing', admin)).toBe('install')
|
||||
})
|
||||
|
||||
it('missing: the control plane minted an install page; only a personal repo’s non-owner is stuck', () => {
|
||||
// An org member's install becomes a request for an owner (D2).
|
||||
expect(cloudPushAccess('missing', orgMember)).toBe('install')
|
||||
expect(cloudPushAccess('missing', null)).toBe('install')
|
||||
expect(cloudPushAccess('missing', notOwner)).toBe('cantInstall')
|
||||
})
|
||||
|
||||
it('requested, granted, no answer yet, and no answer at all', () => {
|
||||
expect(cloudPushAccess('requested', null)).toBe('requested')
|
||||
expect(cloudPushAccess('granted', null)).toBe('granted')
|
||||
expect(cloudPushAccess(undefined, null)).toBe('checking')
|
||||
expect(cloudPushAccess(null, null)).toBe('unknown')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
// ENV-NOTICE-ACTIONS: what the "can't push" notice can offer, from what the control plane says about
|
||||
// the Orca GitHub App's access (repo-access) and what the person's own gh login says about installing
|
||||
// it. Install only shows where installing can work; a request already sent offers a copy for an owner;
|
||||
// a public repository someone else owns (octocat/Hello-World) gets a request to copy, no Install.
|
||||
import { toast } from 'sonner'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { HostedGithubRepoAccessResponse } from '../../../../shared/hosted-sandbox/hosted-github-wire'
|
||||
import type { GitHubInstallRights } from '../../../../shared/hosted-sandbox/cloud-workspace-api'
|
||||
import { cloudWorkspaceApi } from './orca-cloud-workspace-store'
|
||||
import { fetchOrcaCloudRepoAccess } from './use-orca-cloud-repo-access'
|
||||
import { repoSlug } from './cloud-setup-notice-actions'
|
||||
|
||||
/**
|
||||
* `install`: Install on {owner}. `requested`: an owner was asked; copy a request for them.
|
||||
* `cantInstall`: only the owner's admins can install, and this person isn't one. `granted`: the App
|
||||
* reaches the repository, so the refusal is something else (Check again). `checking`: no answer yet.
|
||||
* `unknown`: couldn't ask (offline, signed out).
|
||||
*/
|
||||
export type CloudPushAccess =
|
||||
| 'install'
|
||||
| 'requested'
|
||||
| 'cantInstall'
|
||||
| 'granted'
|
||||
| 'checking'
|
||||
| 'unknown'
|
||||
|
||||
export function cloudPushAccess(
|
||||
access: HostedGithubRepoAccessResponse['access'] | null | undefined,
|
||||
rights: GitHubInstallRights | null
|
||||
): CloudPushAccess {
|
||||
switch (access) {
|
||||
case undefined:
|
||||
return 'checking'
|
||||
case null:
|
||||
return 'unknown'
|
||||
case 'requested':
|
||||
return 'requested'
|
||||
case 'granted':
|
||||
return 'granted'
|
||||
case 'missing':
|
||||
// The control plane minted an install page. An org member who isn't an admin still gets
|
||||
// somewhere (GitHub turns the install into a request for an owner); a collaborator on someone's
|
||||
// personal repository doesn't.
|
||||
return rights && !rights.admin && rights.ownerType === 'User' ? 'cantInstall' : 'install'
|
||||
case 'public':
|
||||
// Readable by anyone, so readability says nothing about the person: only admin rights do.
|
||||
return rights?.admin ? 'install' : 'cantInstall'
|
||||
}
|
||||
}
|
||||
|
||||
/** The control plane's answer plus, where it decides the button, the person's install rights. */
|
||||
export async function fetchCloudPushAccess(
|
||||
repoUrl: string,
|
||||
options: { force?: boolean } = {}
|
||||
): Promise<CloudPushAccess> {
|
||||
const answer = await fetchOrcaCloudRepoAccess(repoUrl, options)
|
||||
const access = answer?.access ?? null
|
||||
if (access !== 'missing' && access !== 'public') {
|
||||
return cloudPushAccess(access, null)
|
||||
}
|
||||
const rights = await cloudWorkspaceApi()
|
||||
?.gitHubInstallRights({ repoUrl })
|
||||
.then((result) => (result.ok ? result.value : null))
|
||||
.catch(() => null)
|
||||
return cloudPushAccess(access, rights ?? null)
|
||||
}
|
||||
|
||||
/** A message for an owner of the repository's org, with the install page that preselects it. */
|
||||
export async function copyOwnerPushRequest(repoUrl: string): Promise<boolean> {
|
||||
// The install page the control plane minted for this repository (preselects it), if any.
|
||||
const link = (await fetchOrcaCloudRepoAccess(repoUrl).catch(() => null))?.installUrl ?? null
|
||||
const message = translate(
|
||||
'orcaCloud.setupNotices.pushRequestMessage',
|
||||
'Could you give the Orca GitHub App access to {{repo}}? Orca Cloud needs it to push from workspaces on this repository.',
|
||||
{ repo: repoSlug(repoUrl) }
|
||||
)
|
||||
try {
|
||||
await window.api.ui.writeClipboardText(link ? `${message}\n${link}` : message)
|
||||
return true
|
||||
} catch {
|
||||
toast.error(translate('orcaCloud.setupNotices.pushRequestCopyFailed', 'Couldn’t copy it.'))
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ export function getCloudSetupNoticesRecord(
|
||||
return snapshot.records[environmentId]
|
||||
}
|
||||
|
||||
/** Visible notices first by importance: push access, reaching GitHub, setup, then the rest. */
|
||||
/** Visible notices first by importance: push access, reaching GitHub, setup, tools, then disk. */
|
||||
export function visibleCloudSetupNotices(
|
||||
state: Snapshot,
|
||||
environmentId: string | undefined
|
||||
@@ -73,7 +73,7 @@ export function visibleCloudSetupNotices(
|
||||
if (!record) {
|
||||
return []
|
||||
}
|
||||
const order = { push: 0, fetch: 1, setup: 2, check: 3 } as const
|
||||
const order = { push: 0, fetch: 1, setup: 2, tool: 3, disk: 4 } as const
|
||||
return record.notices
|
||||
.filter((notice) => !state.dismissed.has(dismissalKey(record.environmentId, notice.id)))
|
||||
.slice()
|
||||
|
||||
@@ -219,7 +219,7 @@ describe('createWithCloudAgentHold (the one hold for every Orca Cloud agent laun
|
||||
expect(readCloudAgentHold(key)).toBeNull()
|
||||
})
|
||||
|
||||
it('expired: holds, then starts on Renew', async () => {
|
||||
it('expired: holds; Renew turns it to "renewed · Start", and Start launches it (B)', async () => {
|
||||
setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'revoked' }))
|
||||
const create = hostCreate()
|
||||
const args = { ...CREATE_AGENT, prompt: 'fix the bug' }
|
||||
@@ -231,9 +231,14 @@ describe('createWithCloudAgentHold (the one hold for every Orca Cloud agent laun
|
||||
prompt: 'fix the bug'
|
||||
})
|
||||
setOrcaCloudAgentAccountsStateForTest(accounts({ state: 'active' }))
|
||||
await flush()
|
||||
// A renewal never launches by itself (team-lead, B): it waits for Start.
|
||||
expect(readCloudAgentHold(key)).toMatchObject({ reason: 'awaitingStart', awaiting: 'renewed' })
|
||||
expect(create).toHaveBeenCalledOnce()
|
||||
releaseCloudAgentHold(key, 'proceed')
|
||||
await expect(result).resolves.toBe(AGENT)
|
||||
// Renew's auto-start: the original args, no focus taken (the tab may be in the background).
|
||||
expect(create).toHaveBeenLastCalledWith(args)
|
||||
// The person's Start: the original args, with its tab brought forward.
|
||||
expect(create).toHaveBeenLastCalledWith({ ...args, activate: true })
|
||||
})
|
||||
|
||||
it('cancel leaves the plain shell and never starts the agent', async () => {
|
||||
|
||||
@@ -50,6 +50,7 @@ function stub(
|
||||
gitHubUserConnectLink: vi.fn(unused),
|
||||
disconnectGitHubUser: vi.fn(unused),
|
||||
getRepoAccess: vi.fn(unused),
|
||||
gitHubInstallRights: vi.fn(unused),
|
||||
listGitHubRepositories: vi.fn(unused),
|
||||
createFromGitHub: vi.fn(unused),
|
||||
onWakeProgress: vi.fn(() => () => {}),
|
||||
|
||||
@@ -40,6 +40,7 @@ function stub(
|
||||
gitHubUserConnectLink: vi.fn(unused),
|
||||
disconnectGitHubUser: vi.fn(unused),
|
||||
getRepoAccess: vi.fn(unused),
|
||||
gitHubInstallRights: vi.fn(unused),
|
||||
listGitHubRepositories: vi.fn(unused),
|
||||
createFromGitHub: vi.fn(unused),
|
||||
onWakeProgress: vi.fn(() => () => {}),
|
||||
|
||||
@@ -44,6 +44,7 @@ function stubApi(overrides: Partial<OrcaCloudWorkspaceApi> = {}): OrcaCloudWorks
|
||||
connectGitHubUser: vi.fn(async () => ok(null)),
|
||||
gitHubUserConnectLink: vi.fn(async () => ok('https://github.com/login/oauth/authorize')),
|
||||
disconnectGitHubUser: vi.fn(async () => ok({ connected: false as const })),
|
||||
gitHubInstallRights: vi.fn(async () => ok(null)),
|
||||
getRepoAccess: vi.fn(async () =>
|
||||
ok({ access: 'granted' as const, owner: 'a', repo: 'https://github.com/a/b' })
|
||||
),
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
// @vitest-environment happy-dom
|
||||
// GH-CREATE-CANCEL and GH-CREATE-NO-ROW for a GitHub-only Orca Cloud create: Cancel at any step
|
||||
// leaves no workspace (the sandbox is deleted, which drops the pairing too), and a create that
|
||||
// opens hands its pending row off to the paired server's rows, which stay hidden until then.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
type OpenOptions = { onHost?: (hostId: string) => void }
|
||||
type OpenOutcome = { ok: true; worktreeId: string | null; hostId?: string }
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
open: vi.fn<(workspace: unknown, options?: OpenOptions) => Promise<OpenOutcome>>(),
|
||||
launch: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/components/cloud-workspaces/open-cloud-workspace', () => ({
|
||||
openCloudWorkspace: mocks.open
|
||||
}))
|
||||
vi.mock('@/lib/agent-session/launch/new-tab/launch-agent-in-new-tab', () => ({
|
||||
launchAgentInNewTabWithOutcome: mocks.launch
|
||||
}))
|
||||
|
||||
import { startCloudWorkspaceFromGitHub } from './start-cloud-workspace-from-github'
|
||||
import { resetGitHubCreateKeysForTest } from './github-cloud-create-key'
|
||||
|
||||
const createFromGitHub = vi.fn<(args: unknown) => Promise<unknown>>()
|
||||
const destroy = vi.fn(async () => ({ ok: true, value: undefined }))
|
||||
|
||||
beforeEach(() => {
|
||||
resetGitHubCreateKeysForTest()
|
||||
useAppStore.setState({ pendingWorktreeCreations: {}, activePendingCreationId: null })
|
||||
createFromGitHub.mockResolvedValue({ ok: true, value: { sandboxId: 'sbx_1' } })
|
||||
mocks.open.mockResolvedValue({ ok: true, worktreeId: null })
|
||||
Object.assign(window, {
|
||||
api: {
|
||||
...window.api,
|
||||
orcaCloudWorkspace: { createFromGitHub },
|
||||
cloudWorkspaces: {
|
||||
get: vi.fn(async (id: string) => ({ ok: true, value: { id } })),
|
||||
destroy,
|
||||
localWorkspaces: vi.fn(async () => []),
|
||||
environmentBindings: vi.fn(async () => ({}))
|
||||
},
|
||||
ephemeralVm: {
|
||||
onProvisionEvent: () => () => {},
|
||||
cancelProvision: vi.fn(async () => ({ cancelled: true })),
|
||||
// Cancel's cleanup looks for this create's runtime records; a GitHub-only create has none.
|
||||
listRuntimes: vi.fn(async () => [])
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const REQUEST = {
|
||||
repoUrl: 'https://github.com/acme/web',
|
||||
fullName: 'acme/web',
|
||||
prompt: '',
|
||||
agent: null,
|
||||
composerRepoId: 'repo-1'
|
||||
}
|
||||
|
||||
const creationId = (): string =>
|
||||
Object.keys(useAppStore.getState().pendingWorktreeCreations)[0] ?? ''
|
||||
const cancel = (): void => useAppStore.getState().removePendingWorktreeCreation(creationId())
|
||||
|
||||
describe('Cancel leaves no workspace (GH-CREATE-CANCEL)', () => {
|
||||
it('cancelled after the sandbox started, before it opened: deleted, never opened', async () => {
|
||||
createFromGitHub.mockImplementationOnce(async () => {
|
||||
cancel()
|
||||
return { ok: true, value: { sandboxId: 'sbx_1' } }
|
||||
})
|
||||
expect(await startCloudWorkspaceFromGitHub(REQUEST)).toBe(false)
|
||||
expect(mocks.open).not.toHaveBeenCalled()
|
||||
expect(destroy).toHaveBeenCalledWith('sbx_1')
|
||||
})
|
||||
|
||||
it('cancelled while it opened: the sandbox is deleted (its pairing and row go with it)', async () => {
|
||||
mocks.open.mockImplementationOnce(async (_workspace, options) => {
|
||||
options?.onHost?.('runtime:p')
|
||||
cancel()
|
||||
return { ok: true, worktreeId: 'w1', hostId: 'runtime:p' }
|
||||
})
|
||||
expect(await startCloudWorkspaceFromGitHub(REQUEST)).toBe(false)
|
||||
expect(destroy).toHaveBeenCalledWith('sbx_1')
|
||||
expect(useAppStore.getState().pendingWorktreeCreations).toEqual({})
|
||||
})
|
||||
|
||||
it('cancelled while the create was in flight: main reclaims it; nothing opens here', async () => {
|
||||
createFromGitHub.mockImplementationOnce(async () => {
|
||||
cancel()
|
||||
return { ok: false, code: 'failed', message: 'Cancelled.' }
|
||||
})
|
||||
expect(await startCloudWorkspaceFromGitHub(REQUEST)).toBe(false)
|
||||
expect(window.api.ephemeralVm.cancelProvision).toHaveBeenCalled()
|
||||
expect(mocks.open).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pending row hand-off (GH-CREATE-NO-ROW)', () => {
|
||||
it('records the paired server while opening, and the hand-off drops the pending row', async () => {
|
||||
let pairingHostIdDuringOpen: string | undefined
|
||||
mocks.open.mockImplementationOnce(async (_workspace, options) => {
|
||||
options?.onHost?.('runtime:p')
|
||||
pairingHostIdDuringOpen =
|
||||
useAppStore.getState().pendingWorktreeCreations[creationId()]?.pairingHostId
|
||||
return { ok: true, worktreeId: null }
|
||||
})
|
||||
expect(await startCloudWorkspaceFromGitHub(REQUEST)).toBe(true)
|
||||
expect(pairingHostIdDuringOpen).toBe('runtime:p')
|
||||
expect(useAppStore.getState().pendingWorktreeCreations).toEqual({})
|
||||
expect(destroy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,7 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { openCloudWorkspace } from '@/components/cloud-workspaces/open-cloud-workspace'
|
||||
import { refreshCloudWorktreeBindings } from '@/components/cloud-workspaces/cloud-workspace-identity'
|
||||
import { createNonSecureContextUuid } from '../../../../shared/non-secure-context-uuid'
|
||||
import { isTuiAgent } from '../../../../shared/agent-launch/catalog/tui-agent-config'
|
||||
import { ORCA_CLOUD_RECIPE_ID } from '../../../../shared/hosted-sandbox/orca-cloud-recipe-id'
|
||||
@@ -87,7 +88,12 @@ export function startCloudWorkspaceFromGitHub(request: StartFromGitHubRequest):
|
||||
})
|
||||
store.setActiveView('terminal')
|
||||
store.setSidebarOpen(true)
|
||||
return runGitHubCloudCreate(creationId)
|
||||
return runTracked(creationId)
|
||||
}
|
||||
|
||||
/** Cancel's "Deleting the sandbox…" waits for every step that may still own a sandbox. */
|
||||
function runTracked(creationId: string): Promise<boolean> {
|
||||
return trackOrcaCloudCreateStep(creationId, runGitHubCloudCreate(creationId))
|
||||
}
|
||||
|
||||
/** The sheet's Retry (and the plans page's retry after checkout) for a GitHub-only create. */
|
||||
@@ -114,7 +120,7 @@ export function retryGitHubCloudCreate(creationId: string): void {
|
||||
})
|
||||
store.setActivePendingWorktreeCreation(creationId)
|
||||
store.setActiveView('terminal')
|
||||
void runGitHubCloudCreate(creationId)
|
||||
void runTracked(creationId)
|
||||
}
|
||||
|
||||
function current(creationId: string) {
|
||||
@@ -186,13 +192,27 @@ async function runGitHubCloudCreate(creationId: string): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
if (!current(creationId)) {
|
||||
// Cancelled while it started: main stopped the create and deleted what it had made.
|
||||
startedSandboxes.delete(creationId)
|
||||
// GH-CREATE-CANCEL: cancelled after the sandbox started but before it opened.
|
||||
await discardSandbox(creationId, sandboxId)
|
||||
return false
|
||||
}
|
||||
useAppStore.getState().updatePendingWorktreeCreation(creationId, { phase: 'creating' })
|
||||
const workspace = await window.api.cloudWorkspaces.get(sandboxId).catch(() => null)
|
||||
const opened = workspace?.ok ? await openCloudWorkspace(workspace.value) : null
|
||||
const opened = workspace?.ok
|
||||
? await openCloudWorkspace(workspace.value, {
|
||||
// GH-CREATE-NO-ROW: that server's rows wait behind the pending row until the hand-off.
|
||||
onHost: (hostId) =>
|
||||
useAppStore
|
||||
.getState()
|
||||
.updatePendingWorktreeCreation(creationId, { pairingHostId: hostId })
|
||||
})
|
||||
: null
|
||||
if (!current(creationId)) {
|
||||
// GH-CREATE-CANCEL: cancelled while it opened. Opening can't be stopped half way, so the
|
||||
// sandbox is deleted, which also drops this computer's pairing and its row.
|
||||
await discardSandbox(creationId, sandboxId)
|
||||
return false
|
||||
}
|
||||
if (!opened?.ok) {
|
||||
// The sandbox exists from here on: Retry opens it and never creates it again.
|
||||
return fail(
|
||||
@@ -219,3 +239,14 @@ async function runGitHubCloudCreate(creationId: string): Promise<boolean> {
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
/** A cancelled create's sandbox: deleted, so nothing keeps running (or billing) behind the cancel. */
|
||||
async function discardSandbox(creationId: string, sandboxId: string): Promise<void> {
|
||||
startedSandboxes.delete(creationId)
|
||||
forgetGitHubCreateKey(creationId)
|
||||
const result = await window.api.cloudWorkspaces.destroy(sandboxId).catch(() => null)
|
||||
if (!result?.ok) {
|
||||
console.warn('[orca-cloud] cancelled GitHub create: could not delete sandbox', sandboxId)
|
||||
}
|
||||
void refreshCloudWorktreeBindings().catch(() => undefined)
|
||||
}
|
||||
|
||||
@@ -125,3 +125,26 @@ describe('paired workspaces scoped by account (PAIRED-ACCOUNT-FILTER)', () => {
|
||||
expect(hostsFor('team-two', paired()).hiddenHostIds.has('runtime:gh')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GH-CREATE-NO-ROW hand-off', () => {
|
||||
it('hides the server a GitHub-only create is pairing to until its pending row hands off', () => {
|
||||
const pairing = (pairingHostId?: string) =>
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the selector reads only these fields.
|
||||
({
|
||||
runtimeEnvironments: [{ id: 'p', name: 'web (cloud)', orcaCloudPaired: true }],
|
||||
orcaProfileAuthStatus: { cloud: { userId: 'u1' } },
|
||||
pendingWorktreeCreations: {
|
||||
gh: {
|
||||
creationId: 'gh',
|
||||
request: { githubSource: { fullName: 'acme/web' } },
|
||||
...(pairingHostId ? { pairingHostId } : {})
|
||||
}
|
||||
}
|
||||
}) as unknown as Pick<AppState, 'runtimeEnvironments'>
|
||||
const hidden = (state: Pick<AppState, 'runtimeEnvironments'>) => [
|
||||
...parseOrcaCloudHostScopeKey(selectOrcaCloudHostScopeKey(state)).hiddenHostIds
|
||||
]
|
||||
expect(hidden(pairing())).toEqual([])
|
||||
expect(hidden(pairing('runtime:p'))).toEqual(['runtime:p'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,7 +13,12 @@ import { isOrcaCloudRuntimeEnvironment } from '../../../../shared/remote-runtime
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
|
||||
type CloudScopeState = Pick<AppState, 'runtimeEnvironments'> &
|
||||
Partial<Pick<AppState, 'orcaProfileAuthStatus' | 'cloudAccountIdByEnvironmentId'>>
|
||||
Partial<
|
||||
Pick<
|
||||
AppState,
|
||||
'orcaProfileAuthStatus' | 'cloudAccountIdByEnvironmentId' | 'pendingWorktreeCreations'
|
||||
>
|
||||
>
|
||||
|
||||
/**
|
||||
* Every Orca Cloud server this desktop runs, `+host` when the active team owns it and `-host`
|
||||
@@ -30,7 +35,12 @@ type CloudScopeState = Pick<AppState, 'runtimeEnvironments'> &
|
||||
export function selectOrcaCloudHostScopeKey(state: CloudScopeState): string {
|
||||
const active = orcaCloudAccountId(state.orcaProfileAuthStatus?.cloud)
|
||||
const owners = state.cloudAccountIdByEnvironmentId ?? {}
|
||||
return (state.runtimeEnvironments ?? [])
|
||||
// GH-CREATE-NO-ROW: a GitHub-only create's pending row stands for its server's rows until the
|
||||
// hand-off removes it; both change in one store update, so the row never shows twice.
|
||||
const handingOff = Object.values(state.pendingWorktreeCreations ?? {}).flatMap((entry) =>
|
||||
entry.request.githubSource && entry.pairingHostId ? [`-${entry.pairingHostId}`] : []
|
||||
)
|
||||
const scoped = (state.runtimeEnvironments ?? [])
|
||||
.filter((environment) => isOrcaCloudRuntimeEnvironment(environment))
|
||||
.flatMap((environment) => {
|
||||
const hostId = toRuntimeExecutionHostId(environment.id)
|
||||
@@ -41,7 +51,7 @@ export function selectOrcaCloudHostScopeKey(state: CloudScopeState): string {
|
||||
const inTeam = active !== null && owners[environment.id] === active
|
||||
return [`${inTeam ? '+' : '-'}${hostId}`]
|
||||
})
|
||||
.join('\n')
|
||||
return [...scoped, ...handingOff].join('\n')
|
||||
}
|
||||
|
||||
export function parseOrcaCloudHostScopeKey(key: string): {
|
||||
|
||||
@@ -195,6 +195,8 @@ export type PendingWorktreeCreation = {
|
||||
resumedAt?: number
|
||||
/** VM creates: the provisioned VM's runtime environment, known once it answers. */
|
||||
vmEnvironmentId?: string
|
||||
/** GitHub-only Orca Cloud create: the server it is pairing to; its rows wait for the hand-off. */
|
||||
pairingHostId?: string
|
||||
// Local git checkout meter; absent when git emits none or creation runs over SSH/runtime.
|
||||
checkoutProgress?: WorktreeCheckoutProgress
|
||||
/** Phases entered so far with their start times, so the chat checklist can time each step. */
|
||||
|
||||
+50
-5
@@ -1,12 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { EnvironmentDoctorCheck } from '../../../../shared/environment-doctor-types'
|
||||
import { triageCloudEnvironmentChecks } from './ephemeral-vm-environment-check-triage'
|
||||
import {
|
||||
missingToolCommand,
|
||||
triageCloudEnvironmentChecks
|
||||
} from './ephemeral-vm-environment-check-triage'
|
||||
|
||||
const c = (
|
||||
id: string,
|
||||
status: EnvironmentDoctorCheck['status'],
|
||||
category: EnvironmentDoctorCheck['category'] = 'tools'
|
||||
): EnvironmentDoctorCheck => ({ id, category, status, message: id })
|
||||
const disk = (code: 'diskLow' | 'diskVeryLow'): EnvironmentDoctorCheck => ({
|
||||
...c('disk.free', code === 'diskLow' ? 'warn' : 'fail', 'disk'),
|
||||
code,
|
||||
params: { free: '0.8' }
|
||||
})
|
||||
const kinds = (checks: EnvironmentDoctorCheck[]): [string, string][] =>
|
||||
triageCloudEnvironmentChecks(checks, { setupRunsOnCreate: true }).notices.map((notice) => [
|
||||
notice.id,
|
||||
notice.kind
|
||||
])
|
||||
|
||||
describe('triageCloudEnvironmentChecks', () => {
|
||||
it('stops only on a sandbox nobody can work in', () => {
|
||||
@@ -41,7 +54,7 @@ describe('triageCloudEnvironmentChecks', () => {
|
||||
c('setup.run', 'warn', 'setup'),
|
||||
c('setup.script', 'warn', 'setup'),
|
||||
c('git.remote', 'warn', 'git'),
|
||||
c('disk.free', 'warn', 'disk')
|
||||
disk('diskLow')
|
||||
],
|
||||
{ setupRunsOnCreate: false }
|
||||
)
|
||||
@@ -49,7 +62,7 @@ describe('triageCloudEnvironmentChecks', () => {
|
||||
expect(notices.map((notice) => [notice.id, notice.kind])).toEqual([
|
||||
['git.push', 'push'],
|
||||
['setup.run', 'setup'],
|
||||
['disk.free', 'check']
|
||||
['disk.free', 'disk']
|
||||
])
|
||||
})
|
||||
|
||||
@@ -63,13 +76,45 @@ describe('triageCloudEnvironmentChecks', () => {
|
||||
|
||||
it('a failed reach of origin is its own notice, never a bare check line (ENV-ORIGIN-TOAST)', () => {
|
||||
const { fatal, notices } = triageCloudEnvironmentChecks(
|
||||
[c('git.fetch', 'fail', 'git'), c('disk.free', 'warn', 'disk')],
|
||||
[c('git.fetch', 'fail', 'git'), disk('diskLow')],
|
||||
{ setupRunsOnCreate: true }
|
||||
)
|
||||
expect(fatal).toEqual([])
|
||||
expect(notices.map((notice) => [notice.id, notice.kind])).toEqual([
|
||||
['git.fetch', 'fetch'],
|
||||
['disk.free', 'check']
|
||||
['disk.free', 'disk']
|
||||
])
|
||||
})
|
||||
|
||||
it('gives low disk and a missing tool their own kinds (ENV-NOTICE-ACTIONS)', () => {
|
||||
expect(
|
||||
kinds([
|
||||
disk('diskVeryLow'),
|
||||
{
|
||||
...c('tool.pnpm', 'fail'),
|
||||
code: 'toolMissing',
|
||||
params: { command: 'pnpm', reason: 'pnpm-lock.yaml' }
|
||||
},
|
||||
// An older server: no code or params, the id still names the tool.
|
||||
c('tool.cargo', 'fail')
|
||||
])
|
||||
).toEqual([
|
||||
['disk.free', 'disk'],
|
||||
['tool.pnpm', 'tool'],
|
||||
['tool.cargo', 'tool']
|
||||
])
|
||||
expect(missingToolCommand(c('tool.cargo', 'fail'))).toBe('cargo')
|
||||
})
|
||||
|
||||
it('hides what nobody can act on, and any check it has no words for, never a bare label', () => {
|
||||
expect(
|
||||
kinds([
|
||||
// "Could not read free disk space."
|
||||
c('disk.free', 'warn', 'disk'),
|
||||
// Checks from a newer doctor this desktop doesn't know.
|
||||
c('network.dns', 'fail', 'workspace'),
|
||||
c('cache.stale', 'warn', 'setup')
|
||||
])
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,7 +14,12 @@ const FATAL_CHECK_IDS: ReadonlySet<string> = new Set([
|
||||
'tool.git'
|
||||
])
|
||||
|
||||
export type CloudSetupNoticeKind = 'push' | 'fetch' | 'setup' | 'check'
|
||||
/**
|
||||
* Each kind has its own plain words (CloudSetupNotices). `disk`: little free space; `tool`: a CLI the
|
||||
* repository needs isn't installed. A check with no kind of its own never shows as the doctor's
|
||||
* bare label (ENV-NOTICE-ACTIONS).
|
||||
*/
|
||||
export type CloudSetupNoticeKind = 'push' | 'fetch' | 'setup' | 'disk' | 'tool'
|
||||
|
||||
/** One problem the opened workspace shows as a single dismissible line. */
|
||||
export type CloudSetupNotice = {
|
||||
@@ -82,7 +87,23 @@ function noticeKind(
|
||||
case 'setup.script':
|
||||
case 'git.remote':
|
||||
return null
|
||||
case 'disk.free':
|
||||
// "Couldn't read free disk space" (no code) says nothing anyone can act on.
|
||||
return (check.code === 'diskLow' || check.code === 'diskVeryLow') && check.params?.free
|
||||
? 'disk'
|
||||
: null
|
||||
default:
|
||||
return 'check'
|
||||
// tool.git is fatal above; every other tool.<command> is one the repository's files need.
|
||||
if (check.id.startsWith('tool.') && check.id.length > 'tool.'.length) {
|
||||
return 'tool'
|
||||
}
|
||||
// A check this desktop doesn't know yet (a newer doctor): hidden rather than shown as a bare
|
||||
// English label with nothing to do. The create still goes ahead, as it did before.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** The CLI a `tool` notice names: the doctor's param, else the id's suffix (older servers). */
|
||||
export function missingToolCommand(check: EnvironmentDoctorCheck): string {
|
||||
return check.params?.command || check.id.slice('tool.'.length)
|
||||
}
|
||||
|
||||
@@ -149,13 +149,7 @@ export function createRemovePendingWorktreeCreation(
|
||||
.catch(() => undefined)
|
||||
}
|
||||
const cleanup = cleanUpCancelledVm(creationId, removedEntry.request, get)
|
||||
// A GitHub-only create past its start is opening a sandbox that stays; nothing is deleted.
|
||||
const opening = Boolean(removedEntry.request.githubSource) && removedEntry.phase === 'creating'
|
||||
if (
|
||||
removedEntry.status === 'creating' &&
|
||||
isOrcaCloudCreateRequest(removedEntry.request) &&
|
||||
!opening
|
||||
) {
|
||||
if (removedEntry.status === 'creating' && isOrcaCloudCreateRequest(removedEntry.request)) {
|
||||
beginOrcaCloudCancelCleanup(creationId, cleanup ? [cleanup] : [], {
|
||||
runtimeId: removedEntry.request.ephemeralVmRuntimeId,
|
||||
createStartedAt: removedEntry.startedAt,
|
||||
|
||||
@@ -186,6 +186,7 @@ export type WorktreeSlice = {
|
||||
pausedMs?: number
|
||||
resumedAt?: number
|
||||
vmEnvironmentId?: string
|
||||
pairingHostId?: string
|
||||
checkoutProgress?: PendingWorktreeCreation['checkoutProgress']
|
||||
worktreeId?: string
|
||||
heldChatMessage?: string
|
||||
|
||||
@@ -58,6 +58,12 @@ export type CloudOomEvent = {
|
||||
at: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The person's own standing on a GitHub repository: admin on it (an owner of a personal account,
|
||||
* or an org owner or repository admin), and whether its owner is a user or an organization.
|
||||
*/
|
||||
export type GitHubInstallRights = { admin: boolean; ownerType: 'User' | 'Organization' }
|
||||
|
||||
/** The status panel's read plus what the desktop needs from the sandbox view alongside it. */
|
||||
export type CloudWorkspaceStatus = HostedWorkspaceStatus & {
|
||||
/** GIT-SETUP: commits and pushes are refused in the VM until a boot installs the identities. */
|
||||
@@ -110,6 +116,13 @@ export type OrcaCloudWorkspaceApi = {
|
||||
repoUrl: string
|
||||
setupSessionId?: string
|
||||
}) => Promise<CloudWorkspaceResult<HostedGithubRepoAccessResponse>>
|
||||
/**
|
||||
* Whether the person can install the Orca GitHub App on this repository's owner, read with their
|
||||
* own GitHub CLI login; null when gh can't tell (not installed, signed out, slow).
|
||||
*/
|
||||
gitHubInstallRights: (args: {
|
||||
repoUrl: string
|
||||
}) => Promise<CloudWorkspaceResult<GitHubInstallRights | null>>
|
||||
/** One page of the repositories the account's GitHub installations reach (cloud-start N12). */
|
||||
listGitHubRepositories: (args: {
|
||||
cursor?: string | null
|
||||
@@ -148,6 +161,7 @@ export const ORCA_CLOUD_WORKSPACE_CHANNELS = {
|
||||
gitHubUserConnectLink: 'orcaCloudWorkspace:gitHubUserConnectLink',
|
||||
disconnectGitHubUser: 'orcaCloudWorkspace:disconnectGitHubUser',
|
||||
getRepoAccess: 'orcaCloudWorkspace:getRepoAccess',
|
||||
gitHubInstallRights: 'orcaCloudWorkspace:gitHubInstallRights',
|
||||
listGitHubRepositories: 'orcaCloudWorkspace:listGitHubRepositories',
|
||||
createFromGitHub: 'orcaCloudWorkspace:createFromGitHub',
|
||||
wakeProgress: 'orcaCloudWorkspace:wakeProgress',
|
||||
|
||||
@@ -212,8 +212,10 @@ export function planSidebarRows(inputs: SidebarRowPlanInputs) {
|
||||
const naturalWorktrees = orderedGroups.flatMap(([, group]) => group.items)
|
||||
const render = (collapsedGroups: Set<string>): Row[] => {
|
||||
const result: Row[] = []
|
||||
if (groupBy !== 'repo') {
|
||||
for (const creation of pendingCreations) {
|
||||
// By project, a create sits atop its own project; one with no project here yet (a GitHub-only
|
||||
// Orca Cloud create, GH-CREATE-NO-ROW) sits atop the list, as in every other grouping.
|
||||
for (const creation of pendingCreations) {
|
||||
if (groupBy !== 'repo' || !repoMap.has(creation.repoId)) {
|
||||
result.push(buildPendingCreationRow(creation, repoMap))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,8 @@ export function buildOrderedGroups(args: {
|
||||
([repoId, candidate]) => [repoId, candidate.repo] as const
|
||||
)
|
||||
],
|
||||
pendingRepoIds: pendingByRepo.keys(),
|
||||
// A create with no project here yet sits atop the list instead (GH-CREATE-NO-ROW).
|
||||
pendingRepoIds: [...pendingByRepo.keys()].filter((repoId) => repoMap.has(repoId)),
|
||||
repoOrder,
|
||||
projectOrderBy
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user