mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
fix(cloud-create): GitHub-only creates get a sidebar row and a real Cancel (GH-CREATE-NO-ROW, GH-CREATE-CANCEL)
NO-ROW: a create with no project here yet (GitHub-only Orca Cloud) now has its pending row atop the list when grouped by project too (it got a blank 'repo:' section before). While it opens, openCloudWorkspace reports the server it pairs to (onHost); the sidebar hides that server's rows until the hand-off removes the pending row, both in one store update, so the workspace never shows twice. CANCEL: createSandboxWithProgress (owned and GitHub-only creates) deletes what a cancel leaves: cancelled in flight, it replays the create by its Idempotency-Key and deletes the sandbox the control plane hands back (reclaimCancelledCreate, retrying while the first is still in progress); cancelled just as it answered, it deletes it. A cancelled clone already deleted itself. The GitHub runner deletes the sandbox when Cancel lands before or during Open workspace (which also drops this computer's pairing), and cancel's 'Deleting the sandbox…' waits for it. Copy link moves to cloud-workspace-share-link.ts (re-exported) to keep open-cloud-workspace.ts within max-lines.
This commit is contained in:
@@ -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,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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
+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' })
|
||||
})
|
||||
})
|
||||
+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>
|
||||
)
|
||||
})
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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