Add read-only orca linear CLI with trusted launch-prompt pointer (V1) (#5126)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson
2026-06-10 20:20:50 -07:00
committed by GitHub
co-authored by Orca
parent 519cc5d1c3
commit cdc0ca5e53
68 changed files with 4379 additions and 283 deletions
+2
View File
@@ -332,6 +332,8 @@ describe('mergeWorktree', () => {
linkedIssue: 42,
linkedPR: 10,
linkedLinearIssue: null,
linkedLinearIssueWorkspaceId: null,
linkedLinearIssueOrganizationUrlKey: null,
linkedGitLabMR: null,
linkedGitLabIssue: null,
isArchived: true,
+2
View File
@@ -290,6 +290,8 @@ export function mergeWorktree(
linkedIssue: meta?.linkedIssue ?? null,
linkedPR: meta?.linkedPR ?? null,
linkedLinearIssue: meta?.linkedLinearIssue ?? null,
linkedLinearIssueWorkspaceId: meta?.linkedLinearIssueWorkspaceId ?? null,
linkedLinearIssueOrganizationUrlKey: meta?.linkedLinearIssueOrganizationUrlKey ?? null,
linkedGitLabMR: meta?.linkedGitLabMR ?? null,
linkedGitLabIssue: meta?.linkedGitLabIssue ?? null,
isArchived: meta?.isArchived ?? false,
+12
View File
@@ -1452,6 +1452,12 @@ export async function createRemoteWorktree(
...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}),
...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}),
...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}),
...(args.linkedLinearIssueWorkspaceId !== undefined
? { linkedLinearIssueWorkspaceId: args.linkedLinearIssueWorkspaceId }
: {}),
...(args.linkedLinearIssueOrganizationUrlKey !== undefined
? { linkedLinearIssueOrganizationUrlKey: args.linkedLinearIssueOrganizationUrlKey }
: {}),
...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}),
...(args.linkedGitLabIssue !== undefined ? { linkedGitLabIssue: args.linkedGitLabIssue } : {}),
...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}),
@@ -1948,6 +1954,12 @@ export async function createLocalWorktree(
...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}),
...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}),
...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}),
...(args.linkedLinearIssueWorkspaceId !== undefined
? { linkedLinearIssueWorkspaceId: args.linkedLinearIssueWorkspaceId }
: {}),
...(args.linkedLinearIssueOrganizationUrlKey !== undefined
? { linkedLinearIssueOrganizationUrlKey: args.linkedLinearIssueOrganizationUrlKey }
: {}),
...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}),
...(args.linkedGitLabIssue !== undefined ? { linkedGitLabIssue: args.linkedGitLabIssue } : {}),
...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}),
+8
View File
@@ -507,6 +507,8 @@ function mergeFolderWorkspace(repo: Repo, worktreeId: string, meta: WorktreeMeta
linkedIssue: meta.linkedIssue ?? null,
linkedPR: meta.linkedPR ?? null,
linkedLinearIssue: meta.linkedLinearIssue ?? null,
linkedLinearIssueWorkspaceId: meta.linkedLinearIssueWorkspaceId ?? null,
linkedLinearIssueOrganizationUrlKey: meta.linkedLinearIssueOrganizationUrlKey ?? null,
linkedGitLabMR: meta.linkedGitLabMR ?? null,
linkedGitLabIssue: meta.linkedGitLabIssue ?? null,
isArchived: meta.isArchived ?? false,
@@ -596,6 +598,12 @@ function createFolderWorkspace(
...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}),
...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}),
...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}),
...(args.linkedLinearIssueWorkspaceId !== undefined
? { linkedLinearIssueWorkspaceId: args.linkedLinearIssueWorkspaceId }
: {}),
...(args.linkedLinearIssueOrganizationUrlKey !== undefined
? { linkedLinearIssueOrganizationUrlKey: args.linkedLinearIssueOrganizationUrlKey }
: {}),
...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}),
...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {}),
...(args.linkedGitLabIssue !== undefined ? { linkedGitLabIssue: args.linkedGitLabIssue } : {}),
@@ -0,0 +1,345 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LinearClientForWorkspace } from './client'
const getClients = vi.fn()
const getStatus = vi.fn()
const isAuthError = vi.fn()
const clearToken = vi.fn()
vi.mock('./client', () => ({
acquire: vi.fn().mockResolvedValue(undefined),
release: vi.fn(),
getClients: (...args: unknown[]) => getClients(...args),
getStatus: (...args: unknown[]) => getStatus(...args),
isAuthError: (...args: unknown[]) => isAuthError(...args),
clearToken: (...args: unknown[]) => clearToken(...args)
}))
function makeEntry(options: {
workspaceId: string
organizationName: string
rawRequest: ReturnType<typeof vi.fn>
}): LinearClientForWorkspace {
return {
workspace: {
id: options.workspaceId,
organizationId: options.workspaceId,
organizationName: options.organizationName,
displayName: 'Brennan',
email: 'brennan@example.com'
},
client: {
client: { rawRequest: options.rawRequest }
}
} as unknown as LinearClientForWorkspace
}
function rawIssue(identifier: string) {
return {
id: `${identifier}-id`,
identifier,
title: `Title ${identifier}`,
url: `https://linear.app/acme/issue/${identifier}`,
labels: { nodes: [] }
}
}
describe('Linear agent issue context client', () => {
beforeEach(() => {
vi.clearAllMocks()
getStatus.mockReturnValue({ workspaces: [] })
isAuthError.mockReturnValue(false)
vi.spyOn(console, 'warn').mockImplementation(() => {})
})
it('keeps implicit multi-workspace issue reads working when an unrelated workspace fails', async () => {
const failingRequest = vi.fn().mockRejectedValue(new Error('fetch failed'))
const workingRequest = vi.fn().mockResolvedValue({ data: { issue: rawIssue('ENG-123') } })
getClients.mockReturnValue([
makeEntry({
workspaceId: 'workspace-stale',
organizationName: 'Stale',
rawRequest: failingRequest
}),
makeEntry({
workspaceId: 'workspace-good',
organizationName: 'Good',
rawRequest: workingRequest
})
])
const { resolveIssue } = await import('./issue-context-client')
await expect(resolveIssue('ENG-123', {})).resolves.toMatchObject({
issue: { identifier: 'ENG-123' },
workspace: { id: 'workspace-good' }
})
expect(console.warn).toHaveBeenCalledWith(
'[linear] agent issue read failed:',
expect.any(Error)
)
})
it('keeps implicit multi-workspace search working when an unrelated workspace fails', async () => {
const failingRequest = vi.fn().mockRejectedValue(new Error('fetch failed'))
const workingRequest = vi.fn().mockResolvedValue({
data: { searchIssues: { nodes: [rawIssue('ENG-123')] } }
})
getClients.mockReturnValue([
makeEntry({
workspaceId: 'workspace-stale',
organizationName: 'Stale',
rawRequest: failingRequest
}),
makeEntry({
workspaceId: 'workspace-good',
organizationName: 'Good',
rawRequest: workingRequest
})
])
const { searchLinearIssuesForAgents } = await import('./issue-context-client')
await expect(
searchLinearIssuesForAgents({ query: 'auth', workspaceId: 'all' })
).resolves.toMatchObject({
issues: [{ identifier: 'ENG-123', workspace: { id: 'workspace-good' } }],
meta: {
returned: 1,
partial: true,
workspaceErrors: [
{
workspace: { id: 'workspace-stale', name: 'Stale' },
code: 'linear_network_error'
}
]
}
})
})
it('keeps all-workspace search working when one saved credential cannot load', async () => {
const workingRequest = vi.fn().mockResolvedValue({
data: { searchIssues: { nodes: [rawIssue('ENG-123')] } }
})
getStatus.mockReturnValue({
workspaces: [
{
id: 'workspace-stale',
organizationId: 'workspace-stale',
organizationName: 'Stale',
displayName: 'Brennan',
email: 'brennan@example.com'
},
{
id: 'workspace-good',
organizationId: 'workspace-good',
organizationName: 'Good',
displayName: 'Brennan',
email: 'brennan@example.com'
}
]
})
getClients.mockImplementation((workspaceId: string) => {
if (workspaceId === 'workspace-stale') {
throw new Error('Could not decrypt Linear credential')
}
return [
makeEntry({
workspaceId: 'workspace-good',
organizationName: 'Good',
rawRequest: workingRequest
})
]
})
const { searchLinearIssuesForAgents } = await import('./issue-context-client')
await expect(
searchLinearIssuesForAgents({ query: 'auth', workspaceId: 'all' })
).resolves.toMatchObject({
issues: [{ identifier: 'ENG-123', workspace: { id: 'workspace-good' } }],
meta: {
returned: 1,
partial: true,
workspaceErrors: [
{
workspace: { id: 'workspace-stale', name: 'Stale' },
message: 'Could not decrypt Linear credential'
}
]
}
})
})
it('does not report not-found when every successful workspace missed but another failed', async () => {
const failingRequest = vi.fn().mockRejectedValue(new Error('fetch failed'))
const missingRequest = vi.fn().mockResolvedValue({ data: { issue: null } })
getClients.mockReturnValue([
makeEntry({
workspaceId: 'workspace-stale',
organizationName: 'Stale',
rawRequest: failingRequest
}),
makeEntry({
workspaceId: 'workspace-empty',
organizationName: 'Empty',
rawRequest: missingRequest
})
])
const { resolveIssue } = await import('./issue-context-client')
await expect(resolveIssue('ENG-123', {})).rejects.toMatchObject({
code: 'linear_network_error'
})
})
it('preserves hard errors for explicitly selected workspaces', async () => {
const failingRequest = vi.fn().mockRejectedValue(new Error('fetch failed'))
getStatus.mockReturnValue({
workspaces: [
{
id: 'workspace-selected',
organizationId: 'workspace-selected',
organizationName: 'Selected',
displayName: 'Brennan',
email: 'brennan@example.com'
}
]
})
getClients.mockReturnValue([
makeEntry({
workspaceId: 'workspace-selected',
organizationName: 'Selected',
rawRequest: failingRequest
})
])
const { resolveIssue } = await import('./issue-context-client')
await expect(
resolveIssue('ENG-123', { workspaceId: 'workspace-selected' })
).rejects.toMatchObject({ code: 'linear_network_error' })
})
it('normalizes explicit issue workspace credential-load failures', async () => {
getStatus.mockReturnValue({
workspaces: [
{
id: 'workspace-selected',
organizationId: 'workspace-selected',
organizationName: 'Selected',
displayName: 'Brennan',
email: 'brennan@example.com'
}
]
})
getClients.mockImplementation((workspaceId: string) => {
if (workspaceId === 'workspace-selected') {
throw new Error('Could not decrypt Linear credential')
}
return []
})
const { resolveIssue } = await import('./issue-context-client')
await expect(
resolveIssue('ENG-123', { workspaceId: 'workspace-selected' })
).rejects.toMatchObject({
code: 'linear_network_error',
message: 'Could not decrypt Linear credential'
})
})
it('normalizes explicit search workspace credential-load failures', async () => {
getStatus.mockReturnValue({
workspaces: [
{
id: 'workspace-selected',
organizationId: 'workspace-selected',
organizationName: 'Selected',
displayName: 'Brennan',
email: 'brennan@example.com'
}
]
})
getClients.mockImplementation((workspaceId: string) => {
if (workspaceId === 'workspace-selected') {
throw new Error('Could not decrypt Linear credential')
}
return []
})
const { searchLinearIssuesForAgents } = await import('./issue-context-client')
await expect(
searchLinearIssuesForAgents({ query: 'auth', workspaceId: 'workspace-selected' })
).rejects.toMatchObject({
code: 'linear_network_error',
message: 'Could not decrypt Linear credential'
})
})
it('reports an invalid workspace for explicit search workspace typos', async () => {
getStatus.mockReturnValue({
workspaces: [
{
id: 'workspace-selected',
organizationId: 'workspace-selected',
organizationName: 'Selected',
displayName: 'Brennan',
email: 'brennan@example.com'
}
]
})
const { searchLinearIssuesForAgents } = await import('./issue-context-client')
await expect(
searchLinearIssuesForAgents({ query: 'auth', workspaceId: 'workspace-typo' })
).rejects.toMatchObject({
code: 'linear_invalid_workspace'
})
expect(getClients).not.toHaveBeenCalled()
})
it('reports invalid explicit search workspace typos when clients still exist', async () => {
const workingRequest = vi.fn()
getStatus.mockReturnValue({ connected: false, workspaces: [] })
getClients.mockImplementation((workspaceId: string) => {
if (workspaceId === 'all') {
return [
makeEntry({
workspaceId: 'workspace-good',
organizationName: 'Good',
rawRequest: workingRequest
})
]
}
return []
})
const { searchLinearIssuesForAgents } = await import('./issue-context-client')
await expect(
searchLinearIssuesForAgents({ query: 'auth', workspaceId: 'workspace-typo' })
).rejects.toMatchObject({
code: 'linear_invalid_workspace'
})
expect(workingRequest).not.toHaveBeenCalled()
})
it('does not fan out explicit issue workspace typos when clients still exist', async () => {
const workingRequest = vi.fn().mockResolvedValue({ data: { issue: rawIssue('ENG-123') } })
getStatus.mockReturnValue({ connected: false, workspaces: [] })
getClients.mockImplementation((workspaceId: string) => {
if (workspaceId === 'all') {
return [
makeEntry({
workspaceId: 'workspace-good',
organizationName: 'Good',
rawRequest: workingRequest
})
]
}
return []
})
const { resolveIssue } = await import('./issue-context-client')
await expect(resolveIssue('ENG-123', { workspaceId: 'workspace-typo' })).rejects.toMatchObject({
code: 'linear_invalid_workspace'
})
expect(workingRequest).not.toHaveBeenCalled()
})
})
+302
View File
@@ -0,0 +1,302 @@
import type { LinearSearchIssueSummary, LinearSearchResult } from '../../shared/linear-agent-access'
import { clampLinearSearchLimit } from '../../shared/linear-agent-access'
import type { LinearWorkspace } from '../../shared/types'
import {
acquire,
clearToken,
getClients,
getStatus,
isAuthError,
release,
type LinearClientForWorkspace
} from './client'
import {
ISSUE_QUERY,
SEARCH_QUERY,
mapIssue,
pickSearchIssue,
type RawIssueResponse
} from './issue-context-raw'
import {
LinearAgentAccessError,
classifyLinearError,
linearError,
linearMessage
} from './issue-context-errors'
import {
getFanoutClientEntries,
workspaceFailure,
type WorkspaceReadFailure
} from './issue-context-fanout'
import {
ambiguousWorkspace,
resolveWorkspaceSelector,
unknownWorkspace
} from './issue-context-workspaces'
export type ResolvedIssue = {
issue: ReturnType<typeof mapIssue>
workspace: LinearWorkspace
}
export async function searchLinearIssuesForAgents(args: {
query: string
limit?: number
workspaceId?: string | 'all'
}): Promise<LinearSearchResult> {
const limit = clampLinearSearchLimit(args.limit)
const workspaceId = resolveSearchWorkspaceId(args.workspaceId)
const { entries, failures: entryFailures } =
workspaceId === 'all' ? getFanoutClientEntries() : getExplicitClientEntries(workspaceId)
if (entries.length === 0) {
throwIfExplicitWorkspaceHasConnectedAlternatives(workspaceId)
if (entryFailures[0]) {
throw entryFailures[0].error
}
throw linearError('linear_not_connected', 'Linear is not connected.', {
nextSteps: ['Connect Linear from Orca settings, then retry the search.']
})
}
const perWorkspace = await readSearchWorkspaces(
entries,
args.query,
limit + 1,
workspaceId,
entryFailures
)
const merged = perWorkspace.results
.flat()
.sort((left, right) => Date.parse(right.updatedAt ?? '') - Date.parse(left.updatedAt ?? ''))
const limited = merged.slice(0, limit)
return {
issues: limited,
meta: {
query: args.query,
workspaceId,
limit,
returned: limited.length,
limitReached: merged.length > limit,
partial: perWorkspace.failures.length > 0,
workspaceErrors: perWorkspace.failures.map(({ workspace, code, message }) => ({
workspace,
code,
message
}))
}
}
}
export async function resolveIssue(
identifier: string,
selectors: { workspaceId?: string | null; organizationUrlKey?: string | null }
): Promise<ResolvedIssue> {
const workspace = resolveWorkspaceSelector(selectors, getConnectedWorkspaces())
const selection = workspace?.id ?? selectors.workspaceId ?? 'all'
const { entries, failures: entryFailures } =
selection === 'all' ? getFanoutClientEntries() : getExplicitClientEntries(selection)
if (entries.length === 0) {
throwIfExplicitWorkspaceHasConnectedAlternatives(selection)
if (entryFailures[0]) {
throw entryFailures[0].error
}
throw linearError('linear_not_connected', 'Linear is not connected.', {
nextSteps: ['Connect Linear from Orca settings, then retry the issue read.']
})
}
const results = await readIssueWorkspaces(entries, identifier, selection, entryFailures)
if (results.length === 0) {
throw linearError('linear_issue_not_found', `Linear issue ${identifier} was not found.`)
}
if (results.length > 1) {
throw ambiguousWorkspace(
results.map((result) => result.workspace),
identifier
)
}
return results[0]
}
export const getConnectedWorkspaces = (): LinearWorkspace[] => getStatus().workspaces ?? []
export function getRequiredEntry(workspaceId: string): LinearClientForWorkspace {
const entry = getClients(workspaceId)[0]
if (!entry) {
throw linearError('linear_not_connected', 'Linear is not connected.')
}
return entry
}
function getExplicitClientEntries(workspaceId?: string): {
entries: LinearClientForWorkspace[]
failures: WorkspaceReadFailure[]
} {
try {
return { entries: getClients(workspaceId), failures: [] }
} catch (error) {
if (error instanceof LinearAgentAccessError) {
throw error
}
throw linearError(classifyLinearError(error), linearMessage(error))
}
}
function resolveSearchWorkspaceId(workspaceId?: string | 'all'): string | 'all' | undefined {
if (!workspaceId || workspaceId === 'all') {
return workspaceId
}
return resolveWorkspaceSelector({ workspaceId }, getConnectedWorkspaces())?.id ?? workspaceId
}
function throwIfExplicitWorkspaceHasConnectedAlternatives(workspaceId?: string | 'all'): void {
if (!workspaceId || workspaceId === 'all') {
return
}
try {
if (getClients('all').length > 0) {
throw unknownWorkspace(workspaceId)
}
} catch (error) {
if (error instanceof LinearAgentAccessError && error.code === 'linear_invalid_workspace') {
throw error
}
}
}
export async function withLinearRead<T>(
entry: LinearClientForWorkspace,
read: () => Promise<T>,
selection?: string | 'all'
): Promise<T> {
void selection
await acquire()
try {
return await read()
} catch (error) {
if (isAuthError(error)) {
clearToken(entry.workspace.id)
throw linearError('linear_auth_expired', 'Linear authentication expired.', {
nextSteps: ['Reconnect Linear from Orca settings.']
})
}
throw linearError(classifyLinearError(error), linearMessage(error))
} finally {
release()
}
}
async function readIssueWorkspace(
entry: LinearClientForWorkspace,
identifier: string
): Promise<ResolvedIssue | null> {
const response = await withLinearRead(entry, async () => {
const raw = await entry.client.client.rawRequest<RawIssueResponse, Record<string, unknown>>(
ISSUE_QUERY,
{ id: identifier }
)
return raw.data?.issue ?? null
})
return response ? { issue: mapIssue(response), workspace: entry.workspace } : null
}
async function readIssueWorkspaces(
entries: LinearClientForWorkspace[],
identifier: string,
selection: string | 'all',
initialFailures: WorkspaceReadFailure[] = []
): Promise<ResolvedIssue[]> {
if (selection !== 'all') {
const selected = await readIssueWorkspace(entries[0], identifier)
return selected ? [selected] : []
}
const settled = await Promise.allSettled(
entries.map((entry) => readIssueWorkspace(entry, identifier))
)
const results: ResolvedIssue[] = []
const failures: LinearAgentAccessError[] = initialFailures.map((failure) => failure.error)
for (const result of settled) {
if (result.status === 'fulfilled') {
if (result.value) {
results.push(result.value)
}
continue
}
if (result.reason instanceof LinearAgentAccessError) {
failures.push(result.reason)
}
console.warn('[linear] agent issue read failed:', result.reason)
}
if (results.length === 0 && failures[0]) {
throw failures[0]
}
return results
}
async function readSearchWorkspace(
entry: LinearClientForWorkspace,
query: string,
limit: number,
workspaceId?: string | 'all'
): Promise<LinearSearchIssueSummary[]> {
const response = await withLinearRead(
entry,
async () => {
const raw = await entry.client.client.rawRequest<RawIssueResponse, Record<string, unknown>>(
SEARCH_QUERY,
{ term: query, first: limit }
)
return raw.data?.searchIssues?.nodes ?? []
},
workspaceId
)
return response.map((issue) => ({
...pickSearchIssue(mapIssue(issue)),
workspace: {
id: entry.workspace.id,
name: entry.workspace.organizationName
}
}))
}
async function readSearchWorkspaces(
entries: LinearClientForWorkspace[],
query: string,
limit: number,
workspaceId?: string | 'all',
initialFailures: WorkspaceReadFailure[] = []
): Promise<{ results: LinearSearchIssueSummary[][]; failures: WorkspaceReadFailure[] }> {
if (workspaceId && workspaceId !== 'all') {
return {
results: [await readSearchWorkspace(entries[0], query, limit, workspaceId)],
failures: []
}
}
const settled = await Promise.allSettled(
entries.map(async (entry) => readSearchWorkspace(entry, query, limit, workspaceId))
)
const attemptedWorkspaceCount = entries.length + initialFailures.length
const results: LinearSearchIssueSummary[][] = []
const failures: WorkspaceReadFailure[] = [...initialFailures]
for (let index = 0; index < settled.length; index += 1) {
const result = settled[index]
if (result.status === 'fulfilled') {
results.push(result.value)
continue
}
if (result.reason instanceof LinearAgentAccessError) {
failures.push(workspaceFailure(entries[index].workspace, result.reason))
}
console.warn('[linear] agent search failed:', result.reason)
}
if (results.length === 0 && failures.length === attemptedWorkspaceCount && failures[0]) {
throw failures[0].error
}
return { results, failures }
}
+53
View File
@@ -0,0 +1,53 @@
import { parseLinearIssueInput } from '../../shared/linear-links'
import { getConnectedWorkspaces } from './issue-context-client'
import { linearError } from './issue-context-errors'
export type CurrentIssueLink = {
identifier: string
workspaceId?: string | null
organizationUrlKey?: string | null
worktreeId?: string
worktreePath?: string
backfill?: {
workspaceId?: string | null
organizationUrlKey?: string | null
}
}
export function getLinearCurrentIssueFromWorktree(worktree: {
id: string
path: string
linkedLinearIssue?: string | null
linkedLinearIssueWorkspaceId?: string | null
linkedLinearIssueOrganizationUrlKey?: string | null
}): CurrentIssueLink {
const linked = worktree.linkedLinearIssue?.trim()
if (!linked) {
throw linearError('linear_no_linked_issue', 'The current worktree is not linked to Linear.', {
nextSteps: ['Open a Linear-linked worktree or pass an explicit issue id.']
})
}
const parsed = parseLinearIssueInput(linked)
return {
identifier: parsed?.identifier ?? linked.toUpperCase(),
workspaceId: worktree.linkedLinearIssueWorkspaceId,
organizationUrlKey:
worktree.linkedLinearIssueOrganizationUrlKey ?? parsed?.organizationUrlKey ?? null,
worktreeId: worktree.id,
worktreePath: worktree.path
}
}
export function resolveLegacyLinearLinkWorkspace(identifier: string): CurrentIssueLink['backfill'] {
const parsed = parseLinearIssueInput(identifier)
const organizationUrlKey = parsed?.organizationUrlKey
if (!organizationUrlKey) {
return undefined
}
const matches = getConnectedWorkspaces().filter(
(workspace) => workspace.organizationUrlKey === organizationUrlKey
)
return matches.length === 1
? { workspaceId: matches[0].id, organizationUrlKey }
: { organizationUrlKey }
}
+62
View File
@@ -0,0 +1,62 @@
import type { LinearErrorCode, LinearIncludeErrorCode } from '../../shared/linear-agent-access'
export class LinearAgentAccessError extends Error {
readonly code: LinearErrorCode
readonly data?: unknown
constructor(code: LinearErrorCode, message: string, data?: unknown) {
super(message)
this.name = 'LinearAgentAccessError'
this.code = code
this.data = data
}
}
export function linearError(
code: LinearErrorCode,
message: string,
data?: unknown
): LinearAgentAccessError {
return new LinearAgentAccessError(code, message, data)
}
export function includeErrorCode(error: unknown): LinearIncludeErrorCode {
if (error instanceof LinearAgentAccessError) {
if (
error.code === 'linear_timeout' ||
error.code === 'linear_rate_limited' ||
error.code === 'linear_permission_denied' ||
error.code === 'linear_auth_expired' ||
error.code === 'linear_network_error'
) {
return error.code
}
}
return 'linear_include_failed'
}
export function classifyLinearError(error: unknown): LinearErrorCode {
const message = linearMessage(error).toLowerCase()
if (message.includes('rate limit') || message.includes('429')) {
return 'linear_rate_limited'
}
if (message.includes('timeout') || message.includes('timed out')) {
return 'linear_timeout'
}
if (message.includes('permission') || message.includes('forbidden') || message.includes('403')) {
return 'linear_permission_denied'
}
if (
message.includes('network') ||
message.includes('econnreset') ||
message.includes('enotfound') ||
message.includes('fetch failed')
) {
return 'linear_network_error'
}
return 'linear_network_error'
}
export function linearMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
+64
View File
@@ -0,0 +1,64 @@
import type { LinearErrorCode, LinearWorkspaceCandidate } from '../../shared/linear-agent-access'
import type { LinearWorkspace } from '../../shared/types'
import { getClients, getStatus, type LinearClientForWorkspace } from './client'
import {
LinearAgentAccessError,
classifyLinearError,
linearError,
linearMessage
} from './issue-context-errors'
export type WorkspaceReadFailure = {
workspace: LinearWorkspaceCandidate
code: LinearErrorCode
message: string
error: LinearAgentAccessError
}
export function getFanoutClientEntries(): {
entries: LinearClientForWorkspace[]
failures: WorkspaceReadFailure[]
} {
const workspaces = getStatus().workspaces ?? []
if (workspaces.length === 0) {
return { entries: getClients('all'), failures: [] }
}
const entries: LinearClientForWorkspace[] = []
const failures: WorkspaceReadFailure[] = []
for (const workspace of workspaces) {
try {
const entry = getClients(workspace.id)[0]
if (entry) {
entries.push(entry)
}
} catch (error) {
const failure = workspaceFailure(workspace, toLinearAccessError(error))
failures.push(failure)
console.warn('[linear] agent workspace credential read failed:', error)
}
}
return { entries, failures }
}
export function workspaceFailure(
workspace: LinearWorkspace,
error: LinearAgentAccessError
): WorkspaceReadFailure {
return {
workspace: {
id: workspace.id,
name: workspace.organizationName
},
code: error.code,
message: error.message,
error
}
}
function toLinearAccessError(error: unknown): LinearAgentAccessError {
if (error instanceof LinearAgentAccessError) {
return error
}
return linearError(classifyLinearError(error), linearMessage(error))
}
@@ -0,0 +1,227 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LinearIssueContextResult, LinearIssueRequest } from '../../shared/linear-agent-access'
import type { ResolvedIssue } from './issue-context-client'
import {
ATTACHMENTS_QUERY,
CHILDREN_QUERY,
COMMENTS_QUERY,
RELATIONS_QUERY
} from './issue-context-raw'
const rawRequest = vi.fn()
vi.mock('./issue-context-client', () => ({
getRequiredEntry: () => ({
workspace: {
id: 'workspace-1',
organizationId: 'workspace-1',
organizationName: 'Acme',
displayName: 'Brennan',
email: 'brennan@example.com'
},
client: { client: { rawRequest } }
}),
withLinearRead: async (_entry: unknown, read: () => Promise<unknown>) => read()
}))
function rawChild(index: number) {
return {
id: `child-${index}`,
identifier: `ENG-${index}`,
title: `Child ${index}`,
url: `https://linear.app/acme/issue/ENG-${index}`,
labels: { nodes: [] }
}
}
function rawComment(index: number) {
return {
id: `comment-${index}`,
body: `Comment ${index}`
}
}
function resolvedIssue(): ResolvedIssue {
return {
issue: {
id: 'parent',
identifier: 'ENG-1',
title: 'Parent',
url: 'https://linear.app/acme/issue/ENG-1',
labels: []
},
workspace: {
id: 'workspace-1',
organizationId: 'workspace-1',
organizationName: 'Acme',
displayName: 'Brennan',
email: 'brennan@example.com'
}
}
}
function request(): LinearIssueRequest {
return {
include: { comments: false, children: true, attachments: false, relations: false },
depth: 2
}
}
function requestWithDepth(depth: number): LinearIssueRequest {
return {
...request(),
depth
}
}
function requestWithComments(): LinearIssueRequest {
return {
include: { comments: true, children: false, attachments: false, relations: false },
depth: 2
}
}
function result(): LinearIssueContextResult {
return {
issue: resolvedIssue().issue,
meta: {
requested: {
current: false,
include: { comments: false, children: true, attachments: false, relations: false },
depth: 2
},
resolved: {
id: 'parent',
identifier: 'ENG-1',
workspaceId: 'workspace-1',
workspaceName: 'Acme'
},
partial: false,
includeErrors: [],
sections: {}
}
}
}
describe('Linear issue context includes', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('declares cursor variables on every paged include query', () => {
for (const query of [COMMENTS_QUERY, CHILDREN_QUERY, ATTACHMENTS_QUERY, RELATIONS_QUERY]) {
expect(query).toContain('$after: String')
expect(query).toContain('after: $after')
}
})
it('does not probe grandchildren when the first child page exhausts the node cap', async () => {
for (let page = 0; page < 4; page += 1) {
rawRequest.mockResolvedValueOnce({
data: {
issue: {
children: {
nodes: Array.from({ length: 50 }, (_, index) => rawChild(page * 50 + index + 1)),
pageInfo: {
hasNextPage: page < 3,
endCursor: page < 3 ? `cursor-${page}` : null
}
}
}
}
})
}
const { readOptionalIncludes } = await import('./issue-context-includes')
const output = result()
await readOptionalIncludes(resolvedIssue(), request(), output, [], output.meta.sections)
expect(output.children).toHaveLength(200)
expect(output.meta.sections.children).toMatchObject({
returned: 200,
cap: 200,
capReached: true,
mayHaveMore: true
})
expect(rawRequest).toHaveBeenCalledTimes(4)
expect(rawRequest.mock.calls[0]?.[1]).toEqual({ id: 'parent', first: 50 })
expect(rawRequest.mock.calls[1]?.[1]).toEqual({
id: 'parent',
first: 50,
after: 'cursor-0'
})
})
it('paginates comments up to the advertised include cap', async () => {
for (let page = 0; page < 3; page += 1) {
rawRequest.mockResolvedValueOnce({
data: {
issue: {
comments: {
nodes: Array.from({ length: 50 }, (_, index) => rawComment(page * 50 + index + 1)),
pageInfo: {
hasNextPage: page < 2,
endCursor: page < 2 ? `comment-cursor-${page}` : null
}
}
}
}
})
}
const { readOptionalIncludes } = await import('./issue-context-includes')
const output = result()
await readOptionalIncludes(
resolvedIssue(),
requestWithComments(),
output,
[],
output.meta.sections
)
expect(output.comments).toHaveLength(150)
expect(output.meta.sections.comments).toMatchObject({
returned: 150,
cap: 500,
capReached: false
})
expect(rawRequest).toHaveBeenCalledTimes(3)
expect(rawRequest.mock.calls[2]?.[1]).toEqual({
id: 'parent',
first: 50,
after: 'comment-cursor-1'
})
})
it('marks section metadata when children are truncated by requested depth', async () => {
rawRequest.mockResolvedValueOnce({
data: {
issue: {
children: {
nodes: [rawChild(1)],
pageInfo: { hasNextPage: false }
}
}
}
})
const { readOptionalIncludes } = await import('./issue-context-includes')
const output = result()
await readOptionalIncludes(
resolvedIssue(),
requestWithDepth(1),
output,
[],
output.meta.sections
)
expect(output.children).toHaveLength(1)
expect(output.children?.[0]?.mayHaveMore).toBe(true)
expect(output.meta.sections.children).toMatchObject({
returned: 1,
capReached: false,
mayHaveMore: true
})
expect(rawRequest).toHaveBeenCalledTimes(1)
})
})
+273
View File
@@ -0,0 +1,273 @@
import type {
LinearCollectionMeta,
LinearIssueAttachment,
LinearIssueChildNode,
LinearIssueCommentNode,
LinearIssueContextResult,
LinearIssueInclude,
LinearIssueRelation,
LinearIssueRequest
} from '../../shared/linear-agent-access'
import {
LINEAR_ATTACHMENTS_CAP,
LINEAR_CHILDREN_NODE_CAP,
LINEAR_COMMENTS_CAP,
LINEAR_COMMENT_BODY_CAP,
LINEAR_RELATIONS_CAP,
clampLinearIssueDepth
} from '../../shared/linear-agent-access'
import type { ResolvedIssue } from './issue-context-client'
import { getRequiredEntry, withLinearRead } from './issue-context-client'
import { includeErrorCode } from './issue-context-errors'
import { readConnectionPages } from './issue-context-pagination'
import {
ATTACHMENTS_QUERY,
CHILDREN_QUERY,
COMMENTS_QUERY,
RELATIONS_QUERY,
collectionMeta,
mapIssue,
type RawAttachmentsResponse,
type RawChildrenResponse,
type RawCommentsResponse,
type RawRelationsResponse
} from './issue-context-raw'
export async function readOptionalIncludes(
resolved: ResolvedIssue,
request: LinearIssueRequest,
result: LinearIssueContextResult,
includeErrors: LinearIssueContextResult['meta']['includeErrors'],
sections: LinearIssueContextResult['meta']['sections']
): Promise<void> {
const includeTasks: [LinearIssueInclude, () => Promise<void>][] = []
if (request.include.comments) {
includeTasks.push(['comments', async () => assignComments(resolved, result, sections)])
}
if (request.include.children) {
includeTasks.push([
'children',
async () => assignChildren(resolved, request.depth, result, sections)
])
}
if (request.include.attachments) {
includeTasks.push(['attachments', async () => assignAttachments(resolved, result, sections)])
}
if (request.include.relations) {
includeTasks.push(['relations', async () => assignRelations(resolved, result, sections)])
}
for (const [include, task] of includeTasks) {
try {
await task()
} catch (error) {
includeErrors.push({
include,
code: includeErrorCode(error),
message: error instanceof Error ? error.message : String(error)
})
}
}
}
async function assignComments(
resolved: ResolvedIssue,
result: LinearIssueContextResult,
sections: LinearIssueContextResult['meta']['sections']
): Promise<void> {
const read = await readComments(resolved)
result.comments = read.items
sections.comments = read.meta
}
async function assignChildren(
resolved: ResolvedIssue,
depth: number,
result: LinearIssueContextResult,
sections: LinearIssueContextResult['meta']['sections']
): Promise<void> {
const read = await readChildren(resolved, clampLinearIssueDepth(depth))
result.children = read.items
sections.children = read.meta
}
async function assignAttachments(
resolved: ResolvedIssue,
result: LinearIssueContextResult,
sections: LinearIssueContextResult['meta']['sections']
): Promise<void> {
const read = await readAttachments(resolved)
result.attachments = read.items
sections.attachments = read.meta
}
async function assignRelations(
resolved: ResolvedIssue,
result: LinearIssueContextResult,
sections: LinearIssueContextResult['meta']['sections']
): Promise<void> {
const read = await readRelations(resolved)
result.relations = read.items
sections.relations = read.meta
}
async function readComments(resolved: ResolvedIssue): Promise<{
items: LinearIssueCommentNode[]
meta: LinearCollectionMeta
}> {
const entry = getRequiredEntry(resolved.workspace.id)
const response = await readConnectionPages(LINEAR_COMMENTS_CAP, async (page) => {
return await withLinearRead(entry, async () => {
const raw = await entry.client.client.rawRequest<
RawCommentsResponse,
Record<string, unknown>
>(COMMENTS_QUERY, { id: resolved.issue.id, ...page })
return raw.data?.issue?.comments ?? null
})
})
const nodes = response.nodes
const items = nodes.slice(0, LINEAR_COMMENTS_CAP).map((comment) => {
const body = comment.body ?? ''
return {
id: comment.id,
body: body.slice(0, LINEAR_COMMENT_BODY_CAP),
bodyTruncated: body.length > LINEAR_COMMENT_BODY_CAP,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
parentId: comment.parent?.id ?? null,
user: comment.user ?? null
}
})
return {
items,
meta: collectionMeta(items.length, LINEAR_COMMENTS_CAP, response.hasMore)
}
}
async function readChildren(
resolved: ResolvedIssue,
depth: number
): Promise<{ items: LinearIssueChildNode[]; meta: LinearCollectionMeta }> {
if (depth <= 0) {
return { items: [], meta: collectionMeta(0, LINEAR_CHILDREN_NODE_CAP, false) }
}
const entry = getRequiredEntry(resolved.workspace.id)
let returned = 0
let capReached = false
let depthReached = false
const readLevel = async (issueId: string, level: number): Promise<LinearIssueChildNode[]> => {
if (level > depth || returned >= LINEAR_CHILDREN_NODE_CAP) {
depthReached = true
return []
}
const remaining = LINEAR_CHILDREN_NODE_CAP - returned
const response = await readConnectionPages(remaining, async (page) => {
return await withLinearRead(entry, async () => {
const raw = await entry.client.client.rawRequest<
RawChildrenResponse,
Record<string, unknown>
>(CHILDREN_QUERY, { id: issueId, ...page })
return raw.data?.issue?.children ?? null
})
})
const nodes = response.nodes
if (response.hasMore || nodes.length > remaining) {
capReached = true
}
const children = nodes.slice(0, remaining).map((node) => {
returned += 1
return { raw: node, child: mapIssue(node) as LinearIssueChildNode }
})
if (returned >= LINEAR_CHILDREN_NODE_CAP) {
capReached = true
}
// Why: when the current level already exhausts the output cap, fetching
// grandchildren would add latency without returning any additional nodes.
const canReadNested = level < depth && returned < LINEAR_CHILDREN_NODE_CAP
if (!canReadNested && level >= depth && children.length > 0) {
depthReached = true
}
const mappedChildren: LinearIssueChildNode[] = []
for (const { raw, child } of children) {
const nested = canReadNested ? await readLevel(raw.id, level + 1) : []
if (nested.length > 0) {
child.children = nested
}
child.mayHaveMore = level >= depth || returned >= LINEAR_CHILDREN_NODE_CAP || response.hasMore
mappedChildren.push(child)
}
return mappedChildren
}
const items = await readLevel(resolved.issue.id, 1)
return {
items,
meta: {
returned,
cap: LINEAR_CHILDREN_NODE_CAP,
capReached,
mayHaveMore: capReached || depthReached
}
}
}
async function readAttachments(
resolved: ResolvedIssue
): Promise<{ items: LinearIssueAttachment[]; meta: LinearCollectionMeta }> {
const entry = getRequiredEntry(resolved.workspace.id)
const response = await readConnectionPages(LINEAR_ATTACHMENTS_CAP, async (page) => {
return await withLinearRead(entry, async () => {
const raw = await entry.client.client.rawRequest<
RawAttachmentsResponse,
Record<string, unknown>
>(ATTACHMENTS_QUERY, { id: resolved.issue.id, ...page })
return raw.data?.issue?.attachments ?? null
})
})
const items = response.nodes.slice(0, LINEAR_ATTACHMENTS_CAP).map((node) => ({
id: node.id,
title: node.title,
url: node.url,
source: node.source,
subtitle: node.subtitle,
createdAt: node.createdAt,
metadataOnly: true as const
}))
return {
items,
meta: collectionMeta(items.length, LINEAR_ATTACHMENTS_CAP, response.hasMore)
}
}
async function readRelations(
resolved: ResolvedIssue
): Promise<{ items: LinearIssueRelation[]; meta: LinearCollectionMeta }> {
const entry = getRequiredEntry(resolved.workspace.id)
const response = await readConnectionPages(LINEAR_RELATIONS_CAP, async (page) => {
return await withLinearRead(entry, async () => {
const raw = await entry.client.client.rawRequest<
RawRelationsResponse,
Record<string, unknown>
>(RELATIONS_QUERY, { id: resolved.issue.id, ...page })
return raw.data?.issue?.relations ?? null
})
})
const items = response.nodes.slice(0, LINEAR_RELATIONS_CAP).map((node) => ({
id: node.id,
type: node.type,
relatedIssue: node.relatedIssue
? {
id: node.relatedIssue.id,
identifier: node.relatedIssue.identifier,
title: node.relatedIssue.title,
url: node.relatedIssue.url
}
: null
}))
return {
items,
meta: collectionMeta(items.length, LINEAR_RELATIONS_CAP, response.hasMore)
}
}
@@ -0,0 +1,38 @@
import { LINEAR_ISSUE_API_PAGE_SIZE_MAX } from '../../shared/linear-issue-read-limits'
export type LinearPageVariables = { first: number; after?: string }
export type LinearConnection<T> = {
nodes?: T[]
pageInfo?: {
hasNextPage?: boolean
endCursor?: string | null
}
} | null
export async function readConnectionPages<T>(
limit: number,
loadConnection: (page: LinearPageVariables) => Promise<LinearConnection<T>>
): Promise<{ nodes: T[]; hasMore: boolean }> {
const nodes: T[] = []
let after: string | undefined
let hasMore = false
while (nodes.length < limit) {
// Why: Linear caps connection page sizes, so the CLI's larger context caps
// must be reached by cursor walking rather than one oversized request.
const first = Math.min(LINEAR_ISSUE_API_PAGE_SIZE_MAX, limit - nodes.length)
const connection = await loadConnection(after ? { first, after } : { first })
const pageNodes = connection?.nodes ?? []
nodes.push(...pageNodes.slice(0, limit - nodes.length))
hasMore = Boolean(connection?.pageInfo?.hasNextPage)
const nextCursor = connection?.pageInfo?.endCursor ?? undefined
if (!hasMore || !nextCursor || nextCursor === after || pageNodes.length === 0) {
break
}
after = nextCursor
}
return { nodes, hasMore }
}
+245
View File
@@ -0,0 +1,245 @@
import type {
LinearCollectionMeta,
LinearIssueSummary,
LinearSearchIssueSummary
} from '../../shared/linear-agent-access'
export type RawIssueResponse = {
issue?: RawIssue | null
searchIssues?: { nodes?: RawIssue[] }
}
export type RawIssue = {
id: string
identifier: string
title: string
url: string
description?: string | null
priority?: number | null
estimate?: number | null
branchName?: string | null
createdAt?: string | null
updatedAt?: string | null
state?: RawNamedEntity | null
team?: (RawNamedEntity & { key?: string | null }) | null
project?: RawNamedEntity | null
cycle?: RawNamedEntity | null
assignee?: RawUser | null
labels?: { nodes?: RawNamedEntity[]; pageInfo?: RawPageInfo } | null
}
export type RawNamedEntity = {
id?: string | null
name?: string | null
color?: string | null
type?: string | null
}
export type RawUser = {
id?: string | null
displayName?: string | null
avatarUrl?: string | null
}
export type RawPageInfo = {
hasNextPage?: boolean
endCursor?: string | null
}
export type RawCommentsResponse = {
issue?: {
comments?: {
nodes?: {
id: string
body?: string | null
createdAt?: string | null
updatedAt?: string | null
parent?: { id?: string | null } | null
user?: RawUser | null
}[]
pageInfo?: RawPageInfo
} | null
} | null
}
export type RawChildrenResponse = {
issue?: {
children?: {
nodes?: RawIssue[]
pageInfo?: RawPageInfo
} | null
} | null
}
export type RawAttachmentsResponse = {
issue?: {
attachments?: {
nodes?: {
id: string
title?: string | null
url?: string | null
source?: string | null
subtitle?: string | null
createdAt?: string | null
}[]
pageInfo?: RawPageInfo
} | null
} | null
}
export type RawRelationsResponse = {
issue?: {
relations?: {
nodes?: {
id: string
type?: string | null
relatedIssue?: RawIssue | null
}[]
pageInfo?: RawPageInfo
} | null
} | null
}
export const ISSUE_FIELDS = `
id
identifier
title
url
description
priority
estimate
branchName
createdAt
updatedAt
state { id name type color }
team { id name key color }
project { id name color }
cycle { id name }
assignee { id displayName avatarUrl }
labels(first: 50) { nodes { id name color } pageInfo { hasNextPage } }
`
export const ISSUE_QUERY = `
query OrcaAgentLinearIssue($id: String!) {
issue(id: $id) {
${ISSUE_FIELDS}
}
}
`
export const SEARCH_QUERY = `
query OrcaAgentLinearSearch($term: String!, $first: Int) {
searchIssues(term: $term, first: $first) {
nodes {
${ISSUE_FIELDS}
}
}
}
`
export const COMMENTS_QUERY = `
query OrcaAgentLinearIssueComments($id: String!, $first: Int, $after: String) {
issue(id: $id) {
comments(first: $first, after: $after) {
nodes {
id
body
createdAt
updatedAt
parent { id }
user { id displayName avatarUrl }
}
pageInfo { hasNextPage endCursor }
}
}
}
`
export const CHILDREN_QUERY = `
query OrcaAgentLinearIssueChildren($id: String!, $first: Int, $after: String) {
issue(id: $id) {
children(first: $first, after: $after) {
nodes {
${ISSUE_FIELDS}
}
pageInfo { hasNextPage endCursor }
}
}
}
`
export const ATTACHMENTS_QUERY = `
query OrcaAgentLinearIssueAttachments($id: String!, $first: Int, $after: String) {
issue(id: $id) {
attachments(first: $first, after: $after) {
nodes { id title url source subtitle createdAt }
pageInfo { hasNextPage endCursor }
}
}
}
`
export const RELATIONS_QUERY = `
query OrcaAgentLinearIssueRelations($id: String!, $first: Int, $after: String) {
issue(id: $id) {
relations(first: $first, after: $after) {
nodes {
id
type
relatedIssue { id identifier title url }
}
pageInfo { hasNextPage endCursor }
}
}
}
`
export function mapIssue(issue: RawIssue): LinearIssueSummary {
return {
id: issue.id,
identifier: issue.identifier,
title: issue.title,
url: issue.url,
description: issue.description,
state: issue.state ?? null,
team: issue.team ?? null,
project: issue.project ?? null,
cycle: issue.cycle ?? null,
assignee: issue.assignee ?? null,
labels: issue.labels?.nodes ?? [],
priority: issue.priority,
estimate: issue.estimate,
branchName: issue.branchName,
createdAt: issue.createdAt,
updatedAt: issue.updatedAt
}
}
export function pickSearchIssue(
issue: LinearIssueSummary
): Omit<LinearSearchIssueSummary, 'workspace'> {
return {
id: issue.id,
identifier: issue.identifier,
title: issue.title,
url: issue.url,
state: issue.state,
team: issue.team,
project: issue.project,
assignee: issue.assignee,
updatedAt: issue.updatedAt
}
}
export function collectionMeta(
returned: number,
cap: number,
hasMore?: boolean
): LinearCollectionMeta {
return {
returned,
cap,
capReached: returned >= cap || hasMore === true,
...(hasMore !== undefined ? { hasMore } : {})
}
}
@@ -0,0 +1,68 @@
import type { LinearWorkspaceCandidate } from '../../shared/linear-agent-access'
import type { LinearWorkspace } from '../../shared/types'
import { linearError } from './issue-context-errors'
export function resolveWorkspaceSelector(
selectors: {
workspaceId?: string | null
organizationUrlKey?: string | null
},
workspaces: LinearWorkspace[]
): LinearWorkspace | null {
if (workspaces.length === 0) {
return null
}
const byId = selectors.workspaceId
? workspaces.find((workspace) => workspace.id === selectors.workspaceId)
: null
const byOrg = selectors.organizationUrlKey
? workspaces.find((workspace) => workspace.organizationUrlKey === selectors.organizationUrlKey)
: null
if (selectors.workspaceId && !byId) {
throw unknownWorkspace(selectors.workspaceId)
}
if (selectors.organizationUrlKey && !byOrg) {
throw linearError(
'linear_invalid_workspace',
`Linear organization ${selectors.organizationUrlKey} is not connected.`,
{
nextSteps: ['Connect that Linear workspace or pass --workspace for a connected workspace.']
}
)
}
if (byId && byOrg && byId.id !== byOrg.id) {
throw linearError('linear_invalid_workspace', 'The issue URL and --workspace do not match.', {
nextSteps: [
`Retry with --workspace ${byOrg.id} or use an issue URL from ${byId.organizationName}.`
]
})
}
return byId ?? byOrg ?? null
}
export function unknownWorkspace(workspaceId: string): ReturnType<typeof linearError> {
return linearError('linear_invalid_workspace', `Unknown Linear workspace ${workspaceId}.`, {
nextSteps: ['Run `orca linear search <query> --workspace all --json` to inspect workspace ids.']
})
}
export function ambiguousWorkspace(
workspaces: LinearWorkspace[],
identifier: string
): ReturnType<typeof linearError> {
const candidates: LinearWorkspaceCandidate[] = workspaces.map((workspace) => ({
id: workspace.id,
name: workspace.organizationName
}))
return linearError(
'linear_workspace_ambiguous',
`Linear issue ${identifier} exists in more than one workspace.`,
{
candidates,
nextSteps: candidates.map(
(candidate) => `Retry with --workspace ${candidate.id} for ${candidate.name}.`
)
}
)
}
+101
View File
@@ -0,0 +1,101 @@
import type {
LinearCurrentIssueContextHints,
LinearIssueContextResult,
LinearIssueRequest
} from '../../shared/linear-agent-access'
import { parseLinearIssueInput } from '../../shared/linear-links'
import {
resolveIssue,
searchLinearIssuesForAgents,
type ResolvedIssue
} from './issue-context-client'
import {
getLinearCurrentIssueFromWorktree,
resolveLegacyLinearLinkWorkspace,
type CurrentIssueLink
} from './issue-context-current'
import { LinearAgentAccessError, linearError } from './issue-context-errors'
import { readOptionalIncludes } from './issue-context-includes'
export {
LinearAgentAccessError,
getLinearCurrentIssueFromWorktree,
resolveLegacyLinearLinkWorkspace,
searchLinearIssuesForAgents
}
export async function readLinearIssueContext(
request: LinearIssueRequest,
resolveCurrent: (context?: LinearCurrentIssueContextHints) => Promise<CurrentIssueLink>
): Promise<LinearIssueContextResult> {
if (request.workspaceId === 'all') {
throw linearError('linear_invalid_workspace', '--workspace all is not valid for issue reads.', {
nextSteps: ['Pass a concrete Linear workspace id or omit --workspace.']
})
}
const parsed = request.input ? parseLinearIssueInput(request.input) : null
if (request.input && !parsed) {
throw linearError('linear_issue_required', 'Pass a Linear issue identifier or issue URL.', {
nextSteps: ['Use a Linear identifier like ENG-123 or a https://linear.app/... issue URL.']
})
}
const currentLink = parsed
? null
: request.current
? await resolveCurrent(request.context)
: await missingIssueInput()
const identifier = parsed?.identifier ?? currentLink?.identifier
if (!identifier) {
throw linearError('linear_issue_required', 'Pass an issue id or use --current.')
}
const resolved = await resolveIssue(identifier, {
workspaceId: request.workspaceId ?? currentLink?.workspaceId ?? undefined,
organizationUrlKey: parsed?.organizationUrlKey ?? currentLink?.organizationUrlKey
})
return buildIssueContextResult(resolved, request, currentLink)
}
async function missingIssueInput(): Promise<CurrentIssueLink> {
throw linearError('linear_issue_required', 'Pass an issue id or use --current.', {
nextSteps: ['Run `orca linear issue ENG-123` or retry from a linked worktree with --current.']
})
}
async function buildIssueContextResult(
resolved: ResolvedIssue,
request: LinearIssueRequest,
currentLink: CurrentIssueLink | null
): Promise<LinearIssueContextResult> {
const includeErrors: LinearIssueContextResult['meta']['includeErrors'] = []
const sections: LinearIssueContextResult['meta']['sections'] = {}
const result: LinearIssueContextResult = {
issue: resolved.issue,
meta: {
requested: {
id: request.input,
current: request.current === true,
workspaceId: request.workspaceId,
include: request.include,
depth: request.depth
},
resolved: {
id: resolved.issue.id,
identifier: resolved.issue.identifier,
workspaceId: resolved.workspace.id,
workspaceName: resolved.workspace.organizationName,
...(currentLink?.worktreeId ? { worktreeId: currentLink.worktreeId } : {}),
...(currentLink?.worktreePath ? { worktreePath: currentLink.worktreePath } : {})
},
partial: false,
includeErrors,
sections
}
}
await readOptionalIncludes(resolved, request, result, includeErrors, sections)
result.meta.partial = includeErrors.length > 0
return result
}
+128
View File
@@ -82,6 +82,10 @@ import type {
} from '../../shared/types'
import type { RuntimeClientEvent } from '../../shared/runtime-client-events'
import { toRuntimeActivateWorktreeEvent } from '../../shared/runtime-client-events'
import type {
LinearCurrentIssueContextHints,
LinearIssueRequest
} from '../../shared/linear-agent-access'
import type { FeatureInteractionId } from '../../shared/feature-interactions'
import type { TerminalPaneSplitSource } from '../../shared/feature-education-telemetry'
import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, splitWorktreeId } from '../../shared/worktree-id'
@@ -306,6 +310,13 @@ import {
updateIssue as updateLinearIssue,
type LinearListFilter
} from '../linear/issues'
import {
LinearAgentAccessError,
getLinearCurrentIssueFromWorktree,
readLinearIssueContext,
resolveLegacyLinearLinkWorkspace,
searchLinearIssuesForAgents
} from '../linear/issue-context'
import {
createProject as createLinearProject,
getCustomView as getLinearCustomView,
@@ -972,6 +983,8 @@ function mergeRuntimeFolderWorkspace(repo: Repo, worktreeId: string, meta: Workt
linkedIssue: meta.linkedIssue ?? null,
linkedPR: meta.linkedPR ?? null,
linkedLinearIssue: meta.linkedLinearIssue ?? null,
linkedLinearIssueWorkspaceId: meta.linkedLinearIssueWorkspaceId ?? null,
linkedLinearIssueOrganizationUrlKey: meta.linkedLinearIssueOrganizationUrlKey ?? null,
linkedGitLabMR: meta.linkedGitLabMR ?? null,
linkedGitLabIssue: meta.linkedGitLabIssue ?? null,
isArchived: meta.isArchived ?? false,
@@ -8919,6 +8932,8 @@ export class OrcaRuntimeService {
linkedIssue?: number | null
linkedPR?: number | null
linkedLinearIssue?: string
linkedLinearIssueWorkspaceId?: string | null
linkedLinearIssueOrganizationUrlKey?: string | null
linkedGitLabMR?: number | null
linkedGitLabIssue?: number | null
comment?: string
@@ -8998,6 +9013,12 @@ export class OrcaRuntimeService {
...(args.linkedLinearIssue !== undefined
? { linkedLinearIssue: args.linkedLinearIssue }
: {}),
...(args.linkedLinearIssueWorkspaceId !== undefined
? { linkedLinearIssueWorkspaceId: args.linkedLinearIssueWorkspaceId }
: {}),
...(args.linkedLinearIssueOrganizationUrlKey !== undefined
? { linkedLinearIssueOrganizationUrlKey: args.linkedLinearIssueOrganizationUrlKey }
: {}),
...(args.linkedGitLabIssue !== undefined
? { linkedGitLabIssue: args.linkedGitLabIssue }
: {}),
@@ -9385,6 +9406,12 @@ export class OrcaRuntimeService {
...(args.linkedLinearIssue !== undefined
? { linkedLinearIssue: args.linkedLinearIssue }
: {}),
...(args.linkedLinearIssueWorkspaceId !== undefined
? { linkedLinearIssueWorkspaceId: args.linkedLinearIssueWorkspaceId }
: {}),
...(args.linkedLinearIssueOrganizationUrlKey !== undefined
? { linkedLinearIssueOrganizationUrlKey: args.linkedLinearIssueOrganizationUrlKey }
: {}),
...(args.linkedGitLabIssue !== undefined
? { linkedGitLabIssue: args.linkedGitLabIssue }
: {}),
@@ -9627,6 +9654,8 @@ export class OrcaRuntimeService {
linkedIssue?: number | null
linkedPR?: number | null
linkedLinearIssue?: string
linkedLinearIssueWorkspaceId?: string | null
linkedLinearIssueOrganizationUrlKey?: string | null
linkedGitLabMR?: number | null
linkedGitLabIssue?: number | null
comment?: string
@@ -9670,6 +9699,12 @@ export class OrcaRuntimeService {
...(args.linkedIssue != null ? { linkedIssue: args.linkedIssue } : {}),
...(args.linkedPR != null ? { linkedPR: args.linkedPR } : {}),
...(args.linkedLinearIssue ? { linkedLinearIssue: args.linkedLinearIssue } : {}),
...(args.linkedLinearIssueWorkspaceId !== undefined
? { linkedLinearIssueWorkspaceId: args.linkedLinearIssueWorkspaceId }
: {}),
...(args.linkedLinearIssueOrganizationUrlKey !== undefined
? { linkedLinearIssueOrganizationUrlKey: args.linkedLinearIssueOrganizationUrlKey }
: {}),
...(args.linkedGitLabMR != null ? { linkedGitLabMR: args.linkedGitLabMR } : {}),
...(args.linkedGitLabIssue != null ? { linkedGitLabIssue: args.linkedGitLabIssue } : {}),
...(args.pushTarget ? { pushTarget: args.pushTarget } : {}),
@@ -14275,6 +14310,99 @@ export class OrcaRuntimeService {
return searchLinearIssues(query, Math.min(Math.max(1, limit), 50), workspaceId)
}
linearSearchForAgents(args: {
query: string
limit?: number
workspaceId?: string | 'all'
}): ReturnType<typeof searchLinearIssuesForAgents> {
return searchLinearIssuesForAgents(args)
}
linearIssueContext(request: LinearIssueRequest): ReturnType<typeof readLinearIssueContext> {
return readLinearIssueContext(request, (context) => this.linearResolveCurrentIssue(context))
}
async linearResolveCurrentIssue(
context?: LinearCurrentIssueContextHints
): Promise<ReturnType<typeof getLinearCurrentIssueFromWorktree>> {
if (!this.store) {
throw new Error('runtime_unavailable')
}
let worktree: ResolvedWorktree | null = null
if (context?.terminalHandle) {
try {
const terminal = await this.showTerminal(context.terminalHandle)
if (context.worktreeId && context.worktreeId !== terminal.worktreeId) {
throw new LinearAgentAccessError(
'linear_permission_denied',
'The provided Linear worktree context does not match the caller terminal.'
)
}
worktree = await this.resolveWorktreeSelector(`id:${terminal.worktreeId}`)
} catch (error) {
if (error instanceof LinearAgentAccessError) {
throw error
}
if (context.remote === true || context.worktreeId) {
throw new LinearAgentAccessError(
'linear_issue_required',
'Could not verify the current Linear-linked worktree.'
)
}
}
}
if (!worktree && context?.remote !== true && context?.cwd) {
worktree = await this.resolveWorktreeForContainedPath(context.cwd)
if (!worktree) {
throw new LinearAgentAccessError(
'linear_issue_required',
'Run --current from inside an Orca-managed worktree or pass an issue id.'
)
}
}
if (!worktree) {
throw new LinearAgentAccessError(
'linear_issue_required',
'Run --current from inside an Orca-managed worktree or pass an issue id.'
)
}
const link = getLinearCurrentIssueFromWorktree(worktree)
if (!link.workspaceId) {
const backfill = resolveLegacyLinearLinkWorkspace(worktree.linkedLinearIssue ?? '')
if (backfill?.workspaceId) {
this.store.setWorktreeMeta(worktree.id, {
linkedLinearIssueWorkspaceId: backfill.workspaceId,
linkedLinearIssueOrganizationUrlKey: backfill.organizationUrlKey ?? null
})
return {
...link,
workspaceId: backfill.workspaceId,
organizationUrlKey: backfill.organizationUrlKey ?? link.organizationUrlKey,
backfill
}
}
}
return link
}
private async resolveWorktreeForContainedPath(cwd: string): Promise<ResolvedWorktree | null> {
const currentPath = resolve(cwd)
let best: ResolvedWorktree | null = null
for (const candidate of await this.listResolvedWorktrees()) {
if (!isPathInsideOrEqual(candidate.path, currentPath)) {
continue
}
if (!best || candidate.path.length > best.path.length) {
best = candidate
}
}
return best
}
linearListIssues(
filter?: LinearListFilter,
limit = 20,
+16
View File
@@ -5,6 +5,7 @@
import type { RpcEnvelopeMeta, RpcFailure, RpcSuccess } from './core'
import { computerUseErrorRecoveryData } from '../../../shared/computer-use-error-recovery'
import { COMPUTER_ERROR_CODES } from '../../../shared/runtime-types'
import { LINEAR_ERROR_CODES } from '../../../shared/linear-agent-access'
export function successResponse(id: string, meta: RpcEnvelopeMeta, result: unknown): RpcSuccess {
return {
@@ -49,6 +50,7 @@ const RUNTIME_PASSTHROUGH_CODES: ReadonlySet<string> = new Set([
])
const COMPUTER_PASSTHROUGH_CODES: ReadonlySet<string> = new Set(Object.values(COMPUTER_ERROR_CODES))
const LINEAR_PASSTHROUGH_CODES: ReadonlySet<string> = new Set(LINEAR_ERROR_CODES)
export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknown): RpcFailure {
const message = error instanceof Error ? error.message : String(error)
@@ -75,6 +77,20 @@ export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknow
(error as { data?: unknown }).data
)
}
if (
error instanceof Error &&
'code' in error &&
typeof (error as { code: unknown }).code === 'string' &&
LINEAR_PASSTHROUGH_CODES.has((error as { code: string }).code)
) {
return errorResponse(
id,
meta,
(error as { code: string }).code,
message,
(error as { data?: unknown }).data
)
}
if (RUNTIME_PASSTHROUGH_CODES.has(message)) {
return errorResponse(id, meta, message, message)
}
+2
View File
@@ -21,6 +21,7 @@ import { GITHUB_METHODS } from './github'
import { GITLAB_METHODS } from './gitlab'
import { HOSTED_REVIEW_METHODS } from './hosted-review'
import { LINEAR_METHODS } from './linear'
import { LINEAR_AGENT_ACCESS_METHODS } from './linear-agent-access'
import { JIRA_METHODS } from './jira'
import { SSH_METHODS } from './ssh'
import { SPEECH_METHODS } from './speech'
@@ -58,6 +59,7 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
...GITLAB_METHODS,
...HOSTED_REVIEW_METHODS,
...LINEAR_METHODS,
...LINEAR_AGENT_ACCESS_METHODS,
...JIRA_METHODS,
...SSH_METHODS,
...SPEECH_METHODS,
@@ -0,0 +1,57 @@
import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
const AgentSearchIssues = z.object({
query: requiredString('Missing query'),
limit: OptionalFiniteNumber,
workspaceId: z.union([z.string(), z.literal('all')]).optional()
})
const LinearIncludeFlags = z.object({
comments: z.boolean(),
children: z.boolean(),
attachments: z.boolean(),
relations: z.boolean()
})
const LinearCurrentContext = z
.object({
worktreeId: OptionalString,
terminalHandle: OptionalString,
cwd: OptionalString,
remote: z.boolean().optional()
})
.optional()
const AgentIssueContext = z.object({
input: OptionalString,
current: z.boolean().optional(),
workspaceId: OptionalString,
include: LinearIncludeFlags,
depth: z.number().int().min(0).max(5),
context: LinearCurrentContext
})
export const LINEAR_AGENT_ACCESS_METHODS: RpcMethod[] = [
defineMethod({
name: 'linear.agentSearchIssues',
params: AgentSearchIssues,
handler: async (params, { runtime }) =>
runtime.linearSearchForAgents({
query: params.query,
limit: params.limit,
workspaceId: params.workspaceId
})
}),
defineMethod({
name: 'linear.issueContext',
params: AgentIssueContext,
handler: async (params, { runtime }) => runtime.linearIssueContext(params)
}),
defineMethod({
name: 'linear.resolveCurrentIssue',
params: LinearCurrentContext,
handler: async (params, { runtime }) => runtime.linearResolveCurrentIssue(params)
})
]
@@ -59,6 +59,8 @@ export const WorktreeCreate = z
linkedIssue: TriStateLinkedIssue,
linkedPR: TriStateLinkedIssue,
linkedLinearIssue: z.string().optional(),
linkedLinearIssueWorkspaceId: z.union([z.string(), z.null()]).optional(),
linkedLinearIssueOrganizationUrlKey: z.union([z.string(), z.null()]).optional(),
linkedGitLabMR: TriStateLinkedIssue,
linkedGitLabIssue: TriStateLinkedIssue,
comment: OptionalString,
+2
View File
@@ -64,6 +64,8 @@ export const WORKTREE_METHODS: RpcMethod[] = [
linkedIssue: params.linkedIssue,
linkedPR: params.linkedPR,
linkedLinearIssue: params.linkedLinearIssue,
linkedLinearIssueWorkspaceId: params.linkedLinearIssueWorkspaceId,
linkedLinearIssueOrganizationUrlKey: params.linkedLinearIssueOrganizationUrlKey,
linkedGitLabMR: params.linkedGitLabMR,
linkedGitLabIssue: params.linkedGitLabIssue,
comment: params.comment,
+3
View File
@@ -224,6 +224,9 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
'linear.getCustomView',
'linear.getIssue',
'linear.getProject',
'linear.agentSearchIssues',
'linear.issueContext',
'linear.resolveCurrentIssue',
'linear.addIssueComment',
'linear.connect',
'linear.createIssue',
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import type { RpcResponse } from '../runtime/rpc/core'
import { formatRemoteCli } from './ssh-remote-cli-format'
const meta = { runtimeId: 'runtime-test' }
describe('formatRemoteCli', () => {
it('falls back to JSON for malformed Linear issue results', () => {
const response: RpcResponse = {
id: 'rpc-1',
ok: true,
_meta: meta,
result: {
issue: {
identifier: 'ENG-123',
title: 'Fix thing',
url: 'https://linear.app/acme/issue/ENG-123',
labels: []
},
meta: {
includeErrors: null,
sections: {}
}
}
}
expect(formatRemoteCli(response)).toEqual({
stdout: `${JSON.stringify(response.result)}\n`,
stderr: ''
})
})
it('falls back to JSON for malformed Linear search results', () => {
const response: RpcResponse = {
id: 'rpc-1',
ok: true,
_meta: meta,
result: {
issues: [],
meta: {
query: 'auth',
returned: '0'
}
}
}
expect(formatRemoteCli(response)).toEqual({
stdout: `${JSON.stringify(response.result)}\n`,
stderr: ''
})
})
})
+139
View File
@@ -0,0 +1,139 @@
import type { LinearIssueContextResult, LinearSearchResult } from '../../shared/linear-agent-access'
import type { CliStatusResult } from '../../shared/runtime-types'
import type { RpcResponse } from '../runtime/rpc/core'
export function formatRemoteCli(response: RpcResponse): { stdout: string; stderr: string } {
if (!response.ok) {
return { stdout: '', stderr: `${response.error.message}\n` }
}
const result = response.result
if (isRecord(result) && 'app' in result && 'runtime' in result && 'graph' in result) {
const record = result as Record<string, unknown>
return formatStatusResult(record as CliStatusResult)
}
if (isLinearIssueContextResult(result)) {
return {
stdout: `${formatLinearIssue(result)}\n`,
stderr: linearIssueWarnings(result)
}
}
if (isLinearSearchResult(result)) {
return {
stdout: `${formatLinearSearch(result)}\n`,
stderr: linearSearchWarnings(result)
}
}
return { stdout: `${JSON.stringify(result)}\n`, stderr: '' }
}
function formatStatusResult(status: CliStatusResult): { stdout: string; stderr: string } {
return {
stdout: `${[
`appRunning: ${status.app.running}`,
`pid: ${status.app.pid ?? 'none'}`,
`runtimeState: ${status.runtime.state}`,
`runtimeReachable: ${status.runtime.reachable}`,
`runtimeId: ${status.runtime.runtimeId ?? 'none'}`,
`graphState: ${status.graph.state}`
].join('\n')}\n`,
stderr: ''
}
}
function isLinearIssueContextResult(result: unknown): result is LinearIssueContextResult {
if (!isRecord(result)) {
return false
}
const issue = result.issue
const meta = result.meta
if (!isRecord(issue) || !isRecord(meta)) {
return false
}
return (
typeof issue.identifier === 'string' &&
typeof issue.title === 'string' &&
typeof issue.url === 'string' &&
Array.isArray(issue.labels) &&
Array.isArray(meta.includeErrors) &&
isRecord(meta.sections)
)
}
function isLinearSearchResult(result: unknown): result is LinearSearchResult {
if (!isRecord(result) || !isRecord(result.meta)) {
return false
}
return (
Array.isArray(result.issues) &&
typeof result.meta.query === 'string' &&
typeof result.meta.returned === 'number'
)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object'
}
function formatLinearIssue(result: LinearIssueContextResult): string {
const issue = result.issue
const lines: string[] = []
lines.push(`${issue.identifier} ${issue.title}`)
lines.push(`URL: ${issue.url}`)
lines.push(`State: ${issue.state?.name ?? 'unknown'}`)
lines.push(`Assignee: ${issue.assignee?.displayName ?? 'unassigned'}`)
lines.push(`Project: ${issue.project?.name ?? 'none'}`)
if (issue.labels.length > 0) {
lines.push(
`Labels: ${issue.labels
.map((label) => label.name)
.filter(Boolean)
.join(', ')}`
)
}
for (const section of ['comments', 'children', 'attachments', 'relations'] as const) {
const meta = result.meta.sections[section]
if (meta) {
lines.push(`${section[0].toUpperCase()}${section.slice(1)}: ${meta.returned}`)
}
}
return lines.join('\n')
}
function formatLinearSearch(result: LinearSearchResult): string {
if (result.issues.length === 0) {
return 'No Linear issues found.'
}
return result.issues
.map((issue) => {
const state = issue.state?.name ?? 'unknown'
const assignee = issue.assignee?.displayName ?? 'unassigned'
return `${issue.identifier.padEnd(10)} ${state.padEnd(14)} ${assignee.padEnd(18)} ${issue.title}`
})
.join('\n')
}
function linearIssueWarnings(result: LinearIssueContextResult): string {
const warnings: string[] = []
for (const error of result.meta.includeErrors) {
warnings.push(`warning: ${error.include} unavailable: ${error.message}`)
}
for (const [name, meta] of Object.entries(result.meta.sections)) {
if (meta?.capReached) {
warnings.push(`warning: ${name} capped at ${meta.returned}/${meta.cap}`)
}
}
return warnings.length > 0 ? `${warnings.join('\n')}\n` : ''
}
function linearSearchWarnings(result: LinearSearchResult): string {
const warnings: string[] = []
if (result.meta.limitReached) {
warnings.push(`warning: showing first ${result.meta.returned} Linear issues`)
}
for (const error of result.meta.workspaceErrors ?? []) {
warnings.push(
`warning: ${error.workspace.name} unavailable for Linear search: ${error.message}`
)
}
return warnings.length > 0 ? `${warnings.join('\n')}\n` : ''
}
+349
View File
@@ -0,0 +1,349 @@
import { describe, expect, it, vi } from 'vitest'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import { runRemoteOrcaCli } from './ssh-remote-orca-cli'
function createRuntime() {
const runtime = {
getRuntimeId: () => 'runtime-test',
getStatus: () => ({
runtimeId: 'runtime-test',
rendererGraphEpoch: 1,
graphStatus: 'ready',
authoritativeWindowId: 1,
liveTabCount: 1,
liveLeafCount: 1
}),
linearIssueContext: vi.fn(async (request: unknown) => ({
request,
issue: {
id: 'issue-1',
identifier: 'ENG-123',
title: 'Fix thing',
url: 'https://linear.app/acme/issue/ENG-123',
labels: []
},
meta: {
requested: {
current: true,
include: { comments: true, children: true, attachments: true, relations: true },
depth: 2
},
resolved: {
id: 'issue-1',
identifier: 'ENG-123',
workspaceId: 'workspace-1',
workspaceName: 'Acme'
},
partial: false,
includeErrors: [],
sections: {}
}
})),
linearSearchForAgents: vi.fn(async (request: unknown) => ({
request,
issues: [],
meta: {
query: 'auth bug',
limit: 5,
returned: 0,
limitReached: false,
partial: false,
workspaceErrors: []
}
}))
} as unknown as OrcaRuntimeService
return runtime
}
describe('runRemoteOrcaCli Linear commands', () => {
it('dispatches Linear issue reads through the remote runtime with SSH context hints', async () => {
const runtime = createRuntime()
const result = await runRemoteOrcaCli(runtime, {
argv: ['linear', 'issue', '--current', '--full', '--json'],
cwd: '/home/alice/remote-repo',
env: {
ORCA_TERMINAL_HANDLE: 'term_ssh',
ORCA_WORKTREE_ID: 'repo::remote'
}
})
expect(result.exitCode).toBe(0)
const payload = JSON.parse(result.stdout) as {
ok: boolean
result: { request: { current: boolean; context: Record<string, unknown> } }
}
expect(payload.ok).toBe(true)
expect(payload.result.request).toMatchObject({
current: true,
include: { comments: true, children: true, attachments: true, relations: true },
context: {
remote: true,
terminalHandle: 'term_ssh',
worktreeId: 'repo::remote'
}
})
expect(payload.result.request.context).not.toHaveProperty('cwd')
})
it('accepts leading boolean flags before SSH Linear commands', async () => {
const runtime = createRuntime()
const result = await runRemoteOrcaCli(runtime, {
argv: ['--json', 'linear', 'issue', 'ENG-123', '--full'],
cwd: '/home/alice/remote-repo',
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
})
expect(result.exitCode).toBe(0)
const payload = JSON.parse(result.stdout) as {
ok: boolean
result: { request: { input: string; include: Record<string, boolean> } }
}
expect(payload.ok).toBe(true)
expect(payload.result.request).toMatchObject({
input: 'ENG-123',
include: { comments: true, children: true, attachments: true, relations: true }
})
})
it('dispatches Linear search positional queries through the remote runtime', async () => {
const runtime = createRuntime()
const result = await runRemoteOrcaCli(runtime, {
argv: ['linear', 'search', 'auth bug', '--limit', '5', '--workspace', 'all', '--json'],
cwd: '/home/alice/remote-repo',
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
})
expect(result.exitCode).toBe(0)
const payload = JSON.parse(result.stdout) as {
ok: boolean
result: { request: { query: string; limit: number; workspaceId: string } }
}
expect(payload.ok).toBe(true)
expect(payload.result.request).toEqual({
query: 'auth bug',
limit: 5,
workspaceId: 'all'
})
})
it('formats SSH Linear issue reads in non-json mode', async () => {
const runtime = createRuntime()
const result = await runRemoteOrcaCli(runtime, {
argv: ['linear', 'issue', '--current'],
cwd: '/home/alice/remote-repo',
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
})
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('ENG-123 Fix thing')
expect(result.stdout).toContain('URL: https://linear.app/acme/issue/ENG-123')
expect(result.stdout).not.toContain('"issue"')
})
it('prints SSH Linear search partial warnings to stderr in non-json mode', async () => {
const runtime = createRuntime()
const linearSearchForAgents = (
runtime as unknown as { linearSearchForAgents: ReturnType<typeof vi.fn> }
).linearSearchForAgents
linearSearchForAgents.mockResolvedValueOnce({
issues: [],
meta: {
query: 'auth',
limit: 20,
returned: 0,
limitReached: false,
partial: true,
workspaceErrors: [
{
workspace: { id: 'workspace-stale', name: 'Stale' },
code: 'linear_network_error',
message: 'fetch failed'
}
]
}
})
const result = await runRemoteOrcaCli(runtime, {
argv: ['linear', 'search', 'auth'],
cwd: '/home/alice/remote-repo',
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
})
expect(result.exitCode).toBe(0)
expect(result.stdout).toBe('No Linear issues found.\n')
expect(result.stderr).toContain('warning: Stale unavailable for Linear search: fetch failed')
})
it('formats older SSH Linear search results without workspaceErrors in non-json mode', async () => {
const runtime = createRuntime()
const linearSearchForAgents = (
runtime as unknown as { linearSearchForAgents: ReturnType<typeof vi.fn> }
).linearSearchForAgents
linearSearchForAgents.mockResolvedValueOnce({
issues: [],
meta: {
query: 'auth',
limit: 20,
returned: 0,
limitReached: false,
partial: false
}
})
const result = await runRemoteOrcaCli(runtime, {
argv: ['linear', 'search', 'auth'],
cwd: '/home/alice/remote-repo',
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
})
expect(result.exitCode).toBe(0)
expect(result.stdout).toBe('No Linear issues found.\n')
expect(result.stderr).toBe('')
})
it('prints SSH Linear non-json failures to stderr instead of stdout', async () => {
const runtime = createRuntime()
const linearIssueContext = (
runtime as unknown as { linearIssueContext: ReturnType<typeof vi.fn> }
).linearIssueContext
linearIssueContext.mockRejectedValueOnce(new Error('Linear is not connected.'))
const result = await runRemoteOrcaCli(runtime, {
argv: ['linear', 'issue', '--current'],
cwd: '/home/alice/remote-repo',
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
})
expect(result.exitCode).toBe(1)
expect(result.stdout).toBe('')
expect(result.stderr).toContain('Linear is not connected.')
})
it('shows SSH Linear command help without dispatching to the runtime', async () => {
const runtime = createRuntime()
const linearIssueContext = (
runtime as unknown as { linearIssueContext: ReturnType<typeof vi.fn> }
).linearIssueContext
const result = await runRemoteOrcaCli(runtime, {
argv: ['linear', 'issue', '--help'],
cwd: '/home/alice/remote-repo',
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
})
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('orca linear issue')
expect(result.stdout).toContain('Usage: orca linear issue')
expect(linearIssueContext).not.toHaveBeenCalled()
})
it('shows SSH Linear group help without dispatching to the runtime', async () => {
const runtime = createRuntime()
const linearIssueContext = (
runtime as unknown as { linearIssueContext: ReturnType<typeof vi.fn> }
).linearIssueContext
const result = await runRemoteOrcaCli(runtime, {
argv: ['linear', '--help'],
cwd: '/home/alice/remote-repo',
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
})
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain('orca linear')
expect(result.stdout).toContain('Usage: orca linear <command> [options]')
expect(result.stdout).toContain('search')
expect(linearIssueContext).not.toHaveBeenCalled()
})
it('shows SSH Linear help through the local help command form', async () => {
const runtime = createRuntime()
const linearIssueContext = (
runtime as unknown as { linearIssueContext: ReturnType<typeof vi.fn> }
).linearIssueContext
const group = await runRemoteOrcaCli(runtime, {
argv: ['help', 'linear'],
cwd: '/home/alice/remote-repo',
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
})
const issue = await runRemoteOrcaCli(runtime, {
argv: ['help', 'linear', 'issue'],
cwd: '/home/alice/remote-repo',
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
})
expect(group.exitCode).toBe(0)
expect(group.stdout).toContain('Usage: orca linear <command> [options]')
expect(issue.exitCode).toBe(0)
expect(issue.stdout).toContain('Usage: orca linear issue')
expect(linearIssueContext).not.toHaveBeenCalled()
})
it('rejects ambiguous Linear issue positional and flag ids in the remote shim', async () => {
const runtime = createRuntime()
const result = await runRemoteOrcaCli(runtime, {
argv: ['linear', 'issue', 'ENG-123', '--id', 'ENG-456', '--json'],
cwd: '/home/alice/remote-repo',
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
})
expect(result.exitCode).toBe(1)
const payload = JSON.parse(result.stdout) as {
ok: boolean
error: { code: string; message: string }
}
expect(payload.ok).toBe(false)
expect(payload.error).toMatchObject({
code: 'invalid_argument',
message: 'Pass --id either positionally or as a flag, not both.'
})
})
it('rejects invalid Linear numeric flags in the remote shim', async () => {
const runtime = createRuntime()
const result = await runRemoteOrcaCli(runtime, {
argv: ['linear', 'search', 'auth', '--limit', 'bad', '--json'],
cwd: '/home/alice/remote-repo',
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
})
expect(result.exitCode).toBe(1)
const payload = JSON.parse(result.stdout) as {
ok: boolean
error: { code: string; message: string }
}
expect(payload.ok).toBe(false)
expect(payload.error).toMatchObject({
code: 'invalid_argument',
message: 'Invalid numeric value for --limit'
})
})
it('preserves Linear-specific JSON error codes for pre-dispatch remote shim validation', async () => {
const runtime = createRuntime()
const result = await runRemoteOrcaCli(runtime, {
argv: ['linear', 'issue', 'ENG-123', '--workspace', 'all', '--json'],
cwd: '/home/alice/remote-repo',
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
})
expect(result.exitCode).toBe(1)
const payload = JSON.parse(result.stdout) as {
ok: boolean
error: { code: string; message: string }
}
expect(payload.ok).toBe(false)
expect(payload.error).toMatchObject({
code: 'linear_invalid_workspace',
message: '--workspace all is not valid for issue'
})
})
})
+321
View File
@@ -0,0 +1,321 @@
import {
LINEAR_CHILDREN_MAX_DEPTH,
clampLinearIssueDepth,
clampLinearSearchLimit,
type LinearIssueInclude
} from '../../shared/linear-agent-access'
import type { RpcDispatcher } from '../runtime/rpc/dispatcher'
import type { RpcResponse } from '../runtime/rpc/core'
type ParsedRemoteCli = {
commandPath: string[]
flags: Map<string, string | boolean>
}
export class RemoteCliArgumentError extends Error {
readonly code: string
constructor(code: string, message: string) {
super(message)
this.name = 'RemoteCliArgumentError'
this.code = code
}
}
export function getRemoteLinearHelp(parsed: ParsedRemoteCli): string | null {
const helpPath = remoteLinearHelpPath(parsed)
if (!helpPath) {
return null
}
if (helpPath.length === 1 && helpPath[0] === 'linear') {
return LINEAR_HELP
}
if (matchesRemoteCommand(helpPath, 'linear', 'issue')) {
return LINEAR_ISSUE_HELP
}
if (matchesRemoteCommand(helpPath, 'linear', 'search')) {
return LINEAR_SEARCH_HELP
}
return null
}
function remoteLinearHelpPath(parsed: ParsedRemoteCli): string[] | null {
if (parsed.commandPath[0] === 'help' && parsed.commandPath[1] === 'linear') {
return parsed.commandPath.slice(1)
}
if (parsed.flags.has('help') && parsed.commandPath[0] === 'linear') {
return parsed.commandPath
}
return null
}
const LINEAR_HELP = `orca linear
Usage: orca linear <command> [options]
Commands:
issue Read Linear issue context for agents
search Search connected Linear workspaces
Run \`orca linear <command> --help\` for command-specific usage.`
const LINEAR_ISSUE_HELP = `orca linear issue
Usage: orca linear issue [<id>] [--current] [--comments] [--children] [--depth <n>] [--attachments] [--relations] [--full] [--workspace <id>] [--json]
Read Linear issue context for agents
Options:
--help Show this help message
--json Emit machine-readable JSON
--pairing-code
--environment
--current Use the current Orca worktree linked Linear issue
--comments Include threaded Linear comments
--children Include recursive child issues
--depth <n> Child issue depth for --children/--full
--attachments Include attachment metadata and URLs
--relations Include blocking, related, and duplicate links
--full Include all supported V1 issue context within caps
--workspace <id> Connected Linear workspace id
--id <id> Linear issue key, id, or URL
Examples:
$ orca linear issue ENG-123
$ orca linear issue --current --comments
$ orca linear issue https://linear.app/acme/issue/ENG-123 --full --json`
const LINEAR_SEARCH_HELP = `orca linear search
Usage: orca linear search <query> [--limit <n>] [--workspace <id>|all] [--json]
Search connected Linear workspaces
Options:
--help Show this help message
--json Emit machine-readable JSON
--pairing-code
--environment
--limit <n> Maximum number of rows to return
--workspace <id|all> Connected Linear workspace id, or all
--query <text> Text to search across Linear issues
Examples:
$ orca linear search "auth bug"
$ orca linear search ENG --workspace all --json`
const LINEAR_ISSUE_FLAGS = new Set([
'help',
'json',
'pairing-code',
'environment',
'current',
'comments',
'children',
'depth',
'attachments',
'relations',
'full',
'workspace',
'id'
])
const LINEAR_SEARCH_FLAGS = new Set([
'help',
'json',
'pairing-code',
'environment',
'limit',
'workspace',
'query'
])
export async function tryDispatchRemoteLinearCli(
dispatcher: RpcDispatcher,
parsed: ParsedRemoteCli,
env: Record<string, string>
): Promise<RpcResponse | null> {
if (isRemoteCommand(parsed, 'linear', 'issue')) {
validateLinearRemoteArgs(parsed, {
command: ['linear', 'issue'],
allowedFlags: LINEAR_ISSUE_FLAGS,
positionalFlag: 'id',
maxPositionals: 1
})
return await call(dispatcher, 'linear.issueContext', buildRemoteLinearIssueRequest(parsed, env))
}
if (isRemoteCommand(parsed, 'linear', 'search')) {
validateLinearRemoteArgs(parsed, {
command: ['linear', 'search'],
allowedFlags: LINEAR_SEARCH_FLAGS,
positionalFlag: 'query',
maxPositionals: 1
})
return await call(dispatcher, 'linear.agentSearchIssues', {
query: remotePositional(parsed, 2) ?? requiredString(parsed.flags, 'query'),
limit: clampLinearSearchLimit(optionalPositiveInteger(parsed.flags, 'limit')),
workspaceId: optionalString(parsed.flags, 'workspace')
})
}
return null
}
function validateLinearRemoteArgs(
parsed: ParsedRemoteCli,
options: {
command: string[]
allowedFlags: ReadonlySet<string>
positionalFlag: string
maxPositionals: number
}
): void {
for (const flag of parsed.flags.keys()) {
if (!options.allowedFlags.has(flag)) {
throw new RemoteCliArgumentError(
'invalid_argument',
`Unknown flag --${flag} for command: ${options.command.join(' ')}`
)
}
}
const positionals = parsed.commandPath.slice(options.command.length)
if (positionals.length > options.maxPositionals) {
throw new RemoteCliArgumentError(
'invalid_argument',
`Unknown command: ${parsed.commandPath.join(' ')}`
)
}
if (positionals.length > 0 && parsed.flags.has(options.positionalFlag)) {
throw new RemoteCliArgumentError(
'invalid_argument',
`Pass --${options.positionalFlag} either positionally or as a flag, not both.`
)
}
}
function isRemoteCommand(parsed: ParsedRemoteCli, ...command: string[]): boolean {
return command.every((part, index) => parsed.commandPath[index] === part)
}
function matchesRemoteCommand(commandPath: string[], ...command: string[]): boolean {
return (
commandPath.length === command.length &&
command.every((part, index) => commandPath[index] === part)
)
}
function remotePositional(parsed: ParsedRemoteCli, startIndex: number): string | undefined {
const value = parsed.commandPath.slice(startIndex).join(' ').trim()
return value || undefined
}
function buildRemoteLinearIssueRequest(
parsed: ParsedRemoteCli,
env: Record<string, string>
): Record<string, unknown> {
const full = parsed.flags.get('full') === true
const include: Record<LinearIssueInclude, boolean> = {
comments: full || parsed.flags.get('comments') === true,
children: full || parsed.flags.get('children') === true,
attachments: full || parsed.flags.get('attachments') === true,
relations: full || parsed.flags.get('relations') === true
}
if (parsed.flags.has('depth') && !include.children) {
throw new RemoteCliArgumentError('invalid_argument', '--depth requires --children or --full')
}
const requestedDepth = optionalNonNegativeInteger(parsed.flags, 'depth')
if (requestedDepth !== undefined && requestedDepth > LINEAR_CHILDREN_MAX_DEPTH) {
throw new RemoteCliArgumentError(
'invalid_argument',
`--depth must be at most ${LINEAR_CHILDREN_MAX_DEPTH}`
)
}
const workspaceId = optionalString(parsed.flags, 'workspace')
if (workspaceId === 'all') {
throw new RemoteCliArgumentError(
'linear_invalid_workspace',
'--workspace all is not valid for issue'
)
}
const input = optionalString(parsed.flags, 'id') ?? remotePositional(parsed, 2)
return {
input,
current: input ? false : parsed.flags.get('current') === true,
workspaceId,
include,
depth: clampLinearIssueDepth(requestedDepth),
context: {
remote: true,
...(env.ORCA_WORKTREE_ID ? { worktreeId: env.ORCA_WORKTREE_ID } : {}),
...(env.ORCA_TERMINAL_HANDLE ? { terminalHandle: env.ORCA_TERMINAL_HANDLE } : {})
}
}
}
async function call(
dispatcher: RpcDispatcher,
method: string,
params?: Record<string, unknown>
): Promise<RpcResponse> {
return await dispatcher.dispatch({
id: `remote-cli-${Date.now()}`,
authToken: 'remote-cli',
method,
params
})
}
function requiredString(flags: Map<string, string | boolean>, name: string): string {
const value = optionalString(flags, name)
if (!value) {
throw new RemoteCliArgumentError('invalid_argument', `Missing --${name}`)
}
return value
}
function optionalString(flags: Map<string, string | boolean>, name: string): string | undefined {
const value = flags.get(name)
return typeof value === 'string' && value.length > 0 ? value : undefined
}
function optionalNumber(flags: Map<string, string | boolean>, name: string): number | undefined {
const value = optionalString(flags, name)
if (value === undefined) {
return undefined
}
const parsed = Number(value)
if (!Number.isFinite(parsed)) {
throw new RemoteCliArgumentError('invalid_argument', `Invalid numeric value for --${name}`)
}
return parsed
}
function optionalPositiveInteger(
flags: Map<string, string | boolean>,
name: string
): number | undefined {
const value = optionalNumber(flags, name)
if (value === undefined) {
return undefined
}
if (!Number.isInteger(value) || value <= 0) {
throw new RemoteCliArgumentError('invalid_argument', `Invalid positive integer for --${name}`)
}
return value
}
function optionalNonNegativeInteger(
flags: Map<string, string | boolean>,
name: string
): number | undefined {
const value = optionalNumber(flags, name)
if (value === undefined) {
return undefined
}
if (!Number.isInteger(value) || value < 0) {
throw new RemoteCliArgumentError(
'invalid_argument',
`Invalid non-negative integer for --${name}`
)
}
return value
}
+32 -1
View File
@@ -54,7 +54,38 @@ describe('runRemoteOrcaCli', () => {
}),
getOrchestrationDb: () => db,
deliverPendingMessagesForHandle: vi.fn(),
notifyMessageArrived: vi.fn()
notifyMessageArrived: vi.fn(),
linearIssueContext: vi.fn(async (request: unknown) => ({
request,
issue: {
id: 'issue-1',
identifier: 'ENG-123',
title: 'Fix thing',
url: 'https://linear.app/acme/issue/ENG-123',
labels: []
},
meta: {
requested: {
current: true,
include: { comments: true, children: true, attachments: true, relations: true },
depth: 2
},
resolved: {
id: 'issue-1',
identifier: 'ENG-123',
workspaceId: 'workspace-1',
workspaceName: 'Acme'
},
partial: false,
includeErrors: [],
sections: {}
}
})),
linearSearchForAgents: vi.fn(async (request: unknown) => ({
request,
issues: [],
meta: { query: 'auth bug', limit: 5, returned: 0, limitReached: false }
}))
} as unknown as OrcaRuntimeService
return { runtime, db }
}
+50 -26
View File
@@ -2,6 +2,12 @@ import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types'
import { RpcDispatcher } from '../runtime/rpc/dispatcher'
import type { RpcResponse } from '../runtime/rpc/core'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import { formatRemoteCli } from './ssh-remote-cli-format'
import {
RemoteCliArgumentError,
getRemoteLinearHelp,
tryDispatchRemoteLinearCli
} from './ssh-remote-linear-cli'
export type RemoteOrcaCliRequest = {
argv: string[]
@@ -20,6 +26,21 @@ type ParsedRemoteCli = {
flags: Map<string, string | boolean>
}
const REMOTE_BOOLEAN_FLAGS = new Set([
'all',
'attachments',
'children',
'comments',
'current',
'full',
'help',
'inject',
'json',
'relations',
'unread',
'wait'
])
export async function runRemoteOrcaCli(
runtime: OrcaRuntimeService,
request: RemoteOrcaCliRequest
@@ -27,19 +48,34 @@ export async function runRemoteOrcaCli(
const dispatcher = new RpcDispatcher({ runtime })
const parsed = parseRemoteCliArgs(request.argv)
const json = parsed.flags.has('json')
const help = getRemoteLinearHelp(parsed)
if (help) {
return { stdout: `${help}\n`, stderr: '', exitCode: 0 }
}
try {
const response = await dispatchRemoteCli(dispatcher, parsed, request.env)
const formatted = json
? { stdout: `${JSON.stringify(response, null, 2)}\n`, stderr: '' }
: formatRemoteCli(response)
return {
stdout: json ? `${JSON.stringify(response, null, 2)}\n` : `${formatRemoteCli(response)}\n`,
stderr: '',
stdout: formatted.stdout,
stderr: formatted.stderr,
exitCode: response.ok ? 0 : 1
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const code =
err instanceof RemoteCliArgumentError
? err.code
: err instanceof Error &&
'code' in err &&
typeof (err as { code: unknown }).code === 'string'
? (err as { code: string }).code
: 'runtime_error'
if (json) {
return {
stdout: `${JSON.stringify(buildLocalError(message), null, 2)}\n`,
stdout: `${JSON.stringify(buildLocalError(message, code), null, 2)}\n`,
stderr: '',
exitCode: 1
}
@@ -54,6 +90,10 @@ async function dispatchRemoteCli(
env: Record<string, string>
): Promise<RpcResponse> {
const command = parsed.commandPath.join(' ')
const linearResponse = await tryDispatchRemoteLinearCli(dispatcher, parsed, env)
if (linearResponse) {
return linearResponse
}
switch (command) {
case 'status': {
const response = await call(dispatcher, 'status.get')
@@ -147,7 +187,7 @@ function parseRemoteCliArgs(argv: string[]): ParsedRemoteCli {
const flag = assignment
const next = argv[i + 1]
if (next && !next.startsWith('--')) {
if (!REMOTE_BOOLEAN_FLAGS.has(flag) && next && !next.startsWith('--')) {
flags.set(flag, next)
i += 1
} else {
@@ -184,33 +224,17 @@ function optionalNumber(flags: Map<string, string | boolean>, name: string): num
return undefined
}
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : undefined
if (!Number.isFinite(parsed)) {
throw new RemoteCliArgumentError('invalid_argument', `Invalid numeric value for --${name}`)
}
return parsed
}
function formatRemoteCli(response: RpcResponse): string {
if (!response.ok) {
return response.error.message
}
const result = response.result as Record<string, unknown>
if ('app' in result && 'runtime' in result && 'graph' in result) {
const status = result as CliStatusResult
return [
`appRunning: ${status.app.running}`,
`pid: ${status.app.pid ?? 'none'}`,
`runtimeState: ${status.runtime.state}`,
`runtimeReachable: ${status.runtime.reachable}`,
`runtimeId: ${status.runtime.runtimeId ?? 'none'}`,
`graphState: ${status.graph.state}`
].join('\n')
}
return JSON.stringify(response.result)
}
function buildLocalError(message: string): RpcResponse {
function buildLocalError(message: string, code = 'runtime_error'): RpcResponse {
return {
id: 'remote-cli-local',
ok: false,
error: { code: 'runtime_error', message },
error: { code, message },
_meta: { runtimeId: 'unknown' }
}
}