Verify Linear page recovery across account and delivery lifetimes

This commit is contained in:
Merge Sim
2026-09-08 00:56:37 -07:00
parent 47f57582e0
commit bdc787da9d
29 changed files with 1104 additions and 126 deletions
@@ -0,0 +1,19 @@
# Linear issue listing and recovery
`orca linear list-issues` keeps the full existing issue projection and bounds each provider page before parsing. It retains one result and admits complete provider pages; a rejected page retries a smaller `first` at the same cursor. Descriptions are not clipped or replaced with references. An omitted limit still walks ordinary small results across pages; the public explicit limit remains 1–250.
Concrete workspace calls use the existing `meta.nextCursor` / `--cursor` continuation. On capable runtimes, the CLI negotiates `linear.status.mcpListPageRecoveryVersion: 1` for `--workspace all` and returns `meta.pageRecovery.continuation`. Resume the same query with `--workspace all --page-recovery <continuation>`. The row limit may change. A recovery vector cannot be combined with a concrete workspace or `--cursor`.
An all-workspace result is a **sorted admitted batch**, not a globally ordered prefix. Each successful page or diagnosed workspace failure rotates scheduling to the next workspace. A failed or unvisited workspace prevents completion. Check `meta.hasMore`, `meta.partial`, and workspace errors; recovery can also appear in a top-level error's `data.pageRecovery`. Both JSON and human CLI output expose it.
Old hosts receive no new parameter when their status does not advertise support. Their producer does not gain these bounds. On new hosts, old concrete callers retain v1 continuation. Old all-workspace callers retain complete small sorted results; **incomplete all-workspace calls now fail explicitly** with `linear_list_concrete_workspace_required`, including a limit 1 query matching two small rows. Restart the original query separately for each connected workspace and reconcile by `(workspace,id)`. This can change scripts' exit status and removes the former clipped preview.
The provider body is limited to 4 MiB decoded transport bytes,250,000 structure tokens and depth 32. Invalid transport UTF-8 is rejected. Full mapped rows contribute at most 896 KiB, reserving room within a 1 MiB direct compact/pretty response for recovery and diagnostics. The complete SSH wrapper is bounded to 2,129,920 bytes before existing E2EE/frame limits. Metadata growth and final serialization are commit gates; a final-envelope failure returns the invocation's input position, since advancing over undelivered rows would lose data.
A valid successful first 1 record exceeding the empty allowance yields `linear_list_record_too_large`. HTTP 200 body/structure overflow yields `linear_list_acquisition_too_large`; it cannot prove that an issue, rather than an unparsed GraphQL error, is oversized. Known HTTP statuses retain auth/permission/rate/provider classification. No arbitrary-size detail escape or automatic skipping is promised.
Each listing reserves 1.125 MiB against a 32 MiB method allowance:28 accepted owners, with the 29th rejected immediately. Each listing has at most one provider page pending or active, sharing the existing four-request limiter. Queued aborts cannot later acquire a slot. Caller timeout can precede actual read/cancel settlement; provider slots and cleanup debt remain charged until settlement, and unsent results remain charged through delivery. The byte accounting includes bounded additional parsing/serialization copies; it is not an RSS ceiling. Four stuck provider slots can exhaust availability.
Every account upsert, including test/reconnect, invalidates in-flight account reads and changes persisted credential revision. All-workspace vectors validate current roster/query/revisions; an ordinary transport reconnect with unchanged persisted revisions does not expire them. Concrete v1 has no new cross-call generation guarantee. There is no TTL or snapshot, and no global secondary tie-order guarantee. Static no-loss/progress assumes stable representable provider enumeration, admissible settlement within scheduling/attempt budgets, and callers applying returned recovery; mutable provider data remains best effort.
SSH Linear listing runs through the existing in-process dispatcher on the account-owning runtime, preserving folder calls without Git. Its reservation follows the existing mux writer's settlement. Other commands keep host CLI passthrough. This focused routing change avoids transferring a reservation across a spawned CLI process; it does not move credentials or execution authority to the remote shell host.
+8 -4
View File
@@ -1,3 +1,4 @@
import { linearListRecoveryInstructions } from '../shared/linear/list-recovery-format'
import { computerUseErrorRecoveryData } from '../shared/computer-use-error-recovery'
import {
matchAutomationOwnerConflict,
@@ -181,11 +182,14 @@ function nextStepsFromData(data: unknown): string[] {
typeof data === 'object' &&
Array.isArray((data as { nextSteps?: unknown }).nextSteps)
) {
return (data as { nextSteps: unknown[] }).nextSteps.filter(
(step): step is string => typeof step === 'string'
)
return [
...(data as { nextSteps: unknown[] }).nextSteps.filter(
(step): step is string => typeof step === 'string'
),
...linearListRecoveryInstructions(data)
]
}
return []
return linearListRecoveryInstructions(data)
}
function localCliErrorData(error: unknown, context: CliErrorContext): unknown {
+5 -12
View File
@@ -1,3 +1,4 @@
import { linearListFlagHelp } from './linear-list-flag-help'
import type { CommandSpec } from './args'
import { findCommandSpec, isCommandGroup, supportsBrowserPageFlag } from './args'
import { unknownCommandData } from './command-suggestion'
@@ -73,6 +74,10 @@ export function formatGroupHelp(specs: CommandSpec[], group: string): string {
function formatCommandFlagHelp(flag: string, commandPath: string[]): string {
const command = commandPath.join(' ')
const linearListHelp = command === 'linear list-issues' ? linearListFlagHelp(flag) : undefined
if (linearListHelp) {
return linearListHelp
}
const skillsHelp = formatSkillsCommandFlagHelp(command, flag)
if (skillsHelp) {
return skillsHelp
@@ -92,15 +97,6 @@ function formatCommandFlagHelp(flag: string, commandPath: string[]): string {
if (command === 'linear search' && flag === 'workspace') {
return '--workspace <id|all> Connected Linear workspace id, or all'
}
if (command === 'linear list-issues' && flag === 'cursor') {
return '--cursor <cursor> Opaque cursor from a previous list-issues page; issued cursors bind the workspace, raw Linear cursors need --workspace'
}
if (command === 'linear list-issues' && flag === 'priority') {
return '--priority <0-4> 0=none, 1=urgent, 2=high, 3=medium, 4=low'
}
if (command === 'linear list-issues' && flag === 'limit') {
return '--limit <n> Max issues to return; omit to return every match'
}
if (command === 'artifacts list' && flag === 'cursor') {
return '--cursor <cursor> Opaque cursor returned by a previous artifacts page'
}
@@ -119,9 +115,6 @@ function formatCommandFlagHelp(flag: string, commandPath: string[]): string {
if (command === 'orchestration worker-list' && flag === 'include-remote') {
return '--include-remote Include connected-server worker observations'
}
if (command === 'linear list-issues' && flag === 'workspace') {
return '--workspace <id|all> Connected Linear workspace id, or all'
}
if (command.startsWith('linear ') && flag === 'workspace') {
return '--workspace <id> Connected Linear workspace id'
}
+18
View File
@@ -0,0 +1,18 @@
export function linearListFlagHelp(flag: string): string | undefined {
if (flag === 'cursor') {
return '--cursor <cursor> Opaque cursor from a previous list-issues page; issued cursors bind the workspace, raw Linear cursors need --workspace'
}
if (flag === 'page-recovery') {
return '--page-recovery <vector> Resume an admitted batch with --workspace all on a capable runtime; cannot use --cursor'
}
if (flag === 'priority') {
return '--priority <0-4> 0=none, 1=urgent, 2=high, 3=medium, 4=low'
}
if (flag === 'limit') {
return '--limit <n> Max issues to return; omit to return every match'
}
if (flag === 'workspace') {
return '--workspace <id|all> Connected Linear workspace id, or all'
}
return undefined
}
+1 -1
View File
@@ -43,7 +43,7 @@ export const LINEAR_MCP_COMMAND_SPECS: CommandSpec[] = [
path: ['linear', 'list-issues'],
summary: 'List Linear issues with MCP-compatible filters',
usage:
'orca linear list-issues [--team <team>] [--cycle <cycle>] [--label <label>] [--limit <n>] [--query <text>] [--state <state>] [--cursor <cursor>] [--order-by createdAt|updatedAt] [--project <project>] [--release <release>] [--assignee <user|me|null>] [--delegate <user|me|null>] [--parent-id <issue|null>] [--priority <0-4>] [--created-at <datetime|duration>] [--updated-at <datetime|duration>] [--include-archived] [--workspace <id>|all] [--json]',
'orca linear list-issues [--team <team>] [--cycle <cycle>] [--label <label>] [--limit <n>] [--query <text>] [--state <state>] [--cursor <cursor>] [--page-recovery <vector>] [--order-by createdAt|updatedAt] [--project <project>] [--release <release>] [--assignee <user|me|null>] [--delegate <user|me|null>] [--parent-id <issue|null>] [--priority <0-4>] [--created-at <datetime|duration>] [--updated-at <datetime|duration>] [--include-archived] [--workspace <id>|all] [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'team',
+27
View File
@@ -390,3 +390,30 @@ describe('Linear client workspace storage', () => {
expect(() => linear.getClients('bad')).toThrow('Could not decrypt')
})
})
describe('in-flight Linear account revision invalidation', () => {
it('cancels reads on every test/reconnect/upsert and disconnect while status reads preserve them', async () => {
const linear = await loadClientModule()
await linear.connect('token-alpha')
const { registerLinearAccountRead } = await import('./linear-account-read-lifetime')
const { upsertWorkspace } = await import('./linear-workspace-registry')
for (const mutate of [
() => linear.testConnection('org-alpha'),
() => linear.connect('token-alpha'),
() => upsertWorkspace(linear.getStatus().workspaces![0])
]) {
const before = linear.getStatus().workspaces![0].credentialRevision!
const read = registerLinearAccountRead('org-alpha')
linear.getStatus()
expect(read.signal.aborted).toBe(false)
await mutate()
expect(read.signal.aborted).toBe(true)
expect(linear.getStatus().workspaces![0].credentialRevision).toBeGreaterThan(before)
read.dispose()
}
const read = registerLinearAccountRead('org-alpha')
linear.disconnect('org-alpha')
expect(read.signal.aborted).toBe(true)
read.dispose()
})
})
@@ -1,3 +1,4 @@
import { linearError } from './issue-context-errors'
const reads = new Map<string, Set<AbortController>>()
export function registerLinearAccountRead(workspaceId: string): {
@@ -11,11 +12,16 @@ export function registerLinearAccountRead(workspaceId: string): {
reads.set(workspaceId, active)
}
active.add(controller)
let disposed = false
return {
signal: controller.signal,
dispose: () => {
if (disposed) {
return
}
disposed = true
active.delete(controller)
if (active.size === 0) {
if (active.size === 0 && reads.get(workspaceId) === active) {
reads.delete(workspaceId)
}
}
@@ -24,6 +30,11 @@ export function registerLinearAccountRead(workspaceId: string): {
export function invalidateLinearAccountReads(workspaceId: string): void {
for (const controller of reads.get(workspaceId) ?? []) {
controller.abort(new Error('Linear account changed during the read.'))
controller.abort(
linearError(
'linear_list_stale_recovery',
'Linear account changed during the read; restart and reconcile.'
)
)
}
}
+3 -1
View File
@@ -6,7 +6,9 @@ import { createRequire } from 'node:module'
// an eager module import and satisfies the no-`import()`-type lint rule.
export type LinearSdkModule = {
LinearClient: new (options: {
apiKey: string
apiKey?: string
accessToken?: string
apiUrl?: string
headers?: Record<string, string>
signal?: AbortSignal
}) => LinearClient
@@ -0,0 +1,111 @@
import { loadLinearSdk } from './linear-sdk'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { acquireIssueListPage } from './mcp-issue-list-acquisition'
import { JsonTextStructureCapacityError } from '../../shared/json-text-structure-limit'
afterEach(() => vi.unstubAllGlobals())
const variables = { first: 250, orderBy: 'updatedAt' }
const signal = new AbortController().signal
function node(id: number) {
return {
id: String(id),
identifier: `F-${id}`,
title: 'Fixture',
url: 'https://linear.app/fixture'
}
}
function response(nodes: unknown[]) {
return { data: { issues: { nodes, pageInfo: { hasNextPage: false } } } }
}
describe('Linear bounded acquisition adapter', () => {
it('uses the installed SDK public account options including auth, headers and endpoint', async () => {
const client = new (loadLinearSdk().LinearClient)({
accessToken: 'synthetic-access',
apiUrl: 'https://fixture.invalid/graphql',
headers: { 'X-Fixture': 'present' }
})
const fetch = vi.fn(async () => Response.json(response([])))
vi.stubGlobal('fetch', fetch)
await acquireIssueListPage(client.options, variables, signal)
const [url, init] = fetch.mock.calls[0] as unknown as [string, RequestInit]
expect(url).toBe('https://fixture.invalid/graphql')
const headers = new Headers(init.headers)
expect(headers.get('Authorization')).toBe('Bearer synthetic-access')
expect(headers.get('X-Fixture')).toBe('present')
expect(headers.get('User-Agent')).toBe(new Headers(client.options.headers).get('User-Agent'))
expect(init.signal).toBe(signal)
})
it('keeps full250x50-label projection, null/absence and valid Unicode including JSON lone surrogates', async () => {
const nodes = Array.from({ length: 250 }, (_, i) => ({
...node(i),
...(i === 0 ? { description: '😀\ud800\\\n' } : i === 1 ? { description: null } : {}),
labels: {
nodes: Array.from({ length: 50 }, (_, label) => ({
id: String(label),
name: 'Label',
color: '#ffffff'
})),
pageInfo: { hasNextPage: true }
}
}))
vi.stubGlobal(
'fetch',
vi.fn(async () => Response.json(response(nodes)))
)
const page = await acquireIssueListPage({ apiKey: 'synthetic' }, variables, signal)
expect(page.nodes).toHaveLength(250)
expect(page.nodes[0].description).toBe('😀\ud800\\\n')
expect(page.nodes[1].description).toBeNull()
expect(page.nodes[2]).not.toHaveProperty('description')
expect(page.nodes[249].labels?.nodes).toHaveLength(50)
})
it('rejects over-depth JSON before mapping even when the issue projection is otherwise valid', async () => {
let nested: unknown = 0
for (let depth = 0; depth < 33; depth++) {
nested = [nested]
}
vi.stubGlobal(
'fetch',
vi.fn(async () => Response.json({ ...response([node(0)]), nested }))
)
await expect(
acquireIssueListPage({ apiKey: 'synthetic' }, variables, signal)
).rejects.toBeInstanceOf(JsonTextStructureCapacityError)
})
it('refuses high-level-only adapters that bypass streaming acquisition', async () => {
const json = vi.fn()
vi.stubGlobal(
'fetch',
vi.fn(async () => ({ ok: true, json }))
)
await expect(
acquireIssueListPage({ apiKey: 'synthetic' }, variables, signal)
).rejects.toMatchObject({ code: 'linear_list_invalid_response' })
expect(json).not.toHaveBeenCalled()
})
it.each(['AuthenticationError', 'Forbidden', 'Ratelimited'])(
'classifies GraphQL %s without copying provider text',
async (type) => {
vi.stubGlobal(
'fetch',
vi.fn(async () =>
Response.json({
errors: [{ message: 'untrusted provider details', extensions: { type } }]
})
)
)
await expect(
acquireIssueListPage({ apiKey: 'synthetic' }, variables, signal)
).rejects.toMatchObject({
code:
type === 'AuthenticationError'
? 'linear_auth_expired'
: type === 'Forbidden'
? 'linear_permission_denied'
: 'linear_rate_limited',
message: 'Linear returned a GraphQL error.'
})
}
)
})
@@ -1,8 +1,13 @@
import { invalidateLinearAccountReads } from './linear-account-read-lifetime'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { IssueListLifetime } from './mcp-issue-list-lifetime'
import { linearError } from './issue-context-errors'
import { acquire, release } from './linear-request-concurrency'
import { readFetchResponseBytesWithinLimit } from '../../shared/fetch-response-body'
vi.mock('./linear-token-store', () => ({ clearToken: vi.fn() }))
vi.mock('./linear-token-store', async () => {
const { invalidateLinearAccountReads } = await import('./linear-account-read-lifetime')
return { clearToken: vi.fn((id: string) => invalidateLinearAccountReads(id)) }
})
function deferred<T>() {
let resolve!: (value: T) => void
@@ -82,6 +87,36 @@ describe('Linear list lease lifetime', () => {
expect(reader.releaseLock).toHaveBeenCalledOnce()
}
)
it('cannot start a second page while an invalidated read still owns cleanup', async () => {
const owner = new IssueListLifetime()
const held = deferred<string>()
const pending = owner.read('fixture', () => held.promise)
const observed = expect(pending).rejects.toMatchObject({ code: 'linear_list_stale_recovery' })
await Promise.resolve()
invalidateLinearAccountReads('fixture')
await observed
expect(owner.cleanupPending).toBe(true)
const next = vi.fn(async () => 'next')
await expect(owner.read('healthy', next)).rejects.toMatchObject({
code: 'linear_list_capacity'
})
expect(next).not.toHaveBeenCalled()
owner.finish()
held.resolve('late page')
await vi.waitFor(() => expect(owner.cleanupPending).toBe(false))
})
it('keeps auth expiration typed when clearing tokens invalidates sibling reads', async () => {
const owner = new IssueListLifetime()
try {
await expect(
owner.read('fixture', async () => {
throw linearError('linear_auth_expired', 'Linear authentication expired.')
})
).rejects.toMatchObject({ code: 'linear_auth_expired' })
} finally {
owner.finish()
}
})
it('holds a completed result until delivery handoff', async () => {
const owners = Array.from({ length: 28 }, () => new IssueListLifetime())
expect(await owners[0].read('fixture', async () => 'complete')).toBe('complete')
@@ -21,6 +21,10 @@ export class IssueListLifetime {
this.deadline = Date.now() + budgetMs
}
get cleanupPending(): boolean {
return this.pending > 0
}
finish(): void {
this.finished = true
this.maybeRelease()
@@ -33,6 +37,9 @@ export class IssueListLifetime {
}
async read<T>(workspaceId: string, operation: (signal: AbortSignal) => Promise<T>): Promise<T> {
if (this.pending > 0) {
throw linearError('linear_list_capacity', 'Previous Linear page cleanup is still pending.')
}
this.signal?.throwIfAborted()
const remaining = this.deadline - Date.now()
if (remaining <= 0) {
@@ -57,6 +64,7 @@ export class IssueListLifetime {
return await operation(signal)
} catch (error) {
if (error instanceof LinearAgentAccessError && error.code === 'linear_auth_expired') {
account.dispose()
clearToken(workspaceId)
}
throw error
@@ -0,0 +1,86 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { listMcpIssues } from './mcp-issue-list'
import { createPageRecovery, encodePageRecovery } from './mcp-issue-list-recovery'
const fixture = vi.hoisted(() => ({
workspaces: Array.from({ length: 32 }, (_, i) => ({
id: String(i).padStart(2, '0'),
organizationId: String(i),
organizationName: 'Fixture',
displayName: 'Fixture',
email: null,
credentialRevision: 1
}))
}))
vi.mock('./client', () => ({
getStatus: () => ({ workspaces: fixture.workspaces }),
getClients: (id: string) => [
{
workspace: fixture.workspaces.find((w) => w.id === id),
client: { options: { apiKey: 'synthetic' } },
apiKey: 'synthetic'
}
]
}))
vi.mock('./linear-token-store', () => ({ clearToken: vi.fn() }))
afterEach(() => vi.unstubAllGlobals())
describe('prospective page recovery metadata', () => {
it('refuses legitimate cursor growth across64KiB before rows or cursor commit', async () => {
const request = { workspaceId: 'all', limit: 1, pageRecovery: { version: 1 as const } }
const state = createPageRecovery(request, fixture.workspaces)
for (let index = 1; index < state.workspaces.length; index++) {
const remaining = 64_000 - encodePageRecovery(state).length
const length = Math.min(2048, Math.max(0, Math.floor(remaining * 0.75) - 40))
if (length) {
state.workspaces[index].after = 'x'.repeat(length)
}
}
const continuation = encodePageRecovery(state)
expect(continuation.length).toBeGreaterThan(63_500)
expect(continuation.length).toBeLessThan(65_536)
const fetch = vi.fn(async () =>
Response.json({
data: {
issues: {
nodes: [
{
id: 'issue',
identifier: 'F-1',
title: 'Full issue',
url: 'https://linear.app/fixture'
}
],
pageInfo: { hasNextPage: true, endCursor: 'y'.repeat(2048) }
}
}
})
)
vi.stubGlobal('fetch', fetch)
const failure = await listMcpIssues({
...request,
pageRecovery: { version: 1, continuation }
}).catch((error) => error)
expect(failure.code).toBe('linear_list_metadata_capacity')
const recovered = JSON.parse(
Buffer.from(failure.data.pageRecovery.continuation, 'base64url').toString()
)
expect(recovered.workspaces).toEqual(state.workspaces)
expect(recovered.nextWorkspaceIndex).toBe(1)
expect(fetch).toHaveBeenCalledOnce()
})
it('rejects a stale account revision before any provider request', async () => {
const request = { workspaceId: 'all', pageRecovery: { version: 1 as const } }
const state = createPageRecovery(request, fixture.workspaces)
state.workspaces[0].credentialRevision++
const fetch = vi.fn()
vi.stubGlobal('fetch', fetch)
await expect(
listMcpIssues({
...request,
pageRecovery: { version: 1, continuation: encodePageRecovery(state) }
})
).rejects.toMatchObject({ code: 'linear_list_stale_recovery' })
expect(fetch).not.toHaveBeenCalled()
})
})
@@ -20,7 +20,11 @@ vi.mock('./client', () => ({
getClients: (id: string) =>
state.workspaces
.filter((w) => w.id === id)
.map((workspace) => ({ workspace, apiKey: workspace.id }))
.map((workspace) => ({
workspace,
client: { options: { apiKey: workspace.id } },
apiKey: workspace.id
}))
}))
vi.mock('./linear-token-store', () => ({ clearToken: vi.fn() }))
@@ -63,6 +67,7 @@ function provider(rows: ReturnType<typeof row>[]) {
afterEach(() => {
vi.unstubAllGlobals()
vi.useRealTimers()
vi.restoreAllMocks()
})
beforeEach(() => {
state.workspaces = [
@@ -247,6 +252,57 @@ describe('actual page-owned Linear producer', () => {
data: { retryPosition: { workspaceId: 'a' } }
})
})
it.each([
{ cursors: [null], admitted: 0, code: 'linear_list_invalid_response' },
{ cursors: ['a', 'a'], admitted: 1, code: 'linear_list_cursor_cycle' },
{ cursors: ['a', 'b', 'a'], admitted: 2, code: 'linear_list_cursor_cycle' }
])(
'diagnoses missing and cycling cursors without committing rejected rows',
async ({ cursors, admitted, code }) => {
let page = 0
vi.stubGlobal(
'fetch',
vi.fn(async () => {
const index = page++
return Response.json({
data: {
issues: {
nodes: [row(index)],
pageInfo: { hasNextPage: true, endCursor: cursors[index] }
}
}
})
})
)
if (!admitted) {
await expect(listMcpIssues({ workspaceId: 'a' })).rejects.toMatchObject({ code })
} else {
const result = await listMcpIssues({ workspaceId: 'a' })
expect(result.issues.map((issue) => issue.id)).toEqual(
Array.from({ length: admitted }, (_, i) => String(i))
)
expect(result.meta.workspaceErrors[0].code).toBe(code)
expect(decodeIssueListCursor(result.meta.nextCursor!)?.cursor).toBe(cursors[admitted - 1])
}
expect(page).toBe(admitted + 1)
}
)
it('does not invent a failed workspace when the deadline expires before a turn starts', async () => {
const fetch = vi.fn()
vi.stubGlobal('fetch', fetch)
vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValue(20_001)
const failure = await listMcpIssues({ workspaceId: 'all', pageRecovery: { version: 1 } }).catch(
(error) => error
)
expect(failure.code).toBe('linear_timeout')
const vector = JSON.parse(
Buffer.from(failure.data.pageRecovery.continuation, 'base64url').toString()
)
expect(vector.nextWorkspaceIndex).toBe(0)
expect(vector.workspaces[0]).not.toHaveProperty('after')
expect(failure.data).not.toHaveProperty('workspaceErrors')
expect(fetch).not.toHaveBeenCalled()
})
it('suppresses a page invalidated during acquisition', async () => {
vi.stubGlobal(
'fetch',
@@ -258,7 +314,7 @@ describe('actual page-owned Linear producer', () => {
})
)
await expect(listMcpIssues({ workspaceId: 'a' })).rejects.toMatchObject({
code: 'linear_network_error'
code: 'linear_list_stale_recovery'
})
})
})
+35 -68
View File
@@ -1,3 +1,4 @@
import { finishIssueList } from './mcp-issue-list-result'
import type {
LinearMcpIssueListRequest,
LinearMcpIssueListResult
@@ -73,7 +74,7 @@ export async function readIssueListPages(
const page = await owner
.read(position.id, async (signal) => {
return acquireIssueListPage(
{ apiKey: entry.apiKey },
entry.client.options,
{
first,
after: position.after,
@@ -164,8 +165,14 @@ export async function readIssueListPages(
)
}
owner.signal?.throwIfAborted()
const current = getStatus().workspaces?.find((w) => w.id === position.id)
if (!current || (current.credentialRevision ?? 0) !== position.credentialRevision) {
const roster = getStatus().workspaces ?? []
if (
(request.workspaceId === 'all' && roster.length !== state.workspaces.length) ||
state.workspaces.some((expected) => {
const current = roster.find((w) => w.id === expected.id)
return !current || (current.credentialRevision ?? 0) !== expected.credentialRevision
})
) {
throw linearError(
'linear_list_stale_recovery',
'Linear account changed before page commit.'
@@ -191,7 +198,12 @@ export async function readIssueListPages(
'Linear listing failed; retry from the returned position.'
)
const item = {
workspace: { id: position.id, name: position.id },
workspace: {
id: position.id,
name:
getStatus().workspaces?.find((w) => w.id === position.id)?.organizationName ??
position.id
},
code: failure.code,
message: failure.message,
data: {
@@ -201,7 +213,8 @@ export async function readIssueListPages(
? { cursor: encodeIssueListCursor(position.id, position.after) }
: {})
},
detailsComplete: false
detailsComplete: false,
...(failure.code === 'linear_list_record_too_large' ? { maxBytes: 896 * 1024 } : {})
}
}
try {
@@ -212,69 +225,23 @@ export async function readIssueListPages(
}
failed.add(position.id)
state.nextWorkspaceIndex = (index + 1) % state.workspaces.length
if (owner.cleanupPending) {
stopReason = 'cleanup_pending'
break
}
if (failure.code === 'linear_list_metadata_capacity') {
stopReason = 'metadata_capacity'
break
}
}
}
const hasMore = state.workspaces.some((w) => !w.done)
const pageRecovery = request.pageRecovery
? {
version: 1 as const,
continuation: encodePageRecovery(state),
ordering: 'admitted_batch' as const,
consistency: 'best_effort' as const,
...(stopReason ? { stopReason } : {})
}
: undefined
if (request.workspaceId === 'all' && hasMore && !pageRecovery) {
throw linearError(
'linear_list_concrete_workspace_required',
'Incomplete all-workspace listing requires concrete workspace restart and reconciliation.'
)
}
if (admission.issues.length === 0 && hasMore) {
const failure = failures[0]
throw linearError(
failure?.code ?? 'linear_timeout',
failure?.message ?? 'Linear listing stopped before a page was admitted.',
{
...(pageRecovery
? { pageRecovery }
: {
retryPosition: {
workspaceId: state.workspaces[0].id,
...(state.workspaces[0].after
? {
cursor: encodeIssueListCursor(
state.workspaces[0].id,
state.workspaces[0].after
)
}
: {})
}
}),
detailsComplete: false
}
)
}
admission.issues.sort((a, b) =>
(b[request.orderBy ?? 'updatedAt'] ?? '').localeCompare(a[request.orderBy ?? 'updatedAt'] ?? '')
)
const concrete = request.workspaceId !== 'all' ? state.workspaces[0] : undefined
return {
issues: admission.issues,
truncated: hasMore,
meta: {
limit,
returned: admission.issues.length,
hasMore,
...(concrete && hasMore && concrete.after
? { nextCursor: encodeIssueListCursor(concrete.id, concrete.after) }
: {}),
...(pageRecovery ? { pageRecovery } : {}),
orderBy: request.orderBy ?? 'updatedAt',
workspaceId: concrete?.id ?? 'all',
partial: failures.length + omittedWorkspaceErrors > 0,
workspaceErrors: failures,
...(omittedWorkspaceErrors ? { omittedWorkspaceErrors } : {})
}
}
return finishIssueList({
request,
state,
admission,
failures,
limit,
stopReason,
omittedWorkspaceErrors
})
}
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const rawRequest = vi.fn()
const getClients = vi.fn()
@@ -17,12 +17,14 @@ const workspace = (id: string, organizationName: string) => ({
const clientEntry = (id: string, organizationName: string) => ({
workspace: workspace(id, organizationName),
client: { client: { rawRequest } }
apiKey: id,
client: { options: { apiKey: id }, client: { rawRequest } }
})
vi.mock('./linear-request-concurrency', () => ({
acquire,
release
release,
reserveLinearListing: () => () => {}
}))
vi.mock('./linear-token-store', () => ({
@@ -64,11 +66,19 @@ function pageResponse(
}
describe('list-issues pagination contract', () => {
afterEach(() => vi.unstubAllGlobals())
beforeEach(() => {
vi.clearAllMocks()
const entry = clientEntry('workspace-1', 'Acme')
getClients.mockReturnValue([entry])
getStatus.mockReturnValue({ workspaces: [entry.workspace] })
vi.stubGlobal(
'fetch',
vi.fn(async (_url, options) => {
const { query, variables } = JSON.parse(options.body)
return Response.json(await rawRequest(query, variables))
})
)
})
it('marks a full page with more results as truncated and binds workspace into nextCursor', async () => {
@@ -231,11 +241,9 @@ describe('list-issues pagination contract', () => {
)
const { listMcpIssues } = await import('./mcp-issue-list')
const result = await listMcpIssues({})
await expect(listMcpIssues({})).rejects.toMatchObject({ code: 'linear_list_invalid_response' })
expect(rawRequest).toHaveBeenCalledTimes(1)
expect(result.truncated).toBe(true)
expect(result.meta.nextCursor).toBeUndefined()
})
it('stops on the read budget and hands back a cursor instead of outliving the RPC', async () => {
+14 -3
View File
@@ -78,9 +78,20 @@ export function createPageRecovery(
includeArchived: request.includeArchived ?? false
})
)
const roster = workspaces
.map(({ id, credentialRevision }) => ({ id, credentialRevision: credentialRevision ?? 0 }))
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
const roster: { id: string; credentialRevision: number }[] = []
let rosterBytes = 2
for (const { id, credentialRevision } of workspaces) {
const entry = { id, credentialRevision: credentialRevision ?? 0 }
rosterBytes += Buffer.byteLength(boundedListJson(entry)) + 1
if (rosterBytes > LIST_CONTEXT_BYTES) {
throw linearError(
'linear_list_metadata_capacity',
'Linear roster exceeds capacity; use a concrete workspace.'
)
}
roster.push(entry)
}
roster.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
const rosterHash = hash(roster)
const initial: IssueListRecoveryVector = {
version: 1,
+107
View File
@@ -0,0 +1,107 @@
import type {
LinearMcpIssueListRequest,
LinearMcpIssueListResult
} from '../../shared/linear/mcp-issue-list'
import type { IssueListAdmission } from './mcp-issue-list-admission'
import { linearError } from './issue-context-errors'
import { encodeIssueListCursor } from './mcp-issue-list-cursor'
import { encodePageRecovery, type IssueListRecoveryVector } from './mcp-issue-list-recovery'
export function finishIssueList({
request,
state,
admission,
failures,
limit,
stopReason,
omittedWorkspaceErrors
}: {
request: LinearMcpIssueListRequest
state: IssueListRecoveryVector
admission: IssueListAdmission
failures: LinearMcpIssueListResult['meta']['workspaceErrors']
limit: number | null
stopReason?: string
omittedWorkspaceErrors: number
}): LinearMcpIssueListResult {
const hasMore = state.workspaces.some((w) => !w.done)
const pageRecovery = request.pageRecovery
? {
version: 1 as const,
continuation: encodePageRecovery(state),
ordering: 'admitted_batch' as const,
consistency: 'best_effort' as const,
...(stopReason ? { stopReason } : {})
}
: undefined
if (request.workspaceId === 'all' && hasMore && !pageRecovery) {
throw linearError(
'linear_list_concrete_workspace_required',
'Incomplete all-workspace listing requires concrete workspace restart and reconciliation.',
{
restartConcreteWorkspaces: true,
partial: failures.length + omittedWorkspaceErrors > 0,
workspaceErrors: failures.map(({ workspace, code, message }) => ({
workspace,
code,
message
})),
omittedWorkspaceErrors,
nextSteps: [
'Run linear status --json to identify connected workspaces, then restart the original query for each concrete workspace.'
]
}
)
}
if (admission.issues.length === 0 && hasMore) {
const failure = failures[0]
throw linearError(
failure?.code ?? 'linear_timeout',
failure?.message ?? 'Linear listing stopped before a page was admitted.',
{
...(failure?.data && typeof failure.data === 'object' ? failure.data : {}),
...(pageRecovery
? { pageRecovery }
: {
retryPosition: {
workspaceId: state.workspaces[0].id,
...(state.workspaces[0].after
? {
cursor: encodeIssueListCursor(
state.workspaces[0].id,
state.workspaces[0].after
)
}
: {})
}
}),
detailsComplete: false,
nextSteps: [
'Use --json to inspect the recovery position; retry or restart and reconcile by workspace and issue ID.'
]
}
)
}
admission.issues.sort((a, b) =>
(b[request.orderBy ?? 'updatedAt'] ?? '').localeCompare(a[request.orderBy ?? 'updatedAt'] ?? '')
)
const concrete = request.workspaceId !== 'all' ? state.workspaces[0] : undefined
return {
issues: admission.issues,
truncated: hasMore,
meta: {
limit,
returned: admission.issues.length,
hasMore,
...(concrete && hasMore && concrete.after
? { nextCursor: encodeIssueListCursor(concrete.id, concrete.after) }
: {}),
...(pageRecovery ? { pageRecovery } : {}),
orderBy: request.orderBy ?? 'updatedAt',
workspaceId: concrete?.id ?? 'all',
partial: failures.length + omittedWorkspaceErrors > 0,
workspaceErrors: failures,
...(omittedWorkspaceErrors ? { omittedWorkspaceErrors } : {})
}
}
}
+2 -2
View File
@@ -22,7 +22,7 @@ const clientEntry = (
) => ({
workspace: workspace(id, organizationName),
apiKey: id,
client: { client: { rawRequest: request } }
client: { options: { apiKey: id }, client: { rawRequest: request } }
})
vi.mock('./linear-request-concurrency', () => ({
@@ -255,7 +255,7 @@ describe('MCP-compatible Linear issue listing', () => {
expect(result.meta).toMatchObject({ partial: true, returned: 1 })
expect(result.meta.workspaceErrors).toMatchObject([
{
workspace: { id: 'workspace-2', name: 'workspace-2' },
workspace: { id: 'workspace-2', name: 'Beta' },
code: 'linear_rate_limited',
message: 'Linear provider request failed (HTTP 429).'
}
-1
View File
@@ -51,7 +51,6 @@ export async function listMcpIssues(
if (!selected.length) {
throw linearError('linear_not_connected', 'Linear is not connected.')
}
boundedListJson(selected)
const state = createPageRecovery(request, selected)
if (!request.pageRecovery && pagination.linearCursor) {
state.workspaces[0].after = pagination.linearCursor
@@ -0,0 +1,174 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeRpcServer } from './runtime-rpc'
import type { OrcaRuntimeService } from './orca-runtime'
import { readRuntimeMetadata } from './runtime-metadata'
import { createConnection } from 'node:net'
import { once } from 'node:events'
import { createInterface } from 'node:readline'
import {
authenticateMobileWsSession,
createEncryptedWsResponseReader,
sendEncryptedWsRequest
} from './runtime-rpc-mobile-ws-test-harness'
import { LINEAR_METHODS } from './rpc/methods/linear'
import { listMcpIssues } from '../linear/mcp-issue-list'
import type { LinearMcpIssueListRequest } from '../../shared/linear/mcp-issue-list'
const fixture = vi.hoisted(() => ({
workspace: {
id: 'fixture',
organizationId: 'fixture',
organizationName: 'Fixture',
displayName: 'Fixture',
email: null,
credentialRevision: 1
}
}))
vi.mock('../linear/client', () => ({
getStatus: () => ({ workspaces: [fixture.workspace], activeWorkspaceId: 'fixture' }),
getClients: () => [
{
workspace: fixture.workspace,
client: { options: { apiKey: 'synthetic-fixture' } },
apiKey: 'synthetic-fixture'
}
]
}))
vi.mock('../linear/linear-token-store', () => ({ clearToken: vi.fn() }))
const folders: string[] = []
afterEach(() => {
vi.unstubAllGlobals()
folders.splice(0).forEach((path) => rmSync(path, { recursive: true, force: true }))
})
function row(size: number) {
return {
id: 'issue',
identifier: 'F-1',
title: 'Fixture',
url: 'https://linear.app/fixture',
description: 'x'.repeat(size)
}
}
describe('Linear page delivery through actual runtime local socket lifecycle', () => {
it.each(['local', 'paired'] as const)(
'delivers bounded complete/pressure/error replies and next usable request through %s',
async (route) => {
let size = 130 * 1024
let oversizedMeta = false
vi.stubGlobal(
'fetch',
vi.fn(async () =>
Response.json({
data: {
issues: {
nodes: [row(size)],
pageInfo: { hasNextPage: false }
}
}
})
)
)
const runtime = {
getRuntimeId: () => (oversizedMeta ? 'm'.repeat(20 * 1024) : 'fixture-runtime'),
getStartedAt: () => '2026-09-08T00:00:00Z',
cleanupSubscriptionsForConnection: vi.fn(),
cancelMobileDictationForConnection: vi.fn(),
onClientDisconnected: vi.fn(),
linearMcpIssueList: (
request: LinearMcpIssueListRequest,
options: Parameters<typeof listMcpIssues>[1]
) => listMcpIssues(request, options)
} as unknown as OrcaRuntimeService
const folder = mkdtempSync(join(tmpdir(), 'orca-linear-page-'))
folders.push(folder)
const server = new OrcaRuntimeRpcServer({
runtime,
userDataPath: folder,
methods: LINEAR_METHODS,
enableWebSocket: route === 'paired',
wsPort: 0
})
await server.start()
try {
const metadata = readRuntimeMetadata(folder)!
const offer =
route === 'paired'
? server.createPairingOffer({
address: '127.0.0.1',
name: 'synthetic-fixture',
scope: 'runtime'
})
: null
if (offer && !offer.available) {
throw new Error('Synthetic pairing unavailable')
}
const session = offer?.available
? await authenticateMobileWsSession(offer.pairingUrl)
: null
const reader = session ? createEncryptedWsResponseReader(session) : null
const local = session ? null : createConnection(metadata.transports[0].endpoint)
if (local) {
await once(local, 'connect')
}
const lines = local ? createInterface({ input: local }) : null
const encryptedSizes: number[] = []
session?.ws.on('message', (message) =>
encryptedSizes.push(
Array.isArray(message)
? message.reduce((total, part) => total + part.byteLength, 0)
: message.byteLength
)
)
const call = async (cursor?: string) => {
const request = {
id: 'fixture-call',
method: 'linear.mcpListIssues',
params: { workspaceId: 'fixture', limit: 1, ...(cursor ? { cursor } : {}) }
}
if (session) {
sendEncryptedWsRequest(session, request)
}
const localReply = lines ? once(lines, 'line') : null
local?.write(`${JSON.stringify({ ...request, authToken: metadata.authToken })}\n`)
const response =
session && reader
? await reader.next(request.id)
: JSON.parse((await localReply!)[0] as string)
expect(Buffer.byteLength(JSON.stringify(response, null, 2)) + 1).toBeLessThanOrEqual(
1024 * 1024
)
return response as {
ok: boolean
result?: { issues: { description: string }[] }
error?: { code: string; data: { retryPosition: { cursor?: string } } }
}
}
expect((await call()).result?.issues[0].description).toHaveLength(130 * 1024)
size = 950 * 1024
expect((await call()).error?.code).toBe('linear_list_record_too_large')
size = 1
expect((await call()).ok).toBe(true)
oversizedMeta = true
const fallback = await call('input-position')
expect(fallback.error?.code).toBe('linear_list_metadata_capacity')
expect(fallback.error?.data.retryPosition.cursor).toBe('input-position')
expect(fallback.result).toBeUndefined()
oversizedMeta = false
expect((await call('input-position')).ok).toBe(true)
expect(encryptedSizes.every((size) => size <= 2_839_948)).toBe(true)
lines?.close()
local?.destroy()
reader?.dispose()
session?.ws.close()
} finally {
await server.stop()
}
}
)
})
+3
View File
@@ -72,6 +72,9 @@ export class LinearListSshDelivery implements LinearListDeliveryContext {
bound(response: JsonRpcResponse): JsonRpcResponse {
try {
if (response.error) {
throw new Error('Linear reply failed')
}
if (!Number.isSafeInteger(response.id)) {
throw new Error('invalid correlation')
}
@@ -0,0 +1,158 @@
import type * as HostCli from './ssh-remote-cli-host-passthrough'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { SshChannelMultiplexer, type MultiplexerTransport } from './ssh-channel-multiplexer'
import { encodeJsonRpcFrame, HEADER_LENGTH } from './relay-protocol'
import { runRemoteOrcaCli } from './ssh-remote-orca-cli'
import { runHostOrcaCliPassthrough } from './ssh-remote-cli-host-passthrough'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import { listMcpIssues } from '../linear/mcp-issue-list'
import { IssueListLifetime } from '../linear/mcp-issue-list-lifetime'
const fixture = vi.hoisted(() => ({
workspace: {
id: 'account-owner',
organizationId: 'account-owner',
organizationName: 'Account owner',
displayName: 'Fixture',
email: null,
credentialRevision: 1
}
}))
vi.mock('../linear/client', () => ({
getStatus: () => ({ workspaces: [fixture.workspace], activeWorkspaceId: fixture.workspace.id }),
getClients: () => [
{
workspace: fixture.workspace,
client: { options: { apiKey: 'synthetic-fixture' } },
apiKey: 'synthetic-fixture'
}
]
}))
vi.mock('../linear/linear-token-store', () => ({ clearToken: vi.fn() }))
vi.mock('./ssh-remote-cli-host-passthrough', async (original) => ({
...(await original<typeof HostCli>()),
runHostOrcaCliPassthrough: vi.fn()
}))
afterEach(() => vi.unstubAllGlobals())
describe('actual SSH Linear dispatcher and mux delivery', () => {
it('uses the account-owning runtime for a folder call and holds reservation through mux settlement', async () => {
let receive!: (data: Buffer) => void
const written: Buffer[] = []
const settlements: (() => void)[] = []
const transport: MultiplexerTransport = {
supportsWriteSettlement: true,
onData: (callback) => {
receive = callback
},
onClose: () => {},
write: (data, settled) => {
written.push(data)
settlements.push(() => settled?.({ ok: true }))
}
}
const mux = new SshChannelMultiplexer(transport)
let size = 130 * 1024
let bloatOuter = false
vi.stubGlobal(
'fetch',
vi.fn(async () =>
Response.json({
data: {
issues: {
nodes: [
{
id: '1',
identifier: 'F-1',
title: 'Fixture',
url: 'https://linear.app/fixture',
description: 'x'.repeat(size)
}
],
pageInfo: { hasNextPage: false }
}
}
})
)
)
const runtime = {
getRuntimeId: () => 'account-owner-runtime',
linearMcpIssueList: listMcpIssues,
linearStatus: () => ({ connected: true, viewer: null, mcpListPageRecoveryVersion: 1 })
} as unknown as OrcaRuntimeService
mux.onRequest('orca.cli', async (params, delivery) => {
const result = await runRemoteOrcaCli(runtime, {
argv: params.argv as string[],
cwd: '/remote/folder-without-git',
env: {},
delivery
})
return bloatOuter ? { ...result, stderr: 'x'.repeat(3 * 1024 * 1024) } : result
})
let seq = 0
const request = async (cursor?: string) => {
seq++
receive(
encodeJsonRpcFrame(
{
jsonrpc: '2.0',
id: seq,
method: 'orca.cli',
params: {
argv: [
'linear',
'list-issues',
'--workspace',
'account-owner',
'--limit',
'1',
'--json',
...(cursor ? ['--cursor', cursor] : [])
]
}
},
seq,
seq - 1
)
)
await vi.waitFor(() => expect(written).toHaveLength(seq))
const outer = JSON.parse(written[seq - 1].subarray(HEADER_LENGTH).toString())
expect(Buffer.byteLength(JSON.stringify(outer, null, 2)) + 1).toBeLessThanOrEqual(2_129_920)
expect(Buffer.byteLength(outer.result.stdout)).toBeLessThanOrEqual(1024 * 1024)
return JSON.parse(outer.result.stdout)
}
const held: IssueListLifetime[] = []
try {
const result = await request()
expect(result._meta.runtimeId).toBe('account-owner-runtime')
expect(result.result.issues[0].workspace.id).toBe('account-owner')
expect(result.result.issues[0].description).toHaveLength(130 * 1024)
for (let i = 0; i < 27; i++) {
held.push(new IssueListLifetime())
}
expect(() => new IssueListLifetime()).toThrow('capacity')
settlements[0]()
const recovered = new IssueListLifetime()
recovered.finish()
held.splice(0).forEach((owner) => owner.finish())
size = 950 * 1024
expect((await request()).error.code).toBe('linear_list_record_too_large')
settlements[1]()
size = 1
expect((await request()).ok).toBe(true)
settlements[2]()
bloatOuter = true
const failure = await request('input-cursor')
expect(failure.error.code).toBe('linear_list_metadata_capacity')
expect(failure.error.data.retryPosition.cursor).toBe('input-cursor')
settlements[3]()
bloatOuter = false
expect((await request('input-cursor')).ok).toBe(true)
settlements[4]()
expect(runHostOrcaCliPassthrough).not.toHaveBeenCalled()
} finally {
held.forEach((owner) => owner.finish())
mux.dispose()
}
})
})
@@ -1,3 +1,5 @@
import { RemoteCliArgumentError } from './ssh-remote-cli-argument-error'
import type { RemoteOrcaCliResult } from './ssh-remote-cli-host-passthrough'
import type { RpcResponse } from '../runtime/rpc/core'
export function buildRemoteCliError(message: string, code = 'runtime_error'): RpcResponse {
@@ -8,3 +10,34 @@ export function buildRemoteCliError(message: string, code = 'runtime_error'): Rp
_meta: { runtimeId: 'unknown' }
}
}
export function buildRemoteCliFailure(
err: unknown,
json: boolean,
command: string
): RemoteOrcaCliResult {
if (command === 'linear list-issues' && !(err instanceof RemoteCliArgumentError)) {
throw new Error(
'Linear listing could not be delivered; retry the input position or restart concrete workspaces and reconcile by issue ID.'
)
}
const rawMessage = err instanceof Error ? err.message : String(err)
const message =
command === 'linear list-issues' && Buffer.byteLength(rawMessage) > 512
? 'Invalid Linear listing arguments; retry with a bounded request.'
: rawMessage
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(buildRemoteCliError(message, code), null, 2)}\n`,
stderr: '',
exitCode: 1
}
}
return { stdout: '', stderr: `${message}\n`, exitCode: 1 }
}
+2
View File
@@ -1,3 +1,4 @@
import { linearListRecoveryInstructions } from '../../shared/linear/list-recovery-format'
import type { CliStatusResult } from '../../shared/runtime-types'
import type { RpcResponse } from '../runtime/rpc/core'
import { formatRemoteLinearCli } from './ssh-remote-linear-output'
@@ -23,6 +24,7 @@ function formatRemoteCliError(error: { message: string; data?: unknown }): strin
isRecord(error.data) && Array.isArray(error.data.nextSteps)
? error.data.nextSteps.filter((step): step is string => typeof step === 'string')
: []
nextSteps.push(...linearListRecoveryInstructions(error.data))
if (nextSteps.length === 0) {
return error.message
}
+4 -2
View File
@@ -103,9 +103,11 @@ Examples:
const LINEAR_MCP_ISSUE_LIST_HELP = `orca linear list-issues
Usage: orca linear list-issues [--team <team>] [--cycle <cycle>] [--label <label>] [--limit <n>] [--query <text>] [--state <state>] [--cursor <cursor>] [--order-by createdAt|updatedAt] [--project <project>] [--release <release>] [--assignee <user|me|null>] [--delegate <user|me|null>] [--parent-id <issue|null>] [--priority <0-4>] [--created-at <datetime|duration>] [--updated-at <datetime|duration>] [--include-archived] [--workspace <id>|all] [--json]
Usage: orca linear list-issues [--team <team>] [--cycle <cycle>] [--label <label>] [--limit <n>] [--query <text>] [--state <state>] [--cursor <cursor>] [--page-recovery <vector>] [--order-by createdAt|updatedAt] [--project <project>] [--release <release>] [--assignee <user|me|null>] [--delegate <user|me|null>] [--parent-id <issue|null>] [--priority <0-4>] [--created-at <datetime|duration>] [--updated-at <datetime|duration>] [--include-archived] [--workspace <id>|all] [--json]
List Linear issues with MCP-compatible filters and cursor pagination`
List Linear issues with MCP-compatible filters and cursor pagination.
Capable runtimes return sorted admitted batches for --workspace all; resume with --page-recovery. Concrete workspaces keep --cursor. Check hasMore and partial before reporting completeness.`
const LINEAR_SEARCH_HELP = `orca linear search
+3 -19
View File
@@ -17,7 +17,7 @@ import {
type RemoteOrcaCliRequest,
type RemoteOrcaCliResult
} from './ssh-remote-cli-host-passthrough'
import { RemoteCliArgumentError, type ParsedRemoteCli } from './ssh-remote-cli-argument-error'
import type { ParsedRemoteCli } from './ssh-remote-cli-argument-error'
import {
optionalRemoteCliNumber,
optionalRemoteCliString,
@@ -26,7 +26,7 @@ import {
requiredRemoteCliString,
resolveRemoteCliHandle
} from './ssh-remote-cli-args'
import { buildRemoteCliError } from './ssh-remote-cli-error-response'
import { buildRemoteCliError, buildRemoteCliFailure } from './ssh-remote-cli-error-response'
import { getRemoteLinearHelp, tryDispatchRemoteLinearCli } from './ssh-remote-linear-cli'
import {
getRemoteOrchestrationPayload,
@@ -122,23 +122,7 @@ async function runLegacyRemoteOrcaCli(
)
return formatInProcessRemoteCliResult(parsed, request.env, response, json)
} 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(buildRemoteCliError(message, code), null, 2)}\n`,
stderr: '',
exitCode: 1
}
}
return { stdout: '', stderr: `${message}\n`, exitCode: 1 }
return buildRemoteCliFailure(err, json, parsed.commandPath.join(' '))
}
}
+6 -2
View File
@@ -77,13 +77,17 @@ export async function readFetchResponseBytesWithinLimit(
try {
while (true) {
signal?.throwIfAborted()
const { done, value } = await reader.read()
const chunk = await reader.read()
signal?.throwIfAborted()
if (done) {
if (chunk.done) {
return output.subarray(0, byteLength)
}
let value = chunk.value
const nextLength = byteLength + value.byteLength
if (!Number.isSafeInteger(nextLength) || nextLength > maxBytes) {
output = new Uint8Array()
chunk.value = new Uint8Array()
value = new Uint8Array()
await cancelReader(reader)
throw new FetchResponseBodyTooLargeError(nextLength, maxBytes)
}
+22
View File
@@ -0,0 +1,22 @@
export function linearListRecoveryInstructions(data: unknown): string[] {
if (!data || typeof data !== 'object') {
return []
}
const recovery = (data as { pageRecovery?: unknown }).pageRecovery
if (recovery && typeof recovery === 'object' && 'continuation' in recovery) {
const value = recovery.continuation
if (typeof value === 'string' && value.length <= 65_536 && /^[A-Za-z0-9_-]+$/.test(value)) {
return [`Continue with --workspace all --page-recovery ${value}`]
}
}
const position = (data as { retryPosition?: unknown }).retryPosition
if (position && typeof position === 'object') {
const { workspaceId, cursor } = position as { workspaceId?: unknown; cursor?: unknown }
if (typeof workspaceId === 'string' && workspaceId.length <= 2048) {
return [
`Retry workspace: ${JSON.stringify(workspaceId)}${typeof cursor === 'string' && cursor.length <= 4096 ? `; cursor: ${JSON.stringify(cursor)}` : '; start position'}`
]
}
}
return []
}
@@ -0,0 +1,134 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { z } from 'zod'
import { runLinearListIssues } from '../../src/cli/handlers/linear-list-issues'
import type { RuntimeClient } from '../../src/cli/runtime-client'
import { RuntimeRpcFailureError } from '../../src/cli/runtime/types'
import { formatCliError } from '../../src/cli/cli-error'
import { RpcDispatcher } from '../../src/main/runtime/rpc/dispatcher'
import { defineMethod } from '../../src/main/runtime/rpc/core'
import { LINEAR_MCP_ISSUE_LIST_METHOD } from '../../src/main/runtime/rpc/methods/linear-issue-list-method'
import type { OrcaRuntimeService } from '../../src/main/runtime/orca-runtime'
import { dispatchRemoteLinearListIssues } from '../../src/main/ssh/ssh-remote-linear-list-issues'
import { formatRemoteCli } from '../../src/main/ssh/ssh-remote-cli-format'
afterEach(() => vi.restoreAllMocks())
const result = {
issues: [],
truncated: false,
meta: {
limit: null,
returned: 0,
hasMore: false,
orderBy: 'updatedAt',
workspaceId: 'all',
partial: false,
workspaceErrors: []
}
}
describe('actual CLI/SSH capability negotiation against strict schema skew', () => {
it.each(['cli', 'ssh'] as const)(
'omits the option for an unadvertised host and negotiates a new host through %s',
async (route) => {
vi.spyOn(console, 'log').mockImplementation(() => {})
for (const capable of [false, true]) {
const read = vi.fn().mockResolvedValue(result)
const runtime = {
getRuntimeId: () => (capable ? 'new-owner' : 'old-owner'),
linearMcpIssueList: read
} as unknown as OrcaRuntimeService
// The pinned old method has the same strict fields except pageRecovery.
const oldSchema = (LINEAR_MCP_ISSUE_LIST_METHOD.params as z.ZodObject).omit({
pageRecovery: true
})
const method = capable
? LINEAR_MCP_ISSUE_LIST_METHOD
: { ...LINEAR_MCP_ISSUE_LIST_METHOD, params: oldSchema }
const status = defineMethod({
name: 'linear.status',
params: null,
handler: () => ({
connected: true,
viewer: null,
...(capable ? { mcpListPageRecoveryVersion: 1 } : {})
})
})
const dispatcher = new RpcDispatcher({ runtime, methods: [method, status] })
const calls: string[] = []
const client = {
call: async (name: string, params: unknown) => {
calls.push(name)
const response = await dispatcher.dispatch({
id: 'fixture',
authToken: 'synthetic',
method: name,
params: JSON.parse(JSON.stringify(params))
})
if (!response.ok) {
throw new RuntimeRpcFailureError(response)
}
return response
}
} as unknown as RuntimeClient
const flags = new Map<string, string | boolean>([['workspace', 'all']])
if (route === 'cli') {
await runLinearListIssues({ flags, client, cwd: '/folder-without-git', json: true })
expect(calls).toEqual(['linear.status', 'linear.mcpListIssues'])
} else {
expect(
(
await dispatchRemoteLinearListIssues(dispatcher, {
commandPath: ['linear', 'list-issues'],
flags
})
).ok
).toBe(true)
}
expect(read).toHaveBeenCalledOnce()
expect(read.mock.calls[0][0].limit).toBeUndefined()
expect(read.mock.calls[0][0].pageRecovery).toEqual(capable ? { version: 1 } : undefined)
if (!capable) {
const refused = await dispatcher.dispatch({
id: 'fixture',
authToken: 'synthetic',
method: 'linear.mcpListIssues',
params: { workspaceId: 'all', pageRecovery: { version: 1 } }
})
expect(refused.ok).toBe(false)
}
}
}
)
it('keeps error-carried recovery through the client exception and both human formatters', () => {
const failure = {
id: 'fixture',
ok: false as const,
_meta: { runtimeId: 'owner' },
error: {
code: 'linear_timeout',
message: 'Linear listing deadline reached.',
data: { pageRecovery: { version: 1, continuation: 'YWJj' } }
}
}
const error = new RuntimeRpcFailureError(failure)
expect(error.response.error.data).toEqual(failure.error.data)
expect(formatCliError(error)).toContain('--page-recovery YWJj')
expect(formatRemoteCli(failure).stderr).toContain('--page-recovery YWJj')
})
it('refuses vector and concrete-cursor combination before contacting any host', async () => {
const call = vi.fn()
await expect(
runLinearListIssues({
flags: new Map([
['workspace', 'all'],
['cursor', 'v1'],
['page-recovery', 'YWJj']
]),
client: { call } as unknown as RuntimeClient,
cwd: '/folder',
json: true
})
).rejects.toMatchObject({ code: 'invalid_argument' })
expect(call).not.toHaveBeenCalled()
})
})