mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 00:02:56 +00:00
Verify Linear page recovery across account and delivery lifetimes
This commit is contained in:
@@ -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.
|
||||
@@ -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
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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.'
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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).'
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(' '))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user