mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
Merge remote-tracking branch 'origin/main' into Jinwoo-H/feature-control-multiple-orca-servers-concurrent
This commit is contained in:
@@ -36,6 +36,7 @@ export const BOOLEAN_FLAGS = new Set([
|
||||
'mobile',
|
||||
'mobile-pairing',
|
||||
'no-pairing',
|
||||
'parent-current',
|
||||
'ready',
|
||||
'relations',
|
||||
'restore-window',
|
||||
|
||||
@@ -182,6 +182,169 @@ describe('orca linear CLI handlers', () => {
|
||||
workspaceId: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('maps status writes to the agent write RPC with an explicit target', async () => {
|
||||
queueFixtures(callMock, okFixture('req_status', statusSetResult()))
|
||||
|
||||
await main(['linear', 'status', 'set', 'ENG-123', '--to', 'In Review', '--json'], '/tmp/repo')
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'linear.issueSetState',
|
||||
{
|
||||
input: 'ENG-123',
|
||||
current: false,
|
||||
workspaceId: undefined,
|
||||
to: 'In Review',
|
||||
context: {
|
||||
remote: false,
|
||||
cwd: '/tmp/repo'
|
||||
}
|
||||
},
|
||||
{ timeoutMs: 75_000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('requires exact write targets for issue writes', async () => {
|
||||
await main(['linear', 'comment', 'add', '--body', 'done'], '/tmp/repo')
|
||||
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(console.error).mock.calls[0][0]).toContain(
|
||||
'Pass a Linear issue id or --current'
|
||||
)
|
||||
expect(process.exitCode).toBe(1)
|
||||
})
|
||||
|
||||
it('rejects --workspace all for writes before dispatch', async () => {
|
||||
await main(
|
||||
[
|
||||
'linear',
|
||||
'attach',
|
||||
'ENG-123',
|
||||
'--url',
|
||||
'https://example.com/review/123',
|
||||
'--workspace',
|
||||
'all'
|
||||
],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(console.error).mock.calls[0][0]).toContain(
|
||||
'--workspace all is not valid for Linear writes'
|
||||
)
|
||||
expect(process.exitCode).toBe(1)
|
||||
})
|
||||
|
||||
it('reads comment bodies from stdin and passes retry write ids through', async () => {
|
||||
const stdin = mockStdin(false, ['line one\n', 'line two'])
|
||||
queueFixtures(callMock, okFixture('req_comment', commentAddResult()))
|
||||
|
||||
try {
|
||||
await main(
|
||||
[
|
||||
'linear',
|
||||
'comment',
|
||||
'add',
|
||||
'--current',
|
||||
'--body-file',
|
||||
'-',
|
||||
'--reply-to',
|
||||
'comment-parent',
|
||||
'--write-id',
|
||||
'123e4567-e89b-12d3-a456-426614174000',
|
||||
'--json'
|
||||
],
|
||||
'/tmp/repo'
|
||||
)
|
||||
} finally {
|
||||
stdin.restore()
|
||||
}
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'linear.issueAddComment',
|
||||
{
|
||||
input: undefined,
|
||||
current: true,
|
||||
workspaceId: undefined,
|
||||
body: 'line one\nline two',
|
||||
replyTo: 'comment-parent',
|
||||
writeId: '123e4567-e89b-12d3-a456-426614174000',
|
||||
context: {
|
||||
remote: false,
|
||||
cwd: '/tmp/repo'
|
||||
}
|
||||
},
|
||||
{ timeoutMs: 75_000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects malformed write ids before dispatch', async () => {
|
||||
await main(
|
||||
[
|
||||
'linear',
|
||||
'attach',
|
||||
'ENG-123',
|
||||
'--url',
|
||||
'https://example.com/review/123',
|
||||
'--write-id',
|
||||
'not-a-uuid',
|
||||
'--json'
|
||||
],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
const payload = JSON.parse(String(vi.mocked(console.log).mock.calls[0][0])) as {
|
||||
error: { code: string }
|
||||
}
|
||||
expect(payload.error.code).toBe('linear_invalid_write_id')
|
||||
})
|
||||
|
||||
it('maps create with parent-current and optional body flags', async () => {
|
||||
queueFixtures(callMock, okFixture('req_create', createResult()))
|
||||
|
||||
await main(
|
||||
[
|
||||
'linear',
|
||||
'create',
|
||||
'--title',
|
||||
'Follow-up bug',
|
||||
'--body',
|
||||
'Concrete repro',
|
||||
'--parent-current',
|
||||
'--json'
|
||||
],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'linear.issueCreate',
|
||||
{
|
||||
title: 'Follow-up bug',
|
||||
body: 'Concrete repro',
|
||||
teamKey: undefined,
|
||||
parentInput: undefined,
|
||||
parentCurrent: true,
|
||||
workspaceId: undefined,
|
||||
writeId: undefined,
|
||||
context: {
|
||||
remote: false,
|
||||
cwd: '/tmp/repo'
|
||||
}
|
||||
},
|
||||
{ timeoutMs: 75_000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects duplicate body inputs before dispatch', async () => {
|
||||
await main(
|
||||
['linear', 'create', '--title', 'Bug', '--body', 'one', '--body-file', 'body.md'],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(console.error).mock.calls[0][0]).toContain('Use either --body or --body-file')
|
||||
})
|
||||
})
|
||||
|
||||
function issueResult(): unknown {
|
||||
@@ -213,3 +376,73 @@ function issueResult(): unknown {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function statusSetResult(): unknown {
|
||||
return {
|
||||
issue: { id: 'issue-id', identifier: 'ENG-123', url: 'https://linear.app/acme/issue/ENG-123' },
|
||||
state: { id: 'state-review', name: 'In Review', type: 'started' },
|
||||
previousState: { id: 'state-started', name: 'In Progress' },
|
||||
meta: { workspaceId: 'workspace-1', alreadyInState: false }
|
||||
}
|
||||
}
|
||||
|
||||
function commentAddResult(): unknown {
|
||||
return {
|
||||
comment: { id: 'comment-id', url: null, parentId: 'comment-parent' },
|
||||
issue: { id: 'issue-id', identifier: 'ENG-123', url: 'https://linear.app/acme/issue/ENG-123' },
|
||||
meta: {
|
||||
workspaceId: 'workspace-1',
|
||||
bodyChars: 17,
|
||||
writeId: '123e4567-e89b-12d3-a456-426614174000',
|
||||
deduplicated: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createResult(): unknown {
|
||||
return {
|
||||
issue: {
|
||||
id: 'issue-child',
|
||||
identifier: 'ENG-456',
|
||||
title: 'Follow-up bug',
|
||||
url: 'https://linear.app/acme/issue/ENG-456',
|
||||
team: { id: 'team-eng', key: 'ENG', name: 'Engineering' },
|
||||
state: { id: 'state-triage', name: 'Triage' },
|
||||
parent: { id: 'issue-id', identifier: 'ENG-123' }
|
||||
},
|
||||
meta: {
|
||||
workspaceId: 'workspace-1',
|
||||
writeId: '123e4567-e89b-12d3-a456-426614174000',
|
||||
deduplicated: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mockStdin(isTTY: boolean, chunks: string[]): { restore: () => void } {
|
||||
const stdin = process.stdin
|
||||
const previousIsTTY = stdin.isTTY
|
||||
const previousAsyncIterator = stdin[Symbol.asyncIterator]
|
||||
Object.defineProperty(stdin, 'isTTY', {
|
||||
configurable: true,
|
||||
value: isTTY
|
||||
})
|
||||
;(stdin as unknown as Record<symbol, unknown>)[Symbol.asyncIterator] = async function* () {
|
||||
for (const chunk of chunks) {
|
||||
yield chunk
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
restore: () => {
|
||||
Object.defineProperty(stdin, 'isTTY', {
|
||||
configurable: true,
|
||||
value: previousIsTTY
|
||||
})
|
||||
if (previousAsyncIterator) {
|
||||
;(stdin as unknown as Record<symbol, unknown>)[Symbol.asyncIterator] = previousAsyncIterator
|
||||
} else {
|
||||
Reflect.deleteProperty(stdin, Symbol.asyncIterator)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+200
-9
@@ -1,11 +1,23 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { isAbsolute, join } from 'node:path'
|
||||
import type {
|
||||
LinearAttachRequest,
|
||||
LinearAttachResult,
|
||||
LinearCommentAddRequest,
|
||||
LinearCommentAddResult,
|
||||
LinearCreateRequest,
|
||||
LinearCreateResult,
|
||||
LinearIssueContextResult,
|
||||
LinearIssueInclude,
|
||||
LinearIssueRequest,
|
||||
LinearSearchResult
|
||||
LinearSearchResult,
|
||||
LinearStatusSetRequest,
|
||||
LinearStatusSetResult,
|
||||
LinearWriteTargetRequest
|
||||
} from '../../shared/linear-agent-access'
|
||||
import {
|
||||
LINEAR_CHILDREN_MAX_DEPTH,
|
||||
LINEAR_WRITE_BODY_CAP,
|
||||
clampLinearIssueDepth,
|
||||
clampLinearSearchLimit
|
||||
} from '../../shared/linear-agent-access'
|
||||
@@ -15,17 +27,24 @@ import {
|
||||
getOptionalNonNegativeIntegerFlag,
|
||||
getOptionalPositiveIntegerFlag,
|
||||
getOptionalStringFlag,
|
||||
getRequiredStringFlagAllowingEmpty,
|
||||
getRequiredStringFlag
|
||||
} from '../flags'
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
import {
|
||||
formatLinearAttach,
|
||||
formatLinearCommentAdd,
|
||||
formatLinearCreate,
|
||||
formatLinearIssue,
|
||||
formatLinearSearch,
|
||||
formatLinearStatusSet,
|
||||
printLinearIssueWarnings,
|
||||
printLinearSearchWarnings
|
||||
} from '../linear-format'
|
||||
|
||||
const ISSUE_CONTEXT_TIMEOUT_MS = 120_000
|
||||
const LINEAR_WRITE_TIMEOUT_MS = 75_000
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
export const LINEAR_HANDLERS: Record<string, CommandHandler> = {
|
||||
'linear issue': async ({ flags, client, cwd, json }) => {
|
||||
@@ -49,6 +68,67 @@ export const LINEAR_HANDLERS: Record<string, CommandHandler> = {
|
||||
printLinearSearchWarnings(response.result)
|
||||
}
|
||||
printResult(response, json, formatLinearSearch)
|
||||
},
|
||||
'linear status set': async ({ flags, client, cwd, json }) => {
|
||||
const request: LinearStatusSetRequest = {
|
||||
...buildWriteTargetRequest(flags, cwd, client.isRemote),
|
||||
to: getRequiredStringFlag(flags, 'to')
|
||||
}
|
||||
const response = await client.call<LinearStatusSetResult>('linear.issueSetState', request, {
|
||||
timeoutMs: LINEAR_WRITE_TIMEOUT_MS
|
||||
})
|
||||
printResult(response, json, formatLinearStatusSet)
|
||||
},
|
||||
'linear comment add': async ({ flags, client, cwd, json }) => {
|
||||
const body = await readLinearBody(flags, cwd, { required: true })
|
||||
const request: LinearCommentAddRequest = {
|
||||
...buildWriteTargetRequest(flags, cwd, client.isRemote),
|
||||
body,
|
||||
replyTo: getOptionalStringFlag(flags, 'reply-to'),
|
||||
writeId: getOptionalWriteId(flags)
|
||||
}
|
||||
const response = await client.call<LinearCommentAddResult>('linear.issueAddComment', request, {
|
||||
timeoutMs: LINEAR_WRITE_TIMEOUT_MS
|
||||
})
|
||||
printResult(response, json, formatLinearCommentAdd)
|
||||
},
|
||||
'linear attach': async ({ flags, client, cwd, json }) => {
|
||||
const request: LinearAttachRequest = {
|
||||
...buildWriteTargetRequest(flags, cwd, client.isRemote),
|
||||
url: getHttpUrlFlag(flags, 'url'),
|
||||
title: getOptionalStringFlag(flags, 'title'),
|
||||
writeId: getOptionalWriteId(flags)
|
||||
}
|
||||
const response = await client.call<LinearAttachResult>('linear.issueAttachLink', request, {
|
||||
timeoutMs: LINEAR_WRITE_TIMEOUT_MS
|
||||
})
|
||||
printResult(response, json, formatLinearAttach)
|
||||
},
|
||||
'linear create': async ({ flags, client, cwd, json }) => {
|
||||
rejectAllWorkspaceForWrite(flags)
|
||||
const parentInput = getOptionalStringFlag(flags, 'parent')
|
||||
const parentCurrent = flags.get('parent-current') === true
|
||||
if (parentInput && parentCurrent) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Use either --parent or --parent-current, not both'
|
||||
)
|
||||
}
|
||||
const body = await readLinearBody(flags, cwd, { required: false })
|
||||
const request: LinearCreateRequest = {
|
||||
title: getRequiredStringFlag(flags, 'title'),
|
||||
...(body !== undefined ? { body } : {}),
|
||||
teamKey: getOptionalStringFlag(flags, 'team'),
|
||||
parentInput,
|
||||
parentCurrent,
|
||||
workspaceId: getOptionalStringFlag(flags, 'workspace'),
|
||||
writeId: getOptionalWriteId(flags),
|
||||
context: buildLinearCurrentContext(cwd, client.isRemote)
|
||||
}
|
||||
const response = await client.call<LinearCreateResult>('linear.issueCreate', request, {
|
||||
timeoutMs: LINEAR_WRITE_TIMEOUT_MS
|
||||
})
|
||||
printResult(response, json, formatLinearCreate)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,13 +168,124 @@ function buildIssueRequest(
|
||||
workspaceId,
|
||||
include: includes,
|
||||
depth: clampLinearIssueDepth(requestedDepth),
|
||||
context: {
|
||||
remote,
|
||||
...(remote ? {} : { cwd }),
|
||||
...(process.env.ORCA_WORKTREE_ID ? { worktreeId: process.env.ORCA_WORKTREE_ID } : {}),
|
||||
...(process.env.ORCA_TERMINAL_HANDLE
|
||||
? { terminalHandle: process.env.ORCA_TERMINAL_HANDLE }
|
||||
: {})
|
||||
}
|
||||
context: buildLinearCurrentContext(cwd, remote)
|
||||
}
|
||||
}
|
||||
|
||||
function buildWriteTargetRequest(
|
||||
flags: Map<string, string | boolean>,
|
||||
cwd: string,
|
||||
remote: boolean
|
||||
): LinearWriteTargetRequest {
|
||||
rejectAllWorkspaceForWrite(flags)
|
||||
const input = getOptionalStringFlag(flags, 'id')
|
||||
const current = flags.get('current') === true
|
||||
if (input && current) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Pass either <id> or --current, not both')
|
||||
}
|
||||
if (!input && !current) {
|
||||
throw new RuntimeClientError('linear_issue_required', 'Pass a Linear issue id or --current')
|
||||
}
|
||||
return {
|
||||
input,
|
||||
current,
|
||||
workspaceId: getOptionalStringFlag(flags, 'workspace'),
|
||||
context: buildLinearCurrentContext(cwd, remote)
|
||||
}
|
||||
}
|
||||
|
||||
function buildLinearCurrentContext(cwd: string, remote: boolean): LinearIssueRequest['context'] {
|
||||
return {
|
||||
remote,
|
||||
...(remote ? {} : { cwd }),
|
||||
...(process.env.ORCA_WORKTREE_ID ? { worktreeId: process.env.ORCA_WORKTREE_ID } : {}),
|
||||
...(process.env.ORCA_TERMINAL_HANDLE
|
||||
? { terminalHandle: process.env.ORCA_TERMINAL_HANDLE }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
function rejectAllWorkspaceForWrite(flags: Map<string, string | boolean>): void {
|
||||
if (getOptionalStringFlag(flags, 'workspace') === 'all') {
|
||||
throw new RuntimeClientError(
|
||||
'linear_invalid_workspace',
|
||||
'--workspace all is not valid for Linear writes'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function getOptionalWriteId(flags: Map<string, string | boolean>): string | undefined {
|
||||
if (!flags.has('write-id')) {
|
||||
return undefined
|
||||
}
|
||||
const writeId = getRequiredStringFlag(flags, 'write-id')
|
||||
if (!UUID_PATTERN.test(writeId)) {
|
||||
throw new RuntimeClientError('linear_invalid_write_id', '--write-id must be a UUID')
|
||||
}
|
||||
return writeId
|
||||
}
|
||||
|
||||
function getHttpUrlFlag(flags: Map<string, string | boolean>, name: string): string {
|
||||
const value = getRequiredStringFlag(flags, name)
|
||||
try {
|
||||
const parsed = new URL(value)
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||
return value
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the stable Linear error below.
|
||||
}
|
||||
throw new RuntimeClientError('linear_invalid_url', '--url must be an absolute http(s) URL')
|
||||
}
|
||||
|
||||
function readLinearBody(
|
||||
flags: Map<string, string | boolean>,
|
||||
cwd: string,
|
||||
options: { required: true }
|
||||
): Promise<string>
|
||||
function readLinearBody(
|
||||
flags: Map<string, string | boolean>,
|
||||
cwd: string,
|
||||
options: { required: false }
|
||||
): Promise<string | undefined>
|
||||
async function readLinearBody(
|
||||
flags: Map<string, string | boolean>,
|
||||
cwd: string,
|
||||
options: { required: boolean }
|
||||
): Promise<string | undefined> {
|
||||
const hasBody = flags.has('body')
|
||||
const hasBodyFile = flags.has('body-file')
|
||||
if (hasBody && hasBodyFile) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Use either --body or --body-file, not both')
|
||||
}
|
||||
if (!hasBody && !hasBodyFile) {
|
||||
if (options.required) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Missing --body or --body-file')
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const body = hasBody
|
||||
? getRequiredStringFlagAllowingEmpty(flags, 'body')
|
||||
: await readLinearBodyFile(getRequiredStringFlag(flags, 'body-file'), cwd)
|
||||
if (body.length > LINEAR_WRITE_BODY_CAP) {
|
||||
throw new RuntimeClientError(
|
||||
'linear_body_too_large',
|
||||
`Linear body must be at most ${LINEAR_WRITE_BODY_CAP} characters`
|
||||
)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
async function readLinearBodyFile(path: string, cwd: string): Promise<string> {
|
||||
if (path !== '-') {
|
||||
return await readFile(isAbsolute(path) ? path : join(cwd, path), 'utf8')
|
||||
}
|
||||
if (process.stdin.isTTY) {
|
||||
throw new RuntimeClientError('invalid_argument', 'stdin body requested but stdin is a TTY')
|
||||
}
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)))
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf8')
|
||||
}
|
||||
|
||||
@@ -387,6 +387,42 @@ 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.startsWith('linear ') && flag === 'workspace') {
|
||||
return '--workspace <id> Connected Linear workspace id'
|
||||
}
|
||||
if (command.startsWith('linear ') && flag === 'body') {
|
||||
return '--body <text> Linear comment or issue body'
|
||||
}
|
||||
if (command.startsWith('linear ') && flag === 'body-file') {
|
||||
return '--body-file <path|-> Read Linear body from a file or stdin'
|
||||
}
|
||||
if (command.startsWith('linear ') && flag === 'write-id') {
|
||||
return '--write-id <uuid> Retry id from linear_write_unconfirmed'
|
||||
}
|
||||
if (command.startsWith('linear ') && flag === 'to') {
|
||||
return '--to <state> Exact Linear workflow state name'
|
||||
}
|
||||
if (command === 'linear comment add' && flag === 'reply-to') {
|
||||
return '--reply-to <id> Comment id to reply to'
|
||||
}
|
||||
if (command === 'linear attach' && flag === 'url') {
|
||||
return '--url <url> Absolute http(s) link to attach'
|
||||
}
|
||||
if (command === 'linear attach' && flag === 'title') {
|
||||
return '--title <text> Attachment title'
|
||||
}
|
||||
if (command === 'linear create' && flag === 'title') {
|
||||
return '--title <text> New Linear issue title'
|
||||
}
|
||||
if (command === 'linear create' && flag === 'team') {
|
||||
return '--team <key> Linear team key'
|
||||
}
|
||||
if (command === 'linear create' && flag === 'parent') {
|
||||
return '--parent <id> Parent Linear issue key, id, or URL'
|
||||
}
|
||||
if (command === 'linear create' && flag === 'parent-current') {
|
||||
return '--parent-current Use the current linked issue as parent'
|
||||
}
|
||||
if (flag === 'key' && command === 'computer hotkey') {
|
||||
return '--key <key-combo> Modifier chord with one key, e.g. CmdOrCtrl+A'
|
||||
}
|
||||
|
||||
@@ -196,6 +196,67 @@ describe('orca root help', () => {
|
||||
expect(searchHelp).toContain('--query <text> Text to search across Linear issues')
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('progressively discloses Linear commands', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['--help'], '/tmp/repo')
|
||||
|
||||
const rootHelp = String(logSpy.mock.calls[0][0])
|
||||
expect(rootHelp).toContain('Linear:')
|
||||
expect(rootHelp).toContain('linear Read Linear ticket context for agents')
|
||||
expect(rootHelp).not.toContain('linear issue')
|
||||
expect(rootHelp).not.toContain('linear search')
|
||||
expect(rootHelp).not.toContain('linear status set')
|
||||
|
||||
logSpy.mockClear()
|
||||
await main(['linear', '--help'], '/tmp/repo')
|
||||
|
||||
const groupHelp = String(logSpy.mock.calls[0][0])
|
||||
expect(groupHelp).toContain('orca linear')
|
||||
expect(groupHelp).toContain('issue')
|
||||
expect(groupHelp).toContain('search')
|
||||
expect(groupHelp).toContain('status set')
|
||||
expect(groupHelp).toContain('comment add')
|
||||
expect(groupHelp).toContain('attach')
|
||||
expect(groupHelp).toContain('create')
|
||||
expect(groupHelp).not.toContain('--comments')
|
||||
expect(groupHelp).not.toContain('--attachments')
|
||||
|
||||
logSpy.mockClear()
|
||||
await main(['linear', 'issue', '--help'], '/tmp/repo')
|
||||
|
||||
const issueHelp = String(logSpy.mock.calls[0][0])
|
||||
expect(issueHelp).toContain('orca linear issue [<id>]')
|
||||
expect(issueHelp).toContain('--comments Include threaded Linear comments')
|
||||
expect(issueHelp).toContain('--attachments Include attachment metadata and URLs')
|
||||
expect(issueHelp).toContain('--workspace <id> Connected Linear workspace id')
|
||||
expect(issueHelp).toContain('--id <id> Linear issue key, id, or URL')
|
||||
|
||||
logSpy.mockClear()
|
||||
await main(['linear', 'search', '--help'], '/tmp/repo')
|
||||
|
||||
const searchHelp = String(logSpy.mock.calls[0][0])
|
||||
expect(searchHelp).toContain('orca linear search <query>')
|
||||
expect(searchHelp).toContain('--workspace <id|all> Connected Linear workspace id, or all')
|
||||
expect(searchHelp).toContain('--query <text> Text to search across Linear issues')
|
||||
|
||||
logSpy.mockClear()
|
||||
await main(['linear', 'comment', 'add', '--help'], '/tmp/repo')
|
||||
|
||||
const commentHelp = String(logSpy.mock.calls[0][0])
|
||||
expect(commentHelp).toContain('orca linear comment add [<id>]')
|
||||
expect(commentHelp).toContain('--body-file <path|-> Read Linear body from a file or stdin')
|
||||
expect(commentHelp).toContain('--write-id <uuid> Retry id from linear_write_unconfirmed')
|
||||
|
||||
logSpy.mockClear()
|
||||
await main(['linear', 'create', '--help'], '/tmp/repo')
|
||||
|
||||
const createHelp = String(logSpy.mock.calls[0][0])
|
||||
expect(createHelp).toContain('orca linear create --title <title>')
|
||||
expect(createHelp).toContain('--parent-current Use the current linked issue as parent')
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('orca cli worktree awareness', () => {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type {
|
||||
LinearAttachResult,
|
||||
LinearCommentAddResult,
|
||||
LinearCreateResult,
|
||||
LinearIssueContextResult,
|
||||
LinearSearchIssueSummary,
|
||||
LinearSearchResult
|
||||
LinearSearchResult,
|
||||
LinearStatusSetResult
|
||||
} from '../shared/linear-agent-access'
|
||||
|
||||
export function formatLinearIssue(result: LinearIssueContextResult): string {
|
||||
@@ -44,6 +48,27 @@ export function formatLinearSearch(result: LinearSearchResult): string {
|
||||
return result.issues.map(formatSearchRow).join('\n')
|
||||
}
|
||||
|
||||
export function formatLinearStatusSet(result: LinearStatusSetResult): string {
|
||||
const suffix = result.meta.alreadyInState ? ' (already set)' : ''
|
||||
return `Set ${result.issue.identifier} to ${result.state.name}${suffix}.`
|
||||
}
|
||||
|
||||
export function formatLinearCommentAdd(result: LinearCommentAddResult): string {
|
||||
const suffix = result.meta.deduplicated ? ' (already posted)' : ''
|
||||
return `Added comment ${result.comment.id} to ${result.issue.identifier}${suffix}.`
|
||||
}
|
||||
|
||||
export function formatLinearAttach(result: LinearAttachResult): string {
|
||||
const suffix = result.meta.deduplicated ? ' (already attached)' : ''
|
||||
return `Attached ${result.attachment.title} to ${result.issue.identifier}${suffix}.`
|
||||
}
|
||||
|
||||
export function formatLinearCreate(result: LinearCreateResult): string {
|
||||
const parent = result.issue.parent ? ` under ${result.issue.parent.identifier}` : ''
|
||||
const suffix = result.meta.deduplicated ? ' (already created)' : ''
|
||||
return `Created ${result.issue.identifier}${parent}: ${result.issue.title}${suffix}.`
|
||||
}
|
||||
|
||||
export function printLinearIssueWarnings(result: LinearIssueContextResult): void {
|
||||
for (const error of result.meta.includeErrors) {
|
||||
console.error(`warning: ${error.include} unavailable: ${error.message}`)
|
||||
|
||||
@@ -33,5 +33,72 @@ export const LINEAR_COMMAND_SPECS: CommandSpec[] = [
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'limit', 'workspace', 'query'],
|
||||
positionalArgs: ['query'],
|
||||
examples: ['orca linear search "auth bug"', 'orca linear search ENG --workspace all --json']
|
||||
},
|
||||
{
|
||||
path: ['linear', 'status', 'set'],
|
||||
summary: 'Set a Linear issue status',
|
||||
usage: 'orca linear status set [<id>] [--current] --to <state> [--workspace <id>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'current', 'to', 'workspace', 'id'],
|
||||
positionalArgs: ['id'],
|
||||
examples: [
|
||||
'orca linear status set ENG-123 --to "In Review"',
|
||||
'orca linear status set --current --to Done --json'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['linear', 'comment', 'add'],
|
||||
summary: 'Add a comment to a Linear issue',
|
||||
usage:
|
||||
'orca linear comment add [<id>] [--current] (--body <text> | --body-file <path|->) [--reply-to <commentId>] [--write-id <uuid>] [--workspace <id>] [--json]',
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'current',
|
||||
'body',
|
||||
'body-file',
|
||||
'reply-to',
|
||||
'write-id',
|
||||
'workspace',
|
||||
'id'
|
||||
],
|
||||
positionalArgs: ['id'],
|
||||
examples: [
|
||||
'orca linear comment add ENG-123 --body "Implementation is ready for review."',
|
||||
'orca linear comment add --current --body-file - --json'
|
||||
],
|
||||
notes: ['Use --body-file - to read multiline comment bodies from stdin.']
|
||||
},
|
||||
{
|
||||
path: ['linear', 'attach'],
|
||||
summary: 'Attach a link to a Linear issue',
|
||||
usage:
|
||||
'orca linear attach [<id>] [--current] --url <url> [--title <title>] [--write-id <uuid>] [--workspace <id>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'current', 'url', 'title', 'write-id', 'workspace', 'id'],
|
||||
positionalArgs: ['id'],
|
||||
examples: [
|
||||
'orca linear attach ENG-123 --url https://example.com/review/123 --title "PR/MR link"',
|
||||
'orca linear attach --current --url https://example.com/review/123 --json'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['linear', 'create'],
|
||||
summary: 'Create a Linear issue',
|
||||
usage:
|
||||
'orca linear create --title <title> [--body <text> | --body-file <path|->] [--team <key>] [--parent <id> | --parent-current] [--write-id <uuid>] [--workspace <id>] [--json]',
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'title',
|
||||
'body',
|
||||
'body-file',
|
||||
'team',
|
||||
'parent',
|
||||
'parent-current',
|
||||
'write-id',
|
||||
'workspace'
|
||||
],
|
||||
examples: [
|
||||
'orca linear create --title "Investigate flaky login" --team ENG',
|
||||
'orca linear create --title "Follow-up bug" --parent-current --body-file - --json'
|
||||
],
|
||||
notes: ['Use --body-file - to read multiline issue bodies from stdin.']
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { createReadStream } from 'fs'
|
||||
import { readFile } from 'fs/promises'
|
||||
import { dirname, join } from 'path'
|
||||
import { createInterface } from 'readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
|
||||
import {
|
||||
addPreviewMessage,
|
||||
createAccumulator,
|
||||
finalizeSession,
|
||||
sessionIdFromFileName,
|
||||
updateTimeline
|
||||
} from './session-scanner-accumulator'
|
||||
import {
|
||||
asRecord,
|
||||
extractString,
|
||||
normalizeTitleText,
|
||||
numberValue,
|
||||
parseJsonObject
|
||||
} from './session-scanner-values'
|
||||
|
||||
export async function parseGrokSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown)
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
const info = asRecord(record.info)
|
||||
const sessionId = extractString(info?.id) ?? sessionIdFromFileName(dirname(file.path))
|
||||
const accumulator = createAccumulator({ agent: 'grok', file, sessionId })
|
||||
accumulator.cwd = extractString(info?.cwd)
|
||||
accumulator.title =
|
||||
normalizeTitleText(extractString(record.generated_title) ?? '') ??
|
||||
normalizeTitleText(extractString(record.session_summary) ?? '')
|
||||
accumulator.model = extractString(record.current_model_id)
|
||||
accumulator.branch = extractString(record.head_branch)
|
||||
accumulator.messageCount =
|
||||
numberValue(record.num_chat_messages) || numberValue(record.num_messages)
|
||||
updateTimeline(accumulator, extractString(record.created_at))
|
||||
updateTimeline(accumulator, extractString(record.updated_at))
|
||||
updateTimeline(accumulator, extractString(record.last_active_at))
|
||||
await consumeGrokChatHistory(accumulator, dirname(file.path))
|
||||
return finalizeSession(accumulator, platform)
|
||||
}
|
||||
|
||||
async function consumeGrokChatHistory(
|
||||
accumulator: SessionAccumulator,
|
||||
sessionDir: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const lines = createInterface({
|
||||
input: createReadStream(join(sessionDir, 'chat_history.jsonl'), { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
|
||||
for await (const line of lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
const role = extractString(record.type)
|
||||
if (role !== 'user' && role !== 'assistant') {
|
||||
continue
|
||||
}
|
||||
const text = extractGrokContentText(record.content)
|
||||
if (role === 'user') {
|
||||
accumulator.title ??= normalizeTitleText(text ?? '')
|
||||
}
|
||||
addPreviewMessage(accumulator, {
|
||||
role,
|
||||
text,
|
||||
timestamp: extractString(record.timestamp)
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Summary-only sessions still provide enough metadata for the Vault list.
|
||||
}
|
||||
}
|
||||
|
||||
function extractGrokContentText(value: unknown): string | null {
|
||||
const text = extractGrokRawContentText(value)
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
return text.match(/<user_query>\s*([\s\S]*?)\s*<\/user_query>/i)?.[1]?.trim() || text
|
||||
}
|
||||
|
||||
function extractGrokRawContentText(value: unknown): string | null {
|
||||
if (typeof value === 'string') {
|
||||
return extractString(value)
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
return null
|
||||
}
|
||||
const parts: string[] = []
|
||||
for (const item of value) {
|
||||
if (typeof item === 'string') {
|
||||
parts.push(item)
|
||||
continue
|
||||
}
|
||||
const record = asRecord(item)
|
||||
const text = extractString(record?.text) || extractString(record?.content)
|
||||
if (text) {
|
||||
parts.push(text)
|
||||
}
|
||||
}
|
||||
return extractString(parts.join(' '))
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export type AiVaultScanOptions = {
|
||||
copilotSessionsDir?: string
|
||||
cursorProjectsDir?: string
|
||||
opencodeStorageDir?: string
|
||||
grokSessionsDir?: string
|
||||
hermesSessionsDir?: string
|
||||
rovoSessionsDir?: string
|
||||
openclawStateDir?: string
|
||||
|
||||
@@ -20,6 +20,7 @@ function isolatedScanRoots(root: string) {
|
||||
copilotSessionsDir: join(root, 'copilot-sessions'),
|
||||
cursorProjectsDir: join(root, 'cursor-projects'),
|
||||
opencodeStorageDir: join(root, 'opencode-storage'),
|
||||
grokSessionsDir: join(root, 'grok-sessions'),
|
||||
hermesSessionsDir: join(root, 'hermes-sessions'),
|
||||
rovoSessionsDir: join(root, 'rovo-sessions'),
|
||||
openclawStateDir: join(root, 'openclaw-state'),
|
||||
@@ -422,6 +423,42 @@ describe('scanAiVaultSessions', () => {
|
||||
})
|
||||
)
|
||||
|
||||
await mkdir(join(roots.grokSessionsDir, encodeURIComponent('/tmp/grok'), 'grok-session'), {
|
||||
recursive: true
|
||||
})
|
||||
await writeFile(
|
||||
join(roots.grokSessionsDir, encodeURIComponent('/tmp/grok'), 'grok-session', 'summary.json'),
|
||||
JSON.stringify({
|
||||
info: { id: 'grok-session', cwd: '/tmp/grok' },
|
||||
session_summary: '',
|
||||
created_at: '2026-05-01T10:04:00.000Z',
|
||||
updated_at: '2026-05-01T10:04:01.000Z',
|
||||
num_chat_messages: 2,
|
||||
current_model_id: 'grok-build',
|
||||
head_branch: 'feature/grok-vault'
|
||||
})
|
||||
)
|
||||
await writeFile(
|
||||
join(
|
||||
roots.grokSessionsDir,
|
||||
encodeURIComponent('/tmp/grok'),
|
||||
'grok-session',
|
||||
'chat_history.jsonl'
|
||||
),
|
||||
jsonLines([
|
||||
{
|
||||
type: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '<user_info>context</user_info><user_query>Grok title</user_query>'
|
||||
}
|
||||
]
|
||||
},
|
||||
{ type: 'assistant', content: 'Done' }
|
||||
])
|
||||
)
|
||||
|
||||
await mkdir(roots.hermesSessionsDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.hermesSessionsDir, 'session_hermes-session.json'),
|
||||
@@ -544,6 +581,7 @@ describe('scanAiVaultSessions', () => {
|
||||
expect(commandByAgent.get('opencode')).toBe(
|
||||
"cd '/tmp/opencode' && opencode --session 'opencode-session'"
|
||||
)
|
||||
expect(commandByAgent.get('grok')).toBe("cd '/tmp/grok' && grok --resume 'grok-session'")
|
||||
expect(commandByAgent.get('hermes')).toBe(
|
||||
"cd '/tmp/hermes' && hermes --resume 'hermes-session'"
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
import { sessionSortTime } from './session-scanner-accumulator'
|
||||
import { codexHomeForSessionsDir, uniqueCodexSessionsDirs } from './session-scanner-codex-paths'
|
||||
import { discoverFiles, discoverOpenClawFiles } from './session-scanner-discovery'
|
||||
import { parseGrokSessionFile } from './session-scanner-grok-parser'
|
||||
import {
|
||||
parseDroidSessionFile,
|
||||
parseMessageGraphSessionFile,
|
||||
@@ -53,15 +54,17 @@ const OPENCODE_STORAGE_DIR = join(
|
||||
process.env.OPENCODE_CONFIG_DIR?.trim() || join(homedir(), '.local', 'share', 'opencode'),
|
||||
'storage'
|
||||
)
|
||||
const GROK_SESSIONS_DIR = join(
|
||||
process.env.GROK_HOME?.trim() || join(homedir(), '.grok'),
|
||||
'sessions'
|
||||
)
|
||||
const HERMES_SESSIONS_DIR = join(homedir(), '.hermes', 'sessions')
|
||||
const ROVO_SESSIONS_DIR = join(homedir(), '.rovodev', 'sessions')
|
||||
const OPENCLAW_STATE_DIR = process.env.OPENCLAW_STATE_DIR?.trim() || join(homedir(), '.openclaw')
|
||||
const OPENCLAW_LEGACY_STATE_DIR = join(homedir(), '.clawdbot')
|
||||
const PI_SESSIONS_DIR = normalizePiSessionsDir(
|
||||
process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), '.pi', 'agent', 'sessions')
|
||||
)
|
||||
const DROID_SESSIONS_DIR = join(homedir(), '.factory', 'sessions')
|
||||
const DROID_PROJECTS_DIR = join(homedir(), '.factory', 'projects')
|
||||
|
||||
export async function scanAiVaultSessions(
|
||||
options: AiVaultScanOptions = {}
|
||||
@@ -121,6 +124,14 @@ export async function scanAiVaultSessions(
|
||||
issues,
|
||||
extensions: ['.json']
|
||||
}),
|
||||
discoverFiles({
|
||||
rootDir: options.grokSessionsDir ?? GROK_SESSIONS_DIR,
|
||||
limit: limitPerAgent,
|
||||
agent: 'grok',
|
||||
issues,
|
||||
extensions: ['.json'],
|
||||
filePredicate: (path) => basename(path) === 'summary.json'
|
||||
}),
|
||||
discoverFiles({
|
||||
rootDir: options.hermesSessionsDir ?? HERMES_SESSIONS_DIR,
|
||||
limit: limitPerAgent,
|
||||
@@ -140,7 +151,7 @@ export async function scanAiVaultSessions(
|
||||
discoverOpenClawFiles({
|
||||
rootDirs: [
|
||||
options.openclawStateDir ?? OPENCLAW_STATE_DIR,
|
||||
options.openclawLegacyStateDir ?? OPENCLAW_LEGACY_STATE_DIR
|
||||
options.openclawLegacyStateDir ?? join(homedir(), '.clawdbot')
|
||||
],
|
||||
limit: limitPerAgent,
|
||||
issues
|
||||
@@ -160,7 +171,7 @@ export async function scanAiVaultSessions(
|
||||
extensions: ['.jsonl']
|
||||
}),
|
||||
discoverFiles({
|
||||
rootDir: options.droidProjectsDir ?? DROID_PROJECTS_DIR,
|
||||
rootDir: options.droidProjectsDir ?? join(homedir(), '.factory', 'projects'),
|
||||
limit: limitPerAgent,
|
||||
agent: 'droid',
|
||||
issues,
|
||||
@@ -274,6 +285,8 @@ async function parseAgentSessionFile(
|
||||
return parseCursorSessionFile(candidate.file, platform)
|
||||
case 'opencode':
|
||||
return parseOpenCodeSessionFile(candidate.file, platform)
|
||||
case 'grok':
|
||||
return parseGrokSessionFile(candidate.file, platform)
|
||||
case 'hermes':
|
||||
return parseHermesSessionFile(candidate.file, platform)
|
||||
case 'rovo':
|
||||
|
||||
@@ -2872,7 +2872,7 @@ describe('ClaudeRuntimeAuthService', () => {
|
||||
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials)
|
||||
})
|
||||
|
||||
it('does not clobber unverified live runtime credentials when switching accounts', async () => {
|
||||
it('switches accounts without persisting unverified live runtime credentials', async () => {
|
||||
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
|
||||
const account1Original = createClaudeCredentialsJson('one@example.com', 'one-original', 'org-a')
|
||||
const unverifiedLiveCredentials = createClaudeCredentialsWithoutEmail('one-live', 'org-b')
|
||||
@@ -2912,15 +2912,16 @@ describe('ClaudeRuntimeAuthService', () => {
|
||||
writeFileSync(runtimeCredentialsPath, unverifiedLiveCredentials, 'utf-8')
|
||||
settings.activeClaudeManagedAccountId = 'account-2'
|
||||
|
||||
await expect(service.syncForCurrentSelection()).rejects.toThrow(
|
||||
'live Claude terminal has unverified refreshed auth'
|
||||
)
|
||||
await service.syncForCurrentSelection()
|
||||
} finally {
|
||||
markClaudePtyExited('live-claude-pty')
|
||||
}
|
||||
|
||||
expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(account1Original)
|
||||
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(unverifiedLiveCredentials)
|
||||
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account2Credentials)
|
||||
if (process.platform === 'darwin') {
|
||||
expect(testState.scopedKeychainCredentials).toBe(account2Credentials)
|
||||
}
|
||||
})
|
||||
|
||||
it('routes refreshed Claude credentials to the matching managed account', async () => {
|
||||
|
||||
@@ -211,8 +211,12 @@ export class ClaudeRuntimeAuthService {
|
||||
outgoingReadBackResult.runtimeCredentialsJson
|
||||
)
|
||||
} else {
|
||||
throw new Error(
|
||||
'Claude account switch paused because a live Claude terminal has unverified refreshed auth.'
|
||||
// Why: Claude's runtime credential blob can lack enough identity
|
||||
// proof to attribute a live-session refresh. Do not persist that
|
||||
// unverified blob, but also do not block the user from moving new
|
||||
// terminals to the selected managed account.
|
||||
console.warn(
|
||||
'[claude-runtime-auth] Skipping unverified live Claude auth read-back while switching accounts'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -803,7 +803,7 @@ describe('ClaudeAccountService credential capture', () => {
|
||||
}
|
||||
const runtimeAuth = {
|
||||
syncForCurrentSelection: vi.fn(async () => {
|
||||
throw new Error('unverified live auth')
|
||||
throw new Error('runtime sync failed')
|
||||
}),
|
||||
forceMaterializeCurrentSelectionForRollback: vi.fn(async () => {})
|
||||
}
|
||||
@@ -817,7 +817,7 @@ describe('ClaudeAccountService credential capture', () => {
|
||||
runtimeAuth as never
|
||||
)
|
||||
|
||||
await expect(service.selectAccount('account-2')).rejects.toThrow('unverified live auth')
|
||||
await expect(service.selectAccount('account-2')).rejects.toThrow('runtime sync failed')
|
||||
|
||||
expect(settings.activeClaudeManagedAccountId).toBe('account-1')
|
||||
expect(settings.activeClaudeManagedAccountIdsByRuntime).toEqual({
|
||||
|
||||
@@ -82,6 +82,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
||||
localAccountRuntime: 'host',
|
||||
localAccountWslDistro: null,
|
||||
openLinksInApp: false,
|
||||
openLinksInAppPreferencePrompted: false,
|
||||
rightSidebarOpenByDefault: true,
|
||||
sourceControlViewMode: 'list',
|
||||
showTitlebarAppName: true,
|
||||
@@ -136,6 +137,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
||||
terminalWindowsPowerShellImplementation: 'powershell.exe',
|
||||
enableGitHubAttribution: true,
|
||||
...overrides,
|
||||
leftSidebarAppearanceMode: overrides.leftSidebarAppearanceMode ?? 'default',
|
||||
appFontFamily,
|
||||
agentStatusHooksEnabled,
|
||||
tabAutoGenerateTitle
|
||||
|
||||
@@ -86,6 +86,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
||||
localAccountRuntime: 'host',
|
||||
localAccountWslDistro: null,
|
||||
openLinksInApp: false,
|
||||
openLinksInAppPreferencePrompted: false,
|
||||
rightSidebarOpenByDefault: true,
|
||||
sourceControlViewMode: 'list',
|
||||
showTitlebarAppName: true,
|
||||
@@ -140,6 +141,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
||||
terminalWindowsPowerShellImplementation: 'powershell.exe',
|
||||
enableGitHubAttribution: true,
|
||||
...overrides,
|
||||
leftSidebarAppearanceMode: overrides.leftSidebarAppearanceMode ?? 'default',
|
||||
appFontFamily,
|
||||
agentStatusHooksEnabled,
|
||||
tabAutoGenerateTitle
|
||||
|
||||
@@ -147,19 +147,7 @@ export function checkDaemonHealth(
|
||||
}
|
||||
|
||||
if (message.id === 'health-1') {
|
||||
if (!message.ok) {
|
||||
settle('unhealthy')
|
||||
return
|
||||
}
|
||||
// Why: protocol ping only proves the socket loop is alive. New
|
||||
// terminals also depend on node-pty's native helper state inside
|
||||
// the daemon process, which can go stale after dev rebuilds.
|
||||
sock?.write(encodeNdjson({ id: 'health-2', type: 'ptySpawnHealth' }))
|
||||
continue
|
||||
}
|
||||
|
||||
if (message.id === 'health-2') {
|
||||
settle(message.ok ? 'healthy' : 'pty-spawn-unhealthy')
|
||||
settle(message.ok === true)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,6 +275,7 @@ async function importFresh() {
|
||||
checkDaemonHealthMock.mockClear()
|
||||
checkDaemonHealthMock.mockResolvedValue('healthy')
|
||||
healthCheckDaemonMock.mockClear()
|
||||
healthCheckDaemonMock.mockResolvedValue(true)
|
||||
getMacDaemonSystemResolverHealthMock.mockReset()
|
||||
getMacDaemonSystemResolverHealthMock.mockReturnValue('healthy')
|
||||
getDaemonLaunchIdentityMock.mockClear()
|
||||
@@ -1173,7 +1174,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
expect(child.unref).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves a spawn-unhealthy daemon when it owns live sessions', async () => {
|
||||
it('preserves a health-check-failing daemon when it owns live sessions', async () => {
|
||||
const mod = await importFresh()
|
||||
await mod.initDaemonPtyProvider()
|
||||
|
||||
@@ -1198,7 +1199,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
socketPath: string,
|
||||
tokenPath: string
|
||||
) => Promise<{ shutdown(): Promise<void> }>
|
||||
checkDaemonHealthMock.mockResolvedValueOnce('pty-spawn-unhealthy')
|
||||
healthCheckDaemonMock.mockResolvedValueOnce(false)
|
||||
|
||||
await launcher('/fake/socket', '/fake/token')
|
||||
|
||||
@@ -1208,7 +1209,51 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
expect(forkMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('replaces a spawn-unhealthy daemon when no live sessions would be lost', async () => {
|
||||
it('replaces a health-check-failing daemon when live sessions cannot be verified', async () => {
|
||||
const mod = await importFresh()
|
||||
await mod.initDaemonPtyProvider()
|
||||
|
||||
daemonClientMock.mockImplementationOnce(function MockDaemonClient() {
|
||||
return {
|
||||
ensureConnected: vi.fn(async () => {
|
||||
throw new Error('daemon is wedged')
|
||||
}),
|
||||
request: vi.fn(),
|
||||
disconnect: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
const launcher = spawnerInstances[0].launcher as (
|
||||
socketPath: string,
|
||||
tokenPath: string
|
||||
) => Promise<{ shutdown(): Promise<void> }>
|
||||
healthCheckDaemonMock.mockResolvedValueOnce(false)
|
||||
forkMock.mockImplementationOnce(() => ({
|
||||
pid: 12345,
|
||||
on(event: string, cb: (arg?: unknown) => void) {
|
||||
if (event === 'message') {
|
||||
queueMicrotask(() => cb({ type: 'ready' }))
|
||||
}
|
||||
return this
|
||||
},
|
||||
off() {
|
||||
return this
|
||||
},
|
||||
disconnect: vi.fn(),
|
||||
unref: vi.fn()
|
||||
}))
|
||||
|
||||
await launcher('/fake/socket', '/fake/token')
|
||||
|
||||
expect(killStaleDaemonMock).toHaveBeenCalledWith(
|
||||
'/fake/userData/daemon',
|
||||
'/fake/socket',
|
||||
'/fake/token'
|
||||
)
|
||||
expect(forkMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('replaces a health-check-failing daemon when no live sessions would be lost', async () => {
|
||||
const mod = await importFresh()
|
||||
await mod.initDaemonPtyProvider()
|
||||
|
||||
@@ -1216,7 +1261,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
socketPath: string,
|
||||
tokenPath: string
|
||||
) => Promise<{ shutdown(): Promise<void> }>
|
||||
checkDaemonHealthMock.mockResolvedValueOnce('pty-spawn-unhealthy')
|
||||
healthCheckDaemonMock.mockResolvedValueOnce(false)
|
||||
forkMock.mockImplementationOnce(() => {
|
||||
const handlers: Record<string, ((arg?: unknown) => void)[]> = {
|
||||
message: [],
|
||||
|
||||
@@ -189,15 +189,21 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
|
||||
console.warn('[daemon] Replacing daemon that cannot spawn new PTYs')
|
||||
await cleanupDaemonForProtocol(runtimeDir, PROTOCOL_VERSION)
|
||||
} else {
|
||||
const resolverHealth = await getMacDaemonSystemResolverHealth(socketPath, tokenPath)
|
||||
if (resolverHealth === 'unhealthy') {
|
||||
const liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath)
|
||||
if (liveSessionCount !== 0) {
|
||||
console.warn(
|
||||
liveSessionCount === null
|
||||
? '[daemon] Preserving daemon with unavailable macOS system resolver because live session state could not be verified'
|
||||
: `[daemon] Preserving daemon with unavailable macOS system resolver because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}`
|
||||
)
|
||||
// Why: a protocol-healthy daemon can outlive the app bundle that
|
||||
// launched it. In dev this happens after deleting/rebuilding a
|
||||
// worktree; in packaged apps it happens when the stable
|
||||
// /Applications/Orca.app path is replaced during update.
|
||||
const identity = await getDaemonLaunchIdentity(runtimeDir, socketPath, tokenPath, entryPath)
|
||||
const stalePackagedBundle =
|
||||
app.isPackaged &&
|
||||
(await isDaemonStaleForCurrentBundle(runtimeDir, socketPath, tokenPath, app.getVersion()))
|
||||
if (identity === 'mismatch' || stalePackagedBundle) {
|
||||
// Why: replacing a healthy daemon kills its child PTYs; defer code
|
||||
// freshness until no live terminal sessions would be lost.
|
||||
const replacementLabel = stalePackagedBundle
|
||||
? 'launched before the current app bundle was installed'
|
||||
: 'launched from a different app path'
|
||||
if (await shouldPreserveDaemonWithLiveSessions(socketPath, tokenPath, replacementLabel)) {
|
||||
return createPreservedDaemonHandle(runtimeDir)
|
||||
}
|
||||
console.warn('[daemon] Replacing daemon with unavailable macOS system resolver')
|
||||
@@ -245,6 +251,20 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Why: a busy machine (e.g. right after an update) can time out the
|
||||
// health check while the daemon is alive and owning terminals. Killing
|
||||
// it would destroy every live session, so re-verify with a session list
|
||||
// first. Only a verified non-empty list preserves: a daemon that cannot
|
||||
// even list sessions cannot serve terminals, and replacing it is the
|
||||
// only recovery.
|
||||
const liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath)
|
||||
if (liveSessionCount !== null && liveSessionCount > 0) {
|
||||
console.warn(
|
||||
`[daemon] Preserving daemon that failed the health check because it owns ${liveSessionCount} live session${liveSessionCount === 1 ? '' : 's'}`
|
||||
)
|
||||
return createPreservedDaemonHandle(runtimeDir)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: a raw socket can outlive a broken or wedged daemon. Kill by PID
|
||||
@@ -367,6 +387,14 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
|
||||
|
||||
export async function initDaemonPtyProvider(signal?: AbortSignal): Promise<void> {
|
||||
logDaemonMilestone('daemon-init-start')
|
||||
// Why: e2e coverage for the startup PTY gate (#5232) needs a daemon init
|
||||
// that deterministically outlasts the first-window timeout. Real triggers
|
||||
// (stale-daemon cleanup, legacy probes on a busy disk) are not controllable
|
||||
// from a test.
|
||||
const e2eInitDelayMs = Number(process.env.ORCA_E2E_DAEMON_INIT_DELAY_MS)
|
||||
if (Number.isFinite(e2eInitDelayMs) && e2eInitDelayMs > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, e2eInitDelayMs))
|
||||
}
|
||||
const runtimeDir = getRuntimeDir()
|
||||
|
||||
const newSpawner = new DaemonSpawner({
|
||||
|
||||
@@ -7,7 +7,6 @@ import { DaemonPtyAdapter } from './daemon-pty-adapter'
|
||||
import { DaemonServer } from './daemon-server'
|
||||
import { getHistorySessionDirName } from './history-paths'
|
||||
import type { SubprocessHandle } from './session'
|
||||
import type { GetSnapshotResult, TerminalSnapshot } from './types'
|
||||
import type * as DaemonHealthModule from './daemon-health'
|
||||
|
||||
const { getMacDaemonSystemResolverHealthMock } = vi.hoisted(() => ({
|
||||
@@ -58,27 +57,6 @@ function createMockSubprocess(): SubprocessHandle & {
|
||||
}
|
||||
}
|
||||
|
||||
function createTestSnapshot(label: string): TerminalSnapshot {
|
||||
return {
|
||||
snapshotAnsi: label,
|
||||
scrollbackAnsi: '',
|
||||
rehydrateSequences: '',
|
||||
cwd: null,
|
||||
modes: {
|
||||
bracketedPaste: false,
|
||||
mouseTracking: false,
|
||||
mouseTrackingMode: 'none',
|
||||
sgrMouseMode: false,
|
||||
sgrMousePixelsMode: false,
|
||||
applicationCursor: false,
|
||||
alternateScreen: false
|
||||
},
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
scrollbackLines: 0
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
|
||||
const start = Date.now()
|
||||
while (!predicate()) {
|
||||
@@ -536,7 +514,7 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('checkpoints only dirty sessions on the periodic timer', async () => {
|
||||
it('appends increments for only dirty sessions on the periodic timer', async () => {
|
||||
const adapterClass = DaemonPtyAdapter as unknown as { CHECKPOINT_INTERVAL_MS: number }
|
||||
const previousInterval = adapterClass.CHECKPOINT_INTERVAL_MS
|
||||
adapterClass.CHECKPOINT_INTERVAL_MS = 25
|
||||
@@ -550,22 +528,33 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
sessionId: 'dirty-checkpoint'
|
||||
})
|
||||
const checkpointSpy = vi.spyOn(historyAdapter.getHistoryManager()!, 'checkpoint')
|
||||
const appendSpy = vi.spyOn(historyAdapter.getHistoryManager()!, 'appendIncrements')
|
||||
|
||||
await new Promise((r) => setTimeout(r, 80))
|
||||
|
||||
// Why: idle terminals can be numerous. A periodic pass with no data
|
||||
// must not serialize every live daemon session just because it exists.
|
||||
expect(checkpointSpy).not.toHaveBeenCalled()
|
||||
expect(appendSpy).not.toHaveBeenCalled()
|
||||
|
||||
lastSubprocess._simulateData('new output\r\n')
|
||||
await waitFor(() => checkpointSpy.mock.calls.length === 1)
|
||||
expect(checkpointSpy).toHaveBeenCalledWith(
|
||||
id,
|
||||
expect.objectContaining({ snapshotAnsi: expect.stringContaining('new output') })
|
||||
)
|
||||
await waitFor(() => appendSpy.mock.calls.length === 1)
|
||||
expect(appendSpy).toHaveBeenCalledWith(id, expect.any(Number), [
|
||||
{ kind: 'output', data: 'new output\r\n' }
|
||||
])
|
||||
// Why: the periodic tick must persist increments, never re-serialize
|
||||
// the full emulator buffer (the issue #5096 stall).
|
||||
expect(checkpointSpy).not.toHaveBeenCalled()
|
||||
const logPath = join(historyDir, getHistorySessionDirName(id), 'output.log')
|
||||
await waitFor(() => {
|
||||
try {
|
||||
return readFileSync(logPath).includes('new output')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
await new Promise((r) => setTimeout(r, 80))
|
||||
expect(checkpointSpy).toHaveBeenCalledTimes(1)
|
||||
expect(appendSpy).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
adapterClass.CHECKPOINT_INTERVAL_MS = previousInterval
|
||||
}
|
||||
@@ -577,30 +566,38 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
const requestedSessionIds: string[] = []
|
||||
let inFlight = 0
|
||||
let maxInFlight = 0
|
||||
const request = vi.fn(
|
||||
async (_type: string, payload: { sessionId: string }): Promise<GetSnapshotResult> => {
|
||||
requestedSessionIds.push(payload.sessionId)
|
||||
inFlight++
|
||||
maxInFlight = Math.max(maxInFlight, inFlight)
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseSnapshotRequests.push(() => {
|
||||
inFlight--
|
||||
resolve()
|
||||
})
|
||||
const request = vi.fn(async (_type: string, payload: { sessionId: string }) => {
|
||||
requestedSessionIds.push(payload.sessionId)
|
||||
inFlight++
|
||||
maxInFlight = Math.max(maxInFlight, inFlight)
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseSnapshotRequests.push(() => {
|
||||
inFlight--
|
||||
resolve()
|
||||
})
|
||||
return { snapshot: createTestSnapshot(payload.sessionId) }
|
||||
})
|
||||
return {
|
||||
records: [{ kind: 'output', data: payload.sessionId }],
|
||||
seq: 1,
|
||||
overflowed: false,
|
||||
snapshot: null
|
||||
}
|
||||
)
|
||||
})
|
||||
const checkpoint = vi.fn(async () => {})
|
||||
const appendIncrements = vi.fn(async () => 'ok' as const)
|
||||
const dispose = vi.fn(async () => {})
|
||||
const disconnect = vi.fn()
|
||||
const internals = historyAdapter as unknown as {
|
||||
client: { request: typeof request; disconnect: typeof disconnect }
|
||||
historyManager: { checkpoint: typeof checkpoint; dispose: typeof dispose }
|
||||
historyManager: {
|
||||
checkpoint: typeof checkpoint
|
||||
appendIncrements: typeof appendIncrements
|
||||
dispose: typeof dispose
|
||||
}
|
||||
checkpointSessions(sessionIds: Iterable<string>): Promise<Set<string>>
|
||||
}
|
||||
internals.client = { request, disconnect }
|
||||
internals.historyManager = { checkpoint, dispose }
|
||||
internals.historyManager = { checkpoint, appendIncrements, dispose }
|
||||
|
||||
const checkpointing = internals.checkpointSessions(['a', 'b', 'c', 'd', 'e', 'f'])
|
||||
await waitFor(() => requestedSessionIds.length === 4)
|
||||
@@ -619,7 +616,8 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
release()
|
||||
}
|
||||
await expect(checkpointing).resolves.toEqual(new Set(['a', 'b', 'c', 'd', 'e', 'f']))
|
||||
expect(checkpoint).toHaveBeenCalledTimes(6)
|
||||
expect(appendIncrements).toHaveBeenCalledTimes(6)
|
||||
expect(checkpoint).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not schedule a checkpoint timer until a session is dirty', async () => {
|
||||
@@ -750,6 +748,58 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('re-anchors a cold-restored session with a full checkpoint on the first tick', async () => {
|
||||
const adapterClass = DaemonPtyAdapter as unknown as { CHECKPOINT_INTERVAL_MS: number }
|
||||
const previousInterval = adapterClass.CHECKPOINT_INTERVAL_MS
|
||||
adapterClass.CHECKPOINT_INTERVAL_MS = 25
|
||||
|
||||
try {
|
||||
// Simulate a previous daemon crash with stale checkpoint + log files.
|
||||
const sessionId = 'cold-restore-reanchor'
|
||||
const sessionDir = join(historyDir, getHistorySessionDirName(sessionId))
|
||||
mkdirSync(sessionDir, { recursive: true })
|
||||
writeFileSync(
|
||||
join(sessionDir, 'meta.json'),
|
||||
JSON.stringify({
|
||||
cwd: '/projects/myapp',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
startedAt: '2026-04-15T10:00:00Z',
|
||||
endedAt: null,
|
||||
exitCode: null
|
||||
})
|
||||
)
|
||||
writeFileSync(join(sessionDir, 'scrollback.bin'), 'pre-crash output\r\n')
|
||||
|
||||
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
|
||||
const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId })
|
||||
expect(result.coldRestore).toBeDefined()
|
||||
|
||||
const checkpointSpy = vi.spyOn(historyAdapter.getHistoryManager()!, 'checkpoint')
|
||||
const appendSpy = vi.spyOn(historyAdapter.getHistoryManager()!, 'appendIncrements')
|
||||
|
||||
lastSubprocess._simulateData('revived session output\r\n')
|
||||
await waitFor(() => checkpointSpy.mock.calls.length === 1)
|
||||
|
||||
// Why: appending the fresh session's records to the pre-crash log
|
||||
// would be rejected by the sequence check on a second crash, reverting
|
||||
// the restore to pre-crash content. The full checkpoint resets the log
|
||||
// to a new generation.
|
||||
expect(appendSpy).not.toHaveBeenCalled()
|
||||
expect(checkpointSpy).toHaveBeenCalledWith(
|
||||
sessionId,
|
||||
expect.objectContaining({ snapshotAnsi: expect.stringContaining('revived session') })
|
||||
)
|
||||
|
||||
// Subsequent ticks return to incremental appends.
|
||||
lastSubprocess._simulateData('later output\r\n')
|
||||
await waitFor(() => appendSpy.mock.calls.length === 1)
|
||||
expect(checkpointSpy).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
adapterClass.CHECKPOINT_INTERVAL_MS = previousInterval
|
||||
}
|
||||
})
|
||||
|
||||
it('returns same cold restore on StrictMode double-mount (sticky cache)', async () => {
|
||||
const sessionId = 'sticky-cache-test'
|
||||
const sessionDir = join(historyDir, getHistorySessionDirName(sessionId))
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
type DaemonEvent,
|
||||
type GetSnapshotResult,
|
||||
type ListSessionsResult,
|
||||
type SessionInfo
|
||||
type SessionInfo,
|
||||
type TakePendingOutputResult
|
||||
} from './types'
|
||||
import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types'
|
||||
import { isShellProcess } from '../../shared/agent-detection'
|
||||
@@ -71,11 +72,20 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
private coldRestoreCache = new Map<string, { scrollback: string; cwd: string }>()
|
||||
private activeSessionIds = new Set<string>()
|
||||
private dirtySessionVersions = new Map<string, number>()
|
||||
// Why: a cold-restored session is a fresh shell whose on-disk checkpoint and
|
||||
// log belong to the pre-crash session. Incremental appends would land on
|
||||
// that stale log (and be rejected by its sequence check on restore), so the
|
||||
// first tick must re-anchor with a full snapshot checkpoint, which resets
|
||||
// the log to a new generation.
|
||||
private sessionsNeedingFullCheckpoint = new Set<string>()
|
||||
private checkpointTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private checkpointInFlight: Promise<void> | null = null
|
||||
// Why: checkpoint-based persistence requires the getSnapshot RPC (v4+).
|
||||
// Legacy daemons reject it, causing noisy log spam every 5 seconds.
|
||||
private supportsCheckpoints: boolean
|
||||
// Why: incremental checkpoints require the takePendingOutput RPC (v13+).
|
||||
// Against older daemons the tick falls back to full-snapshot checkpoints.
|
||||
private supportsIncrementalCheckpoints: boolean
|
||||
private static CHECKPOINT_INTERVAL_MS = 5_000
|
||||
|
||||
constructor(opts: DaemonPtyAdapterOptions) {
|
||||
@@ -91,6 +101,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
this.historyReader = opts.historyPath ? new HistoryReader(opts.historyPath) : null
|
||||
this.respawnFn = opts.respawn ?? null
|
||||
this.supportsCheckpoints = this.protocolVersion >= 4
|
||||
this.supportsIncrementalCheckpoints = this.protocolVersion >= 13
|
||||
}
|
||||
|
||||
getHistoryManager(): HistoryManager | null {
|
||||
@@ -188,6 +199,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
// the next 5s tick, the checkpoint is the only recovery data available.
|
||||
if (this.historyManager) {
|
||||
this.historyManager.registerWriter(sessionId)
|
||||
this.sessionsNeedingFullCheckpoint.add(sessionId)
|
||||
}
|
||||
if (scrollback) {
|
||||
const coldRestore = { scrollback, cwd: restoreInfo.cwd }
|
||||
@@ -615,17 +627,22 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
}
|
||||
|
||||
// Why: the adapter runs in the Electron main process and does not have direct
|
||||
// access to daemon Session objects. It calls the getSnapshot RPC over the
|
||||
// daemon socket per session. Returns a promise that resolves when all
|
||||
// checkpoint writes complete (callers that don't need to wait can void it).
|
||||
// access to daemon Session objects. It calls checkpoint RPCs over the daemon
|
||||
// socket per session. Returns a promise that resolves when all checkpoint
|
||||
// writes complete (callers that don't need to wait can void it).
|
||||
// Why final=true here: this runs on clean disconnect, where the full-depth
|
||||
// snapshot (not the increment log) must be the restore source.
|
||||
private async checkpointAllSessions(): Promise<void> {
|
||||
const completed = await this.checkpointSessions(this.activeSessionIds)
|
||||
const completed = await this.checkpointSessions(this.activeSessionIds, { final: true })
|
||||
for (const sessionId of completed) {
|
||||
this.dirtySessionVersions.delete(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
private async checkpointSessions(sessionIds: Iterable<string>): Promise<Set<string>> {
|
||||
private async checkpointSessions(
|
||||
sessionIds: Iterable<string>,
|
||||
opts?: { final?: boolean }
|
||||
): Promise<Set<string>> {
|
||||
const completed = new Set<string>()
|
||||
if (!this.historyManager) {
|
||||
return completed
|
||||
@@ -641,16 +658,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
return
|
||||
}
|
||||
const sessionId = ids[index]
|
||||
await this.client
|
||||
.request<GetSnapshotResult>('getSnapshot', { sessionId })
|
||||
.then((result) => {
|
||||
if (result.snapshot && this.historyManager) {
|
||||
return this.historyManager.checkpoint(sessionId, result.snapshot).then(() => {
|
||||
completed.add(sessionId)
|
||||
})
|
||||
}
|
||||
await this.checkpointSession(sessionId, opts?.final === true)
|
||||
.then(() => {
|
||||
completed.add(sessionId)
|
||||
return undefined
|
||||
})
|
||||
.catch((err) => console.warn('[history] checkpoint failed:', sessionId, err))
|
||||
}
|
||||
@@ -665,6 +675,65 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
return completed
|
||||
}
|
||||
|
||||
private async checkpointSession(sessionId: string, final: boolean): Promise<void> {
|
||||
if (!this.supportsIncrementalCheckpoints) {
|
||||
const result = await this.client.request<GetSnapshotResult>('getSnapshot', { sessionId })
|
||||
if (result.snapshot && this.historyManager) {
|
||||
await this.historyManager.checkpoint(sessionId, result.snapshot)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (final || this.sessionsNeedingFullCheckpoint.has(sessionId)) {
|
||||
// Why take-with-snapshot instead of plain getSnapshot: the take clears
|
||||
// the daemon's pending records in the same synchronous turn as the
|
||||
// serialize. A plain snapshot would leave pre-snapshot records pending;
|
||||
// a later warm reattach would append them to the fresh log and cold
|
||||
// restore would replay them on top of a checkpoint that already
|
||||
// contains them.
|
||||
await this.takeSnapshotAndCheckpoint(sessionId)
|
||||
this.sessionsNeedingFullCheckpoint.delete(sessionId)
|
||||
return
|
||||
}
|
||||
const take = await this.client.request<TakePendingOutputResult | null>('takePendingOutput', {
|
||||
sessionId
|
||||
})
|
||||
if (!take) {
|
||||
return
|
||||
}
|
||||
if (take.overflowed) {
|
||||
// Why: overflow dropped records, so the log has a hole — only a full
|
||||
// snapshot (which reflects everything ever written) can re-anchor it.
|
||||
await this.takeSnapshotAndCheckpoint(sessionId)
|
||||
return
|
||||
}
|
||||
if (take.records.length === 0) {
|
||||
return
|
||||
}
|
||||
if (!this.historyManager) {
|
||||
return
|
||||
}
|
||||
const appendResult = await this.historyManager.appendIncrements(
|
||||
sessionId,
|
||||
take.seq,
|
||||
take.records
|
||||
)
|
||||
if (appendResult === 'needs-checkpoint') {
|
||||
// Why dropping take.records is lossless: they were applied to the live
|
||||
// emulator before the take, so the snapshot below contains them.
|
||||
await this.takeSnapshotAndCheckpoint(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
private async takeSnapshotAndCheckpoint(sessionId: string): Promise<void> {
|
||||
const take = await this.client.request<TakePendingOutputResult | null>('takePendingOutput', {
|
||||
sessionId,
|
||||
includeSnapshot: true
|
||||
})
|
||||
if (take?.snapshot && this.historyManager) {
|
||||
await this.historyManager.checkpoint(sessionId, take.snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: when the daemon process dies, operations fail with ENOENT (socket
|
||||
// gone), ECONNREFUSED, or "Connection lost" (socket closed mid-request).
|
||||
// Rather than leaving all terminals permanently broken until app restart,
|
||||
|
||||
@@ -198,19 +198,19 @@ describe('DaemonServer', () => {
|
||||
expect(result).toEqual({ pong: true })
|
||||
})
|
||||
|
||||
it('handles ptySpawnHealth through the daemon process', async () => {
|
||||
const ptySpawnHealthCheck = vi.fn(async () => {})
|
||||
server = new DaemonServer({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
ptySpawnHealthCheck,
|
||||
spawnSubprocess: () => createMockSubprocess()
|
||||
})
|
||||
await server.start()
|
||||
it('replies with an error to unknown request types and keeps serving', async () => {
|
||||
await startServer()
|
||||
const c = await connectClient()
|
||||
|
||||
await expect(c.request('ptySpawnHealth', undefined)).resolves.toEqual({ healthy: true })
|
||||
expect(ptySpawnHealthCheck).toHaveBeenCalledOnce()
|
||||
// Why: older app builds still send the removed ptySpawnHealth probe.
|
||||
// The daemon must reject it gracefully so a downgraded client lands on
|
||||
// its session-preserving branch instead of crashing the daemon.
|
||||
await expect(c.request('ptySpawnHealth', undefined)).rejects.toThrow(
|
||||
'Unknown request type: ptySpawnHealth'
|
||||
)
|
||||
await expect(c.request<{ pong: boolean }>('ping', undefined)).resolves.toEqual({
|
||||
pong: true
|
||||
})
|
||||
})
|
||||
|
||||
it('handles systemResolverHealth', async () => {
|
||||
|
||||
@@ -373,6 +373,16 @@ export class DaemonServer {
|
||||
case 'getSnapshot':
|
||||
return { snapshot: this.host.getSnapshot(request.payload.sessionId) }
|
||||
|
||||
case 'takePendingOutput':
|
||||
// Why no await before this call: with includeSnapshot, drain and
|
||||
// serialize must share one synchronous turn — an intervening await
|
||||
// would let PTY data land in between, and cold restore would replay
|
||||
// those bytes on top of a snapshot that already contains them.
|
||||
return this.host.takePendingOutput(
|
||||
request.payload.sessionId,
|
||||
request.payload.includeSnapshot === true
|
||||
)
|
||||
|
||||
case 'ping':
|
||||
return { pong: true }
|
||||
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { HeadlessEmulator } from './headless-emulator'
|
||||
|
||||
// Benchmark harness for issue #5096 (terminal output delay / UI lag growing
|
||||
// with session history). Run with:
|
||||
// ORCA_TERMINAL_PERF_BENCH=1 pnpm vitest run \
|
||||
// src/main/daemon/headless-emulator-snapshot-cost.bench.test.ts \
|
||||
// --config config/vitest.config.ts
|
||||
//
|
||||
// Why these measurements: during an active agent session every PTY chunk marks
|
||||
// the session dirty, so daemon-pty-adapter checkpoints every 5s. getSnapshot()
|
||||
// serializes the full headless buffer synchronously on the daemon event loop
|
||||
// (stalling the PTY pump), and history-manager JSON.stringifies the result on
|
||||
// the Electron main process (stalling input IPC). Both stalls scale with
|
||||
// buffer content, which matches the report that clearing history fixes the lag.
|
||||
const benchEnabled = process.env.ORCA_TERMINAL_PERF_BENCH === '1'
|
||||
|
||||
const COLS = 200
|
||||
const ROWS = 50
|
||||
const DAEMON_DEFAULT_SCROLLBACK = 5_000
|
||||
const RENDERER_SCALE_SCROLLBACK = 50_000
|
||||
const SNAPSHOT_ITERATIONS = 5
|
||||
const FILL_WRITE_CHUNK_LINES = 200
|
||||
|
||||
type BenchRow = {
|
||||
scenario: string
|
||||
bufferRows: number
|
||||
fillMs: number
|
||||
snapshotMedianMs: number
|
||||
snapshotMaxMs: number
|
||||
snapshotBytes: number
|
||||
checkpointStringifyMs: number
|
||||
reflowMs: number
|
||||
}
|
||||
|
||||
function agentTranscriptLine(index: number): string {
|
||||
// Mimic agent TUI transcripts: SGR colors, tool-call box drawing, varied
|
||||
// widths — serialized cost depends on attribute churn, not just row count.
|
||||
const color = 30 + (index % 8)
|
||||
const variant = index % 4
|
||||
if (variant === 0) {
|
||||
return `\x1b[1;${color}m● Tool call ${index}\x1b[0m \x1b[2m(src/example/file-${index % 97}.ts)\x1b[0m\r\n`
|
||||
}
|
||||
if (variant === 1) {
|
||||
return `\x1b[${color}m│\x1b[0m ${'response token '.repeat(1 + (index % 7))}#${index}\r\n`
|
||||
}
|
||||
if (variant === 2) {
|
||||
return `\x1b[38;5;${index % 256}m${'═'.repeat(20 + (index % 60))}\x1b[0m\r\n`
|
||||
}
|
||||
return ` \x1b[32m+\x1b[0m line ${index}: ${'x'.repeat(10 + (index % 80))}\r\n`
|
||||
}
|
||||
|
||||
function fillEmulator(emulator: HeadlessEmulator, lines: number): number {
|
||||
const start = performance.now()
|
||||
for (let offset = 0; offset < lines; offset += FILL_WRITE_CHUNK_LINES) {
|
||||
let chunk = ''
|
||||
const end = Math.min(offset + FILL_WRITE_CHUNK_LINES, lines)
|
||||
for (let index = offset; index < end; index += 1) {
|
||||
chunk += agentTranscriptLine(index)
|
||||
}
|
||||
void emulator.write(chunk)
|
||||
}
|
||||
return performance.now() - start
|
||||
}
|
||||
|
||||
function medianOf(values: number[]): number {
|
||||
const sorted = [...values].sort((a, b) => a - b)
|
||||
return sorted[Math.floor(sorted.length / 2)] ?? 0
|
||||
}
|
||||
|
||||
function measureCheckpointStringify(emulator: HeadlessEmulator): number {
|
||||
const snapshot = emulator.getSnapshot()
|
||||
const start = performance.now()
|
||||
// Mirrors history-manager.ts checkpoint(): the payload main stringifies and
|
||||
// writes to disk every 5s per dirty session.
|
||||
JSON.stringify({
|
||||
snapshotAnsi: snapshot.snapshotAnsi,
|
||||
scrollbackAnsi: snapshot.scrollbackAnsi,
|
||||
rehydrateSequences: snapshot.rehydrateSequences,
|
||||
cwd: snapshot.cwd,
|
||||
cols: snapshot.cols,
|
||||
rows: snapshot.rows,
|
||||
modes: snapshot.modes,
|
||||
scrollbackLines: snapshot.scrollbackLines,
|
||||
checkpointedAt: new Date().toISOString()
|
||||
})
|
||||
return performance.now() - start
|
||||
}
|
||||
|
||||
function measureReflow(emulator: HeadlessEmulator): number {
|
||||
const start = performance.now()
|
||||
emulator.resize(COLS - 1, ROWS)
|
||||
emulator.resize(COLS, ROWS)
|
||||
return performance.now() - start
|
||||
}
|
||||
|
||||
function runScenario(scenario: string, scrollback: number, fillLines: number): BenchRow {
|
||||
const emulator = new HeadlessEmulator({ cols: COLS, rows: ROWS, scrollback })
|
||||
try {
|
||||
const fillMs = fillEmulator(emulator, fillLines)
|
||||
const durations: number[] = []
|
||||
let snapshotBytes = 0
|
||||
for (let iteration = 0; iteration < SNAPSHOT_ITERATIONS; iteration += 1) {
|
||||
const start = performance.now()
|
||||
const snapshot = emulator.getSnapshot()
|
||||
durations.push(performance.now() - start)
|
||||
snapshotBytes = Buffer.byteLength(snapshot.snapshotAnsi, 'utf8')
|
||||
}
|
||||
const checkpointStringifyMs = measureCheckpointStringify(emulator)
|
||||
const reflowMs = measureReflow(emulator)
|
||||
return {
|
||||
scenario,
|
||||
bufferRows: fillLines,
|
||||
fillMs: round(fillMs),
|
||||
snapshotMedianMs: round(medianOf(durations)),
|
||||
snapshotMaxMs: round(Math.max(...durations)),
|
||||
snapshotBytes,
|
||||
checkpointStringifyMs: round(checkpointStringifyMs),
|
||||
reflowMs: round(reflowMs)
|
||||
}
|
||||
} finally {
|
||||
emulator.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function round(value: number): number {
|
||||
return Math.round(value * 100) / 100
|
||||
}
|
||||
|
||||
// Why a file: vitest's default reporter swallows console output from passing
|
||||
// tests; the measurements are the deliverable of this harness.
|
||||
function writeBenchReport(fileName: string, report: unknown): void {
|
||||
const reportPath = join(tmpdir(), fileName)
|
||||
writeFileSync(reportPath, JSON.stringify(report, null, 2))
|
||||
process.stdout.write(`\n[bench] report written to ${reportPath}\n`)
|
||||
}
|
||||
|
||||
// Why: models the daemon event loop. PTY chunks and the checkpoint work share
|
||||
// one thread in the daemon process; the worst inter-chunk gap during a
|
||||
// checkpoint is the output latency a user sees when a tick lands.
|
||||
// 'snapshot' models the pre-#5096 design (full serialize per tick);
|
||||
// 'incremental-take' models the replacement (drain pending records — the
|
||||
// daemon-side cost of the takePendingOutput RPC).
|
||||
async function measureStreamInterference(
|
||||
checkpointAtChunk: number | null,
|
||||
mode: 'snapshot' | 'incremental-take' = 'snapshot'
|
||||
): Promise<{
|
||||
maxGapMs: number
|
||||
checkpointMs: number
|
||||
}> {
|
||||
const emulator = new HeadlessEmulator({
|
||||
cols: COLS,
|
||||
rows: ROWS,
|
||||
scrollback: DAEMON_DEFAULT_SCROLLBACK
|
||||
})
|
||||
try {
|
||||
fillEmulator(emulator, DAEMON_DEFAULT_SCROLLBACK)
|
||||
const totalChunks = 200
|
||||
let pending: { kind: 'output'; data: string }[] = []
|
||||
let maxGapMs = 0
|
||||
let checkpointMs = 0
|
||||
let lastChunkAt = performance.now()
|
||||
for (let chunk = 0; chunk < totalChunks; chunk += 1) {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 2))
|
||||
const now = performance.now()
|
||||
maxGapMs = Math.max(maxGapMs, now - lastChunkAt)
|
||||
const line = agentTranscriptLine(chunk)
|
||||
void emulator.write(line)
|
||||
pending.push({ kind: 'output', data: line })
|
||||
lastChunkAt = performance.now()
|
||||
if (chunk === checkpointAtChunk) {
|
||||
const start = performance.now()
|
||||
if (mode === 'snapshot') {
|
||||
emulator.getSnapshot()
|
||||
} else {
|
||||
const taken = pending
|
||||
pending = []
|
||||
JSON.stringify({ records: taken, seq: 1, overflowed: false, snapshot: null })
|
||||
}
|
||||
checkpointMs = performance.now() - start
|
||||
}
|
||||
}
|
||||
return { maxGapMs: round(maxGapMs), checkpointMs: round(checkpointMs) }
|
||||
} finally {
|
||||
emulator.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(!benchEnabled)('headless emulator snapshot cost (issue #5096 harness)', () => {
|
||||
it('measures daemon checkpoint cost across history sizes', () => {
|
||||
const results: BenchRow[] = [
|
||||
runScenario('empty buffer', DAEMON_DEFAULT_SCROLLBACK, 0),
|
||||
runScenario('short session (1k rows)', DAEMON_DEFAULT_SCROLLBACK, 1_000),
|
||||
runScenario('daemon cap (5k rows)', DAEMON_DEFAULT_SCROLLBACK, DAEMON_DEFAULT_SCROLLBACK),
|
||||
runScenario('renderer-scale (50k rows)', RENDERER_SCALE_SCROLLBACK, RENDERER_SCALE_SCROLLBACK)
|
||||
]
|
||||
|
||||
writeBenchReport('orca-headless-snapshot-bench.json', {
|
||||
interpretation:
|
||||
'snapshotMedianMs stalls the daemon PTY pump per 5s checkpoint; ' +
|
||||
'checkpointStringifyMs stalls Electron main (input IPC); ' +
|
||||
'reflowMs models the renderer-side resize/reflow stall at the same fill.',
|
||||
cols: COLS,
|
||||
rows: ROWS,
|
||||
results
|
||||
})
|
||||
|
||||
expect(results).toHaveLength(4)
|
||||
for (const row of results) {
|
||||
expect(row.snapshotMedianMs).toBeGreaterThanOrEqual(0)
|
||||
}
|
||||
}, 300_000)
|
||||
|
||||
it('measures PTY pump stall when a checkpoint lands mid-stream', async () => {
|
||||
const baseline = await measureStreamInterference(null)
|
||||
const withFullSnapshot = await measureStreamInterference(100, 'snapshot')
|
||||
const withIncrementalTake = await measureStreamInterference(100, 'incremental-take')
|
||||
writeBenchReport('orca-checkpoint-interference-bench.json', {
|
||||
interpretation:
|
||||
'maxGapMs is the worst chunk-to-chunk forwarding delay on the simulated daemon loop. ' +
|
||||
'withFullSnapshot models the old per-5s full serialize; withIncrementalTake models ' +
|
||||
'the incremental checkpoint take that replaced it.',
|
||||
baseline,
|
||||
withFullSnapshot,
|
||||
withIncrementalTake
|
||||
})
|
||||
expect(withFullSnapshot.checkpointMs).toBeGreaterThan(0)
|
||||
}, 300_000)
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import './xterm-env-polyfill'
|
||||
import { Terminal } from '@xterm/headless'
|
||||
import { SerializeAddon } from '@xterm/addon-serialize'
|
||||
import { extractLastOscTitle } from '../../shared/agent-detection'
|
||||
import { parseFileUriPath } from './osc7-file-uri'
|
||||
import type { TerminalSnapshot, TerminalModes } from './types'
|
||||
|
||||
export type HeadlessEmulatorOptions = {
|
||||
@@ -27,34 +28,6 @@ const OSC_SCAN_TAIL_LIMIT = 4096
|
||||
const PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096
|
||||
type MouseTrackingMode = NonNullable<TerminalModes['mouseTrackingMode']>
|
||||
|
||||
function parseFileUriPath(uri: string): string | null {
|
||||
try {
|
||||
const url = new URL(uri)
|
||||
if (url.protocol !== 'file:') {
|
||||
return null
|
||||
}
|
||||
|
||||
const decodedPath = decodeURIComponent(url.pathname)
|
||||
if (process.platform !== 'win32') {
|
||||
return decodedPath
|
||||
}
|
||||
|
||||
// Why: Windows OSC-7 cwd updates can describe both drive-letter paths
|
||||
// (`file:///C:/repo`) and UNC shares (`file://server/share/repo`). Use the
|
||||
// hostname when present so live cwd tracking, snapshots, and restore all
|
||||
// round-trip to a native Windows path instead of dropping the server name.
|
||||
if (url.hostname) {
|
||||
return `\\\\${url.hostname}${decodedPath.replace(/\//g, '\\')}`
|
||||
}
|
||||
if (/^\/[A-Za-z]:/.test(decodedPath)) {
|
||||
return decodedPath.slice(1)
|
||||
}
|
||||
return decodedPath.replace(/\//g, '\\')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export class HeadlessEmulator {
|
||||
private terminal: Terminal
|
||||
private serializer: SerializeAddon
|
||||
@@ -97,21 +70,10 @@ export class HeadlessEmulator {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
const oscInput = this.oscScanTail + data
|
||||
this.oscScanTail = this.extractOscScanTail(oscInput)
|
||||
this.scanOsc7(oscInput)
|
||||
const lastTitle = extractLastOscTitle(oscInput)
|
||||
if (lastTitle !== null) {
|
||||
this.lastTitle = lastTitle
|
||||
}
|
||||
const writeSync = (this.terminal as TerminalWithSynchronousWrite)._core?.writeSync
|
||||
if (typeof writeSync === 'function') {
|
||||
// Why: hidden renderer restore snapshots are requested immediately after
|
||||
// PTY bursts; queued headless writes can snapshot half-cleared TUI rows.
|
||||
writeSync.call((this.terminal as TerminalWithSynchronousWrite)._core, data)
|
||||
this.scanPrivateModes(data)
|
||||
if (this.tryWriteSync(data)) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
this.scanInputForOscState(data)
|
||||
return new Promise<void>((resolve) => {
|
||||
this.terminal.write(data, () => {
|
||||
// Why: snapshots combine serialized xterm state with mirrored mouse
|
||||
@@ -122,6 +84,40 @@ export class HeadlessEmulator {
|
||||
})
|
||||
}
|
||||
|
||||
/** Synchronous write used by cold-restore log replay, where a snapshot is
|
||||
* taken immediately after the last record and queued async writes would
|
||||
* serialize a half-applied stream. Returns false when xterm's synchronous
|
||||
* write path is unavailable — callers must then abandon the replay. */
|
||||
writeSync(data: string): boolean {
|
||||
if (this.disposed) {
|
||||
return false
|
||||
}
|
||||
return this.tryWriteSync(data)
|
||||
}
|
||||
|
||||
private tryWriteSync(data: string): boolean {
|
||||
const writeSync = (this.terminal as TerminalWithSynchronousWrite)._core?.writeSync
|
||||
if (typeof writeSync !== 'function') {
|
||||
return false
|
||||
}
|
||||
this.scanInputForOscState(data)
|
||||
// Why: hidden renderer restore snapshots are requested immediately after
|
||||
// PTY bursts; queued headless writes can snapshot half-cleared TUI rows.
|
||||
writeSync.call((this.terminal as TerminalWithSynchronousWrite)._core, data)
|
||||
this.scanPrivateModes(data)
|
||||
return true
|
||||
}
|
||||
|
||||
private scanInputForOscState(data: string): void {
|
||||
const oscInput = this.oscScanTail + data
|
||||
this.oscScanTail = this.extractOscScanTail(oscInput)
|
||||
this.scanOsc7(oscInput)
|
||||
const lastTitle = extractLastOscTitle(oscInput)
|
||||
if (lastTitle !== null) {
|
||||
this.lastTitle = lastTitle
|
||||
}
|
||||
}
|
||||
|
||||
resize(cols: number, rows: number): void {
|
||||
if (this.disposed) {
|
||||
return
|
||||
|
||||
@@ -6,10 +6,26 @@ import {
|
||||
existsSync,
|
||||
rmSync,
|
||||
unlinkSync,
|
||||
openSync,
|
||||
closeSync,
|
||||
readSync,
|
||||
fstatSync,
|
||||
promises as fsPromises
|
||||
} from 'fs'
|
||||
import { getHistorySessionDirName } from './history-paths'
|
||||
import type { TerminalSnapshot } from './types'
|
||||
import {
|
||||
decodeLogHeader,
|
||||
encodeLogBatch,
|
||||
encodeLogHeader,
|
||||
LOG_HEADER_BYTES
|
||||
} from './terminal-history-log'
|
||||
import type { PendingOutputRecord, TerminalCheckpointFile, TerminalSnapshot } from './types'
|
||||
|
||||
// Why 5MB: bounds both cold-restore replay time and disk usage per session.
|
||||
// Reaching the cap triggers one full snapshot checkpoint (which subsumes and
|
||||
// resets the log) — one O(buffer) serialize per ~5MB of output instead of one
|
||||
// per 5-second tick.
|
||||
const LOG_MAX_BYTES = 5 * 1024 * 1024
|
||||
|
||||
export type SessionMeta = {
|
||||
cwd: string
|
||||
@@ -29,6 +45,12 @@ export type OpenSessionOptions = {
|
||||
type SessionWriter = {
|
||||
dir: string
|
||||
checkpointPath: string
|
||||
logPath: string
|
||||
/** Generation of the on-disk log header. Null until lazily resolved on the
|
||||
* first append after a warm registerWriter (the file may predate us). */
|
||||
logGeneration: number | null
|
||||
/** Current log file size. Null until lazily resolved alongside generation. */
|
||||
logBytes: number | null
|
||||
}
|
||||
|
||||
export type HistoryManagerOptions = {
|
||||
@@ -70,7 +92,8 @@ export class HistoryManager {
|
||||
// because the reader falls back to scrollback.bin when no checkpoint
|
||||
// exists.
|
||||
const checkpointPath = join(dir, 'checkpoint.json')
|
||||
for (const staleFile of [checkpointPath, join(dir, 'scrollback.bin')]) {
|
||||
const logPath = join(dir, 'output.log')
|
||||
for (const staleFile of [checkpointPath, join(dir, 'scrollback.bin'), logPath]) {
|
||||
try {
|
||||
unlinkSync(staleFile)
|
||||
} catch {
|
||||
@@ -80,7 +103,10 @@ export class HistoryManager {
|
||||
|
||||
this.writers.set(sessionId, {
|
||||
dir,
|
||||
checkpointPath
|
||||
checkpointPath,
|
||||
logPath,
|
||||
logGeneration: 0,
|
||||
logBytes: 0
|
||||
})
|
||||
} catch (err) {
|
||||
this.handleWriteError(sessionId, err)
|
||||
@@ -99,13 +125,57 @@ export class HistoryManager {
|
||||
const dir = join(this.basePath, getHistorySessionDirName(sessionId))
|
||||
this.writers.set(sessionId, {
|
||||
dir,
|
||||
checkpointPath: join(dir, 'checkpoint.json')
|
||||
checkpointPath: join(dir, 'checkpoint.json'),
|
||||
logPath: join(dir, 'output.log'),
|
||||
logGeneration: null,
|
||||
logBytes: null
|
||||
})
|
||||
}
|
||||
|
||||
/** Appends one take batch to the incremental log. Returns 'needs-checkpoint'
|
||||
* when the log is at capacity — the caller must take a full snapshot, which
|
||||
* subsumes the un-appended records (they were already applied to the live
|
||||
* emulator) and resets the log via checkpoint(). */
|
||||
async appendIncrements(
|
||||
sessionId: string,
|
||||
seq: number,
|
||||
records: PendingOutputRecord[]
|
||||
): Promise<'ok' | 'needs-checkpoint'> {
|
||||
if (this.disabledSessions.has(sessionId) || records.length === 0) {
|
||||
return 'ok'
|
||||
}
|
||||
const writer = this.writers.get(sessionId)
|
||||
if (!writer) {
|
||||
return 'ok'
|
||||
}
|
||||
try {
|
||||
this.resolveLogState(writer)
|
||||
const batch = encodeLogBatch(seq, records)
|
||||
// Why max(..., header): a fresh log gets its header written below, so
|
||||
// the projected size must include it or the cap can be overshot.
|
||||
const projectedBytes = Math.max(writer.logBytes ?? 0, LOG_HEADER_BYTES) + batch.length
|
||||
if (projectedBytes > LOG_MAX_BYTES) {
|
||||
return 'needs-checkpoint'
|
||||
}
|
||||
if (writer.logBytes === 0) {
|
||||
// Why: header carries the generation that ties this log to its base
|
||||
// checkpoint; written lazily so warm reattaches never clobber a log
|
||||
// that already has appended batches.
|
||||
await fsPromises.writeFile(writer.logPath, encodeLogHeader(writer.logGeneration ?? 0))
|
||||
writer.logBytes = LOG_HEADER_BYTES
|
||||
}
|
||||
await fsPromises.appendFile(writer.logPath, batch)
|
||||
writer.logBytes = (writer.logBytes ?? LOG_HEADER_BYTES) + batch.length
|
||||
return 'ok'
|
||||
} catch (err) {
|
||||
this.handleWriteError(sessionId, err)
|
||||
return 'ok'
|
||||
}
|
||||
}
|
||||
|
||||
// Why: replaces the old appendData (which wrote every PTY chunk to disk).
|
||||
// Checkpoints happen every ~5 seconds from a timer, not on every data event,
|
||||
// so disk I/O drops from O(PTY throughput) to O(1 write per interval).
|
||||
// Full checkpoints are now rare (clean disconnect, pending-buffer overflow,
|
||||
// log cap); the 5s tick appends increments via appendIncrements instead.
|
||||
async checkpoint(sessionId: string, snapshot: TerminalSnapshot): Promise<void> {
|
||||
if (this.disabledSessions.has(sessionId)) {
|
||||
return
|
||||
@@ -126,7 +196,9 @@ export class HistoryManager {
|
||||
effectiveCwd = meta?.cwd ?? null
|
||||
}
|
||||
|
||||
const data = JSON.stringify({
|
||||
this.resolveLogState(writer)
|
||||
const generation = (writer.logGeneration ?? 0) + 1
|
||||
const checkpointFile: TerminalCheckpointFile = {
|
||||
snapshotAnsi: snapshot.snapshotAnsi,
|
||||
scrollbackAnsi: snapshot.scrollbackAnsi,
|
||||
rehydrateSequences: snapshot.rehydrateSequences,
|
||||
@@ -135,23 +207,76 @@ export class HistoryManager {
|
||||
rows: snapshot.rows,
|
||||
modes: snapshot.modes,
|
||||
scrollbackLines: snapshot.scrollbackLines,
|
||||
generation,
|
||||
checkpointedAt: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
const data = JSON.stringify(checkpointFile)
|
||||
// Why: atomic write via tmp+rename prevents half-written checkpoints
|
||||
// on crash. Reading a corrupt checkpoint is worse than reading a
|
||||
// slightly stale one. Async IO — this runs every ~5s per dirty session
|
||||
// on the Electron main process, and a sync ~MB write (worse under
|
||||
// slightly stale one. Async IO — a sync ~MB write (worse under
|
||||
// antivirus scanning on Windows) would stall input/IPC for its
|
||||
// duration. Overlap is prevented by the adapter's checkpointInFlight
|
||||
// guard, which awaits this promise before the next tick.
|
||||
const tmpPath = `${writer.checkpointPath}.tmp`
|
||||
await fsPromises.writeFile(tmpPath, data)
|
||||
await fsPromises.rename(tmpPath, writer.checkpointPath)
|
||||
// Why: the snapshot subsumes every logged record, so the log resets to
|
||||
// the new generation. Crash between rename and this reset is safe: the
|
||||
// stale log's generation no longer matches the checkpoint's, so the
|
||||
// restore reader ignores it.
|
||||
await fsPromises.writeFile(writer.logPath, encodeLogHeader(generation))
|
||||
writer.logGeneration = generation
|
||||
writer.logBytes = LOG_HEADER_BYTES
|
||||
} catch (err) {
|
||||
this.handleWriteError(sessionId, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: a warm registerWriter may attach to a session dir that already has a
|
||||
// log (app relaunch while the daemon kept running). Generation and size are
|
||||
// read from disk once so appends continue the existing stream instead of
|
||||
// clobbering it.
|
||||
private resolveLogState(writer: SessionWriter): void {
|
||||
if (writer.logBytes !== null && writer.logGeneration !== null) {
|
||||
return
|
||||
}
|
||||
let headerGeneration: number | null = null
|
||||
let size = 0
|
||||
try {
|
||||
const fd = openSync(writer.logPath, 'r')
|
||||
try {
|
||||
size = fstatSync(fd).size
|
||||
const header = Buffer.alloc(LOG_HEADER_BYTES)
|
||||
if (readSync(fd, header, 0, LOG_HEADER_BYTES, 0) === LOG_HEADER_BYTES) {
|
||||
headerGeneration = decodeLogHeader(header)
|
||||
}
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
} catch {
|
||||
// Missing log file — fresh state below.
|
||||
}
|
||||
if (headerGeneration !== null) {
|
||||
writer.logGeneration = headerGeneration
|
||||
writer.logBytes = size
|
||||
return
|
||||
}
|
||||
// Missing or unreadable header: logBytes = 0 makes the next append rewrite
|
||||
// the file from scratch (writeFile truncates), so a garbage file cannot be
|
||||
// extended.
|
||||
writer.logBytes = 0
|
||||
writer.logGeneration = this.readCheckpointGeneration(writer) ?? 0
|
||||
}
|
||||
|
||||
private readCheckpointGeneration(writer: SessionWriter): number | null {
|
||||
try {
|
||||
const checkpoint = JSON.parse(readFileSync(writer.checkpointPath, 'utf-8'))
|
||||
return typeof checkpoint.generation === 'number' ? checkpoint.generation : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async closeSession(sessionId: string, exitCode: number): Promise<void> {
|
||||
const writer = this.writers.get(sessionId)
|
||||
if (!writer) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { join } from 'path'
|
||||
import { readFileSync, existsSync, readdirSync } from 'fs'
|
||||
import type { SessionMeta } from './history-manager'
|
||||
import type { TerminalModes } from './types'
|
||||
import type { TerminalCheckpointFile, TerminalModes } from './types'
|
||||
import { getHistorySessionDirName } from './history-paths'
|
||||
import { decodeTerminalHistoryLog } from './terminal-history-log'
|
||||
import { HeadlessEmulator } from './headless-emulator'
|
||||
|
||||
export type ColdRestoreInfo = {
|
||||
snapshotAnsi: string
|
||||
@@ -33,44 +35,33 @@ export class HistoryReader {
|
||||
return null
|
||||
}
|
||||
|
||||
const checkpointPath = join(
|
||||
this.basePath,
|
||||
getHistorySessionDirName(sessionId),
|
||||
'checkpoint.json'
|
||||
)
|
||||
if (!existsSync(checkpointPath)) {
|
||||
// Why: backward compatibility with pre-checkpoint sessions. If the user
|
||||
// upgrades and then the daemon crashes before a checkpoint is written,
|
||||
// the old scrollback.bin is still the best recovery data available.
|
||||
const sessionDir = join(this.basePath, getHistorySessionDirName(sessionId))
|
||||
const checkpointPath = join(sessionDir, 'checkpoint.json')
|
||||
const checkpointExists = existsSync(checkpointPath)
|
||||
let checkpoint: TerminalCheckpointFile | null = null
|
||||
if (checkpointExists) {
|
||||
try {
|
||||
checkpoint = JSON.parse(readFileSync(checkpointPath, 'utf-8'))
|
||||
} catch {
|
||||
checkpoint = null
|
||||
}
|
||||
}
|
||||
|
||||
// Why log replay is preferred over the checkpoint alone: the log carries
|
||||
// byte-exact output up to ~5s before the crash, while the checkpoint can
|
||||
// be a full log-cap (~5MB of output) stale.
|
||||
const logRestore = this.restoreFromIncrementalLog(sessionDir, meta, checkpoint)
|
||||
if (logRestore) {
|
||||
return logRestore
|
||||
}
|
||||
|
||||
if (!checkpoint) {
|
||||
// Why: backward compatibility with pre-checkpoint sessions, and corrupt
|
||||
// checkpoints — the old scrollback.bin is the best remaining data.
|
||||
return this.detectColdRestoreFromScrollback(sessionId, meta)
|
||||
}
|
||||
|
||||
try {
|
||||
const checkpoint = JSON.parse(readFileSync(checkpointPath, 'utf-8'))
|
||||
// Why: HeadlessEmulator.getSnapshot() doesn't populate scrollbackAnsi
|
||||
// (it's always ''). For non-alt-screen checkpoints, snapshotAnsi IS
|
||||
// the normal buffer content and is safe to use as scrollback. For
|
||||
// alt-screen checkpoints, snapshotAnsi is the serialized TUI buffer
|
||||
// (not raw PTY stream), so truncateAltScreen won't find transition
|
||||
// sequences and would return stale TUI content. Return empty instead
|
||||
// — the adapter skips cold restore when scrollbackAnsi is falsy.
|
||||
const scrollbackAnsi =
|
||||
checkpoint.scrollbackAnsi ||
|
||||
(checkpoint.modes?.alternateScreen ? '' : (checkpoint.snapshotAnsi ?? ''))
|
||||
return {
|
||||
snapshotAnsi: checkpoint.snapshotAnsi,
|
||||
scrollbackAnsi,
|
||||
rehydrateSequences: checkpoint.rehydrateSequences,
|
||||
cwd: checkpoint.cwd,
|
||||
cols: checkpoint.cols,
|
||||
rows: checkpoint.rows,
|
||||
modes: checkpoint.modes
|
||||
}
|
||||
} catch {
|
||||
// Why: corrupt checkpoint — fall back to scrollback.bin rather than
|
||||
// discarding recoverable data entirely.
|
||||
return this.detectColdRestoreFromScrollback(sessionId, meta)
|
||||
}
|
||||
return this.coldRestoreInfoFromSnapshot(checkpoint, checkpoint.cwd, meta)
|
||||
}
|
||||
|
||||
listRestorable(): string[] {
|
||||
@@ -105,6 +96,106 @@ export class HistoryReader {
|
||||
return restorable
|
||||
}
|
||||
|
||||
// Why a scratch emulator: replaying base + raw records through the same
|
||||
// emulator the daemon used reproduces the exact terminal state at the last
|
||||
// appended batch — including alt-screen and mode handling — and reuses
|
||||
// getSnapshot()'s normalization instead of string-level reconstruction.
|
||||
private restoreFromIncrementalLog(
|
||||
sessionDir: string,
|
||||
meta: SessionMeta,
|
||||
checkpoint: TerminalCheckpointFile | null
|
||||
): ColdRestoreInfo | null {
|
||||
let logBuffer: Buffer
|
||||
try {
|
||||
logBuffer = readFileSync(join(sessionDir, 'output.log'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const log = decodeTerminalHistoryLog(logBuffer)
|
||||
if (!log || log.batches.length === 0) {
|
||||
return null
|
||||
}
|
||||
// Generation mismatch means the log does not continue this checkpoint
|
||||
// (e.g. crash between checkpoint rename and log reset, or a pre-log
|
||||
// checkpoint without a generation field). Replaying it would duplicate or
|
||||
// garble content; the checkpoint alone is consistent.
|
||||
if (checkpoint) {
|
||||
if (typeof checkpoint.generation !== 'number' || log.generation !== checkpoint.generation) {
|
||||
return null
|
||||
}
|
||||
} else if (log.generation !== 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const emulator = new HeadlessEmulator({
|
||||
cols: checkpoint?.cols ?? meta.cols,
|
||||
rows: checkpoint?.rows ?? meta.rows
|
||||
})
|
||||
try {
|
||||
if (checkpoint) {
|
||||
if (!emulator.writeSync(checkpoint.rehydrateSequences + checkpoint.snapshotAnsi)) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
for (const batch of log.batches) {
|
||||
for (const record of batch.records) {
|
||||
if (record.kind === 'output') {
|
||||
if (!emulator.writeSync(record.data)) {
|
||||
return null
|
||||
}
|
||||
} else if (record.kind === 'resize') {
|
||||
emulator.resize(record.cols, record.rows)
|
||||
} else {
|
||||
emulator.clearScrollback()
|
||||
}
|
||||
}
|
||||
}
|
||||
const snapshot = emulator.getSnapshot()
|
||||
return this.coldRestoreInfoFromSnapshot(
|
||||
snapshot,
|
||||
snapshot.cwd ?? checkpoint?.cwd ?? meta.cwd,
|
||||
meta
|
||||
)
|
||||
} catch {
|
||||
// Why: a replay failure must degrade to checkpoint-only restore, never
|
||||
// surface as a failed spawn.
|
||||
return null
|
||||
} finally {
|
||||
emulator.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
private coldRestoreInfoFromSnapshot(
|
||||
snapshot: {
|
||||
snapshotAnsi: string
|
||||
scrollbackAnsi: string
|
||||
rehydrateSequences: string
|
||||
cols: number
|
||||
rows: number
|
||||
modes: TerminalModes
|
||||
},
|
||||
cwd: string | null,
|
||||
meta: SessionMeta
|
||||
): ColdRestoreInfo {
|
||||
// Why: HeadlessEmulator.getSnapshot() doesn't populate scrollbackAnsi
|
||||
// (it's always ''). For non-alt-screen snapshots, snapshotAnsi IS the
|
||||
// normal buffer content and is safe to use as scrollback. For alt-screen
|
||||
// snapshots, snapshotAnsi is the serialized TUI buffer (not raw PTY
|
||||
// stream); return empty instead — the adapter skips cold restore when
|
||||
// scrollbackAnsi is falsy.
|
||||
const scrollbackAnsi =
|
||||
snapshot.scrollbackAnsi || (snapshot.modes?.alternateScreen ? '' : snapshot.snapshotAnsi)
|
||||
return {
|
||||
snapshotAnsi: snapshot.snapshotAnsi,
|
||||
scrollbackAnsi,
|
||||
rehydrateSequences: snapshot.rehydrateSequences,
|
||||
cwd: cwd ?? meta.cwd,
|
||||
cols: snapshot.cols,
|
||||
rows: snapshot.rows,
|
||||
modes: snapshot.modes
|
||||
}
|
||||
}
|
||||
|
||||
private readMeta(sessionId: string): SessionMeta | null {
|
||||
const metaPath = join(this.basePath, getHistorySessionDirName(sessionId), 'meta.json')
|
||||
if (!existsSync(metaPath)) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
export function parseFileUriPath(uri: string): string | null {
|
||||
try {
|
||||
const url = new URL(uri)
|
||||
if (url.protocol !== 'file:') {
|
||||
return null
|
||||
}
|
||||
|
||||
const decodedPath = decodeURIComponent(url.pathname)
|
||||
if (process.platform !== 'win32') {
|
||||
return decodedPath
|
||||
}
|
||||
|
||||
// Why: Windows OSC-7 cwd updates can describe both drive-letter paths
|
||||
// (`file:///C:/repo`) and UNC shares (`file://server/share/repo`). Use the
|
||||
// hostname when present so live cwd tracking, snapshots, and restore all
|
||||
// round-trip to a native Windows path instead of dropping the server name.
|
||||
if (url.hostname) {
|
||||
return `\\\\${url.hostname}${decodedPath.replace(/\//g, '\\')}`
|
||||
}
|
||||
if (/^\/[A-Za-z]:/.test(decodedPath)) {
|
||||
return decodedPath.slice(1)
|
||||
}
|
||||
return decodedPath.replace(/\//g, '\\')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,11 @@ const {
|
||||
spawnMock,
|
||||
isPwshAvailableMock,
|
||||
validateWorkingDirectoryMock,
|
||||
getNodePtySpawnHelperCandidatesMock
|
||||
resolveAgentForegroundProcessMock
|
||||
} = vi.hoisted(() => ({
|
||||
spawnMock: vi.fn(),
|
||||
isPwshAvailableMock: vi.fn(),
|
||||
getNodePtySpawnHelperCandidatesMock: vi.fn(),
|
||||
resolveAgentForegroundProcessMock: vi.fn(),
|
||||
validateWorkingDirectoryMock: vi.fn((cwd: string) => {
|
||||
if (cwd.includes('definitely-missing')) {
|
||||
throw new Error(
|
||||
@@ -40,7 +40,11 @@ vi.mock('../providers/local-pty-utils', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
import { checkPtySpawnHealth, createPtySubprocess } from './pty-subprocess'
|
||||
vi.mock('../providers/agent-foreground-process', () => ({
|
||||
resolveAgentForegroundProcess: resolveAgentForegroundProcessMock
|
||||
}))
|
||||
|
||||
import { createPtySubprocess } from './pty-subprocess'
|
||||
|
||||
const ORCA_SHELL_WRAPPER_ENV = [
|
||||
'ORCA_ATTRIBUTION_SHIM_DIR',
|
||||
@@ -82,6 +86,10 @@ describe('createPtySubprocess', () => {
|
||||
beforeEach(() => {
|
||||
spawnMock.mockReset()
|
||||
isPwshAvailableMock.mockReset()
|
||||
resolveAgentForegroundProcessMock.mockReset()
|
||||
resolveAgentForegroundProcessMock.mockImplementation(
|
||||
async (_pid: number, fallbackProcess: string | null) => fallbackProcess
|
||||
)
|
||||
validateWorkingDirectoryMock.mockClear()
|
||||
isPwshAvailableMock.mockReturnValue(false)
|
||||
previousUserDataPath = process.env.ORCA_USER_DATA_PATH
|
||||
@@ -255,6 +263,38 @@ describe('createPtySubprocess', () => {
|
||||
expect(handle.getForegroundProcess()).toBe('codex')
|
||||
})
|
||||
|
||||
it('serves daemon wrapper agent foreground from an async cache without blocking', async () => {
|
||||
const proc = mockPtyProcess()
|
||||
proc.process = 'node'
|
||||
spawnMock.mockReturnValue(proc)
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' })
|
||||
let resolveForeground!: (processName: string) => void
|
||||
resolveAgentForegroundProcessMock.mockReturnValue(
|
||||
new Promise<string>((resolve) => {
|
||||
resolveForeground = resolve
|
||||
})
|
||||
)
|
||||
|
||||
try {
|
||||
const handle = createPtySubprocess({
|
||||
sessionId: 'test',
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
|
||||
expect(handle.getForegroundProcess()).toBe('node')
|
||||
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledWith(proc.pid, 'node')
|
||||
|
||||
resolveForeground('codex')
|
||||
await vi.waitFor(() => expect(handle.getForegroundProcess()).toBe('codex'))
|
||||
} finally {
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('treats node-pty terminal name as inconclusive foreground process', () => {
|
||||
const proc = mockPtyProcess()
|
||||
proc.process = 'xterm-256color'
|
||||
@@ -1074,12 +1114,7 @@ describe('createPtySubprocess', () => {
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'wsl.exe',
|
||||
[
|
||||
'--',
|
||||
'bash',
|
||||
'-c',
|
||||
`cd '${expectedLinuxCwd}' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l`
|
||||
],
|
||||
['--', 'sh', '-c', expect.stringContaining(`cd '${expectedLinuxCwd}'`)],
|
||||
expect.objectContaining({ cwd: expect.any(String) })
|
||||
)
|
||||
})
|
||||
@@ -1116,14 +1151,7 @@ describe('createPtySubprocess', () => {
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'wsl.exe',
|
||||
[
|
||||
'-d',
|
||||
'Debian',
|
||||
'--',
|
||||
'bash',
|
||||
'-c',
|
||||
`cd '${expectedLinuxCwd}' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l`
|
||||
],
|
||||
['-d', 'Debian', '--', 'sh', '-c', expect.stringContaining(`cd '${expectedLinuxCwd}'`)],
|
||||
expect.objectContaining({ cwd: expect.any(String) })
|
||||
)
|
||||
})
|
||||
@@ -1151,14 +1179,7 @@ describe('createPtySubprocess', () => {
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'wsl.exe',
|
||||
[
|
||||
'-d',
|
||||
'Ubuntu',
|
||||
'--',
|
||||
'bash',
|
||||
'-c',
|
||||
'cd \'/home/jin/repo\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l'
|
||||
],
|
||||
['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringContaining("cd '/home/jin/repo'")],
|
||||
expect.objectContaining({ cwd: expect.any(String) })
|
||||
)
|
||||
})
|
||||
@@ -1186,14 +1207,7 @@ describe('createPtySubprocess', () => {
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'wsl.exe',
|
||||
[
|
||||
'-d',
|
||||
'Ubuntu',
|
||||
'--',
|
||||
'bash',
|
||||
'-c',
|
||||
'cd \'/home/jin/repo\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l'
|
||||
],
|
||||
['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringContaining("cd '/home/jin/repo'")],
|
||||
expect.objectContaining({
|
||||
env: expect.not.objectContaining({
|
||||
CODEX_HOME: expect.anything(),
|
||||
@@ -1278,14 +1292,7 @@ describe('createPtySubprocess', () => {
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'wsl.exe',
|
||||
[
|
||||
'-d',
|
||||
'Ubuntu',
|
||||
'--',
|
||||
'bash',
|
||||
'-c',
|
||||
`cd '${expectedLinuxCwd}' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l`
|
||||
],
|
||||
['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringContaining(`cd '${expectedLinuxCwd}'`)],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
CODEX_HOME: '/home/jin/.local/share/orca/codex-accounts/a/home',
|
||||
@@ -1319,14 +1326,7 @@ describe('createPtySubprocess', () => {
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'wsl.exe',
|
||||
[
|
||||
'-d',
|
||||
'Ubuntu',
|
||||
'--',
|
||||
'bash',
|
||||
'-c',
|
||||
'cd \'/home/jin/repo\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l'
|
||||
],
|
||||
['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringContaining("cd '/home/jin/repo'")],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ CODEX_HOME: '/home/jin/.codex-alt' })
|
||||
})
|
||||
@@ -1409,14 +1409,7 @@ describe('createPtySubprocess', () => {
|
||||
)
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'wsl.exe',
|
||||
[
|
||||
'-d',
|
||||
'Ubuntu',
|
||||
'--',
|
||||
'bash',
|
||||
'-c',
|
||||
'cd \'/home/jin/repo/subdir\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l'
|
||||
],
|
||||
['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringContaining("cd '/home/jin/repo/subdir'")],
|
||||
expect.objectContaining({ cwd: expect.any(String) })
|
||||
)
|
||||
})
|
||||
|
||||
@@ -27,9 +27,12 @@ import { getWslContextFromSessionId } from './wsl-session-context'
|
||||
import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env'
|
||||
import { isWindowsGitBashShellPath, resolveWindowsGitBashShellPath } from '../git-bash'
|
||||
import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell'
|
||||
import { resolveAgentForegroundProcess } from '../providers/agent-foreground-process'
|
||||
import { recognizeAgentProcess } from '../../shared/agent-process-recognition'
|
||||
import { isShellProcess } from '../../shared/shell-process-detection'
|
||||
|
||||
const PANE_IDENTITY_ENV_KEYS = ['ORCA_PANE_KEY', 'ORCA_TAB_ID', 'ORCA_WORKTREE_ID'] as const
|
||||
const PTY_SPAWN_HEALTH_TIMEOUT_MS = 2_000
|
||||
const FOREGROUND_AGENT_CACHE_TTL_MS = 1000
|
||||
|
||||
export type PtySubprocessOptions = {
|
||||
sessionId: string
|
||||
@@ -532,8 +535,51 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
|
||||
let dead = false
|
||||
let disposed = false
|
||||
let nodePtyKillIssued = false
|
||||
let cachedAgentForeground: { processName: string; refreshedAt: number } | null = null
|
||||
let foregroundRefreshInFlight = false
|
||||
let lastForegroundRefreshStartedAt = 0
|
||||
const getFallbackForegroundProcess = (): string | null =>
|
||||
normalizeForegroundProcessName(proc.process)
|
||||
const scheduleAgentForegroundRefresh = (fallbackProcess: string | null): void => {
|
||||
if (dead || process.platform === 'win32' || !proc.pid) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
!fallbackProcess ||
|
||||
isShellProcess(fallbackProcess) ||
|
||||
recognizeAgentProcess(fallbackProcess)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const now = Date.now()
|
||||
if (
|
||||
foregroundRefreshInFlight ||
|
||||
now - lastForegroundRefreshStartedAt < FOREGROUND_AGENT_CACHE_TTL_MS
|
||||
) {
|
||||
return
|
||||
}
|
||||
foregroundRefreshInFlight = true
|
||||
lastForegroundRefreshStartedAt = now
|
||||
// Why: daemon `getForegroundProcess()` is sync and runs on the IPC hot path.
|
||||
// Refresh wrapper-derived identities (node/python → codex/gemini/etc.) in
|
||||
// the background and serve them from a short cache on later reads.
|
||||
void resolveAgentForegroundProcess(proc.pid, fallbackProcess)
|
||||
.then((processName) => {
|
||||
if (dead || !processName || !recognizeAgentProcess(processName)) {
|
||||
return
|
||||
}
|
||||
cachedAgentForeground = { processName, refreshedAt: Date.now() }
|
||||
})
|
||||
.catch(() => {
|
||||
// Best-effort only: foreground enrichment must never affect PTY health.
|
||||
})
|
||||
.finally(() => {
|
||||
foregroundRefreshInFlight = false
|
||||
})
|
||||
}
|
||||
proc.onExit(() => {
|
||||
dead = true
|
||||
cachedAgentForeground = null
|
||||
// Why: UnixTerminal.destroy() registers `_socket.once('close', () => this.kill('SIGHUP'))`
|
||||
// (unixTerminal.js:219-229). After the child exits, the master socket's
|
||||
// 'close' event can fire before our dispose() path gets to neutralize
|
||||
@@ -559,7 +605,23 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return normalizeForegroundProcessName(proc.process)
|
||||
const fallbackProcess = getFallbackForegroundProcess()
|
||||
if (fallbackProcess && isShellProcess(fallbackProcess)) {
|
||||
cachedAgentForeground = null
|
||||
return fallbackProcess
|
||||
}
|
||||
if (fallbackProcess && recognizeAgentProcess(fallbackProcess)) {
|
||||
cachedAgentForeground = { processName: fallbackProcess, refreshedAt: Date.now() }
|
||||
return fallbackProcess
|
||||
}
|
||||
scheduleAgentForegroundRefresh(fallbackProcess)
|
||||
if (
|
||||
cachedAgentForeground &&
|
||||
Date.now() - cachedAgentForeground.refreshedAt <= FOREGROUND_AGENT_CACHE_TTL_MS
|
||||
) {
|
||||
return cachedAgentForeground.processName
|
||||
}
|
||||
return fallbackProcess
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Session } from './session'
|
||||
|
||||
// Coverage for the incremental-checkpoint record stream (issue #5096): every
|
||||
// PTY byte, resize, and clear is recorded so the 5s checkpoint can persist
|
||||
// increments without serializing the emulator.
|
||||
|
||||
function createMockSubprocess() {
|
||||
let onData: ((data: string) => void) | null = null
|
||||
return {
|
||||
pid: 12345,
|
||||
getForegroundProcess: (): string | null => null,
|
||||
write(_data: string) {},
|
||||
resize(_cols: number, _rows: number) {},
|
||||
kill() {},
|
||||
forceKill() {},
|
||||
signal(_sig: string) {},
|
||||
onData(cb: (data: string) => void) {
|
||||
onData = cb
|
||||
},
|
||||
onExit(_cb: (code: number) => void) {},
|
||||
dispose() {},
|
||||
simulateData(data: string) {
|
||||
onData?.(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let session: Session | null = null
|
||||
|
||||
afterEach(() => {
|
||||
session?.dispose()
|
||||
session = null
|
||||
})
|
||||
|
||||
function createSession(subprocess = createMockSubprocess()): Session {
|
||||
session = new Session({
|
||||
sessionId: 'pending-test',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
subprocess,
|
||||
shellReadySupported: false
|
||||
})
|
||||
return session
|
||||
}
|
||||
|
||||
describe('Session pending output', () => {
|
||||
it('records output, resize, and clear in application order', () => {
|
||||
const subprocess = createMockSubprocess()
|
||||
const live = createSession(subprocess)
|
||||
|
||||
subprocess.simulateData('before resize')
|
||||
live.resize(100, 30)
|
||||
subprocess.simulateData('after resize')
|
||||
live.clearScrollback()
|
||||
|
||||
const take = live.takePendingOutput(false)
|
||||
expect(take).not.toBeNull()
|
||||
expect(take!.overflowed).toBe(false)
|
||||
expect(take!.snapshot).toBeNull()
|
||||
expect(take!.records).toEqual([
|
||||
{ kind: 'output', data: 'before resize' },
|
||||
{ kind: 'resize', cols: 100, rows: 30 },
|
||||
{ kind: 'output', data: 'after resize' },
|
||||
{ kind: 'clear' }
|
||||
])
|
||||
})
|
||||
|
||||
it('coalesces adjacent output chunks', () => {
|
||||
const subprocess = createMockSubprocess()
|
||||
const live = createSession(subprocess)
|
||||
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
subprocess.simulateData(`chunk-${i};`)
|
||||
}
|
||||
|
||||
const take = live.takePendingOutput(false)
|
||||
expect(take!.records).toHaveLength(1)
|
||||
expect(take!.records[0]).toMatchObject({ kind: 'output' })
|
||||
})
|
||||
|
||||
it('drains on take and increments the batch sequence', () => {
|
||||
const subprocess = createMockSubprocess()
|
||||
const live = createSession(subprocess)
|
||||
|
||||
subprocess.simulateData('first')
|
||||
const first = live.takePendingOutput(false)
|
||||
expect(first!.records).toEqual([{ kind: 'output', data: 'first' }])
|
||||
|
||||
subprocess.simulateData('second')
|
||||
const second = live.takePendingOutput(false)
|
||||
expect(second!.records).toEqual([{ kind: 'output', data: 'second' }])
|
||||
expect(second!.seq).toBe(first!.seq + 1)
|
||||
|
||||
const empty = live.takePendingOutput(false)
|
||||
expect(empty!.records).toEqual([])
|
||||
})
|
||||
|
||||
it('flags overflow past the cap and recovers after a take', () => {
|
||||
const subprocess = createMockSubprocess()
|
||||
const live = createSession(subprocess)
|
||||
|
||||
const megabyte = 'x'.repeat(1024 * 1024)
|
||||
subprocess.simulateData(megabyte)
|
||||
subprocess.simulateData(megabyte)
|
||||
subprocess.simulateData(megabyte)
|
||||
|
||||
const overflowed = live.takePendingOutput(false)
|
||||
expect(overflowed!.overflowed).toBe(true)
|
||||
expect(overflowed!.records).toEqual([])
|
||||
|
||||
subprocess.simulateData('post-overflow')
|
||||
const recovered = live.takePendingOutput(false)
|
||||
expect(recovered!.overflowed).toBe(false)
|
||||
expect(recovered!.records).toEqual([{ kind: 'output', data: 'post-overflow' }])
|
||||
})
|
||||
|
||||
it('returns the snapshot and drops records in the same take when requested', () => {
|
||||
const subprocess = createMockSubprocess()
|
||||
const live = createSession(subprocess)
|
||||
|
||||
subprocess.simulateData('snapshot content\r\n')
|
||||
const take = live.takePendingOutput(true)
|
||||
expect(take!.records).toEqual([])
|
||||
expect(take!.snapshot?.snapshotAnsi).toContain('snapshot content')
|
||||
|
||||
// Records taken alongside the snapshot must not reappear later — they are
|
||||
// already part of the snapshot and would replay twice on cold restore.
|
||||
const next = live.takePendingOutput(false)
|
||||
expect(next!.records).toEqual([])
|
||||
})
|
||||
|
||||
it('returns null after dispose', () => {
|
||||
const live = createSession()
|
||||
live.dispose()
|
||||
expect(live.takePendingOutput(false)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -2,11 +2,26 @@
|
||||
import { HeadlessEmulator } from './headless-emulator'
|
||||
import { isValidPtySize, normalizePtySize } from './daemon-pty-size'
|
||||
import { PostReadyFlushGate } from './post-ready-flush-gate'
|
||||
import type { SessionState, ShellReadyState, TerminalSnapshot } from './types'
|
||||
import type {
|
||||
PendingOutputRecord,
|
||||
SessionState,
|
||||
ShellReadyState,
|
||||
TakePendingOutputResult,
|
||||
TerminalSnapshot
|
||||
} from './types'
|
||||
|
||||
const SHELL_READY_TIMEOUT_MS = 15_000
|
||||
const KILL_TIMEOUT_MS = 5_000
|
||||
const SHELL_READY_MARKER = '\x1b]777;orca-shell-ready\x07'
|
||||
// Why: pending records exist so the 5s checkpoint can persist increments
|
||||
// instead of re-serializing the whole buffer. If no client drains them (main
|
||||
// process gone, history disabled), memory must stay bounded — past the cap we
|
||||
// drop the records and flag overflow so the next take falls back to one full
|
||||
// snapshot, which subsumes everything dropped.
|
||||
// Counted in UTF-16 code units (string .length), which tracks JS heap cost.
|
||||
// Worst-case wire size for a full take is ~6x this (each control char
|
||||
// JSON-escapes to six bytes) and must stay under NDJSON_MAX_LINE_BYTES (16MB).
|
||||
const PENDING_OUTPUT_MAX_BYTES = 2 * 1024 * 1024
|
||||
|
||||
export type SubprocessHandle = {
|
||||
pid: number
|
||||
@@ -56,6 +71,10 @@ export class Session {
|
||||
private shellReadyTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private killTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private postReadyFlushGate: PostReadyFlushGate
|
||||
private pendingOutputRecords: PendingOutputRecord[] = []
|
||||
private pendingOutputBytes = 0
|
||||
private pendingOutputOverflowed = false
|
||||
private pendingOutputSeq = 0
|
||||
|
||||
constructor(opts: SessionOptions) {
|
||||
this.sessionId = opts.sessionId
|
||||
@@ -134,6 +153,9 @@ export class Session {
|
||||
return
|
||||
}
|
||||
this.emulator.resize(cols, rows)
|
||||
// Why: the record stream must mirror the order operations were applied to
|
||||
// the emulator, or cold-restore replay reflows at the wrong point.
|
||||
this.recordPendingOutput({ kind: 'resize', cols, rows })
|
||||
this.subprocess.resize(cols, rows)
|
||||
}
|
||||
|
||||
@@ -183,6 +205,28 @@ export class Session {
|
||||
return this.emulator.getSnapshot()
|
||||
}
|
||||
|
||||
/** Drains the records accumulated since the last take. Runs synchronously —
|
||||
* when includeSnapshot is set, the serialize happens in the same turn so no
|
||||
* PTY data can land between the drain and the snapshot (which would later
|
||||
* be replayed twice on cold restore). */
|
||||
takePendingOutput(includeSnapshot: boolean): TakePendingOutputResult | null {
|
||||
if (this._disposed) {
|
||||
return null
|
||||
}
|
||||
const records = this.pendingOutputRecords
|
||||
const overflowed = this.pendingOutputOverflowed
|
||||
this.pendingOutputRecords = []
|
||||
this.pendingOutputBytes = 0
|
||||
this.pendingOutputOverflowed = false
|
||||
this.pendingOutputSeq += 1
|
||||
return {
|
||||
records: includeSnapshot ? [] : records,
|
||||
seq: this.pendingOutputSeq,
|
||||
overflowed,
|
||||
snapshot: includeSnapshot ? this.emulator.getSnapshot() : null
|
||||
}
|
||||
}
|
||||
|
||||
getCwd(): string | null {
|
||||
return this.emulator.getCwd()
|
||||
}
|
||||
@@ -196,6 +240,7 @@ export class Session {
|
||||
return
|
||||
}
|
||||
this.emulator.clearScrollback()
|
||||
this.recordPendingOutput({ kind: 'clear' })
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
@@ -301,6 +346,29 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private recordPendingOutput(record: PendingOutputRecord): void {
|
||||
if (this.pendingOutputOverflowed) {
|
||||
return
|
||||
}
|
||||
const bytes = record.kind === 'output' ? record.data.length : 8
|
||||
if (this.pendingOutputBytes + bytes > PENDING_OUTPUT_MAX_BYTES) {
|
||||
this.pendingOutputRecords = []
|
||||
this.pendingOutputBytes = 0
|
||||
this.pendingOutputOverflowed = true
|
||||
return
|
||||
}
|
||||
// Why: TUIs emit thousands of tiny chunks between checkpoint ticks;
|
||||
// coalescing adjacent output keeps the take RPC and log frames compact.
|
||||
// The 64KB segment cap bounds per-chunk string-append cost.
|
||||
const last = this.pendingOutputRecords.at(-1)
|
||||
if (record.kind === 'output' && last?.kind === 'output' && last.data.length < 64 * 1024) {
|
||||
last.data += record.data
|
||||
} else {
|
||||
this.pendingOutputRecords.push(record)
|
||||
}
|
||||
this.pendingOutputBytes += bytes
|
||||
}
|
||||
|
||||
private handleSubprocessData(data: string): void {
|
||||
if (this._disposed) {
|
||||
return
|
||||
@@ -308,6 +376,7 @@ export class Session {
|
||||
|
||||
// Feed data to headless emulator for state tracking
|
||||
this.emulator.write(data)
|
||||
this.recordPendingOutput({ kind: 'output', data })
|
||||
|
||||
if (this._shellState === 'pending') {
|
||||
this.scanForShellMarker(data)
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { connect, createServer, type Server, type Socket } from 'net'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { mkdtempSync, rmSync } from 'fs'
|
||||
import { DaemonServer } from './daemon-server'
|
||||
import { DaemonClient } from './client'
|
||||
import { healthCheckDaemon } from './daemon-health'
|
||||
import type { ListSessionsResult } from './types'
|
||||
import type { SubprocessHandle } from './session'
|
||||
|
||||
// Why: terminals were lost after app updates because a busy machine could
|
||||
// time out the 3s startup health check against a daemon that was alive and
|
||||
// owning sessions, and the unhealthy path killed it. The fix re-verifies
|
||||
// with listSessions, which has far larger budgets (5s hello, 30s request).
|
||||
// This test reproduces the production asymmetry against a REAL daemon by
|
||||
// inserting a response delay that exceeds the health-check budget but fits
|
||||
// the verification budgets, and asserts the guard's two inputs disagree the
|
||||
// way the fix depends on.
|
||||
const RESPONSE_DELAY_MS = 3_500
|
||||
|
||||
function createMockSubprocess(): SubprocessHandle {
|
||||
return {
|
||||
pid: 55555,
|
||||
getForegroundProcess: vi.fn(() => null),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
forceKill: vi.fn(),
|
||||
signal: vi.fn(),
|
||||
onData: vi.fn(),
|
||||
onExit: vi.fn(),
|
||||
dispose: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
/** Forwards client bytes to the daemon immediately, but delays every daemon
|
||||
* response so each round-trip looks like a daemon under heavy load. */
|
||||
function startDelayProxy(listenPath: string, upstreamPath: string): Server {
|
||||
const proxy = createServer((clientSocket: Socket) => {
|
||||
const upstream = connect(upstreamPath)
|
||||
clientSocket.on('data', (chunk) => upstream.write(chunk))
|
||||
upstream.on('data', (chunk) => {
|
||||
setTimeout(() => {
|
||||
if (!clientSocket.destroyed) {
|
||||
clientSocket.write(chunk)
|
||||
}
|
||||
}, RESPONSE_DELAY_MS)
|
||||
})
|
||||
const teardown = (): void => {
|
||||
clientSocket.destroy()
|
||||
upstream.destroy()
|
||||
}
|
||||
clientSocket.on('close', teardown)
|
||||
clientSocket.on('error', teardown)
|
||||
upstream.on('close', () => {
|
||||
setTimeout(teardown, RESPONSE_DELAY_MS)
|
||||
})
|
||||
upstream.on('error', teardown)
|
||||
})
|
||||
proxy.listen(listenPath)
|
||||
return proxy
|
||||
}
|
||||
|
||||
describe('slow daemon session verification', () => {
|
||||
let dir: string
|
||||
let daemonSocketPath: string
|
||||
let proxySocketPath: string
|
||||
let tokenPath: string
|
||||
let server: DaemonServer
|
||||
let proxy: Server
|
||||
const clients: DaemonClient[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'daemon-slow-verification-test-'))
|
||||
daemonSocketPath = join(dir, 'daemon.sock')
|
||||
proxySocketPath = join(dir, 'proxy.sock')
|
||||
tokenPath = join(dir, 'daemon.token')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
for (const client of clients.splice(0)) {
|
||||
client.disconnect()
|
||||
}
|
||||
await new Promise<void>((resolve) => proxy?.close(() => resolve()))
|
||||
await server?.shutdown()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it(
|
||||
'fails the 3s health check against a slow daemon while listSessions still verifies its live session',
|
||||
{ timeout: 60_000 },
|
||||
async () => {
|
||||
server = new DaemonServer({
|
||||
socketPath: daemonSocketPath,
|
||||
tokenPath,
|
||||
spawnSubprocess: () => createMockSubprocess()
|
||||
})
|
||||
await server.start()
|
||||
|
||||
const directClient = new DaemonClient({ socketPath: daemonSocketPath, tokenPath })
|
||||
clients.push(directClient)
|
||||
await directClient.ensureConnected()
|
||||
await directClient.request('createOrAttach', {
|
||||
sessionId: 'wt-1@@live-session',
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
|
||||
proxy = startDelayProxy(proxySocketPath, daemonSocketPath)
|
||||
|
||||
// The exact pre-fix kill trigger: the daemon is alive but too slow for
|
||||
// the health-check budget.
|
||||
await expect(healthCheckDaemon(proxySocketPath, tokenPath)).resolves.toBe(false)
|
||||
|
||||
// The fix's re-verification against the SAME slow daemon: the larger
|
||||
// client budgets absorb the latency and prove the session is alive.
|
||||
const verificationClient = new DaemonClient({ socketPath: proxySocketPath, tokenPath })
|
||||
clients.push(verificationClient)
|
||||
await verificationClient.ensureConnected()
|
||||
const result = await verificationClient.request<ListSessionsResult>('listSessions', undefined)
|
||||
const liveSessionCount = result.sessions.filter((session) => session.isAlive).length
|
||||
expect(liveSessionCount).toBe(1)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,238 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync, truncateSync } from 'fs'
|
||||
import { HistoryManager } from './history-manager'
|
||||
import { HistoryReader } from './history-reader'
|
||||
import { HeadlessEmulator } from './headless-emulator'
|
||||
import { encodeLogBatch, encodeLogHeader } from './terminal-history-log'
|
||||
import { getHistorySessionDirName } from './history-paths'
|
||||
import type { PendingOutputRecord } from './types'
|
||||
|
||||
// End-to-end coverage for incremental checkpoint persistence and cold-restore
|
||||
// replay (issue #5096): HistoryManager appends take batches to output.log;
|
||||
// HistoryReader replays checkpoint base + log tail through a scratch emulator.
|
||||
|
||||
const SESSION_ID = 'wt@@incremental-test'
|
||||
|
||||
let dir: string
|
||||
let manager: HistoryManager
|
||||
let reader: HistoryReader
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'orca-incremental-restore-'))
|
||||
manager = new HistoryManager(dir)
|
||||
reader = new HistoryReader(dir)
|
||||
await manager.openSession(SESSION_ID, { cwd: '/home/user', cols: 80, rows: 24 })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function sessionFile(name: string): string {
|
||||
return join(dir, getHistorySessionDirName(SESSION_ID), name)
|
||||
}
|
||||
|
||||
function snapshotOf(writes: string[], cols = 80, rows = 24) {
|
||||
const emulator = new HeadlessEmulator({ cols, rows })
|
||||
try {
|
||||
for (const data of writes) {
|
||||
emulator.writeSync(data)
|
||||
}
|
||||
return emulator.getSnapshot()
|
||||
} finally {
|
||||
emulator.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
describe('incremental terminal history restore', () => {
|
||||
it('replays appended output with no checkpoint base', async () => {
|
||||
await manager.appendIncrements(SESSION_ID, 1, [
|
||||
{ kind: 'output', data: 'first line\r\n' },
|
||||
{ kind: 'output', data: 'second line\r\n' }
|
||||
])
|
||||
|
||||
const restore = reader.detectColdRestore(SESSION_ID)
|
||||
expect(restore).not.toBeNull()
|
||||
expect(restore!.scrollbackAnsi).toContain('first line')
|
||||
expect(restore!.scrollbackAnsi).toContain('second line')
|
||||
expect(restore!.cwd).toBe('/home/user')
|
||||
})
|
||||
|
||||
it('replays checkpoint base plus log tail', async () => {
|
||||
await manager.checkpoint(SESSION_ID, snapshotOf(['from base\r\n']))
|
||||
await manager.appendIncrements(SESSION_ID, 1, [
|
||||
{ kind: 'output', data: 'from tail after checkpoint\r\n' }
|
||||
])
|
||||
|
||||
const restore = reader.detectColdRestore(SESSION_ID)
|
||||
expect(restore).not.toBeNull()
|
||||
expect(restore!.scrollbackAnsi).toContain('from base')
|
||||
expect(restore!.scrollbackAnsi).toContain('from tail after checkpoint')
|
||||
})
|
||||
|
||||
it('ignores a stale log whose generation predates the checkpoint', async () => {
|
||||
await manager.appendIncrements(SESSION_ID, 1, [{ kind: 'output', data: 'stale tail\r\n' }])
|
||||
// Simulate a crash between checkpoint rename and log reset: write the
|
||||
// checkpoint with a newer generation while the gen-0 log stays on disk.
|
||||
const checkpoint = JSON.parse(JSON.stringify(snapshotOf(['base content\r\n'])))
|
||||
writeFileSync(
|
||||
sessionFile('checkpoint.json'),
|
||||
JSON.stringify({ ...checkpoint, cwd: '/home/user', generation: 1 })
|
||||
)
|
||||
|
||||
const restore = reader.detectColdRestore(SESSION_ID)
|
||||
expect(restore).not.toBeNull()
|
||||
expect(restore!.scrollbackAnsi).toContain('base content')
|
||||
expect(restore!.scrollbackAnsi).not.toContain('stale tail')
|
||||
})
|
||||
|
||||
it('ignores a log when the checkpoint has no generation (pre-log format)', async () => {
|
||||
const checkpoint = JSON.parse(JSON.stringify(snapshotOf(['old format base\r\n'])))
|
||||
writeFileSync(
|
||||
sessionFile('checkpoint.json'),
|
||||
JSON.stringify({ ...checkpoint, cwd: '/home/user' })
|
||||
)
|
||||
writeFileSync(
|
||||
sessionFile('output.log'),
|
||||
Buffer.concat([
|
||||
encodeLogHeader(0),
|
||||
encodeLogBatch(1, [{ kind: 'output', data: 'orphan tail\r\n' }])
|
||||
])
|
||||
)
|
||||
|
||||
const restore = reader.detectColdRestore(SESSION_ID)
|
||||
expect(restore).not.toBeNull()
|
||||
expect(restore!.scrollbackAnsi).toContain('old format base')
|
||||
expect(restore!.scrollbackAnsi).not.toContain('orphan tail')
|
||||
})
|
||||
|
||||
it('falls back to the checkpoint when the log has a sequence gap', async () => {
|
||||
await manager.checkpoint(SESSION_ID, snapshotOf(['safe base\r\n']))
|
||||
const generation = 1
|
||||
writeFileSync(
|
||||
sessionFile('output.log'),
|
||||
Buffer.concat([
|
||||
encodeLogHeader(generation),
|
||||
encodeLogBatch(1, [{ kind: 'output', data: 'kept\r\n' }]),
|
||||
encodeLogBatch(3, [{ kind: 'output', data: 'after gap\r\n' }])
|
||||
])
|
||||
)
|
||||
|
||||
const restore = reader.detectColdRestore(SESSION_ID)
|
||||
expect(restore).not.toBeNull()
|
||||
expect(restore!.scrollbackAnsi).toContain('safe base')
|
||||
expect(restore!.scrollbackAnsi).not.toContain('kept')
|
||||
expect(restore!.scrollbackAnsi).not.toContain('after gap')
|
||||
})
|
||||
|
||||
it('replays the complete prefix of a torn final append', async () => {
|
||||
await manager.appendIncrements(SESSION_ID, 1, [{ kind: 'output', data: 'complete batch\r\n' }])
|
||||
await manager.appendIncrements(SESSION_ID, 2, [{ kind: 'output', data: 'torn batch\r\n' }])
|
||||
const logPath = sessionFile('output.log')
|
||||
truncateSync(logPath, readFileSync(logPath).length - 5)
|
||||
|
||||
const restore = reader.detectColdRestore(SESSION_ID)
|
||||
expect(restore).not.toBeNull()
|
||||
expect(restore!.scrollbackAnsi).toContain('complete batch')
|
||||
expect(restore!.scrollbackAnsi).not.toContain('torn batch')
|
||||
})
|
||||
|
||||
it('applies resize records during replay', async () => {
|
||||
await manager.appendIncrements(SESSION_ID, 1, [
|
||||
{ kind: 'output', data: 'before resize\r\n' },
|
||||
{ kind: 'resize', cols: 132, rows: 40 },
|
||||
{ kind: 'output', data: 'after resize\r\n' }
|
||||
])
|
||||
|
||||
const restore = reader.detectColdRestore(SESSION_ID)
|
||||
expect(restore).not.toBeNull()
|
||||
expect(restore!.cols).toBe(132)
|
||||
expect(restore!.rows).toBe(40)
|
||||
expect(restore!.scrollbackAnsi).toContain('after resize')
|
||||
})
|
||||
|
||||
it('applies clear records during replay', async () => {
|
||||
await manager.appendIncrements(SESSION_ID, 1, [
|
||||
{ kind: 'output', data: 'cleared away\r\n' },
|
||||
{ kind: 'clear' },
|
||||
{ kind: 'output', data: 'survives clear\r\n' }
|
||||
])
|
||||
|
||||
const restore = reader.detectColdRestore(SESSION_ID)
|
||||
expect(restore).not.toBeNull()
|
||||
expect(restore!.scrollbackAnsi).toContain('survives clear')
|
||||
expect(restore!.scrollbackAnsi).not.toContain('cleared away')
|
||||
})
|
||||
|
||||
it('skips restorable content for sessions crashed inside the alt screen', async () => {
|
||||
await manager.appendIncrements(SESSION_ID, 1, [
|
||||
{ kind: 'output', data: 'normal output\r\n\x1b[?1049halt screen content' }
|
||||
])
|
||||
|
||||
const restore = reader.detectColdRestore(SESSION_ID)
|
||||
expect(restore).not.toBeNull()
|
||||
expect(restore!.modes.alternateScreen).toBe(true)
|
||||
// Why: the adapter skips cold restore when scrollbackAnsi is empty — alt
|
||||
// buffer contents must not replay into a fresh shell.
|
||||
expect(restore!.scrollbackAnsi).toBe('')
|
||||
})
|
||||
|
||||
it('resets the log on checkpoint so old records are not replayed twice', async () => {
|
||||
await manager.appendIncrements(SESSION_ID, 1, [{ kind: 'output', data: 'pre-checkpoint\r\n' }])
|
||||
await manager.checkpoint(SESSION_ID, snapshotOf(['pre-checkpoint\r\n']))
|
||||
await manager.appendIncrements(SESSION_ID, 2, [{ kind: 'output', data: 'post-checkpoint\r\n' }])
|
||||
|
||||
const restore = reader.detectColdRestore(SESSION_ID)
|
||||
expect(restore).not.toBeNull()
|
||||
const occurrences = restore!.scrollbackAnsi.split('pre-checkpoint').length - 1
|
||||
expect(occurrences).toBe(1)
|
||||
expect(restore!.scrollbackAnsi).toContain('post-checkpoint')
|
||||
})
|
||||
|
||||
it('requests a full checkpoint when the log reaches its cap', async () => {
|
||||
const bigRecord: PendingOutputRecord = {
|
||||
kind: 'output',
|
||||
data: 'x'.repeat(2 * 1024 * 1024)
|
||||
}
|
||||
expect(await manager.appendIncrements(SESSION_ID, 1, [bigRecord])).toBe('ok')
|
||||
expect(await manager.appendIncrements(SESSION_ID, 2, [bigRecord])).toBe('ok')
|
||||
expect(await manager.appendIncrements(SESSION_ID, 3, [bigRecord])).toBe('needs-checkpoint')
|
||||
// Why: the rejected batch is subsumed by the snapshot the caller takes
|
||||
// next; checkpoint() resets the log for the new generation.
|
||||
await manager.checkpoint(SESSION_ID, snapshotOf(['compacted\r\n']))
|
||||
expect(
|
||||
await manager.appendIncrements(SESSION_ID, 4, [{ kind: 'output', data: 'fresh\r\n' }])
|
||||
).toBe('ok')
|
||||
|
||||
const restore = reader.detectColdRestore(SESSION_ID)
|
||||
expect(restore).not.toBeNull()
|
||||
expect(restore!.scrollbackAnsi).toContain('compacted')
|
||||
expect(restore!.scrollbackAnsi).toContain('fresh')
|
||||
})
|
||||
|
||||
it('openSession removes a stale log from a previous session with the same id', async () => {
|
||||
await manager.appendIncrements(SESSION_ID, 1, [{ kind: 'output', data: 'old session\r\n' }])
|
||||
expect(existsSync(sessionFile('output.log'))).toBe(true)
|
||||
|
||||
await manager.openSession(SESSION_ID, { cwd: '/home/user', cols: 80, rows: 24 })
|
||||
expect(existsSync(sessionFile('output.log'))).toBe(false)
|
||||
})
|
||||
|
||||
it('continues an existing log after a warm registerWriter', async () => {
|
||||
await manager.appendIncrements(SESSION_ID, 1, [{ kind: 'output', data: 'before relaunch\r\n' }])
|
||||
|
||||
// Simulate app relaunch: a fresh HistoryManager attaches to the same dir.
|
||||
const relaunched = new HistoryManager(dir)
|
||||
relaunched.registerWriter(SESSION_ID)
|
||||
await relaunched.appendIncrements(SESSION_ID, 2, [
|
||||
{ kind: 'output', data: 'after relaunch\r\n' }
|
||||
])
|
||||
|
||||
const restore = reader.detectColdRestore(SESSION_ID)
|
||||
expect(restore).not.toBeNull()
|
||||
expect(restore!.scrollbackAnsi).toContain('before relaunch')
|
||||
expect(restore!.scrollbackAnsi).toContain('after relaunch')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
decodeLogHeader,
|
||||
decodeTerminalHistoryLog,
|
||||
encodeLogBatch,
|
||||
encodeLogHeader,
|
||||
LOG_HEADER_BYTES
|
||||
} from './terminal-history-log'
|
||||
import type { PendingOutputRecord } from './types'
|
||||
|
||||
function buildLog(generation: number, batches: { seq: number; records: PendingOutputRecord[] }[]) {
|
||||
return Buffer.concat([
|
||||
encodeLogHeader(generation),
|
||||
...batches.map((batch) => encodeLogBatch(batch.seq, batch.records))
|
||||
])
|
||||
}
|
||||
|
||||
describe('terminal history log codec', () => {
|
||||
it('round-trips header generation', () => {
|
||||
expect(decodeLogHeader(encodeLogHeader(0))).toBe(0)
|
||||
expect(decodeLogHeader(encodeLogHeader(42))).toBe(42)
|
||||
})
|
||||
|
||||
it('rejects bad magic and unknown format versions', () => {
|
||||
expect(decodeLogHeader(Buffer.from('NOPE\x01\x00\x00\x00\x00', 'latin1'))).toBeNull()
|
||||
const wrongVersion = encodeLogHeader(1)
|
||||
wrongVersion.writeUInt8(99, 4)
|
||||
expect(decodeLogHeader(wrongVersion)).toBeNull()
|
||||
expect(decodeTerminalHistoryLog(wrongVersion)).toBeNull()
|
||||
expect(decodeLogHeader(Buffer.alloc(3))).toBeNull()
|
||||
})
|
||||
|
||||
it('round-trips output, resize, and clear records', () => {
|
||||
const records: PendingOutputRecord[] = [
|
||||
{ kind: 'output', data: 'hello \x1b[31mred\x1b[0m — émoji 🐳\r\n' },
|
||||
{ kind: 'resize', cols: 132, rows: 43 },
|
||||
{ kind: 'clear' },
|
||||
{ kind: 'output', data: 'after clear' }
|
||||
]
|
||||
const log = decodeTerminalHistoryLog(buildLog(7, [{ seq: 3, records }]))
|
||||
expect(log).not.toBeNull()
|
||||
expect(log!.generation).toBe(7)
|
||||
expect(log!.truncatedTail).toBe(false)
|
||||
expect(log!.batches).toEqual([{ seq: 3, records }])
|
||||
})
|
||||
|
||||
it('decodes multiple contiguous batches', () => {
|
||||
const log = decodeTerminalHistoryLog(
|
||||
buildLog(1, [
|
||||
{ seq: 5, records: [{ kind: 'output', data: 'a' }] },
|
||||
{ seq: 6, records: [{ kind: 'output', data: 'b' }] },
|
||||
{ seq: 7, records: [] }
|
||||
])
|
||||
)
|
||||
expect(log!.batches.map((batch) => batch.seq)).toEqual([5, 6, 7])
|
||||
})
|
||||
|
||||
it('rejects the whole log on a batch sequence gap', () => {
|
||||
// Why: a gap means an appended take batch was lost (e.g. main crashed
|
||||
// between take and append); the byte stream has a hole, so replaying any
|
||||
// of it would corrupt the restored terminal.
|
||||
const log = decodeTerminalHistoryLog(
|
||||
buildLog(1, [
|
||||
{ seq: 5, records: [{ kind: 'output', data: 'a' }] },
|
||||
{ seq: 7, records: [{ kind: 'output', data: 'b' }] }
|
||||
])
|
||||
)
|
||||
expect(log).toBeNull()
|
||||
})
|
||||
|
||||
it('truncates a torn final frame and keeps the complete prefix', () => {
|
||||
const full = buildLog(2, [
|
||||
{ seq: 1, records: [{ kind: 'output', data: 'complete' }] },
|
||||
{ seq: 2, records: [{ kind: 'output', data: 'torn-away-tail' }] }
|
||||
])
|
||||
for (const cut of [1, 3, 7] as const) {
|
||||
const torn = full.subarray(0, full.length - cut)
|
||||
const log = decodeTerminalHistoryLog(torn)
|
||||
expect(log).not.toBeNull()
|
||||
expect(log!.truncatedTail).toBe(true)
|
||||
expect(log!.batches[0]).toEqual({
|
||||
seq: 1,
|
||||
records: [{ kind: 'output', data: 'complete' }]
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('treats a record frame before any batch frame as unreadable', () => {
|
||||
const orphanRecord = Buffer.concat([
|
||||
encodeLogHeader(0),
|
||||
// encodeLogBatch always prefixes a batch frame; slice it off to craft
|
||||
// a stream that starts with a bare output frame.
|
||||
encodeLogBatch(1, [{ kind: 'output', data: 'x' }]).subarray(9)
|
||||
])
|
||||
expect(decodeTerminalHistoryLog(orphanRecord)).toBeNull()
|
||||
})
|
||||
|
||||
it('decodes an empty log (header only)', () => {
|
||||
const log = decodeTerminalHistoryLog(encodeLogHeader(4))
|
||||
expect(log).toEqual({ generation: 4, batches: [], truncatedTail: false })
|
||||
expect(LOG_HEADER_BYTES).toBe(encodeLogHeader(4).length)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { PendingOutputRecord } from './types'
|
||||
|
||||
// On-disk framing for the incremental terminal history log (output.log).
|
||||
//
|
||||
// Layout: header, then batch frames appended every checkpoint tick.
|
||||
// header = magic 'OCKL' (4 bytes) + u8 formatVersion + u32le generation
|
||||
// frame = u8 kind + u32le payloadLength + payload
|
||||
// kind 0x01 batch — payload u32le seq (one per appended take batch)
|
||||
// kind 0x02 output — payload utf8 bytes
|
||||
// kind 0x03 resize — payload u16le cols + u16le rows
|
||||
// kind 0x04 clear — empty payload
|
||||
//
|
||||
// Why framing instead of raw bytes: a crash can tear the final append. Length
|
||||
// prefixes make the torn tail detectable so restore truncates at the last
|
||||
// complete frame instead of replaying half an escape sequence ("reading a
|
||||
// corrupt checkpoint is worse than reading a slightly stale one").
|
||||
|
||||
const LOG_MAGIC = 'OCKL'
|
||||
const LOG_FORMAT_VERSION = 1
|
||||
export const LOG_HEADER_BYTES = 9
|
||||
|
||||
const FRAME_BATCH = 0x01
|
||||
const FRAME_OUTPUT = 0x02
|
||||
const FRAME_RESIZE = 0x03
|
||||
const FRAME_CLEAR = 0x04
|
||||
|
||||
export type TerminalHistoryLogBatch = {
|
||||
seq: number
|
||||
records: PendingOutputRecord[]
|
||||
}
|
||||
|
||||
export type TerminalHistoryLogContents = {
|
||||
generation: number
|
||||
batches: TerminalHistoryLogBatch[]
|
||||
/** True when the file ended mid-frame (torn final append). The complete
|
||||
* prefix is still safe to replay. */
|
||||
truncatedTail: boolean
|
||||
}
|
||||
|
||||
export function encodeLogHeader(generation: number): Buffer {
|
||||
const header = Buffer.alloc(LOG_HEADER_BYTES)
|
||||
header.write(LOG_MAGIC, 0, 'ascii')
|
||||
header.writeUInt8(LOG_FORMAT_VERSION, 4)
|
||||
header.writeUInt32LE(generation >>> 0, 5)
|
||||
return header
|
||||
}
|
||||
|
||||
/** Validates magic + format version and returns the generation, or null when
|
||||
* the buffer is not a readable log header. */
|
||||
export function decodeLogHeader(buffer: Buffer): number | null {
|
||||
if (buffer.length < LOG_HEADER_BYTES) {
|
||||
return null
|
||||
}
|
||||
if (buffer.toString('ascii', 0, 4) !== LOG_MAGIC) {
|
||||
return null
|
||||
}
|
||||
if (buffer.readUInt8(4) !== LOG_FORMAT_VERSION) {
|
||||
return null
|
||||
}
|
||||
return buffer.readUInt32LE(5)
|
||||
}
|
||||
|
||||
export function encodeLogBatch(seq: number, records: PendingOutputRecord[]): Buffer {
|
||||
const frames: Buffer[] = [encodeFrame(FRAME_BATCH, encodeSeqPayload(seq))]
|
||||
for (const record of records) {
|
||||
if (record.kind === 'output') {
|
||||
frames.push(encodeFrame(FRAME_OUTPUT, Buffer.from(record.data, 'utf8')))
|
||||
} else if (record.kind === 'resize') {
|
||||
const payload = Buffer.alloc(4)
|
||||
payload.writeUInt16LE(clampU16(record.cols), 0)
|
||||
payload.writeUInt16LE(clampU16(record.rows), 2)
|
||||
frames.push(encodeFrame(FRAME_RESIZE, payload))
|
||||
} else {
|
||||
frames.push(encodeFrame(FRAME_CLEAR, Buffer.alloc(0)))
|
||||
}
|
||||
}
|
||||
return Buffer.concat(frames)
|
||||
}
|
||||
|
||||
/** Returns null for missing magic / unknown format version — callers fall
|
||||
* back to checkpoint-only restore. Seq-gap detection is also done here: a
|
||||
* non-contiguous batch sequence means an appended batch was lost (e.g. main
|
||||
* crashed between take and append), so the byte stream has a hole and
|
||||
* replaying it would corrupt the restored terminal. */
|
||||
export function decodeTerminalHistoryLog(buffer: Buffer): TerminalHistoryLogContents | null {
|
||||
const generation = decodeLogHeader(buffer)
|
||||
if (generation === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const batches: TerminalHistoryLogBatch[] = []
|
||||
let current: TerminalHistoryLogBatch | null = null
|
||||
let offset = LOG_HEADER_BYTES
|
||||
let truncatedTail = false
|
||||
|
||||
while (offset < buffer.length) {
|
||||
if (offset + 5 > buffer.length) {
|
||||
truncatedTail = true
|
||||
break
|
||||
}
|
||||
const kind = buffer.readUInt8(offset)
|
||||
const payloadLength = buffer.readUInt32LE(offset + 1)
|
||||
const payloadStart = offset + 5
|
||||
const payloadEnd = payloadStart + payloadLength
|
||||
if (payloadEnd > buffer.length) {
|
||||
truncatedTail = true
|
||||
break
|
||||
}
|
||||
|
||||
if (kind === FRAME_BATCH) {
|
||||
if (payloadLength !== 4) {
|
||||
return null
|
||||
}
|
||||
const seq = buffer.readUInt32LE(payloadStart)
|
||||
if (current && seq !== current.seq + 1) {
|
||||
return null
|
||||
}
|
||||
current = { seq, records: [] }
|
||||
batches.push(current)
|
||||
} else if (!current) {
|
||||
// A record frame before any batch frame means the writer and format
|
||||
// disagree — treat the whole log as unreadable.
|
||||
return null
|
||||
} else if (kind === FRAME_OUTPUT) {
|
||||
current.records.push({
|
||||
kind: 'output',
|
||||
data: buffer.toString('utf8', payloadStart, payloadEnd)
|
||||
})
|
||||
} else if (kind === FRAME_RESIZE) {
|
||||
if (payloadLength !== 4) {
|
||||
return null
|
||||
}
|
||||
current.records.push({
|
||||
kind: 'resize',
|
||||
cols: buffer.readUInt16LE(payloadStart),
|
||||
rows: buffer.readUInt16LE(payloadStart + 2)
|
||||
})
|
||||
} else if (kind === FRAME_CLEAR) {
|
||||
current.records.push({ kind: 'clear' })
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
||||
offset = payloadEnd
|
||||
}
|
||||
|
||||
return { generation, batches, truncatedTail }
|
||||
}
|
||||
|
||||
function encodeFrame(kind: number, payload: Buffer): Buffer {
|
||||
const header = Buffer.alloc(5)
|
||||
header.writeUInt8(kind, 0)
|
||||
header.writeUInt32LE(payload.length, 1)
|
||||
return Buffer.concat([header, payload])
|
||||
}
|
||||
|
||||
function encodeSeqPayload(seq: number): Buffer {
|
||||
const payload = Buffer.alloc(4)
|
||||
payload.writeUInt32LE(seq >>> 0, 0)
|
||||
return payload
|
||||
}
|
||||
|
||||
function clampU16(value: number): number {
|
||||
return Math.max(0, Math.min(0xffff, Math.floor(value)))
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Session, type SubprocessHandle } from './session'
|
||||
import { normalizePtySize } from './daemon-pty-size'
|
||||
import { resolveProcessCwd } from '../providers/process-cwd'
|
||||
import type { SessionInfo, TerminalSnapshot, ShellReadyState } from './types'
|
||||
import type {
|
||||
SessionInfo,
|
||||
TakePendingOutputResult,
|
||||
TerminalSnapshot,
|
||||
ShellReadyState
|
||||
} from './types'
|
||||
import { SessionNotFoundError } from './types'
|
||||
|
||||
const DEFAULT_MAX_TOMBSTONES = 1000
|
||||
@@ -210,6 +215,16 @@ export class TerminalHost {
|
||||
return session.getSnapshot()
|
||||
}
|
||||
|
||||
// Why: same null-not-throw semantics as getSnapshot — incremental
|
||||
// checkpoints are best-effort against sessions that may have just exited.
|
||||
takePendingOutput(sessionId: string, includeSnapshot: boolean): TakePendingOutputResult | null {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session || !session.isAlive) {
|
||||
return null
|
||||
}
|
||||
return session.takePendingOutput(includeSnapshot)
|
||||
}
|
||||
|
||||
isKilled(sessionId: string): boolean {
|
||||
return this.killedTombstones.has(sessionId)
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
// when daemon-baked behavior cannot be delivered by on-disk wrapper refresh.
|
||||
// Why: bump when adding daemon wire behavior so same-version old daemons do
|
||||
// not silently accept the handshake and then reject new RPCs.
|
||||
export const PROTOCOL_VERSION = 12
|
||||
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] as const
|
||||
export const PROTOCOL_VERSION = 13
|
||||
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] as const
|
||||
|
||||
// ─── Session State Machine ──────────────────────────────────────────
|
||||
export type SessionState = 'created' | 'spawning' | 'running' | 'exiting' | 'exited'
|
||||
@@ -36,6 +36,25 @@ export type TerminalModes = {
|
||||
alternateScreen: boolean
|
||||
}
|
||||
|
||||
/** On-disk shape of checkpoint.json. Written by history-manager, read by
|
||||
* history-reader — one type so the generation pairing with output.log's
|
||||
* header (see terminal-history-log.ts) cannot silently diverge between the
|
||||
* writer and the consumer. */
|
||||
export type TerminalCheckpointFile = {
|
||||
snapshotAnsi: string
|
||||
scrollbackAnsi: string
|
||||
rehydrateSequences: string
|
||||
cwd: string | null
|
||||
cols: number
|
||||
rows: number
|
||||
modes: TerminalModes
|
||||
scrollbackLines: number
|
||||
/** Ties this checkpoint to the output.log whose header carries the same
|
||||
* generation. Absent on checkpoints written before incremental logs. */
|
||||
generation?: number
|
||||
checkpointedAt: string
|
||||
}
|
||||
|
||||
// ─── NDJSON Protocol Messages ───────────────────────────────────────
|
||||
|
||||
// Hello handshake (first message on each socket)
|
||||
@@ -195,6 +214,42 @@ export type GetSnapshotRequest = {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Incremental checkpoint records (v13+) ──────────────────────────
|
||||
// Why: the 5s checkpoint used to re-serialize the full emulator buffer per
|
||||
// tick, stalling the daemon's PTY pump for O(buffer). Incremental checkpoints
|
||||
// take only the raw records accumulated since the last take; the emulator is
|
||||
// serialized only when a full snapshot is explicitly requested (clean
|
||||
// shutdown, pending-buffer overflow, or the on-disk log reaching its cap).
|
||||
export type PendingOutputRecord =
|
||||
| { kind: 'output'; data: string }
|
||||
| { kind: 'resize'; cols: number; rows: number }
|
||||
| { kind: 'clear' }
|
||||
|
||||
export type TakePendingOutputRequest = {
|
||||
id: string
|
||||
type: 'takePendingOutput'
|
||||
payload: {
|
||||
sessionId: string
|
||||
/** When true, the daemon serializes a full snapshot in the SAME
|
||||
* synchronous turn as the take. This atomicity is load-bearing: a
|
||||
* snapshot taken in a separate request could include bytes that a later
|
||||
* take would replay again, duplicating content on cold restore. */
|
||||
includeSnapshot?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export type TakePendingOutputResult = {
|
||||
records: PendingOutputRecord[]
|
||||
/** Monotonic per-session batch sequence. The history log stores it so the
|
||||
* cold-restore reader can detect a lost batch (gap) and discard the log
|
||||
* instead of replaying a stream with missing bytes. */
|
||||
seq: number
|
||||
/** True when the session's pending buffer exceeded its cap and records were
|
||||
* dropped. The caller must fall back to a full snapshot checkpoint. */
|
||||
overflowed: boolean
|
||||
snapshot: TerminalSnapshot | null
|
||||
}
|
||||
|
||||
export type DaemonRequest =
|
||||
| CreateOrAttachRequest
|
||||
| CancelCreateOrAttachRequest
|
||||
@@ -212,6 +267,7 @@ export type DaemonRequest =
|
||||
| SystemResolverHealthRequest
|
||||
| PtySpawnHealthRequest
|
||||
| GetSnapshotRequest
|
||||
| TakePendingOutputRequest
|
||||
|
||||
// ─── RPC Responses (Daemon → Client, on control socket) ────────────
|
||||
|
||||
|
||||
@@ -95,6 +95,23 @@ describe('git remote operations', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('maps recursive submodule push failures to submodule-specific guidance', async () => {
|
||||
gitExecFileAsyncMock
|
||||
.mockRejectedValueOnce(new Error('no branch'))
|
||||
.mockRejectedValueOnce(
|
||||
new Error(
|
||||
"Command failed: git push\nPushing submodule 'find-cmux-followers'\n" +
|
||||
' ! [rejected] master -> master (fetch first)\n' +
|
||||
"Unable to push submodule 'find-cmux-followers'\n" +
|
||||
'fatal: failed to push all needed submodules'
|
||||
)
|
||||
)
|
||||
|
||||
await expect(gitPush('/repo', false)).rejects.toThrow(
|
||||
"Submodule 'find-cmux-followers' has remote changes. Pull inside the submodule, then try again."
|
||||
)
|
||||
})
|
||||
|
||||
it('passes through clean tail line when push error does not match known patterns', async () => {
|
||||
gitExecFileAsyncMock
|
||||
.mockRejectedValueOnce(new Error('no branch'))
|
||||
|
||||
@@ -235,10 +235,19 @@ export async function updateIssue(
|
||||
if (updates.state) {
|
||||
await acquire()
|
||||
try {
|
||||
const cmd = updates.state === 'closed' ? 'close' : 'reopen'
|
||||
await ghExecFileAsync(['issue', cmd, String(issueNumber), '--repo', repo], {
|
||||
...ghOptions
|
||||
})
|
||||
if (updates.state === 'closed') {
|
||||
const closeArgs = ['issue', 'close', String(issueNumber), '--repo', repo]
|
||||
if (updates.stateReason === 'completed') {
|
||||
closeArgs.push('--reason', 'completed')
|
||||
} else if (updates.stateReason === 'not_planned') {
|
||||
closeArgs.push('--reason', 'not planned')
|
||||
} else if (updates.stateReason === 'duplicate' && updates.duplicateOf) {
|
||||
closeArgs.push('--duplicate-of', String(updates.duplicateOf))
|
||||
}
|
||||
await ghExecFileAsync(closeArgs, ghOptions)
|
||||
} else {
|
||||
await ghExecFileAsync(['issue', 'reopen', String(issueNumber), '--repo', repo], ghOptions)
|
||||
}
|
||||
} catch (err) {
|
||||
const stderr = err instanceof Error ? err.message : String(err)
|
||||
// Treat "already closed/open" as a no-op
|
||||
|
||||
@@ -171,15 +171,29 @@ export async function updateIssueBySlug(
|
||||
if (!args.updates || typeof args.updates !== 'object') {
|
||||
return { ok: false, error: { type: 'validation_error', message: 'Updates required.' } }
|
||||
}
|
||||
const { title, body, state, addLabels, removeLabels, addAssignees, removeAssignees } =
|
||||
args.updates
|
||||
const {
|
||||
title,
|
||||
body,
|
||||
state,
|
||||
stateReason,
|
||||
duplicateOf,
|
||||
addLabels,
|
||||
removeLabels,
|
||||
addAssignees,
|
||||
removeAssignees
|
||||
} = args.updates
|
||||
|
||||
// Title / body / state go through PATCH /repos/{owner}/{repo}/issues/{n}.
|
||||
// Labels/assignees go through their dedicated endpoints.
|
||||
const base = `repos/${args.owner}/${args.repo}/issues/${args.number}`
|
||||
|
||||
// 1) PATCH body
|
||||
if (title !== undefined || body !== undefined || state !== undefined) {
|
||||
if (
|
||||
title !== undefined ||
|
||||
body !== undefined ||
|
||||
state !== undefined ||
|
||||
stateReason !== undefined
|
||||
) {
|
||||
const patchArgs: string[] = ['-X', 'PATCH', base]
|
||||
if (title !== undefined) {
|
||||
patchArgs.push('--raw-field', `title=${title}`)
|
||||
@@ -190,6 +204,12 @@ export async function updateIssueBySlug(
|
||||
if (state !== undefined) {
|
||||
patchArgs.push('--raw-field', `state=${state}`)
|
||||
}
|
||||
if (stateReason !== undefined) {
|
||||
patchArgs.push('--raw-field', `state_reason=${stateReason}`)
|
||||
}
|
||||
if (duplicateOf !== undefined) {
|
||||
patchArgs.push('--raw-field', `duplicate_of=${duplicateOf}`)
|
||||
}
|
||||
const r = await runRest<unknown>(patchArgs)
|
||||
if (!r.ok) {
|
||||
return { ok: false, error: r.error }
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable max-lines -- Why: GitLab remote parsing coverage needs many URL/host fixtures against the same mocked git/glab helpers. */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { gitExecFileAsyncMock, glabExecFileAsyncMock, sshExecMock } = vi.hoisted(() => ({
|
||||
@@ -22,96 +21,12 @@ import {
|
||||
getGlabKnownHosts,
|
||||
getProjectRef,
|
||||
getProjectRefForRemote,
|
||||
parseGitLabProjectRef,
|
||||
parseGlabApiResponse,
|
||||
parseGlabAuthStatusHosts,
|
||||
resolveIssueSource
|
||||
} from './gl-utils'
|
||||
import { registerSshGitProvider, unregisterSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
|
||||
describe('gitlab project ref parsing', () => {
|
||||
it('parses HTTPS and SSH GitLab.com remotes', () => {
|
||||
expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets.git')).toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'acme/widgets'
|
||||
})
|
||||
expect(parseGitLabProjectRef('git@gitlab.com:stablyai/orca.git')).toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'stablyai/orca'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves nested group paths', () => {
|
||||
expect(parseGitLabProjectRef('git@gitlab.com:group/subgroup/project.git')).toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'group/subgroup/project'
|
||||
})
|
||||
expect(parseGitLabProjectRef('https://gitlab.com/g1/g2/g3/proj.git')).toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'g1/g2/g3/proj'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for non-GitLab hosts when host not in knownHosts', () => {
|
||||
expect(parseGitLabProjectRef('git@github.com:stablyai/orca.git')).toBeNull()
|
||||
expect(parseGitLabProjectRef('git@example.com:foo/bar.git')).toBeNull()
|
||||
})
|
||||
|
||||
it('matches self-hosted hosts when included in knownHosts', () => {
|
||||
expect(
|
||||
parseGitLabProjectRef('git@gitlab.example.com:team/api.git', [
|
||||
'gitlab.com',
|
||||
'gitlab.example.com'
|
||||
])
|
||||
).toEqual({ host: 'gitlab.example.com', path: 'team/api' })
|
||||
})
|
||||
|
||||
it('parses GitLab remotes with non-standard ports without treating the port as a path segment', () => {
|
||||
expect(
|
||||
parseGitLabProjectRef('ssh://git@gitlab.example.com:2222/team/api.git', [
|
||||
'gitlab.com',
|
||||
'gitlab.example.com'
|
||||
])
|
||||
).toEqual({ host: 'gitlab.example.com', path: 'team/api' })
|
||||
expect(
|
||||
parseGitLabProjectRef('https://gitlab.example.com:8443/team/api.git', [
|
||||
'gitlab.com',
|
||||
'gitlab.example.com'
|
||||
])
|
||||
).toEqual({ host: 'gitlab.example.com', path: 'team/api' })
|
||||
})
|
||||
|
||||
it('rejects single-segment paths (host root or user-only)', () => {
|
||||
expect(parseGitLabProjectRef('git@gitlab.com:foo.git')).toBeNull()
|
||||
expect(parseGitLabProjectRef('https://gitlab.com/foo.git')).toBeNull()
|
||||
})
|
||||
|
||||
it('handles missing .git suffix', () => {
|
||||
expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets')).toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'acme/widgets'
|
||||
})
|
||||
})
|
||||
|
||||
it('strips trailing slashes after .git suffixes', () => {
|
||||
expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets.git/')).toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'acme/widgets'
|
||||
})
|
||||
expect(parseGitLabProjectRef('ssh://git@gitlab.com/acme/widgets.git/')).toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'acme/widgets'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves git protocol remote support', () => {
|
||||
expect(parseGitLabProjectRef('git://gitlab.com/acme/widgets.git')).toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'acme/widgets'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('gitlab project ref resolution', () => {
|
||||
beforeEach(() => {
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
@@ -376,6 +291,24 @@ gitlab.example.com:
|
||||
expect(parseGlabAuthStatusHosts(out)).toContain('gitlab.example.com')
|
||||
})
|
||||
|
||||
it('extracts hosts from bare auth-status section headers', () => {
|
||||
const out = `
|
||||
gitlab.com
|
||||
✓ Logged in to gitlab.com as user1 (/home/user/.config/glab-cli/config.yml)
|
||||
✓ Token: **************************
|
||||
gitlab.internal
|
||||
✓ Logged in as user2
|
||||
✓ Token: **************************
|
||||
Self-hosted-git
|
||||
✓ Logged in as user3
|
||||
`
|
||||
expect(parseGlabAuthStatusHosts(out).sort()).toEqual([
|
||||
'gitlab.com',
|
||||
'gitlab.internal',
|
||||
'self-hosted-git'
|
||||
])
|
||||
})
|
||||
|
||||
it('returns empty list for output with no hosts', () => {
|
||||
expect(parseGlabAuthStatusHosts('Not logged in.')).toEqual([])
|
||||
})
|
||||
|
||||
+55
-64
@@ -1,9 +1,16 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { gitExecFileAsync, glabExecFileAsync } from '../git/runner'
|
||||
import type { ClassifiedError, GitLabProjectRef, IssueSourcePreference } from '../../shared/types'
|
||||
import type { ClassifiedError, IssueSourcePreference } from '../../shared/types'
|
||||
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import { clearProjectRefInFlight, runProjectRefProbeOnce } from './project-ref-inflight'
|
||||
import {
|
||||
DEFAULT_GITLAB_HOSTS,
|
||||
normalizeGitLabHost,
|
||||
parseGitLabProjectRef,
|
||||
parseRemoteProjectRefCandidate,
|
||||
type ProjectRef
|
||||
} from './project-ref-parser'
|
||||
|
||||
// Why: legacy generic execFile wrapper — only used by callers that don't need
|
||||
// WSL-aware routing. Repo-scoped callers should use glabExecFileAsync from
|
||||
@@ -98,11 +105,8 @@ export function classifyListIssuesError(stderr: string): ClassifiedError {
|
||||
return { type: c.type, message: readMessages[c.type] }
|
||||
}
|
||||
|
||||
// ── Project ref resolution ──────────────────────────────────────────
|
||||
// Why: alias the shared shape so `src/shared/types.ts#GitLabProjectRef`
|
||||
// remains the single source of truth while main-side call sites can use
|
||||
// the short local name `ProjectRef`.
|
||||
export type ProjectRef = GitLabProjectRef
|
||||
export { DEFAULT_GITLAB_HOSTS, parseGitLabProjectRef }
|
||||
export type { ProjectRef }
|
||||
|
||||
const PROJECT_REF_CACHE_MAX_ENTRIES = 512
|
||||
const projectRefCache = new Map<string, ProjectRef | null>()
|
||||
@@ -129,61 +133,6 @@ function rememberProjectRefCacheEntry(cacheKey: string, value: ProjectRef | null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts always treated as GitLab. Self-hosted instances are added at
|
||||
* runtime via `getGlabKnownHosts()`, which inspects `glab auth status`.
|
||||
*/
|
||||
export const DEFAULT_GITLAB_HOSTS = ['gitlab.com'] as const
|
||||
|
||||
function normalizeHost(value: string): string {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function stripGitSuffix(path: string): string {
|
||||
return path.replace(/\/+$/, '').replace(/\.git$/i, '')
|
||||
}
|
||||
|
||||
function makeProjectRef(
|
||||
host: string,
|
||||
path: string,
|
||||
knownHosts: readonly string[]
|
||||
): ProjectRef | null {
|
||||
const normalizedHost = normalizeHost(host)
|
||||
if (!knownHosts.map(normalizeHost).includes(normalizedHost)) {
|
||||
return null
|
||||
}
|
||||
const normalizedPath = stripGitSuffix(path.replace(/^\/+/, '')).trim()
|
||||
// Reject paths without at least one group segment — `gitlab.com:foo`
|
||||
// alone is not a project reference.
|
||||
if (!normalizedPath.includes('/')) {
|
||||
return null
|
||||
}
|
||||
return { host: normalizedHost, path: normalizedPath }
|
||||
}
|
||||
|
||||
export function parseGitLabProjectRef(
|
||||
remoteUrl: string,
|
||||
knownHosts: readonly string[] = DEFAULT_GITLAB_HOSTS
|
||||
): ProjectRef | null {
|
||||
const trimmed = remoteUrl.trim()
|
||||
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
|
||||
const scpLike = trimmed.match(/^(?:[^@/:]+@)?([^:\s/]+):([^\s]+?)(?:\.git)?$/)
|
||||
if (scpLike) {
|
||||
return makeProjectRef(scpLike[1], scpLike[2], knownHosts)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed)
|
||||
if (!['http:', 'https:', 'ssh:', 'git:', 'git+ssh:'].includes(url.protocol.toLowerCase())) {
|
||||
return null
|
||||
}
|
||||
return makeProjectRef(url.hostname, url.pathname, knownHosts)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProjectRefForRemote(
|
||||
repoPath: string,
|
||||
remoteName: string,
|
||||
@@ -224,6 +173,17 @@ async function resolveProjectRefForRemote(
|
||||
rememberProjectRefCacheEntry(cacheKey, result)
|
||||
return result
|
||||
}
|
||||
const remoteCandidate = parseRemoteProjectRefCandidate(stdout)
|
||||
if (
|
||||
remoteCandidate &&
|
||||
(await isGlabConfiguredForRemoteHost(repoPath, remoteCandidate, connectionId))
|
||||
) {
|
||||
// Why: `glab auth status` is process-global and can be stale or formatted
|
||||
// differently across versions; the origin host itself is the durable repo context.
|
||||
rememberGlabKnownHost(remoteCandidate.host)
|
||||
rememberProjectRefCacheEntry(cacheKey, remoteCandidate)
|
||||
return remoteCandidate
|
||||
}
|
||||
} catch {
|
||||
if (connectionId) {
|
||||
// Why: remote SSH failures are often transient tunnel/process errors.
|
||||
@@ -315,6 +275,36 @@ export function glabHostnameArgs(
|
||||
|
||||
let knownHostsCache: readonly string[] | null = null
|
||||
|
||||
function rememberGlabKnownHost(host: string): void {
|
||||
const normalizedHost = normalizeGitLabHost(host)
|
||||
if (!knownHostsCache || knownHostsCache.map(normalizeGitLabHost).includes(normalizedHost)) {
|
||||
return
|
||||
}
|
||||
knownHostsCache = [...knownHostsCache, normalizedHost]
|
||||
}
|
||||
|
||||
async function isGlabConfiguredForRemoteHost(
|
||||
repoPath: string,
|
||||
projectRef: Pick<ProjectRef, 'host'>,
|
||||
connectionId?: string | null
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const result = await glabExecFileAsync(
|
||||
['auth', 'status', '--hostname', projectRef.host],
|
||||
glabRepoExecOptions(repoPath, connectionId)
|
||||
)
|
||||
return result !== undefined
|
||||
} catch (error) {
|
||||
const execLike = error as { stdout?: unknown; stderr?: unknown; message?: unknown }
|
||||
const output =
|
||||
[execLike.stdout, execLike.stderr, execLike.message]
|
||||
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
||||
.join('\n') || String(error)
|
||||
const hosts = parseGlabAuthStatusHosts(output).map(normalizeGitLabHost)
|
||||
return hosts.includes(normalizeGitLabHost(projectRef.host))
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal — exposed for tests only */
|
||||
export function _resetKnownHostsCache(): void {
|
||||
knownHostsCache = null
|
||||
@@ -397,9 +387,10 @@ export function parseGlabAuthStatusHosts(output: string): string[] {
|
||||
hosts.add(m[1].toLowerCase())
|
||||
}
|
||||
for (const line of output.split('\n')) {
|
||||
const m = line.match(/^([a-zA-Z0-9][a-zA-Z0-9.-]*\.[a-zA-Z]{2,}):\s*$/)
|
||||
if (m) {
|
||||
hosts.add(m[1].toLowerCase())
|
||||
const bareLine = line.trim()
|
||||
const hostLine = bareLine.endsWith(':') ? bareLine.slice(0, -1) : bareLine
|
||||
if (line === bareLine && /^[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?$/.test(hostLine)) {
|
||||
hosts.add(hostLine.toLowerCase())
|
||||
}
|
||||
}
|
||||
return Array.from(hosts)
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseGitLabProjectRef, parseRemoteProjectRefCandidate } from './project-ref-parser'
|
||||
|
||||
describe('gitlab project ref parsing', () => {
|
||||
it('parses HTTPS and SSH GitLab.com remotes', () => {
|
||||
expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets.git')).toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'acme/widgets'
|
||||
})
|
||||
expect(parseGitLabProjectRef('git@gitlab.com:stablyai/orca.git')).toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'stablyai/orca'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves nested group paths', () => {
|
||||
expect(parseGitLabProjectRef('git@gitlab.com:group/subgroup/project.git')).toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'group/subgroup/project'
|
||||
})
|
||||
expect(parseGitLabProjectRef('https://gitlab.com/g1/g2/g3/proj.git')).toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'g1/g2/g3/proj'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for non-GitLab hosts when host not in knownHosts', () => {
|
||||
expect(parseGitLabProjectRef('git@github.com:stablyai/orca.git')).toBeNull()
|
||||
expect(parseGitLabProjectRef('git@example.com:foo/bar.git')).toBeNull()
|
||||
})
|
||||
|
||||
it('matches self-hosted hosts when included in knownHosts', () => {
|
||||
expect(
|
||||
parseGitLabProjectRef('git@gitlab.example.com:team/api.git', [
|
||||
'gitlab.com',
|
||||
'gitlab.example.com'
|
||||
])
|
||||
).toEqual({ host: 'gitlab.example.com', path: 'team/api' })
|
||||
})
|
||||
|
||||
it('parses GitLab remotes with non-standard ports without treating the port as a path segment', () => {
|
||||
expect(
|
||||
parseGitLabProjectRef('ssh://git@gitlab.example.com:2222/team/api.git', [
|
||||
'gitlab.com',
|
||||
'gitlab.example.com'
|
||||
])
|
||||
).toEqual({ host: 'gitlab.example.com', path: 'team/api' })
|
||||
expect(
|
||||
parseGitLabProjectRef('https://gitlab.example.com:8443/team/api.git', [
|
||||
'gitlab.com',
|
||||
'gitlab.example.com'
|
||||
])
|
||||
).toEqual({ host: 'gitlab.example.com', path: 'team/api' })
|
||||
})
|
||||
|
||||
it('rejects single-segment paths (host root or user-only)', () => {
|
||||
expect(parseGitLabProjectRef('git@gitlab.com:foo.git')).toBeNull()
|
||||
expect(parseGitLabProjectRef('https://gitlab.com/foo.git')).toBeNull()
|
||||
})
|
||||
|
||||
it('handles missing .git suffix', () => {
|
||||
expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets')).toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'acme/widgets'
|
||||
})
|
||||
})
|
||||
|
||||
it('strips trailing slashes after .git suffixes', () => {
|
||||
expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets.git/')).toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'acme/widgets'
|
||||
})
|
||||
expect(parseGitLabProjectRef('ssh://git@gitlab.com/acme/widgets.git/')).toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'acme/widgets'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves git protocol remote support', () => {
|
||||
expect(parseGitLabProjectRef('git://gitlab.com/acme/widgets.git')).toEqual({
|
||||
host: 'gitlab.com',
|
||||
path: 'acme/widgets'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('gitlab remote project ref candidates', () => {
|
||||
it('extracts self-hosted candidates before the host is trusted', () => {
|
||||
expect(parseRemoteProjectRefCandidate('git@gitlab.internal:team/orca.git')).toEqual({
|
||||
host: 'gitlab.internal',
|
||||
path: 'team/orca'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects non-git URLs and single-segment project paths', () => {
|
||||
expect(parseRemoteProjectRefCandidate('file:///tmp/repo')).toBeNull()
|
||||
expect(parseRemoteProjectRefCandidate('git@gitlab.internal:team.git')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { GitLabProjectRef } from '../../shared/types'
|
||||
|
||||
export type ProjectRef = GitLabProjectRef
|
||||
|
||||
/**
|
||||
* Hosts always treated as GitLab. Self-hosted instances are added at
|
||||
* runtime via `getGlabKnownHosts()`, which inspects `glab auth status`.
|
||||
*/
|
||||
export const DEFAULT_GITLAB_HOSTS = ['gitlab.com'] as const
|
||||
|
||||
export function normalizeGitLabHost(value: string): string {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function stripGitSuffix(path: string): string {
|
||||
return path.replace(/\/+$/, '').replace(/\.git$/i, '')
|
||||
}
|
||||
|
||||
function makeProjectRefForTrustedHost(host: string, path: string): ProjectRef | null {
|
||||
const normalizedHost = normalizeGitLabHost(host)
|
||||
const normalizedPath = stripGitSuffix(path.replace(/^\/+/, '')).trim()
|
||||
// Reject paths without at least one group segment — `gitlab.com:foo`
|
||||
// alone is not a project reference.
|
||||
if (!normalizedPath.includes('/')) {
|
||||
return null
|
||||
}
|
||||
return { host: normalizedHost, path: normalizedPath }
|
||||
}
|
||||
|
||||
function makeProjectRef(
|
||||
host: string,
|
||||
path: string,
|
||||
knownHosts: readonly string[]
|
||||
): ProjectRef | null {
|
||||
const normalizedHost = normalizeGitLabHost(host)
|
||||
const normalizedKnownHosts = knownHosts.map(normalizeGitLabHost)
|
||||
if (!normalizedKnownHosts.includes(normalizedHost)) {
|
||||
return null
|
||||
}
|
||||
return makeProjectRefForTrustedHost(normalizedHost, path)
|
||||
}
|
||||
|
||||
export function parseRemoteProjectRefCandidate(remoteUrl: string): ProjectRef | null {
|
||||
const trimmed = remoteUrl.trim()
|
||||
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
|
||||
const scpLike = trimmed.match(/^(?:[^@/:]+@)?([^:\s/]+):([^\s]+?)(?:\.git)?$/)
|
||||
if (scpLike) {
|
||||
return makeProjectRefForTrustedHost(scpLike[1], scpLike[2])
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed)
|
||||
if (!['http:', 'https:', 'ssh:', 'git:', 'git+ssh:'].includes(url.protocol.toLowerCase())) {
|
||||
return null
|
||||
}
|
||||
return makeProjectRefForTrustedHost(url.hostname, url.pathname)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function parseGitLabProjectRef(
|
||||
remoteUrl: string,
|
||||
knownHosts: readonly string[] = DEFAULT_GITLAB_HOSTS
|
||||
): ProjectRef | null {
|
||||
const trimmed = remoteUrl.trim()
|
||||
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
|
||||
const scpLike = trimmed.match(/^(?:[^@/:]+@)?([^:\s/]+):([^\s]+?)(?:\.git)?$/)
|
||||
if (scpLike) {
|
||||
return makeProjectRef(scpLike[1], scpLike[2], knownHosts)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(trimmed)
|
||||
if (!['http:', 'https:', 'ssh:', 'git:', 'git+ssh:'].includes(url.protocol.toLowerCase())) {
|
||||
return null
|
||||
}
|
||||
return makeProjectRef(url.hostname, url.pathname, knownHosts)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
+22
-9
@@ -3,6 +3,10 @@ import type { CliInstallStatus } from '../../shared/cli-install-types'
|
||||
import { CliInstaller } from '../cli/cli-installer'
|
||||
import { WslCliInstaller } from '../cli/wsl-cli-installer'
|
||||
|
||||
function normalizeWslCliDistro(args?: { distro?: string | null }): string | undefined {
|
||||
return args?.distro?.trim() || undefined
|
||||
}
|
||||
|
||||
export function registerCliHandlers(): void {
|
||||
ipcMain.handle('cli:getInstallStatus', async (): Promise<CliInstallStatus> => {
|
||||
return new CliInstaller().getStatus()
|
||||
@@ -16,15 +20,24 @@ export function registerCliHandlers(): void {
|
||||
return new CliInstaller().remove()
|
||||
})
|
||||
|
||||
ipcMain.handle('cli:getWslInstallStatus', async (): Promise<CliInstallStatus> => {
|
||||
return new WslCliInstaller().getStatus()
|
||||
})
|
||||
ipcMain.handle(
|
||||
'cli:getWslInstallStatus',
|
||||
async (_event, args?: { distro?: string | null }): Promise<CliInstallStatus> => {
|
||||
return new WslCliInstaller({ distro: normalizeWslCliDistro(args) }).getStatus()
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('cli:installWsl', async (): Promise<CliInstallStatus> => {
|
||||
return new WslCliInstaller().install()
|
||||
})
|
||||
ipcMain.handle(
|
||||
'cli:installWsl',
|
||||
async (_event, args?: { distro?: string | null }): Promise<CliInstallStatus> => {
|
||||
return new WslCliInstaller({ distro: normalizeWslCliDistro(args) }).install()
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('cli:removeWsl', async (): Promise<CliInstallStatus> => {
|
||||
return new WslCliInstaller().remove()
|
||||
})
|
||||
ipcMain.handle(
|
||||
'cli:removeWsl',
|
||||
async (_event, args?: { distro?: string | null }): Promise<CliInstallStatus> => {
|
||||
return new WslCliInstaller({ distro: normalizeWslCliDistro(args) }).remove()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import path from 'path'
|
||||
import {
|
||||
buildWslLoginShellCommand,
|
||||
escapeWslShCommandForWindows
|
||||
} from '../../shared/wsl-login-shell-command'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const WSL_AGENT_DETECTION_TIMEOUT_MS = 10000
|
||||
@@ -30,8 +34,8 @@ export async function detectWslCommandsOnPath(
|
||||
|
||||
try {
|
||||
// Why: WSL cold-start plus many parallel wsl.exe probes can timeout and
|
||||
// cache an empty result. One interactive probe matches user terminals and
|
||||
// gives the distro a single startup path.
|
||||
// cache an empty result. One probe through the distro user's login shell
|
||||
// matches zsh/bash PATH customizations from their normal terminals.
|
||||
const { stdout } = await execWslAgentDetectionCommand(wslTarget, script)
|
||||
return parseWslDetectedCommands(stdout)
|
||||
} catch {
|
||||
@@ -50,7 +54,13 @@ async function execWslAgentDetectionCommand(
|
||||
const distroArgs = target.distro ? ['-d', target.distro] : []
|
||||
const commandPromise = execFileAsync(
|
||||
'wsl.exe',
|
||||
[...distroArgs, '--exec', 'bash', '-ic', command],
|
||||
[
|
||||
...distroArgs,
|
||||
'--',
|
||||
'sh',
|
||||
'-c',
|
||||
escapeWslShCommandForWindows(buildWslLoginShellCommand(command))
|
||||
],
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
timeout: WSL_AGENT_DETECTION_TIMEOUT_MS
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import {
|
||||
buildWslLoginShellCommand,
|
||||
escapeWslShCommandForWindows
|
||||
} from '../../shared/wsl-login-shell-command'
|
||||
import type { WslPreflightTarget } from './preflight-wsl-agent-detection'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
export type PreflightWslCommandResult = { stdout: string; stderr: string }
|
||||
|
||||
export function runPreflightCommandInWsl(
|
||||
target: WslPreflightTarget,
|
||||
command: string,
|
||||
timeoutMs: number
|
||||
): Promise<PreflightWslCommandResult> {
|
||||
const distroArgs = target.distro ? ['-d', target.distro] : []
|
||||
return execFileAsync(
|
||||
'wsl.exe',
|
||||
[
|
||||
...distroArgs,
|
||||
'--',
|
||||
'sh',
|
||||
'-c',
|
||||
escapeWslShCommandForWindows(buildWslLoginShellCommand(command))
|
||||
],
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
timeout: timeoutMs
|
||||
}
|
||||
) as Promise<PreflightWslCommandResult>
|
||||
}
|
||||
@@ -278,10 +278,10 @@ describe('preflight', () => {
|
||||
}
|
||||
if (command === 'wsl.exe') {
|
||||
const script = String(args[5])
|
||||
if (script === "'gh' --version") {
|
||||
if (script.includes('gh') && script.includes('--version')) {
|
||||
return { stdout: 'gh version 2.0.0\n' }
|
||||
}
|
||||
if (script === "'gh' auth status") {
|
||||
if (script.includes('gh') && script.includes('auth status')) {
|
||||
return { stdout: 'github.com\n - Active account: true\n' }
|
||||
}
|
||||
throw new Error(`unexpected WSL script ${script}`)
|
||||
@@ -294,12 +294,12 @@ describe('preflight', () => {
|
||||
expect(status.gh).toEqual({ installed: true, authenticated: true })
|
||||
expect(execFileAsyncMock).toHaveBeenCalledWith(
|
||||
'wsl.exe',
|
||||
['-d', 'Ubuntu', '--', 'bash', '-lc', "'gh' --version"],
|
||||
['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringMatching(/gh[\s\S]*--version/)],
|
||||
{ encoding: 'utf-8', timeout: 5000 }
|
||||
)
|
||||
expect(execFileAsyncMock).toHaveBeenCalledWith(
|
||||
'wsl.exe',
|
||||
['-d', 'Ubuntu', '--', 'bash', '-lc', "'gh' auth status"],
|
||||
['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringMatching(/gh[\s\S]*auth status/)],
|
||||
{ encoding: 'utf-8', timeout: 5000 }
|
||||
)
|
||||
})
|
||||
@@ -318,10 +318,18 @@ describe('preflight', () => {
|
||||
if (command === 'gh' || command === 'glab') {
|
||||
return Promise.reject(Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' }))
|
||||
}
|
||||
if (command === 'wsl.exe' && Array.isArray(args) && args.at(-1) === "'gh' --version") {
|
||||
if (
|
||||
command === 'wsl.exe' &&
|
||||
Array.isArray(args) &&
|
||||
String(args.at(-1)).includes("'gh' --version")
|
||||
) {
|
||||
return new Promise(() => {})
|
||||
}
|
||||
if (command === 'wsl.exe' && Array.isArray(args) && args.at(-1) === "'glab' --version") {
|
||||
if (
|
||||
command === 'wsl.exe' &&
|
||||
Array.isArray(args) &&
|
||||
String(args.at(-1)).includes("'glab' --version")
|
||||
) {
|
||||
return Promise.reject(Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' }))
|
||||
}
|
||||
throw new Error(`unexpected command ${String(command)}`)
|
||||
@@ -606,9 +614,9 @@ describe('preflight', () => {
|
||||
expect.arrayContaining([
|
||||
'-d',
|
||||
'Ubuntu',
|
||||
'--exec',
|
||||
'bash',
|
||||
'-ic',
|
||||
'--',
|
||||
'sh',
|
||||
'-c',
|
||||
expect.stringContaining("'claude'")
|
||||
]),
|
||||
{ encoding: 'utf-8', timeout: 10000 }
|
||||
@@ -637,7 +645,7 @@ describe('preflight', () => {
|
||||
expect(resolveCliCommandsMock).not.toHaveBeenCalled()
|
||||
expect(execFileAsyncMock).toHaveBeenCalledWith(
|
||||
'wsl.exe',
|
||||
expect.arrayContaining(['--exec', 'bash', '-ic', expect.stringContaining("'codex'")]),
|
||||
expect.arrayContaining(['--', 'sh', '-c', expect.stringContaining("'codex'")]),
|
||||
{ encoding: 'utf-8', timeout: 10000 }
|
||||
)
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getGiteaAuthStatus } from '../gitea/client'
|
||||
import { _resetKnownHostsCache } from '../gitlab/gl-utils'
|
||||
import { getActiveMultiplexer } from './ssh'
|
||||
import { detectWslCommandsOnPath, type WslPreflightTarget } from './preflight-wsl-agent-detection'
|
||||
import { runPreflightCommandInWsl } from './preflight-wsl-command'
|
||||
import { detectCommandsInInstallDirs } from './local-agent-install-dir-detection'
|
||||
const execFileAsync = promisify(execFile)
|
||||
const PREFLIGHT_COMMAND_TIMEOUT_MS = 5000
|
||||
@@ -102,11 +103,7 @@ async function execCommandInWsl(
|
||||
target: WslPreflightTarget,
|
||||
command: string
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const distroArgs = target.distro ? ['-d', target.distro] : []
|
||||
const commandPromise = execFileAsync('wsl.exe', [...distroArgs, '--', 'bash', '-lc', command], {
|
||||
encoding: 'utf-8',
|
||||
timeout: PREFLIGHT_COMMAND_TIMEOUT_MS
|
||||
}) as Promise<{ stdout: string; stderr: string }>
|
||||
const commandPromise = runPreflightCommandInWsl(target, command, PREFLIGHT_COMMAND_TIMEOUT_MS)
|
||||
return withPreflightTimeout('wsl.exe', commandPromise)
|
||||
}
|
||||
|
||||
|
||||
+43
-40
@@ -546,14 +546,14 @@ describe('registerPtyHandlers', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function spawnAndGetCall(args?: {
|
||||
async function spawnAndGetCall(args?: {
|
||||
cwd?: string
|
||||
env?: Record<string, string>
|
||||
command?: string
|
||||
}): [string, string[], { cwd: string; env: Record<string, string> }] {
|
||||
}): Promise<[string, string[], { cwd: string; env: Record<string, string> }]> {
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
handlers.get('pty:spawn')!(null, {
|
||||
await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
...args
|
||||
@@ -3811,11 +3811,11 @@ describe('registerPtyHandlers', () => {
|
||||
delete process.env.PYTHONUTF8
|
||||
})
|
||||
|
||||
it('passes chcp 65001 to cmd.exe for UTF-8 console output', () => {
|
||||
it('passes chcp 65001 to cmd.exe for UTF-8 console output', async () => {
|
||||
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
|
||||
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
|
||||
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'C:\\Windows\\system32\\cmd.exe',
|
||||
@@ -3824,11 +3824,11 @@ describe('registerPtyHandlers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('sets Console encoding for powershell.exe', () => {
|
||||
it('sets Console encoding for powershell.exe', async () => {
|
||||
process.env.COMSPEC = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'
|
||||
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
|
||||
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe',
|
||||
@@ -3837,11 +3837,11 @@ describe('registerPtyHandlers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('sets Console encoding for pwsh.exe', () => {
|
||||
it('sets Console encoding for pwsh.exe', async () => {
|
||||
process.env.COMSPEC = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'
|
||||
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
|
||||
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'C:\\Program Files\\PowerShell\\7\\pwsh.exe',
|
||||
@@ -3850,34 +3850,34 @@ describe('registerPtyHandlers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('sets PYTHONUTF8=1 in the spawn environment on Windows', () => {
|
||||
it('sets PYTHONUTF8=1 in the spawn environment on Windows', async () => {
|
||||
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
|
||||
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
|
||||
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
|
||||
|
||||
const spawnCall = spawnMock.mock.calls.at(-1)!
|
||||
const env = spawnCall[2].env as Record<string, string>
|
||||
expect(env.PYTHONUTF8).toBe('1')
|
||||
})
|
||||
|
||||
it('does not override an existing PYTHONUTF8 value', () => {
|
||||
it('does not override an existing PYTHONUTF8 value', async () => {
|
||||
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
|
||||
process.env.PYTHONUTF8 = '0'
|
||||
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
|
||||
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
|
||||
|
||||
const spawnCall = spawnMock.mock.calls.at(-1)!
|
||||
const env = spawnCall[2].env as Record<string, string>
|
||||
expect(env.PYTHONUTF8).toBe('0')
|
||||
})
|
||||
|
||||
it('launches Git Bash from COMSPEC as an interactive login shell', () => {
|
||||
it('launches Git Bash from COMSPEC as an interactive login shell', async () => {
|
||||
process.env.COMSPEC = 'C:\\Program Files\\Git\\bin\\bash.exe'
|
||||
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
|
||||
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'C:\\Program Files\\Git\\bin\\bash.exe',
|
||||
@@ -3888,7 +3888,7 @@ describe('registerPtyHandlers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('uses terminalWindowsShell setting over COMSPEC when provided', () => {
|
||||
it('uses terminalWindowsShell setting over COMSPEC when provided', async () => {
|
||||
// Why: COMSPEC always points to cmd.exe on stock Windows, so without the
|
||||
// setting the terminal would ignore the user's shell preference.
|
||||
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
|
||||
@@ -3902,7 +3902,7 @@ describe('registerPtyHandlers', () => {
|
||||
terminalWindowsShell: 'powershell.exe'
|
||||
}) as never
|
||||
)
|
||||
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
|
||||
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'powershell.exe',
|
||||
@@ -3911,7 +3911,7 @@ describe('registerPtyHandlers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('spawns powershell.exe when PowerShell family keeps the inbox implementation', () => {
|
||||
it('spawns powershell.exe when PowerShell family keeps the inbox implementation', async () => {
|
||||
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
|
||||
|
||||
registerPtyHandlers(
|
||||
@@ -3924,7 +3924,7 @@ describe('registerPtyHandlers', () => {
|
||||
terminalWindowsPowerShellImplementation: 'powershell.exe'
|
||||
}) as never
|
||||
)
|
||||
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
|
||||
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'powershell.exe',
|
||||
@@ -3933,7 +3933,7 @@ describe('registerPtyHandlers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('spawns pwsh.exe when PowerShell 7 is selected and available', () => {
|
||||
it('spawns pwsh.exe when PowerShell 7 is selected and available', async () => {
|
||||
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
|
||||
isPwshAvailableMock.mockReturnValue(true)
|
||||
|
||||
@@ -3947,12 +3947,12 @@ describe('registerPtyHandlers', () => {
|
||||
terminalWindowsPowerShellImplementation: 'pwsh.exe'
|
||||
}) as never
|
||||
)
|
||||
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
|
||||
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith('pwsh.exe', POWERSHELL_OSC133_ARGS, expect.any(Object))
|
||||
})
|
||||
|
||||
it('falls back to powershell.exe when PowerShell 7 is selected but unavailable', () => {
|
||||
it('falls back to powershell.exe when PowerShell 7 is selected but unavailable', async () => {
|
||||
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
|
||||
isPwshAvailableMock.mockReturnValue(false)
|
||||
|
||||
@@ -3966,7 +3966,7 @@ describe('registerPtyHandlers', () => {
|
||||
terminalWindowsPowerShellImplementation: 'pwsh.exe'
|
||||
}) as never
|
||||
)
|
||||
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
|
||||
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'powershell.exe',
|
||||
@@ -3975,7 +3975,7 @@ describe('registerPtyHandlers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to powershell.exe when shellOverride requests pwsh.exe but pwsh is unavailable', () => {
|
||||
it('falls back to powershell.exe when shellOverride requests pwsh.exe but pwsh is unavailable', async () => {
|
||||
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
|
||||
isPwshAvailableMock.mockReturnValue(false)
|
||||
|
||||
@@ -3989,7 +3989,7 @@ describe('registerPtyHandlers', () => {
|
||||
terminalWindowsPowerShellImplementation: 'pwsh.exe'
|
||||
}) as never
|
||||
)
|
||||
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, shellOverride: 'pwsh.exe' })
|
||||
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, shellOverride: 'pwsh.exe' })
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'powershell.exe',
|
||||
@@ -3998,7 +3998,7 @@ describe('registerPtyHandlers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores the PowerShell implementation setting for cmd.exe', () => {
|
||||
it('ignores the PowerShell implementation setting for cmd.exe', async () => {
|
||||
process.env.COMSPEC = 'C:\\Windows\\system32\\powershell.exe'
|
||||
isPwshAvailableMock.mockReturnValue(true)
|
||||
|
||||
@@ -4012,7 +4012,7 @@ describe('registerPtyHandlers', () => {
|
||||
terminalWindowsPowerShellImplementation: 'pwsh.exe'
|
||||
}) as never
|
||||
)
|
||||
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
|
||||
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'cmd.exe',
|
||||
@@ -4021,7 +4021,7 @@ describe('registerPtyHandlers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores the PowerShell implementation setting for wsl.exe', () => {
|
||||
it('ignores the PowerShell implementation setting for wsl.exe', async () => {
|
||||
process.env.COMSPEC = 'C:\\Windows\\system32\\powershell.exe'
|
||||
isPwshAvailableMock.mockReturnValue(true)
|
||||
|
||||
@@ -4035,7 +4035,7 @@ describe('registerPtyHandlers', () => {
|
||||
terminalWindowsPowerShellImplementation: 'pwsh.exe'
|
||||
}) as never
|
||||
)
|
||||
handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
|
||||
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
|
||||
|
||||
const spawnOptions = spawnMock.mock.calls.at(-1)?.[2] as { env: Record<string, string> }
|
||||
expect(spawnMock).toHaveBeenCalledWith('wsl.exe', expect.any(Array), expect.any(Object))
|
||||
@@ -4043,7 +4043,7 @@ describe('registerPtyHandlers', () => {
|
||||
expect(spawnOptions.env.ORCA_CODEX_HOME).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps shellOverride priority for one-off tabs', () => {
|
||||
it('keeps shellOverride priority for one-off tabs', async () => {
|
||||
process.env.COMSPEC = 'C:\\Windows\\system32\\cmd.exe'
|
||||
isPwshAvailableMock.mockReturnValue(false)
|
||||
|
||||
@@ -4057,7 +4057,7 @@ describe('registerPtyHandlers', () => {
|
||||
terminalWindowsPowerShellImplementation: 'pwsh.exe'
|
||||
}) as never
|
||||
)
|
||||
handlers.get('pty:spawn')!(null, {
|
||||
await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
shellOverride: 'wsl.exe'
|
||||
@@ -4113,7 +4113,7 @@ describe('registerPtyHandlers', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('spawns a plain POSIX login shell and queues startup commands for the live session', () => {
|
||||
it('spawns a plain POSIX login shell and queues startup commands for the live session', async () => {
|
||||
const originalPlatform = process.platform
|
||||
const originalShell = process.env.SHELL
|
||||
const originalZdotdir = process.env.ZDOTDIR
|
||||
@@ -4126,7 +4126,10 @@ describe('registerPtyHandlers', () => {
|
||||
delete process.env.ZDOTDIR
|
||||
|
||||
try {
|
||||
const [shell, args, options] = spawnAndGetCall({ cwd: '/tmp', command: 'printf "hello"' })
|
||||
const [shell, args, options] = await spawnAndGetCall({
|
||||
cwd: '/tmp',
|
||||
command: 'printf "hello"'
|
||||
})
|
||||
expect(shell).toBe('/bin/zsh')
|
||||
expect(args).toEqual(['-l'])
|
||||
expect(options.env.ZDOTDIR).toBe('/tmp/orca-user-data/shell-ready/zsh')
|
||||
@@ -4149,7 +4152,7 @@ describe('registerPtyHandlers', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the POSIX shell wrapper so OpenCode config survives shell startup files', () => {
|
||||
it('uses the POSIX shell wrapper so OpenCode config survives shell startup files', async () => {
|
||||
const originalPlatform = process.platform
|
||||
const originalShell = process.env.SHELL
|
||||
|
||||
@@ -4160,7 +4163,7 @@ describe('registerPtyHandlers', () => {
|
||||
process.env.SHELL = '/bin/zsh'
|
||||
|
||||
try {
|
||||
const [shell, args, options] = spawnAndGetCall({ cwd: '/tmp' })
|
||||
const [shell, args, options] = await spawnAndGetCall({ cwd: '/tmp' })
|
||||
expect(shell).toBe('/bin/zsh')
|
||||
expect(args).toEqual(['-l'])
|
||||
expect(options.env.OPENCODE_CONFIG_DIR).toBe('/tmp/orca-opencode-config')
|
||||
@@ -4180,7 +4183,7 @@ describe('registerPtyHandlers', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the POSIX shell wrapper so Pi config survives shell startup files', () => {
|
||||
it('uses the POSIX shell wrapper so Pi config survives shell startup files', async () => {
|
||||
const originalPlatform = process.platform
|
||||
const originalShell = process.env.SHELL
|
||||
|
||||
@@ -4196,7 +4199,7 @@ describe('registerPtyHandlers', () => {
|
||||
}))
|
||||
|
||||
try {
|
||||
const [shell, args, options] = spawnAndGetCall({
|
||||
const [shell, args, options] = await spawnAndGetCall({
|
||||
cwd: '/tmp',
|
||||
env: { PI_CODING_AGENT_DIR: '/tmp/user-pi-agent' }
|
||||
})
|
||||
@@ -4233,7 +4236,7 @@ describe('registerPtyHandlers', () => {
|
||||
process.env.SHELL = '/bin/bash'
|
||||
|
||||
try {
|
||||
spawnAndGetCall({ cwd: '/tmp', command: 'echo hello' })
|
||||
await spawnAndGetCall({ cwd: '/tmp', command: 'echo hello' })
|
||||
|
||||
const { getBashShellReadyRcfileContent } = await import('./pty')
|
||||
const bashRcContent = getBashShellReadyRcfileContent()
|
||||
@@ -4259,7 +4262,7 @@ describe('registerPtyHandlers', () => {
|
||||
|
||||
try {
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
handlers.get('pty:spawn')!(null, {
|
||||
await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/tmp',
|
||||
@@ -4288,7 +4291,7 @@ describe('registerPtyHandlers', () => {
|
||||
|
||||
try {
|
||||
registerPtyHandlers(mainWindow as never)
|
||||
handlers.get('pty:spawn')!(null, {
|
||||
await handlers.get('pty:spawn')!(null, {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/tmp',
|
||||
|
||||
@@ -102,7 +102,7 @@ vi.mock('./filesystem-auth', () => ({
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: vi.fn().mockImplementation((id: string) => {
|
||||
if (id === 'conn-1') {
|
||||
if (id === 'conn-1' || id === 'conn-2') {
|
||||
return mockGitProvider
|
||||
}
|
||||
return undefined
|
||||
@@ -111,7 +111,7 @@ vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
|
||||
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
|
||||
getSshFilesystemProvider: vi.fn().mockImplementation((id: string) => {
|
||||
if (id === 'conn-1') {
|
||||
if (id === 'conn-1' || id === 'conn-2') {
|
||||
return mockFilesystemProvider
|
||||
}
|
||||
return undefined
|
||||
@@ -120,7 +120,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({
|
||||
|
||||
vi.mock('./ssh', () => ({
|
||||
getActiveMultiplexer: vi.fn().mockImplementation((id: string) => {
|
||||
if (id === 'conn-1') {
|
||||
if (id === 'conn-1' || id === 'conn-2') {
|
||||
return mockMultiplexer
|
||||
}
|
||||
return undefined
|
||||
@@ -1298,6 +1298,59 @@ describe('repos:addRemote', () => {
|
||||
expect(mockStore.addRepo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows the same resolved remote path on a different SSH connection', async () => {
|
||||
const existing = {
|
||||
id: 'machine-1-project',
|
||||
path: '/home/user/project',
|
||||
connectionId: 'conn-1',
|
||||
displayName: 'project',
|
||||
badgeColor: '#fff',
|
||||
addedAt: 1000,
|
||||
kind: 'git'
|
||||
}
|
||||
mockStore.getRepos.mockReturnValue([existing])
|
||||
|
||||
const result = await handlers.get('repos:addRemote')!(null, {
|
||||
connectionId: 'conn-2',
|
||||
remotePath: '/home/user/project'
|
||||
})
|
||||
|
||||
expect(mockStore.addRepo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: '/home/user/project',
|
||||
connectionId: 'conn-2'
|
||||
})
|
||||
)
|
||||
expect(result).toHaveProperty('repo.connectionId', 'conn-2')
|
||||
expect(result).toHaveProperty('repo.id')
|
||||
expect(result).not.toEqual({ repo: existing })
|
||||
})
|
||||
|
||||
it('dedupes remote projects after git root resolution on the same SSH connection', async () => {
|
||||
const existing = {
|
||||
id: 'existing-id',
|
||||
path: '/home/user/project',
|
||||
connectionId: 'conn-1',
|
||||
displayName: 'project',
|
||||
badgeColor: '#fff',
|
||||
addedAt: 1000,
|
||||
kind: 'git'
|
||||
}
|
||||
mockStore.getRepos.mockReturnValue([existing])
|
||||
mockGitProvider.isGitRepoAsync.mockResolvedValueOnce({
|
||||
isRepo: true,
|
||||
rootPath: '/home/user/project'
|
||||
})
|
||||
|
||||
const result = await handlers.get('repos:addRemote')!(null, {
|
||||
connectionId: 'conn-1',
|
||||
remotePath: '/home/user/project/src'
|
||||
})
|
||||
|
||||
expect(result).toEqual({ repo: existing })
|
||||
expect(mockStore.addRepo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throws when SSH connection is not found', async () => {
|
||||
const result = await handlers.get('repos:addRemote')!(null, {
|
||||
connectionId: 'unknown-conn',
|
||||
|
||||
+143
-368
@@ -31,11 +31,6 @@ import { DEFAULT_REPO_BADGE_COLOR } from '../../shared/constants'
|
||||
import { normalizeRepoBadgeColor } from '../../shared/repo-badge-color'
|
||||
import { sanitizeRepoIcon } from '../../shared/repo-icon'
|
||||
import { normalizeRepoSourceControlAiOverrides } from '../../shared/source-control-ai'
|
||||
import {
|
||||
isRuntimePathAbsolute,
|
||||
normalizeRuntimePathForComparison,
|
||||
relativePathInsideRoot
|
||||
} from '../../shared/cross-platform-path'
|
||||
import { isTuiAgent } from '../../shared/tui-agent-config'
|
||||
import { invalidateAuthorizedRootsCache } from './filesystem-auth'
|
||||
import type { ChildProcess } from 'child_process'
|
||||
@@ -78,15 +73,11 @@ import { track } from '../telemetry/client'
|
||||
import { getCohortAtEmit } from '../telemetry/cohort-classifier'
|
||||
import type { RepoMethod } from '../../shared/telemetry-events'
|
||||
import { detectRepoIconAndUpstream } from '../repo-icon-autodetect'
|
||||
import { getProjectHostSetupForRepo } from '../../shared/project-host-setup-projection'
|
||||
import { normalizeExecutionHostId, parseExecutionHostId } from '../../shared/execution-host'
|
||||
import { joinRemotePath } from '../ssh/ssh-remote-platform'
|
||||
import {
|
||||
assertFolderWorkspacePathUsable,
|
||||
getFolderWorkspacePathStatus,
|
||||
getFolderWorkspacePathStatusForPath
|
||||
} from '../project-groups/folder-workspace-path-status'
|
||||
import { getGitCloneFailureMessage } from '../../shared/git-clone-failure-message'
|
||||
|
||||
// Why: `method` answers "which entry point did the user take?", not "what did
|
||||
// they add?" — so the IPC the renderer invoked IS the method. We never send
|
||||
@@ -121,193 +112,6 @@ function emitRepoAdded(method: RepoMethod, alreadyExisted: boolean, isGitRepo?:
|
||||
track('repo_added', props)
|
||||
}
|
||||
|
||||
function buildProjectHostSetupResult(store: Store, repo: Repo): ProjectHostSetupResult {
|
||||
const setup = getProjectHostSetupForRepo(store.getProjectHostSetups(), repo)
|
||||
const project = store.getProjects().find((entry) => entry.id === setup.projectId)
|
||||
if (!project) {
|
||||
throw new Error(`Project setup was created without a project record: ${setup.projectId}`)
|
||||
}
|
||||
return { project, setup, repo }
|
||||
}
|
||||
|
||||
function alignRepoWithRequestedProject(
|
||||
store: Store,
|
||||
repo: Repo,
|
||||
projectId: string,
|
||||
setupMethod: ProjectHostSetupExistingFolderArgs['setupMethod'] = 'imported-existing-folder'
|
||||
): ProjectHostSetupResult {
|
||||
let setup = getProjectHostSetupForRepo(store.getProjectHostSetups(), repo)
|
||||
if (setup.projectId !== projectId) {
|
||||
const project = store.getProjects().find((entry) => entry.id === projectId)
|
||||
if (!project?.providerIdentity || project.providerIdentity.provider !== 'github') {
|
||||
throw new Error('Imported folder does not match the selected project identity.')
|
||||
}
|
||||
// Why: setup-on-host is an explicit user action for this project. When the
|
||||
// folder lacks upstream metadata but the selected project has provider
|
||||
// identity, stamp that identity so compatibility projection can merge it.
|
||||
const updated = store.updateRepo(repo.id, {
|
||||
upstream: {
|
||||
owner: project.providerIdentity.owner,
|
||||
repo: project.providerIdentity.repo
|
||||
}
|
||||
})
|
||||
if (!updated) {
|
||||
throw new Error(`Project setup repo disappeared before it could be linked: ${repo.id}`)
|
||||
}
|
||||
repo = updated
|
||||
setup = getProjectHostSetupForRepo(store.getProjectHostSetups(), repo)
|
||||
}
|
||||
const updated = store.updateRepo(repo.id, { projectHostSetupMethod: setupMethod })
|
||||
if (!updated) {
|
||||
throw new Error(
|
||||
`Project setup repo disappeared before setup metadata could be linked: ${repo.id}`
|
||||
)
|
||||
}
|
||||
repo = updated
|
||||
return buildProjectHostSetupResult(store, repo)
|
||||
}
|
||||
|
||||
async function addLocalRepoFromPath(
|
||||
store: Store,
|
||||
path: string,
|
||||
kind: 'git' | 'folder' = 'git'
|
||||
): Promise<{ repo: Repo; alreadyExisted: boolean } | { error: string }> {
|
||||
const repoKind = kind === 'folder' ? 'folder' : 'git'
|
||||
if (repoKind === 'git' && !isGitRepo(path)) {
|
||||
return { error: `Not a valid git repository: ${path}` }
|
||||
}
|
||||
|
||||
const existing = store.getRepos().find((r) => r.path === path)
|
||||
if (existing) {
|
||||
return { repo: existing, alreadyExisted: true }
|
||||
}
|
||||
|
||||
const detected = await detectRepoIconAndUpstream({ repoPath: path, kind: repoKind })
|
||||
const repo: Repo = {
|
||||
id: randomUUID(),
|
||||
path,
|
||||
displayName: getRepoName(path),
|
||||
badgeColor: DEFAULT_REPO_BADGE_COLOR,
|
||||
...detected,
|
||||
addedAt: Date.now(),
|
||||
kind: repoKind,
|
||||
...(repoKind === 'git'
|
||||
? {
|
||||
externalWorktreeVisibility: 'hide' as const,
|
||||
externalWorktreeVisibilityLegacy: false,
|
||||
// Why: new Add Project imports should become explicit ready host
|
||||
// setups; `legacy-repo` is reserved for older records/projection.
|
||||
projectHostSetupMethod: 'imported-existing-folder' as const
|
||||
}
|
||||
: {})
|
||||
}
|
||||
|
||||
store.addRepo(repo)
|
||||
return { repo, alreadyExisted: false }
|
||||
}
|
||||
|
||||
async function addRemoteRepoFromPath(
|
||||
store: Store,
|
||||
args: {
|
||||
connectionId: string
|
||||
remotePath: string
|
||||
displayName?: string
|
||||
kind?: 'git' | 'folder'
|
||||
setupMethod?: Repo['projectHostSetupMethod']
|
||||
}
|
||||
): Promise<{ repo: Repo; alreadyExisted: boolean } | { error: string }> {
|
||||
const gitProvider = getSshGitProvider(args.connectionId)
|
||||
if (!gitProvider) {
|
||||
return { error: `SSH connection "${args.connectionId}" not found or not connected` }
|
||||
}
|
||||
|
||||
let repoKind: 'git' | 'folder' = args.kind ?? 'git'
|
||||
let resolvedPath = await resolveRemoteHomePath(args.connectionId, args.remotePath)
|
||||
|
||||
const existing = store
|
||||
.getRepos()
|
||||
.find(
|
||||
(repo) =>
|
||||
repo.connectionId === args.connectionId &&
|
||||
normalizeRuntimePathForComparison(repo.path) ===
|
||||
normalizeRuntimePathForComparison(resolvedPath)
|
||||
)
|
||||
if (existing) {
|
||||
return { repo: existing, alreadyExisted: true }
|
||||
}
|
||||
|
||||
if (args.kind !== 'folder') {
|
||||
try {
|
||||
const check = await gitProvider.isGitRepoAsync(resolvedPath)
|
||||
if (check.isRepo) {
|
||||
repoKind = 'git'
|
||||
if (check.rootPath) {
|
||||
resolvedPath = check.rootPath
|
||||
}
|
||||
} else {
|
||||
return { error: `Not a valid git repository: ${args.remotePath}` }
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.includes('Not a valid git repository')) {
|
||||
return { error: err.message }
|
||||
}
|
||||
return { error: `Not a valid git repository: ${args.remotePath}` }
|
||||
}
|
||||
}
|
||||
|
||||
const existingAfterRootResolve = store
|
||||
.getRepos()
|
||||
.find(
|
||||
(repo) =>
|
||||
repo.connectionId === args.connectionId &&
|
||||
normalizeRuntimePathForComparison(repo.path) ===
|
||||
normalizeRuntimePathForComparison(resolvedPath)
|
||||
)
|
||||
if (existingAfterRootResolve) {
|
||||
return { repo: existingAfterRootResolve, alreadyExisted: true }
|
||||
}
|
||||
|
||||
const folderName = getRemoteRepoFolderName(resolvedPath)
|
||||
let displayName = args.displayName || folderName
|
||||
if (!args.displayName && (args.remotePath === '~' || args.remotePath === '~/')) {
|
||||
const sshTarget = store.getSshTarget(args.connectionId)
|
||||
if (sshTarget) {
|
||||
displayName = sshTarget.label
|
||||
}
|
||||
}
|
||||
|
||||
const detected = await detectRepoIconAndUpstream({
|
||||
repoPath: resolvedPath,
|
||||
kind: repoKind,
|
||||
connectionId: args.connectionId
|
||||
})
|
||||
const repo: Repo = {
|
||||
id: randomUUID(),
|
||||
path: resolvedPath,
|
||||
displayName,
|
||||
badgeColor: DEFAULT_REPO_BADGE_COLOR,
|
||||
...detected,
|
||||
addedAt: Date.now(),
|
||||
kind: repoKind,
|
||||
connectionId: args.connectionId,
|
||||
...(repoKind === 'git'
|
||||
? {
|
||||
externalWorktreeVisibility: 'hide' as const,
|
||||
externalWorktreeVisibilityLegacy: false,
|
||||
projectHostSetupMethod: args.setupMethod ?? ('imported-existing-folder' as const)
|
||||
}
|
||||
: {})
|
||||
}
|
||||
|
||||
store.addRepo(repo)
|
||||
const mux = getActiveMultiplexer(args.connectionId)
|
||||
if (mux) {
|
||||
mux.notify('session.registerRoot', { rootPath: resolvedPath })
|
||||
}
|
||||
|
||||
return { repo, alreadyExisted: false }
|
||||
}
|
||||
|
||||
function getRemoteRepoFolderName(remotePath: string): string {
|
||||
const trimmed = remotePath.replace(/[\\/]+$/, '')
|
||||
if (!trimmed) {
|
||||
@@ -621,18 +425,6 @@ const completedNestedRepoScans = new Map<string, CompletedNestedRepoScan>()
|
||||
const MAX_COMPLETED_NESTED_SCAN_RESULTS = 50
|
||||
const GIT_AVAILABILITY_TIMEOUT_MS = 1500
|
||||
|
||||
function emitCloneProgressFromText(mainWindow: BrowserWindow, text: string): void {
|
||||
for (const line of text.split(/[\r\n]+/)) {
|
||||
const match = line.match(/^([\w\s]+):\s+(\d+)%/)
|
||||
if (match && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('repos:clone-progress', {
|
||||
phase: match[1].trim(),
|
||||
percent: parseInt(match[2], 10)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ProjectGroupCreateArgs = z.object({
|
||||
name: z.string().min(1),
|
||||
parentPath: z.string().nullable().optional(),
|
||||
@@ -661,57 +453,6 @@ const ProjectGroupMoveProjectArgs = z.object({
|
||||
order: z.number().finite().optional()
|
||||
})
|
||||
|
||||
const ProjectHostSetupExistingFolderIpcArgs = z.object({
|
||||
projectId: z.string().min(1),
|
||||
hostId: z.string().min(1),
|
||||
path: z.string().min(1),
|
||||
kind: z.enum(['git', 'folder']).optional(),
|
||||
displayName: z.string().min(1).optional(),
|
||||
setupMethod: z.enum(['imported-existing-folder', 'cloned']).optional()
|
||||
})
|
||||
|
||||
const ProjectHostSetupCreateIpcArgs = z.object({
|
||||
projectId: z.string().min(1),
|
||||
hostId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.transform((value, ctx) => {
|
||||
const hostId = normalizeExecutionHostId(value)
|
||||
if (!hostId) {
|
||||
ctx.addIssue({ code: 'custom', message: 'Invalid host ID' })
|
||||
return z.NEVER
|
||||
}
|
||||
return hostId
|
||||
}),
|
||||
setupId: z.string().min(1).optional(),
|
||||
path: z.string().optional(),
|
||||
kind: z.enum(['git', 'folder']).optional(),
|
||||
displayName: z.string().min(1).optional(),
|
||||
worktreeBasePath: z.string().optional(),
|
||||
gitUsername: z.string().optional(),
|
||||
setupState: z.enum(['ready', 'not-set-up', 'setting-up', 'error', 'unsupported']).optional(),
|
||||
setupMethod: z.enum(['imported-existing-folder', 'cloned', 'provisioned']).optional()
|
||||
})
|
||||
|
||||
const ProjectHostSetupUpdateIpcArgs = z.object({
|
||||
setupId: z.string().min(1),
|
||||
updates: z.object({
|
||||
displayName: z.string().optional(),
|
||||
path: z.string().optional(),
|
||||
worktreeBasePath: z.string().optional(),
|
||||
setupState: z.enum(['ready', 'not-set-up', 'setting-up', 'error', 'unsupported']).optional(),
|
||||
setupMethod: z
|
||||
.enum(['legacy-repo', 'imported-existing-folder', 'cloned', 'provisioned'])
|
||||
.optional(),
|
||||
gitUsername: z.string().optional(),
|
||||
kind: z.enum(['git', 'folder']).optional()
|
||||
})
|
||||
})
|
||||
|
||||
const ProjectHostSetupDeleteIpcArgs = z.object({
|
||||
setupId: z.string().min(1)
|
||||
})
|
||||
|
||||
const FolderWorkspaceLinkedTaskArgs = z
|
||||
.object({
|
||||
provider: z.enum(['github', 'gitlab', 'linear', 'jira']),
|
||||
@@ -1095,7 +836,6 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
ipcMain.removeHandler('repos:pickDirectory')
|
||||
ipcMain.removeHandler('repos:clone')
|
||||
ipcMain.removeHandler('repos:cloneAbort')
|
||||
ipcMain.removeHandler('repos:cloneRemote')
|
||||
ipcMain.removeHandler('repos:isGitAvailable')
|
||||
ipcMain.removeHandler('repos:getDefaultCreateProjectParent')
|
||||
ipcMain.removeHandler('repos:getGitUsername')
|
||||
@@ -1113,105 +853,6 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
return store.getRepos()
|
||||
})
|
||||
|
||||
ipcMain.handle('projects:list', () => store.getProjects())
|
||||
|
||||
ipcMain.handle('projectHostSetups:list', () => store.getProjectHostSetups())
|
||||
|
||||
ipcMain.handle(
|
||||
'projectHostSetups:create',
|
||||
(_event, rawArgs: ProjectHostSetupCreateArgs): ProjectHostSetupCreateResult => {
|
||||
const args = parseProjectGroupIpcArgs(
|
||||
ProjectHostSetupCreateIpcArgs,
|
||||
rawArgs,
|
||||
'project_host_setup_create_invalid_args'
|
||||
)
|
||||
const result = store.createProjectHostSetup(args)
|
||||
if (!result) {
|
||||
throw new Error(`Project not found: ${args.projectId}`)
|
||||
}
|
||||
notifyReposChanged(mainWindow)
|
||||
return result
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'projectHostSetups:update',
|
||||
(_event, rawArgs: ProjectHostSetupUpdateArgs): ProjectHostSetupUpdateResult => {
|
||||
const args = parseProjectGroupIpcArgs(
|
||||
ProjectHostSetupUpdateIpcArgs,
|
||||
rawArgs,
|
||||
'project_host_setup_update_invalid_args'
|
||||
)
|
||||
const result = store.updateProjectHostSetup(args)
|
||||
if (!result) {
|
||||
throw new Error(`Project host setup not found: ${args.setupId}`)
|
||||
}
|
||||
notifyReposChanged(mainWindow)
|
||||
return result
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'projectHostSetups:delete',
|
||||
(_event, rawArgs: ProjectHostSetupDeleteArgs): ProjectHostSetupDeleteResult => {
|
||||
const args = parseProjectGroupIpcArgs(
|
||||
ProjectHostSetupDeleteIpcArgs,
|
||||
rawArgs,
|
||||
'project_host_setup_delete_invalid_args'
|
||||
)
|
||||
const result = store.deleteProjectHostSetup(args)
|
||||
if (!result) {
|
||||
throw new Error(`Project host setup not found: ${args.setupId}`)
|
||||
}
|
||||
notifyReposChanged(mainWindow)
|
||||
return result
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'projectHostSetups:setupExistingFolder',
|
||||
async (
|
||||
_event,
|
||||
rawArgs: ProjectHostSetupExistingFolderArgs
|
||||
): Promise<ProjectHostSetupResult> => {
|
||||
const args = parseProjectGroupIpcArgs(
|
||||
ProjectHostSetupExistingFolderIpcArgs,
|
||||
rawArgs,
|
||||
'project_host_setup_invalid_args'
|
||||
)
|
||||
const parsedHost = parseExecutionHostId(args.hostId)
|
||||
if (!parsedHost) {
|
||||
throw new Error(`Unsupported host: ${args.hostId}`)
|
||||
}
|
||||
const existingProject = store.getProjects().find((project) => project.id === args.projectId)
|
||||
if (!existingProject) {
|
||||
throw new Error(`Project not found: ${args.projectId}`)
|
||||
}
|
||||
|
||||
const result =
|
||||
parsedHost.kind === 'local'
|
||||
? await addLocalRepoFromPath(store, args.path, args.kind)
|
||||
: parsedHost.kind === 'ssh'
|
||||
? await addRemoteRepoFromPath(store, {
|
||||
connectionId: parsedHost.targetId,
|
||||
remotePath: args.path,
|
||||
displayName: args.displayName,
|
||||
kind: args.kind
|
||||
})
|
||||
: {
|
||||
error:
|
||||
'Runtime hosts must be set up through the runtime projectHostSetup.setupExistingFolder RPC.'
|
||||
}
|
||||
if ('error' in result) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
invalidateAuthorizedRootsCache()
|
||||
notifyReposChanged(mainWindow)
|
||||
emitRepoAdded('folder_picker', result.alreadyExisted)
|
||||
return alignRepoWithRequestedProject(store, result.repo, args.projectId, args.setupMethod)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('repos:isGitAvailable', () => isGitAvailable())
|
||||
ipcMain.handle('repos:getDefaultCreateProjectParent', () => getDefaultCreateProjectParent())
|
||||
|
||||
@@ -1537,10 +1178,38 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
if ('error' in result) {
|
||||
return result
|
||||
}
|
||||
|
||||
// Check if already added
|
||||
const existing = store.getRepos().find((r) => r.path === args.path)
|
||||
if (existing) {
|
||||
emitRepoAdded('folder_picker', true, repoKind === 'git')
|
||||
return { repo: existing }
|
||||
}
|
||||
|
||||
const detected = await detectRepoIconAndUpstream({ repoPath: args.path, kind: repoKind })
|
||||
const repo: Repo = {
|
||||
id: randomUUID(),
|
||||
path: args.path,
|
||||
displayName: getRepoName(args.path),
|
||||
badgeColor: DEFAULT_REPO_BADGE_COLOR,
|
||||
...detected,
|
||||
addedAt: Date.now(),
|
||||
kind: repoKind,
|
||||
...(repoKind === 'git'
|
||||
? {
|
||||
externalWorktreeVisibility: 'hide' as const,
|
||||
externalWorktreeVisibilityLegacy: false
|
||||
}
|
||||
: {})
|
||||
}
|
||||
|
||||
store.addRepo(repo)
|
||||
invalidateAuthorizedRootsCache()
|
||||
notifyReposChanged(mainWindow)
|
||||
emitRepoAdded('folder_picker', result.alreadyExisted, result.repo.kind === 'git')
|
||||
return { repo: result.repo }
|
||||
// Why: `repos:add` validates git-ness via `isGitRepo(args.path)` above
|
||||
// when kind is 'git', and `repoKind` reflects that resolved choice.
|
||||
emitRepoAdded('folder_picker', false, repoKind === 'git')
|
||||
return { repo }
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1559,6 +1228,113 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
if ('error' in result) {
|
||||
return result
|
||||
}
|
||||
|
||||
const findExistingRemoteRepo = (path: string): Repo | undefined =>
|
||||
store
|
||||
.getRepos()
|
||||
.find(
|
||||
(repo) =>
|
||||
repo.connectionId === args.connectionId &&
|
||||
normalizeRuntimePathForComparison(repo.path) ===
|
||||
normalizeRuntimePathForComparison(path)
|
||||
)
|
||||
|
||||
let repoKind: 'git' | 'folder' = args.kind ?? 'git'
|
||||
let resolvedPath = args.remotePath
|
||||
|
||||
// Why: `~` is a shell expansion that Node's fs APIs don't understand.
|
||||
// Resolve tilde paths to absolute paths via the relay before storing,
|
||||
// so all downstream fs operations (readDir, stat, etc.) work correctly.
|
||||
if (resolvedPath === '~' || resolvedPath === '~/' || resolvedPath.startsWith('~/')) {
|
||||
const mux = getActiveMultiplexer(args.connectionId)
|
||||
if (mux) {
|
||||
try {
|
||||
const result = (await mux.request('session.resolveHome', {
|
||||
path: resolvedPath
|
||||
})) as { resolvedPath: string }
|
||||
resolvedPath = result.resolvedPath
|
||||
} catch {
|
||||
// Relay may not support resolveHome yet — fall through to raw path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: check for duplicates after tilde resolution so that adding `~/`
|
||||
// when `/home/ubuntu` is already stored correctly detects the duplicate.
|
||||
const existing = findExistingRemoteRepo(resolvedPath)
|
||||
if (existing) {
|
||||
// Why: duplicate hit is suppressed by `emitRepoAdded` anyway, and for
|
||||
// remote adds git-ness isn't resolved until the isGitRepoAsync check
|
||||
// below — pass `undefined` rather than guess.
|
||||
emitRepoAdded('folder_picker', true, undefined)
|
||||
return { repo: existing }
|
||||
}
|
||||
|
||||
if (args.kind !== 'folder') {
|
||||
// Why: when kind is not explicitly 'folder', verify the remote path is
|
||||
// a git repo. Return an error on failure so the renderer can show the "Open as
|
||||
// Folder" confirmation dialog — matching the local add-repo behavior
|
||||
// where non-git directories require explicit user consent.
|
||||
try {
|
||||
const check = await gitProvider.isGitRepoAsync(resolvedPath)
|
||||
if (check.isRepo) {
|
||||
repoKind = 'git'
|
||||
if (check.rootPath) {
|
||||
resolvedPath = check.rootPath
|
||||
}
|
||||
const existingAfterRootResolution = findExistingRemoteRepo(resolvedPath)
|
||||
if (existingAfterRootResolution) {
|
||||
// Why: users may browse inside a repo; store identity is the Git root,
|
||||
// but different SSH targets can legitimately share that same path.
|
||||
emitRepoAdded('folder_picker', true, true)
|
||||
return { repo: existingAfterRootResolution }
|
||||
}
|
||||
} else {
|
||||
return { error: `Not a valid git repository: ${args.remotePath}` }
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.includes('Not a valid git repository')) {
|
||||
return { error: err.message }
|
||||
}
|
||||
return { error: `Not a valid git repository: ${args.remotePath}` }
|
||||
}
|
||||
}
|
||||
|
||||
const folderName = getRemoteRepoFolderName(resolvedPath)
|
||||
|
||||
// When folderName is the home directory basename (e.g. 'ubuntu'),
|
||||
// use SSH target label for a more descriptive name
|
||||
let displayName = args.displayName || folderName
|
||||
if (!args.displayName && (args.remotePath === '~' || args.remotePath === '~/')) {
|
||||
const sshTarget = store.getSshTarget(args.connectionId)
|
||||
if (sshTarget) {
|
||||
displayName = sshTarget.label
|
||||
}
|
||||
}
|
||||
|
||||
const detected = await detectRepoIconAndUpstream({
|
||||
repoPath: resolvedPath,
|
||||
kind: repoKind,
|
||||
connectionId: args.connectionId
|
||||
})
|
||||
const repo: Repo = {
|
||||
id: randomUUID(),
|
||||
path: resolvedPath,
|
||||
displayName,
|
||||
badgeColor: DEFAULT_REPO_BADGE_COLOR,
|
||||
...detected,
|
||||
addedAt: Date.now(),
|
||||
kind: repoKind,
|
||||
connectionId: args.connectionId,
|
||||
...(repoKind === 'git'
|
||||
? {
|
||||
externalWorktreeVisibility: 'hide' as const,
|
||||
externalWorktreeVisibilityLegacy: false
|
||||
}
|
||||
: {})
|
||||
}
|
||||
|
||||
store.addRepo(repo)
|
||||
notifyReposChanged(mainWindow)
|
||||
emitRepoAdded('folder_picker', result.alreadyExisted, result.repo.kind === 'git')
|
||||
return { repo: result.repo }
|
||||
@@ -1575,13 +1351,12 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
name: string
|
||||
kind: 'git' | 'folder'
|
||||
}
|
||||
): Promise<{ repo: Repo } | { error: string }> => {
|
||||
const result = await createRemoteRepo(store, args)
|
||||
if ('error' in result) {
|
||||
return result
|
||||
}
|
||||
notifyReposChanged(mainWindow)
|
||||
return result
|
||||
|
||||
// Why: `repoKind` here reflects the SSH/remote-aware isGitRepoAsync
|
||||
// result resolved above (or an explicit 'folder' kind), so it's the real
|
||||
// git-vs-folder signal for this remote add.
|
||||
emitRepoAdded('folder_picker', false, repoKind === 'git')
|
||||
return { repo }
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -160,6 +160,14 @@ describe('telemetry IPC handlers', () => {
|
||||
registerWith({ installId: 'x', existedBeforeTelemetryRelease: false, optedIn: true })
|
||||
const handler = handlers.get('telemetry:track')!
|
||||
handler({}, 'app_starred_orca', { source: 'settings' })
|
||||
handler({}, 'star_nag_outcome', {
|
||||
outcome: 'shown',
|
||||
source: 'threshold',
|
||||
mode: 'gh',
|
||||
threshold: 35,
|
||||
agents_since_baseline: 35,
|
||||
agents_since_baseline_bucket: '35-69'
|
||||
})
|
||||
handler({}, 'feature_interaction_usage_bucket_reached', {
|
||||
feature_id: 'tasks',
|
||||
feature_category: 'task_management',
|
||||
|
||||
@@ -55,6 +55,7 @@ let storeRef: Store | null = null
|
||||
|
||||
const MAIN_OWNED_TELEMETRY_EVENTS = new Set<EventName>([
|
||||
'app_starred_orca',
|
||||
'star_nag_outcome',
|
||||
'feature_interaction_usage_bucket_reached'
|
||||
])
|
||||
|
||||
|
||||
@@ -337,9 +337,6 @@ describe('mergeWorktree', () => {
|
||||
linkedLinearIssue: null,
|
||||
linkedLinearIssueWorkspaceId: null,
|
||||
linkedLinearIssueOrganizationUrlKey: null,
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'ssh:openclaw-2',
|
||||
projectHostSetupId: 'remote-repo',
|
||||
linkedGitLabMR: null,
|
||||
linkedGitLabIssue: null,
|
||||
isArchived: true,
|
||||
|
||||
@@ -60,6 +60,7 @@ type LinearWorkspaceFile = {
|
||||
export type LinearClientForWorkspace = {
|
||||
workspace: LinearWorkspace
|
||||
client: LinearClient
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
let cachedTokens = new Map<string, string>()
|
||||
@@ -543,7 +544,7 @@ export function getClients(
|
||||
if (!token) {
|
||||
continue
|
||||
}
|
||||
clients.push({ workspace, client: new LinearClient({ apiKey: token }) })
|
||||
clients.push({ workspace, client: new LinearClient({ apiKey: token }), apiKey: token })
|
||||
}
|
||||
return clients
|
||||
}
|
||||
|
||||
@@ -58,5 +58,22 @@ export function classifyLinearError(error: unknown): LinearErrorCode {
|
||||
}
|
||||
|
||||
export function linearMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return sanitizeLinearErrorMessage(message)
|
||||
}
|
||||
|
||||
export function sanitizeLinearErrorMessage(message: string): string {
|
||||
// Why: provider text is useful in CLI errors, but raw SDK failures can embed secrets or user payloads.
|
||||
return message
|
||||
.split(/\r?\n\s+at\s+/)[0]
|
||||
.replace(
|
||||
/(headers?\s*[:=]\s*)\{[^{}]*(?:authorization|token|api[-_]?key)[^{}]*\}/gi,
|
||||
'$1[REDACTED]'
|
||||
)
|
||||
.replace(/(authorization\s*[:=]\s*bearer\s+)[^\s]+/gi, '$1[REDACTED]')
|
||||
.replace(/((?:api[-_]?key|token)\s*[:=]\s*)[^\s,}\]]+/gi, '$1[REDACTED]')
|
||||
.replace(/(variables\s*[:=]\s*)\{[\s\S]*?\}/gi, '$1[REDACTED]')
|
||||
.replace(/((?:body|comment|description)\s*[:=]\s*)\{[\s\S]*?\}/gi, '$1[REDACTED]')
|
||||
.replace(/((?:body|comment|description)\s*[:=]\s*)(["']).*?\2/gi, '$1[REDACTED]')
|
||||
.trim()
|
||||
}
|
||||
|
||||
@@ -332,4 +332,288 @@ describe('Linear issue queries', () => {
|
||||
|
||||
expect(updateIssue).toHaveBeenCalledWith('issue-1', { estimate: 5 })
|
||||
})
|
||||
|
||||
it('reads back agent state updates before confirming success', async () => {
|
||||
const updateIssue = vi.fn().mockResolvedValue({ success: true })
|
||||
rawRequest.mockResolvedValueOnce({
|
||||
data: {
|
||||
issue: {
|
||||
id: 'issue-1',
|
||||
identifier: 'ENG-1',
|
||||
title: 'Fix thing',
|
||||
description: 'Description',
|
||||
url: 'https://linear.app/ENG-1',
|
||||
team: { id: 'team-1', key: 'ENG', name: 'Engineering' },
|
||||
state: { id: 'state-review', name: 'In Review' },
|
||||
parent: null
|
||||
}
|
||||
}
|
||||
})
|
||||
getClients.mockReturnValue([
|
||||
{
|
||||
...makeEntry(),
|
||||
client: { updateIssue, client: { rawRequest } }
|
||||
}
|
||||
])
|
||||
const { updateIssueForAgent } = await import('./issues')
|
||||
|
||||
await expect(
|
||||
updateIssueForAgent('issue-1', { stateId: 'state-review' }, 'workspace-1')
|
||||
).resolves.toMatchObject({ state: { id: 'state-review' } })
|
||||
|
||||
expect(updateIssue).toHaveBeenCalledWith('issue-1', { stateId: 'state-review' })
|
||||
expect(rawRequest.mock.calls[0][0]).toContain('query OrcaLinearIssueByUuid')
|
||||
})
|
||||
|
||||
it('treats post-state-update readback misses as unconfirmed', async () => {
|
||||
const updateIssue = vi.fn().mockResolvedValue({ success: true })
|
||||
rawRequest.mockResolvedValueOnce({ data: { issue: null } })
|
||||
getClients.mockReturnValue([
|
||||
{
|
||||
...makeEntry(),
|
||||
client: { updateIssue, client: { rawRequest } }
|
||||
}
|
||||
])
|
||||
const { updateIssueForAgent } = await import('./issues')
|
||||
|
||||
await expect(
|
||||
updateIssueForAgent('issue-1', { stateId: 'state-review' }, 'workspace-1')
|
||||
).rejects.toMatchObject({ kind: 'unconfirmed' })
|
||||
})
|
||||
|
||||
it('treats direct write-id lookup misses as null', async () => {
|
||||
rawRequest
|
||||
.mockRejectedValueOnce(
|
||||
new Error('Entity not found: Issue - Could not find referenced Issue.')
|
||||
)
|
||||
.mockRejectedValueOnce(
|
||||
new Error('Entity not found: Comment - Could not find referenced Comment.')
|
||||
)
|
||||
.mockRejectedValueOnce(
|
||||
new Error('Entity not found: Attachment - Could not find referenced Attachment.')
|
||||
)
|
||||
getClients.mockReturnValue([{ ...makeEntry(), client: { client: { rawRequest } } }])
|
||||
const { getIssueByUuidForAgent, getCommentByUuidForAgent, getAttachmentByUuidForAgent } =
|
||||
await import('./issues')
|
||||
|
||||
await expect(getIssueByUuidForAgent('missing-issue', 'workspace-1')).resolves.toBeNull()
|
||||
await expect(getCommentByUuidForAgent('missing-comment', 'workspace-1')).resolves.toBeNull()
|
||||
await expect(
|
||||
getAttachmentByUuidForAgent('missing-attachment', 'workspace-1')
|
||||
).resolves.toBeNull()
|
||||
expect(clearToken).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends threaded agent comments with a client supplied id', async () => {
|
||||
const createComment = vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
comment: Promise.resolve({ id: 'comment-1', url: 'https://linear.app/comment-1' })
|
||||
})
|
||||
getClients.mockReturnValue([
|
||||
{
|
||||
...makeEntry(),
|
||||
client: { createComment }
|
||||
}
|
||||
])
|
||||
const { addIssueComment } = await import('./issues')
|
||||
|
||||
await expect(
|
||||
addIssueComment('issue-1', 'hello', 'workspace-1', {
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
parentId: 'parent-comment'
|
||||
})
|
||||
).resolves.toMatchObject({ ok: true, id: 'comment-1', parentId: 'parent-comment' })
|
||||
|
||||
expect(createComment).toHaveBeenCalledWith({
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
issueId: 'issue-1',
|
||||
body: 'hello',
|
||||
parentId: 'parent-comment'
|
||||
})
|
||||
})
|
||||
|
||||
it('creates agent attachments with a client supplied id', async () => {
|
||||
const createAttachment = vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
attachment: Promise.resolve({ id: 'attachment-1' })
|
||||
})
|
||||
rawRequest.mockResolvedValueOnce({
|
||||
data: {
|
||||
attachment: {
|
||||
id: 'attachment-1',
|
||||
title: 'PR link',
|
||||
url: 'https://example.com/review/1',
|
||||
issue: { id: 'issue-1', identifier: 'ENG-1', url: 'https://linear.app/ENG-1' }
|
||||
}
|
||||
}
|
||||
})
|
||||
getClients.mockReturnValue([
|
||||
{
|
||||
...makeEntry(),
|
||||
client: { createAttachment, client: { rawRequest } }
|
||||
}
|
||||
])
|
||||
const { createIssueAttachment } = await import('./issues')
|
||||
|
||||
await expect(
|
||||
createIssueAttachment(
|
||||
'issue-1',
|
||||
{
|
||||
id: '22222222-2222-4222-8222-222222222222',
|
||||
title: 'PR link',
|
||||
url: 'https://example.com/review/1'
|
||||
},
|
||||
'workspace-1'
|
||||
)
|
||||
).resolves.toMatchObject({ id: 'attachment-1', issue: { identifier: 'ENG-1' } })
|
||||
|
||||
expect(createAttachment).toHaveBeenCalledWith({
|
||||
id: '22222222-2222-4222-8222-222222222222',
|
||||
issueId: 'issue-1',
|
||||
title: 'PR link',
|
||||
url: 'https://example.com/review/1'
|
||||
})
|
||||
})
|
||||
|
||||
it('treats post-mutation attachment readback misses as unconfirmed', async () => {
|
||||
const createAttachment = vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
attachment: Promise.resolve({ id: 'attachment-1' })
|
||||
})
|
||||
rawRequest.mockResolvedValueOnce({ data: { attachment: null } })
|
||||
getClients.mockReturnValue([
|
||||
{
|
||||
...makeEntry(),
|
||||
client: { createAttachment, client: { rawRequest } }
|
||||
}
|
||||
])
|
||||
const { createIssueAttachment } = await import('./issues')
|
||||
|
||||
await expect(
|
||||
createIssueAttachment(
|
||||
'issue-1',
|
||||
{
|
||||
id: '22222222-2222-4222-8222-222222222222',
|
||||
title: 'PR link',
|
||||
url: 'https://example.com/review/1'
|
||||
},
|
||||
'workspace-1'
|
||||
)
|
||||
).rejects.toMatchObject({ kind: 'unconfirmed' })
|
||||
})
|
||||
|
||||
it('creates parented agent issues with a client supplied id', async () => {
|
||||
const createIssue = vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
issue: Promise.resolve({ id: 'issue-created' })
|
||||
})
|
||||
rawRequest.mockResolvedValueOnce({
|
||||
data: {
|
||||
issue: {
|
||||
id: 'issue-created',
|
||||
identifier: 'ENG-2',
|
||||
title: 'Follow up',
|
||||
url: 'https://linear.app/ENG-2',
|
||||
team: { id: 'team-1', key: 'ENG', name: 'Engineering' },
|
||||
state: { id: 'state-1', name: 'Todo' },
|
||||
parent: { id: 'issue-parent', identifier: 'ENG-1' }
|
||||
}
|
||||
}
|
||||
})
|
||||
getClients.mockReturnValue([
|
||||
{
|
||||
...makeEntry(),
|
||||
client: { createIssue, client: { rawRequest } }
|
||||
}
|
||||
])
|
||||
const { createIssueForAgent } = await import('./issues')
|
||||
|
||||
await expect(
|
||||
createIssueForAgent('team-1', 'Follow up', 'Details', 'workspace-1', {
|
||||
id: '33333333-3333-4333-8333-333333333333',
|
||||
parentId: 'issue-parent'
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
id: 'issue-created',
|
||||
parent: { id: 'issue-parent' },
|
||||
team: { key: 'ENG' }
|
||||
})
|
||||
|
||||
expect(createIssue).toHaveBeenCalledWith({
|
||||
id: '33333333-3333-4333-8333-333333333333',
|
||||
teamId: 'team-1',
|
||||
title: 'Follow up',
|
||||
description: 'Details',
|
||||
parentId: 'issue-parent'
|
||||
})
|
||||
expect(rawRequest.mock.calls.at(-1)?.[0]).toContain('description')
|
||||
})
|
||||
|
||||
it('treats post-create readback misses as unconfirmed', async () => {
|
||||
const createIssue = vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
issue: Promise.resolve({ id: 'issue-created' })
|
||||
})
|
||||
rawRequest.mockResolvedValueOnce({ data: { issue: null } })
|
||||
getClients.mockReturnValue([
|
||||
{
|
||||
...makeEntry(),
|
||||
client: { createIssue, client: { rawRequest } }
|
||||
}
|
||||
])
|
||||
const { createIssueForAgent } = await import('./issues')
|
||||
|
||||
await expect(
|
||||
createIssueForAgent('team-1', 'Follow up', 'Details', 'workspace-1', {
|
||||
id: '33333333-3333-4333-8333-333333333333'
|
||||
})
|
||||
).rejects.toMatchObject({ kind: 'unconfirmed' })
|
||||
})
|
||||
|
||||
it('treats post-create readback auth-like errors as unconfirmed', async () => {
|
||||
const authError = new Error('Auth expired during confirmation')
|
||||
isAuthError.mockImplementation((error) => error === authError)
|
||||
const createIssue = vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
issue: Promise.resolve({ id: 'issue-created' })
|
||||
})
|
||||
rawRequest.mockRejectedValueOnce(authError)
|
||||
getClients.mockReturnValue([
|
||||
{
|
||||
...makeEntry(),
|
||||
client: { createIssue, client: { rawRequest } }
|
||||
}
|
||||
])
|
||||
const { createIssueForAgent } = await import('./issues')
|
||||
|
||||
await expect(
|
||||
createIssueForAgent('team-1', 'Follow up', 'Details', 'workspace-1', {
|
||||
id: '33333333-3333-4333-8333-333333333333'
|
||||
})
|
||||
).rejects.toMatchObject({ kind: 'unconfirmed' })
|
||||
expect(clearToken).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves a reply target to its thread root without reading capped issue comments', async () => {
|
||||
rawRequest.mockResolvedValueOnce({
|
||||
data: {
|
||||
comment: {
|
||||
id: 'reply-1',
|
||||
url: 'https://linear.app/comment/reply-1',
|
||||
body: 'Nested reply',
|
||||
parent: { id: 'root-1' },
|
||||
issue: { id: 'issue-1', identifier: 'ENG-1', url: 'https://linear.app/ENG-1' }
|
||||
}
|
||||
}
|
||||
})
|
||||
const { getIssueCommentThreadRoot } = await import('./issues')
|
||||
|
||||
await expect(getIssueCommentThreadRoot('issue-1', 'reply-1', 'workspace-1')).resolves.toEqual({
|
||||
id: 'root-1',
|
||||
parentId: 'root-1'
|
||||
})
|
||||
|
||||
expect(rawRequest.mock.calls[0][0]).toContain('query OrcaLinearCommentByUuid')
|
||||
expect(rawRequest.mock.calls[0][0]).toContain('body')
|
||||
})
|
||||
})
|
||||
|
||||
+541
-4
@@ -8,6 +8,7 @@ import type {
|
||||
LinearCollectionResult,
|
||||
LinearWorkspaceSelection
|
||||
} from '../../shared/types'
|
||||
import { LinearClient } from '@linear/sdk'
|
||||
import {
|
||||
LINEAR_ISSUE_API_PAGE_SIZE_MAX,
|
||||
clampLinearIssueListLimit
|
||||
@@ -78,6 +79,47 @@ type LinearIssueConnectionLoader = (
|
||||
page: LinearIssuePageRequest
|
||||
) => Promise<LinearIssueConnection | null | undefined>
|
||||
|
||||
export type LinearWriteFailureKind = 'duplicate_id' | 'failed' | 'network' | 'unconfirmed'
|
||||
|
||||
export class LinearWriteFailure extends Error {
|
||||
readonly kind: LinearWriteFailureKind
|
||||
readonly cause: unknown
|
||||
|
||||
constructor(kind: LinearWriteFailureKind, message: string, cause?: unknown) {
|
||||
super(message)
|
||||
this.name = 'LinearWriteFailure'
|
||||
this.kind = kind
|
||||
this.cause = cause
|
||||
}
|
||||
}
|
||||
|
||||
export type LinearIssueWriteRecord = {
|
||||
id: string
|
||||
identifier: string
|
||||
title: string
|
||||
description?: string | null
|
||||
url: string
|
||||
team: { id: string; key: string; name: string }
|
||||
state: { id: string; name: string } | null
|
||||
parent: { id: string; identifier: string } | null
|
||||
}
|
||||
|
||||
export type LinearCommentWriteRecord = {
|
||||
id: string
|
||||
url: string | null
|
||||
body: string
|
||||
issue: { id: string; identifier: string; url: string }
|
||||
parentId: string | null
|
||||
threadRootId: string | null
|
||||
}
|
||||
|
||||
export type LinearAttachmentWriteRecord = {
|
||||
id: string
|
||||
title: string
|
||||
url: string
|
||||
issue: { id: string; identifier: string; url: string }
|
||||
}
|
||||
|
||||
const LINEAR_ISSUE_NODE_FIELDS = `
|
||||
id
|
||||
identifier
|
||||
@@ -182,6 +224,71 @@ const VIEWER_CREATED_ISSUES_QUERY = `
|
||||
}
|
||||
`
|
||||
|
||||
const AGENT_ISSUE_WRITE_FIELDS = `
|
||||
id
|
||||
identifier
|
||||
title
|
||||
description
|
||||
url
|
||||
team { id key name }
|
||||
state { id name }
|
||||
parent { id identifier }
|
||||
`
|
||||
|
||||
const ISSUE_BY_UUID_QUERY = `
|
||||
query OrcaLinearIssueByUuid($id: String!) {
|
||||
issue(id: $id) {
|
||||
${AGENT_ISSUE_WRITE_FIELDS}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const COMMENT_BY_UUID_QUERY = `
|
||||
query OrcaLinearCommentByUuid($id: String!) {
|
||||
comment(id: $id) {
|
||||
id
|
||||
url
|
||||
body
|
||||
parent { id }
|
||||
issue { id identifier url }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const ATTACHMENT_BY_UUID_QUERY = `
|
||||
query OrcaLinearAttachmentByUuid($id: String!) {
|
||||
attachment(id: $id) {
|
||||
id
|
||||
title
|
||||
url
|
||||
issue { id identifier url }
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
type LinearIssueByUuidResponse = {
|
||||
issue?: LinearIssueWriteRecord | null
|
||||
}
|
||||
|
||||
type LinearCommentByUuidResponse = {
|
||||
comment?: {
|
||||
id: string
|
||||
url?: string | null
|
||||
body?: string | null
|
||||
parent?: { id?: string | null } | null
|
||||
issue?: { id?: string | null; identifier?: string | null; url?: string | null } | null
|
||||
} | null
|
||||
}
|
||||
|
||||
type LinearAttachmentByUuidResponse = {
|
||||
attachment?: {
|
||||
id: string
|
||||
title?: string | null
|
||||
url?: string | null
|
||||
issue?: { id?: string | null; identifier?: string | null; url?: string | null } | null
|
||||
} | null
|
||||
}
|
||||
|
||||
async function mapIssueForWorkspace(
|
||||
entry: LinearClientForWorkspace,
|
||||
issue: Parameters<typeof mapLinearIssue>[0],
|
||||
@@ -349,6 +456,164 @@ function shouldThrowAuthError(selection: LinearWorkspaceSelection | null | undef
|
||||
return selection !== 'all'
|
||||
}
|
||||
|
||||
function linearWriteMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function isDuplicateIdError(error: unknown): boolean {
|
||||
const message = linearWriteMessage(error).toLowerCase()
|
||||
return (
|
||||
message.includes('duplicate') ||
|
||||
message.includes('already exists') ||
|
||||
message.includes('already in use') ||
|
||||
message.includes('id has already')
|
||||
)
|
||||
}
|
||||
|
||||
function errorCauseCode(error: unknown): string {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return ''
|
||||
}
|
||||
const cause = (error as { cause?: unknown }).cause
|
||||
if (!cause || typeof cause !== 'object') {
|
||||
return ''
|
||||
}
|
||||
const code = (cause as { code?: unknown }).code
|
||||
return typeof code === 'string' ? code.toLowerCase() : ''
|
||||
}
|
||||
|
||||
function classifyWriteFailure(error: unknown): LinearWriteFailure {
|
||||
if (error instanceof LinearWriteFailure) {
|
||||
return error
|
||||
}
|
||||
if (isDuplicateIdError(error)) {
|
||||
return new LinearWriteFailure('duplicate_id', linearWriteMessage(error), error)
|
||||
}
|
||||
const message = linearWriteMessage(error)
|
||||
const lower = message.toLowerCase()
|
||||
const code = errorCauseCode(error)
|
||||
if (
|
||||
lower.includes('enotfound') ||
|
||||
lower.includes('econnrefused') ||
|
||||
code === 'enotfound' ||
|
||||
code === 'econnrefused'
|
||||
) {
|
||||
return new LinearWriteFailure('network', message, error)
|
||||
}
|
||||
if (
|
||||
lower.includes('abort') ||
|
||||
lower.includes('timeout') ||
|
||||
lower.includes('timed out') ||
|
||||
lower.includes('network') ||
|
||||
lower.includes('econnreset') ||
|
||||
lower.includes('fetch failed') ||
|
||||
lower.includes('socket')
|
||||
) {
|
||||
return new LinearWriteFailure('unconfirmed', message, error)
|
||||
}
|
||||
return new LinearWriteFailure('failed', message, error)
|
||||
}
|
||||
|
||||
async function runLinearWrite<T>(
|
||||
entry: LinearClientForWorkspace,
|
||||
signal: AbortSignal | undefined,
|
||||
write: (client: LinearClient) => Promise<T>
|
||||
): Promise<T> {
|
||||
await acquire()
|
||||
try {
|
||||
const client = signal ? new LinearClient({ apiKey: entry.apiKey, signal }) : entry.client
|
||||
return await write(client)
|
||||
} catch (error) {
|
||||
if (error instanceof LinearWriteFailure) {
|
||||
throw error
|
||||
}
|
||||
if (isAuthError(error)) {
|
||||
clearToken(entry.workspace.id)
|
||||
throw error
|
||||
}
|
||||
throw classifyWriteFailure(error)
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
async function runLinearLookup<T>(
|
||||
entry: LinearClientForWorkspace,
|
||||
lookup: () => Promise<T>
|
||||
): Promise<T | null> {
|
||||
await acquire()
|
||||
try {
|
||||
return await lookup()
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
clearToken(entry.workspace.id)
|
||||
throw error
|
||||
}
|
||||
if (isLinearLookupMiss(error)) {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
function isLinearLookupMiss(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
// Why: Linear throws for direct entity lookups that miss; write-id probes
|
||||
// need the same null shape as GraphQL nullable data, not a failed write.
|
||||
return message.includes('Entity not found:') && message.includes('Could not find referenced')
|
||||
}
|
||||
|
||||
async function confirmLinearWrite<T>(message: string, readback: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await readback()
|
||||
} catch (error) {
|
||||
throw new LinearWriteFailure('unconfirmed', message, error)
|
||||
}
|
||||
}
|
||||
|
||||
function mapRawCommentWriteRecord(
|
||||
comment: NonNullable<LinearCommentByUuidResponse['comment']>
|
||||
): LinearCommentWriteRecord | null {
|
||||
const issue = comment.issue
|
||||
if (!issue?.id || !issue.identifier || !issue.url) {
|
||||
return null
|
||||
}
|
||||
const parentId = comment.parent?.id ?? null
|
||||
return {
|
||||
id: comment.id,
|
||||
url: comment.url ?? null,
|
||||
body: comment.body ?? '',
|
||||
issue: {
|
||||
id: issue.id,
|
||||
identifier: issue.identifier,
|
||||
url: issue.url
|
||||
},
|
||||
parentId,
|
||||
threadRootId: parentId ?? comment.id
|
||||
}
|
||||
}
|
||||
|
||||
function mapRawAttachmentWriteRecord(
|
||||
attachment: NonNullable<LinearAttachmentByUuidResponse['attachment']>
|
||||
): LinearAttachmentWriteRecord | null {
|
||||
const issue = attachment.issue
|
||||
if (!issue?.id || !issue.identifier || !issue.url || !attachment.url) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
id: attachment.id,
|
||||
title: attachment.title ?? attachment.url,
|
||||
url: attachment.url,
|
||||
issue: {
|
||||
id: issue.id,
|
||||
identifier: issue.identifier,
|
||||
url: issue.url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getIssue(
|
||||
id: string,
|
||||
workspaceId?: LinearWorkspaceSelection | null
|
||||
@@ -382,6 +647,74 @@ export async function getIssue(
|
||||
return null
|
||||
}
|
||||
|
||||
export async function getIssueByUuidForAgent(
|
||||
id: string,
|
||||
workspaceId?: string | null
|
||||
): Promise<LinearIssueWriteRecord | null> {
|
||||
const entry = getClients(workspaceId)[0]
|
||||
if (!entry) {
|
||||
return null
|
||||
}
|
||||
|
||||
return runLinearLookup(entry, async () => {
|
||||
const result = await entry.client.client.rawRequest<
|
||||
LinearIssueByUuidResponse,
|
||||
LinearRawVariables
|
||||
>(ISSUE_BY_UUID_QUERY, { id })
|
||||
return result.data?.issue ?? null
|
||||
})
|
||||
}
|
||||
|
||||
export async function getCommentByUuidForAgent(
|
||||
id: string,
|
||||
workspaceId?: string | null
|
||||
): Promise<LinearCommentWriteRecord | null> {
|
||||
const entry = getClients(workspaceId)[0]
|
||||
if (!entry) {
|
||||
return null
|
||||
}
|
||||
|
||||
return runLinearLookup(entry, async () => {
|
||||
const result = await entry.client.client.rawRequest<
|
||||
LinearCommentByUuidResponse,
|
||||
LinearRawVariables
|
||||
>(COMMENT_BY_UUID_QUERY, { id })
|
||||
const comment = result.data?.comment
|
||||
return comment ? mapRawCommentWriteRecord(comment) : null
|
||||
})
|
||||
}
|
||||
|
||||
export async function getAttachmentByUuidForAgent(
|
||||
id: string,
|
||||
workspaceId?: string | null
|
||||
): Promise<LinearAttachmentWriteRecord | null> {
|
||||
const entry = getClients(workspaceId)[0]
|
||||
if (!entry) {
|
||||
return null
|
||||
}
|
||||
|
||||
return runLinearLookup(entry, async () => {
|
||||
const result = await entry.client.client.rawRequest<
|
||||
LinearAttachmentByUuidResponse,
|
||||
LinearRawVariables
|
||||
>(ATTACHMENT_BY_UUID_QUERY, { id })
|
||||
const attachment = result.data?.attachment
|
||||
return attachment ? mapRawAttachmentWriteRecord(attachment) : null
|
||||
})
|
||||
}
|
||||
|
||||
export async function getIssueCommentThreadRoot(
|
||||
issueId: string,
|
||||
commentId: string,
|
||||
workspaceId?: string | null
|
||||
): Promise<{ id: string; parentId: string | null } | null> {
|
||||
const comment = await getCommentByUuidForAgent(commentId, workspaceId)
|
||||
if (!comment || comment.issue.id !== issueId) {
|
||||
return null
|
||||
}
|
||||
return { id: comment.threadRootId ?? comment.id, parentId: comment.parentId }
|
||||
}
|
||||
|
||||
export async function searchIssues(
|
||||
query: string,
|
||||
limit = 20,
|
||||
@@ -622,6 +955,7 @@ export async function createIssue(
|
||||
description?: string,
|
||||
workspaceId?: string | null,
|
||||
options?: {
|
||||
id?: string
|
||||
parentId?: string
|
||||
projectId?: string | null
|
||||
stateId?: string
|
||||
@@ -641,6 +975,7 @@ export async function createIssue(
|
||||
await acquire()
|
||||
try {
|
||||
const result = await entry.client.createIssue({
|
||||
...(options?.id ? { id: options.id } : {}),
|
||||
teamId,
|
||||
title,
|
||||
...(description ? { description } : {}),
|
||||
@@ -677,6 +1012,61 @@ export async function createIssue(
|
||||
}
|
||||
}
|
||||
|
||||
export async function createIssueForAgent(
|
||||
teamId: string,
|
||||
title: string,
|
||||
description: string | undefined,
|
||||
workspaceId: string,
|
||||
options: {
|
||||
id: string
|
||||
parentId?: string | null
|
||||
signal?: AbortSignal
|
||||
}
|
||||
): Promise<LinearIssueWriteRecord> {
|
||||
const entry = getClients(workspaceId)[0]
|
||||
if (!entry) {
|
||||
throw new LinearWriteFailure('failed', 'Not connected to Linear')
|
||||
}
|
||||
|
||||
return runLinearWrite(entry, options.signal, async (client) => {
|
||||
const result = await client.createIssue({
|
||||
id: options.id,
|
||||
teamId,
|
||||
title,
|
||||
...(description ? { description } : {}),
|
||||
...(options.parentId ? { parentId: options.parentId } : {})
|
||||
})
|
||||
if (!result.success) {
|
||||
throw new LinearWriteFailure('failed', 'Linear create failed')
|
||||
}
|
||||
const issue = await confirmLinearWrite(
|
||||
'Issue was created but could not be retrieved',
|
||||
async () => result.issue
|
||||
)
|
||||
if (!issue?.id) {
|
||||
throw new LinearWriteFailure('unconfirmed', 'Issue was created but could not be retrieved')
|
||||
}
|
||||
return confirmLinearWrite('Issue was created but could not be retrieved', () =>
|
||||
getCreatedIssueRecord(issue.id, client)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function getCreatedIssueRecord(
|
||||
issueId: string,
|
||||
client: LinearClient
|
||||
): Promise<LinearIssueWriteRecord> {
|
||||
const result = await client.client.rawRequest<LinearIssueByUuidResponse, LinearRawVariables>(
|
||||
ISSUE_BY_UUID_QUERY,
|
||||
{ id: issueId }
|
||||
)
|
||||
const record = result.data?.issue ?? null
|
||||
if (!record) {
|
||||
throw new LinearWriteFailure('unconfirmed', 'Issue was created but could not be retrieved')
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
export async function updateIssue(
|
||||
id: string,
|
||||
updates: LinearIssueUpdate,
|
||||
@@ -738,11 +1128,41 @@ export async function updateIssue(
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateIssueForAgent(
|
||||
id: string,
|
||||
updates: Pick<LinearIssueUpdate, 'stateId'>,
|
||||
workspaceId: string,
|
||||
options: { signal?: AbortSignal } = {}
|
||||
): Promise<LinearIssueWriteRecord> {
|
||||
const entry = getClients(workspaceId)[0]
|
||||
if (!entry) {
|
||||
throw new LinearWriteFailure('failed', 'Not connected to Linear')
|
||||
}
|
||||
|
||||
return runLinearWrite(entry, options.signal, async (client) => {
|
||||
const payload: Record<string, unknown> = {}
|
||||
if (updates.stateId !== undefined) {
|
||||
payload.stateId = updates.stateId
|
||||
}
|
||||
const result = await client.updateIssue(id, payload)
|
||||
if (!result.success) {
|
||||
throw new LinearWriteFailure('failed', 'Linear update failed')
|
||||
}
|
||||
return confirmLinearWrite('Issue was updated but could not be retrieved', () =>
|
||||
getCreatedIssueRecord(id, client)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export async function addIssueComment(
|
||||
issueId: string,
|
||||
body: string,
|
||||
workspaceId?: string | null
|
||||
): Promise<{ ok: true; id: string } | { ok: false; error: string }> {
|
||||
workspaceId?: string | null,
|
||||
options?: { id?: string; parentId?: string | null }
|
||||
): Promise<
|
||||
| { ok: true; id: string; url?: string | null; parentId?: string | null }
|
||||
| { ok: false; error: string }
|
||||
> {
|
||||
const entry = getClients(workspaceId)[0]
|
||||
if (!entry) {
|
||||
return { ok: false, error: 'Not connected to Linear' }
|
||||
@@ -750,12 +1170,22 @@ export async function addIssueComment(
|
||||
|
||||
await acquire()
|
||||
try {
|
||||
const result = await entry.client.createComment({ issueId, body })
|
||||
const result = await entry.client.createComment({
|
||||
...(options?.id ? { id: options.id } : {}),
|
||||
issueId,
|
||||
body,
|
||||
...(options?.parentId ? { parentId: options.parentId } : {})
|
||||
})
|
||||
if (!result.success) {
|
||||
return { ok: false, error: 'Failed to create comment' }
|
||||
}
|
||||
const comment = await result.comment
|
||||
return { ok: true, id: comment?.id ?? '' }
|
||||
return {
|
||||
ok: true,
|
||||
id: comment?.id ?? '',
|
||||
url: comment?.url ?? null,
|
||||
parentId: options?.parentId ?? null
|
||||
}
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
clearToken(entry.workspace.id)
|
||||
@@ -768,6 +1198,113 @@ export async function addIssueComment(
|
||||
}
|
||||
}
|
||||
|
||||
export async function addIssueCommentForAgent(
|
||||
issueId: string,
|
||||
body: string,
|
||||
workspaceId: string,
|
||||
options: { id: string; parentId?: string | null; signal?: AbortSignal }
|
||||
): Promise<LinearCommentWriteRecord> {
|
||||
const entry = getClients(workspaceId)[0]
|
||||
if (!entry) {
|
||||
throw new LinearWriteFailure('failed', 'Not connected to Linear')
|
||||
}
|
||||
|
||||
return runLinearWrite(entry, options.signal, async (client) => {
|
||||
const result = await client.createComment({
|
||||
id: options.id,
|
||||
issueId,
|
||||
body,
|
||||
...(options.parentId ? { parentId: options.parentId } : {})
|
||||
})
|
||||
if (!result.success) {
|
||||
throw new LinearWriteFailure('failed', 'Failed to create comment')
|
||||
}
|
||||
const comment = await confirmLinearWrite(
|
||||
'Comment was created but could not be retrieved',
|
||||
async () => result.comment
|
||||
)
|
||||
if (!comment?.id) {
|
||||
throw new LinearWriteFailure('unconfirmed', 'Comment was created but could not be retrieved')
|
||||
}
|
||||
const record = await confirmLinearWrite('Comment was created but could not be retrieved', () =>
|
||||
readCommentWriteRecord(client, comment.id)
|
||||
)
|
||||
if (!record) {
|
||||
throw new LinearWriteFailure('unconfirmed', 'Comment was created but could not be retrieved')
|
||||
}
|
||||
return record
|
||||
})
|
||||
}
|
||||
|
||||
export async function createIssueAttachment(
|
||||
issueId: string,
|
||||
input: { id: string; title: string; url: string },
|
||||
workspaceId: string,
|
||||
options: { signal?: AbortSignal } = {}
|
||||
): Promise<LinearAttachmentWriteRecord> {
|
||||
const entry = getClients(workspaceId)[0]
|
||||
if (!entry) {
|
||||
throw new LinearWriteFailure('failed', 'Not connected to Linear')
|
||||
}
|
||||
|
||||
return runLinearWrite(entry, options.signal, async (client) => {
|
||||
const result = await client.createAttachment({
|
||||
id: input.id,
|
||||
issueId,
|
||||
title: input.title,
|
||||
url: input.url
|
||||
})
|
||||
if (!result.success) {
|
||||
throw new LinearWriteFailure('failed', 'Failed to create attachment')
|
||||
}
|
||||
const attachment = await confirmLinearWrite(
|
||||
'Attachment was created but could not be retrieved',
|
||||
async () => result.attachment
|
||||
)
|
||||
if (!attachment?.id) {
|
||||
throw new LinearWriteFailure(
|
||||
'unconfirmed',
|
||||
'Attachment was created but could not be retrieved'
|
||||
)
|
||||
}
|
||||
const record = await confirmLinearWrite(
|
||||
'Attachment was created but could not be retrieved',
|
||||
() => readAttachmentWriteRecord(client, attachment.id)
|
||||
)
|
||||
if (!record) {
|
||||
throw new LinearWriteFailure(
|
||||
'unconfirmed',
|
||||
'Attachment was created but could not be retrieved'
|
||||
)
|
||||
}
|
||||
return record
|
||||
})
|
||||
}
|
||||
|
||||
async function readCommentWriteRecord(
|
||||
client: LinearClient,
|
||||
id: string
|
||||
): Promise<LinearCommentWriteRecord | null> {
|
||||
const result = await client.client.rawRequest<LinearCommentByUuidResponse, LinearRawVariables>(
|
||||
COMMENT_BY_UUID_QUERY,
|
||||
{ id }
|
||||
)
|
||||
const comment = result.data?.comment
|
||||
return comment ? mapRawCommentWriteRecord(comment) : null
|
||||
}
|
||||
|
||||
async function readAttachmentWriteRecord(
|
||||
client: LinearClient,
|
||||
id: string
|
||||
): Promise<LinearAttachmentWriteRecord | null> {
|
||||
const result = await client.client.rawRequest<LinearAttachmentByUuidResponse, LinearRawVariables>(
|
||||
ATTACHMENT_BY_UUID_QUERY,
|
||||
{ id }
|
||||
)
|
||||
const attachment = result.data?.attachment
|
||||
return attachment ? mapRawAttachmentWriteRecord(attachment) : null
|
||||
}
|
||||
|
||||
export async function getIssueComments(
|
||||
issueId: string,
|
||||
workspaceId?: string | null
|
||||
|
||||
@@ -67,6 +67,32 @@ export async function listTeams(
|
||||
return results.flat().sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
export async function listTeamsOrThrow(
|
||||
workspaceId?: LinearWorkspaceSelection | null
|
||||
): Promise<LinearTeam[]> {
|
||||
const entries = getClients(workspaceId)
|
||||
if (entries.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
await acquire()
|
||||
try {
|
||||
return await fetchAllTeamsForWorkspace(entry)
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
clearToken(entry.workspace.id)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
})
|
||||
)
|
||||
return results.flat().sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
export async function getTeamStates(
|
||||
teamId: string,
|
||||
workspaceId?: string | null
|
||||
@@ -101,6 +127,38 @@ export async function getTeamStates(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTeamStatesOrThrow(
|
||||
teamId: string,
|
||||
workspaceId?: string | null
|
||||
): Promise<LinearWorkflowState[]> {
|
||||
const entry = getClients(workspaceId)[0]
|
||||
if (!entry) {
|
||||
return []
|
||||
}
|
||||
|
||||
await acquire()
|
||||
try {
|
||||
const team = await entry.client.team(teamId)
|
||||
const states = await team.states()
|
||||
return states.nodes
|
||||
.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
type: s.type,
|
||||
color: s.color,
|
||||
position: s.position
|
||||
}))
|
||||
.sort((a, b) => a.position - b.position)
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
clearToken(entry.workspace.id)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTeamLabels(
|
||||
teamId: string,
|
||||
workspaceId?: string | null
|
||||
|
||||
@@ -314,87 +314,6 @@ describe('Store', () => {
|
||||
expect(store.getRepos()).toEqual([])
|
||||
}, 15_000)
|
||||
|
||||
it('backfills project host setup compatibility records from legacy repos on load', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [
|
||||
makeRepo({
|
||||
id: 'local-repo',
|
||||
path: '/Users/alice/orca',
|
||||
displayName: 'Orca',
|
||||
upstream: { owner: 'StablyAI', repo: 'Orca' }
|
||||
}),
|
||||
makeRepo({
|
||||
id: 'remote-repo',
|
||||
path: '/home/alice/orca',
|
||||
displayName: 'orca',
|
||||
connectionId: 'gpu-vm',
|
||||
upstream: { owner: 'stablyai', repo: 'orca' }
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
|
||||
expect(store.getProjects()).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'github:stablyai/orca',
|
||||
sourceRepoIds: ['local-repo', 'remote-repo']
|
||||
})
|
||||
])
|
||||
expect(store.getProjectHostSetups()).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'local-repo',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'local',
|
||||
path: '/Users/alice/orca'
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'remote-repo',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'ssh:gpu-vm',
|
||||
path: '/home/alice/orca'
|
||||
})
|
||||
])
|
||||
|
||||
store.flush()
|
||||
const persisted = readDataFile() as PersistedState
|
||||
expect(persisted.projects).toEqual(store.getProjects())
|
||||
expect(persisted.projectHostSetups).toEqual(store.getProjectHostSetups())
|
||||
})
|
||||
|
||||
it('preserves independent project host setup records on load', async () => {
|
||||
const independentProject = makeProject({
|
||||
id: 'cloud-project',
|
||||
displayName: 'Cloud Project'
|
||||
})
|
||||
const independentSetup = makeProjectHostSetup({
|
||||
id: 'cloud-project::gpu-vm',
|
||||
projectId: independentProject.id,
|
||||
hostId: 'runtime:gpu-vm',
|
||||
repoId: '',
|
||||
path: '/srv/cloud-project',
|
||||
displayName: 'GPU VM'
|
||||
})
|
||||
writeDataFile({
|
||||
...getDefaultPersistedState(testState.dir),
|
||||
repos: [makeRepo({ id: 'r1', path: '/repo', displayName: 'Repo' })],
|
||||
projects: [independentProject],
|
||||
projectHostSetups: [independentSetup]
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
|
||||
expect(store.getProjects().map((project) => project.id)).toEqual(['repo:r1', 'cloud-project'])
|
||||
expect(store.getProjectHostSetups().map((setup) => setup.id)).toEqual([
|
||||
'r1',
|
||||
'cloud-project::gpu-vm'
|
||||
])
|
||||
store.flush()
|
||||
const persisted = readDataFile() as PersistedState
|
||||
expect(persisted.projectHostSetups).toContainEqual(independentSetup)
|
||||
})
|
||||
|
||||
it('returns default settings when no data file exists', async () => {
|
||||
const store = await createStore()
|
||||
const settings = store.getSettings()
|
||||
@@ -779,6 +698,26 @@ describe('Store', () => {
|
||||
expect(store.getUI().projectOrderBy).toBe('manual')
|
||||
})
|
||||
|
||||
it('shows the manual-default notice for upgraded profiles without projectOrderBy', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [{ id: 'repo-1', path: '/tmp/repo', displayName: 'Repo', badgeColor: '#000000' }],
|
||||
ui: {}
|
||||
})
|
||||
const store = await createStore()
|
||||
expect(store.getUI().projectOrderManualDefaultNoticeDismissed).toBe(false)
|
||||
})
|
||||
|
||||
it('hides the manual-default notice for profiles that already chose recent', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [{ id: 'repo-1', path: '/tmp/repo', displayName: 'Repo', badgeColor: '#000000' }],
|
||||
ui: { projectOrderBy: 'recent' }
|
||||
})
|
||||
const store = await createStore()
|
||||
expect(store.getUI().projectOrderManualDefaultNoticeDismissed).toBe(true)
|
||||
})
|
||||
|
||||
// ── 2. Load from existing valid file ─────────────────────────────────
|
||||
|
||||
it('reads repos from an existing data file', async () => {
|
||||
|
||||
+73
-3
@@ -114,6 +114,11 @@ import { normalizeOpenInApplications } from '../shared/open-in-applications'
|
||||
import { normalizeTerminalShortcutPolicy } from '../shared/keybindings'
|
||||
import { normalizeAppIconId } from '../shared/app-icon'
|
||||
import { normalizeTerminalCustomThemes } from '../shared/terminal-custom-themes'
|
||||
import {
|
||||
normalizeLeftSidebarAppearanceMode,
|
||||
normalizeLeftSidebarTintColor,
|
||||
normalizeLeftSidebarTintOpacity
|
||||
} from '../shared/left-sidebar-appearance'
|
||||
import {
|
||||
compareFeatureInteractionUsageBuckets,
|
||||
getFeatureInteractionCategory,
|
||||
@@ -479,6 +484,11 @@ function normalizeProjectOrderBy(projectOrderBy: unknown): PersistedState['ui'][
|
||||
return getDefaultUIState().projectOrderBy
|
||||
}
|
||||
|
||||
import {
|
||||
isExistingPersistedProfile,
|
||||
resolveProjectOrderManualDefaultNoticeDismissed
|
||||
} from '../shared/project-order-manual-default-notice'
|
||||
|
||||
function normalizeRightSidebarTab(tab: unknown): PersistedState['ui']['rightSidebarTab'] {
|
||||
if (
|
||||
tab === 'explorer' ||
|
||||
@@ -2361,6 +2371,14 @@ export class Store {
|
||||
parsed.settings?.disabledTuiAgents
|
||||
)
|
||||
const migratedAgentYoloDefaults = migrateAgentYoloDefaults(parsed.settings)
|
||||
const openLinksInAppWasPersisted = Object.prototype.hasOwnProperty.call(
|
||||
parsed.settings ?? {},
|
||||
'openLinksInApp'
|
||||
)
|
||||
const migratedOpenLinksInAppPreferencePrompted =
|
||||
typeof parsed.settings?.openLinksInAppPreferencePrompted === 'boolean'
|
||||
? parsed.settings.openLinksInAppPreferencePrompted
|
||||
: openLinksInAppWasPersisted
|
||||
if (
|
||||
parsed.settings?.agentYoloDefaultsMigrated !== true ||
|
||||
hasUnsupportedTuiAgentArgs('opencode', parsed.settings?.agentDefaultArgs?.opencode) ||
|
||||
@@ -2377,6 +2395,12 @@ export class Store {
|
||||
if (!autoRenameBranchFromWorkDefaultedOn) {
|
||||
this.loadNeedsSave = true
|
||||
}
|
||||
if (
|
||||
parsed.settings?.openLinksInAppPreferencePrompted !==
|
||||
migratedOpenLinksInAppPreferencePrompted
|
||||
) {
|
||||
this.loadNeedsSave = true
|
||||
}
|
||||
const normalizedOnboarding = normalizeLoadedOnboardingState(
|
||||
parsed.onboarding,
|
||||
defaults.onboarding
|
||||
@@ -2440,6 +2464,15 @@ export class Store {
|
||||
terminalCustomThemes: normalizeTerminalCustomThemes(
|
||||
parsed.settings?.terminalCustomThemes
|
||||
),
|
||||
leftSidebarAppearanceMode: normalizeLeftSidebarAppearanceMode(
|
||||
parsed.settings?.leftSidebarAppearanceMode
|
||||
),
|
||||
leftSidebarTintColor: normalizeLeftSidebarTintColor(
|
||||
parsed.settings?.leftSidebarTintColor
|
||||
),
|
||||
leftSidebarTintOpacity: normalizeLeftSidebarTintOpacity(
|
||||
parsed.settings?.leftSidebarTintOpacity
|
||||
),
|
||||
appIcon: normalizeAppIconId(parsed.settings?.appIcon),
|
||||
uiLanguage: normalizeUiLanguage(parsed.settings?.uiLanguage),
|
||||
defaultTaskSource: taskProviderSettings.defaultTaskSource,
|
||||
@@ -2454,6 +2487,7 @@ export class Store {
|
||||
openInApplications: normalizeOpenInApplications(parsed.settings?.openInApplications, {
|
||||
seedDefaults: true
|
||||
}),
|
||||
openLinksInAppPreferencePrompted: migratedOpenLinksInAppPreferencePrompted,
|
||||
notifications: normalizeNotificationSettings(parsed.settings?.notifications),
|
||||
sourceControlAi: migratedSourceControlAi,
|
||||
// Why: new builds read sourceControlAi, but rollback builds still
|
||||
@@ -2594,12 +2628,28 @@ export class Store {
|
||||
parsed.ui?.setupGuideSidebarDismissed,
|
||||
normalizedOnboarding
|
||||
)
|
||||
const projectOrderManualDefaultNoticeDismissed =
|
||||
resolveProjectOrderManualDefaultNoticeDismissed({
|
||||
rawDismissed: parsed.ui?.projectOrderManualDefaultNoticeDismissed,
|
||||
rawProjectOrderBy: parsed.ui?.projectOrderBy,
|
||||
isExistingProfile: isExistingPersistedProfile({
|
||||
repoCount: parsed.repos?.length ?? 0,
|
||||
onboardingClosedAt: normalizedOnboarding.closedAt,
|
||||
ui: parsed.ui
|
||||
})
|
||||
})
|
||||
if (
|
||||
parsed.ui?.setupGuideSidebarDismissed !== setupGuideSidebarDismissed &&
|
||||
(setupGuideSidebarDismissed || parsed.ui?.setupGuideSidebarDismissed !== undefined)
|
||||
) {
|
||||
this.loadNeedsSave = true
|
||||
}
|
||||
if (
|
||||
parsed.ui?.projectOrderManualDefaultNoticeDismissed !==
|
||||
projectOrderManualDefaultNoticeDismissed
|
||||
) {
|
||||
this.loadNeedsSave = true
|
||||
}
|
||||
return {
|
||||
...defaults.ui,
|
||||
...stripMainOwnedTelemetryMarkerFromUI(parsed.ui),
|
||||
@@ -2608,6 +2658,7 @@ export class Store {
|
||||
rightSidebarOpen,
|
||||
rightSidebarTab: normalizeRightSidebarTab(parsed.ui?.rightSidebarTab),
|
||||
setupGuideSidebarDismissed,
|
||||
projectOrderManualDefaultNoticeDismissed,
|
||||
setupGuideBrowserMilestoneMigrated:
|
||||
typeof parsed.ui?.setupGuideBrowserMilestoneMigrated === 'boolean'
|
||||
? parsed.ui.setupGuideBrowserMilestoneMigrated
|
||||
@@ -2716,11 +2767,15 @@ export class Store {
|
||||
this.loadNeedsSave = true
|
||||
}
|
||||
|
||||
const repos = clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? [])
|
||||
const projectHostSetupCompatibility = mergeProjectHostSetupCompatibilityState(result, repos)
|
||||
if (!projectHostSetupCompatibilityStateEqual(result, projectHostSetupCompatibility)) {
|
||||
const folderScopeConnectionMigration = backfillFolderScopeConnectionIds({
|
||||
...result,
|
||||
repos: clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? []),
|
||||
workspaceSession: migratedScrollback.session
|
||||
})
|
||||
if (folderScopeConnectionMigration.changed) {
|
||||
this.loadNeedsSave = true
|
||||
}
|
||||
result = folderScopeConnectionMigration.state
|
||||
|
||||
const folderScopeConnectionMigration = backfillFolderScopeConnectionIds({
|
||||
...result,
|
||||
@@ -3955,6 +4010,21 @@ export class Store {
|
||||
updates.terminalCustomThemes
|
||||
)
|
||||
}
|
||||
if ('leftSidebarAppearanceMode' in updates) {
|
||||
sanitizedUpdates.leftSidebarAppearanceMode = normalizeLeftSidebarAppearanceMode(
|
||||
updates.leftSidebarAppearanceMode
|
||||
)
|
||||
}
|
||||
if ('leftSidebarTintColor' in updates) {
|
||||
sanitizedUpdates.leftSidebarTintColor = normalizeLeftSidebarTintColor(
|
||||
updates.leftSidebarTintColor
|
||||
)
|
||||
}
|
||||
if ('leftSidebarTintOpacity' in updates) {
|
||||
sanitizedUpdates.leftSidebarTintOpacity = normalizeLeftSidebarTintOpacity(
|
||||
updates.leftSidebarTintOpacity
|
||||
)
|
||||
}
|
||||
if ('visibleTaskProviders' in updates || 'defaultTaskSource' in updates) {
|
||||
const taskProviderSettings = normalizeTaskProviderSettings({
|
||||
visibleTaskProviders:
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { execFile } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
type ProcessRow = {
|
||||
pid: number
|
||||
ppid: number
|
||||
stat: string
|
||||
command: string
|
||||
}
|
||||
|
||||
function parsePsRows(stdout: string): ProcessRow[] {
|
||||
const rows: ProcessRow[] = []
|
||||
for (const line of stdout.split('\n')) {
|
||||
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/)
|
||||
if (!match) {
|
||||
continue
|
||||
}
|
||||
rows.push({
|
||||
pid: Number(match[1]),
|
||||
ppid: Number(match[2]),
|
||||
stat: match[3],
|
||||
command: match[4]
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
function collectDescendants(
|
||||
rows: ProcessRow[],
|
||||
rootPid: number
|
||||
): (ProcessRow & { depth: number })[] {
|
||||
const childrenByParent = new Map<number, ProcessRow[]>()
|
||||
for (const row of rows) {
|
||||
const children = childrenByParent.get(row.ppid) ?? []
|
||||
children.push(row)
|
||||
childrenByParent.set(row.ppid, children)
|
||||
}
|
||||
|
||||
const descendants: (ProcessRow & { depth: number })[] = []
|
||||
const stack = (childrenByParent.get(rootPid) ?? []).map((row) => ({ row, depth: 1 }))
|
||||
while (stack.length > 0) {
|
||||
const { row, depth } = stack.pop()!
|
||||
descendants.push({ ...row, depth })
|
||||
for (const child of childrenByParent.get(row.pid) ?? []) {
|
||||
stack.push({ row: child, depth: depth + 1 })
|
||||
}
|
||||
}
|
||||
return descendants
|
||||
}
|
||||
|
||||
function candidateScore(row: ProcessRow & { depth: number }): number {
|
||||
// Why: foreground descendants carry `+` in `ps stat` on Unix PTYs. Prefer
|
||||
// them, then prefer leaf/deeper wrappers so `node /path/bin/codex` beats the
|
||||
// parent shell but still lets the native child confirm the same identity.
|
||||
return (row.stat.includes('+') ? 10_000 : 0) + row.depth
|
||||
}
|
||||
|
||||
export async function resolveAgentForegroundProcess(
|
||||
shellPid: number | null | undefined,
|
||||
fallbackProcess: string | null
|
||||
): Promise<string | null> {
|
||||
if (process.platform === 'win32' || !shellPid) {
|
||||
return fallbackProcess
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execFileAsync('ps', ['-axo', 'pid=,ppid=,stat=,command='], {
|
||||
encoding: 'utf8',
|
||||
timeout: 3000
|
||||
})
|
||||
return resolveAgentForegroundProcessFromPs(stdout, shellPid) ?? fallbackProcess
|
||||
} catch {
|
||||
// Fall through to node-pty's process name. Foreground process inspection is
|
||||
// best-effort because terminal identity should never break PTY operation.
|
||||
}
|
||||
|
||||
return fallbackProcess
|
||||
}
|
||||
|
||||
function resolveAgentForegroundProcessFromPs(stdout: string, shellPid: number): string | null {
|
||||
const candidates = collectDescendants(parsePsRows(stdout), shellPid).sort(
|
||||
(a, b) => candidateScore(b) - candidateScore(a)
|
||||
)
|
||||
for (const candidate of candidates) {
|
||||
const recognized = recognizeAgentProcessFromCommandLine(candidate.command)
|
||||
if (recognized) {
|
||||
return recognized.processName
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -345,10 +345,11 @@ describe('LocalPtyProvider', () => {
|
||||
'-d',
|
||||
'Debian',
|
||||
'--',
|
||||
'bash',
|
||||
'sh',
|
||||
'-c',
|
||||
'cd \'/mnt/c/Users/jin/repo\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l'
|
||||
expect.stringContaining("cd '/mnt/c/Users/jin/repo'")
|
||||
])
|
||||
expect(spawnCall[1][5]).toContain('exec "\\$_orca_wsl_shell" -l')
|
||||
})
|
||||
|
||||
it('marks Orca terminal handle for WSL import when buildSpawnEnv opts in', async () => {
|
||||
@@ -517,14 +518,7 @@ describe('LocalPtyProvider', () => {
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
'wsl.exe',
|
||||
[
|
||||
'-d',
|
||||
'Ubuntu',
|
||||
'--',
|
||||
'bash',
|
||||
'-c',
|
||||
'cd \'/home/jin/repo/subdir\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l'
|
||||
],
|
||||
['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringContaining("cd '/home/jin/repo/subdir'")],
|
||||
expect.objectContaining({ cwd: expect.any(String) })
|
||||
)
|
||||
})
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
resolveWindowsGitBashShellPath
|
||||
} from '../git-bash'
|
||||
import { WINDOWS_GIT_BASH_SHELL } from '../../shared/windows-terminal-shell'
|
||||
import { resolveAgentForegroundProcess } from './agent-foreground-process'
|
||||
|
||||
const PANE_IDENTITY_ENV_KEYS = ['ORCA_PANE_KEY', 'ORCA_TAB_ID', 'ORCA_WORKTREE_ID'] as const
|
||||
|
||||
@@ -710,7 +711,7 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return proc.process || null
|
||||
return await resolveAgentForegroundProcess(proc.pid, proc.process || null)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import { JsonRpcErrorCode } from '../ssh/relay-protocol'
|
||||
import type { CommitMessageDraftContext } from '../../shared/commit-message-generation'
|
||||
import type { CommitMessagePlan } from '../../shared/commit-message-plan'
|
||||
import type { RemoteCommitMessageExecResult } from '../text-generation/commit-message-text-generation'
|
||||
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import {
|
||||
describeMaxBufferOverflowError,
|
||||
isMaxBufferOverflowError
|
||||
|
||||
@@ -3,8 +3,18 @@ import {
|
||||
encodePowerShellCommand,
|
||||
getPowerShellOsc133Bootstrap
|
||||
} from '../powershell-osc133-bootstrap'
|
||||
import {
|
||||
buildWslInteractiveLoginShellCommand,
|
||||
escapeWslShCommandForWindows
|
||||
} from '../../shared/wsl-login-shell-command'
|
||||
import { resolveWindowsShellLaunchArgs } from './windows-shell-args'
|
||||
|
||||
function expectedWslArgs(linuxCwd: string, distro?: string): string[] {
|
||||
const command = `cd '${linuxCwd}' && export PATH="$HOME/.local/bin:$PATH" && ${buildWslInteractiveLoginShellCommand()}`
|
||||
const shellArgs = ['--', 'sh', '-c', escapeWslShCommandForWindows(command)]
|
||||
return distro ? ['-d', distro, ...shellArgs] : shellArgs
|
||||
}
|
||||
|
||||
describe('resolveWindowsShellLaunchArgs', () => {
|
||||
it('returns cmd.exe args with chcp 65001 for UTF-8 output', () => {
|
||||
const result = resolveWindowsShellLaunchArgs('cmd.exe', 'C:\\Users\\alice', 'C:\\Users\\alice')
|
||||
@@ -95,12 +105,7 @@ describe('resolveWindowsShellLaunchArgs', () => {
|
||||
'C:\\Users\\alice\\code',
|
||||
'C:\\Users\\alice'
|
||||
)
|
||||
expect(result.shellArgs).toEqual([
|
||||
'--',
|
||||
'bash',
|
||||
'-c',
|
||||
'cd \'/mnt/c/Users/alice/code\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l'
|
||||
])
|
||||
expect(result.shellArgs).toEqual(expectedWslArgs('/mnt/c/Users/alice/code'))
|
||||
// Why: WSL cannot cd into a Windows path, so node-pty must start from the
|
||||
// user's Windows home and we inject the Linux cd into the shellArgs above.
|
||||
expect(result.effectiveCwd).toBe('C:\\Users\\alice')
|
||||
@@ -109,17 +114,16 @@ describe('resolveWindowsShellLaunchArgs', () => {
|
||||
|
||||
it('escapes single quotes when translating a WSL cwd', () => {
|
||||
const result = resolveWindowsShellLaunchArgs('wsl.exe', "C:\\weird'path", 'C:\\Users\\alice')
|
||||
// The injected bash cmd must not break out of the surrounding single
|
||||
// quotes when the path contains a ' character.
|
||||
expect(result.shellArgs[3]).toBe(
|
||||
"cd '/mnt/c/weird'\\''path' && export PATH=\"$HOME/.local/bin:$PATH\" && exec bash -l"
|
||||
)
|
||||
// The injected sh cmd must not break out of the surrounding single quotes
|
||||
// when the path contains a ' character.
|
||||
expect(result.shellArgs[3]).toContain("cd '/mnt/c/weird'\\''path'")
|
||||
expect(result.shellArgs[3]).toContain('exec "\\$_orca_wsl_shell" -l')
|
||||
})
|
||||
|
||||
it('falls back to /mnt/c when cwd is not a drive-letter path', () => {
|
||||
const result = resolveWindowsShellLaunchArgs('wsl.exe', '\\\\server\\share', 'C:\\Users\\alice')
|
||||
expect(result.shellArgs[3]).toBe(
|
||||
'cd \'/mnt/c\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l'
|
||||
expect(result.shellArgs[3]).toContain(
|
||||
'cd \'/mnt/c\' && export PATH="\\$HOME/.local/bin:\\$PATH"'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -136,14 +140,7 @@ describe('resolveWindowsShellLaunchArgs', () => {
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo',
|
||||
'C:\\Users\\alice'
|
||||
)
|
||||
expect(result.shellArgs).toEqual([
|
||||
'-d',
|
||||
'Ubuntu',
|
||||
'--',
|
||||
'bash',
|
||||
'-c',
|
||||
'cd \'/home/alice/repo\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l'
|
||||
])
|
||||
expect(result.shellArgs).toEqual(expectedWslArgs('/home/alice/repo', 'Ubuntu'))
|
||||
expect(result.effectiveCwd).toBe('C:\\Users\\alice')
|
||||
expect(result.validationCwd).toBe('\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo')
|
||||
} finally {
|
||||
@@ -162,14 +159,7 @@ describe('resolveWindowsShellLaunchArgs', () => {
|
||||
{ distro: 'Ubuntu', treatPosixCwdAsWsl: true }
|
||||
)
|
||||
|
||||
expect(result.shellArgs).toEqual([
|
||||
'-d',
|
||||
'Ubuntu',
|
||||
'--',
|
||||
'bash',
|
||||
'-c',
|
||||
'cd \'/home/alice/repo/subdir\' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l'
|
||||
])
|
||||
expect(result.shellArgs).toEqual(expectedWslArgs('/home/alice/repo/subdir', 'Ubuntu'))
|
||||
expect(result.effectiveCwd).toBe('C:\\Users\\alice')
|
||||
expect(result.validationCwd).toBe('\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo\\subdir')
|
||||
})
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { win32 as pathWin32 } from 'path'
|
||||
import { isWindowsGitBashShellPath } from '../git-bash'
|
||||
import { parseWslPath, toLinuxPath, toWindowsWslPath } from '../wsl'
|
||||
import {
|
||||
buildWslInteractiveLoginShellCommand,
|
||||
escapeWslShCommandForWindows,
|
||||
quotePosixShell
|
||||
} from '../../shared/wsl-login-shell-command'
|
||||
import {
|
||||
encodePowerShellCommand,
|
||||
getPowerShellOsc133Bootstrap
|
||||
@@ -32,15 +37,14 @@ export type WindowsShellWslContext = {
|
||||
}
|
||||
|
||||
function buildWslShellArgs(linuxCwd: string, distro?: string): string[] {
|
||||
const escapedLinuxCwd = linuxCwd.replace(/'/g, "'\\''")
|
||||
// Why: Orca's WSL bridge is installed under ~/.local/bin, but distro login
|
||||
// files do not consistently include that directory before agent commands run.
|
||||
const shellArgs = [
|
||||
'--',
|
||||
'bash',
|
||||
'-c',
|
||||
`cd '${escapedLinuxCwd}' && export PATH="$HOME/.local/bin:$PATH" && exec bash -l`
|
||||
]
|
||||
const setupCommand = [
|
||||
`cd ${quotePosixShell(linuxCwd)}`,
|
||||
'export PATH="$HOME/.local/bin:$PATH"',
|
||||
buildWslInteractiveLoginShellCommand()
|
||||
].join(' && ')
|
||||
// Why: WSL users often customize zsh rather than bash; launch the distro's
|
||||
// login shell so terminal PATH matches the environment Orca detects.
|
||||
const shellArgs = ['--', 'sh', '-c', escapeWslShCommandForWindows(setupCommand)]
|
||||
return distro ? ['-d', distro, ...shellArgs] : shellArgs
|
||||
}
|
||||
|
||||
@@ -50,8 +54,8 @@ function buildWslShellArgs(linuxCwd: string, distro?: string): string[] {
|
||||
* - powershell.exe / pwsh.exe: dot-source $PROFILE and force UTF-8 I/O so
|
||||
* oh-my-posh / starship / PSReadLine keep working. `-NoExit` alone would
|
||||
* skip the profile.
|
||||
* - wsl.exe: translate the Windows cwd to /mnt/<drive>/... and enter a login
|
||||
* bash inside the default distro.
|
||||
* - wsl.exe: translate the Windows cwd to /mnt/<drive>/... and enter the
|
||||
* distro user's login shell.
|
||||
* - anything else: no args, same cwd. */
|
||||
export function resolveWindowsShellLaunchArgs(
|
||||
shellPath: string,
|
||||
|
||||
@@ -9444,6 +9444,151 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(spawn.mock.calls[1]?.[0]).not.toHaveProperty('sessionId')
|
||||
})
|
||||
|
||||
it('keeps the activated headless tab active across PTY republishes (serve focus-jump regression)', async () => {
|
||||
// Why: in `orca serve`, focusTerminal has no renderer to persist the remote
|
||||
// client's tab choice before PTY republishes.
|
||||
let nextPty = 0
|
||||
const spawn = vi.fn().mockImplementation(async () => ({ id: `headless-pty-${++nextPty}` }))
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
|
||||
const FIRST_LEAF = '22222222-2222-4222-8222-222222222222'
|
||||
const SECOND_LEAF = '33333333-3333-4333-8333-333333333333'
|
||||
// The first-created headless terminal is the one the snapshot marks active.
|
||||
await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
tabId: 'tab-first',
|
||||
leafId: FIRST_LEAF
|
||||
})
|
||||
await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
tabId: 'tab-other',
|
||||
leafId: SECOND_LEAF
|
||||
})
|
||||
|
||||
const events: RuntimeMobileSessionTabsResult[] = []
|
||||
runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot))
|
||||
|
||||
// The remote client switches to the other (non-active) tab.
|
||||
await runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'tab-other')
|
||||
|
||||
const afterActivate = events.at(-1)
|
||||
expect(afterActivate?.activeTabId).toBe(`tab-other::${SECOND_LEAF}`)
|
||||
expect(afterActivate?.activeTabType).toBe('terminal')
|
||||
expect(afterActivate?.tabGroups?.[0]?.activeTabId).toBe('tab-other')
|
||||
expect(
|
||||
afterActivate?.tabs.find((tab) => tab.id === `tab-other::${SECOND_LEAF}`)?.isActive
|
||||
).toBe(true)
|
||||
expect(afterActivate?.tabs.find((tab) => tab.id === `tab-first::${FIRST_LEAF}`)?.isActive).toBe(
|
||||
false
|
||||
)
|
||||
|
||||
// PTY title updates republish snapshots, so the client's chosen tab must
|
||||
// survive after activation.
|
||||
events.length = 0
|
||||
runtime.onPtyData('headless-pty-2', '\x1b]0;tab-other running\x07', 200)
|
||||
|
||||
const afterPtyData = events.at(-1)
|
||||
expect(afterPtyData?.activeTabId).toBe(`tab-other::${SECOND_LEAF}`)
|
||||
expect(afterPtyData?.activeTabType).toBe('terminal')
|
||||
expect(afterPtyData?.tabGroups?.[0]?.activeTabId).toBe('tab-other')
|
||||
})
|
||||
|
||||
it('does not bump the snapshot version when re-activating the already-active headless tab', async () => {
|
||||
// Why: redundant activations of the current tab must not force a remote re-render.
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'headless-pty-solo' })
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
|
||||
const LEAF = '44444444-4444-4444-8444-444444444444'
|
||||
await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { tabId: 'tab-solo', leafId: LEAF })
|
||||
|
||||
const events: RuntimeMobileSessionTabsResult[] = []
|
||||
runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot))
|
||||
|
||||
await runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'tab-solo')
|
||||
|
||||
expect(events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not persist active server-side when an authoritative renderer is attached', async () => {
|
||||
// Why: when a renderer window is authoritative it re-syncs the snapshot itself,
|
||||
// so the headless persist must NOT fire — the renderer stays the source of truth.
|
||||
let nextPty = 0
|
||||
const spawn = vi.fn().mockImplementation(async () => ({ id: `attached-pty-${++nextPty}` }))
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
|
||||
const LEAF_A = '55555555-5555-4555-8555-555555555555'
|
||||
const LEAF_B = '66666666-6666-4666-8666-666666666666'
|
||||
await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { tabId: 'tab-a', leafId: LEAF_A })
|
||||
await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { tabId: 'tab-b', leafId: LEAF_B })
|
||||
|
||||
// Make an authoritative renderer window present.
|
||||
runtime.attachWindow(1)
|
||||
runtime.markGraphReady(1)
|
||||
electronMocks.BrowserWindow.fromId.mockReturnValue({
|
||||
isDestroyed: () => false,
|
||||
webContents: { send: vi.fn() }
|
||||
})
|
||||
|
||||
const events: RuntimeMobileSessionTabsResult[] = []
|
||||
runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot))
|
||||
|
||||
await runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'tab-b')
|
||||
|
||||
// The headless persist is gated off by the authoritative window — nothing emitted.
|
||||
expect(events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not persist active server-side for a `:headless-merge:` snapshot after renderer detach', async () => {
|
||||
// Why: after renderer detach, merged snapshots have no authoritative window
|
||||
// but still carry renderer-owned group state.
|
||||
let nextPty = 0
|
||||
const spawn = vi.fn().mockImplementation(async () => ({ id: `merge-pty-${++nextPty}` }))
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
|
||||
const LEAF_A = '77777777-7777-4777-8777-777777777777'
|
||||
const LEAF_B = '88888888-8888-4888-8888-888888888888'
|
||||
// tab-a (first-created) is the snapshot's active tab.
|
||||
await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { tabId: 'tab-a', leafId: LEAF_A })
|
||||
await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { tabId: 'tab-b', leafId: LEAF_B })
|
||||
|
||||
// Simulate a post-detach merged snapshot with no authoritative window.
|
||||
const current = runtime['mobileSessionTabsByWorktree'].get(TEST_WORKTREE_ID)!
|
||||
runtime['mobileSessionTabsByWorktree'].set(TEST_WORKTREE_ID, {
|
||||
...current,
|
||||
publicationEpoch: `renderer:headless-merge:${current.publicationEpoch}`
|
||||
})
|
||||
|
||||
const events: RuntimeMobileSessionTabsResult[] = []
|
||||
runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot))
|
||||
|
||||
// The merge exclusion must suppress server-side active rewrites.
|
||||
await runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'tab-b')
|
||||
|
||||
expect(events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('spawns fresh SSH terminals when hydrated persistence has no relay identity', async () => {
|
||||
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(
|
||||
makeWorkspaceSessionWithHeadlessTerminal({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -153,6 +153,7 @@ const UiUpdate = z
|
||||
groupBy: z.enum(['none', 'workspace-status', 'repo', 'pr-status']).optional(),
|
||||
showWorkspaceLineage: z.boolean().optional(),
|
||||
sortBy: z.enum(['name', 'smart', 'recent', 'repo', 'manual']).optional(),
|
||||
projectOrderBy: z.enum(['manual', 'recent']).optional(),
|
||||
showActiveOnly: z.boolean().optional(),
|
||||
hideSleepingWorkspaces: z.boolean().optional(),
|
||||
showSleepingWorkspaces: z.boolean().optional(),
|
||||
@@ -208,6 +209,7 @@ const UiUpdate = z
|
||||
starNagCompleted: z.boolean().optional(),
|
||||
trustedOrcaHooks: z.record(z.string(), z.unknown()).optional(),
|
||||
setupScriptPromptDismissedRepoIds: StringArray.optional(),
|
||||
projectOrderManualDefaultNoticeDismissed: z.boolean().optional(),
|
||||
usageEmptyStateDismissed: z.boolean().optional(),
|
||||
petVisible: z.boolean().optional(),
|
||||
petId: z.string().optional(),
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { RpcDispatcher } from '../dispatcher'
|
||||
import type { RpcRequest } from '../core'
|
||||
import { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import { LinearWriteFailure } from '../../../linear/issues'
|
||||
import { sanitizeLinearErrorMessage } from '../../../linear/issue-context-errors'
|
||||
import { LINEAR_AGENT_ACCESS_METHODS } from './linear-agent-access'
|
||||
|
||||
function makeRequest(method: string, params?: unknown): RpcRequest {
|
||||
return { id: 'req-1', authToken: 'tok', method, params }
|
||||
}
|
||||
|
||||
describe('Linear agent access RPC methods', () => {
|
||||
it('routes agent write methods to the runtime server', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
linearIssueSetState: vi.fn().mockResolvedValue({ ok: true }),
|
||||
linearIssueAddComment: vi.fn().mockResolvedValue({ ok: true }),
|
||||
linearIssueAttachLink: vi.fn().mockResolvedValue({ ok: true }),
|
||||
linearIssueCreate: vi.fn().mockResolvedValue({ ok: true })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: LINEAR_AGENT_ACCESS_METHODS })
|
||||
|
||||
const setStateResponse = await dispatcher.dispatch(
|
||||
makeRequest('linear.issueSetState', {
|
||||
input: 'ENG-1',
|
||||
to: 'In Review',
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
)
|
||||
const commentResponse = await dispatcher.dispatch(
|
||||
makeRequest('linear.issueAddComment', {
|
||||
input: 'ENG-1',
|
||||
body: 'Done',
|
||||
replyTo: 'comment-1',
|
||||
writeId: '11111111-1111-4111-8111-111111111111',
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
)
|
||||
const attachResponse = await dispatcher.dispatch(
|
||||
makeRequest('linear.issueAttachLink', {
|
||||
input: 'ENG-1',
|
||||
url: 'https://example.com/review/1',
|
||||
title: 'Review',
|
||||
writeId: '22222222-2222-4222-8222-222222222222',
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
)
|
||||
const createResponse = await dispatcher.dispatch(
|
||||
makeRequest('linear.issueCreate', {
|
||||
title: 'Follow up',
|
||||
body: 'Details',
|
||||
teamKey: 'ENG',
|
||||
parentInput: 'ENG-1',
|
||||
writeId: '33333333-3333-4333-8333-333333333333',
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
)
|
||||
|
||||
expect(setStateResponse.ok).toBe(true)
|
||||
expect(commentResponse.ok).toBe(true)
|
||||
expect(attachResponse.ok).toBe(true)
|
||||
expect(createResponse.ok).toBe(true)
|
||||
expect(runtime.linearIssueSetState).toHaveBeenCalledWith({
|
||||
input: 'ENG-1',
|
||||
to: 'In Review',
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
expect(runtime.linearIssueAddComment).toHaveBeenCalledWith({
|
||||
input: 'ENG-1',
|
||||
body: 'Done',
|
||||
replyTo: 'comment-1',
|
||||
writeId: '11111111-1111-4111-8111-111111111111',
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
expect(runtime.linearIssueAttachLink).toHaveBeenCalledWith({
|
||||
input: 'ENG-1',
|
||||
url: 'https://example.com/review/1',
|
||||
title: 'Review',
|
||||
writeId: '22222222-2222-4222-8222-222222222222',
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
expect(runtime.linearIssueCreate).toHaveBeenCalledWith({
|
||||
title: 'Follow up',
|
||||
body: 'Details',
|
||||
teamKey: 'ENG',
|
||||
parentInput: 'ENG-1',
|
||||
writeId: '33333333-3333-4333-8333-333333333333',
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects malformed write ids before the runtime is called', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
linearIssueAddComment: vi.fn()
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: LINEAR_AGENT_ACCESS_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('linear.issueAddComment', {
|
||||
input: 'ENG-1',
|
||||
body: 'Done',
|
||||
writeId: 'not-a-uuid'
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.ok).toBe(false)
|
||||
expect(response.ok === false ? response.error.code : '').toBe('linear_invalid_write_id')
|
||||
expect(runtime.linearIssueAddComment).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects workspace all for direct write RPC calls', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
linearIssueSetState: vi.fn()
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: LINEAR_AGENT_ACCESS_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('linear.issueSetState', {
|
||||
input: 'ENG-1',
|
||||
to: 'In Review',
|
||||
workspaceId: 'all'
|
||||
})
|
||||
)
|
||||
|
||||
expect(response.ok).toBe(false)
|
||||
expect(response.ok === false ? response.error.message : '').toContain(
|
||||
'--workspace all is not valid for Linear writes'
|
||||
)
|
||||
expect(runtime.linearIssueSetState).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
type LinearWriteRunner = {
|
||||
runLinearAgentWrite<T>(
|
||||
write: (signal: AbortSignal) => Promise<T>,
|
||||
unconfirmed: (cause?: string) => Error
|
||||
): Promise<T>
|
||||
}
|
||||
|
||||
type LinearUnconfirmedBuilder = {
|
||||
linearCreateStyleUnconfirmed(
|
||||
verb: 'comment' | 'attach' | 'create',
|
||||
writeId: string,
|
||||
target: unknown,
|
||||
extra?: unknown
|
||||
): Error & { data?: { cause?: string; nextSteps?: string[] } }
|
||||
resolveLinearAgentState(input: string, states: unknown[]): unknown | null
|
||||
notifyLinearLinkedIssueUpdated(workspaceId: string, identifier: string): Promise<void>
|
||||
listResolvedWorktrees(): Promise<unknown[]>
|
||||
}
|
||||
|
||||
type LinearRetryLookupTester = {
|
||||
getMatchingLinearCommentWrite(
|
||||
writeId: string,
|
||||
issueId: string,
|
||||
parentId: string | null,
|
||||
workspaceId: string,
|
||||
required: boolean
|
||||
): Promise<unknown | null>
|
||||
getMatchingLinearAttachmentWrite(
|
||||
writeId: string,
|
||||
issueId: string,
|
||||
workspaceId: string,
|
||||
required: boolean
|
||||
): Promise<unknown | null>
|
||||
getMatchingLinearCreatedIssue(
|
||||
writeId: string,
|
||||
teamId: string,
|
||||
parentId: string | null,
|
||||
workspaceId: string,
|
||||
required: boolean
|
||||
): Promise<unknown | null>
|
||||
refetchLinearCommentAfterDuplicate(
|
||||
writeId: string,
|
||||
issueId: string,
|
||||
parentId: string | null,
|
||||
workspaceId: string,
|
||||
unconfirmed: () => Error
|
||||
): Promise<unknown>
|
||||
readLinearWriteLookup(lookup: () => Promise<unknown>): Promise<unknown>
|
||||
}
|
||||
|
||||
describe('Linear agent write recovery helpers', () => {
|
||||
it('keeps stable write failure codes while preserving the Linear provider message', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const runner = runtime as unknown as LinearWriteRunner
|
||||
|
||||
await expect(
|
||||
runner.runLinearAgentWrite(
|
||||
async () => {
|
||||
throw new LinearWriteFailure(
|
||||
'failed',
|
||||
'Linear rejected the state transition because the issue is archived.'
|
||||
)
|
||||
},
|
||||
() => Object.assign(new Error('should not be used'), { code: 'linear_write_unconfirmed' })
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: 'linear_write_failed',
|
||||
message: 'Linear rejected the state transition because the issue is archived.'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps pinned retry guidance for unconfirmed writes while adding sanitized cause text', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const runner = runtime as unknown as LinearWriteRunner
|
||||
const builder = runtime as unknown as LinearUnconfirmedBuilder
|
||||
const writeId = '11111111-1111-4111-8111-111111111111'
|
||||
const target = {
|
||||
workspaceId: 'workspace-1',
|
||||
issue: { id: 'issue-1', identifier: 'ENG-123', url: 'https://example.invalid/ENG-123' }
|
||||
}
|
||||
|
||||
await expect(
|
||||
runner.runLinearAgentWrite(
|
||||
async () => {
|
||||
throw new LinearWriteFailure(
|
||||
'unconfirmed',
|
||||
'Linear write could not be confirmed.',
|
||||
new Error('fetch failed: socket hang up Authorization: Bearer linear-secret-token')
|
||||
)
|
||||
},
|
||||
(cause) =>
|
||||
builder.linearCreateStyleUnconfirmed('comment', writeId, target, {
|
||||
bodyRequired: true,
|
||||
cause
|
||||
})
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: 'linear_write_unconfirmed',
|
||||
data: {
|
||||
cause: 'fetch failed: socket hang up Authorization: Bearer [REDACTED]',
|
||||
nextSteps: [expect.stringContaining(`--write-id=${writeId}`)]
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('sanitizes Linear provider messages before they enter RPC error envelopes', () => {
|
||||
const message = sanitizeLinearErrorMessage(
|
||||
'Linear rejected mutation variables: {"body":"user comment payload","id":"issue-1"} headers: {Authorization: Bearer token-123}\n at handler (linear.ts:1:1)'
|
||||
)
|
||||
|
||||
expect(message).toContain('Linear rejected mutation')
|
||||
expect(message).toContain('variables: [REDACTED]')
|
||||
expect(message).toContain('headers: [REDACTED]')
|
||||
expect(message).not.toContain('user comment payload')
|
||||
expect(message).not.toContain('token-123')
|
||||
expect(message).not.toContain('at handler')
|
||||
})
|
||||
|
||||
it('returns unconfirmed at the write deadline even when the request ignores abort', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const write = vi.fn((_signal: AbortSignal) => new Promise<string>(() => undefined))
|
||||
const unconfirmed = vi.fn(() =>
|
||||
Object.assign(new Error('unconfirmed'), { code: 'linear_write_unconfirmed' })
|
||||
)
|
||||
|
||||
const pending = (runtime as unknown as LinearWriteRunner).runLinearAgentWrite(
|
||||
write,
|
||||
unconfirmed
|
||||
)
|
||||
const rejection = expect(pending).rejects.toMatchObject({
|
||||
code: 'linear_write_unconfirmed'
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(25_000)
|
||||
|
||||
await rejection
|
||||
expect(unconfirmed).toHaveBeenCalledTimes(1)
|
||||
expect(write.mock.calls[0]?.[0].aborted).toBe(true)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps payload and destination details in pinned retries', () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const builder = runtime as unknown as LinearUnconfirmedBuilder
|
||||
const writeId = '11111111-1111-4111-8111-111111111111'
|
||||
const target = {
|
||||
workspaceId: 'workspace-1',
|
||||
issue: { id: 'issue-1', identifier: 'ENG-123', url: 'https://example.invalid/ENG-123' }
|
||||
}
|
||||
const parent = {
|
||||
workspaceId: 'workspace-1',
|
||||
issue: { id: 'issue-1', identifier: 'ENG-123', url: 'https://example.invalid/ENG-123' }
|
||||
}
|
||||
|
||||
const comment = builder.linearCreateStyleUnconfirmed('comment', writeId, target, {
|
||||
parentId: 'comment-root',
|
||||
bodyRequired: true
|
||||
})
|
||||
const attach = builder.linearCreateStyleUnconfirmed('attach', writeId, target, {
|
||||
title: 'Review link',
|
||||
url: 'https://example.invalid/review/1'
|
||||
})
|
||||
const create = builder.linearCreateStyleUnconfirmed('create', writeId, null, {
|
||||
parent,
|
||||
team: { id: 'team-2', key: 'OTHER', name: 'Other', workspaceId: 'workspace-1' },
|
||||
title: 'Follow up',
|
||||
bodyRequired: true
|
||||
})
|
||||
|
||||
expect(comment.data?.nextSteps?.[0]).toContain('--body-file -')
|
||||
expect(comment.data?.nextSteps?.[0]).toContain('--reply-to=comment-root')
|
||||
expect(attach.data?.nextSteps?.[0]).toContain('--url URL_HERE')
|
||||
expect(attach.data?.nextSteps?.[0]).toContain('--title TITLE_HERE')
|
||||
expect(attach.data?.nextSteps?.[0]).toContain('Replace TITLE_HERE/URL_HERE')
|
||||
expect(create.data?.nextSteps?.[0]).toContain('--title TITLE_HERE')
|
||||
expect(create.data?.nextSteps?.[0]).toContain('--body-file -')
|
||||
expect(create.data?.nextSteps?.[0]).toContain('--parent=ENG-123')
|
||||
expect(create.data?.nextSteps?.[0]).toContain('--team=OTHER')
|
||||
expect(create.data?.nextSteps?.[0]).toContain('Replace TITLE_HERE')
|
||||
})
|
||||
|
||||
it('resolves workflow states by UUID or case-insensitive exact name', () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const states = [
|
||||
{ id: 'state-review', name: 'In Review', type: 'started' },
|
||||
{ id: 'state-done', name: 'Done', type: 'completed' }
|
||||
]
|
||||
const builder = runtime as unknown as LinearUnconfirmedBuilder
|
||||
|
||||
expect(builder.resolveLinearAgentState('In Review', states)).toBe(states[0])
|
||||
expect(builder.resolveLinearAgentState('in review', states)).toBe(states[0])
|
||||
expect(builder.resolveLinearAgentState('STATE-REVIEW', states)).toBe(states[0])
|
||||
expect(builder.resolveLinearAgentState('Review', states)).toBeNull()
|
||||
})
|
||||
|
||||
it('deduplicates write-id lookups by relationship target without comparing payloads', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const tester = runtime as unknown as LinearRetryLookupTester
|
||||
|
||||
tester.readLinearWriteLookup = vi.fn(async () => ({
|
||||
id: 'comment-1',
|
||||
body: 'different retry body',
|
||||
issue: { id: 'issue-1', identifier: 'ENG-1', url: 'https://example.invalid/ENG-1' },
|
||||
parentId: 'comment-root',
|
||||
threadRootId: 'comment-root',
|
||||
url: null
|
||||
}))
|
||||
await expect(
|
||||
tester.getMatchingLinearCommentWrite(
|
||||
'11111111-1111-4111-8111-111111111111',
|
||||
'issue-1',
|
||||
'comment-root',
|
||||
'workspace-1',
|
||||
true
|
||||
)
|
||||
).resolves.toMatchObject({ id: 'comment-1' })
|
||||
|
||||
tester.readLinearWriteLookup = vi.fn(async () => ({
|
||||
id: 'attachment-1',
|
||||
title: 'Different title',
|
||||
url: 'https://example.invalid/different',
|
||||
issue: { id: 'issue-1', identifier: 'ENG-1', url: 'https://example.invalid/ENG-1' }
|
||||
}))
|
||||
await expect(
|
||||
tester.getMatchingLinearAttachmentWrite(
|
||||
'22222222-2222-4222-8222-222222222222',
|
||||
'issue-1',
|
||||
'workspace-1',
|
||||
true
|
||||
)
|
||||
).resolves.toMatchObject({ id: 'attachment-1' })
|
||||
|
||||
tester.readLinearWriteLookup = vi.fn(async () => ({
|
||||
id: 'issue-2',
|
||||
identifier: 'ENG-2',
|
||||
title: 'Different title',
|
||||
description: 'Different body',
|
||||
url: 'https://example.invalid/ENG-2',
|
||||
team: { id: 'team-1', key: 'ENG', name: 'Engineering' },
|
||||
state: null,
|
||||
parent: { id: 'issue-1', identifier: 'ENG-1' }
|
||||
}))
|
||||
await expect(
|
||||
tester.getMatchingLinearCreatedIssue(
|
||||
'33333333-3333-4333-8333-333333333333',
|
||||
'team-1',
|
||||
'issue-1',
|
||||
'workspace-1',
|
||||
true
|
||||
)
|
||||
).resolves.toMatchObject({ id: 'issue-2' })
|
||||
})
|
||||
|
||||
it('keeps the unconfirmed retry envelope when duplicate recovery lookup fails', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const tester = runtime as unknown as LinearRetryLookupTester
|
||||
const unconfirmed = Object.assign(new Error('try pinned retry again'), {
|
||||
code: 'linear_write_unconfirmed',
|
||||
data: { writeId: '11111111-1111-4111-8111-111111111111' }
|
||||
})
|
||||
|
||||
tester.readLinearWriteLookup = vi.fn(async () => {
|
||||
throw Object.assign(new Error('socket reset during lookup'), {
|
||||
code: 'linear_network_error'
|
||||
})
|
||||
})
|
||||
|
||||
await expect(
|
||||
tester.refetchLinearCommentAfterDuplicate(
|
||||
'11111111-1111-4111-8111-111111111111',
|
||||
'issue-1',
|
||||
null,
|
||||
'workspace-1',
|
||||
() => unconfirmed
|
||||
)
|
||||
).rejects.toBe(unconfirmed)
|
||||
})
|
||||
|
||||
it('emits linked issue refresh events for matching workspace links', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
const builder = runtime as unknown as LinearUnconfirmedBuilder
|
||||
const events: unknown[] = []
|
||||
runtime.onClientEvent((event) => events.push(event))
|
||||
builder.listResolvedWorktrees = vi.fn(async () => [
|
||||
{
|
||||
id: 'worktree-1',
|
||||
linkedLinearIssue: 'eng-123',
|
||||
linkedLinearIssueWorkspaceId: 'workspace-1'
|
||||
},
|
||||
{
|
||||
id: 'worktree-2',
|
||||
linkedLinearIssue: 'ENG-123',
|
||||
linkedLinearIssueWorkspaceId: 'workspace-2'
|
||||
}
|
||||
])
|
||||
|
||||
await builder.notifyLinearLinkedIssueUpdated('workspace-1', 'ENG-123')
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: 'linearLinkedIssueUpdated',
|
||||
worktreeId: 'worktree-1',
|
||||
identifier: 'ENG-123',
|
||||
workspaceId: 'workspace-1'
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,9 @@
|
||||
import { z } from 'zod'
|
||||
import { defineMethod, type RpcMethod } from '../core'
|
||||
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
|
||||
import { linearError } from '../../../linear/issue-context-errors'
|
||||
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
const AgentSearchIssues = z.object({
|
||||
query: requiredString('Missing query'),
|
||||
@@ -24,6 +27,15 @@ const LinearCurrentContext = z
|
||||
})
|
||||
.optional()
|
||||
|
||||
const LinearWriteTarget = z.object({
|
||||
input: OptionalString,
|
||||
current: z.boolean().optional(),
|
||||
workspaceId: OptionalString.refine((value) => value !== 'all', {
|
||||
message: '--workspace all is not valid for Linear writes'
|
||||
}),
|
||||
context: LinearCurrentContext
|
||||
})
|
||||
|
||||
const AgentIssueContext = z.object({
|
||||
input: OptionalString,
|
||||
current: z.boolean().optional(),
|
||||
@@ -33,6 +45,45 @@ const AgentIssueContext = z.object({
|
||||
context: LinearCurrentContext
|
||||
})
|
||||
|
||||
const LinearIssueSetState = LinearWriteTarget.extend({
|
||||
to: requiredString('Missing target state')
|
||||
})
|
||||
|
||||
const LinearIssueAddComment = LinearWriteTarget.extend({
|
||||
body: requiredString('Missing comment body'),
|
||||
replyTo: OptionalString,
|
||||
writeId: OptionalString
|
||||
})
|
||||
|
||||
const LinearIssueAttachLink = LinearWriteTarget.extend({
|
||||
url: requiredString('Missing attachment URL'),
|
||||
title: OptionalString,
|
||||
writeId: OptionalString
|
||||
})
|
||||
|
||||
const LinearIssueCreate = z.object({
|
||||
title: requiredString('Missing issue title'),
|
||||
body: OptionalString,
|
||||
teamKey: OptionalString,
|
||||
parentInput: OptionalString,
|
||||
parentCurrent: z.boolean().optional(),
|
||||
workspaceId: OptionalString.refine((value) => value !== 'all', {
|
||||
message: '--workspace all is not valid for Linear writes'
|
||||
}),
|
||||
writeId: OptionalString,
|
||||
context: LinearCurrentContext
|
||||
})
|
||||
|
||||
function parseLinearWriteId(writeId: string | undefined): string | undefined {
|
||||
if (writeId === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (!UUID_PATTERN.test(writeId)) {
|
||||
throw linearError('linear_invalid_write_id', '--write-id must be a UUID')
|
||||
}
|
||||
return writeId
|
||||
}
|
||||
|
||||
export const LINEAR_AGENT_ACCESS_METHODS: RpcMethod[] = [
|
||||
defineMethod({
|
||||
name: 'linear.agentSearchIssues',
|
||||
@@ -53,5 +104,28 @@ export const LINEAR_AGENT_ACCESS_METHODS: RpcMethod[] = [
|
||||
name: 'linear.resolveCurrentIssue',
|
||||
params: LinearCurrentContext,
|
||||
handler: async (params, { runtime }) => runtime.linearResolveCurrentIssue(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'linear.issueSetState',
|
||||
params: LinearIssueSetState,
|
||||
handler: async (params, { runtime }) => runtime.linearIssueSetState(params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'linear.issueAddComment',
|
||||
params: LinearIssueAddComment,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.linearIssueAddComment({ ...params, writeId: parseLinearWriteId(params.writeId) })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'linear.issueAttachLink',
|
||||
params: LinearIssueAttachLink,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.linearIssueAttachLink({ ...params, writeId: parseLinearWriteId(params.writeId) })
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'linear.issueCreate',
|
||||
params: LinearIssueCreate,
|
||||
handler: async (params, { runtime }) =>
|
||||
runtime.linearIssueCreate({ ...params, writeId: parseLinearWriteId(params.writeId) })
|
||||
})
|
||||
]
|
||||
|
||||
@@ -399,234 +399,6 @@ describe('repo RPC methods', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('routes project and project-host setup lists to the runtime server', async () => {
|
||||
const project = {
|
||||
id: 'project-1',
|
||||
displayName: 'Project',
|
||||
badgeColor: '#737373',
|
||||
sourceRepoIds: ['repo-1'],
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
const setup = {
|
||||
id: 'repo-1',
|
||||
projectId: 'project-1',
|
||||
hostId: 'local',
|
||||
repoId: 'repo-1',
|
||||
path: '/repo',
|
||||
displayName: 'Project',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'legacy-repo',
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
listProjects: vi.fn().mockReturnValue([project]),
|
||||
listProjectHostSetups: vi.fn().mockReturnValue([setup])
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
|
||||
|
||||
const projectsResponse = await dispatcher.dispatch(makeRequest('project.list'))
|
||||
const setupsResponse = await dispatcher.dispatch(makeRequest('projectHostSetup.list'))
|
||||
|
||||
expect(projectsResponse).toMatchObject({ ok: true, result: { projects: [project] } })
|
||||
expect(setupsResponse).toMatchObject({ ok: true, result: { setups: [setup] } })
|
||||
expect(runtime.listProjects).toHaveBeenCalled()
|
||||
expect(runtime.listProjectHostSetups).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes project-host setup import to the runtime server', async () => {
|
||||
const result = {
|
||||
project: {
|
||||
id: 'project-1',
|
||||
displayName: 'Project',
|
||||
badgeColor: '#737373',
|
||||
sourceRepoIds: ['repo-1'],
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
},
|
||||
setup: {
|
||||
id: 'repo-1',
|
||||
projectId: 'project-1',
|
||||
hostId: 'local',
|
||||
repoId: 'repo-1',
|
||||
path: '/repo',
|
||||
displayName: 'Project',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'legacy-repo',
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
},
|
||||
repo: { id: 'repo-1', path: '/repo', displayName: 'Project' }
|
||||
}
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
setupProjectExistingFolder: vi.fn().mockResolvedValue(result)
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('projectHostSetup.setupExistingFolder', {
|
||||
projectId: 'project-1',
|
||||
hostId: 'runtime:env-1',
|
||||
path: '/repo',
|
||||
kind: 'git',
|
||||
setupMethod: 'cloned'
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.setupProjectExistingFolder).toHaveBeenCalledWith({
|
||||
projectId: 'project-1',
|
||||
hostId: 'runtime:env-1',
|
||||
path: '/repo',
|
||||
kind: 'git',
|
||||
setupMethod: 'cloned'
|
||||
})
|
||||
expect(response).toMatchObject({ ok: true, result: { result } })
|
||||
})
|
||||
|
||||
it('routes project-host setup creates to the runtime server', async () => {
|
||||
const result = {
|
||||
project: {
|
||||
id: 'project-1',
|
||||
displayName: 'Project',
|
||||
badgeColor: '#737373',
|
||||
sourceRepoIds: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
},
|
||||
setup: {
|
||||
id: 'setup-1',
|
||||
projectId: 'project-1',
|
||||
hostId: 'runtime:env-1',
|
||||
repoId: '',
|
||||
path: '',
|
||||
displayName: 'GPU VM',
|
||||
setupState: 'setting-up',
|
||||
setupMethod: 'provisioned',
|
||||
createdAt: 1,
|
||||
updatedAt: 2
|
||||
}
|
||||
}
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
createProjectHostSetup: vi.fn().mockReturnValue(result)
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('projectHostSetup.create', {
|
||||
projectId: 'project-1',
|
||||
hostId: 'runtime:env-1',
|
||||
setupId: 'setup-1',
|
||||
displayName: 'GPU VM',
|
||||
setupState: 'setting-up',
|
||||
setupMethod: 'provisioned'
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.createProjectHostSetup).toHaveBeenCalledWith({
|
||||
projectId: 'project-1',
|
||||
hostId: 'runtime:env-1',
|
||||
setupId: 'setup-1',
|
||||
displayName: 'GPU VM',
|
||||
setupState: 'setting-up',
|
||||
setupMethod: 'provisioned'
|
||||
})
|
||||
expect(response).toMatchObject({ ok: true, result: { result } })
|
||||
})
|
||||
|
||||
it('routes project-host setup updates to the runtime server', async () => {
|
||||
const result = {
|
||||
project: {
|
||||
id: 'project-1',
|
||||
displayName: 'Project',
|
||||
badgeColor: '#737373',
|
||||
sourceRepoIds: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
},
|
||||
setup: {
|
||||
id: 'setup-1',
|
||||
projectId: 'project-1',
|
||||
hostId: 'runtime:env-1',
|
||||
repoId: '',
|
||||
path: '/srv/project',
|
||||
displayName: 'GPU VM',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'imported-existing-folder',
|
||||
createdAt: 1,
|
||||
updatedAt: 2
|
||||
}
|
||||
}
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
updateProjectHostSetup: vi.fn().mockReturnValue(result)
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('projectHostSetup.update', {
|
||||
setupId: 'setup-1',
|
||||
updates: {
|
||||
displayName: 'GPU VM',
|
||||
path: '/srv/project',
|
||||
setupState: 'ready'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.updateProjectHostSetup).toHaveBeenCalledWith({
|
||||
setupId: 'setup-1',
|
||||
updates: {
|
||||
displayName: 'GPU VM',
|
||||
path: '/srv/project',
|
||||
setupState: 'ready'
|
||||
}
|
||||
})
|
||||
expect(response).toMatchObject({ ok: true, result: { result } })
|
||||
})
|
||||
|
||||
it('routes project-host setup deletes to the runtime server', async () => {
|
||||
const result = {
|
||||
project: {
|
||||
id: 'project-1',
|
||||
displayName: 'Project',
|
||||
badgeColor: '#737373',
|
||||
sourceRepoIds: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
},
|
||||
setup: {
|
||||
id: 'setup-1',
|
||||
projectId: 'project-1',
|
||||
hostId: 'runtime:env-1',
|
||||
repoId: '',
|
||||
path: '/srv/project',
|
||||
displayName: 'GPU VM',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'imported-existing-folder',
|
||||
createdAt: 1,
|
||||
updatedAt: 2
|
||||
}
|
||||
}
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
deleteProjectHostSetup: vi.fn().mockReturnValue(result)
|
||||
} as unknown as OrcaRuntimeService
|
||||
const dispatcher = new RpcDispatcher({ runtime, methods: REPO_METHODS })
|
||||
|
||||
const response = await dispatcher.dispatch(
|
||||
makeRequest('projectHostSetup.delete', {
|
||||
setupId: 'setup-1'
|
||||
})
|
||||
)
|
||||
|
||||
expect(runtime.deleteProjectHostSetup).toHaveBeenCalledWith({ setupId: 'setup-1' })
|
||||
expect(response).toMatchObject({ ok: true, result: { result } })
|
||||
})
|
||||
|
||||
it('allows separate nested-repo imports without a group name', async () => {
|
||||
const runtime = {
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
|
||||
@@ -4,7 +4,6 @@ import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas
|
||||
import { sanitizeRepoIcon } from '../../../../shared/repo-icon'
|
||||
import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color'
|
||||
import { normalizeRepoSourceControlAiOverrides } from '../../../../shared/source-control-ai'
|
||||
import { PROJECT_RUNTIME_METHODS } from './project-runtime-rpc-methods'
|
||||
import { FOLDER_WORKSPACE_METHODS } from './folder-workspace'
|
||||
|
||||
const RepoSelector = z.object({
|
||||
|
||||
@@ -140,7 +140,11 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
|
||||
'browser.screencast.unsubscribe',
|
||||
'browser.tabCreate',
|
||||
'browser.viewport',
|
||||
'clipboard.abortImageUpload',
|
||||
'clipboard.appendImageUploadChunk',
|
||||
'clipboard.commitImageUpload',
|
||||
'clipboard.saveImageAsTempFile',
|
||||
'clipboard.startImageUpload',
|
||||
'diagnostics.memory',
|
||||
'files.browseServerDir',
|
||||
'files.createFile',
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
gitExecFileAsyncMock,
|
||||
glabExecFileAsyncMock,
|
||||
ghExecFileAsyncMock,
|
||||
getAzureDevOpsRepoSlugMock,
|
||||
getBitbucketRepoSlugMock,
|
||||
getGiteaRepoSlugMock,
|
||||
getHostedReviewForBranchMock,
|
||||
getRepoSlugMock,
|
||||
getSshGitProviderMock
|
||||
} = vi.hoisted(() => ({
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
glabExecFileAsyncMock: vi.fn(),
|
||||
ghExecFileAsyncMock: vi.fn(),
|
||||
getAzureDevOpsRepoSlugMock: vi.fn(),
|
||||
getBitbucketRepoSlugMock: vi.fn(),
|
||||
getGiteaRepoSlugMock: vi.fn(),
|
||||
getHostedReviewForBranchMock: vi.fn(),
|
||||
getRepoSlugMock: vi.fn(),
|
||||
getSshGitProviderMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../git/runner', () => ({
|
||||
gitExecFileAsync: gitExecFileAsyncMock,
|
||||
glabExecFileAsync: glabExecFileAsyncMock,
|
||||
ghExecFileAsync: ghExecFileAsyncMock,
|
||||
extractExecError: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../github/client', () => ({
|
||||
createGitHubPullRequest: vi.fn(),
|
||||
getRepoSlug: getRepoSlugMock,
|
||||
getPRForBranch: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../bitbucket/client', () => ({
|
||||
getBitbucketRepoSlug: getBitbucketRepoSlugMock,
|
||||
getBitbucketPullRequestForBranch: vi.fn(),
|
||||
getBitbucketPullRequest: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../azure-devops/client', () => ({
|
||||
getAzureDevOpsRepoSlug: getAzureDevOpsRepoSlugMock,
|
||||
getAzureDevOpsPullRequestForBranch: vi.fn(),
|
||||
getAzureDevOpsPullRequest: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../gitea/client', () => ({
|
||||
getGiteaRepoSlug: getGiteaRepoSlugMock,
|
||||
getGiteaPullRequestForBranch: vi.fn(),
|
||||
getGiteaPullRequest: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: getSshGitProviderMock
|
||||
}))
|
||||
|
||||
vi.mock('./hosted-review', () => ({
|
||||
getHostedReviewForBranch: getHostedReviewForBranchMock
|
||||
}))
|
||||
|
||||
import { _resetKnownHostsCache, _resetProjectRefCache } from '../gitlab/gl-utils'
|
||||
import { getHostedReviewCreationEligibility } from './hosted-review-creation'
|
||||
|
||||
function resetMocks(): void {
|
||||
for (const mock of [
|
||||
gitExecFileAsyncMock,
|
||||
glabExecFileAsyncMock,
|
||||
ghExecFileAsyncMock,
|
||||
getAzureDevOpsRepoSlugMock,
|
||||
getBitbucketRepoSlugMock,
|
||||
getGiteaRepoSlugMock,
|
||||
getHostedReviewForBranchMock,
|
||||
getRepoSlugMock,
|
||||
getSshGitProviderMock
|
||||
]) {
|
||||
mock.mockReset()
|
||||
}
|
||||
_resetKnownHostsCache()
|
||||
_resetProjectRefCache()
|
||||
}
|
||||
|
||||
function mockNonGitLabProviders(): void {
|
||||
getRepoSlugMock.mockResolvedValue(null)
|
||||
getBitbucketRepoSlugMock.mockResolvedValue(null)
|
||||
getAzureDevOpsRepoSlugMock.mockResolvedValue(null)
|
||||
getGiteaRepoSlugMock.mockResolvedValue(null)
|
||||
}
|
||||
|
||||
describe('GitLab self-hosted hosted review creation eligibility', () => {
|
||||
beforeEach(() => {
|
||||
resetMocks()
|
||||
mockNonGitLabProviders()
|
||||
getHostedReviewForBranchMock.mockResolvedValue(null)
|
||||
gitExecFileAsyncMock.mockResolvedValue({
|
||||
stdout: 'git@gitlab.internal:team/orca.git\n',
|
||||
stderr: ''
|
||||
})
|
||||
})
|
||||
|
||||
it('enables MR creation when glab recognizes the self-hosted origin host', async () => {
|
||||
glabExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'auth' && args[1] === 'status' && args.includes('--hostname')) {
|
||||
return {
|
||||
stdout: `gitlab.internal
|
||||
✓ Logged in as user
|
||||
`,
|
||||
stderr: ''
|
||||
}
|
||||
}
|
||||
if (args[0] === 'auth' && args[1] === 'status') {
|
||||
return {
|
||||
stdout: `gitlab.com
|
||||
✓ Logged in to gitlab.com as user
|
||||
`,
|
||||
stderr: ''
|
||||
}
|
||||
}
|
||||
return { stdout: '', stderr: '' }
|
||||
})
|
||||
|
||||
await expect(
|
||||
getHostedReviewCreationEligibility({
|
||||
repoPath: '/repo',
|
||||
branch: 'feature/self-hosted-mr',
|
||||
base: 'main',
|
||||
hasUncommittedChanges: false,
|
||||
hasUpstream: true,
|
||||
ahead: 0,
|
||||
behind: 0
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
provider: 'gitlab',
|
||||
canCreate: true,
|
||||
blockedReason: null,
|
||||
nextAction: null,
|
||||
head: 'feature/self-hosted-mr'
|
||||
})
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], {
|
||||
cwd: '/repo'
|
||||
})
|
||||
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['auth', 'status', '--hostname', 'gitlab.internal'],
|
||||
{ cwd: '/repo' }
|
||||
)
|
||||
})
|
||||
|
||||
it('classifies known-but-unauthenticated self-hosted GitLab as auth_required', async () => {
|
||||
glabExecFileAsyncMock.mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'auth' && args[1] === 'status' && args.includes('--hostname')) {
|
||||
const error = new Error('invalid token provided') as Error & {
|
||||
stdout: string
|
||||
stderr: string
|
||||
}
|
||||
error.stdout = `gitlab.internal
|
||||
! Invalid token provided
|
||||
`
|
||||
error.stderr = ''
|
||||
throw error
|
||||
}
|
||||
if (args[0] === 'auth' && args[1] === 'status') {
|
||||
return {
|
||||
stdout: `gitlab.com
|
||||
✓ Logged in to gitlab.com as user
|
||||
gitlab.internal
|
||||
! Invalid token provided
|
||||
`,
|
||||
stderr: ''
|
||||
}
|
||||
}
|
||||
return { stdout: '', stderr: '' }
|
||||
})
|
||||
|
||||
const result = await getHostedReviewCreationEligibility({
|
||||
repoPath: '/repo',
|
||||
branch: 'feature/self-hosted-mr',
|
||||
base: 'main',
|
||||
hasUncommittedChanges: false,
|
||||
hasUpstream: true,
|
||||
ahead: 0,
|
||||
behind: 0
|
||||
})
|
||||
expect(result).toMatchObject({
|
||||
provider: 'gitlab',
|
||||
canCreate: false,
|
||||
blockedReason: 'auth_required',
|
||||
nextAction: 'authenticate'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -5,24 +5,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SPEECH_MODEL_CATALOG } from './model-catalog'
|
||||
import { ModelManager } from './model-manager'
|
||||
|
||||
const { httpsGetMock } = vi.hoisted(() => ({
|
||||
httpsGetMock: vi.fn()
|
||||
const { netRequestMock } = vi.hoisted(() => ({
|
||||
netRequestMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => '/tmp/orca-speech-models-test'
|
||||
},
|
||||
net: {
|
||||
request: netRequestMock
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('https', async () => {
|
||||
const actual = await vi.importActual('https')
|
||||
return { ...(actual as Record<string, unknown>), get: httpsGetMock }
|
||||
})
|
||||
|
||||
describe('ModelManager download failures', () => {
|
||||
beforeEach(() => {
|
||||
httpsGetMock.mockReset()
|
||||
netRequestMock.mockReset()
|
||||
})
|
||||
|
||||
it('rejects failed model downloads so the caller can surface the error', async () => {
|
||||
@@ -31,8 +29,15 @@ describe('ModelManager download failures', () => {
|
||||
const manifest = SPEECH_MODEL_CATALOG[0]
|
||||
const errorHandlers: ((err: Error) => void)[] = []
|
||||
const request = {
|
||||
destroy: vi.fn(() => request),
|
||||
setTimeout: vi.fn(() => request),
|
||||
abort: vi.fn(() => request),
|
||||
end: vi.fn(() => {
|
||||
queueMicrotask(() => {
|
||||
for (const handler of errorHandlers) {
|
||||
handler(new Error('network down'))
|
||||
}
|
||||
})
|
||||
return request
|
||||
}),
|
||||
on: vi.fn((event: string, cb: (err: Error) => void) => {
|
||||
if (event === 'error') {
|
||||
errorHandlers.push(cb)
|
||||
@@ -49,14 +54,7 @@ describe('ModelManager download failures', () => {
|
||||
return request
|
||||
})
|
||||
}
|
||||
httpsGetMock.mockImplementation(() => {
|
||||
queueMicrotask(() => {
|
||||
for (const handler of errorHandlers) {
|
||||
handler(new Error('network down'))
|
||||
}
|
||||
})
|
||||
return request
|
||||
})
|
||||
netRequestMock.mockReturnValue(request)
|
||||
const manager = new ModelManager(dir)
|
||||
|
||||
await expect(manager.downloadModel(manifest.id)).rejects.toThrow('network down')
|
||||
|
||||
@@ -5,21 +5,19 @@ import { PassThrough } from 'stream'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ModelManager } from './model-manager'
|
||||
|
||||
const { httpsGetMock } = vi.hoisted(() => ({
|
||||
httpsGetMock: vi.fn()
|
||||
const { netRequestMock } = vi.hoisted(() => ({
|
||||
netRequestMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => '/tmp/orca-speech-models-test'
|
||||
},
|
||||
net: {
|
||||
request: netRequestMock
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('https', async () => {
|
||||
const actual = await vi.importActual('https')
|
||||
return { ...(actual as Record<string, unknown>), get: httpsGetMock }
|
||||
})
|
||||
|
||||
type ModelManagerInternals = {
|
||||
downloadFile: (
|
||||
url: string,
|
||||
@@ -33,7 +31,7 @@ type ModelManagerInternals = {
|
||||
|
||||
describe('ModelManager stream cleanup', () => {
|
||||
beforeEach(() => {
|
||||
httpsGetMock.mockReset()
|
||||
netRequestMock.mockReset()
|
||||
})
|
||||
|
||||
it('removes response progress listeners after a model download finishes', async () => {
|
||||
@@ -45,16 +43,24 @@ describe('ModelManager stream cleanup', () => {
|
||||
}
|
||||
response.statusCode = 200
|
||||
response.headers = { 'content-length': '4' }
|
||||
const responseHandlers: ((response: unknown) => void)[] = []
|
||||
const request = {
|
||||
destroy: vi.fn(() => request),
|
||||
setTimeout: vi.fn(() => request),
|
||||
on: vi.fn(() => request),
|
||||
abort: vi.fn(() => request),
|
||||
end: vi.fn(() => {
|
||||
for (const handler of responseHandlers) {
|
||||
handler(response)
|
||||
}
|
||||
return request
|
||||
}),
|
||||
on: vi.fn((event: string, cb: (response: unknown) => void) => {
|
||||
if (event === 'response') {
|
||||
responseHandlers.push(cb)
|
||||
}
|
||||
return request
|
||||
}),
|
||||
off: vi.fn(() => request)
|
||||
}
|
||||
httpsGetMock.mockImplementation((_url: URL, cb: (response: unknown) => void) => {
|
||||
cb(response)
|
||||
return request
|
||||
})
|
||||
netRequestMock.mockReturnValue(request)
|
||||
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
|
||||
|
||||
const download = manager.downloadFile(
|
||||
|
||||
@@ -6,15 +6,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SPEECH_MODEL_CATALOG } from './model-catalog'
|
||||
import { ModelManager } from './model-manager'
|
||||
|
||||
const { hasOpenAiSpeechApiKeyMock, httpsGetMock, spawnMock } = vi.hoisted(() => ({
|
||||
const { hasOpenAiSpeechApiKeyMock, netRequestMock, spawnMock } = vi.hoisted(() => ({
|
||||
hasOpenAiSpeechApiKeyMock: vi.fn(),
|
||||
httpsGetMock: vi.fn(),
|
||||
netRequestMock: vi.fn(),
|
||||
spawnMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: () => '/tmp/orca-speech-models-test'
|
||||
},
|
||||
net: {
|
||||
request: netRequestMock
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -23,11 +26,6 @@ vi.mock('child_process', async () => {
|
||||
return { ...(actual as Record<string, unknown>), spawn: spawnMock }
|
||||
})
|
||||
|
||||
vi.mock('https', async () => {
|
||||
const actual = await vi.importActual('https')
|
||||
return { ...(actual as Record<string, unknown>), get: httpsGetMock }
|
||||
})
|
||||
|
||||
vi.mock('./openai-api-key-store', () => ({
|
||||
hasOpenAiSpeechApiKey: hasOpenAiSpeechApiKeyMock
|
||||
}))
|
||||
@@ -52,7 +50,7 @@ type ModelManagerInternals = {
|
||||
|
||||
describe('ModelManager', () => {
|
||||
beforeEach(() => {
|
||||
httpsGetMock.mockReset()
|
||||
netRequestMock.mockReset()
|
||||
hasOpenAiSpeechApiKeyMock.mockReset()
|
||||
hasOpenAiSpeechApiKeyMock.mockReturnValue(false)
|
||||
spawnMock.mockReset()
|
||||
@@ -129,23 +127,30 @@ describe('ModelManager', () => {
|
||||
try {
|
||||
const manifest = SPEECH_MODEL_CATALOG[0]
|
||||
const errorHandlers: ((err: Error) => void)[] = []
|
||||
const timeoutHandlers: (() => void)[] = []
|
||||
const responseHandlers: ((response: unknown) => void)[] = []
|
||||
const redirectHandlers: ((
|
||||
statusCode: number,
|
||||
method: string,
|
||||
redirectUrl: string
|
||||
) => void)[] = []
|
||||
const request = {
|
||||
destroy: vi.fn((err?: Error) => {
|
||||
abort: vi.fn(() => {
|
||||
queueMicrotask(() => {
|
||||
for (const handler of errorHandlers) {
|
||||
handler(err ?? new Error('destroyed'))
|
||||
handler(new Error('Aborted'))
|
||||
}
|
||||
})
|
||||
return request
|
||||
}),
|
||||
setTimeout: vi.fn((_ms: number, cb: () => void) => {
|
||||
timeoutHandlers.push(cb)
|
||||
return request
|
||||
}),
|
||||
on: vi.fn((event: string, cb: (err: Error) => void) => {
|
||||
if (event === 'error') {
|
||||
errorHandlers.push(cb)
|
||||
} else if (event === 'response') {
|
||||
responseHandlers.push(cb as unknown as (response: unknown) => void)
|
||||
} else if (event === 'redirect') {
|
||||
redirectHandlers.push(
|
||||
cb as unknown as (statusCode: number, method: string, redirectUrl: string) => void
|
||||
)
|
||||
}
|
||||
return request
|
||||
}),
|
||||
@@ -156,75 +161,154 @@ describe('ModelManager', () => {
|
||||
errorHandlers.splice(index, 1)
|
||||
}
|
||||
}
|
||||
if (event === 'timeout') {
|
||||
const index = timeoutHandlers.indexOf(cb as () => void)
|
||||
if (event === 'response') {
|
||||
const index = responseHandlers.indexOf(cb as (response: unknown) => void)
|
||||
if (index !== -1) {
|
||||
timeoutHandlers.splice(index, 1)
|
||||
responseHandlers.splice(index, 1)
|
||||
}
|
||||
}
|
||||
if (event === 'redirect') {
|
||||
const index = redirectHandlers.indexOf(
|
||||
cb as (statusCode: number, method: string, redirectUrl: string) => void
|
||||
)
|
||||
if (index !== -1) {
|
||||
redirectHandlers.splice(index, 1)
|
||||
}
|
||||
}
|
||||
return request
|
||||
})
|
||||
}),
|
||||
end: vi.fn(() => request)
|
||||
}
|
||||
httpsGetMock.mockImplementation(
|
||||
(
|
||||
_url: URL,
|
||||
options: { signal?: AbortSignal } | ((response: unknown) => void),
|
||||
_cb?: (response: unknown) => void
|
||||
) => {
|
||||
if (typeof options !== 'function') {
|
||||
options.signal?.addEventListener('abort', () => request.destroy(new Error('Aborted')), {
|
||||
once: true
|
||||
})
|
||||
}
|
||||
return request
|
||||
}
|
||||
)
|
||||
netRequestMock.mockReturnValue(request)
|
||||
const manager = new ModelManager(dir)
|
||||
|
||||
const download = manager.downloadModel(manifest.id)
|
||||
manager.cancelDownload(manifest.id)
|
||||
await expect(download).resolves.toBeUndefined()
|
||||
|
||||
expect(request.destroy).toHaveBeenCalledWith(expect.any(Error))
|
||||
expect(netRequestMock).toHaveBeenCalledWith({
|
||||
method: 'GET',
|
||||
url: expect.stringMatching(/^https:\/\//)
|
||||
})
|
||||
expect(request.end).toHaveBeenCalled()
|
||||
expect(request.abort).toHaveBeenCalled()
|
||||
expect(request.off).toHaveBeenCalledWith('error', expect.any(Function))
|
||||
expect(request.off).toHaveBeenCalledWith('timeout', expect.any(Function))
|
||||
expect(request.off).toHaveBeenCalledWith('response', expect.any(Function))
|
||||
expect(request.off).toHaveBeenCalledWith('redirect', expect.any(Function))
|
||||
expect(errorHandlers).toHaveLength(0)
|
||||
expect(timeoutHandlers).toHaveLength(0)
|
||||
expect(responseHandlers).toHaveLength(0)
|
||||
expect(redirectHandlers).toHaveLength(0)
|
||||
expect((await manager.getModelState(manifest.id)).status).toBe('not-downloaded')
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('settles immediately when the abort signal fires before a response', async () => {
|
||||
vi.useFakeTimers()
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-model-manager-'))
|
||||
try {
|
||||
const errorHandlers: ((err: Error) => void)[] = []
|
||||
const responseHandlers: ((response: unknown) => void)[] = []
|
||||
const redirectHandlers: ((
|
||||
statusCode: number,
|
||||
method: string,
|
||||
redirectUrl: string
|
||||
) => void)[] = []
|
||||
const request = {
|
||||
abort: vi.fn(() => request),
|
||||
on: vi.fn((event: string, cb: (err: Error) => void) => {
|
||||
if (event === 'error') {
|
||||
errorHandlers.push(cb)
|
||||
} else if (event === 'response') {
|
||||
responseHandlers.push(cb as unknown as (response: unknown) => void)
|
||||
} else if (event === 'redirect') {
|
||||
redirectHandlers.push(
|
||||
cb as unknown as (statusCode: number, method: string, redirectUrl: string) => void
|
||||
)
|
||||
}
|
||||
return request
|
||||
}),
|
||||
off: vi.fn((event: string, cb: ((err: Error) => void) | (() => void)) => {
|
||||
if (event === 'error') {
|
||||
const index = errorHandlers.indexOf(cb as (err: Error) => void)
|
||||
if (index !== -1) {
|
||||
errorHandlers.splice(index, 1)
|
||||
}
|
||||
}
|
||||
if (event === 'response') {
|
||||
const index = responseHandlers.indexOf(cb as (response: unknown) => void)
|
||||
if (index !== -1) {
|
||||
responseHandlers.splice(index, 1)
|
||||
}
|
||||
}
|
||||
if (event === 'redirect') {
|
||||
const index = redirectHandlers.indexOf(
|
||||
cb as (statusCode: number, method: string, redirectUrl: string) => void
|
||||
)
|
||||
if (index !== -1) {
|
||||
redirectHandlers.splice(index, 1)
|
||||
}
|
||||
}
|
||||
return request
|
||||
}),
|
||||
end: vi.fn(() => request)
|
||||
}
|
||||
netRequestMock.mockReturnValue(request)
|
||||
const controller = new AbortController()
|
||||
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
|
||||
|
||||
const download = manager.downloadFile(
|
||||
'https://example.com/model.tar.bz2',
|
||||
join(dir, 'model.tar.bz2'),
|
||||
1,
|
||||
'm',
|
||||
() => true,
|
||||
controller.signal
|
||||
)
|
||||
const outcomePromise = download.then(
|
||||
() => 'resolved',
|
||||
(error) => (error instanceof Error ? error.message : String(error))
|
||||
)
|
||||
controller.abort()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
await expect(outcomePromise).resolves.toBe('Aborted')
|
||||
expect(request.abort).toHaveBeenCalled()
|
||||
expect(request.off).toHaveBeenCalledWith('error', expect.any(Function))
|
||||
expect(request.off).toHaveBeenCalledWith('response', expect.any(Function))
|
||||
expect(request.off).toHaveBeenCalledWith('redirect', expect.any(Function))
|
||||
expect(errorHandlers).toHaveLength(0)
|
||||
expect(responseHandlers).toHaveLength(0)
|
||||
expect(redirectHandlers).toHaveLength(0)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('times out a model download request that never responds', async () => {
|
||||
vi.useFakeTimers()
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-model-manager-'))
|
||||
try {
|
||||
const errorHandlers: ((err: Error) => void)[] = []
|
||||
const timeoutHandlers: (() => void)[] = []
|
||||
const responseHandlers: ((response: unknown) => void)[] = []
|
||||
const redirectHandlers: ((
|
||||
statusCode: number,
|
||||
method: string,
|
||||
redirectUrl: string
|
||||
) => void)[] = []
|
||||
const request = {
|
||||
destroy: vi.fn((err?: Error) => {
|
||||
if (err) {
|
||||
queueMicrotask(() => {
|
||||
for (const handler of errorHandlers) {
|
||||
handler(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
return request
|
||||
}),
|
||||
setTimeout: vi.fn((ms: number, cb: () => void) => {
|
||||
timeoutHandlers.push(cb)
|
||||
setTimeout(() => {
|
||||
for (const handler of timeoutHandlers) {
|
||||
handler()
|
||||
}
|
||||
}, ms)
|
||||
return request
|
||||
}),
|
||||
abort: vi.fn(() => request),
|
||||
on: vi.fn((event: string, cb: (err: Error) => void) => {
|
||||
if (event === 'error') {
|
||||
errorHandlers.push(cb)
|
||||
} else if (event === 'response') {
|
||||
responseHandlers.push(cb as unknown as (response: unknown) => void)
|
||||
} else if (event === 'redirect') {
|
||||
redirectHandlers.push(
|
||||
cb as unknown as (statusCode: number, method: string, redirectUrl: string) => void
|
||||
)
|
||||
}
|
||||
return request
|
||||
}),
|
||||
@@ -235,16 +319,25 @@ describe('ModelManager', () => {
|
||||
errorHandlers.splice(index, 1)
|
||||
}
|
||||
}
|
||||
if (event === 'timeout') {
|
||||
const index = timeoutHandlers.indexOf(cb as () => void)
|
||||
if (event === 'response') {
|
||||
const index = responseHandlers.indexOf(cb as (response: unknown) => void)
|
||||
if (index !== -1) {
|
||||
timeoutHandlers.splice(index, 1)
|
||||
responseHandlers.splice(index, 1)
|
||||
}
|
||||
}
|
||||
if (event === 'redirect') {
|
||||
const index = redirectHandlers.indexOf(
|
||||
cb as (statusCode: number, method: string, redirectUrl: string) => void
|
||||
)
|
||||
if (index !== -1) {
|
||||
redirectHandlers.splice(index, 1)
|
||||
}
|
||||
}
|
||||
return request
|
||||
})
|
||||
}),
|
||||
end: vi.fn(() => request)
|
||||
}
|
||||
httpsGetMock.mockReturnValue(request)
|
||||
netRequestMock.mockReturnValue(request)
|
||||
const manager = new ModelManager(dir) as unknown as ModelManagerInternals
|
||||
|
||||
const download = manager.downloadFile(
|
||||
@@ -263,11 +356,13 @@ describe('ModelManager', () => {
|
||||
const outcome = await Promise.race([outcomePromise, Promise.resolve('pending')])
|
||||
|
||||
expect(outcome).toBe('Model download timed out after 120 seconds without network activity')
|
||||
expect(request.destroy).toHaveBeenCalledWith()
|
||||
expect(request.abort).toHaveBeenCalledWith()
|
||||
expect(request.off).toHaveBeenCalledWith('error', expect.any(Function))
|
||||
expect(request.off).toHaveBeenCalledWith('timeout', expect.any(Function))
|
||||
expect(request.off).toHaveBeenCalledWith('response', expect.any(Function))
|
||||
expect(request.off).toHaveBeenCalledWith('redirect', expect.any(Function))
|
||||
expect(errorHandlers).toHaveLength(0)
|
||||
expect(timeoutHandlers).toHaveLength(0)
|
||||
expect(responseHandlers).toHaveLength(0)
|
||||
expect(redirectHandlers).toHaveLength(0)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
/* eslint-disable max-lines -- Why: model download, checksum, extraction, and cleanup share one state machine so progress/error transitions stay coupled. */
|
||||
import { app } from 'electron'
|
||||
import { app, net } from 'electron'
|
||||
import { join, resolve, relative } from 'path'
|
||||
import { existsSync, mkdirSync, createWriteStream, createReadStream, rmSync } from 'fs'
|
||||
import { readdir, rm } from 'fs/promises'
|
||||
import { createHash } from 'crypto'
|
||||
import { get as httpsGet } from 'https'
|
||||
import type { IncomingMessage } from 'http'
|
||||
import { pipeline } from 'stream/promises'
|
||||
import { spawn } from 'child_process'
|
||||
import type {
|
||||
@@ -22,6 +20,12 @@ type DownloadHandle = {
|
||||
}
|
||||
|
||||
type ProgressCallback = (modelId: string, progress: number) => void
|
||||
type DownloadIncomingMessage = Electron.IncomingMessage &
|
||||
NodeJS.ReadableStream & {
|
||||
headers: Record<string, string | string[] | undefined>
|
||||
resume: () => void
|
||||
destroy?: () => void
|
||||
}
|
||||
|
||||
const DOWNLOAD_IDLE_TIMEOUT_MS = 120_000
|
||||
|
||||
@@ -287,16 +291,35 @@ export class ModelManager {
|
||||
}
|
||||
|
||||
let settled = false
|
||||
let request: ReturnType<typeof httpsGet> | null = null
|
||||
let request: Electron.ClientRequest | null = null
|
||||
let idleTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
const onSignalAbort = (): void => {
|
||||
const activeRequest = request
|
||||
rejectOnce(new Error('Aborted'))
|
||||
activeRequest?.abort()
|
||||
}
|
||||
const clearIdleTimeout = (): void => {
|
||||
if (idleTimeout) {
|
||||
clearTimeout(idleTimeout)
|
||||
idleTimeout = null
|
||||
}
|
||||
}
|
||||
const cleanupRequestListeners = (): void => {
|
||||
const activeRequest = request
|
||||
clearIdleTimeout()
|
||||
if (!activeRequest) {
|
||||
return
|
||||
}
|
||||
activeRequest.off('error', onRequestError)
|
||||
activeRequest.off('timeout', onRequestTimeout)
|
||||
activeRequest.off('response', onResponse)
|
||||
activeRequest.off('redirect', onRedirect)
|
||||
signal?.removeEventListener('abort', onSignalAbort)
|
||||
request = null
|
||||
}
|
||||
const resetIdleTimeout = (): void => {
|
||||
clearIdleTimeout()
|
||||
idleTimeout = setTimeout(onRequestTimeout, DOWNLOAD_IDLE_TIMEOUT_MS)
|
||||
}
|
||||
const resolveOnce = (): void => {
|
||||
if (settled) {
|
||||
return
|
||||
@@ -321,62 +344,57 @@ export class ModelManager {
|
||||
`Model download timed out after ${DOWNLOAD_IDLE_TIMEOUT_MS / 1000} seconds without network activity`
|
||||
)
|
||||
)
|
||||
activeRequest?.destroy()
|
||||
activeRequest?.abort()
|
||||
}
|
||||
const onResponse = (response: IncomingMessage): void => {
|
||||
if (
|
||||
response.statusCode === 301 ||
|
||||
response.statusCode === 302 ||
|
||||
response.statusCode === 303 ||
|
||||
response.statusCode === 307 ||
|
||||
response.statusCode === 308
|
||||
) {
|
||||
const redirectUrl = response.headers.location
|
||||
if (!redirectUrl) {
|
||||
response.resume()
|
||||
rejectOnce(new Error('Redirect without location'))
|
||||
return
|
||||
}
|
||||
if (redirectCount >= 5) {
|
||||
response.resume()
|
||||
rejectOnce(new Error('Too many redirects'))
|
||||
return
|
||||
}
|
||||
let resolvedRedirect: URL
|
||||
try {
|
||||
resolvedRedirect = new URL(redirectUrl, parsedUrl)
|
||||
} catch {
|
||||
response.resume()
|
||||
rejectOnce(new Error('Invalid redirect URL'))
|
||||
return
|
||||
}
|
||||
if (resolvedRedirect.protocol !== 'https:') {
|
||||
response.resume()
|
||||
rejectOnce(new Error('Model download redirect must use HTTPS'))
|
||||
return
|
||||
}
|
||||
response.resume()
|
||||
this.downloadFile(
|
||||
resolvedRedirect.toString(),
|
||||
dest,
|
||||
expectedSize,
|
||||
modelId,
|
||||
isAborted,
|
||||
signal,
|
||||
redirectCount + 1
|
||||
)
|
||||
.then(resolveOnce)
|
||||
.catch(rejectOnce)
|
||||
const onRedirect = (_statusCode: number, _method: string, redirectUrl: string): void => {
|
||||
if (redirectCount >= 5) {
|
||||
const activeRequest = request
|
||||
rejectOnce(new Error('Too many redirects'))
|
||||
activeRequest?.abort()
|
||||
return
|
||||
}
|
||||
|
||||
let resolvedRedirect: URL
|
||||
try {
|
||||
resolvedRedirect = new URL(redirectUrl, parsedUrl)
|
||||
} catch {
|
||||
const activeRequest = request
|
||||
rejectOnce(new Error('Invalid redirect URL'))
|
||||
activeRequest?.abort()
|
||||
return
|
||||
}
|
||||
if (resolvedRedirect.protocol !== 'https:') {
|
||||
const activeRequest = request
|
||||
rejectOnce(new Error('Model download redirect must use HTTPS'))
|
||||
activeRequest?.abort()
|
||||
return
|
||||
}
|
||||
const activeRequest = request
|
||||
cleanupRequestListeners()
|
||||
activeRequest?.abort()
|
||||
this.downloadFile(
|
||||
resolvedRedirect.toString(),
|
||||
dest,
|
||||
expectedSize,
|
||||
modelId,
|
||||
isAborted,
|
||||
signal,
|
||||
redirectCount + 1
|
||||
)
|
||||
.then(resolveOnce)
|
||||
.catch(rejectOnce)
|
||||
}
|
||||
const onResponse = (incoming: Electron.IncomingMessage): void => {
|
||||
const response = incoming as DownloadIncomingMessage
|
||||
if (response.statusCode !== 200) {
|
||||
response.resume()
|
||||
rejectOnce(new Error(`HTTP ${response.statusCode}`))
|
||||
return
|
||||
}
|
||||
|
||||
const totalSize = parseInt(response.headers['content-length'] || '0', 10) || expectedSize
|
||||
const contentLength = response.headers['content-length']
|
||||
const totalSize =
|
||||
parseInt(Array.isArray(contentLength) ? contentLength[0] : contentLength || '0', 10) ||
|
||||
expectedSize
|
||||
let downloaded = 0
|
||||
|
||||
const fileStream = createWriteStream(dest)
|
||||
@@ -385,9 +403,10 @@ export class ModelManager {
|
||||
response.off('data', onResponseData)
|
||||
}
|
||||
const onResponseData = (chunk: Buffer): void => {
|
||||
resetIdleTimeout()
|
||||
if (isAborted()) {
|
||||
request?.destroy(new Error('Aborted'))
|
||||
response.destroy()
|
||||
request?.abort()
|
||||
response.destroy?.()
|
||||
fileStream.destroy()
|
||||
return
|
||||
}
|
||||
@@ -412,15 +431,18 @@ export class ModelManager {
|
||||
})
|
||||
}
|
||||
|
||||
request = signal
|
||||
? httpsGet(parsedUrl, { signal }, onResponse)
|
||||
: httpsGet(parsedUrl, onResponse)
|
||||
request = net.request({ method: 'GET', url: parsedUrl.toString() })
|
||||
|
||||
// Why: cancellation only helps after the user presses cancel; a peer
|
||||
// that accepts the socket and goes silent must not leave the model stuck
|
||||
// in "downloading" forever.
|
||||
request.setTimeout(DOWNLOAD_IDLE_TIMEOUT_MS, onRequestTimeout)
|
||||
// Why: Electron's net stack honors app proxy settings, unlike Node's
|
||||
// https client, but it does not expose request.setTimeout().
|
||||
resetIdleTimeout()
|
||||
request.on('error', onRequestError)
|
||||
request.on('response', onResponse)
|
||||
request.on('redirect', onRedirect)
|
||||
if (signal) {
|
||||
signal.addEventListener('abort', onSignalAbort, { once: true })
|
||||
}
|
||||
request.end()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -655,7 +655,13 @@ export class SshRelaySession {
|
||||
)
|
||||
)
|
||||
: {}
|
||||
return await runRemoteOrcaCli(this.runtime, { argv, cwd, env })
|
||||
const stdin = typeof params.stdin === 'string' ? params.stdin : undefined
|
||||
return await runRemoteOrcaCli(this.runtime, {
|
||||
argv,
|
||||
cwd,
|
||||
env,
|
||||
...(stdin !== undefined ? { stdin } : {})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import type { LinearIssueContextResult, LinearSearchResult } from '../../shared/linear-agent-access'
|
||||
import type {
|
||||
LinearAttachResult,
|
||||
LinearCommentAddResult,
|
||||
LinearCreateResult,
|
||||
LinearIssueContextResult,
|
||||
LinearSearchResult,
|
||||
LinearStatusSetResult
|
||||
} from '../../shared/linear-agent-access'
|
||||
import type { CliStatusResult } from '../../shared/runtime-types'
|
||||
import type { RpcResponse } from '../runtime/rpc/core'
|
||||
|
||||
export function formatRemoteCli(response: RpcResponse): { stdout: string; stderr: string } {
|
||||
if (!response.ok) {
|
||||
return { stdout: '', stderr: `${response.error.message}\n` }
|
||||
return { stdout: '', stderr: `${formatRemoteCliError(response.error)}\n` }
|
||||
}
|
||||
const result = response.result
|
||||
if (isRecord(result) && 'app' in result && 'runtime' in result && 'graph' in result) {
|
||||
@@ -23,9 +30,32 @@ export function formatRemoteCli(response: RpcResponse): { stdout: string; stderr
|
||||
stderr: linearSearchWarnings(result)
|
||||
}
|
||||
}
|
||||
if (isLinearStatusSetResult(result)) {
|
||||
return { stdout: `${formatLinearStatusSet(result)}\n`, stderr: '' }
|
||||
}
|
||||
if (isLinearCommentAddResult(result)) {
|
||||
return { stdout: `${formatLinearCommentAdd(result)}\n`, stderr: '' }
|
||||
}
|
||||
if (isLinearAttachResult(result)) {
|
||||
return { stdout: `${formatLinearAttach(result)}\n`, stderr: '' }
|
||||
}
|
||||
if (isLinearCreateResult(result)) {
|
||||
return { stdout: `${formatLinearCreate(result)}\n`, stderr: '' }
|
||||
}
|
||||
return { stdout: `${JSON.stringify(result)}\n`, stderr: '' }
|
||||
}
|
||||
|
||||
function formatRemoteCliError(error: { message: string; data?: unknown }): string {
|
||||
const nextSteps =
|
||||
isRecord(error.data) && Array.isArray(error.data.nextSteps)
|
||||
? error.data.nextSteps.filter((step): step is string => typeof step === 'string')
|
||||
: []
|
||||
if (nextSteps.length === 0) {
|
||||
return error.message
|
||||
}
|
||||
return `${error.message}\n${nextSteps.map((step) => `Next step: ${step}`).join('\n')}`
|
||||
}
|
||||
|
||||
function formatStatusResult(status: CliStatusResult): { stdout: string; stderr: string } {
|
||||
return {
|
||||
stdout: `${[
|
||||
@@ -70,6 +100,50 @@ function isLinearSearchResult(result: unknown): result is LinearSearchResult {
|
||||
)
|
||||
}
|
||||
|
||||
function isLinearStatusSetResult(result: unknown): result is LinearStatusSetResult {
|
||||
return (
|
||||
isRecord(result) &&
|
||||
isRecord(result.issue) &&
|
||||
isRecord(result.state) &&
|
||||
isRecord(result.meta) &&
|
||||
typeof result.state.name === 'string' &&
|
||||
typeof result.meta.alreadyInState === 'boolean'
|
||||
)
|
||||
}
|
||||
|
||||
function isLinearCommentAddResult(result: unknown): result is LinearCommentAddResult {
|
||||
return (
|
||||
isRecord(result) &&
|
||||
isRecord(result.comment) &&
|
||||
isRecord(result.issue) &&
|
||||
isRecord(result.meta) &&
|
||||
typeof result.comment.id === 'string' &&
|
||||
typeof result.meta.bodyChars === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
function isLinearAttachResult(result: unknown): result is LinearAttachResult {
|
||||
return (
|
||||
isRecord(result) &&
|
||||
isRecord(result.attachment) &&
|
||||
isRecord(result.issue) &&
|
||||
isRecord(result.meta) &&
|
||||
typeof result.attachment.title === 'string' &&
|
||||
typeof result.attachment.url === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function isLinearCreateResult(result: unknown): result is LinearCreateResult {
|
||||
return (
|
||||
isRecord(result) &&
|
||||
isRecord(result.issue) &&
|
||||
isRecord(result.meta) &&
|
||||
typeof result.issue.identifier === 'string' &&
|
||||
typeof result.issue.title === 'string' &&
|
||||
typeof result.meta.writeId === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object'
|
||||
}
|
||||
@@ -112,6 +186,27 @@ function formatLinearSearch(result: LinearSearchResult): string {
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function formatLinearStatusSet(result: LinearStatusSetResult): string {
|
||||
const suffix = result.meta.alreadyInState ? ' (already set)' : ''
|
||||
return `Set ${result.issue.identifier} to ${result.state.name}${suffix}.`
|
||||
}
|
||||
|
||||
function formatLinearCommentAdd(result: LinearCommentAddResult): string {
|
||||
const suffix = result.meta.deduplicated ? ' (already posted)' : ''
|
||||
return `Added comment ${result.comment.id} to ${result.issue.identifier}${suffix}.`
|
||||
}
|
||||
|
||||
function formatLinearAttach(result: LinearAttachResult): string {
|
||||
const suffix = result.meta.deduplicated ? ' (already attached)' : ''
|
||||
return `Attached ${result.attachment.title} to ${result.issue.identifier}${suffix}.`
|
||||
}
|
||||
|
||||
function formatLinearCreate(result: LinearCreateResult): string {
|
||||
const parent = result.issue.parent ? ` under ${result.issue.parent.identifier}` : ''
|
||||
const suffix = result.meta.deduplicated ? ' (already created)' : ''
|
||||
return `Created ${result.issue.identifier}${parent}: ${result.issue.title}${suffix}.`
|
||||
}
|
||||
|
||||
function linearIssueWarnings(result: LinearIssueContextResult): string {
|
||||
const warnings: string[] = []
|
||||
for (const error of result.meta.includeErrors) {
|
||||
|
||||
@@ -50,6 +50,63 @@ function createRuntime() {
|
||||
partial: false,
|
||||
workspaceErrors: []
|
||||
}
|
||||
})),
|
||||
linearIssueSetState: vi.fn(async (request: unknown) => ({
|
||||
request,
|
||||
issue: {
|
||||
id: 'issue-1',
|
||||
identifier: 'ENG-123',
|
||||
url: 'https://linear.app/acme/issue/ENG-123'
|
||||
},
|
||||
state: { id: 'state-review', name: 'In Review', type: 'started' },
|
||||
previousState: { id: 'state-started', name: 'In Progress' },
|
||||
meta: { workspaceId: 'workspace-1', alreadyInState: false }
|
||||
})),
|
||||
linearIssueAddComment: vi.fn(async (request: unknown) => ({
|
||||
request,
|
||||
comment: { id: 'comment-1', url: null, parentId: null },
|
||||
issue: {
|
||||
id: 'issue-1',
|
||||
identifier: 'ENG-123',
|
||||
url: 'https://linear.app/acme/issue/ENG-123'
|
||||
},
|
||||
meta: {
|
||||
workspaceId: 'workspace-1',
|
||||
bodyChars: 4,
|
||||
writeId: '123e4567-e89b-12d3-a456-426614174000',
|
||||
deduplicated: false
|
||||
}
|
||||
})),
|
||||
linearIssueAttachLink: vi.fn(async (request: unknown) => ({
|
||||
request,
|
||||
attachment: { id: 'attachment-1', title: 'PR/MR link', url: 'https://example.com/review/1' },
|
||||
issue: {
|
||||
id: 'issue-1',
|
||||
identifier: 'ENG-123',
|
||||
url: 'https://linear.app/acme/issue/ENG-123'
|
||||
},
|
||||
meta: {
|
||||
workspaceId: 'workspace-1',
|
||||
writeId: '123e4567-e89b-12d3-a456-426614174000',
|
||||
deduplicated: false
|
||||
}
|
||||
})),
|
||||
linearIssueCreate: vi.fn(async (request: unknown) => ({
|
||||
request,
|
||||
issue: {
|
||||
id: 'issue-2',
|
||||
identifier: 'ENG-456',
|
||||
title: 'Follow-up',
|
||||
url: 'https://linear.app/acme/issue/ENG-456',
|
||||
team: { id: 'team-1', key: 'ENG', name: 'Engineering' },
|
||||
state: { id: 'state-triage', name: 'Triage' },
|
||||
parent: { id: 'issue-1', identifier: 'ENG-123' }
|
||||
},
|
||||
meta: {
|
||||
workspaceId: 'workspace-1',
|
||||
writeId: '123e4567-e89b-12d3-a456-426614174000',
|
||||
deduplicated: false
|
||||
}
|
||||
}))
|
||||
} as unknown as OrcaRuntimeService
|
||||
return runtime
|
||||
@@ -129,6 +186,110 @@ describe('runRemoteOrcaCli Linear commands', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('dispatches Linear status writes through the remote runtime with SSH context hints', async () => {
|
||||
const runtime = createRuntime()
|
||||
|
||||
const result = await runRemoteOrcaCli(runtime, {
|
||||
argv: ['linear', 'status', 'set', 'ENG-123', '--to', 'In Review', '--json'],
|
||||
cwd: '/home/alice/remote-repo',
|
||||
env: {
|
||||
ORCA_TERMINAL_HANDLE: 'term_ssh',
|
||||
ORCA_WORKTREE_ID: 'repo::remote'
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
const payload = JSON.parse(result.stdout) as {
|
||||
ok: boolean
|
||||
result: { request: { input: string; to: string; context: Record<string, unknown> } }
|
||||
}
|
||||
expect(payload.ok).toBe(true)
|
||||
expect(payload.result.request).toMatchObject({
|
||||
input: 'ENG-123',
|
||||
to: 'In Review',
|
||||
context: {
|
||||
remote: true,
|
||||
terminalHandle: 'term_ssh',
|
||||
worktreeId: 'repo::remote'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('formats SSH Linear writes in non-json mode', async () => {
|
||||
const runtime = createRuntime()
|
||||
|
||||
const result = await runRemoteOrcaCli(runtime, {
|
||||
argv: ['linear', 'comment', 'add', 'ENG-123', '--body', 'Done'],
|
||||
cwd: '/home/alice/remote-repo',
|
||||
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
|
||||
})
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toBe('Added comment comment-1 to ENG-123.\n')
|
||||
expect(result.stderr).toBe('')
|
||||
})
|
||||
|
||||
it('dispatches body-file stdin writes in the SSH shim', async () => {
|
||||
const runtime = createRuntime()
|
||||
|
||||
const result = await runRemoteOrcaCli(runtime, {
|
||||
argv: ['linear', 'comment', 'add', '--current', '--body-file', '-', '--json'],
|
||||
cwd: '/home/alice/remote-repo',
|
||||
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' },
|
||||
stdin: 'line one\nline two\n'
|
||||
})
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
const payload = JSON.parse(result.stdout) as {
|
||||
ok: boolean
|
||||
result: { request: { body: string } }
|
||||
}
|
||||
expect(payload.ok).toBe(true)
|
||||
expect(payload.result.request.body).toBe('line one\nline two\n')
|
||||
})
|
||||
|
||||
it('rejects body-file stdin writes when SSH stdin is unavailable', async () => {
|
||||
const runtime = createRuntime()
|
||||
|
||||
const result = await runRemoteOrcaCli(runtime, {
|
||||
argv: ['linear', 'comment', 'add', '--current', '--body-file', '-', '--json'],
|
||||
cwd: '/home/alice/remote-repo',
|
||||
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
|
||||
})
|
||||
|
||||
expect(result.exitCode).toBe(1)
|
||||
const payload = JSON.parse(result.stdout) as {
|
||||
ok: boolean
|
||||
error: { code: string; message: string }
|
||||
}
|
||||
expect(payload.ok).toBe(false)
|
||||
expect(payload.error).toMatchObject({
|
||||
code: 'invalid_argument',
|
||||
message: 'SSH Linear writes require stdin when using --body-file -.'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects remote body-file paths in the SSH shim before dispatch', async () => {
|
||||
const runtime = createRuntime()
|
||||
|
||||
const result = await runRemoteOrcaCli(runtime, {
|
||||
argv: ['linear', 'comment', 'add', '--current', '--body-file', 'body.md', '--json'],
|
||||
cwd: '/home/alice/remote-repo',
|
||||
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
|
||||
})
|
||||
|
||||
expect(result.exitCode).toBe(1)
|
||||
const payload = JSON.parse(result.stdout) as {
|
||||
ok: boolean
|
||||
error: { code: string; message: string }
|
||||
}
|
||||
expect(payload.ok).toBe(false)
|
||||
expect(payload.error).toMatchObject({
|
||||
code: 'invalid_argument',
|
||||
message: 'SSH Linear writes only support --body-file - for stdin.'
|
||||
})
|
||||
})
|
||||
|
||||
it('formats SSH Linear issue reads in non-json mode', async () => {
|
||||
const runtime = createRuntime()
|
||||
|
||||
@@ -223,6 +384,30 @@ describe('runRemoteOrcaCli Linear commands', () => {
|
||||
expect(result.stderr).toContain('Linear is not connected.')
|
||||
})
|
||||
|
||||
it('prints SSH Linear non-json next steps from structured errors', async () => {
|
||||
const runtime = createRuntime()
|
||||
const linearIssueAddComment = (
|
||||
runtime as unknown as { linearIssueAddComment: ReturnType<typeof vi.fn> }
|
||||
).linearIssueAddComment
|
||||
linearIssueAddComment.mockRejectedValueOnce(
|
||||
Object.assign(new Error('Linear may have applied the write.'), {
|
||||
code: 'linear_write_unconfirmed',
|
||||
data: { nextSteps: ['Retry once with the pinned command: `orca linear comment add`.'] }
|
||||
})
|
||||
)
|
||||
|
||||
const result = await runRemoteOrcaCli(runtime, {
|
||||
argv: ['linear', 'comment', 'add', 'ENG-123', '--body', 'Done'],
|
||||
cwd: '/home/alice/remote-repo',
|
||||
env: { ORCA_TERMINAL_HANDLE: 'term_ssh' }
|
||||
})
|
||||
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stdout).toBe('')
|
||||
expect(result.stderr).toContain('Linear may have applied the write.')
|
||||
expect(result.stderr).toContain('Next step: Retry once with the pinned command')
|
||||
})
|
||||
|
||||
it('shows SSH Linear command help without dispatching to the runtime', async () => {
|
||||
const runtime = createRuntime()
|
||||
const linearIssueContext = (
|
||||
@@ -257,6 +442,7 @@ describe('runRemoteOrcaCli Linear commands', () => {
|
||||
expect(result.stdout).toContain('orca linear')
|
||||
expect(result.stdout).toContain('Usage: orca linear <command> [options]')
|
||||
expect(result.stdout).toContain('search')
|
||||
expect(result.stdout).toContain('comment add')
|
||||
expect(linearIssueContext).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -6,6 +6,11 @@ import {
|
||||
} from '../../shared/linear-agent-access'
|
||||
import type { RpcDispatcher } from '../runtime/rpc/dispatcher'
|
||||
import type { RpcResponse } from '../runtime/rpc/core'
|
||||
import { getRemoteLinearReadHelp } from './ssh-remote-linear-read-help'
|
||||
import {
|
||||
getRemoteLinearWriteHelp,
|
||||
tryDispatchRemoteLinearWriteCli
|
||||
} from './ssh-remote-linear-write-cli'
|
||||
|
||||
type ParsedRemoteCli = {
|
||||
commandPath: string[]
|
||||
@@ -27,16 +32,11 @@ export function getRemoteLinearHelp(parsed: ParsedRemoteCli): string | null {
|
||||
if (!helpPath) {
|
||||
return null
|
||||
}
|
||||
if (helpPath.length === 1 && helpPath[0] === 'linear') {
|
||||
return LINEAR_HELP
|
||||
const readHelp = getRemoteLinearReadHelp(helpPath)
|
||||
if (readHelp) {
|
||||
return readHelp
|
||||
}
|
||||
if (matchesRemoteCommand(helpPath, 'linear', 'issue')) {
|
||||
return LINEAR_ISSUE_HELP
|
||||
}
|
||||
if (matchesRemoteCommand(helpPath, 'linear', 'search')) {
|
||||
return LINEAR_SEARCH_HELP
|
||||
}
|
||||
return null
|
||||
return getRemoteLinearWriteHelp({ ...parsed, commandPath: helpPath })
|
||||
}
|
||||
|
||||
function remoteLinearHelpPath(parsed: ParsedRemoteCli): string[] | null {
|
||||
@@ -49,61 +49,6 @@ function remoteLinearHelpPath(parsed: ParsedRemoteCli): string[] | null {
|
||||
return null
|
||||
}
|
||||
|
||||
const LINEAR_HELP = `orca linear
|
||||
|
||||
Usage: orca linear <command> [options]
|
||||
|
||||
Commands:
|
||||
issue Read Linear issue context for agents
|
||||
search Search connected Linear workspaces
|
||||
|
||||
Run \`orca linear <command> --help\` for command-specific usage.`
|
||||
|
||||
const LINEAR_ISSUE_HELP = `orca linear issue
|
||||
|
||||
Usage: orca linear issue [<id>] [--current] [--comments] [--children] [--depth <n>] [--attachments] [--relations] [--full] [--workspace <id>] [--json]
|
||||
|
||||
Read Linear issue context for agents
|
||||
|
||||
Options:
|
||||
--help Show this help message
|
||||
--json Emit machine-readable JSON
|
||||
--pairing-code
|
||||
--environment
|
||||
--current Use the current Orca worktree linked Linear issue
|
||||
--comments Include threaded Linear comments
|
||||
--children Include recursive child issues
|
||||
--depth <n> Child issue depth for --children/--full
|
||||
--attachments Include attachment metadata and URLs
|
||||
--relations Include blocking, related, and duplicate links
|
||||
--full Include all supported V1 issue context within caps
|
||||
--workspace <id> Connected Linear workspace id
|
||||
--id <id> Linear issue key, id, or URL
|
||||
|
||||
Examples:
|
||||
$ orca linear issue ENG-123
|
||||
$ orca linear issue --current --comments
|
||||
$ orca linear issue https://linear.app/acme/issue/ENG-123 --full --json`
|
||||
|
||||
const LINEAR_SEARCH_HELP = `orca linear search
|
||||
|
||||
Usage: orca linear search <query> [--limit <n>] [--workspace <id>|all] [--json]
|
||||
|
||||
Search connected Linear workspaces
|
||||
|
||||
Options:
|
||||
--help Show this help message
|
||||
--json Emit machine-readable JSON
|
||||
--pairing-code
|
||||
--environment
|
||||
--limit <n> Maximum number of rows to return
|
||||
--workspace <id|all> Connected Linear workspace id, or all
|
||||
--query <text> Text to search across Linear issues
|
||||
|
||||
Examples:
|
||||
$ orca linear search "auth bug"
|
||||
$ orca linear search ENG --workspace all --json`
|
||||
|
||||
const LINEAR_ISSUE_FLAGS = new Set([
|
||||
'help',
|
||||
'json',
|
||||
@@ -132,7 +77,8 @@ const LINEAR_SEARCH_FLAGS = new Set([
|
||||
export async function tryDispatchRemoteLinearCli(
|
||||
dispatcher: RpcDispatcher,
|
||||
parsed: ParsedRemoteCli,
|
||||
env: Record<string, string>
|
||||
env: Record<string, string>,
|
||||
stdin?: string
|
||||
): Promise<RpcResponse | null> {
|
||||
if (isRemoteCommand(parsed, 'linear', 'issue')) {
|
||||
validateLinearRemoteArgs(parsed, {
|
||||
@@ -156,6 +102,10 @@ export async function tryDispatchRemoteLinearCli(
|
||||
workspaceId: optionalString(parsed.flags, 'workspace')
|
||||
})
|
||||
}
|
||||
const writeResponse = await tryDispatchRemoteLinearWriteCli(dispatcher, parsed, env, stdin)
|
||||
if (writeResponse) {
|
||||
return writeResponse
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -196,13 +146,6 @@ function isRemoteCommand(parsed: ParsedRemoteCli, ...command: string[]): boolean
|
||||
return command.every((part, index) => parsed.commandPath[index] === part)
|
||||
}
|
||||
|
||||
function matchesRemoteCommand(commandPath: string[], ...command: string[]): boolean {
|
||||
return (
|
||||
commandPath.length === command.length &&
|
||||
command.every((part, index) => commandPath[index] === part)
|
||||
)
|
||||
}
|
||||
|
||||
function remotePositional(parsed: ParsedRemoteCli, startIndex: number): string | undefined {
|
||||
const value = parsed.commandPath.slice(startIndex).join(' ').trim()
|
||||
return value || undefined
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
export function getRemoteLinearReadHelp(commandPath: string[]): string | null {
|
||||
if (commandPath.length === 1 && commandPath[0] === 'linear') {
|
||||
return LINEAR_HELP
|
||||
}
|
||||
if (matchesRemoteCommand(commandPath, 'linear', 'issue')) {
|
||||
return LINEAR_ISSUE_HELP
|
||||
}
|
||||
if (matchesRemoteCommand(commandPath, 'linear', 'search')) {
|
||||
return LINEAR_SEARCH_HELP
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function matchesRemoteCommand(commandPath: string[], ...command: string[]): boolean {
|
||||
return (
|
||||
commandPath.length === command.length &&
|
||||
command.every((part, index) => commandPath[index] === part)
|
||||
)
|
||||
}
|
||||
|
||||
const LINEAR_HELP = `orca linear
|
||||
|
||||
Usage: orca linear <command> [options]
|
||||
|
||||
Commands:
|
||||
issue Read Linear issue context for agents
|
||||
search Search connected Linear workspaces
|
||||
status set Set a Linear issue status
|
||||
comment add Add a comment to a Linear issue
|
||||
attach Attach a link to a Linear issue
|
||||
create Create a Linear issue
|
||||
|
||||
Run \`orca linear <command> --help\` for command-specific usage.`
|
||||
|
||||
const LINEAR_ISSUE_HELP = `orca linear issue
|
||||
|
||||
Usage: orca linear issue [<id>] [--current] [--comments] [--children] [--depth <n>] [--attachments] [--relations] [--full] [--workspace <id>] [--json]
|
||||
|
||||
Read Linear issue context for agents
|
||||
|
||||
Options:
|
||||
--help Show this help message
|
||||
--json Emit machine-readable JSON
|
||||
--pairing-code
|
||||
--environment
|
||||
--current Use the current Orca worktree linked Linear issue
|
||||
--comments Include threaded Linear comments
|
||||
--children Include recursive child issues
|
||||
--depth <n> Child issue depth for --children/--full
|
||||
--attachments Include attachment metadata and URLs
|
||||
--relations Include blocking, related, and duplicate links
|
||||
--full Include all supported V1 issue context within caps
|
||||
--workspace <id> Connected Linear workspace id
|
||||
--id <id> Linear issue key, id, or URL
|
||||
|
||||
Examples:
|
||||
$ orca linear issue ENG-123
|
||||
$ orca linear issue --current --comments
|
||||
$ orca linear issue https://linear.app/acme/issue/ENG-123 --full --json`
|
||||
|
||||
const LINEAR_SEARCH_HELP = `orca linear search
|
||||
|
||||
Usage: orca linear search <query> [--limit <n>] [--workspace <id>|all] [--json]
|
||||
|
||||
Search connected Linear workspaces
|
||||
|
||||
Options:
|
||||
--help Show this help message
|
||||
--json Emit machine-readable JSON
|
||||
--pairing-code
|
||||
--environment
|
||||
--limit <n> Maximum number of rows to return
|
||||
--workspace <id|all> Connected Linear workspace id, or all
|
||||
--query <text> Text to search across Linear issues
|
||||
|
||||
Examples:
|
||||
$ orca linear search "auth bug"
|
||||
$ orca linear search ENG --workspace all --json`
|
||||
@@ -0,0 +1,310 @@
|
||||
import type { RpcResponse } from '../runtime/rpc/core'
|
||||
import type { RpcDispatcher } from '../runtime/rpc/dispatcher'
|
||||
|
||||
type ParsedRemoteCli = {
|
||||
commandPath: string[]
|
||||
flags: Map<string, string | boolean>
|
||||
}
|
||||
|
||||
class RemoteLinearWriteArgumentError extends Error {
|
||||
readonly code: string
|
||||
|
||||
constructor(code: string, message: string) {
|
||||
super(message)
|
||||
this.name = 'RemoteLinearWriteArgumentError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
const LINEAR_WRITE_FLAGS = new Set(['help', 'json', 'pairing-code', 'environment', 'workspace'])
|
||||
const LINEAR_TARGET_WRITE_FLAGS = new Set([...LINEAR_WRITE_FLAGS, 'current', 'id'])
|
||||
const LINEAR_STATUS_FLAGS = new Set([...LINEAR_TARGET_WRITE_FLAGS, 'to'])
|
||||
const LINEAR_COMMENT_FLAGS = new Set([
|
||||
...LINEAR_TARGET_WRITE_FLAGS,
|
||||
'body',
|
||||
'body-file',
|
||||
'reply-to',
|
||||
'write-id'
|
||||
])
|
||||
const LINEAR_ATTACH_FLAGS = new Set([...LINEAR_TARGET_WRITE_FLAGS, 'url', 'title', 'write-id'])
|
||||
const LINEAR_CREATE_FLAGS = new Set([
|
||||
...LINEAR_WRITE_FLAGS,
|
||||
'title',
|
||||
'body',
|
||||
'body-file',
|
||||
'team',
|
||||
'parent',
|
||||
'parent-current',
|
||||
'write-id'
|
||||
])
|
||||
|
||||
export function getRemoteLinearWriteHelp(parsed: ParsedRemoteCli): string | null {
|
||||
const path = parsed.commandPath
|
||||
if (matchesRemoteCommand(path, 'linear', 'status', 'set')) {
|
||||
return LINEAR_STATUS_HELP
|
||||
}
|
||||
if (matchesRemoteCommand(path, 'linear', 'comment', 'add')) {
|
||||
return LINEAR_COMMENT_HELP
|
||||
}
|
||||
if (matchesRemoteCommand(path, 'linear', 'attach')) {
|
||||
return LINEAR_ATTACH_HELP
|
||||
}
|
||||
if (matchesRemoteCommand(path, 'linear', 'create')) {
|
||||
return LINEAR_CREATE_HELP
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function tryDispatchRemoteLinearWriteCli(
|
||||
dispatcher: RpcDispatcher,
|
||||
parsed: ParsedRemoteCli,
|
||||
env: Record<string, string>,
|
||||
stdin?: string
|
||||
): Promise<RpcResponse | null> {
|
||||
if (isRemoteCommand(parsed, 'linear', 'status', 'set')) {
|
||||
validateLinearRemoteArgs(parsed, LINEAR_STATUS_FLAGS, ['linear', 'status', 'set'], 1, 'id')
|
||||
return await call(dispatcher, 'linear.issueSetState', {
|
||||
...buildRemoteTargetRequest(parsed, env, 3),
|
||||
to: requiredString(parsed.flags, 'to')
|
||||
})
|
||||
}
|
||||
if (isRemoteCommand(parsed, 'linear', 'comment', 'add')) {
|
||||
validateLinearRemoteArgs(parsed, LINEAR_COMMENT_FLAGS, ['linear', 'comment', 'add'], 1, 'id')
|
||||
return await call(dispatcher, 'linear.issueAddComment', {
|
||||
...buildRemoteTargetRequest(parsed, env, 3),
|
||||
body: readRemoteBody(parsed.flags, true, stdin),
|
||||
replyTo: optionalString(parsed.flags, 'reply-to'),
|
||||
writeId: optionalWriteId(parsed.flags)
|
||||
})
|
||||
}
|
||||
if (isRemoteCommand(parsed, 'linear', 'attach')) {
|
||||
validateLinearRemoteArgs(parsed, LINEAR_ATTACH_FLAGS, ['linear', 'attach'], 1, 'id')
|
||||
return await call(dispatcher, 'linear.issueAttachLink', {
|
||||
...buildRemoteTargetRequest(parsed, env, 2),
|
||||
url: requiredHttpUrl(parsed.flags, 'url'),
|
||||
title: optionalString(parsed.flags, 'title'),
|
||||
writeId: optionalWriteId(parsed.flags)
|
||||
})
|
||||
}
|
||||
if (isRemoteCommand(parsed, 'linear', 'create')) {
|
||||
validateLinearRemoteArgs(parsed, LINEAR_CREATE_FLAGS, ['linear', 'create'], 0, 'id')
|
||||
rejectAllWorkspaceForWrite(parsed.flags)
|
||||
const parentInput = optionalString(parsed.flags, 'parent')
|
||||
const parentCurrent = parsed.flags.get('parent-current') === true
|
||||
if (parentInput && parentCurrent) {
|
||||
throw new RemoteLinearWriteArgumentError(
|
||||
'invalid_argument',
|
||||
'Use either --parent or --parent-current, not both'
|
||||
)
|
||||
}
|
||||
const body = readRemoteBody(parsed.flags, false, stdin)
|
||||
return await call(dispatcher, 'linear.issueCreate', {
|
||||
title: requiredString(parsed.flags, 'title'),
|
||||
...(body !== undefined ? { body } : {}),
|
||||
teamKey: optionalString(parsed.flags, 'team'),
|
||||
parentInput,
|
||||
parentCurrent,
|
||||
workspaceId: optionalString(parsed.flags, 'workspace'),
|
||||
writeId: optionalWriteId(parsed.flags),
|
||||
context: buildRemoteContext(env)
|
||||
})
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function buildRemoteTargetRequest(
|
||||
parsed: ParsedRemoteCli,
|
||||
env: Record<string, string>,
|
||||
positionalStart: number
|
||||
): Record<string, unknown> {
|
||||
rejectAllWorkspaceForWrite(parsed.flags)
|
||||
const input = optionalString(parsed.flags, 'id') ?? remotePositional(parsed, positionalStart)
|
||||
const current = parsed.flags.get('current') === true
|
||||
if (input && current) {
|
||||
throw new RemoteLinearWriteArgumentError(
|
||||
'invalid_argument',
|
||||
'Pass either <id> or --current, not both'
|
||||
)
|
||||
}
|
||||
if (!input && !current) {
|
||||
throw new RemoteLinearWriteArgumentError(
|
||||
'linear_issue_required',
|
||||
'Pass a Linear issue id or --current'
|
||||
)
|
||||
}
|
||||
return {
|
||||
input,
|
||||
current,
|
||||
workspaceId: optionalString(parsed.flags, 'workspace'),
|
||||
context: buildRemoteContext(env)
|
||||
}
|
||||
}
|
||||
|
||||
function buildRemoteContext(env: Record<string, string>): Record<string, unknown> {
|
||||
return {
|
||||
remote: true,
|
||||
...(env.ORCA_WORKTREE_ID ? { worktreeId: env.ORCA_WORKTREE_ID } : {}),
|
||||
...(env.ORCA_TERMINAL_HANDLE ? { terminalHandle: env.ORCA_TERMINAL_HANDLE } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function readRemoteBody(
|
||||
flags: Map<string, string | boolean>,
|
||||
required: boolean,
|
||||
stdin?: string
|
||||
): string | undefined {
|
||||
const hasBody = flags.has('body')
|
||||
const hasBodyFile = flags.has('body-file')
|
||||
if (hasBody && hasBodyFile) {
|
||||
throw new RemoteLinearWriteArgumentError(
|
||||
'invalid_argument',
|
||||
'Use either --body or --body-file, not both'
|
||||
)
|
||||
}
|
||||
if (hasBodyFile) {
|
||||
const path = requiredString(flags, 'body-file')
|
||||
if (path !== '-') {
|
||||
throw new RemoteLinearWriteArgumentError(
|
||||
'invalid_argument',
|
||||
'SSH Linear writes only support --body-file - for stdin.'
|
||||
)
|
||||
}
|
||||
if (stdin === undefined) {
|
||||
throw new RemoteLinearWriteArgumentError(
|
||||
'invalid_argument',
|
||||
'SSH Linear writes require stdin when using --body-file -.'
|
||||
)
|
||||
}
|
||||
return stdin
|
||||
}
|
||||
if (!hasBody) {
|
||||
if (required) {
|
||||
throw new RemoteLinearWriteArgumentError('invalid_argument', 'Missing --body or --body-file')
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
return requiredStringAllowingEmpty(flags, 'body')
|
||||
}
|
||||
|
||||
function rejectAllWorkspaceForWrite(flags: Map<string, string | boolean>): void {
|
||||
if (optionalString(flags, 'workspace') === 'all') {
|
||||
throw new RemoteLinearWriteArgumentError(
|
||||
'linear_invalid_workspace',
|
||||
'--workspace all is not valid for Linear writes'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function optionalWriteId(flags: Map<string, string | boolean>): string | undefined {
|
||||
if (!flags.has('write-id')) {
|
||||
return undefined
|
||||
}
|
||||
const writeId = requiredString(flags, 'write-id')
|
||||
if (!UUID_PATTERN.test(writeId)) {
|
||||
throw new RemoteLinearWriteArgumentError('linear_invalid_write_id', '--write-id must be a UUID')
|
||||
}
|
||||
return writeId
|
||||
}
|
||||
|
||||
function requiredHttpUrl(flags: Map<string, string | boolean>, name: string): string {
|
||||
const value = requiredString(flags, name)
|
||||
try {
|
||||
const parsed = new URL(value)
|
||||
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
|
||||
return value
|
||||
}
|
||||
} catch {
|
||||
// Fall through to stable Linear validation error.
|
||||
}
|
||||
throw new RemoteLinearWriteArgumentError(
|
||||
'linear_invalid_url',
|
||||
'--url must be an absolute http(s) URL'
|
||||
)
|
||||
}
|
||||
|
||||
function validateLinearRemoteArgs(
|
||||
parsed: ParsedRemoteCli,
|
||||
allowedFlags: ReadonlySet<string>,
|
||||
command: string[],
|
||||
maxPositionals: number,
|
||||
positionalFlag: string
|
||||
): void {
|
||||
for (const flag of parsed.flags.keys()) {
|
||||
if (!allowedFlags.has(flag)) {
|
||||
throw new RemoteLinearWriteArgumentError(
|
||||
'invalid_argument',
|
||||
`Unknown flag --${flag} for command: ${command.join(' ')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
const positionals = parsed.commandPath.slice(command.length)
|
||||
if (positionals.length > maxPositionals) {
|
||||
throw new RemoteLinearWriteArgumentError(
|
||||
'invalid_argument',
|
||||
`Unknown command: ${parsed.commandPath.join(' ')}`
|
||||
)
|
||||
}
|
||||
if (positionals.length > 0 && parsed.flags.has(positionalFlag)) {
|
||||
throw new RemoteLinearWriteArgumentError(
|
||||
'invalid_argument',
|
||||
`Pass --${positionalFlag} either positionally or as a flag, not both.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function isRemoteCommand(parsed: ParsedRemoteCli, ...command: string[]): boolean {
|
||||
return command.every((part, index) => parsed.commandPath[index] === part)
|
||||
}
|
||||
|
||||
function matchesRemoteCommand(commandPath: string[], ...command: string[]): boolean {
|
||||
return (
|
||||
commandPath.length === command.length &&
|
||||
command.every((part, index) => commandPath[index] === part)
|
||||
)
|
||||
}
|
||||
|
||||
function remotePositional(parsed: ParsedRemoteCli, startIndex: number): string | undefined {
|
||||
const value = parsed.commandPath.slice(startIndex).join(' ').trim()
|
||||
return value || undefined
|
||||
}
|
||||
|
||||
function requiredString(flags: Map<string, string | boolean>, name: string): string {
|
||||
const value = optionalString(flags, name)
|
||||
if (!value) {
|
||||
throw new RemoteLinearWriteArgumentError('invalid_argument', `Missing --${name}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function requiredStringAllowingEmpty(flags: Map<string, string | boolean>, name: string): string {
|
||||
const value = flags.get(name)
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
throw new RemoteLinearWriteArgumentError('invalid_argument', `Missing --${name}`)
|
||||
}
|
||||
|
||||
function optionalString(flags: Map<string, string | boolean>, name: string): string | undefined {
|
||||
const value = flags.get(name)
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
async function call(
|
||||
dispatcher: RpcDispatcher,
|
||||
method: string,
|
||||
params?: Record<string, unknown>
|
||||
): Promise<RpcResponse> {
|
||||
return await dispatcher.dispatch({
|
||||
id: `remote-cli-${Date.now()}`,
|
||||
authToken: 'remote-cli',
|
||||
method,
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
const LINEAR_STATUS_HELP = `orca linear status set\n\nUsage: orca linear status set [<id>] [--current] --to <state> [--workspace <id>] [--json]\n\nSet a Linear issue status`
|
||||
const LINEAR_COMMENT_HELP = `orca linear comment add\n\nUsage: orca linear comment add [<id>] [--current] (--body <text> | --body-file <path|->) [--reply-to <commentId>] [--write-id <uuid>] [--workspace <id>] [--json]\n\nAdd a comment to a Linear issue`
|
||||
const LINEAR_ATTACH_HELP = `orca linear attach\n\nUsage: orca linear attach [<id>] [--current] --url <url> [--title <title>] [--write-id <uuid>] [--workspace <id>] [--json]\n\nAttach a link to a Linear issue`
|
||||
const LINEAR_CREATE_HELP = `orca linear create\n\nUsage: orca linear create --title <title> [--body <text> | --body-file <path|->] [--team <key>] [--parent <id> | --parent-current] [--write-id <uuid>] [--workspace <id>] [--json]\n\nCreate a Linear issue`
|
||||
@@ -13,6 +13,7 @@ export type RemoteOrcaCliRequest = {
|
||||
argv: string[]
|
||||
cwd: string
|
||||
env: Record<string, string>
|
||||
stdin?: string
|
||||
}
|
||||
|
||||
export type RemoteOrcaCliResult = {
|
||||
@@ -37,6 +38,7 @@ const REMOTE_BOOLEAN_FLAGS = new Set([
|
||||
'inject',
|
||||
'json',
|
||||
'relations',
|
||||
'parent-current',
|
||||
'unread',
|
||||
'wait'
|
||||
])
|
||||
@@ -54,7 +56,7 @@ export async function runRemoteOrcaCli(
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await dispatchRemoteCli(dispatcher, parsed, request.env)
|
||||
const response = await dispatchRemoteCli(dispatcher, parsed, request.env, request.stdin)
|
||||
const formatted = json
|
||||
? { stdout: `${JSON.stringify(response, null, 2)}\n`, stderr: '' }
|
||||
: formatRemoteCli(response)
|
||||
@@ -87,10 +89,11 @@ export async function runRemoteOrcaCli(
|
||||
async function dispatchRemoteCli(
|
||||
dispatcher: RpcDispatcher,
|
||||
parsed: ParsedRemoteCli,
|
||||
env: Record<string, string>
|
||||
env: Record<string, string>,
|
||||
stdin?: string
|
||||
): Promise<RpcResponse> {
|
||||
const command = parsed.commandPath.join(' ')
|
||||
const linearResponse = await tryDispatchRemoteLinearCli(dispatcher, parsed, env)
|
||||
const linearResponse = await tryDispatchRemoteLinearCli(dispatcher, parsed, env, stdin)
|
||||
if (linearResponse) {
|
||||
return linearResponse
|
||||
}
|
||||
|
||||
@@ -13,7 +13,15 @@ type TestWindow = {
|
||||
webContents: { send: ReturnType<typeof vi.fn> }
|
||||
}
|
||||
|
||||
const { appMock, browserWindowMock, checkOrcaStarredMock, ipcMainHandleMock } = vi.hoisted(() => ({
|
||||
const {
|
||||
appMock,
|
||||
browserWindowMock,
|
||||
checkOrcaStarredMock,
|
||||
starOrcaMock,
|
||||
trackMock,
|
||||
getCohortAtEmitMock,
|
||||
ipcMainHandleMock
|
||||
} = vi.hoisted(() => ({
|
||||
appMock: {
|
||||
getVersion: vi.fn(() => '1.2.3')
|
||||
},
|
||||
@@ -21,6 +29,9 @@ const { appMock, browserWindowMock, checkOrcaStarredMock, ipcMainHandleMock } =
|
||||
getAllWindows: vi.fn<() => TestWindow[]>(() => [])
|
||||
},
|
||||
checkOrcaStarredMock: vi.fn(),
|
||||
starOrcaMock: vi.fn(),
|
||||
trackMock: vi.fn(),
|
||||
getCohortAtEmitMock: vi.fn(() => ({ nth_repo_added: 3 })),
|
||||
ipcMainHandleMock: vi.fn()
|
||||
}))
|
||||
|
||||
@@ -33,7 +44,16 @@ vi.mock('electron', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../github/client', () => ({
|
||||
checkOrcaStarred: checkOrcaStarredMock
|
||||
checkOrcaStarred: checkOrcaStarredMock,
|
||||
starOrca: starOrcaMock
|
||||
}))
|
||||
|
||||
vi.mock('../telemetry/client', () => ({
|
||||
track: trackMock
|
||||
}))
|
||||
|
||||
vi.mock('../telemetry/cohort-classifier', () => ({
|
||||
getCohortAtEmit: getCohortAtEmitMock
|
||||
}))
|
||||
|
||||
type AgentStartedListener = (totalAgentsSpawned: number) => void
|
||||
@@ -131,6 +151,11 @@ describe('StarNagService', () => {
|
||||
browserWindowMock.getAllWindows.mockReturnValue([])
|
||||
checkOrcaStarredMock.mockReset()
|
||||
checkOrcaStarredMock.mockResolvedValue(false)
|
||||
starOrcaMock.mockReset()
|
||||
starOrcaMock.mockResolvedValue(true)
|
||||
trackMock.mockReset()
|
||||
getCohortAtEmitMock.mockReset()
|
||||
getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 3 })
|
||||
ipcMainHandleMock.mockReset()
|
||||
consoleInfoMock = vi.spyOn(console, 'info').mockImplementation(() => undefined)
|
||||
})
|
||||
@@ -150,7 +175,9 @@ describe('StarNagService', () => {
|
||||
emitAgentStarted(46)
|
||||
|
||||
expect(window.webContents.send).toHaveBeenCalledTimes(1)
|
||||
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', { mode: 'gh' })
|
||||
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', {
|
||||
mode: 'gh'
|
||||
})
|
||||
expect(consoleInfoMock).toHaveBeenCalledTimes(1)
|
||||
expect(consoleInfoMock).toHaveBeenCalledWith({
|
||||
event: 'star_nag_shown',
|
||||
@@ -171,7 +198,18 @@ describe('StarNagService', () => {
|
||||
emitAgentStarted(45)
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', { mode: 'web' })
|
||||
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', {
|
||||
mode: 'web'
|
||||
})
|
||||
expect(trackMock).toHaveBeenCalledWith('star_nag_outcome', {
|
||||
outcome: 'shown',
|
||||
source: 'threshold',
|
||||
mode: 'web',
|
||||
threshold: STAR_NAG_INITIAL_THRESHOLD,
|
||||
agents_since_baseline: 35,
|
||||
agents_since_baseline_bucket: '35-69',
|
||||
nth_repo_added: 3
|
||||
})
|
||||
expect(consoleInfoMock).toHaveBeenCalledWith({
|
||||
event: 'star_nag_shown',
|
||||
app_version: '1.2.3',
|
||||
@@ -203,13 +241,16 @@ describe('StarNagService', () => {
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(consoleInfoMock).not.toHaveBeenCalled()
|
||||
expect(trackMock).not.toHaveBeenCalled()
|
||||
|
||||
const window = createWindow()
|
||||
browserWindowMock.getAllWindows.mockReturnValue([window])
|
||||
emitAgentStarted(46)
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', { mode: 'gh' })
|
||||
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', {
|
||||
mode: 'gh'
|
||||
})
|
||||
expect(consoleInfoMock).toHaveBeenCalledWith({
|
||||
event: 'star_nag_shown',
|
||||
app_version: '1.2.3',
|
||||
@@ -282,7 +323,9 @@ describe('StarNagService', () => {
|
||||
browserWindowMock.getAllWindows.mockReturnValue([window])
|
||||
forceShow()
|
||||
|
||||
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', { mode: 'gh' })
|
||||
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', {
|
||||
mode: 'gh'
|
||||
})
|
||||
expect(consoleInfoMock).toHaveBeenCalledWith({
|
||||
event: 'star_nag_shown',
|
||||
app_version: '1.2.3',
|
||||
@@ -405,7 +448,9 @@ describe('StarNagService', () => {
|
||||
|
||||
expect(window.webContents.send).toHaveBeenCalledTimes(1)
|
||||
expect(consoleInfoMock).toHaveBeenCalledTimes(1)
|
||||
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', { mode: 'web' })
|
||||
expect(window.webContents.send).toHaveBeenCalledWith('star-nag:show', {
|
||||
mode: 'web'
|
||||
})
|
||||
expect(consoleInfoMock).toHaveBeenCalledWith({
|
||||
event: 'star_nag_shown',
|
||||
app_version: '1.2.3',
|
||||
@@ -458,4 +503,217 @@ describe('StarNagService', () => {
|
||||
source: 'force_show'
|
||||
})
|
||||
})
|
||||
|
||||
it('emits shown and already_starred_suppressed outcomes with cohort context', async () => {
|
||||
const window = createWindow()
|
||||
browserWindowMock.getAllWindows.mockReturnValue([window])
|
||||
const { service, emitAgentStarted } = createHarness()
|
||||
|
||||
service.start()
|
||||
emitAgentStarted(45)
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(trackMock).toHaveBeenCalledWith('star_nag_outcome', {
|
||||
outcome: 'shown',
|
||||
source: 'threshold',
|
||||
mode: 'gh',
|
||||
threshold: STAR_NAG_INITIAL_THRESHOLD,
|
||||
agents_since_baseline: 35,
|
||||
agents_since_baseline_bucket: '35-69',
|
||||
nth_repo_added: 3
|
||||
})
|
||||
|
||||
trackMock.mockClear()
|
||||
checkOrcaStarredMock.mockResolvedValue(true)
|
||||
const next = createHarness()
|
||||
next.service.start()
|
||||
next.emitAgentStarted(45)
|
||||
await flushAsyncWork()
|
||||
|
||||
expect(trackMock).toHaveBeenCalledWith('star_nag_outcome', {
|
||||
outcome: 'already_starred_suppressed',
|
||||
source: 'threshold',
|
||||
mode: 'gh',
|
||||
threshold: STAR_NAG_INITIAL_THRESHOLD,
|
||||
agents_since_baseline: 35,
|
||||
agents_since_baseline_bucket: '35-69',
|
||||
nth_repo_added: 3
|
||||
})
|
||||
})
|
||||
|
||||
it('emits dismissed, disabled, and opened_web as distinct main-owned outcomes', () => {
|
||||
const window = createWindow()
|
||||
browserWindowMock.getAllWindows.mockReturnValue([window])
|
||||
const dismissed = createHarness()
|
||||
|
||||
dismissed.service.registerIpcHandlers()
|
||||
getIpcHandler('star-nag:forceShow')()
|
||||
getIpcHandler('star-nag:dismiss')()
|
||||
|
||||
expect(trackMock).toHaveBeenCalledWith('star_nag_outcome', {
|
||||
outcome: 'dismissed',
|
||||
source: 'force_show',
|
||||
mode: 'gh',
|
||||
threshold: STAR_NAG_INITIAL_THRESHOLD,
|
||||
agents_since_baseline: 35,
|
||||
agents_since_baseline_bucket: '35-69',
|
||||
nth_repo_added: 3,
|
||||
next_threshold: STAR_NAG_INITIAL_THRESHOLD * 2
|
||||
})
|
||||
|
||||
trackMock.mockClear()
|
||||
ipcMainHandleMock.mockClear()
|
||||
const disabled = createHarness()
|
||||
disabled.service.registerIpcHandlers()
|
||||
getIpcHandler('star-nag:forceShow')()
|
||||
getIpcHandler('star-nag:disable')()
|
||||
|
||||
expect(trackMock).toHaveBeenCalledWith(
|
||||
'star_nag_outcome',
|
||||
expect.objectContaining({ outcome: 'disabled', mode: 'gh' })
|
||||
)
|
||||
|
||||
trackMock.mockClear()
|
||||
ipcMainHandleMock.mockClear()
|
||||
const opened = createHarness()
|
||||
opened.service.registerIpcHandlers()
|
||||
getIpcHandler('star-nag:forceShow')()
|
||||
getIpcHandler('star-nag:openWeb')()
|
||||
|
||||
expect(trackMock).toHaveBeenCalledWith(
|
||||
'star_nag_outcome',
|
||||
expect.objectContaining({ outcome: 'opened_web', mode: 'web' })
|
||||
)
|
||||
})
|
||||
|
||||
it('emits direct-star attempted and succeeded outcomes plus app_starred_orca', async () => {
|
||||
const window = createWindow()
|
||||
browserWindowMock.getAllWindows.mockReturnValue([window])
|
||||
const { service, ui } = createHarness()
|
||||
|
||||
service.registerIpcHandlers()
|
||||
getIpcHandler('star-nag:forceShow')()
|
||||
const ok = await getIpcHandler('star-nag:starOrca')()
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(ui.starNagCompleted).toBe(true)
|
||||
expect(trackMock).toHaveBeenCalledWith(
|
||||
'star_nag_outcome',
|
||||
expect.objectContaining({ outcome: 'star_attempted', mode: 'gh' })
|
||||
)
|
||||
expect(trackMock).toHaveBeenCalledWith(
|
||||
'star_nag_outcome',
|
||||
expect.objectContaining({ outcome: 'star_succeeded', mode: 'gh' })
|
||||
)
|
||||
expect(trackMock).toHaveBeenCalledWith('app_starred_orca', {
|
||||
source: 'star_nag',
|
||||
nth_repo_added: 3
|
||||
})
|
||||
})
|
||||
|
||||
it('uses fresh cohort context for canonical app_starred_orca success telemetry', async () => {
|
||||
const window = createWindow()
|
||||
browserWindowMock.getAllWindows.mockReturnValue([window])
|
||||
getCohortAtEmitMock
|
||||
.mockReturnValueOnce({ nth_repo_added: 2 })
|
||||
.mockReturnValueOnce({ nth_repo_added: 4 })
|
||||
const { service } = createHarness()
|
||||
|
||||
service.registerIpcHandlers()
|
||||
getIpcHandler('star-nag:forceShow')()
|
||||
await getIpcHandler('star-nag:starOrca')()
|
||||
|
||||
expect(trackMock).toHaveBeenCalledWith(
|
||||
'star_nag_outcome',
|
||||
expect.objectContaining({ outcome: 'shown', nth_repo_added: 2 })
|
||||
)
|
||||
expect(trackMock).toHaveBeenCalledWith(
|
||||
'star_nag_outcome',
|
||||
expect.objectContaining({ outcome: 'star_succeeded', nth_repo_added: 2 })
|
||||
)
|
||||
expect(trackMock).toHaveBeenCalledWith('app_starred_orca', {
|
||||
source: 'star_nag',
|
||||
nth_repo_added: 4
|
||||
})
|
||||
})
|
||||
|
||||
it('records success and completion when direct star resolves after dismissal cleared the visible session', async () => {
|
||||
const window = createWindow()
|
||||
browserWindowMock.getAllWindows.mockReturnValue([window])
|
||||
const deferredStar = createDeferred<boolean>()
|
||||
starOrcaMock.mockReturnValue(deferredStar.promise)
|
||||
const { service, ui } = createHarness()
|
||||
|
||||
service.registerIpcHandlers()
|
||||
getIpcHandler('star-nag:forceShow')()
|
||||
const starPromise = getIpcHandler('star-nag:starOrca')()
|
||||
getIpcHandler('star-nag:dismiss')()
|
||||
|
||||
deferredStar.resolve(true)
|
||||
await expect(starPromise).resolves.toBe(true)
|
||||
|
||||
expect(trackMock).toHaveBeenCalledWith(
|
||||
'star_nag_outcome',
|
||||
expect.objectContaining({ outcome: 'star_succeeded', mode: 'gh' })
|
||||
)
|
||||
expect(trackMock).toHaveBeenCalledWith('app_starred_orca', {
|
||||
source: 'star_nag',
|
||||
nth_repo_added: 3
|
||||
})
|
||||
expect(ui.starNagCompleted).toBe(true)
|
||||
})
|
||||
|
||||
it('clears the in-flight direct-star guard after thrown attempts so the user can retry', async () => {
|
||||
const window = createWindow()
|
||||
browserWindowMock.getAllWindows.mockReturnValue([window])
|
||||
starOrcaMock.mockRejectedValueOnce(new Error('gh failed')).mockResolvedValueOnce(true)
|
||||
const { service, ui } = createHarness()
|
||||
|
||||
service.registerIpcHandlers()
|
||||
getIpcHandler('star-nag:forceShow')()
|
||||
const starFromNag = getIpcHandler('star-nag:starOrca')
|
||||
|
||||
await expect(starFromNag()).rejects.toThrow('gh failed')
|
||||
await expect(starFromNag()).resolves.toBe(true)
|
||||
|
||||
expect(starOrcaMock).toHaveBeenCalledTimes(2)
|
||||
expect(ui.starNagCompleted).toBe(true)
|
||||
})
|
||||
|
||||
it('records failed direct star before web fallback and guards duplicate in-flight attempts', async () => {
|
||||
const window = createWindow()
|
||||
browserWindowMock.getAllWindows.mockReturnValue([window])
|
||||
const deferredStar = createDeferred<boolean>()
|
||||
starOrcaMock.mockReturnValue(deferredStar.promise)
|
||||
const { service, ui } = createHarness()
|
||||
|
||||
service.registerIpcHandlers()
|
||||
getIpcHandler('star-nag:forceShow')()
|
||||
const starFromNag = getIpcHandler('star-nag:starOrca')
|
||||
const first = starFromNag()
|
||||
const second = starFromNag()
|
||||
|
||||
deferredStar.resolve(false)
|
||||
await expect(first).resolves.toBe(false)
|
||||
await expect(second).resolves.toBe(false)
|
||||
|
||||
const starAttempts = trackMock.mock.calls.filter(
|
||||
([name, payload]) =>
|
||||
name === 'star_nag_outcome' &&
|
||||
(payload as { outcome?: string }).outcome === 'star_attempted'
|
||||
)
|
||||
expect(starAttempts).toHaveLength(1)
|
||||
expect(trackMock).toHaveBeenCalledWith(
|
||||
'star_nag_outcome',
|
||||
expect.objectContaining({ outcome: 'star_failed', mode: 'gh' })
|
||||
)
|
||||
|
||||
getIpcHandler('star-nag:openWeb')()
|
||||
|
||||
expect(trackMock).toHaveBeenCalledWith(
|
||||
'star_nag_outcome',
|
||||
expect.objectContaining({ outcome: 'opened_web', mode: 'web' })
|
||||
)
|
||||
expect(ui.starNagCompleted).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { app, BrowserWindow, ipcMain } from 'electron'
|
||||
import { STAR_NAG_INITIAL_THRESHOLD } from '../../shared/constants'
|
||||
import { checkOrcaStarred } from '../github/client'
|
||||
import { checkOrcaStarred, starOrca } from '../github/client'
|
||||
import type { Store } from '../persistence'
|
||||
import type { StatsCollector } from '../stats/collector'
|
||||
import { track } from '../telemetry/client'
|
||||
import { getCohortAtEmit } from '../telemetry/cohort-classifier'
|
||||
import {
|
||||
bucketStarNagAgentsSinceBaseline,
|
||||
type StarNagOutcome,
|
||||
type StarNagPromptMode,
|
||||
type StarNagPromptSource
|
||||
} from '../../shared/star-nag-telemetry'
|
||||
import type { EventProps } from '../../shared/telemetry-events'
|
||||
|
||||
type StarNagPromptSource = 'threshold' | 'force_show'
|
||||
type StarNagPromptMode = 'gh' | 'web'
|
||||
type StarNagPromptContext = Omit<EventProps<'star_nag_outcome'>, 'outcome' | 'next_threshold'>
|
||||
|
||||
type StarNagPromptSession = {
|
||||
source: StarNagPromptSource
|
||||
type StarNagPromptSession = StarNagPromptContext & {
|
||||
starAttemptPromise?: Promise<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,8 +44,9 @@ export class StarNagService {
|
||||
// resolving.
|
||||
private evaluating = false
|
||||
private pendingForceShow = false
|
||||
// Why: dismissal backoff should only apply to a prompt that was actually
|
||||
// delivered, and the dismissal payload needs the delivered prompt source.
|
||||
// Why: dismissal backoff and action telemetry must use the prompt context
|
||||
// that was delivered, not whatever threshold/source happens to be current
|
||||
// when the renderer later reports a user action.
|
||||
private promptSession: StarNagPromptSession | null = null
|
||||
|
||||
constructor(store: Store, stats: StatsCollector) {
|
||||
@@ -65,7 +74,9 @@ export class StarNagService {
|
||||
registerIpcHandlers(): void {
|
||||
ipcMain.handle('star-nag:dismiss', () => this.dismiss())
|
||||
ipcMain.handle('star-nag:complete', () => this.markCompleted())
|
||||
ipcMain.handle('star-nag:disable', () => this.markCompleted())
|
||||
ipcMain.handle('star-nag:disable', () => this.disable())
|
||||
ipcMain.handle('star-nag:openWeb', () => this.openWeb())
|
||||
ipcMain.handle('star-nag:starOrca', () => this.starOrcaFromNag())
|
||||
ipcMain.handle('star-nag:forceShow', () => this.forceShow())
|
||||
}
|
||||
|
||||
@@ -134,6 +145,7 @@ export class StarNagService {
|
||||
return
|
||||
}
|
||||
if (starred) {
|
||||
this.trackAlreadyStarredSuppressed(source)
|
||||
// Already starred somewhere — lock in the permanent suppression so we
|
||||
// stop recomputing thresholds on every spawn.
|
||||
this.markCompleted()
|
||||
@@ -167,13 +179,67 @@ export class StarNagService {
|
||||
this.promptSession = null
|
||||
return false
|
||||
}
|
||||
const context = this.createPromptContext(source, mode)
|
||||
win.webContents.send('star-nag:show', { mode })
|
||||
this.promptVisible = true
|
||||
this.promptSession = { source }
|
||||
this.promptSession = context
|
||||
this.trackOutcome('shown')
|
||||
this.logConsoleEvent('star_nag_shown', source)
|
||||
return true
|
||||
}
|
||||
|
||||
private createPromptContext(
|
||||
source: StarNagPromptSource,
|
||||
mode: StarNagPromptMode
|
||||
): StarNagPromptContext {
|
||||
const ui = this.store.getUI()
|
||||
const threshold = ui.starNagNextThreshold ?? STAR_NAG_INITIAL_THRESHOLD
|
||||
const agentsSinceBaseline = Math.max(
|
||||
0,
|
||||
this.stats.getTotalAgentsSpawned() - (ui.starNagBaselineAgents ?? 0)
|
||||
)
|
||||
return {
|
||||
source,
|
||||
mode,
|
||||
threshold,
|
||||
agents_since_baseline: agentsSinceBaseline,
|
||||
agents_since_baseline_bucket: bucketStarNagAgentsSinceBaseline(agentsSinceBaseline),
|
||||
...getCohortAtEmit()
|
||||
}
|
||||
}
|
||||
|
||||
private trackOutcome(
|
||||
outcome: StarNagOutcome,
|
||||
options: { mode?: StarNagPromptMode; nextThreshold?: number } = {}
|
||||
): void {
|
||||
const session = this.promptSession
|
||||
if (!session) {
|
||||
return
|
||||
}
|
||||
this.trackSessionOutcome(session, outcome, options)
|
||||
}
|
||||
|
||||
private trackSessionOutcome(
|
||||
session: StarNagPromptSession,
|
||||
outcome: StarNagOutcome,
|
||||
options: { mode?: StarNagPromptMode; nextThreshold?: number } = {}
|
||||
): void {
|
||||
const { starAttemptPromise: _starAttemptPromise, ...context } = session
|
||||
track('star_nag_outcome', {
|
||||
...context,
|
||||
outcome,
|
||||
...(options.mode === undefined ? {} : { mode: options.mode }),
|
||||
...(options.nextThreshold === undefined ? {} : { next_threshold: options.nextThreshold })
|
||||
})
|
||||
}
|
||||
|
||||
private trackAlreadyStarredSuppressed(source: StarNagPromptSource): void {
|
||||
track('star_nag_outcome', {
|
||||
...this.createPromptContext(source, 'gh'),
|
||||
outcome: 'already_starred_suppressed'
|
||||
})
|
||||
}
|
||||
|
||||
private logConsoleEvent(
|
||||
event: 'star_nag_shown' | 'star_nag_dismissed',
|
||||
source: StarNagPromptSource,
|
||||
@@ -211,6 +277,7 @@ export class StarNagService {
|
||||
const ui = this.store.getUI()
|
||||
const threshold = ui.starNagNextThreshold ?? STAR_NAG_INITIAL_THRESHOLD
|
||||
const nextThreshold = threshold * 2
|
||||
this.trackOutcome('dismissed', { nextThreshold })
|
||||
this.logConsoleEvent('star_nag_dismissed', session.source, nextThreshold)
|
||||
this.store.updateUI({
|
||||
starNagNextThreshold: nextThreshold,
|
||||
@@ -220,6 +287,56 @@ export class StarNagService {
|
||||
this.promptSession = null
|
||||
}
|
||||
|
||||
private disable(): void {
|
||||
this.trackOutcome('disabled')
|
||||
this.markCompleted()
|
||||
}
|
||||
|
||||
private openWeb(): void {
|
||||
this.trackOutcome('opened_web', { mode: 'web' })
|
||||
this.markCompleted()
|
||||
}
|
||||
|
||||
private async starOrcaFromNag(): Promise<boolean> {
|
||||
const session = this.promptSession
|
||||
if (!session) {
|
||||
return false
|
||||
}
|
||||
if (session.starAttemptPromise) {
|
||||
return session.starAttemptPromise
|
||||
}
|
||||
const attempt = this.runStarOrcaAttempt(session)
|
||||
session.starAttemptPromise = attempt
|
||||
try {
|
||||
return await attempt
|
||||
} finally {
|
||||
if (this.promptSession === session) {
|
||||
delete session.starAttemptPromise
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async runStarOrcaAttempt(session: StarNagPromptSession): Promise<boolean> {
|
||||
this.trackSessionOutcome(session, 'star_attempted', { mode: 'gh' })
|
||||
const starred = await starOrca()
|
||||
if (!starred) {
|
||||
if (this.promptSession === session) {
|
||||
this.trackSessionOutcome(session, 'star_failed', { mode: 'gh' })
|
||||
session.mode = 'web'
|
||||
}
|
||||
return false
|
||||
}
|
||||
this.trackSessionOutcome(session, 'star_succeeded', { mode: 'gh' })
|
||||
// Why: app_starred_orca remains the canonical cross-surface success event;
|
||||
// star_nag_outcome is only the nag-funnel companion.
|
||||
track('app_starred_orca', {
|
||||
source: 'star_nag',
|
||||
...getCohortAtEmit()
|
||||
})
|
||||
this.markCompleted()
|
||||
return true
|
||||
}
|
||||
|
||||
/** User successfully starred or opted out → never nag again. */
|
||||
private markCompleted(): void {
|
||||
this.store.updateUI({ starNagCompleted: true })
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS,
|
||||
LOCAL_PTY_STARTUP_FAIL_OPEN_TIMEOUT_MS,
|
||||
startFirstWindowStartupServices
|
||||
} from './first-window-startup-services'
|
||||
|
||||
@@ -83,7 +84,49 @@ describe('startFirstWindowStartupServices', () => {
|
||||
expect(onAgentHookServerError).toHaveBeenCalledWith(expect.any(Error))
|
||||
})
|
||||
|
||||
it('fails open the first window and local PTY startup while aborting hung services', async () => {
|
||||
it('opens the first window at the window timeout without aborting a slow daemon or opening the PTY gate', async () => {
|
||||
vi.useFakeTimers()
|
||||
const onDaemonError = vi.fn()
|
||||
let daemonSignal: AbortSignal | undefined
|
||||
let resolveDaemon!: () => void
|
||||
|
||||
try {
|
||||
const started = startFirstWindowStartupServices({
|
||||
startDaemonPtyProvider: (signal) => {
|
||||
daemonSignal = signal
|
||||
return new Promise<void>((resolve) => {
|
||||
resolveDaemon = resolve
|
||||
})
|
||||
},
|
||||
startAgentHookServer: () => Promise.resolve(),
|
||||
onDaemonError,
|
||||
onAgentHookServerError: vi.fn()
|
||||
})
|
||||
|
||||
let ptyGateOpened = false
|
||||
void started.localPtyReady.then(() => {
|
||||
ptyGateOpened = true
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS)
|
||||
await expect(started.firstWindowReady).resolves.toBeUndefined()
|
||||
|
||||
// Why: opening the PTY gate before the daemon attempt finishes would
|
||||
// spawn non-restorable LocalPtyProvider fallback terminals (#5232).
|
||||
expect(ptyGateOpened).toBe(false)
|
||||
expect(daemonSignal?.aborted).toBe(false)
|
||||
expect(onDaemonError).not.toHaveBeenCalled()
|
||||
|
||||
resolveDaemon()
|
||||
await expect(started.localPtyReady).resolves.toBeUndefined()
|
||||
expect(daemonSignal?.aborted).toBe(false)
|
||||
expect(onDaemonError).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('fails open the local PTY gate at the hard cap while aborting hung services', async () => {
|
||||
vi.useFakeTimers()
|
||||
const onDaemonError = vi.fn()
|
||||
const onAgentHookServerError = vi.fn()
|
||||
@@ -101,7 +144,7 @@ describe('startFirstWindowStartupServices', () => {
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
await vi.advanceTimersByTimeAsync(FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS)
|
||||
await vi.advanceTimersByTimeAsync(LOCAL_PTY_STARTUP_FAIL_OPEN_TIMEOUT_MS)
|
||||
await expect(started.firstWindowReady).resolves.toBeUndefined()
|
||||
await expect(started.localPtyReady).resolves.toBeUndefined()
|
||||
|
||||
|
||||
@@ -16,6 +16,12 @@ type FirstWindowStartupServicesResult = {
|
||||
}
|
||||
|
||||
export const FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS = 12_000
|
||||
// Why: a slow (but succeeding) daemon start must not flip terminals to the
|
||||
// LocalPtyProvider fallback — local PTYs are killed on quit, so panes bound to
|
||||
// them lose their daemon sessions permanently (#5232). The PTY gate therefore
|
||||
// waits for the daemon attempt itself and only fail-opens at a hard cap that
|
||||
// exists solely as a deadlock backstop.
|
||||
export const LOCAL_PTY_STARTUP_FAIL_OPEN_TIMEOUT_MS = 60_000
|
||||
|
||||
function startService(
|
||||
label: string,
|
||||
@@ -60,33 +66,40 @@ export function startFirstWindowStartupServices({
|
||||
}: FirstWindowStartupServices): FirstWindowStartupServicesResult {
|
||||
// Why: daemon startup and hook-server binding are independent, but both gate
|
||||
// restored terminals; run them together so cold-start latency is max(), not sum().
|
||||
// The first window and local PTY startup both fail open after the timeout.
|
||||
// The timeout also aborts slow services so late daemon swaps cannot strand
|
||||
// any fallback LocalPtyProvider PTYs that spawn after the barrier opens.
|
||||
// The first window fails open quickly so the user sees the app; the local PTY
|
||||
// gate waits for the services themselves (a slow daemon must not flip spawns
|
||||
// to the non-restorable LocalPtyProvider fallback) and only fails open at the
|
||||
// hard cap, which also aborts the services so a late daemon swap cannot
|
||||
// strand any fallback PTYs that spawn after the gate opens.
|
||||
const daemon = startService('daemon PTY provider', startDaemonPtyProvider, onDaemonError)
|
||||
const hooks = startService('agent hook server', startAgentHookServer, onAgentHookServerError)
|
||||
const allServicesReady = Promise.all([daemon.ready, hooks.ready]).then(() => undefined)
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
let resolveTimedOut!: () => void
|
||||
const timedOut = new Promise<void>((resolve) => {
|
||||
resolveTimedOut = resolve
|
||||
let windowTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let failOpenTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
const servicesSettled = allServicesReady.finally(() => {
|
||||
if (windowTimeout) {
|
||||
clearTimeout(windowTimeout)
|
||||
}
|
||||
if (failOpenTimeout) {
|
||||
clearTimeout(failOpenTimeout)
|
||||
}
|
||||
})
|
||||
const firstWindowReady = Promise.race([
|
||||
allServicesReady.finally(() => {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}),
|
||||
servicesSettled,
|
||||
new Promise<void>((resolve) => {
|
||||
timeout = setTimeout(() => {
|
||||
windowTimeout = setTimeout(resolve, FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS)
|
||||
})
|
||||
])
|
||||
const localPtyReady = Promise.race([
|
||||
servicesSettled,
|
||||
new Promise<void>((resolve) => {
|
||||
failOpenTimeout = setTimeout(() => {
|
||||
daemon.reportTimeout()
|
||||
hooks.reportTimeout()
|
||||
resolveTimedOut()
|
||||
resolve()
|
||||
}, FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS)
|
||||
}, LOCAL_PTY_STARTUP_FAIL_OPEN_TIMEOUT_MS)
|
||||
})
|
||||
])
|
||||
const localPtyReady = Promise.race([allServicesReady, timedOut])
|
||||
|
||||
return { firstWindowReady, localPtyReady }
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ describe('onboarding feature setup telemetry validation', () => {
|
||||
const selection = {
|
||||
browser_use: true,
|
||||
computer_use: false,
|
||||
linear_tickets: true,
|
||||
orchestration: true,
|
||||
selected_count: 2
|
||||
}
|
||||
@@ -45,6 +46,7 @@ describe('onboarding feature setup telemetry validation', () => {
|
||||
validate('onboarding_feature_setup_terminal_opened', {
|
||||
browser_use: true,
|
||||
computer_use: false,
|
||||
linear_tickets: false,
|
||||
orchestration: true,
|
||||
selected_count: 2,
|
||||
command: 'npx skills add https://github.com/stablyai/orca --global'
|
||||
@@ -64,6 +66,7 @@ describe('onboarding feature setup telemetry validation', () => {
|
||||
validate('onboarding_feature_setup_run', {
|
||||
browser_use: false,
|
||||
computer_use: false,
|
||||
linear_tickets: false,
|
||||
orchestration: false,
|
||||
selected_count: 3,
|
||||
cli_touched: false,
|
||||
@@ -77,6 +80,7 @@ describe('onboarding feature setup telemetry validation', () => {
|
||||
validate('onboarding_feature_setup_terminal_opened', {
|
||||
browser_use: true,
|
||||
computer_use: false,
|
||||
linear_tickets: false,
|
||||
orchestration: true,
|
||||
selected_count: 1
|
||||
} as never).ok
|
||||
|
||||
@@ -41,6 +41,43 @@ describe('validate', () => {
|
||||
expect(result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts a well-formed star_nag_outcome payload with cohort context', () => {
|
||||
const result = validate('star_nag_outcome', {
|
||||
outcome: 'opened_web',
|
||||
source: 'force_show',
|
||||
mode: 'web',
|
||||
threshold: 35,
|
||||
agents_since_baseline: 42,
|
||||
agents_since_baseline_bucket: '35-69',
|
||||
nth_repo_added: 4
|
||||
})
|
||||
expect(result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects malformed star_nag_outcome payloads', () => {
|
||||
expect(
|
||||
validate('star_nag_outcome', {
|
||||
outcome: 'opened_web',
|
||||
source: 'force_show',
|
||||
mode: 'web',
|
||||
threshold: 35,
|
||||
agents_since_baseline: 42,
|
||||
agents_since_baseline_bucket: '35-69',
|
||||
raw_error: 'nope'
|
||||
} as never).ok
|
||||
).toBe(false)
|
||||
expect(
|
||||
validate('star_nag_outcome', {
|
||||
outcome: 'opened_web',
|
||||
source: 'force_show',
|
||||
mode: 'web',
|
||||
threshold: 0,
|
||||
agents_since_baseline: 42,
|
||||
agents_since_baseline_bucket: '35-69'
|
||||
} as never).ok
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('drops unknown event names', () => {
|
||||
const result = validate('not_a_real_event' as never, {})
|
||||
expect(result.ok).toBe(false)
|
||||
|
||||
@@ -127,15 +127,6 @@ import type {
|
||||
Project,
|
||||
Repo,
|
||||
ProjectGroup,
|
||||
ProjectHostSetup,
|
||||
ProjectHostSetupCreateArgs,
|
||||
ProjectHostSetupCreateResult,
|
||||
ProjectHostSetupDeleteArgs,
|
||||
ProjectHostSetupDeleteResult,
|
||||
ProjectHostSetupExistingFolderArgs,
|
||||
ProjectHostSetupResult,
|
||||
ProjectHostSetupUpdateArgs,
|
||||
ProjectHostSetupUpdateResult,
|
||||
FolderWorkspace,
|
||||
ProjectGroupImportResult,
|
||||
ProjectGroupImportMode,
|
||||
@@ -1655,6 +1646,8 @@ export type PreloadApi = {
|
||||
dismiss: () => Promise<void>
|
||||
complete: () => Promise<void>
|
||||
disable: () => Promise<void>
|
||||
openWeb: () => Promise<void>
|
||||
starOrca: () => Promise<boolean>
|
||||
forceShow: () => Promise<void>
|
||||
}
|
||||
/** Fire-and-forget track. Loose typing at the IPC boundary on purpose —
|
||||
@@ -1748,9 +1741,9 @@ export type PreloadApi = {
|
||||
getInstallStatus: () => Promise<CliInstallStatus>
|
||||
install: () => Promise<CliInstallStatus>
|
||||
remove: () => Promise<CliInstallStatus>
|
||||
getWslInstallStatus: () => Promise<CliInstallStatus>
|
||||
installWsl: () => Promise<CliInstallStatus>
|
||||
removeWsl: () => Promise<CliInstallStatus>
|
||||
getWslInstallStatus: (args?: { distro?: string | null }) => Promise<CliInstallStatus>
|
||||
installWsl: (args?: { distro?: string | null }) => Promise<CliInstallStatus>
|
||||
removeWsl: (args?: { distro?: string | null }) => Promise<CliInstallStatus>
|
||||
}
|
||||
agentHooks: {
|
||||
claudeStatus: () => Promise<AgentHookInstallStatus>
|
||||
|
||||
@@ -1489,6 +1489,8 @@ const api = {
|
||||
dismiss: (): Promise<void> => ipcRenderer.invoke('star-nag:dismiss'),
|
||||
complete: (): Promise<void> => ipcRenderer.invoke('star-nag:complete'),
|
||||
disable: (): Promise<void> => ipcRenderer.invoke('star-nag:disable'),
|
||||
openWeb: (): Promise<void> => ipcRenderer.invoke('star-nag:openWeb'),
|
||||
starOrca: (): Promise<boolean> => ipcRenderer.invoke('star-nag:starOrca'),
|
||||
forceShow: (): Promise<void> => ipcRenderer.invoke('star-nag:forceShow')
|
||||
},
|
||||
|
||||
@@ -1605,10 +1607,12 @@ const api = {
|
||||
getInstallStatus: (): Promise<CliInstallStatus> => ipcRenderer.invoke('cli:getInstallStatus'),
|
||||
install: (): Promise<CliInstallStatus> => ipcRenderer.invoke('cli:install'),
|
||||
remove: (): Promise<CliInstallStatus> => ipcRenderer.invoke('cli:remove'),
|
||||
getWslInstallStatus: (): Promise<CliInstallStatus> =>
|
||||
ipcRenderer.invoke('cli:getWslInstallStatus'),
|
||||
installWsl: (): Promise<CliInstallStatus> => ipcRenderer.invoke('cli:installWsl'),
|
||||
removeWsl: (): Promise<CliInstallStatus> => ipcRenderer.invoke('cli:removeWsl')
|
||||
getWslInstallStatus: (args?: { distro?: string | null }): Promise<CliInstallStatus> =>
|
||||
ipcRenderer.invoke('cli:getWslInstallStatus', args),
|
||||
installWsl: (args?: { distro?: string | null }): Promise<CliInstallStatus> =>
|
||||
ipcRenderer.invoke('cli:installWsl', args),
|
||||
removeWsl: (args?: { distro?: string | null }): Promise<CliInstallStatus> =>
|
||||
ipcRenderer.invoke('cli:removeWsl', args)
|
||||
},
|
||||
|
||||
agentHooks: {
|
||||
|
||||
+17
-3
@@ -59,6 +59,7 @@ import { resolveOpenCodeSourceConfigDir, resolvePiSourceAgentDir } from './plugi
|
||||
import { detectPiAgentKindFromCommand } from '../shared/pi-agent-kind'
|
||||
import { pickRemoteCliEnv } from './remote-cli-env'
|
||||
import { remoteCliRequestTimeoutMs } from './remote-cli-timeout'
|
||||
import { shouldReadRemoteCliStdin } from './remote-cli-stdin'
|
||||
|
||||
const DEFAULT_GRACE_MS = DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000
|
||||
const SOCK_NAME = 'relay.sock'
|
||||
@@ -213,8 +214,9 @@ function runConnectMode(sockPath: string): void {
|
||||
})
|
||||
}
|
||||
|
||||
function runOrcaCliMode(sockPath: string, argv: string[]): void {
|
||||
async function runOrcaCliMode(sockPath: string, argv: string[]): Promise<void> {
|
||||
const myVersion = readLaunchVersion()
|
||||
const stdin = shouldReadRemoteCliStdin(argv) ? await readOrcaCliStdin() : undefined
|
||||
const sock = createConnection({ path: sockPath })
|
||||
let nextSeq = 1
|
||||
let highestReceivedSeq = 0
|
||||
@@ -230,7 +232,8 @@ function runOrcaCliMode(sockPath: string, argv: string[]): void {
|
||||
params: {
|
||||
argv,
|
||||
cwd: process.cwd(),
|
||||
env
|
||||
env,
|
||||
...(stdin !== undefined ? { stdin } : {})
|
||||
}
|
||||
},
|
||||
nextSeq++,
|
||||
@@ -299,6 +302,17 @@ function runOrcaCliMode(sockPath: string, argv: string[]): void {
|
||||
})
|
||||
}
|
||||
|
||||
async function readOrcaCliStdin(): Promise<string | undefined> {
|
||||
if (process.stdin.isTTY) {
|
||||
return undefined
|
||||
}
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)))
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf8')
|
||||
}
|
||||
|
||||
// ── Normal mode ──────────────────────────────────────────────────────
|
||||
|
||||
async function main(): Promise<void> {
|
||||
@@ -312,7 +326,7 @@ async function main(): Promise<void> {
|
||||
}
|
||||
if (cliMode) {
|
||||
const marker = process.argv.indexOf('--orca-cli')
|
||||
runOrcaCliMode(sockPath, marker >= 0 ? process.argv.slice(marker + 1) : [])
|
||||
await runOrcaCliMode(sockPath, marker >= 0 ? process.argv.slice(marker + 1) : [])
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { shouldReadRemoteCliStdin } from './remote-cli-stdin'
|
||||
|
||||
describe('shouldReadRemoteCliStdin', () => {
|
||||
it('reads stdin only for body-file stdin requests', () => {
|
||||
expect(shouldReadRemoteCliStdin(['linear', 'comment', 'add', '--body-file', '-'])).toBe(true)
|
||||
expect(shouldReadRemoteCliStdin(['linear', 'create', '--body-file=-'])).toBe(true)
|
||||
expect(shouldReadRemoteCliStdin(['status'])).toBe(false)
|
||||
expect(shouldReadRemoteCliStdin(['linear', 'comment', 'add', '--body', 'done'])).toBe(false)
|
||||
expect(shouldReadRemoteCliStdin(['linear', 'issue', '--body-file', '-'])).toBe(false)
|
||||
expect(
|
||||
shouldReadRemoteCliStdin(['linear', 'comment', 'add', '--help', '--body-file', '-'])
|
||||
).toBe(false)
|
||||
expect(shouldReadRemoteCliStdin(['linear', 'comment', 'add', '--body-file', 'body.md'])).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
export function shouldReadRemoteCliStdin(argv: string[]): boolean {
|
||||
if (argv.includes('--help') || argv.includes('-h')) {
|
||||
return false
|
||||
}
|
||||
const commandPath = parseRemoteCliCommandPath(argv)
|
||||
if (!isLinearBodyWriteCommand(commandPath)) {
|
||||
return false
|
||||
}
|
||||
return argv.some((part, index) => {
|
||||
if (part === '--body-file') {
|
||||
return argv[index + 1] === '-'
|
||||
}
|
||||
return part === '--body-file=-'
|
||||
})
|
||||
}
|
||||
|
||||
const REMOTE_STDIN_BOOLEAN_FLAGS = new Set(['current', 'help', 'json', 'parent-current'])
|
||||
|
||||
function parseRemoteCliCommandPath(argv: string[]): string[] {
|
||||
const commandPath: string[] = []
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const token = argv[index]
|
||||
if (!token.startsWith('--')) {
|
||||
commandPath.push(token)
|
||||
continue
|
||||
}
|
||||
const assignment = token.slice(2)
|
||||
if (assignment.includes('=')) {
|
||||
continue
|
||||
}
|
||||
const next = argv[index + 1]
|
||||
if (!REMOTE_STDIN_BOOLEAN_FLAGS.has(assignment) && next && !next.startsWith('--')) {
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
return commandPath
|
||||
}
|
||||
|
||||
function isLinearBodyWriteCommand(commandPath: string[]): boolean {
|
||||
if (commandPath[0] !== 'linear') {
|
||||
return false
|
||||
}
|
||||
return (commandPath[1] === 'comment' && commandPath[2] === 'add') || commandPath[1] === 'create'
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user