mirror of
https://github.com/stablyai/orca.git
synced 2026-10-06 08:02:28 +00:00
* fix(worktrees): delete removed checkouts in git, not in Orca's file pool Local worktree removal renamed the checkout into a sibling trash root and deleted it in the background with a recursive fs.rm in the main process. That queued one request per entry on libuv's shared 4-thread file pool, so for minutes every other async fs call in the main process (the agent-session store behind chat sends, file explorer reads) waited behind the delete. `git worktree remove` now deletes the checkout inline in git's own process again, so the card stays in its Deleting state for the length of the delete while Orca's file pool stays free. No timeout applies to the call, so a large delete is never killed halfway. If git reports success but the path still exists (Git for Windows leaves junctions and their parent directories in place), the leftover is deleted with the existing removeHostTree; WSL checkouts stay with the distro. Nothing creates trash any more: the scheduling queue, rename/restore helpers and the trash_rename span are gone. The startup sweep stays to drain entries older releases left behind, and now removes each emptied trash root so the obligation ends. * fix(worktrees): let Git delete Windows checkouts with long paths enabled Removal now always runs Git's own recursive delete, and worktree creation checks out with core.longpaths on Windows, so a deep checkout Orca created could fail to delete with "Filename too long" (#6433). The Windows recovery then finishes the delete but keeps the branch. Pass the same command-scoped core.longpaths option to `git worktree remove` so Git can delete what it created. Also point the CI shard timing entry at the renamed real-git removal suite. * fix(worktrees): keep an inherited GIT_ASK_YESNO out of the worktree delete Git for Windows asks $GIT_ASK_YESNO whether to retry when a file stays locked during a recursive delete. Orca's git env inherits the user's environment, so an inherited value would run an arbitrary prompt program in the middle of a removal. Drop it for the removal call only. * perf(worktrees): run worktree deletes under their own limit, outside git admission `git worktree remove` now deletes the whole checkout in Git's own process, which takes 20-35 s on a large tree. It took a general git admission slot at status tier for that whole time, and that cap is as small as two slots on a machine with six or fewer cores, so two deletes blocked every status read. Deletes now skip general admission and queue under their own limit of two per host instead: two concurrent deletes already saturate one disk, and more only slow each other down. Leftover cleanup runs inside the same slot. * fix(worktrees): delete removed checkouts in the background and mark them removing Since the checkout is deleted by `git worktree remove` in Git's own process, a large delete takes 20-35 s. Answering the request only after that made web and mobile (30 s), paired desktop (60/180 s) and the CLI (60 s) report a failure for a delete that was still going, and mobile silently re-showed the row. The request now does everything that can refuse (lock, cleanliness, archive hook, watcher/terminal gate, terminal stop, shared-link unlink), records the removal in an in-memory table on the host and answers `removing: true`. The delete, branch cleanup and metadata purge run after it in the same order as before, and the watcher/terminal gate stays held until they finish. - Listings mark rows in the table `removing` for clients that advertise `worktree.background-removal.v1` (the desktop renderer, paired desktop and web), and leave them out for everyone else (older clients, mobile, the CLI), which already dropped the row when the request answered. - The outcome (removed, with any preserved branch, or the error) rides the existing worktrees-changed event as an optional field, sent after the row has left the table. - A repeat delete while Git runs joins it. A create at the same path or with the same branch is refused with "Cleanup is pending; try again shortly"; create's name search skips the path, so generated names move on. - Nothing is persisted: after a quit or crash Git still lists the checkout and it can be deleted again. WSL checkouts still delete inline. - `orca worktree rm` says the checkout is still being deleted. * fix(worktrees): keep the existing Deleting card until the host's Git finishes The host now answers a local worktree delete on acceptance and deletes in the background. The renderer keeps the existing delete state set until the host publishes how it ended: - The delete that asked waits for the outcome on the worktrees-changed event (local IPC or the paired runtime's client event), then runs the same teardown, preserved-branch toast and card error an inline delete did. If that event is lost to a dropped connection, a listing that shows the row gone after it was marked removing finishes the wait, and one that shows it back without the marker fails it. - Any other renderer (a reload, a paired desktop, web) sets the same delete state from the host's `removing` marker and clears it when the marker goes. A failure the host publishes lands on that card's existing error. - Web advertises `worktree.background-removal.v1` so the host sends it the marker; paired desktop does through the Electron capability list. No new component, style or state: the card reads the delete state it always did. A host that predates this answers when done without `removing`, and the renderer takes that as finished, as before. * test(worktrees): type the removal harness and projection for the node typecheck * fix(worktrees): don't fail a delete retry with an earlier attempt's buffered failure A background removal's outcome that reached this renderer with no waiter (another client's delete, a host-marked card, or one already settled from listings) was buffered for 60 s and consumed by the next delete of the same workspace, so retrying a failed delete failed at once with the old error while the host was deleting. Drop the buffered outcome before sending the request; only an outcome that arrives after it can belong to it. * fix(worktrees): let only a gap in host events settle a background delete from listings Git unlists the checkout before the host deletes the branch, cleans the push target and purges metadata, and the worktree-directory watcher refetches within 250 ms. The renderer read the missing row as a finished delete, so the waiter resolved without the preserved branch (no toast) and a failure in those last steps showed as success; the real outcome was then dropped. The listing fallback exists only for a lost outcome event, so it now applies only after this host's event stream had a gap: a new subscription or a replay after reconnect. * perf(worktrees): let a bulk delete start each same-repo checkout delete once the host accepts the last A bulk delete ran one worktree at a time per repo (#2259, for packed-refs and ref-lock races in branch cleanup). With Git now deleting each checkout for 20-35 s before the request settles, N worktrees in one repo took N times that. The renderer now queues same-repo deletes only until the host accepts each one; a parent still waits for its nested children to finish. The host serializes the branch cleanup step per repo itself, which also covers removals started by different clients. * test(worktrees): pin the host platform in the mocked removal suites so they pass on Windows Removal now passes -c core.longpaths=true on Windows, so the exact-argv assertions and command-keyed mocks never matched there (17 failures on a Windows host). Pin darwin as the add-worktree suites already do, and drive the one Windows-specific case through the same spy. * test(worktrees): type the blocked git remove result instead of a broad object The anti-slop static-analysis gate rejects `object` parameters. * test(worktrees): clear the changed-code quality gate in the removal suites Merge the duplicate node:fs import, build the mock child without a cast, read worktrees:list rows through one typed helper, and give the remaining casts a SAFETY line. * fix(worktrees): record each background delete durably and finish it after a quit or crash A quit mid-delete left git to finish the checkout on its own while the branch delete and metadata purge never ran; a crash left a normal-looking row. Each accepted local removal now writes a record beside the profile state before git starts, clears it on success or failure, and the host runs the same delete again for any record left at startup, re-deriving what remains from git and disk. An orderly quit stops the checkout delete without waiting for it. * test(worktrees): type the interrupted-removal assertions for the node typecheck * fix(worktrees): finish an interrupted delete that already removed the checkout's .git file Quit stops git worktree remove mid-delete, and Git deletes the checkout's .git file wherever it falls in directory order. Git then refuses the checkout ("validation failed ... .git does not exist") on every retry, so the startup finish failed and the row could never be deleted from Orca. A registered checkout this record owns that has lost its .git file now finishes like an unregistered one: leftover files, prune, then the branch. * fix(worktrees): let Git finish an interrupted delete, and never take a different checkout A quit or crash that stops `git worktree remove` after it deleted the checkout's .git file left a registered checkout Git refuses to remove. The previous fix deleted that leftover inside Orca's process, which is the bulk delete this change exists to avoid (and on Windows the leftover can be most of the checkout). The startup finish now rewrites the missing .git file from Git's own admin entry for that path and lets `git worktree remove --force` delete it. `git worktree repair` is not used: it also re-points every other registered path, including a checkout another repository now owns there. Orca deletes the leftover itself only when no admin entry claims the path. The startup finish forces, so it now leaves the path alone when the checkout there is not the one recorded: a registered worktree on a different branch or head, or a `.git` at a path Git already unregistered. The record is dropped and the card shows why. The record write before Git starts is now bounded (2 s, logged when exceeded) so a stalled disk cannot hold the delete, and the outcome is published before the record's clear reaches disk. * test(worktrees): compare worktree paths by value and tear down with Windows lock retries Git prints forward slashes in `git worktree list` on Windows, so the real-Git removal suites never found a joined path there: positive checks failed and negative ones passed without proving anything. They now compare Git's parsed rows by value. Teardown uses the shared retrying removeTree, since Windows can hold the deleted checkout busy for a moment after Git exits. Adds a relative-path worktree case for the .git restore (skipped before Git 2.48). * fix(worktrees): reply to a worktree delete when it has finished, not on a broadcast event A current client's delete request now waits for the host's background delete and gets its real result (removed, a preserved branch, or the error) as the reply, the way it did before the delete moved off the request. A request that arrives while the delete runs joins it and gets the same result. Every other view keeps reading the host's `removing` marker: the row leaving means the delete finished, and the row listed again without the marker shows "The delete did not finish. Try again." on a card that view had marked Deleting. A request whose reply is lost (a timeout or a dropped connection) settles the same way from a fresh listing instead of reporting a failure. Clients without the background-removal capability (mobile, the CLI, older desktops) are still answered on acceptance and have rows under removal left out of their listings. This removes the outcome on worktreesChanged and everything it needed: the renderer's outcome waiters, early-outcome buffer and TTL, per-host event-gap generations, the request pre-registration, and the accept callback bulk delete used. Bulk delete runs same-repo deletes in parallel only on this machine, whose host serializes branch cleanup per repo; SSH and paired hosts stay serialized. * test(worktrees): type the pending-removal host id in the background-removal suite * fix(worktrees): answer a delete request even when a concurrent removal of the same worktree replaced its record The desktop app's removal and the runtime removal (CLI, paired clients) coalesce separately, so both can be accepted for one worktree. The second replaced the first's record, and the first delete then finished without resolving the request waiting on it, leaving the desktop card on Deleting indefinitely. Each delete now settles the request it was started for. * fix(worktrees): run same-repo removal archive hooks and teardown one at a time on the host Local bulk delete now sends same-repo removals in parallel, so their archive hooks, terminal teardown and preflight ran at once; a hook that writes refs can race the repo's ref locks (#2259). The host now serializes each local removal up to acceptance per repo, for every client; Git's checkout delete still runs in parallel under the delete limit. * fix(runtime): keep waiting worktree deletes out of a host's foreground call slots worktree.rm now replies only after Git deletes the checkout (up to minutes), so on paired desktop and web each waiting delete held one of the host's 8 foreground call slots, and a bulk delete queued listing refreshes and every other foreground call behind it. Deletes now run in their own lane with the same bound; the 2-slot background lane stays for status polls. * fix(worktrees): join a same-worktree delete accepted while a removal waited its repo turn The desktop app and the runtime (CLI, paired clients, web) check for a running delete before they queue for the repo's acceptance turn. A delete of the same worktree from the other path, accepted while this one queued, was missed: this request re-ran the archive hook, stopped the terminals again and started a second `git worktree remove` on the directory Git was deleting. The queued acceptance now re-checks and joins the running delete. * fix(worktrees): fence a resumed delete's checkout from startup, and drop rows a listing read before the delete finished A delete a quit or crash interrupted took its terminal and file-watcher gate only when the resume job ran, after the first window was shown; session restore could open a shell or watcher inside the half-deleted checkout first, and on Windows that handle can fail the resumed git delete. Loading the records now fences each recorded path, and the resumed job takes the fence over in the same tick it takes its own gate. A listing that read git's registration before a delete finished, and replied after the removal record cleared, returned the row unmarked, so other views briefly showed "The delete did not finish". Listings now capture the pending removals before reading git and leave out a row whose delete finished successfully since; a row whose delete failed stays listed as before. * test(worktrees): keep git's auto-maintenance out of the real-git removal suite CI's Git 2.55 failed the file-pool test in teardown with ENOTEMPTY on the scratch repo's objects/pack after the test body passed: the 3,000-file commit's detached auto-maintenance was still writing a pack. The scratch repo now disables auto-maintenance and auto-gc. * fix(worktrees): one archive-hook approval covers a same-repo bulk delete again Local same-repo deletes now start together, so each queued its trust prompt with a state snapshot taken before the first prompt was answered; approving the first still showed the same prompt once per remaining worktree. The queued check now reads the store when its turn comes.
715 lines
26 KiB
TypeScript
715 lines
26 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const {
|
|
callMock,
|
|
runtimeClientConstructorMock,
|
|
serveOrcaAppMock,
|
|
getDefaultUserDataPathMock,
|
|
addEnvironmentFromPairingCodeMock,
|
|
listEnvironmentsMock,
|
|
spawnMock
|
|
} = vi.hoisted(() => ({
|
|
callMock: vi.fn(),
|
|
runtimeClientConstructorMock: vi.fn(),
|
|
serveOrcaAppMock: vi.fn(),
|
|
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
|
|
addEnvironmentFromPairingCodeMock: vi.fn(),
|
|
listEnvironmentsMock: vi.fn(),
|
|
spawnMock: vi.fn()
|
|
}))
|
|
|
|
vi.mock('./runtime-client', async () => {
|
|
const { createRuntimeClientModuleMock } = await import('./index-test-harness.js')
|
|
return createRuntimeClientModuleMock({
|
|
callMock,
|
|
runtimeClientConstructorMock,
|
|
serveOrcaAppMock,
|
|
getDefaultUserDataPathMock
|
|
})
|
|
})
|
|
|
|
vi.mock('./runtime/environments', () => ({
|
|
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
|
|
listEnvironments: listEnvironmentsMock,
|
|
removeEnvironment: vi.fn(),
|
|
resolveEnvironment: vi.fn()
|
|
}))
|
|
|
|
vi.mock('child_process', async () => {
|
|
const { createChildProcessModuleMock } = await import('./index-test-harness.js')
|
|
return createChildProcessModuleMock(spawnMock)
|
|
})
|
|
|
|
import { COMMAND_SPECS, main } from './index'
|
|
import { formatFlagHelp } from './help'
|
|
import { GLOBAL_FLAGS, specPaths } from './args'
|
|
import { okFixture, queueFixtures } from './test-fixtures'
|
|
|
|
describe('COMMAND_SPECS collision check', () => {
|
|
it('has no duplicate command or alias paths', () => {
|
|
// Why: first-match resolution would silently shadow duplicate aliases.
|
|
const seen = new Set<string>()
|
|
for (const spec of COMMAND_SPECS) {
|
|
for (const path of specPaths(spec)) {
|
|
const key = path.join(' ')
|
|
expect(seen.has(key), `Duplicate command/alias path: "${key}"`).toBe(false)
|
|
seen.add(key)
|
|
}
|
|
}
|
|
})
|
|
|
|
it('allows every flag documented in command usage strings', () => {
|
|
const flagPattern = /--([a-zA-Z0-9-]+)/g
|
|
for (const spec of COMMAND_SPECS) {
|
|
const allowed = new Set([...GLOBAL_FLAGS, ...spec.allowedFlags])
|
|
for (const match of spec.usage.matchAll(flagPattern)) {
|
|
const flag = match[1]
|
|
expect(
|
|
allowed.has(flag),
|
|
`Documented flag --${flag} is not allowed for command: ${spec.path.join(' ')}`
|
|
).toBe(true)
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('command aliases dispatch to the canonical handler', () => {
|
|
let logSpy: ReturnType<typeof vi.spyOn>
|
|
|
|
beforeEach(() => {
|
|
callMock.mockReset()
|
|
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
})
|
|
|
|
afterEach(() => {
|
|
callMock.mockReset()
|
|
// Why: restore console.log so a downstream describe's vi.spyOn starts from a
|
|
// clean spy — otherwise this block's --json output leaks into its calls[0].
|
|
logSpy.mockRestore()
|
|
})
|
|
|
|
it('runs `worktree remove` as the canonical `worktree rm` (the incident)', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_show', { worktree: { hostId: 'local' } }),
|
|
okFixture('req', { removed: true })
|
|
)
|
|
|
|
await main(['worktree', 'remove', '--worktree', 'id:wt-1', '--force', '--json'], '/tmp/repo')
|
|
|
|
expect(callMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
'worktree.rm',
|
|
expect.objectContaining({ worktree: 'id:wt-1', hostId: 'local', force: true })
|
|
)
|
|
})
|
|
|
|
it('runs `worktree delete` as the canonical `worktree rm`', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_show', { worktree: { hostId: 'runtime:env-1' } }),
|
|
okFixture('req', { removed: true })
|
|
)
|
|
|
|
await main(['worktree', 'delete', '--worktree', 'id:wt-1', '--json'], '/tmp/repo')
|
|
|
|
expect(callMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
'worktree.rm',
|
|
expect.objectContaining({ worktree: 'id:wt-1', hostId: 'runtime:env-1' })
|
|
)
|
|
})
|
|
|
|
it('says the checkout is still being deleted when the host accepted a background removal', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_show', { worktree: { hostId: 'local' } }),
|
|
okFixture('req', { removed: true, removing: true })
|
|
)
|
|
|
|
await main(['worktree', 'rm', '--worktree', 'id:wt-1'], '/tmp/repo')
|
|
|
|
expect(logSpy).toHaveBeenCalledWith(
|
|
'removed: true\nOrca is still deleting the checkout in the background.'
|
|
)
|
|
})
|
|
|
|
it('fails closed when worktree removal cannot resolve a host', async () => {
|
|
queueFixtures(callMock, okFixture('req_show', { worktree: { id: 'wt-1' } }))
|
|
const priorExitCode = process.exitCode
|
|
|
|
try {
|
|
await main(['worktree', 'rm', '--worktree', 'id:wt-1', '--json'], '/tmp/repo')
|
|
|
|
expect(process.exitCode).toBe(1)
|
|
expect(callMock).toHaveBeenCalledTimes(1)
|
|
expect(callMock).toHaveBeenCalledWith('worktree.show', { worktree: 'id:wt-1' })
|
|
} finally {
|
|
process.exitCode = priorExitCode
|
|
}
|
|
})
|
|
|
|
// #19334: a failed archive hook blocks removal, so the CLI must exit non-zero rather than
|
|
// report a delete that did not happen — and the waiver must ride its own flag, never --force.
|
|
it('exits non-zero when worktree removal is refused by a failed archive hook', async () => {
|
|
queueFixtures(callMock, okFixture('req_show', { worktree: { hostId: 'local' } }))
|
|
callMock.mockRejectedValueOnce(
|
|
Object.assign(new Error('Archive hook failed for worktree: /tmp/wt — exited 23.'), {
|
|
code: 'worktree_archive_hook_failed'
|
|
})
|
|
)
|
|
const priorExitCode = process.exitCode
|
|
|
|
try {
|
|
await main(
|
|
['worktree', 'rm', '--worktree', 'id:wt-1', '--force', '--run-hooks', '--json'],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(process.exitCode).toBe(1)
|
|
expect(callMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
'worktree.rm',
|
|
expect.objectContaining({
|
|
runHooks: true,
|
|
allowFailedArchiveHook: false
|
|
})
|
|
)
|
|
} finally {
|
|
process.exitCode = priorExitCode
|
|
}
|
|
})
|
|
|
|
// #19334 S4: the waiver only applies to a hook that ran, so alone it silently does nothing.
|
|
it('rejects the archive-hook waiver without --run-hooks instead of ignoring it', async () => {
|
|
queueFixtures(callMock, okFixture('req_show', { worktree: { hostId: 'local' } }))
|
|
const priorExitCode = process.exitCode
|
|
|
|
try {
|
|
await main(
|
|
['worktree', 'rm', '--worktree', 'id:wt-1', '--allow-failed-archive-hook', '--json'],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(process.exitCode).toBe(1)
|
|
// The removal must never have been attempted.
|
|
expect(callMock).not.toHaveBeenCalledWith('worktree.rm', expect.anything())
|
|
} finally {
|
|
process.exitCode = priorExitCode
|
|
}
|
|
})
|
|
|
|
it('forwards the explicit archive-hook waiver on worktree rm', async () => {
|
|
queueFixtures(
|
|
callMock,
|
|
okFixture('req_show', { worktree: { hostId: 'local' } }),
|
|
okFixture('req', { removed: true })
|
|
)
|
|
|
|
await main(
|
|
[
|
|
'worktree',
|
|
'rm',
|
|
'--worktree',
|
|
'id:wt-1',
|
|
'--run-hooks',
|
|
'--allow-failed-archive-hook',
|
|
'--json'
|
|
],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(callMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
'worktree.rm',
|
|
expect.objectContaining({ runHooks: true, allowFailedArchiveHook: true })
|
|
)
|
|
})
|
|
|
|
it('still runs `terminal focus` after the handler de-duplication', async () => {
|
|
queueFixtures(callMock, okFixture('req', { focus: { ok: true } }))
|
|
|
|
await main(['terminal', 'focus', '--terminal', 'term_abc', '--json'], '/tmp/repo')
|
|
|
|
expect(callMock).toHaveBeenCalledWith(
|
|
'terminal.focus',
|
|
expect.objectContaining({ navigation: 'host' })
|
|
)
|
|
})
|
|
|
|
it('serves `agent-context --json` without contacting the runtime', async () => {
|
|
runtimeClientConstructorMock.mockClear()
|
|
await main(['agent-context', '--json'], '/tmp/repo')
|
|
|
|
// Why: pure local read — proves the SSH/offline property (no RPC).
|
|
expect(runtimeClientConstructorMock).not.toHaveBeenCalled()
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
const schema = JSON.parse(String(logSpy.mock.calls.at(-1)?.[0]))
|
|
expect(schema.schemaVersion).toBe(1)
|
|
const rm = schema.commands.find(
|
|
(command: { command: string }) => command.command === 'worktree rm'
|
|
)
|
|
expect(rm.aliases).toContainEqual(['worktree', 'remove'])
|
|
})
|
|
|
|
it('keeps `agent-context` local when remote environment variables are set', async () => {
|
|
vi.stubEnv('ORCA_PAIRING_CODE', 'pairing-code')
|
|
vi.stubEnv('ORCA_ENVIRONMENT', 'stale-environment')
|
|
try {
|
|
await main(['agent-context', '--json'], '/tmp/repo')
|
|
|
|
expect(process.exitCode).not.toBe(1)
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
} finally {
|
|
vi.unstubAllEnvs()
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('artifact runtime routing', () => {
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs()
|
|
vi.restoreAllMocks()
|
|
process.exitCode = 0
|
|
})
|
|
|
|
it('uses the desktop runtime despite remote-selection environment fallbacks', async () => {
|
|
vi.stubEnv('ORCA_ENVIRONMENT', 'remote-environment')
|
|
vi.stubEnv('ORCA_PAIRING_CODE', 'remote-pairing-code')
|
|
vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
|
callMock.mockResolvedValue(okFixture('artifact-list', { status: 'ok', value: [] }))
|
|
runtimeClientConstructorMock.mockClear()
|
|
|
|
await main(['artifacts', 'list', '--json'], '/folder-workspace')
|
|
|
|
expect(process.exitCode).not.toBe(1)
|
|
expect(runtimeClientConstructorMock).toHaveBeenCalledWith(null, null)
|
|
expect(callMock).toHaveBeenCalledWith('artifacts.list', {})
|
|
})
|
|
})
|
|
|
|
describe('unknown command surfaces a suggestion', () => {
|
|
let errorSpy: ReturnType<typeof vi.spyOn>
|
|
|
|
beforeEach(() => {
|
|
callMock.mockReset()
|
|
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
})
|
|
|
|
afterEach(() => {
|
|
errorSpy.mockRestore()
|
|
process.exitCode = 0
|
|
})
|
|
|
|
it('prints did-you-mean for a near-miss command and exits non-zero', async () => {
|
|
await main(['worktree', 'remov'], '/tmp/repo')
|
|
|
|
expect(process.exitCode).toBe(1)
|
|
const stderr = errorSpy.mock.calls.map((call) => String(call[0])).join('\n')
|
|
expect(stderr).toContain('Unknown command: worktree remov')
|
|
expect(stderr).toContain('orca worktree')
|
|
})
|
|
|
|
it('reports a mistyped pre-command flag without swallowing the command', async () => {
|
|
await main(['--jso', 'worktree', 'list'], '/tmp/repo')
|
|
|
|
expect(process.exitCode).toBe(1)
|
|
const stderr = errorSpy.mock.calls.map((call) => String(call[0])).join('\n')
|
|
expect(stderr).toContain('Unknown flag --jso for command: worktree list')
|
|
expect(stderr).toContain('--json')
|
|
})
|
|
|
|
it('names the offending --worktree value and the valid forms on selector_not_found', async () => {
|
|
const { RuntimeRpcFailureError } = await import('./runtime/types.js')
|
|
callMock.mockRejectedValue(
|
|
new RuntimeRpcFailureError({
|
|
id: 'req_selector',
|
|
ok: false,
|
|
error: { code: 'selector_not_found', message: 'selector_not_found' },
|
|
_meta: { runtimeId: 'runtime_local' }
|
|
})
|
|
)
|
|
|
|
await main(
|
|
['orchestration', 'worker-start', '--task', 't1', '--worktree', 'repo-1', '--agent', 'codex'],
|
|
'/tmp/repo'
|
|
)
|
|
|
|
expect(process.exitCode).toBe(1)
|
|
const stderr = errorSpy.mock.calls.map((call) => String(call[0])).join('\n')
|
|
expect(stderr).toContain('No Orca workspace matched the worktree selector "repo-1"')
|
|
expect(stderr).toContain('id:repo-1::<absolute-path>')
|
|
expect(stderr).toContain('Valid selector forms:')
|
|
})
|
|
|
|
it('reports a pre-command flag that belongs to another command', async () => {
|
|
await main(['--workspace', 'worktree', 'list'], '/tmp/repo')
|
|
|
|
expect(process.exitCode).toBe(1)
|
|
const stderr = errorSpy.mock.calls.map((call) => String(call[0])).join('\n')
|
|
expect(stderr).toContain('Unknown flag --workspace for command: worktree list')
|
|
})
|
|
|
|
it('reports a pre-command typo when a global flag splits the command path', async () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(['--jso', 'worktree', '--json', 'list'], '/tmp/repo')
|
|
|
|
expect(process.exitCode).toBe(1)
|
|
expect(logSpy.mock.calls.flat().join('\n')).toContain(
|
|
'Unknown flag --jso for command: worktree list'
|
|
)
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
logSpy.mockRestore()
|
|
})
|
|
|
|
it.each(['environment', 'pairing-code'])(
|
|
'rejects --%s without a selector before runtime construction',
|
|
async (flag) => {
|
|
runtimeClientConstructorMock.mockClear()
|
|
|
|
await main([`--${flag}`, 'worktree', 'list'], '/tmp/repo')
|
|
|
|
expect(process.exitCode).toBe(1)
|
|
const stderr = errorSpy.mock.calls.map((call) => String(call[0])).join('\n')
|
|
expect(stderr).toContain(`Flag --${flag} requires a value.`)
|
|
expect(runtimeClientConstructorMock).not.toHaveBeenCalled()
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
}
|
|
)
|
|
})
|
|
|
|
describe('unknown help command surfaces a suggestion', () => {
|
|
it.each([
|
|
['help prefix', ['help', 'worktree', 'remov']],
|
|
['help flag', ['worktree', 'remov', '--help']]
|
|
])('prints did-you-mean for the %s form', async (_label, argv) => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(argv, '/tmp/repo')
|
|
|
|
expect(process.exitCode).toBe(1)
|
|
expect(logSpy.mock.calls.flat().join('\n')).toContain('Did you mean: orca worktree')
|
|
logSpy.mockRestore()
|
|
process.exitCode = 0
|
|
})
|
|
})
|
|
|
|
describe('nested command group help', () => {
|
|
it.each([
|
|
['browser', ['browser'], ['identity get', 'identity set']],
|
|
['browser identity', ['browser', 'identity'], ['get', 'set']]
|
|
])(
|
|
'prints successful help for %s without constructing a runtime client',
|
|
async (_, path, commands) => {
|
|
const previousExitCode = process.exitCode
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
runtimeClientConstructorMock.mockClear()
|
|
process.exitCode = 0
|
|
|
|
try {
|
|
await main([...path, '--help'], '/tmp/repo')
|
|
|
|
expect(process.exitCode).toBe(0)
|
|
const output = logSpy.mock.calls.flat().join('\n')
|
|
expect(output).toContain(`orca ${path.join(' ')}`)
|
|
for (const command of commands) {
|
|
expect(output).toContain(command)
|
|
}
|
|
expect(output).not.toContain('Unknown command')
|
|
expect(runtimeClientConstructorMock).not.toHaveBeenCalled()
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
} finally {
|
|
process.exitCode = previousExitCode
|
|
logSpy.mockRestore()
|
|
}
|
|
}
|
|
)
|
|
})
|
|
|
|
describe('orca root help', () => {
|
|
it('advertises machine-readable agent discovery', async () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main([], '/tmp/repo')
|
|
|
|
expect(logSpy.mock.calls.flat().join('\n')).toContain('agent-context')
|
|
logSpy.mockRestore()
|
|
})
|
|
|
|
it('advertises host-local account management', async () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main([], '/tmp/repo')
|
|
|
|
expect(logSpy.mock.calls.flat().join('\n')).toContain(
|
|
'account add Add a managed Claude or Codex account on this Orca host'
|
|
)
|
|
expect(logSpy.mock.calls.flat().join('\n')).toContain(
|
|
'account list List managed Claude and Codex accounts on this Orca host'
|
|
)
|
|
logSpy.mockRestore()
|
|
})
|
|
|
|
it('labels retired coordinator scheduler commands at the root', async () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(['--help'], '/tmp/repo')
|
|
|
|
const output = String(logSpy.mock.calls[0]?.[0])
|
|
expect(output).toContain(
|
|
'orchestration coordinator-start Retired: load the current orchestration skill'
|
|
)
|
|
expect(output).toContain(
|
|
'orchestration coordinator-stop Retired: load the current orchestration skill'
|
|
)
|
|
expect(output).not.toContain('Start the legacy automatic coordinator loop')
|
|
expect(output).not.toContain('Stop the legacy automatic coordinator loop')
|
|
logSpy.mockRestore()
|
|
})
|
|
|
|
it('advertises computer-use capabilities discovery', async () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(['--help'], '/tmp/repo')
|
|
|
|
expect(logSpy.mock.calls[0][0]).toContain(
|
|
'computer capabilities Show computer-use provider capabilities'
|
|
)
|
|
expect(logSpy.mock.calls[0][0]).toContain(
|
|
'computer permissions Show or open computer-use permission setup'
|
|
)
|
|
expect(logSpy.mock.calls[0][0]).toContain(
|
|
'computer press-key Press a single key such as Return or Escape'
|
|
)
|
|
expect(logSpy.mock.calls[0][0]).toContain(
|
|
'project setup-existing-folder Make a project available on a host by importing an existing folder'
|
|
)
|
|
expect(logSpy.mock.calls[0][0]).toContain(
|
|
'project setup-create Create independent project host setup metadata'
|
|
)
|
|
expect(logSpy.mock.calls[0][0]).toContain(
|
|
'project setup-update Update project host setup metadata'
|
|
)
|
|
expect(logSpy.mock.calls[0][0]).toContain(
|
|
'project setup-delete Remove a project host setup'
|
|
)
|
|
expect(logSpy.mock.calls[0][0]).toContain('Agent Sessions And Worktrees:')
|
|
expect(logSpy.mock.calls[0][0]).toContain(
|
|
'`worktree create --agent` creates a new checkout with an agent.'
|
|
)
|
|
expect(logSpy.mock.calls[0][0]).toContain(
|
|
'orca terminal create --worktree active --command "codex"'
|
|
)
|
|
expect(logSpy.mock.calls[0][0]).toContain(
|
|
'orchestration worker-start Start a supervised worker locally or on a connected Orca server'
|
|
)
|
|
expect(logSpy.mock.calls[0][0]).toContain(
|
|
'orchestration ask Ask the coordinator a blocking question'
|
|
)
|
|
expect(logSpy.mock.calls[0][0]).toContain(
|
|
'orchestration worker-abandon Fence an uncertain worker without claiming it stopped'
|
|
)
|
|
expect(logSpy.mock.calls[0][0]).toContain(
|
|
"orchestration worker-release Release a settled worker's terminal after archiving its output"
|
|
)
|
|
expect(logSpy.mock.calls[0][0]).toContain(
|
|
'orchestration worker-retain Keep a worker terminal live for debugging'
|
|
)
|
|
expect(logSpy.mock.calls[0][0]).toContain(
|
|
'orchestration worker-list Report worker terminal resource accounting'
|
|
)
|
|
expect(logSpy.mock.calls[0][0]).not.toContain('orchestration worker-cleanup')
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('progressively discloses Linear commands', async () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
|
|
await main(['--help'], '/tmp/repo')
|
|
|
|
const rootHelp = String(logSpy.mock.calls[0][0])
|
|
expect(rootHelp).toContain('Linear:')
|
|
expect(rootHelp).toContain('linear Read and write Linear issues for agents')
|
|
expect(rootHelp).not.toContain('linear issue')
|
|
expect(rootHelp).not.toContain('linear search')
|
|
|
|
logSpy.mockClear()
|
|
await main(['linear', '--help'], '/tmp/repo')
|
|
|
|
const groupHelp = String(logSpy.mock.calls[0][0])
|
|
expect(groupHelp).toContain('orca linear')
|
|
expect(groupHelp).toContain('issue')
|
|
expect(groupHelp).toContain('search')
|
|
expect(groupHelp).not.toContain('--comments')
|
|
expect(groupHelp).not.toContain('--attachments')
|
|
|
|
logSpy.mockClear()
|
|
await main(['linear', 'issue', '--help'], '/tmp/repo')
|
|
|
|
const issueHelp = String(logSpy.mock.calls[0][0])
|
|
expect(issueHelp).toContain('orca linear issue [<id>]')
|
|
expect(issueHelp).toContain('--comments Include threaded Linear comments')
|
|
expect(issueHelp).toContain('--attachments Include attachment metadata and URLs')
|
|
expect(issueHelp).toContain('--activity Include issue field-change history')
|
|
expect(issueHelp).toContain('--workspace <id> Connected Linear workspace id')
|
|
expect(issueHelp).toContain('--id <id> Linear issue key, id, or URL')
|
|
|
|
logSpy.mockClear()
|
|
await main(['linear', 'search', '--help'], '/tmp/repo')
|
|
|
|
const searchHelp = String(logSpy.mock.calls[0][0])
|
|
expect(searchHelp).toContain('orca linear search <query>')
|
|
expect(searchHelp).toContain('--workspace <id|all> Connected Linear workspace id, or all')
|
|
expect(searchHelp).toContain('--query <text> Text to search across Linear issues')
|
|
|
|
logSpy.mockClear()
|
|
await main(['linear', 'list-issues', '--help'], '/tmp/repo')
|
|
|
|
const listIssuesHelp = String(logSpy.mock.calls[0][0])
|
|
expect(listIssuesHelp).toContain(
|
|
'--cursor <cursor> Opaque cursor from a previous list-issues page; issued cursors bind the workspace, raw Linear cursors need --workspace'
|
|
)
|
|
expect(listIssuesHelp).toContain('--workspace <id|all> Connected Linear workspace id, or all')
|
|
expect(listIssuesHelp).toContain('0=none, 1=urgent, 2=high, 3=medium, 4=low')
|
|
expect(listIssuesHelp).toContain(
|
|
'--limit <n> Max issues to return; omit to return every match'
|
|
)
|
|
expect(listIssuesHelp).not.toContain('Line cursor from a previous read')
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('documents the machine-readable terminal topology opt-in', async () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
logSpy.mockClear()
|
|
|
|
await main(['terminal', 'list', '--help'], '/tmp/repo')
|
|
|
|
const help = String(logSpy.mock.calls[0][0])
|
|
expect(help).toContain('[--include-visual-layouts] [--json]')
|
|
expect(help).toContain('--include-visual-layouts Include tab and pane topology in JSON output')
|
|
expect(help).toContain('JSON omits visualLayouts by default')
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('describes worker-read cursors as opaque', async () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
logSpy.mockClear()
|
|
|
|
await main(['orchestration', 'worker-read', '--help'], '/tmp/repo')
|
|
|
|
const help = String(logSpy.mock.calls[0][0])
|
|
expect(help).toContain(
|
|
'--cursor <cursor> Opaque cursor returned by a previous worker-read page'
|
|
)
|
|
expect(help).not.toContain('Line cursor from a previous read')
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('describes worker-list cursors as opaque page cursors', async () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
logSpy.mockClear()
|
|
|
|
await main(['orchestration', 'worker-list', '--help'], '/tmp/repo')
|
|
|
|
const help = String(logSpy.mock.calls[0][0])
|
|
expect(help).toContain('[--cursor <cursor>]')
|
|
expect(help).toContain('--cursor <cursor> Opaque page cursor copied from page.nextCursor')
|
|
expect(help).toContain('Continue with the opaque page.nextCursor value unchanged.')
|
|
expect(help).not.toContain('--cursor <dispatch_id>')
|
|
expect(help).not.toContain('Line cursor from a previous read')
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('advertises Linear issue linking on worktree create and set help', async () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
logSpy.mockClear()
|
|
|
|
await main(['worktree', 'create', '--help'], '/tmp/repo')
|
|
|
|
expect(String(logSpy.mock.calls[0][0])).toContain('--linear-issue <identifier-or-url>')
|
|
|
|
logSpy.mockClear()
|
|
await main(['worktree', 'set', '--help'], '/tmp/repo')
|
|
|
|
const setHelp = String(logSpy.mock.calls[0][0])
|
|
expect(setHelp).toContain('--linear-issue <identifier-or-url|null>')
|
|
expect(setHelp).toContain('--linear-issue <id|url|null> Linked Linear issue identifier or URL')
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('advertises explicit orchestration task display labels', async () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
logSpy.mockClear()
|
|
|
|
await main(['orchestration', 'task-create', '--help'], '/tmp/repo')
|
|
|
|
const help = String(logSpy.mock.calls[0][0])
|
|
expect(help).toContain('[--task-title <text>] [--display-name <text>]')
|
|
expect(help).toContain('--task-title <text> Concise title for the orchestration task')
|
|
expect(help).toContain('--display-name <text> UI label shown for dispatched worker rows')
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('hides removed parent-workspace help and scopes create parent selectors', async () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
logSpy.mockClear()
|
|
|
|
await main(['--help'], '/tmp/repo')
|
|
|
|
const rootHelp = String(logSpy.mock.calls[0][0])
|
|
expect(rootHelp).not.toContain('--parent-workspace')
|
|
expect(rootHelp).toContain('[--parent-worktree <selector>] [--no-parent]')
|
|
expect(rootHelp).toContain('identity:<identity>')
|
|
|
|
logSpy.mockClear()
|
|
await main(['worktree', 'create', '--help'], '/tmp/repo')
|
|
|
|
const createHelp = String(logSpy.mock.calls[0][0])
|
|
expect(createHelp).not.toContain('--parent-workspace')
|
|
expect(createHelp).not.toContain('checkout/workspace')
|
|
expect(createHelp).not.toContain('caller workspace')
|
|
expect(createHelp).not.toContain('current workspace')
|
|
expect(createHelp).not.toContain('active Orca workspace')
|
|
expect(createHelp).not.toContain('folderWorkspaceId')
|
|
expect(createHelp).toContain('folder:<id>')
|
|
expect(createHelp).toContain('folder:<folderId>')
|
|
expect(createHelp).toContain('worktree:<worktreeId>')
|
|
expect(createHelp).toContain(
|
|
'--no-parent only affects Orca lineage; omit --base-branch to use the repo default base'
|
|
)
|
|
|
|
logSpy.mockClear()
|
|
await main(['worktree', 'set', '--help'], '/tmp/repo')
|
|
|
|
const setHelp = String(logSpy.mock.calls[0][0])
|
|
expect(setHelp).not.toContain('--parent-workspace')
|
|
expect(setHelp).not.toContain('folder:<id>')
|
|
expect(formatFlagHelp('parent-worktree')).toContain('identity:<identity>')
|
|
expect(setHelp).not.toContain('worktree:<id>')
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('distinguishes new worktrees from fresh agent terminals in command help', async () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
logSpy.mockClear()
|
|
|
|
await main(['worktree', 'create', '--help'], '/tmp/repo')
|
|
|
|
expect(String(logSpy.mock.calls[0][0])).toContain('This creates a new checkout.')
|
|
expect(String(logSpy.mock.calls[0][0])).toContain(
|
|
'orca terminal create --worktree active --command "codex"'
|
|
)
|
|
|
|
logSpy.mockClear()
|
|
await main(['terminal', 'create', '--help'], '/tmp/repo')
|
|
|
|
const terminalHelp = String(logSpy.mock.calls[0][0])
|
|
expect(terminalHelp).toContain('Use this, not worktree create')
|
|
expect(terminalHelp).toContain(
|
|
'orca terminal create --worktree active --command "codex" --json'
|
|
)
|
|
expect(callMock).not.toHaveBeenCalled()
|
|
})
|
|
})
|