mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 00:03:20 +00:00
Merge branch 'nwparker/term-speed-2-wake-session-reregister' into nwparker/term-speed-2-subscribe-recovery-kind
This commit is contained in:
+18
-1
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { parseArgs, supportsBrowserPageFlag, validateCommandAndFlags } from './args'
|
||||
import {
|
||||
REPEATED_FLAG_SEPARATOR,
|
||||
parseArgs,
|
||||
supportsBrowserPageFlag,
|
||||
validateCommandAndFlags
|
||||
} from './args'
|
||||
|
||||
describe('parseArgs', () => {
|
||||
it('keeps an empty string as a flag value', () => {
|
||||
@@ -37,6 +42,18 @@ describe('parseArgs', () => {
|
||||
expect(parsed.flags.get('json')).toBe(true)
|
||||
expect(parsed.flags.get('url')).toBe('https://example.com')
|
||||
})
|
||||
|
||||
it('preserves repeated string flags', () => {
|
||||
const parsed = parseArgs(['linear', 'label', 'add', '--label', 'Bug', '--label=Regression'])
|
||||
|
||||
expect(parsed.flags.get('label')).toBe(`Bug${REPEATED_FLAG_SEPARATOR}Regression`)
|
||||
})
|
||||
|
||||
it('does not apply repeated flag encoding to ordinary string flags', () => {
|
||||
const parsed = parseArgs(['linear', 'list', '--workspace', 'old', '--workspace', 'new'])
|
||||
|
||||
expect(parsed.flags.get('workspace')).toBe('new')
|
||||
})
|
||||
})
|
||||
|
||||
describe('supportsBrowserPageFlag', () => {
|
||||
|
||||
+58
-4
@@ -17,6 +17,52 @@ export type CommandSpec = {
|
||||
}
|
||||
|
||||
export const GLOBAL_FLAGS = ['help', 'json', 'pairing-code', 'environment']
|
||||
export const BOOLEAN_FLAGS = new Set([
|
||||
'all',
|
||||
'attachments',
|
||||
'children',
|
||||
'comments',
|
||||
'current',
|
||||
'dry-run',
|
||||
'enter',
|
||||
'focus',
|
||||
'force',
|
||||
'full',
|
||||
'help',
|
||||
'inject',
|
||||
'interrupt',
|
||||
'json',
|
||||
'messages',
|
||||
'me',
|
||||
'mobile',
|
||||
'mobile-pairing',
|
||||
'no-pairing',
|
||||
'parent-current',
|
||||
'ready',
|
||||
'relations',
|
||||
'restore-window',
|
||||
'return-preamble',
|
||||
'run-hooks',
|
||||
'show-profile',
|
||||
'staged',
|
||||
'tasks',
|
||||
'text-stdin',
|
||||
'unread',
|
||||
'value-stdin',
|
||||
'wait'
|
||||
])
|
||||
|
||||
export const REPEATED_FLAG_SEPARATOR = '\u0000'
|
||||
const REPEATABLE_STRING_FLAGS = new Set(['label'])
|
||||
|
||||
function setFlagValue(flags: Map<string, string | boolean>, name: string, value: string): void {
|
||||
const existing = flags.get(name)
|
||||
if (typeof existing === 'string' && REPEATABLE_STRING_FLAGS.has(name)) {
|
||||
flags.set(name, `${existing}${REPEATED_FLAG_SEPARATOR}${value}`)
|
||||
return
|
||||
}
|
||||
flags.set(name, value)
|
||||
}
|
||||
|
||||
export function parseArgs(argv: string[]): ParsedArgs {
|
||||
const commandPath: string[] = []
|
||||
@@ -35,18 +81,22 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
||||
// treats a `--`-leading next token as a new flag, so it can't express one.
|
||||
const equalsIndex = assignment.indexOf('=')
|
||||
if (equalsIndex !== -1) {
|
||||
flags.set(assignment.slice(0, equalsIndex), assignment.slice(equalsIndex + 1))
|
||||
setFlagValue(flags, assignment.slice(0, equalsIndex), assignment.slice(equalsIndex + 1))
|
||||
continue
|
||||
}
|
||||
|
||||
const flag = assignment
|
||||
if (BOOLEAN_FLAGS.has(flag)) {
|
||||
flags.set(flag, true)
|
||||
continue
|
||||
}
|
||||
const hasNext = i + 1 < argv.length
|
||||
const next = argv[i + 1]
|
||||
if (!hasNext || next.startsWith('--')) {
|
||||
flags.set(flag, true)
|
||||
continue
|
||||
}
|
||||
flags.set(flag, next)
|
||||
setFlagValue(flags, flag, next)
|
||||
i += 1
|
||||
}
|
||||
|
||||
@@ -77,6 +127,7 @@ export function supportsBrowserPageFlag(commandPath: string[]): boolean {
|
||||
if (
|
||||
[
|
||||
'automations',
|
||||
'project',
|
||||
'repo',
|
||||
'worktree',
|
||||
'terminal',
|
||||
@@ -85,7 +136,8 @@ export function supportsBrowserPageFlag(commandPath: string[]): boolean {
|
||||
'computer',
|
||||
'emulator',
|
||||
'note',
|
||||
'diagnostics'
|
||||
'diagnostics',
|
||||
'linear'
|
||||
].includes(commandPath[0])
|
||||
) {
|
||||
return false
|
||||
@@ -105,6 +157,7 @@ export function isCommandGroup(commandPath: string[]): boolean {
|
||||
(commandPath.length === 1 &&
|
||||
[
|
||||
'automations',
|
||||
'project',
|
||||
'repo',
|
||||
'worktree',
|
||||
'terminal',
|
||||
@@ -123,7 +176,8 @@ export function isCommandGroup(commandPath: string[]): boolean {
|
||||
'emulator',
|
||||
'agent',
|
||||
'environment',
|
||||
'diagnostics'
|
||||
'diagnostics',
|
||||
'linear'
|
||||
].includes(commandPath[0])) ||
|
||||
(commandPath.length === 2 && commandPath[0] === 'agent' && commandPath[1] === 'hooks') ||
|
||||
(commandPath.length === 2 &&
|
||||
|
||||
+5
-1
@@ -2,6 +2,7 @@ import type { RuntimeClient } from './runtime-client'
|
||||
import { RuntimeClientError } from './runtime-client'
|
||||
import { CORE_HANDLERS } from './handlers/core'
|
||||
import { AUTOMATION_HANDLERS } from './handlers/automations'
|
||||
import { PROJECT_HANDLERS } from './handlers/project'
|
||||
import { REPO_HANDLERS } from './handlers/repo'
|
||||
import { WORKTREE_HANDLERS } from './handlers/worktree'
|
||||
import { FILE_HANDLERS } from './handlers/file'
|
||||
@@ -20,6 +21,7 @@ import { ENVIRONMENT_HANDLERS } from './handlers/environment'
|
||||
import { AGENT_HOOK_HANDLERS } from './handlers/agent-hooks'
|
||||
import { DIAGNOSTICS_HANDLERS } from './handlers/diagnostics'
|
||||
import { EMULATOR_HANDLERS } from './handlers/emulator'
|
||||
import { LINEAR_HANDLERS } from './handlers/linear'
|
||||
|
||||
export type HandlerContext = {
|
||||
flags: Map<string, string | boolean>
|
||||
@@ -36,6 +38,7 @@ function buildHandlers(): Map<string, CommandHandler> {
|
||||
const groups = [
|
||||
CORE_HANDLERS,
|
||||
AUTOMATION_HANDLERS,
|
||||
PROJECT_HANDLERS,
|
||||
REPO_HANDLERS,
|
||||
WORKTREE_HANDLERS,
|
||||
FILE_HANDLERS,
|
||||
@@ -53,7 +56,8 @@ function buildHandlers(): Map<string, CommandHandler> {
|
||||
COMPUTER_HANDLERS,
|
||||
AGENT_HOOK_HANDLERS,
|
||||
DIAGNOSTICS_HANDLERS,
|
||||
ENVIRONMENT_HANDLERS
|
||||
ENVIRONMENT_HANDLERS,
|
||||
LINEAR_HANDLERS
|
||||
]
|
||||
for (const group of groups) {
|
||||
for (const [key, handler] of Object.entries(group)) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { RuntimeClientError } from './runtime-client'
|
||||
import { REPEATED_FLAG_SEPARATOR } from './args'
|
||||
|
||||
export function getRequiredStringFlag(flags: Map<string, string | boolean>, name: string): string {
|
||||
const value = flags.get(name)
|
||||
@@ -27,6 +28,16 @@ export function getOptionalStringFlag(
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
export function getRepeatedStringFlag(
|
||||
flags: Map<string, string | boolean>,
|
||||
name: string
|
||||
): string[] {
|
||||
const value = getOptionalStringFlag(flags, name)
|
||||
return value === undefined
|
||||
? []
|
||||
: value.split(REPEATED_FLAG_SEPARATOR).filter((entry) => entry.length > 0)
|
||||
}
|
||||
|
||||
export function getOptionalNumberFlag(
|
||||
flags: Map<string, string | boolean>,
|
||||
name: string
|
||||
|
||||
@@ -6,6 +6,7 @@ import { quoteCliCommandArgument } from './shell-command-quote'
|
||||
import { RuntimeRpcFailureError } from './runtime-client'
|
||||
import {
|
||||
formatCliError,
|
||||
formatAutomationShow,
|
||||
formatComputerAction,
|
||||
formatGetAppState,
|
||||
formatTerminalRead,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
printResult
|
||||
} from './format'
|
||||
import type { ComputerActionResult, RuntimeWorktreeRecord } from '../shared/runtime-types'
|
||||
import type { Automation } from '../shared/automations-types'
|
||||
|
||||
let testScreenshotDir: string | null = null
|
||||
|
||||
@@ -140,6 +142,59 @@ describe('formatWorktreeList', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatAutomationShow', () => {
|
||||
function automation(overrides: Partial<Automation> = {}): Automation {
|
||||
return {
|
||||
id: 'auto-1',
|
||||
name: 'Nightly',
|
||||
prompt: 'Run checks',
|
||||
precheck: null,
|
||||
agentId: 'codex',
|
||||
projectId: 'repo-legacy',
|
||||
executionTargetType: 'local',
|
||||
executionTargetId: 'local',
|
||||
schedulerOwner: 'local_host_service',
|
||||
workspaceMode: 'new_per_run',
|
||||
workspaceId: null,
|
||||
baseBranch: null,
|
||||
reuseSession: false,
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: 0,
|
||||
enabled: true,
|
||||
nextRunAt: 0,
|
||||
missedRunPolicy: 'run_once_within_grace',
|
||||
missedRunGraceMinutes: 720,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
it('shows explicit run context before the legacy repo id', () => {
|
||||
const output = formatAutomationShow({
|
||||
automation: automation({
|
||||
runContext: {
|
||||
kind: 'workspace-run',
|
||||
projectId: 'github:stablyai/orca',
|
||||
hostId: 'runtime:gpu',
|
||||
projectHostSetupId: 'setup-gpu',
|
||||
repoId: 'repo-gpu',
|
||||
path: '/srv/orca'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
expect(output).toContain('runProjectId: github:stablyai/orca')
|
||||
expect(output).toContain('runHostId: runtime:gpu')
|
||||
expect(output).toContain('projectHostSetupId: setup-gpu')
|
||||
expect(output).toContain('runRepoId: repo-gpu')
|
||||
expect(output).toContain('runPath: /srv/orca')
|
||||
expect(output).toContain('legacyRepoId: repo-legacy')
|
||||
expect(output).not.toContain('projectId: repo-legacy')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatTerminalRead', () => {
|
||||
it('warns limited cursor reads to continue with the next cursor', () => {
|
||||
const output = formatTerminalRead({
|
||||
|
||||
@@ -22,6 +22,14 @@ export {
|
||||
formatListWindows
|
||||
} from './computer-format'
|
||||
export type { ComputerActionFollowUpTarget } from './computer-format'
|
||||
export {
|
||||
formatProjectHostSetupCreateResult,
|
||||
formatProjectHostSetupDeleteResult,
|
||||
formatProjectHostSetupList,
|
||||
formatProjectHostSetupResult,
|
||||
formatProjectHostSetupUpdateResult,
|
||||
formatProjectList
|
||||
} from './project-format'
|
||||
export {
|
||||
formatTerminalClose,
|
||||
formatTerminalCreate,
|
||||
|
||||
@@ -7,7 +7,13 @@ import type {
|
||||
AutomationSchedulePreset,
|
||||
AutomationUpdateInput
|
||||
} from '../../shared/automations-types'
|
||||
import type { TuiAgent } from '../../shared/types'
|
||||
import {
|
||||
buildWorkspaceRunContext,
|
||||
normalizeTaskSourceContext,
|
||||
type TaskSourceContext,
|
||||
type WorkspaceRunContext
|
||||
} from '../../shared/task-source-context'
|
||||
import type { ProjectHostSetup, TuiAgent } from '../../shared/types'
|
||||
import {
|
||||
DEFAULT_AUTOMATION_PRECHECK_TIMEOUT_SECONDS,
|
||||
MAX_AUTOMATION_PRECHECK_TIMEOUT_SECONDS
|
||||
@@ -30,6 +36,11 @@ import {
|
||||
} from '../flags'
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
import { getOptionalWorktreeSelector, resolveCurrentWorktreeSelector } from '../selectors'
|
||||
import {
|
||||
assertWorkspaceTargetFlagsCompatible,
|
||||
hasWorkspaceProjectTarget,
|
||||
resolveProjectCreateTarget
|
||||
} from '../worktree-project-target'
|
||||
|
||||
type AutomationCreateParams = Omit<AutomationCreateInput, 'projectId' | 'timezone'> & {
|
||||
repo?: string
|
||||
@@ -270,6 +281,46 @@ function getPrecheckFlag(
|
||||
}
|
||||
}
|
||||
|
||||
function getSourceContextFlag(
|
||||
flags: Map<string, string | boolean>
|
||||
): TaskSourceContext | null | undefined {
|
||||
if (!flags.has('source-context')) {
|
||||
return undefined
|
||||
}
|
||||
const value = flags.get('source-context')
|
||||
if (typeof value !== 'string') {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--source-context requires a JSON TaskSourceContext or null'
|
||||
)
|
||||
}
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(value)
|
||||
} catch {
|
||||
throw new RuntimeClientError('invalid_argument', '--source-context must be valid JSON')
|
||||
}
|
||||
if (parsed === null) {
|
||||
return null
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--source-context must be a JSON TaskSourceContext or null'
|
||||
)
|
||||
}
|
||||
const sourceContext = normalizeTaskSourceContext(
|
||||
parsed as Parameters<typeof normalizeTaskSourceContext>[0]
|
||||
)
|
||||
if (!sourceContext) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--source-context is not a valid TaskSourceContext'
|
||||
)
|
||||
}
|
||||
return sourceContext
|
||||
}
|
||||
|
||||
function getWorkspaceModeFlag(
|
||||
flags: Map<string, string | boolean>
|
||||
): 'existing' | 'new_per_run' | undefined {
|
||||
@@ -293,11 +344,25 @@ async function resolveDefaultTarget(
|
||||
flags: Map<string, string | boolean>,
|
||||
cwd: string,
|
||||
client: Parameters<CommandHandler>[0]['client']
|
||||
): Promise<{ repo?: string; workspace?: string }> {
|
||||
): Promise<{ repo?: string; workspace?: string; runContext?: WorkspaceRunContext }> {
|
||||
assertWorkspaceTargetFlagsCompatible(flags)
|
||||
const repo = getOptionalStringFlag(flags, 'repo')
|
||||
if (repo && getOptionalStringFlag(flags, 'workspace')) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Use either --repo or --workspace, not both.')
|
||||
}
|
||||
if (hasWorkspaceProjectTarget(flags) && getOptionalStringFlag(flags, 'workspace')) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Use either --workspace or project target flags, not both.'
|
||||
)
|
||||
}
|
||||
const projectTarget = await resolveProjectCreateTarget(flags, client)
|
||||
if (projectTarget) {
|
||||
return {
|
||||
repo: projectTarget.repoSelector,
|
||||
runContext: buildAutomationRunContextFromSetup(projectTarget.setup)
|
||||
}
|
||||
}
|
||||
const workspace = await getOptionalWorktreeSelector(flags, 'workspace', cwd, client)
|
||||
if (repo || workspace) {
|
||||
return { repo, workspace }
|
||||
@@ -316,15 +381,46 @@ async function getExplicitTarget(
|
||||
flags: Map<string, string | boolean>,
|
||||
cwd: string,
|
||||
client: Parameters<CommandHandler>[0]['client']
|
||||
): Promise<{ repo?: string; workspace?: string }> {
|
||||
): Promise<{ repo?: string; workspace?: string; runContext?: WorkspaceRunContext }> {
|
||||
assertWorkspaceTargetFlagsCompatible(flags)
|
||||
const repo = getOptionalStringFlag(flags, 'repo')
|
||||
if (repo && getOptionalStringFlag(flags, 'workspace')) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Use either --repo or --workspace, not both.')
|
||||
}
|
||||
if (hasWorkspaceProjectTarget(flags) && getOptionalStringFlag(flags, 'workspace')) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Use either --workspace or project target flags, not both.'
|
||||
)
|
||||
}
|
||||
const projectTarget = await resolveProjectCreateTarget(flags, client)
|
||||
if (projectTarget) {
|
||||
return {
|
||||
repo: projectTarget.repoSelector,
|
||||
runContext: buildAutomationRunContextFromSetup(projectTarget.setup)
|
||||
}
|
||||
}
|
||||
const workspace = await getOptionalWorktreeSelector(flags, 'workspace', cwd, client)
|
||||
return { repo, workspace }
|
||||
}
|
||||
|
||||
function buildAutomationRunContextFromSetup(setup: ProjectHostSetup): WorkspaceRunContext {
|
||||
const runContext = buildWorkspaceRunContext({
|
||||
projectId: setup.projectId,
|
||||
hostId: setup.hostId,
|
||||
projectHostSetupId: setup.id,
|
||||
repoId: setup.repoId,
|
||||
path: setup.path
|
||||
})
|
||||
if (!runContext) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`Project host setup is missing automation run context fields: ${setup.id}`
|
||||
)
|
||||
}
|
||||
return runContext
|
||||
}
|
||||
|
||||
export const AUTOMATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
'automations list': async ({ client, json }) => {
|
||||
const result = await client.call<{ automations: Automation[] }>('automation.list')
|
||||
@@ -342,6 +438,7 @@ export const AUTOMATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
throw new RuntimeClientError('invalid_argument', 'Missing required --trigger')
|
||||
}
|
||||
const target = await resolveDefaultTarget(flags, cwd, client)
|
||||
const sourceContext = getSourceContextFlag(flags)
|
||||
const workspaceMode =
|
||||
getWorkspaceModeFlag(flags) ?? (target.workspace ? 'existing' : 'new_per_run')
|
||||
const result = await client.call<{ automation: Automation }>('automation.create', {
|
||||
@@ -349,6 +446,8 @@ export const AUTOMATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
prompt: getRequiredStringFlag(flags, 'prompt'),
|
||||
precheck: getPrecheckFlag(flags),
|
||||
agentId: getProviderFlag(flags),
|
||||
...(target.runContext ? { runContext: target.runContext } : {}),
|
||||
...(sourceContext !== undefined ? { sourceContext } : {}),
|
||||
repo: target.repo,
|
||||
workspace: target.workspace,
|
||||
workspaceMode,
|
||||
@@ -364,6 +463,7 @@ export const AUTOMATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
'automations edit': async ({ flags, client, cwd, json }) => {
|
||||
const target = await getExplicitTarget(flags, cwd, client)
|
||||
const schedule = getScheduleFlag(flags, false)
|
||||
const sourceContext = getSourceContextFlag(flags)
|
||||
const result = await client.call<{ automation: Automation }>('automation.update', {
|
||||
id: getRequiredStringFlag(flags, 'id'),
|
||||
updates: {
|
||||
@@ -371,6 +471,8 @@ export const AUTOMATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
prompt: getOptionalStringFlag(flags, 'prompt'),
|
||||
precheck: getPrecheckFlag(flags),
|
||||
agentId: getOptionalProviderFlag(flags),
|
||||
...(target.runContext ? { runContext: target.runContext } : {}),
|
||||
...(sourceContext !== undefined ? { sourceContext } : {}),
|
||||
repo: target.repo,
|
||||
workspace: target.workspace,
|
||||
workspaceMode: getWorkspaceModeFlag(flags),
|
||||
|
||||
@@ -0,0 +1,585 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const callMock = vi.fn()
|
||||
|
||||
vi.mock('../runtime-client', () => {
|
||||
class RuntimeClient {
|
||||
readonly isRemote: boolean
|
||||
call = callMock
|
||||
getCliStatus = vi.fn()
|
||||
openOrca = vi.fn()
|
||||
|
||||
constructor(
|
||||
_userDataPath?: string,
|
||||
_requestTimeoutMs?: number,
|
||||
remotePairingCode = process.env.ORCA_PAIRING_CODE ?? null,
|
||||
environmentSelector = process.env.ORCA_ENVIRONMENT ?? null
|
||||
) {
|
||||
this.isRemote = Boolean(remotePairingCode || environmentSelector)
|
||||
}
|
||||
}
|
||||
|
||||
class RuntimeClientError extends Error {
|
||||
readonly code: string
|
||||
|
||||
constructor(code: string, message: string) {
|
||||
super(message)
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
class RuntimeRpcFailureError extends RuntimeClientError {
|
||||
readonly response: unknown
|
||||
|
||||
constructor(response: unknown) {
|
||||
super('runtime_error', 'runtime_error')
|
||||
this.response = response
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
RuntimeClient,
|
||||
RuntimeClientError,
|
||||
RuntimeRpcFailureError
|
||||
}
|
||||
})
|
||||
|
||||
import { main } from '../index'
|
||||
import { okFixture, queueFixtures } from '../test-fixtures'
|
||||
|
||||
describe('orca linear CLI handlers', () => {
|
||||
const originalEnv = { ...process.env }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
callMock.mockReset()
|
||||
process.env = { ...originalEnv }
|
||||
// Why: these tests can run inside an Orca-managed terminal, which exports
|
||||
// real worktree/terminal/pairing env hints; clear them so handler context
|
||||
// assertions stay deterministic.
|
||||
delete process.env.ORCA_WORKTREE_ID
|
||||
delete process.env.ORCA_TERMINAL_HANDLE
|
||||
delete process.env.ORCA_PAIRING_CODE
|
||||
delete process.env.ORCA_ENVIRONMENT
|
||||
process.exitCode = undefined
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('maps --full issue reads to read-only issueContext RPC', async () => {
|
||||
queueFixtures(callMock, okFixture('req_linear', issueResult()))
|
||||
|
||||
await main(['linear', 'issue', 'ENG-123', '--full', '--json'], '/tmp/repo')
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'linear.issueContext',
|
||||
{
|
||||
input: 'ENG-123',
|
||||
current: false,
|
||||
workspaceId: undefined,
|
||||
include: {
|
||||
comments: true,
|
||||
children: true,
|
||||
attachments: true,
|
||||
relations: true
|
||||
},
|
||||
depth: 2,
|
||||
context: {
|
||||
remote: false,
|
||||
cwd: '/tmp/repo'
|
||||
}
|
||||
},
|
||||
{ timeoutMs: 120_000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps global boolean flags before Linear commands from consuming command tokens', async () => {
|
||||
queueFixtures(callMock, okFixture('req_linear', issueResult()))
|
||||
|
||||
await main(['--json', 'linear', 'issue', 'ENG-123', '--full'], '/tmp/repo')
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'linear.issueContext',
|
||||
expect.objectContaining({
|
||||
input: 'ENG-123',
|
||||
include: expect.objectContaining({
|
||||
comments: true,
|
||||
children: true,
|
||||
attachments: true,
|
||||
relations: true
|
||||
})
|
||||
}),
|
||||
{ timeoutMs: 120_000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('passes verified current-context hints without resolving cwd for remote runtimes', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_123'
|
||||
process.env.ORCA_WORKTREE_ID = 'repo::/srv/app'
|
||||
process.env.ORCA_PAIRING_CODE = 'orca://pair?payload=bad'
|
||||
queueFixtures(callMock, okFixture('req_linear', issueResult()))
|
||||
|
||||
await main(['linear', 'issue', '--current', '--comments', '--json'], '/client/repo')
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'linear.issueContext',
|
||||
expect.objectContaining({
|
||||
input: undefined,
|
||||
current: true,
|
||||
include: expect.objectContaining({ comments: true }),
|
||||
context: {
|
||||
remote: true,
|
||||
worktreeId: 'repo::/srv/app',
|
||||
terminalHandle: 'term_123'
|
||||
}
|
||||
}),
|
||||
{ timeoutMs: undefined }
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects --depth unless children are requested', async () => {
|
||||
await main(['linear', 'issue', 'ENG-123', '--depth', '3'], '/tmp/repo')
|
||||
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(console.error).mock.calls[0][0]).toContain(
|
||||
'--depth requires --children or --full'
|
||||
)
|
||||
expect(process.exitCode).toBe(1)
|
||||
})
|
||||
|
||||
it('maps search to agent search RPC with capped limit', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_search', {
|
||||
issues: [],
|
||||
meta: { query: 'auth', workspaceId: 'all', limit: 50, returned: 0, limitReached: false }
|
||||
})
|
||||
)
|
||||
|
||||
await main(['linear', 'search', 'auth', '--workspace', 'all', '--limit', '500'], '/tmp/repo')
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('linear.agentSearchIssues', {
|
||||
query: 'auth',
|
||||
limit: 50,
|
||||
workspaceId: 'all'
|
||||
})
|
||||
})
|
||||
|
||||
it('maps project list to agent project RPC with capped limit', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_projects', {
|
||||
projects: [],
|
||||
meta: {
|
||||
query: 'launch',
|
||||
workspaceId: 'all',
|
||||
limit: 50,
|
||||
returned: 0,
|
||||
hasMore: false,
|
||||
partial: false,
|
||||
workspaceErrors: []
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
await main(
|
||||
['linear', 'project', 'list', '--query', 'launch', '--workspace', 'all', '--limit', '500'],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('linear.agentProjectList', {
|
||||
query: 'launch',
|
||||
limit: 50,
|
||||
workspaceId: 'all'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps boolean flags between Linear and search from consuming the subcommand', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_search', {
|
||||
issues: [],
|
||||
meta: { query: 'auth', workspaceId: undefined, limit: 1, returned: 0, limitReached: false }
|
||||
})
|
||||
)
|
||||
|
||||
await main(['linear', '--json', 'search', 'auth', '--limit', '1'], '/tmp/repo')
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('linear.agentSearchIssues', {
|
||||
query: 'auth',
|
||||
limit: 1,
|
||||
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('maps priority writes with Linear API priority numbering', async () => {
|
||||
queueFixtures(callMock, okFixture('req_priority', taskUpdateResult('priority')))
|
||||
|
||||
await main(['linear', 'priority', 'set', 'ENG-123', '--to', 'urgent', '--json'], '/tmp/repo')
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'linear.issueUpdateTask',
|
||||
expect.objectContaining({
|
||||
input: 'ENG-123',
|
||||
operation: 'priority',
|
||||
priority: 1
|
||||
}),
|
||||
{ timeoutMs: 75_000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects impossible due dates before dispatch', async () => {
|
||||
await main(['linear', 'due-date', 'set', 'ENG-123', '--to', '2026-02-30'], '/tmp/repo')
|
||||
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
expect(vi.mocked(console.error).mock.calls[0][0]).toContain('real calendar date')
|
||||
})
|
||||
|
||||
it('preserves repeated labels for label updates', async () => {
|
||||
queueFixtures(callMock, okFixture('req_label', taskUpdateResult('labels')))
|
||||
|
||||
await main(
|
||||
['linear', 'label', 'add', 'ENG-123', '--label', 'Bug', '--label', 'Regression', '--json'],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'linear.issueUpdateTask',
|
||||
expect.objectContaining({
|
||||
input: 'ENG-123',
|
||||
operation: 'labels',
|
||||
labelMode: 'add',
|
||||
labels: ['Bug', 'Regression']
|
||||
}),
|
||||
{ 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',
|
||||
teamInput: undefined,
|
||||
state: undefined,
|
||||
assignee: undefined,
|
||||
priority: undefined,
|
||||
estimate: undefined,
|
||||
dueDate: undefined,
|
||||
labels: [],
|
||||
parentInput: undefined,
|
||||
parentCurrent: true,
|
||||
workspaceId: undefined,
|
||||
writeId: undefined,
|
||||
context: {
|
||||
remote: false,
|
||||
cwd: '/tmp/repo'
|
||||
}
|
||||
},
|
||||
{ timeoutMs: 75_000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('maps enriched create task fields', async () => {
|
||||
queueFixtures(callMock, okFixture('req_create', createResult()))
|
||||
|
||||
await main(
|
||||
[
|
||||
'linear',
|
||||
'create',
|
||||
'--title',
|
||||
'Triage bug',
|
||||
'--team',
|
||||
'ENG',
|
||||
'--project',
|
||||
'project-1',
|
||||
'--state',
|
||||
'Todo',
|
||||
'--assignee',
|
||||
'me',
|
||||
'--priority',
|
||||
'high',
|
||||
'--estimate',
|
||||
'2',
|
||||
'--due-date',
|
||||
'2026-06-30',
|
||||
'--label',
|
||||
'Bug',
|
||||
'--label',
|
||||
'Regression',
|
||||
'--json'
|
||||
],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'linear.issueCreate',
|
||||
expect.objectContaining({
|
||||
title: 'Triage bug',
|
||||
teamInput: 'ENG',
|
||||
projectInput: 'project-1',
|
||||
state: 'Todo',
|
||||
assignee: 'me',
|
||||
priority: 2,
|
||||
estimate: 2,
|
||||
dueDate: '2026-06-30',
|
||||
labels: ['Bug', 'Regression']
|
||||
}),
|
||||
{ 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 {
|
||||
return {
|
||||
issue: {
|
||||
id: 'issue-id',
|
||||
identifier: 'ENG-123',
|
||||
title: 'Fix auth',
|
||||
url: 'https://linear.app/acme/issue/ENG-123',
|
||||
state: { name: 'Todo' },
|
||||
team: { name: 'Engineering' },
|
||||
labels: []
|
||||
},
|
||||
meta: {
|
||||
requested: {
|
||||
current: false,
|
||||
include: { comments: false, children: false, attachments: false, relations: false },
|
||||
depth: 2
|
||||
},
|
||||
resolved: {
|
||||
id: 'issue-id',
|
||||
identifier: 'ENG-123',
|
||||
workspaceId: 'workspace-1',
|
||||
workspaceName: 'Acme'
|
||||
},
|
||||
partial: false,
|
||||
includeErrors: [],
|
||||
sections: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 taskUpdateResult(operation: string): unknown {
|
||||
return {
|
||||
issue: { id: 'issue-id', identifier: 'ENG-123', url: 'https://linear.app/acme/issue/ENG-123' },
|
||||
operation,
|
||||
previous: {},
|
||||
current: {},
|
||||
meta: { workspaceId: 'workspace-1', alreadySet: 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import type {
|
||||
LinearAttachRequest,
|
||||
LinearAttachResult,
|
||||
LinearCommentAddRequest,
|
||||
LinearCommentAddResult,
|
||||
LinearCreateRequest,
|
||||
LinearCreateResult,
|
||||
LinearIssueListRequest,
|
||||
LinearIssueListResult,
|
||||
LinearIssueContextResult,
|
||||
LinearProjectListRequest,
|
||||
LinearProjectListResult,
|
||||
LinearIssueTaskUpdateRequest,
|
||||
LinearIssueTaskUpdateResult,
|
||||
LinearSearchResult,
|
||||
LinearStatusSetRequest,
|
||||
LinearStatusSetResult,
|
||||
LinearTeamLabelsResult,
|
||||
LinearTeamListResult,
|
||||
LinearTeamMembersResult,
|
||||
LinearTeamStatesResult
|
||||
} from '../../shared/linear-agent-access'
|
||||
import { clampLinearSearchLimit } from '../../shared/linear-agent-access'
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import { printResult } from '../format'
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
import {
|
||||
getOptionalPositiveIntegerFlag,
|
||||
getOptionalStringFlag,
|
||||
getRepeatedStringFlag,
|
||||
getRequiredStringFlag
|
||||
} from '../flags'
|
||||
import {
|
||||
buildAssigneeSetRequest,
|
||||
buildIssueRequest,
|
||||
buildLinearCurrentContext,
|
||||
buildWriteTargetRequest,
|
||||
getDueDateFlag,
|
||||
getHttpUrlFlag,
|
||||
getLinearListFilter,
|
||||
getOptionalWriteId,
|
||||
getPriorityFlag,
|
||||
getRequiredNonNegativeIntegerFlag,
|
||||
getRequiredRepeatedStringFlag,
|
||||
readLinearBody,
|
||||
rejectAllWorkspaceForWrite
|
||||
} from '../linear-request-builders'
|
||||
import {
|
||||
formatLinearAttach,
|
||||
formatLinearCommentAdd,
|
||||
formatLinearCreate,
|
||||
formatLinearIssue,
|
||||
formatLinearIssueList,
|
||||
formatLinearProjectList,
|
||||
formatLinearTaskUpdate,
|
||||
formatLinearSearch,
|
||||
formatLinearStatusSet,
|
||||
formatLinearTeamLabels,
|
||||
formatLinearTeamList,
|
||||
formatLinearTeamMembers,
|
||||
formatLinearTeamStates,
|
||||
printLinearIssueWarnings,
|
||||
printLinearListWarnings,
|
||||
printLinearProjectListWarnings,
|
||||
printLinearSearchWarnings
|
||||
} from '../linear-format'
|
||||
|
||||
const ISSUE_CONTEXT_TIMEOUT_MS = 120_000
|
||||
const LINEAR_WRITE_TIMEOUT_MS = 75_000
|
||||
|
||||
export const LINEAR_HANDLERS: Record<string, CommandHandler> = {
|
||||
'linear issue': async ({ flags, client, cwd, json }) => {
|
||||
const request = buildIssueRequest(flags, cwd, client.isRemote)
|
||||
const response = await client.call<LinearIssueContextResult>('linear.issueContext', request, {
|
||||
timeoutMs: flags.get('full') === true ? ISSUE_CONTEXT_TIMEOUT_MS : undefined
|
||||
})
|
||||
if (!json) {
|
||||
printLinearIssueWarnings(response.result)
|
||||
}
|
||||
printResult(response, json, formatLinearIssue)
|
||||
},
|
||||
'linear search': async ({ flags, client, json }) => {
|
||||
const limit = clampLinearSearchLimit(getOptionalPositiveIntegerFlag(flags, 'limit'))
|
||||
const response = await client.call<LinearSearchResult>('linear.agentSearchIssues', {
|
||||
query: getRequiredStringFlag(flags, 'query'),
|
||||
limit,
|
||||
workspaceId: getOptionalStringFlag(flags, 'workspace')
|
||||
})
|
||||
if (!json) {
|
||||
printLinearSearchWarnings(response.result)
|
||||
}
|
||||
printResult(response, json, formatLinearSearch)
|
||||
},
|
||||
'linear team list': async ({ flags, client, json }) => {
|
||||
const response = await client.call<LinearTeamListResult>('linear.agentTeamList', {
|
||||
workspaceId: getOptionalStringFlag(flags, 'workspace')
|
||||
})
|
||||
if (!json) {
|
||||
printLinearListWarnings(response.result)
|
||||
}
|
||||
printResult(response, json, formatLinearTeamList)
|
||||
},
|
||||
'linear team members': async ({ flags, client, json }) => {
|
||||
const response = await client.call<LinearTeamMembersResult>('linear.agentTeamMembers', {
|
||||
teamInput: getRequiredStringFlag(flags, 'team'),
|
||||
workspaceId: getOptionalStringFlag(flags, 'workspace')
|
||||
})
|
||||
printResult(response, json, formatLinearTeamMembers)
|
||||
},
|
||||
'linear team states': async ({ flags, client, json }) => {
|
||||
const response = await client.call<LinearTeamStatesResult>('linear.agentTeamStates', {
|
||||
teamInput: getRequiredStringFlag(flags, 'team'),
|
||||
workspaceId: getOptionalStringFlag(flags, 'workspace')
|
||||
})
|
||||
printResult(response, json, formatLinearTeamStates)
|
||||
},
|
||||
'linear team labels': async ({ flags, client, json }) => {
|
||||
const response = await client.call<LinearTeamLabelsResult>('linear.agentTeamLabels', {
|
||||
teamInput: getRequiredStringFlag(flags, 'team'),
|
||||
workspaceId: getOptionalStringFlag(flags, 'workspace')
|
||||
})
|
||||
printResult(response, json, formatLinearTeamLabels)
|
||||
},
|
||||
'linear project list': async ({ flags, client, json }) => {
|
||||
const limit = clampLinearSearchLimit(getOptionalPositiveIntegerFlag(flags, 'limit'))
|
||||
const request: LinearProjectListRequest = {
|
||||
query: getOptionalStringFlag(flags, 'query'),
|
||||
limit,
|
||||
workspaceId: getOptionalStringFlag(flags, 'workspace')
|
||||
}
|
||||
const response = await client.call<LinearProjectListResult>('linear.agentProjectList', request)
|
||||
if (!json) {
|
||||
printLinearProjectListWarnings(response.result)
|
||||
}
|
||||
printResult(response, json, formatLinearProjectList)
|
||||
},
|
||||
'linear list': async ({ flags, client, json }) => {
|
||||
const limit = getOptionalPositiveIntegerFlag(flags, 'limit')
|
||||
const filter = getLinearListFilter(flags)
|
||||
const request: LinearIssueListRequest = {
|
||||
filter,
|
||||
teamInput: getOptionalStringFlag(flags, 'team'),
|
||||
limit,
|
||||
workspaceId: getOptionalStringFlag(flags, 'workspace')
|
||||
}
|
||||
const response = await client.call<LinearIssueListResult>('linear.agentIssueList', request)
|
||||
if (!json) {
|
||||
printLinearListWarnings(response.result)
|
||||
}
|
||||
printResult(response, json, formatLinearIssueList)
|
||||
},
|
||||
'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 assignee set': async (ctx) =>
|
||||
runTaskUpdate(ctx, buildAssigneeSetRequest(ctx.flags, ctx.cwd, ctx.client.isRemote)),
|
||||
'linear assignee clear': async (ctx) =>
|
||||
runTaskUpdate(ctx, {
|
||||
...buildWriteTargetRequest(ctx.flags, ctx.cwd, ctx.client.isRemote),
|
||||
operation: 'assignee',
|
||||
assigneeId: null
|
||||
}),
|
||||
'linear priority set': async (ctx) =>
|
||||
runTaskUpdate(ctx, {
|
||||
...buildWriteTargetRequest(ctx.flags, ctx.cwd, ctx.client.isRemote),
|
||||
operation: 'priority',
|
||||
priority: getPriorityFlag(ctx.flags, 'to')
|
||||
}),
|
||||
'linear priority clear': async (ctx) =>
|
||||
runTaskUpdate(ctx, {
|
||||
...buildWriteTargetRequest(ctx.flags, ctx.cwd, ctx.client.isRemote),
|
||||
operation: 'priority',
|
||||
priority: 0
|
||||
}),
|
||||
'linear estimate set': async (ctx) =>
|
||||
runTaskUpdate(ctx, {
|
||||
...buildWriteTargetRequest(ctx.flags, ctx.cwd, ctx.client.isRemote),
|
||||
operation: 'estimate',
|
||||
estimate: getRequiredNonNegativeIntegerFlag(ctx.flags, 'to')
|
||||
}),
|
||||
'linear estimate clear': async (ctx) =>
|
||||
runTaskUpdate(ctx, {
|
||||
...buildWriteTargetRequest(ctx.flags, ctx.cwd, ctx.client.isRemote),
|
||||
operation: 'estimate',
|
||||
estimate: null
|
||||
}),
|
||||
'linear due-date set': async (ctx) =>
|
||||
runTaskUpdate(ctx, {
|
||||
...buildWriteTargetRequest(ctx.flags, ctx.cwd, ctx.client.isRemote),
|
||||
operation: 'dueDate',
|
||||
dueDate: getDueDateFlag(ctx.flags, 'to')
|
||||
}),
|
||||
'linear due-date clear': async (ctx) =>
|
||||
runTaskUpdate(ctx, {
|
||||
...buildWriteTargetRequest(ctx.flags, ctx.cwd, ctx.client.isRemote),
|
||||
operation: 'dueDate',
|
||||
dueDate: null
|
||||
}),
|
||||
'linear label add': async (ctx) => runLabelUpdate(ctx, 'add'),
|
||||
'linear label remove': async (ctx) => runLabelUpdate(ctx, 'remove'),
|
||||
'linear label set': async (ctx) => runLabelUpdate(ctx, 'set'),
|
||||
'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 } : {}),
|
||||
teamInput: getOptionalStringFlag(flags, 'team'),
|
||||
projectInput: getOptionalStringFlag(flags, 'project'),
|
||||
state: getOptionalStringFlag(flags, 'state'),
|
||||
assignee: getOptionalStringFlag(flags, 'assignee'),
|
||||
priority: flags.has('priority') ? getPriorityFlag(flags, 'priority') : undefined,
|
||||
estimate: flags.has('estimate')
|
||||
? getRequiredNonNegativeIntegerFlag(flags, 'estimate')
|
||||
: undefined,
|
||||
dueDate: flags.has('due-date') ? getDueDateFlag(flags, 'due-date') : undefined,
|
||||
labels: getRepeatedStringFlag(flags, 'label'),
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
async function runTaskUpdate(
|
||||
{ client, json }: Parameters<CommandHandler>[0],
|
||||
request: LinearIssueTaskUpdateRequest
|
||||
): Promise<void> {
|
||||
const response = await client.call<LinearIssueTaskUpdateResult>(
|
||||
'linear.issueUpdateTask',
|
||||
request,
|
||||
{
|
||||
timeoutMs: LINEAR_WRITE_TIMEOUT_MS
|
||||
}
|
||||
)
|
||||
printResult(response, json, formatLinearTaskUpdate)
|
||||
}
|
||||
|
||||
function runLabelUpdate(
|
||||
ctx: Parameters<CommandHandler>[0],
|
||||
labelMode: 'add' | 'remove' | 'set'
|
||||
): Promise<void> {
|
||||
return runTaskUpdate(ctx, {
|
||||
...buildWriteTargetRequest(ctx.flags, ctx.cwd, ctx.client.isRemote),
|
||||
operation: 'labels',
|
||||
labelMode,
|
||||
labels: getRequiredRepeatedStringFlag(ctx.flags, 'label')
|
||||
})
|
||||
}
|
||||
@@ -1,14 +1,20 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const callMock = vi.fn()
|
||||
const getTerminalHandleMock = vi.hoisted(() => vi.fn())
|
||||
const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE
|
||||
function lifecycleGroupRecipientError(type: 'worker_done' | 'heartbeat'): string {
|
||||
return `${type} messages must be sent to a concrete coordinator terminal handle, not a group address.`
|
||||
}
|
||||
|
||||
// Why: isolate the handler's flag-to-param mapping; printResult only writes output.
|
||||
vi.mock('../format', () => ({ printResult: vi.fn() }))
|
||||
vi.mock('../selectors', () => ({ getTerminalHandle: getTerminalHandleMock }))
|
||||
|
||||
import { ORCHESTRATION_HANDLERS } from './orchestration'
|
||||
|
||||
afterEach(() => {
|
||||
getTerminalHandleMock.mockReset()
|
||||
if (originalTerminalHandle === undefined) {
|
||||
delete process.env.ORCA_TERMINAL_HANDLE
|
||||
} else {
|
||||
@@ -59,6 +65,7 @@ describe('orchestration reset CLI handler', () => {
|
||||
describe('orchestration send structured payload flags', () => {
|
||||
beforeEach(() => {
|
||||
callMock.mockReset().mockResolvedValue({ result: { message: { id: 'msg_1' } } })
|
||||
getTerminalHandleMock.mockReset()
|
||||
delete process.env.ORCA_TERMINAL_HANDLE
|
||||
})
|
||||
|
||||
@@ -116,6 +123,69 @@ describe('orchestration send structured payload flags', () => {
|
||||
).rejects.toThrow(/structured payload/)
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects worker_done group sends before resolving a sender handle', async () => {
|
||||
getTerminalHandleMock.mockRejectedValue(new Error('sender resolution should not run'))
|
||||
|
||||
await expect(
|
||||
invokeSend(
|
||||
new Map<string, string | boolean>([
|
||||
['to', '@all'],
|
||||
['subject', 'done'],
|
||||
['type', 'worker_done']
|
||||
])
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: 'invalid_argument',
|
||||
message: lifecycleGroupRecipientError('worker_done')
|
||||
})
|
||||
|
||||
expect(getTerminalHandleMock).not.toHaveBeenCalled()
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects heartbeat group sends before resolving a sender handle', async () => {
|
||||
getTerminalHandleMock.mockRejectedValue(new Error('sender resolution should not run'))
|
||||
|
||||
await expect(
|
||||
invokeSend(
|
||||
new Map<string, string | boolean>([
|
||||
['to', '@idle'],
|
||||
['subject', 'alive'],
|
||||
['type', 'heartbeat']
|
||||
])
|
||||
)
|
||||
).rejects.toMatchObject({
|
||||
code: 'invalid_argument',
|
||||
message: lifecycleGroupRecipientError('heartbeat')
|
||||
})
|
||||
|
||||
expect(getTerminalHandleMock).not.toHaveBeenCalled()
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('continues to allow worker_done to a concrete terminal handle', async () => {
|
||||
await invokeSend(
|
||||
new Map<string, string | boolean>([
|
||||
['from', 'term_worker'],
|
||||
['to', 'term_coord'],
|
||||
['subject', 'done'],
|
||||
['type', 'worker_done']
|
||||
])
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.send', {
|
||||
from: 'term_worker',
|
||||
to: 'term_coord',
|
||||
subject: 'done',
|
||||
body: undefined,
|
||||
type: 'worker_done',
|
||||
priority: undefined,
|
||||
threadId: undefined,
|
||||
payload: undefined,
|
||||
devMode: false
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('orchestration timeout flag validation', () => {
|
||||
|
||||
@@ -14,6 +14,9 @@ import { getTerminalHandle } from '../selectors'
|
||||
// parent process the subprocess is alive without flooding logs. See design
|
||||
// doc §3.4.
|
||||
const DEFAULT_HEARTBEAT_INTERVAL_MS = 15_000
|
||||
function getLifecycleGroupRecipientError(type: 'worker_done' | 'heartbeat'): string {
|
||||
return `${type} messages must be sent to a concrete coordinator terminal handle, not a group address.`
|
||||
}
|
||||
|
||||
// Why: test-only escape hatch so subprocess tests can verify the feature in
|
||||
// under 10 s rather than needing a full 15 s silence window. Production users
|
||||
@@ -162,17 +165,27 @@ function getOptionalPositiveIntegerValueFlag(
|
||||
return value
|
||||
}
|
||||
|
||||
function rejectLifecycleGroupRecipient(type: string | undefined, to: string): void {
|
||||
if ((type === 'worker_done' || type === 'heartbeat') && to.startsWith('@')) {
|
||||
throw new RuntimeClientError('invalid_argument', getLifecycleGroupRecipientError(type))
|
||||
}
|
||||
}
|
||||
|
||||
export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
'orchestration send': async ({ flags, client, cwd, json }) => {
|
||||
const to = getRequiredStringFlag(flags, 'to')
|
||||
const type = getOptionalStringFlag(flags, 'type')
|
||||
rejectLifecycleGroupRecipient(type, to)
|
||||
|
||||
const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from')
|
||||
const result = await client.call<
|
||||
{ message: { id: string } } | { messages: { id: string }[]; recipients: number }
|
||||
>('orchestration.send', {
|
||||
from,
|
||||
to: getRequiredStringFlag(flags, 'to'),
|
||||
to,
|
||||
subject: getRequiredStringFlag(flags, 'subject'),
|
||||
body: getOptionalStringFlag(flags, 'body'),
|
||||
type: getOptionalStringFlag(flags, 'type'),
|
||||
type,
|
||||
priority: getOptionalStringFlag(flags, 'priority'),
|
||||
threadId: getOptionalStringFlag(flags, 'thread-id'),
|
||||
payload: getOptionalStructuredMessagePayload(flags),
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import type {
|
||||
Project,
|
||||
ProjectHostSetup,
|
||||
ProjectHostSetupCloneArgs,
|
||||
ProjectHostSetupCreateArgs,
|
||||
ProjectHostSetupCreateResult,
|
||||
ProjectHostSetupDeleteResult,
|
||||
ProjectHostSetupExistingFolderArgs,
|
||||
ProjectHostSetupResult,
|
||||
ProjectHostSetupUpdateArgs,
|
||||
ProjectHostSetupUpdateResult,
|
||||
RepoKind
|
||||
} from '../../shared/types'
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import {
|
||||
formatProjectHostSetupCreateResult,
|
||||
formatProjectHostSetupDeleteResult,
|
||||
formatProjectHostSetupList,
|
||||
formatProjectHostSetupResult,
|
||||
formatProjectHostSetupUpdateResult,
|
||||
formatProjectList,
|
||||
printResult
|
||||
} from '../format'
|
||||
import { getOptionalStringFlag, getRequiredStringFlag } from '../flags'
|
||||
import { resolveRepoPathArgument } from '../repo-path-arguments'
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
|
||||
function getOptionalRepoKind(flags: Map<string, string | boolean>): RepoKind | undefined {
|
||||
const kind = getOptionalStringFlag(flags, 'kind')
|
||||
if (kind === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (kind === 'git' || kind === 'folder') {
|
||||
return kind
|
||||
}
|
||||
throw new RuntimeClientError('invalid_argument', '--kind must be git or folder')
|
||||
}
|
||||
|
||||
export const PROJECT_HANDLERS: Record<string, CommandHandler> = {
|
||||
'project list': async ({ client, json }) => {
|
||||
const result = await client.call<{ projects: Project[] }>('project.list')
|
||||
printResult(result, json, formatProjectList)
|
||||
},
|
||||
'project setups': async ({ flags, client, json }) => {
|
||||
const projectFilter = getOptionalStringFlag(flags, 'project')
|
||||
const hostFilter = getOptionalStringFlag(flags, 'host')
|
||||
const result = await client.call<{ setups: ProjectHostSetup[] }>('projectHostSetup.list')
|
||||
const setups = result.result.setups.filter(
|
||||
(setup) =>
|
||||
(projectFilter === undefined || setup.projectId === projectFilter) &&
|
||||
(hostFilter === undefined || setup.hostId === hostFilter)
|
||||
)
|
||||
printResult({ ...result, result: { setups } }, json, formatProjectHostSetupList)
|
||||
},
|
||||
'project setup-existing-folder': async ({ flags, client, cwd, json }) => {
|
||||
const rawPath = getRequiredStringFlag(flags, 'path')
|
||||
const args: ProjectHostSetupExistingFolderArgs = {
|
||||
projectId: getRequiredStringFlag(flags, 'project'),
|
||||
hostId: getRequiredStringFlag(flags, 'host') as ProjectHostSetupExistingFolderArgs['hostId'],
|
||||
path: resolveRepoPathArgument(rawPath, cwd, client.isRemote, 'Remote project setup'),
|
||||
kind: getOptionalRepoKind(flags),
|
||||
displayName: getOptionalStringFlag(flags, 'display-name')
|
||||
}
|
||||
const result = await client.call<{ result: ProjectHostSetupResult }>(
|
||||
'projectHostSetup.setupExistingFolder',
|
||||
args
|
||||
)
|
||||
printResult(result, json, formatProjectHostSetupResult)
|
||||
},
|
||||
'project setup-clone': async ({ flags, client, cwd, json }) => {
|
||||
const rawDestination = getRequiredStringFlag(flags, 'destination')
|
||||
const args: ProjectHostSetupCloneArgs = {
|
||||
projectId: getRequiredStringFlag(flags, 'project'),
|
||||
hostId: getRequiredStringFlag(flags, 'host') as ProjectHostSetupCloneArgs['hostId'],
|
||||
url: getRequiredStringFlag(flags, 'url'),
|
||||
destination: resolveRepoPathArgument(
|
||||
rawDestination,
|
||||
cwd,
|
||||
client.isRemote,
|
||||
'Project setup clone'
|
||||
),
|
||||
displayName: getOptionalStringFlag(flags, 'display-name')
|
||||
}
|
||||
const result = await client.call<{ result: ProjectHostSetupResult }>(
|
||||
'projectHostSetup.clone',
|
||||
args
|
||||
)
|
||||
printResult(result, json, formatProjectHostSetupResult)
|
||||
},
|
||||
'project setup-create': async ({ flags, client, cwd, json }) => {
|
||||
const path = getOptionalStringFlag(flags, 'path')
|
||||
const args: ProjectHostSetupCreateArgs = {
|
||||
projectId: getRequiredStringFlag(flags, 'project'),
|
||||
hostId: getRequiredStringFlag(flags, 'host') as ProjectHostSetupCreateArgs['hostId'],
|
||||
setupId: getOptionalStringFlag(flags, 'setup-id'),
|
||||
path:
|
||||
path === undefined
|
||||
? undefined
|
||||
: resolveRepoPathArgument(path, cwd, client.isRemote, 'Project setup create'),
|
||||
kind: getOptionalRepoKind(flags),
|
||||
displayName: getOptionalStringFlag(flags, 'display-name'),
|
||||
worktreeBasePath: getOptionalStringFlag(flags, 'worktree-base-path'),
|
||||
gitUsername: getOptionalStringFlag(flags, 'git-username'),
|
||||
setupState: getOptionalSetupState(flags),
|
||||
setupMethod: getOptionalIndependentSetupMethod(flags)
|
||||
}
|
||||
const result = await client.call<{ result: ProjectHostSetupCreateResult }>(
|
||||
'projectHostSetup.create',
|
||||
args
|
||||
)
|
||||
printResult(result, json, formatProjectHostSetupCreateResult)
|
||||
},
|
||||
'project setup-update': async ({ flags, client, cwd, json }) => {
|
||||
const path = getOptionalStringFlag(flags, 'path')
|
||||
const args: ProjectHostSetupUpdateArgs = {
|
||||
setupId: getRequiredStringFlag(flags, 'setup'),
|
||||
updates: {
|
||||
displayName: getOptionalStringFlag(flags, 'display-name'),
|
||||
path:
|
||||
path === undefined
|
||||
? undefined
|
||||
: resolveRepoPathArgument(path, cwd, client.isRemote, 'Project setup update'),
|
||||
worktreeBasePath: getOptionalStringFlag(flags, 'worktree-base-path'),
|
||||
gitUsername: getOptionalStringFlag(flags, 'git-username'),
|
||||
kind: getOptionalRepoKind(flags),
|
||||
setupState: getOptionalSetupState(flags),
|
||||
setupMethod: getOptionalSetupMethod(flags)
|
||||
}
|
||||
}
|
||||
const result = await client.call<{ result: ProjectHostSetupUpdateResult }>(
|
||||
'projectHostSetup.update',
|
||||
args
|
||||
)
|
||||
printResult(result, json, formatProjectHostSetupUpdateResult)
|
||||
},
|
||||
'project setup-delete': async ({ flags, client, json }) => {
|
||||
const result = await client.call<{ result: ProjectHostSetupDeleteResult }>(
|
||||
'projectHostSetup.delete',
|
||||
{
|
||||
setupId: getRequiredStringFlag(flags, 'setup')
|
||||
}
|
||||
)
|
||||
printResult(result, json, formatProjectHostSetupDeleteResult)
|
||||
}
|
||||
}
|
||||
|
||||
function getOptionalSetupState(
|
||||
flags: Map<string, string | boolean>
|
||||
): ProjectHostSetupUpdateArgs['updates']['setupState'] {
|
||||
const state = getOptionalStringFlag(flags, 'state')
|
||||
if (state === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (
|
||||
state === 'ready' ||
|
||||
state === 'not-set-up' ||
|
||||
state === 'setting-up' ||
|
||||
state === 'error' ||
|
||||
state === 'unsupported'
|
||||
) {
|
||||
return state
|
||||
}
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--state must be ready, not-set-up, setting-up, error, or unsupported'
|
||||
)
|
||||
}
|
||||
|
||||
function getOptionalIndependentSetupMethod(
|
||||
flags: Map<string, string | boolean>
|
||||
): ProjectHostSetupCreateArgs['setupMethod'] {
|
||||
const method = getOptionalStringFlag(flags, 'method')
|
||||
if (method === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (method === 'imported-existing-folder' || method === 'cloned' || method === 'provisioned') {
|
||||
return method
|
||||
}
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--method must be imported-existing-folder, cloned, or provisioned'
|
||||
)
|
||||
}
|
||||
|
||||
function getOptionalSetupMethod(
|
||||
flags: Map<string, string | boolean>
|
||||
): ProjectHostSetupUpdateArgs['updates']['setupMethod'] {
|
||||
const method = getOptionalStringFlag(flags, 'method')
|
||||
if (method === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (
|
||||
method === 'legacy-repo' ||
|
||||
method === 'imported-existing-folder' ||
|
||||
method === 'cloned' ||
|
||||
method === 'provisioned'
|
||||
) {
|
||||
return method
|
||||
}
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--method must be legacy-repo, imported-existing-folder, cloned, or provisioned'
|
||||
)
|
||||
}
|
||||
@@ -1,33 +1,8 @@
|
||||
import { resolve as resolvePath } from 'path'
|
||||
import type { RuntimeRepoList, RuntimeRepoSearchRefs } from '../../shared/runtime-types'
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import { formatRepoList, formatRepoRefs, formatRepoShow, printResult } from '../format'
|
||||
import { getOptionalPositiveIntegerFlag, getRequiredStringFlag } from '../flags'
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
|
||||
function isAbsoluteServerPath(value: string): boolean {
|
||||
return (
|
||||
value.startsWith('/') ||
|
||||
/^[A-Za-z]:[\\/]/.test(value) ||
|
||||
value.startsWith('\\\\') ||
|
||||
value.startsWith('//')
|
||||
)
|
||||
}
|
||||
|
||||
function resolveRepoAddPath(inputPath: string, cwd: string, isRemote: boolean): string {
|
||||
if (!isRemote) {
|
||||
return resolvePath(cwd, inputPath)
|
||||
}
|
||||
// Why: the local CLI cwd is unrelated to a paired runtime's filesystem.
|
||||
// Relative remote paths would silently target the wrong machine.
|
||||
if (!isAbsoluteServerPath(inputPath)) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Remote repo add requires --path to be an absolute path on the remote server.'
|
||||
)
|
||||
}
|
||||
return inputPath
|
||||
}
|
||||
import { resolveRepoPathArgument } from '../repo-path-arguments'
|
||||
|
||||
export const REPO_HANDLERS: Record<string, CommandHandler> = {
|
||||
'repo list': async ({ client, json }) => {
|
||||
@@ -37,7 +12,7 @@ export const REPO_HANDLERS: Record<string, CommandHandler> = {
|
||||
'repo add': async ({ flags, client, cwd, json }) => {
|
||||
const repoPath = getRequiredStringFlag(flags, 'path')
|
||||
const result = await client.call<{ repo: Record<string, unknown> }>('repo.add', {
|
||||
path: resolveRepoAddPath(repoPath, cwd, client.isRemote)
|
||||
path: resolveRepoPathArgument(repoPath, cwd, client.isRemote, 'Remote repo add')
|
||||
})
|
||||
printResult(result, json, formatRepoShow)
|
||||
},
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { RuntimeWorktreeCreateResult } from '../../shared/runtime-types'
|
||||
|
||||
function getLineageSourceLabel(source: string): string {
|
||||
switch (source) {
|
||||
case 'terminal-context':
|
||||
return 'terminal'
|
||||
case 'cwd-context':
|
||||
return 'cwd'
|
||||
case 'orchestration-context':
|
||||
return 'orchestration'
|
||||
case 'env-workspace':
|
||||
return 'environment'
|
||||
case 'explicit-cli-flag':
|
||||
return 'explicit flag'
|
||||
case 'active-workspace':
|
||||
return 'active workspace'
|
||||
default:
|
||||
return 'manual action'
|
||||
}
|
||||
}
|
||||
|
||||
export function printLineageSummary(result: RuntimeWorktreeCreateResult, json: boolean): void {
|
||||
if (json) {
|
||||
return
|
||||
}
|
||||
for (const warning of result.warnings ?? []) {
|
||||
console.error(`warning: ${warning.message}`)
|
||||
}
|
||||
if (result.workspaceLineage) {
|
||||
const { parentWorkspaceKey, capture } = result.workspaceLineage
|
||||
console.error(
|
||||
`parent: ${parentWorkspaceKey} (${capture.confidence} from ${getLineageSourceLabel(capture.source)})`
|
||||
)
|
||||
return
|
||||
}
|
||||
if (result.lineage) {
|
||||
const { parentWorktreeId, capture } = result.lineage
|
||||
console.error(
|
||||
`parent: ${parentWorktreeId} (${capture.confidence} from ${getLineageSourceLabel(capture.source)})`
|
||||
)
|
||||
return
|
||||
}
|
||||
console.error('parent: none')
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { parseLinearIssueInput } from '../../shared/linear-links'
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
|
||||
type LinearIssueLinkParams = {
|
||||
linkedLinearIssue: string | null
|
||||
linkedLinearIssueWorkspaceId: string | null
|
||||
linkedLinearIssueOrganizationUrlKey: string | null
|
||||
}
|
||||
|
||||
export function getOptionalLinearIssueLinkFlag(
|
||||
flags: Map<string, string | boolean>,
|
||||
name: string,
|
||||
options: { allowNull?: boolean } = {}
|
||||
): LinearIssueLinkParams | undefined {
|
||||
const value = getPresentStringFlag(flags, name)
|
||||
if (value === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (value.trim().toLowerCase() === 'null') {
|
||||
if (!options.allowNull) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Omit --linear-issue on create, or pass a Linear issue identifier or URL.'
|
||||
)
|
||||
}
|
||||
return {
|
||||
linkedLinearIssue: null,
|
||||
linkedLinearIssueWorkspaceId: null,
|
||||
linkedLinearIssueOrganizationUrlKey: null
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = parseLinearIssueInput(value)
|
||||
if (!parsed) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Pass a Linear issue identifier like STA-335, a Linear issue URL, or null to clear.'
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
linkedLinearIssue: parsed.identifier,
|
||||
// Why: changing a link must not keep a workspace id from a previous issue.
|
||||
// The org key from URLs is enough for current-resolution to safely rehydrate it.
|
||||
linkedLinearIssueWorkspaceId: null,
|
||||
linkedLinearIssueOrganizationUrlKey: parsed.organizationUrlKey ?? null
|
||||
}
|
||||
}
|
||||
|
||||
function getPresentStringFlag(
|
||||
flags: Map<string, string | boolean>,
|
||||
name: string
|
||||
): string | undefined {
|
||||
if (!flags.has(name)) {
|
||||
return undefined
|
||||
}
|
||||
const value = flags.get(name)
|
||||
if (typeof value === 'string' && value.length > 0) {
|
||||
return value
|
||||
}
|
||||
throw new RuntimeClientError('invalid_argument', `Missing value for --${name}`)
|
||||
}
|
||||
@@ -21,6 +21,14 @@ import {
|
||||
resolveCurrentWorktreeSelector
|
||||
} from '../selectors'
|
||||
import { isTuiAgent } from '../../shared/tui-agent-config'
|
||||
import { isWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope'
|
||||
import { printLineageSummary } from './worktree-lineage-summary'
|
||||
import {
|
||||
assertWorkspaceTargetFlagsCompatible,
|
||||
hasWorkspaceProjectTarget,
|
||||
resolveProjectCreateRepoSelector
|
||||
} from '../worktree-project-target'
|
||||
import { getOptionalLinearIssueLinkFlag } from './worktree-linear-issue-link'
|
||||
|
||||
type HookWarningResult = {
|
||||
warning?: string
|
||||
@@ -46,32 +54,6 @@ function printPreservedBranchWarning(result: PreservedBranchResult, json: boolea
|
||||
}
|
||||
}
|
||||
|
||||
function printLineageSummary(result: RuntimeWorktreeCreateResult, json: boolean): void {
|
||||
if (json) {
|
||||
return
|
||||
}
|
||||
for (const warning of result.warnings ?? []) {
|
||||
console.error(`warning: ${warning.message}`)
|
||||
}
|
||||
if (result.lineage) {
|
||||
const source =
|
||||
result.lineage.capture.source === 'terminal-context'
|
||||
? 'terminal'
|
||||
: result.lineage.capture.source === 'cwd-context'
|
||||
? 'cwd'
|
||||
: result.lineage.capture.source === 'orchestration-context'
|
||||
? 'orchestration'
|
||||
: result.lineage.capture.source === 'explicit-cli-flag'
|
||||
? 'explicit flag'
|
||||
: 'manual action'
|
||||
console.error(
|
||||
`parent: ${result.lineage.parentWorktreeId} (${result.lineage.capture.confidence} from ${source})`
|
||||
)
|
||||
} else {
|
||||
console.error('parent: none')
|
||||
}
|
||||
}
|
||||
|
||||
function assertParentFlagsCompatible(flags: Map<string, string | boolean>): void {
|
||||
if (flags.has('parent-worktree') && flags.get('no-parent') === true) {
|
||||
throw new RuntimeClientError(
|
||||
@@ -79,6 +61,18 @@ function assertParentFlagsCompatible(flags: Map<string, string | boolean>): void
|
||||
'Choose either --parent-worktree or --no-parent, not both.'
|
||||
)
|
||||
}
|
||||
if (flags.has('parent-workspace') && flags.get('no-parent') === true) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Choose either --parent-workspace or --no-parent, not both.'
|
||||
)
|
||||
}
|
||||
if (flags.has('parent-workspace') && flags.has('parent-worktree')) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Choose either --parent-workspace or --parent-worktree, not both.'
|
||||
)
|
||||
}
|
||||
const parentWorktree = flags.get('parent-worktree')
|
||||
if (
|
||||
flags.has('parent-worktree') &&
|
||||
@@ -86,6 +80,25 @@ function assertParentFlagsCompatible(flags: Map<string, string | boolean>): void
|
||||
) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Missing required --parent-worktree')
|
||||
}
|
||||
const parentWorkspace = flags.get('parent-workspace')
|
||||
if (
|
||||
flags.has('parent-workspace') &&
|
||||
(typeof parentWorkspace !== 'string' || parentWorkspace === '')
|
||||
) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Missing required --parent-workspace')
|
||||
}
|
||||
}
|
||||
|
||||
function getEnvParentWorkspace(): string | undefined {
|
||||
const workspaceId = process.env.ORCA_WORKSPACE_ID
|
||||
if (typeof workspaceId === 'string' && isWorkspaceKey(workspaceId)) {
|
||||
return workspaceId
|
||||
}
|
||||
const worktreeId = process.env.ORCA_WORKTREE_ID
|
||||
if (typeof worktreeId === 'string' && worktreeId.length > 0) {
|
||||
return isWorkspaceKey(worktreeId) ? worktreeId : worktreeWorkspaceKey(worktreeId)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function getPresentStringFlag(
|
||||
@@ -148,10 +161,15 @@ function getRepoSelectorFromWorktreeSelector(selector: string | undefined): stri
|
||||
return `id:${worktreeId.slice(0, separatorIndex)}`
|
||||
}
|
||||
|
||||
function getCreateRepoSelector(
|
||||
async function getCreateRepoSelector(
|
||||
flags: Map<string, string | boolean>,
|
||||
cwdParentWorktree: string | undefined
|
||||
): string {
|
||||
cwdParentWorktree: string | undefined,
|
||||
client: Parameters<CommandHandler>[0]['client']
|
||||
): Promise<string> {
|
||||
const projectRepoSelector = await resolveProjectCreateRepoSelector(flags, client)
|
||||
if (projectRepoSelector) {
|
||||
return projectRepoSelector
|
||||
}
|
||||
const explicitRepo = getPresentStringFlag(flags, 'repo')
|
||||
if (explicitRepo) {
|
||||
return explicitRepo
|
||||
@@ -194,6 +212,7 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
|
||||
},
|
||||
'worktree create': async ({ flags, client, cwd, json }) => {
|
||||
assertParentFlagsCompatible(flags)
|
||||
assertWorkspaceTargetFlagsCompatible(flags)
|
||||
const callerTerminalHandle =
|
||||
typeof process.env.ORCA_TERMINAL_HANDLE === 'string' &&
|
||||
process.env.ORCA_TERMINAL_HANDLE.length > 0
|
||||
@@ -205,11 +224,20 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
|
||||
cwd,
|
||||
client
|
||||
)
|
||||
const explicitParentWorkspace = getPresentStringFlag(flags, 'parent-workspace')
|
||||
const startupAgent = getOptionalStartupAgent(flags)
|
||||
const setupDecision = getOptionalSetupDecision(flags)
|
||||
const noParent = flags.get('no-parent') === true
|
||||
const envParentWorkspace =
|
||||
!noParent && !explicitParentWorkspace && !explicitParentWorktree
|
||||
? getEnvParentWorkspace()
|
||||
: undefined
|
||||
let cwdParentWorktree: string | undefined
|
||||
if ((!explicitParentWorktree && !noParent) || !flags.has('repo')) {
|
||||
const needsCwdRepoInference = !flags.has('repo') && !hasWorkspaceProjectTarget(flags)
|
||||
if (
|
||||
(!explicitParentWorktree && !explicitParentWorkspace && !noParent) ||
|
||||
needsCwdRepoInference
|
||||
) {
|
||||
try {
|
||||
// Why: agent shells can lose ORCA_TERMINAL_HANDLE while still running
|
||||
// inside an Orca worktree. Cwd keeps CLI-created children nestable and
|
||||
@@ -219,17 +247,21 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
|
||||
cwdParentWorktree = undefined
|
||||
}
|
||||
}
|
||||
const linearIssueLink = getOptionalLinearIssueLinkFlag(flags, 'linear-issue')
|
||||
const result = await client.call<RuntimeWorktreeCreateResult>('worktree.create', {
|
||||
repo: getCreateRepoSelector(flags, cwdParentWorktree),
|
||||
repo: await getCreateRepoSelector(flags, cwdParentWorktree, client),
|
||||
name: getRequiredStringFlag(flags, 'name'),
|
||||
baseBranch: getOptionalStringFlag(flags, 'base-branch'),
|
||||
linkedIssue: getOptionalNumberFlag(flags, 'issue'),
|
||||
...linearIssueLink,
|
||||
comment: getOptionalStringFlag(flags, 'comment'),
|
||||
runHooks: flags.get('run-hooks') === true,
|
||||
activate:
|
||||
flags.get('activate') === true || flags.get('run-hooks') === true || Boolean(startupAgent),
|
||||
...(setupDecision ? { setupDecision } : {}),
|
||||
parentWorktree: explicitParentWorktree,
|
||||
...(explicitParentWorkspace ? { parentWorkspace: explicitParentWorkspace } : {}),
|
||||
...(envParentWorkspace ? { envParentWorkspace } : {}),
|
||||
...(cwdParentWorktree ? { cwdParentWorktree } : {}),
|
||||
noParent,
|
||||
callerTerminalHandle,
|
||||
@@ -246,10 +278,14 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
|
||||
},
|
||||
'worktree set': async ({ flags, client, cwd, json }) => {
|
||||
assertParentFlagsCompatible(flags)
|
||||
const linearIssueLink = getOptionalLinearIssueLinkFlag(flags, 'linear-issue', {
|
||||
allowNull: true
|
||||
})
|
||||
const result = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.set', {
|
||||
worktree: await getRequiredWorktreeSelector(flags, 'worktree', cwd, client),
|
||||
displayName: getOptionalStringFlag(flags, 'display-name'),
|
||||
linkedIssue: getOptionalNullableNumberFlag(flags, 'issue'),
|
||||
...linearIssueLink,
|
||||
comment: getOptionalStringFlag(flags, 'comment'),
|
||||
workspaceStatus: getOptionalStringFlag(flags, 'workspace-status'),
|
||||
parentWorktree: await getOptionalWorktreeSelector(flags, 'parent-worktree', cwd, client),
|
||||
|
||||
+101
-2
@@ -29,6 +29,15 @@ Automations:
|
||||
automations run Run an Orca automation now
|
||||
automations runs List automation run history
|
||||
|
||||
Projects:
|
||||
project list List durable projects known to Orca
|
||||
project setups List project host setups
|
||||
project setup-existing-folder Make a project available on a host by importing an existing folder
|
||||
project setup-clone Make a project available on a host by cloning a repository
|
||||
project setup-create Create independent project host setup metadata
|
||||
project setup-update Update project host setup metadata
|
||||
project setup-delete Remove a project host setup
|
||||
|
||||
Repos:
|
||||
repo list List repos registered in Orca
|
||||
repo add Add a project to Orca by filesystem path
|
||||
@@ -97,6 +106,9 @@ Computer Use:
|
||||
computer paste-text Paste text through the native clipboard path
|
||||
computer set-value Set the value of a settable app element
|
||||
|
||||
Linear:
|
||||
linear Read Linear ticket context for agents
|
||||
|
||||
Mobile Emulator (iOS Simulator):
|
||||
emulator list List available/running emulators (Orca-managed + raw serve-sim)
|
||||
emulator attach <device> Attach/start helper and make active for the worktree
|
||||
@@ -182,10 +194,10 @@ Common Commands:
|
||||
orca environment show --environment <selector> [--json]
|
||||
orca environment rm --environment <selector> [--json]
|
||||
orca worktree list [--repo <selector>] [--limit <n>] [--json]
|
||||
orca worktree create --name <name> [--repo <selector>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]
|
||||
orca worktree create --name <name> [--repo <selector>|--project <id> [--host <host-id>]|--project-host-setup <id>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--linear-issue <identifier-or-url>] [--comment <text>] [--parent-workspace <selector>|--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]
|
||||
orca worktree show --worktree <selector> [--json]
|
||||
orca worktree current [--json]
|
||||
orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--comment <text>] [--workspace-status <id>] [--parent-worktree <selector>|--no-parent] [--json]
|
||||
orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--linear-issue <identifier-or-url|null>] [--comment <text>] [--workspace-status <id>] [--parent-worktree <selector>|--no-parent] [--json]
|
||||
orca worktree rm --worktree <selector> [--force] [--run-hooks] [--json]
|
||||
orca worktree ps [--limit <n>] [--json]
|
||||
orca file open <path> [--worktree <selector>] [--json]
|
||||
@@ -201,6 +213,13 @@ Common Commands:
|
||||
orca terminal split [--terminal <handle>] [--direction horizontal|vertical] [--json]
|
||||
orca terminal switch [--terminal <handle>] [--json]
|
||||
orca terminal close [--terminal <handle>] [--json]
|
||||
orca project list [--json]
|
||||
orca project setups [--project <id>] [--host <host-id>] [--json]
|
||||
orca project setup-existing-folder --project <id> --host <host-id> --path <path> [--kind git|folder] [--display-name <name>] [--json]
|
||||
orca project setup-clone --project <id> --host <host-id> --url <clone-url> --destination <path> [--display-name <name>] [--json]
|
||||
orca project setup-create --project <id> --host <host-id> [--setup-id <id>] [--path <path>] [--kind git|folder] [--display-name <name>] [--worktree-base-path <path>] [--git-username <name>] [--state ready|not-set-up|setting-up|error|unsupported] [--method imported-existing-folder|cloned|provisioned] [--json]
|
||||
orca project setup-update --setup <setup-id> [--display-name <name>] [--path <path>] [--worktree-base-path <path>] [--git-username <name>] [--kind git|folder] [--state ready|not-set-up|setting-up|error|unsupported] [--method legacy-repo|imported-existing-folder|cloned|provisioned] [--json]
|
||||
orca project setup-delete --setup <setup-id> [--json]
|
||||
orca repo list [--json]
|
||||
orca repo add --path <path> [--json]
|
||||
orca repo show --repo <selector> [--json]
|
||||
@@ -211,6 +230,7 @@ Selectors:
|
||||
--repo <selector> Registered repo selector such as id:<id>, name:<name>, or path:<path>
|
||||
--worktree <selector> Worktree selector such as id:<id>, branch:<branch>, issue:<number>, path:<path>, or active/current
|
||||
--terminal <handle> Runtime-issued terminal handle returned by \`orca terminal list --json\`
|
||||
--parent-workspace <selector> Parent workspace selector such as folder:<id> or worktree:<id>
|
||||
--parent-worktree <selector> Parent worktree selector; create infers a child of the caller/current worktree by default
|
||||
--no-parent Force no parent lineage for unrelated worktree creation/update
|
||||
|
||||
@@ -274,9 +294,12 @@ Examples:
|
||||
$ orca repo list
|
||||
$ orca worktree create --name agent-task --agent codex --prompt "hi"
|
||||
$ orca worktree create --repo name:orca --name cli-test-1 --issue 273
|
||||
$ orca worktree create --repo name:orca --name linear-task --linear-issue https://linear.app/stably/issue/STA-335/test-issue
|
||||
$ orca worktree create --name linear-task --linear-issue STA-335
|
||||
$ orca worktree show --worktree branch:Jinwoo-H/cli
|
||||
$ orca worktree current
|
||||
$ orca worktree set --worktree active --comment "waiting on review"
|
||||
$ orca worktree set --worktree active --linear-issue null
|
||||
$ orca worktree ps --limit 10
|
||||
$ orca file open-changed --mode diff
|
||||
$ orca file open src/App.tsx
|
||||
@@ -356,6 +379,54 @@ export function formatGroupHelp(specs: CommandSpec[], group: string): string {
|
||||
|
||||
function formatCommandFlagHelp(flag: string, commandPath: string[]): string {
|
||||
const command = commandPath.join(' ')
|
||||
if (command === 'linear issue' && flag === 'id') {
|
||||
return '--id <id> Linear issue key, id, or URL'
|
||||
}
|
||||
if (command === 'linear issue' && flag === 'workspace') {
|
||||
return '--workspace <id> Connected Linear workspace id'
|
||||
}
|
||||
if (command === 'linear search' && flag === 'query') {
|
||||
return '--query <text> Text to search across Linear issues'
|
||||
}
|
||||
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'
|
||||
}
|
||||
@@ -391,6 +462,8 @@ export function formatFlagHelp(flag: string): string {
|
||||
interrupt: '--interrupt Send as an interrupt-style input when supported',
|
||||
id: '--id <id> Identifier for a target item or permission',
|
||||
issue: '--issue <number|null> Linked GitHub issue number',
|
||||
'linear-issue':
|
||||
'--linear-issue <id|url|null> Linked Linear issue identifier or URL; null clears on set',
|
||||
json: '--json Emit machine-readable JSON',
|
||||
key: '--key <key> Key argument for this command',
|
||||
limit: '--limit <n> Maximum number of rows to return',
|
||||
@@ -400,6 +473,8 @@ export function formatFlagHelp(flag: string): string {
|
||||
'no-parent': '--no-parent Force no parent lineage for unrelated work',
|
||||
'no-screenshot': '--no-screenshot Skip screenshot capture after the operation',
|
||||
pages: '--pages <n> Number of scroll pages',
|
||||
'parent-workspace':
|
||||
'--parent-workspace <selector> Parent workspace selector such as folder:<id>',
|
||||
'parent-worktree':
|
||||
'--parent-worktree <selector> Parent selector; create infers the caller/current worktree by default',
|
||||
path: '--path <path> Path argument for the command',
|
||||
@@ -430,6 +505,8 @@ export function formatFlagHelp(flag: string): string {
|
||||
'--workspace-status <id> Board status id (defaults: todo, in-progress, in-review, completed)',
|
||||
staged: '--staged Open staged source-control changes',
|
||||
provider: '--provider <agent> Agent id such as codex, claude, or gemini',
|
||||
'source-context':
|
||||
'--source-context <json|null> Explicit TaskSourceContext for automation task/provider data',
|
||||
trigger: '--trigger <schedule> Automation schedule preset, cron, or RRULE',
|
||||
schedule: '--schedule <schedule> Alias for --trigger',
|
||||
time: '--time <HH:MM> Time used with daily/weekdays/weekly presets',
|
||||
@@ -459,5 +536,27 @@ export function formatFlagHelp(flag: string): string {
|
||||
format: '--format <png|jpeg> Screenshot image format'
|
||||
}
|
||||
|
||||
if (flag === 'current') {
|
||||
return '--current Use the current Orca worktree linked Linear issue'
|
||||
}
|
||||
if (flag === 'comments') {
|
||||
return '--comments Include threaded Linear comments'
|
||||
}
|
||||
if (flag === 'children') {
|
||||
return '--children Include recursive child issues'
|
||||
}
|
||||
if (flag === 'depth') {
|
||||
return '--depth <n> Child issue depth for --children/--full'
|
||||
}
|
||||
if (flag === 'attachments') {
|
||||
return '--attachments Include attachment metadata and URLs'
|
||||
}
|
||||
if (flag === 'relations') {
|
||||
return '--relations Include blocking, related, and duplicate links'
|
||||
}
|
||||
if (flag === 'full') {
|
||||
return '--full Include all supported V1 issue context within caps'
|
||||
}
|
||||
|
||||
return helpByFlag[flag] ?? `--${flag}`
|
||||
}
|
||||
|
||||
+1283
-15
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,110 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
LinearCreateResult,
|
||||
LinearIssueContextResult,
|
||||
LinearProjectListResult,
|
||||
LinearSearchResult
|
||||
} from '../shared/linear-agent-access'
|
||||
import {
|
||||
formatLinearCreate,
|
||||
formatLinearIssue,
|
||||
formatLinearProjectList,
|
||||
printLinearSearchWarnings
|
||||
} from './linear-format'
|
||||
|
||||
describe('linear-format', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('treats older search results without workspaceErrors as non-partial', () => {
|
||||
const result = {
|
||||
issues: [],
|
||||
meta: {
|
||||
query: 'auth',
|
||||
workspaceId: 'all',
|
||||
limit: 20,
|
||||
returned: 0,
|
||||
limitReached: false,
|
||||
partial: false
|
||||
}
|
||||
} as unknown as LinearSearchResult
|
||||
|
||||
printLinearSearchWarnings(result)
|
||||
|
||||
expect(console.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('includes task fields in issue readback text', () => {
|
||||
const result = {
|
||||
issue: {
|
||||
id: 'issue-1',
|
||||
identifier: 'ENG-123',
|
||||
title: 'Fix task fields',
|
||||
url: 'https://linear.app/acme/issue/ENG-123',
|
||||
state: { name: 'In Progress' },
|
||||
assignee: { displayName: 'Ada' },
|
||||
project: null,
|
||||
labels: [],
|
||||
priority: 2,
|
||||
estimate: 5,
|
||||
dueDate: '2026-06-30'
|
||||
},
|
||||
meta: {
|
||||
sections: {}
|
||||
}
|
||||
} as unknown as LinearIssueContextResult
|
||||
|
||||
expect(formatLinearIssue(result)).toContain('Priority: high')
|
||||
expect(formatLinearIssue(result)).toContain('Estimate: 5')
|
||||
expect(formatLinearIssue(result)).toContain('Due: 2026-06-30')
|
||||
})
|
||||
|
||||
it('formats project rows with names, ids, teams, and workspace', () => {
|
||||
const result = {
|
||||
projects: [
|
||||
{
|
||||
id: 'project-1',
|
||||
name: 'Launch',
|
||||
workspaceName: 'Acme',
|
||||
teams: [
|
||||
{ id: 'team-1', name: 'Engineering', key: 'ENG' },
|
||||
{ id: 'team-2', name: 'Product', key: '' }
|
||||
]
|
||||
}
|
||||
],
|
||||
meta: { limit: 20, returned: 1, hasMore: false, partial: false, workspaceErrors: [] }
|
||||
} as unknown as LinearProjectListResult
|
||||
|
||||
const output = formatLinearProjectList(result)
|
||||
|
||||
expect(output).toContain('Launch')
|
||||
expect(output).toContain('project-1')
|
||||
expect(output).toContain('ENG')
|
||||
expect(output).toContain('Product')
|
||||
expect(output).toContain('Acme')
|
||||
})
|
||||
|
||||
it('includes the project in create output when present', () => {
|
||||
const result = {
|
||||
issue: {
|
||||
id: 'issue-1',
|
||||
identifier: 'ENG-123',
|
||||
title: 'Follow up',
|
||||
url: 'https://linear.app/acme/issue/ENG-123',
|
||||
team: { id: 'team-1', key: 'ENG', name: 'Engineering' },
|
||||
state: null,
|
||||
parent: null,
|
||||
project: { id: 'project-1', name: 'Launch' }
|
||||
},
|
||||
meta: {
|
||||
workspaceId: 'workspace-1',
|
||||
writeId: '11111111-1111-4111-8111-111111111111',
|
||||
deduplicated: false
|
||||
}
|
||||
} as LinearCreateResult
|
||||
|
||||
expect(formatLinearCreate(result)).toBe('Created ENG-123 in Launch: Follow up.')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,214 @@
|
||||
import type {
|
||||
LinearAttachResult,
|
||||
LinearCommentAddResult,
|
||||
LinearCreateResult,
|
||||
LinearIssueListResult,
|
||||
LinearIssueContextResult,
|
||||
LinearIssueTaskUpdateResult,
|
||||
LinearProjectListResult,
|
||||
LinearSearchIssueSummary,
|
||||
LinearSearchResult,
|
||||
LinearTeamLabelsResult,
|
||||
LinearTeamListResult,
|
||||
LinearTeamMembersResult,
|
||||
LinearTeamStatesResult,
|
||||
LinearStatusSetResult
|
||||
} from '../shared/linear-agent-access'
|
||||
import {
|
||||
formatLinearProjectListRows,
|
||||
linearProjectListWarningLines
|
||||
} from '../shared/linear-project-list-format'
|
||||
|
||||
export function formatLinearIssue(result: LinearIssueContextResult): string {
|
||||
const issue = result.issue
|
||||
const lines = [
|
||||
`${issue.identifier} ${issue.title}`,
|
||||
`URL: ${issue.url}`,
|
||||
`State: ${issue.state?.name ?? 'unknown'}`,
|
||||
`Assignee: ${issue.assignee?.displayName ?? 'unassigned'}`,
|
||||
`Project: ${issue.project?.name ?? 'none'}`
|
||||
]
|
||||
lines.push(`Priority: ${formatPriority(issue.priority)}`)
|
||||
lines.push(`Estimate: ${issue.estimate ?? 'none'}`)
|
||||
if (issue.labels.length > 0) {
|
||||
lines.push(
|
||||
`Labels: ${issue.labels
|
||||
.map((label) => label.name)
|
||||
.filter(Boolean)
|
||||
.join(', ')}`
|
||||
)
|
||||
}
|
||||
if (issue.dueDate) {
|
||||
lines.push(`Due: ${issue.dueDate}`)
|
||||
}
|
||||
const sections = result.meta.sections
|
||||
if (sections.comments) {
|
||||
lines.push(`Comments: ${sections.comments.returned}`)
|
||||
}
|
||||
if (sections.children) {
|
||||
lines.push(`Children: ${sections.children.returned}`)
|
||||
}
|
||||
if (sections.attachments) {
|
||||
lines.push(`Attachments: ${sections.attachments.returned}`)
|
||||
}
|
||||
if (sections.relations) {
|
||||
lines.push(`Relations: ${sections.relations.returned}`)
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export function formatLinearSearch(result: LinearSearchResult): string {
|
||||
if (result.issues.length === 0) {
|
||||
return 'No Linear issues found.'
|
||||
}
|
||||
return result.issues.map(formatSearchRow).join('\n')
|
||||
}
|
||||
|
||||
export function formatLinearTeamList(result: LinearTeamListResult): string {
|
||||
if (result.teams.length === 0) {
|
||||
return 'No Linear teams found.'
|
||||
}
|
||||
return result.teams
|
||||
.map((team) => {
|
||||
const workspace = team.workspace ? ` ${team.workspace.name}` : ''
|
||||
return `${team.key.padEnd(10)} ${team.name}${workspace}`
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export function formatLinearTeamMembers(result: LinearTeamMembersResult): string {
|
||||
if (result.members.length === 0) {
|
||||
return `No Linear members found for ${result.team.key}.`
|
||||
}
|
||||
return result.members
|
||||
.map((member) => `${(member.displayName ?? 'unknown').padEnd(24)} ${member.id ?? ''}`)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export function formatLinearTeamStates(result: LinearTeamStatesResult): string {
|
||||
if (result.states.length === 0) {
|
||||
return `No Linear workflow states found for ${result.team.key}.`
|
||||
}
|
||||
return result.states
|
||||
.map((state) => `${state.name.padEnd(24)} ${(state.type ?? '').padEnd(12)} ${state.id}`)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export function formatLinearTeamLabels(result: LinearTeamLabelsResult): string {
|
||||
if (result.labels.length === 0) {
|
||||
return `No Linear labels found for ${result.team.key}.`
|
||||
}
|
||||
return result.labels.map((label) => `${label.name.padEnd(24)} ${label.id}`).join('\n')
|
||||
}
|
||||
|
||||
export function formatLinearIssueList(result: LinearIssueListResult): string {
|
||||
if (result.issues.length === 0) {
|
||||
return 'No Linear issues found.'
|
||||
}
|
||||
return result.issues.map(formatSearchRow).join('\n')
|
||||
}
|
||||
|
||||
export function formatLinearProjectList(result: LinearProjectListResult): string {
|
||||
return formatLinearProjectListRows(result)
|
||||
}
|
||||
|
||||
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 project = result.issue.project?.name ? ` in ${result.issue.project.name}` : ''
|
||||
const suffix = result.meta.deduplicated ? ' (already created)' : ''
|
||||
return `Created ${result.issue.identifier}${parent}${project}: ${result.issue.title}${suffix}.`
|
||||
}
|
||||
|
||||
export function formatLinearTaskUpdate(result: LinearIssueTaskUpdateResult): string {
|
||||
const suffix = result.meta.alreadySet ? ' (already set)' : ''
|
||||
return `Updated ${result.issue.identifier} ${taskOperationLabel(result.operation)}${suffix}.`
|
||||
}
|
||||
|
||||
export function printLinearIssueWarnings(result: LinearIssueContextResult): void {
|
||||
for (const error of result.meta.includeErrors) {
|
||||
console.error(`warning: ${error.include} unavailable: ${error.message}`)
|
||||
}
|
||||
for (const [name, meta] of Object.entries(result.meta.sections)) {
|
||||
if (meta?.capReached) {
|
||||
console.error(`warning: ${name} capped at ${meta.returned}/${meta.cap}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function printLinearSearchWarnings(result: LinearSearchResult): void {
|
||||
if (result.meta.limitReached) {
|
||||
console.error(`warning: showing first ${result.meta.returned} Linear issues`)
|
||||
}
|
||||
for (const error of result.meta.workspaceErrors ?? []) {
|
||||
console.error(
|
||||
`warning: ${error.workspace.name} unavailable for Linear search: ${error.message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function printLinearListWarnings(
|
||||
result: LinearSearchResult | LinearIssueListResult | LinearTeamListResult
|
||||
): void {
|
||||
const meta = result.meta
|
||||
if ('hasMore' in meta && meta.hasMore) {
|
||||
console.error(`warning: showing first ${meta.returned} Linear issues`)
|
||||
}
|
||||
if ('limitReached' in meta && meta.limitReached) {
|
||||
console.error(`warning: showing first ${meta.returned} Linear issues`)
|
||||
}
|
||||
for (const error of meta.workspaceErrors ?? []) {
|
||||
console.error(`warning: ${error.workspace.name} unavailable for Linear: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function printLinearProjectListWarnings(result: LinearProjectListResult): void {
|
||||
for (const warning of linearProjectListWarningLines(result)) {
|
||||
console.error(warning)
|
||||
}
|
||||
}
|
||||
|
||||
function formatSearchRow(issue: LinearSearchIssueSummary): string {
|
||||
const state = issue.state?.name ?? 'unknown'
|
||||
const assignee = issue.assignee?.displayName ?? 'unassigned'
|
||||
return `${issue.identifier.padEnd(10)} ${state.padEnd(14)} ${assignee.padEnd(18)} ${issue.title}`
|
||||
}
|
||||
|
||||
function formatPriority(priority: number | null | undefined): string {
|
||||
if (priority == null || priority === 0) {
|
||||
return 'none'
|
||||
}
|
||||
switch (priority) {
|
||||
case 1:
|
||||
return 'urgent'
|
||||
case 2:
|
||||
return 'high'
|
||||
case 3:
|
||||
return 'medium'
|
||||
case 4:
|
||||
return 'low'
|
||||
default:
|
||||
return 'none'
|
||||
}
|
||||
}
|
||||
|
||||
function taskOperationLabel(operation: LinearIssueTaskUpdateResult['operation']): string {
|
||||
if (operation === 'dueDate') {
|
||||
return 'due date'
|
||||
}
|
||||
return operation
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { isAbsolute, join } from 'node:path'
|
||||
import type {
|
||||
LinearIssueInclude,
|
||||
LinearIssueListRequest,
|
||||
LinearIssueRequest,
|
||||
LinearIssueTaskUpdateRequest,
|
||||
LinearWriteTargetRequest
|
||||
} from '../shared/linear-agent-access'
|
||||
import {
|
||||
LINEAR_CHILDREN_MAX_DEPTH,
|
||||
LINEAR_WRITE_BODY_CAP,
|
||||
clampLinearIssueDepth
|
||||
} from '../shared/linear-agent-access'
|
||||
import { isLinearUuid } from '../shared/linear-uuid'
|
||||
import {
|
||||
getOptionalNonNegativeIntegerFlag,
|
||||
getOptionalStringFlag,
|
||||
getRepeatedStringFlag,
|
||||
getRequiredStringFlag,
|
||||
getRequiredStringFlagAllowingEmpty
|
||||
} from './flags'
|
||||
import { RuntimeClientError } from './runtime-client'
|
||||
|
||||
const LINEAR_PRIORITY_VALUES = new Map([
|
||||
['none', 0],
|
||||
['urgent', 1],
|
||||
['high', 2],
|
||||
['medium', 3],
|
||||
['low', 4]
|
||||
])
|
||||
|
||||
export function buildAssigneeSetRequest(
|
||||
flags: Map<string, string | boolean>,
|
||||
cwd: string,
|
||||
remote: boolean
|
||||
): LinearIssueTaskUpdateRequest {
|
||||
const me = flags.get('me') === true
|
||||
const toId = getOptionalStringFlag(flags, 'to-id')
|
||||
if (me === Boolean(toId)) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Pass exactly one of --me or --to-id')
|
||||
}
|
||||
return {
|
||||
...buildWriteTargetRequest(flags, cwd, remote),
|
||||
operation: 'assignee',
|
||||
...(me ? { assigneeMe: true } : { assigneeId: toId })
|
||||
}
|
||||
}
|
||||
|
||||
export function getLinearListFilter(
|
||||
flags: Map<string, string | boolean>
|
||||
): LinearIssueListRequest['filter'] {
|
||||
const filter = getOptionalStringFlag(flags, 'filter') ?? 'assigned'
|
||||
if (['assigned', 'created', 'all', 'completed', 'open'].includes(filter)) {
|
||||
return filter as LinearIssueListRequest['filter']
|
||||
}
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--filter must be assigned, created, all, completed, or open'
|
||||
)
|
||||
}
|
||||
|
||||
export function getPriorityFlag(flags: Map<string, string | boolean>, name: string): number {
|
||||
const value = getRequiredStringFlag(flags, name).toLocaleLowerCase()
|
||||
const priority = LINEAR_PRIORITY_VALUES.get(value)
|
||||
if (priority === undefined) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`--${name} must be none, low, medium, high, or urgent`
|
||||
)
|
||||
}
|
||||
return priority
|
||||
}
|
||||
|
||||
export function getRequiredNonNegativeIntegerFlag(
|
||||
flags: Map<string, string | boolean>,
|
||||
name: string
|
||||
): number {
|
||||
const raw = getRequiredStringFlag(flags, name)
|
||||
const value = Number(raw)
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
throw new RuntimeClientError('invalid_argument', `--${name} must be a non-negative integer`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function getDueDateFlag(flags: Map<string, string | boolean>, name: string): string {
|
||||
const value = getRequiredStringFlag(flags, name)
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||
throw new RuntimeClientError('invalid_argument', `--${name} must use YYYY-MM-DD`)
|
||||
}
|
||||
const [year, month, day] = value.split('-').map(Number)
|
||||
const date = new Date(Date.UTC(year, month - 1, day))
|
||||
if (
|
||||
date.getUTCFullYear() !== year ||
|
||||
date.getUTCMonth() !== month - 1 ||
|
||||
date.getUTCDate() !== day
|
||||
) {
|
||||
throw new RuntimeClientError('invalid_argument', `--${name} must be a real calendar date`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function getRequiredRepeatedStringFlag(
|
||||
flags: Map<string, string | boolean>,
|
||||
name: string
|
||||
): string[] {
|
||||
const values = getRepeatedStringFlag(flags, name)
|
||||
if (values.length === 0) {
|
||||
throw new RuntimeClientError('invalid_argument', `Missing required --${name}`)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
export function buildIssueRequest(
|
||||
flags: Map<string, string | boolean>,
|
||||
cwd: string,
|
||||
remote: boolean
|
||||
): LinearIssueRequest {
|
||||
const full = flags.get('full') === true
|
||||
const includes: Record<LinearIssueInclude, boolean> = {
|
||||
comments: full || flags.get('comments') === true,
|
||||
children: full || flags.get('children') === true,
|
||||
attachments: full || flags.get('attachments') === true,
|
||||
relations: full || flags.get('relations') === true
|
||||
}
|
||||
if (flags.has('depth') && !includes.children) {
|
||||
throw new RuntimeClientError('invalid_argument', '--depth requires --children or --full')
|
||||
}
|
||||
const requestedDepth = getOptionalNonNegativeIntegerFlag(flags, 'depth')
|
||||
if (requestedDepth !== undefined && requestedDepth > LINEAR_CHILDREN_MAX_DEPTH) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`--depth must be at most ${LINEAR_CHILDREN_MAX_DEPTH}`
|
||||
)
|
||||
}
|
||||
const workspaceId = getOptionalStringFlag(flags, 'workspace')
|
||||
if (workspaceId === 'all') {
|
||||
throw new RuntimeClientError(
|
||||
'linear_invalid_workspace',
|
||||
'--workspace all is not valid for issue'
|
||||
)
|
||||
}
|
||||
const input = getOptionalStringFlag(flags, 'id')
|
||||
return {
|
||||
input,
|
||||
current: input ? false : flags.get('current') === true,
|
||||
workspaceId,
|
||||
include: includes,
|
||||
depth: clampLinearIssueDepth(requestedDepth),
|
||||
context: buildLinearCurrentContext(cwd, remote)
|
||||
}
|
||||
}
|
||||
|
||||
export 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)
|
||||
}
|
||||
}
|
||||
|
||||
export 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 }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
export 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'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function getOptionalWriteId(flags: Map<string, string | boolean>): string | undefined {
|
||||
if (!flags.has('write-id')) {
|
||||
return undefined
|
||||
}
|
||||
const writeId = getRequiredStringFlag(flags, 'write-id')
|
||||
if (!isLinearUuid(writeId)) {
|
||||
throw new RuntimeClientError('linear_invalid_write_id', '--write-id must be a UUID')
|
||||
}
|
||||
return writeId
|
||||
}
|
||||
|
||||
export 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')
|
||||
}
|
||||
|
||||
export function readLinearBody(
|
||||
flags: Map<string, string | boolean>,
|
||||
cwd: string,
|
||||
options: { required: true }
|
||||
): Promise<string>
|
||||
export function readLinearBody(
|
||||
flags: Map<string, string | boolean>,
|
||||
cwd: string,
|
||||
options: { required: false }
|
||||
): Promise<string | undefined>
|
||||
export 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')
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type {
|
||||
Project,
|
||||
ProjectHostSetup,
|
||||
ProjectHostSetupCreateResult,
|
||||
ProjectHostSetupDeleteResult,
|
||||
ProjectHostSetupResult,
|
||||
ProjectHostSetupUpdateResult
|
||||
} from '../shared/types'
|
||||
|
||||
export function formatProjectList(result: { projects: Project[] }): string {
|
||||
if (result.projects.length === 0) {
|
||||
return 'No projects found.'
|
||||
}
|
||||
return result.projects
|
||||
.map((project) => {
|
||||
const identity = project.providerIdentity
|
||||
? `${project.providerIdentity.provider}:${project.providerIdentity.owner}/${project.providerIdentity.repo}`
|
||||
: 'no-provider'
|
||||
return `${project.id} ${project.displayName} ${identity}`
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export function formatProjectHostSetupList(result: { setups: ProjectHostSetup[] }): string {
|
||||
if (result.setups.length === 0) {
|
||||
return 'No project host setups found.'
|
||||
}
|
||||
return result.setups
|
||||
.map(
|
||||
(setup) =>
|
||||
`${setup.id} project:${setup.projectId} host:${setup.hostId} ${setup.setupState} ${setup.path}`
|
||||
)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export function formatProjectHostSetupResult(result: { result: ProjectHostSetupResult }): string {
|
||||
const { project, setup, repo } = result.result
|
||||
return formatProjectHostSetupResultFields(project, setup, repo.id)
|
||||
}
|
||||
|
||||
export function formatProjectHostSetupCreateResult(result: {
|
||||
result: ProjectHostSetupCreateResult
|
||||
}): string {
|
||||
const { project, setup } = result.result
|
||||
return formatProjectHostSetupResultFields(project, setup, undefined)
|
||||
}
|
||||
|
||||
export function formatProjectHostSetupUpdateResult(result: {
|
||||
result: ProjectHostSetupUpdateResult
|
||||
}): string {
|
||||
const { project, setup, repo } = result.result
|
||||
return formatProjectHostSetupResultFields(project, setup, repo?.id)
|
||||
}
|
||||
|
||||
export function formatProjectHostSetupDeleteResult(result: {
|
||||
result: ProjectHostSetupDeleteResult
|
||||
}): string {
|
||||
const { project, setup, repo } = result.result
|
||||
return [
|
||||
`deleted: ${setup.id}`,
|
||||
formatProjectHostSetupResultFields(project, setup, repo?.id)
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function formatProjectHostSetupResultFields(
|
||||
project: Project,
|
||||
setup: ProjectHostSetup,
|
||||
repoId: string | undefined
|
||||
): string {
|
||||
return [
|
||||
`projectId: ${project.id}`,
|
||||
`project: ${project.displayName}`,
|
||||
`setupId: ${setup.id}`,
|
||||
`hostId: ${setup.hostId}`,
|
||||
`path: ${setup.path}`,
|
||||
`state: ${setup.setupState}`,
|
||||
`method: ${setup.setupMethod}`,
|
||||
`repoId: ${repoId ?? 'none'}`
|
||||
].join('\n')
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { resolve as resolvePath } from 'path'
|
||||
import { RuntimeClientError } from './runtime-client'
|
||||
|
||||
function isAbsoluteServerPath(value: string): boolean {
|
||||
return (
|
||||
value.startsWith('/') ||
|
||||
/^[A-Za-z]:[\\/]/.test(value) ||
|
||||
value.startsWith('\\\\') ||
|
||||
value.startsWith('//')
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveRepoPathArgument(
|
||||
inputPath: string,
|
||||
cwd: string,
|
||||
isRemote: boolean,
|
||||
remotePathSubject = 'Remote repo path'
|
||||
): string {
|
||||
if (!isRemote) {
|
||||
return resolvePath(cwd, inputPath)
|
||||
}
|
||||
// Why: the local CLI cwd is unrelated to a paired runtime's filesystem.
|
||||
// Relative remote paths would silently target the wrong machine.
|
||||
if (!isAbsoluteServerPath(inputPath)) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`${remotePathSubject} requires --path to be an absolute path on the remote server.`
|
||||
)
|
||||
}
|
||||
return inputPath
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { mkdtempSync, writeFileSync } from 'fs'
|
||||
import { createServer, type Socket } from 'net'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { getRuntimeMetadataPath } from '../../shared/runtime-bootstrap'
|
||||
import { RuntimeClient } from './client'
|
||||
|
||||
const servers = new Set<ReturnType<typeof createServer>>()
|
||||
const sockets = new Set<Socket>()
|
||||
|
||||
afterEach(async () => {
|
||||
for (const socket of sockets) {
|
||||
socket.destroy()
|
||||
}
|
||||
sockets.clear()
|
||||
await Promise.all(
|
||||
[...servers].map(
|
||||
(server) =>
|
||||
new Promise<void>((resolve) => {
|
||||
server.close(() => resolve())
|
||||
})
|
||||
)
|
||||
)
|
||||
servers.clear()
|
||||
})
|
||||
|
||||
// Why: legacy runtime metadata compatibility only applies to local Unix socket
|
||||
// metadata; Windows uses named pipes and cannot run this fixture directly.
|
||||
describe.skipIf(process.platform === 'win32')('CLI runtime status', () => {
|
||||
it('uses the legacy singular runtime transport when reporting status', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-status-'))
|
||||
const endpoint = join(userDataPath, 'runtime.sock')
|
||||
const server = createServer((socket) => {
|
||||
sockets.add(socket)
|
||||
socket.once('close', () => sockets.delete(socket))
|
||||
socket.once('data', (data) => {
|
||||
const request = JSON.parse(String(data).trim()) as { id: string }
|
||||
socket.write(
|
||||
`${JSON.stringify({
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: {
|
||||
runtimeId: 'runtime-legacy',
|
||||
rendererGraphEpoch: 1,
|
||||
graphStatus: 'ready',
|
||||
authoritativeWindowId: null,
|
||||
liveTabCount: 0
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-legacy' }
|
||||
})}\n`
|
||||
)
|
||||
})
|
||||
})
|
||||
servers.add(server)
|
||||
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
|
||||
writeFileSync(
|
||||
getRuntimeMetadataPath(userDataPath),
|
||||
JSON.stringify({
|
||||
runtimeId: 'runtime-legacy',
|
||||
pid: process.pid,
|
||||
transport: { kind: 'unix', endpoint },
|
||||
authToken: 'token',
|
||||
startedAt: Date.now()
|
||||
})
|
||||
)
|
||||
|
||||
const status = await new RuntimeClient(userDataPath).getCliStatus()
|
||||
|
||||
expect(status.result.runtime).toMatchObject({
|
||||
reachable: true,
|
||||
runtimeId: 'runtime-legacy',
|
||||
state: 'ready'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types'
|
||||
import { findTransport } from '../../shared/runtime-bootstrap'
|
||||
import { tryReadMetadata } from './metadata'
|
||||
import { sendRequest } from './transport'
|
||||
import { RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types'
|
||||
@@ -7,7 +8,8 @@ export async function getCliStatus(
|
||||
userDataPath: string
|
||||
): Promise<RuntimeRpcSuccess<CliStatusResult>> {
|
||||
const metadata = tryReadMetadata(userDataPath)
|
||||
if (!metadata?.transports?.length || !metadata.authToken) {
|
||||
const transport = metadata ? findTransport(metadata, 'unix', 'named-pipe') : null
|
||||
if (!transport || !metadata?.authToken) {
|
||||
return buildCliStatusResponse({
|
||||
app: {
|
||||
running: false,
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import type { CommandSpec } from '../args'
|
||||
import { GLOBAL_FLAGS } from '../args'
|
||||
|
||||
const AUTOMATION_TARGET_FLAGS = ['repo', 'workspace', 'workspace-mode', 'base-branch']
|
||||
const AUTOMATION_TARGET_FLAGS = [
|
||||
'repo',
|
||||
'workspace',
|
||||
'project',
|
||||
'host',
|
||||
'project-host-setup',
|
||||
'source-context',
|
||||
'workspace-mode',
|
||||
'base-branch'
|
||||
]
|
||||
const AUTOMATION_SCHEDULE_FLAGS = ['trigger', 'schedule', 'time', 'day', 'timezone']
|
||||
const AUTOMATION_PRECHECK_FLAGS = ['precheck', 'precheck-timeout']
|
||||
const AUTOMATION_STATE_FLAGS = [
|
||||
@@ -32,7 +41,7 @@ export const AUTOMATION_COMMAND_SPECS: CommandSpec[] = [
|
||||
path: ['automations', 'create'],
|
||||
summary: 'Create a scheduled Orca automation',
|
||||
usage:
|
||||
'orca automations create --name <name> --trigger <preset|cron|rrule> --prompt <text> --provider <agent> [--precheck <command>] [--repo <selector>|--workspace <selector>] [--json]',
|
||||
'orca automations create --name <name> --trigger <preset|cron|rrule> --prompt <text> --provider <agent> [--precheck <command>] [--repo <selector>|--workspace <selector>|--project <id> [--host <id>]|--project-host-setup <id>] [--json]',
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'name',
|
||||
@@ -46,6 +55,8 @@ export const AUTOMATION_COMMAND_SPECS: CommandSpec[] = [
|
||||
notes: [
|
||||
'Trigger accepts hourly, daily, weekdays, weekly, a 5-field cron expression, or an RRULE string.',
|
||||
'When --repo is omitted, the CLI uses the enclosing Orca worktree when one can be resolved from cwd.',
|
||||
'Use --project with --host, or --project-host-setup, to run on a specific project host setup.',
|
||||
'Use --source-context with a JSON TaskSourceContext when task/provider data should come from a specific host/account; pass null on edit to clear it.',
|
||||
'Use --workspace to run in an existing worktree; otherwise the automation creates a new worktree per run.',
|
||||
'Use --precheck to run a bounded command before scheduled runs; exit code 0 continues, anything else records a skipped run.',
|
||||
'Use --reuse-session only with existing-workspace automations to submit later runs to the previous live automation session when it is still available. Use --fresh-session to disable reuse.'
|
||||
|
||||
+19
-4
@@ -102,17 +102,22 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
|
||||
path: ['worktree', 'create'],
|
||||
summary: 'Create a new Orca-managed worktree',
|
||||
usage:
|
||||
'orca worktree create --name <name> [--repo <selector>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]',
|
||||
'orca worktree create --name <name> [--repo <selector>|--project <id> [--host <host-id>]|--project-host-setup <id>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--linear-issue <identifier-or-url>] [--comment <text>] [--parent-workspace <selector>|--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]',
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'repo',
|
||||
'project',
|
||||
'host',
|
||||
'project-host-setup',
|
||||
'name',
|
||||
'agent',
|
||||
'prompt',
|
||||
'base-branch',
|
||||
'issue',
|
||||
'linear-issue',
|
||||
'comment',
|
||||
'setup',
|
||||
'parent-workspace',
|
||||
'parent-worktree',
|
||||
'no-parent',
|
||||
'run-hooks',
|
||||
@@ -121,7 +126,8 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
|
||||
notes: [
|
||||
'By default, Orca records the new worktree as a child of the caller workspace when it can infer one from the Orca terminal or current directory.',
|
||||
'If --repo is omitted, Orca infers the repo from the current Orca-managed worktree.',
|
||||
'For related work, use the inferred parent or pass --parent-worktree active to make the current workspace relationship explicit.',
|
||||
'Use --project with --host to create on a ready project host setup without spelling the backing repo id.',
|
||||
'For related work, use the inferred parent or pass --parent-workspace folder:<id> or worktree:<id>, or --parent-worktree active, to make the relationship explicit.',
|
||||
'Use --no-parent when the new worktree should be independent of the current workspace.',
|
||||
'By default this creates the worktree and its first terminal without switching the active Orca workspace.',
|
||||
'Pass --agent to launch an agent in the first terminal; --prompt sends initial work to that agent.',
|
||||
@@ -132,7 +138,10 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
|
||||
examples: [
|
||||
'orca worktree create --name agent-task --agent codex --prompt "hi" --json',
|
||||
'orca worktree create --repo id:<repoId> --name related-task --json',
|
||||
'orca worktree create --project github:stablyai/orca --host runtime:gpu --name benchmark --json',
|
||||
'orca worktree create --repo id:<repoId> --name linear-task --linear-issue https://linear.app/stably/issue/STA-335/test-issue --json',
|
||||
'orca worktree create --repo id:<repoId> --name agent-task --agent codex --prompt "hi" --json',
|
||||
'orca worktree create --repo id:<repoId> --name folder-child --parent-workspace folder:<folderWorkspaceId> --json',
|
||||
'orca worktree create --repo id:<repoId> --name related-task --parent-worktree active --json',
|
||||
'orca worktree create --repo id:<repoId> --name independent-task --no-parent --json'
|
||||
]
|
||||
@@ -141,19 +150,25 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
|
||||
path: ['worktree', 'set'],
|
||||
summary: 'Update Orca metadata for a worktree',
|
||||
usage:
|
||||
'orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--comment <text>] [--workspace-status <id>] [--parent-worktree <selector>|--no-parent] [--json]',
|
||||
'orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--linear-issue <identifier-or-url|null>] [--comment <text>] [--workspace-status <id>] [--parent-worktree <selector>|--no-parent] [--json]',
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'worktree',
|
||||
'display-name',
|
||||
'issue',
|
||||
'linear-issue',
|
||||
'comment',
|
||||
'workspace-status',
|
||||
'parent-worktree',
|
||||
'no-parent'
|
||||
],
|
||||
notes: [
|
||||
'Workspace status ids match the board columns (defaults: todo, in-progress, in-review, completed); custom statuses use their configured id.'
|
||||
'Workspace status ids match the board columns (defaults: todo, in-progress, in-review, completed); custom statuses use their configured id.',
|
||||
'Pass --linear-issue null to clear the Linear issue link.'
|
||||
],
|
||||
examples: [
|
||||
'orca worktree set --worktree active --linear-issue STA-335 --json',
|
||||
'orca worktree set --worktree active --linear-issue null --json'
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -4,15 +4,18 @@ import { BROWSER_BASIC_COMMAND_SPECS } from './browser-basic'
|
||||
import { AUTOMATION_COMMAND_SPECS } from './automations'
|
||||
import { CORE_COMMAND_SPECS } from './core'
|
||||
import { FILE_COMMAND_SPECS } from './file'
|
||||
import { PROJECT_COMMAND_SPECS } from './project'
|
||||
import { ORCHESTRATION_COMMAND_SPECS } from './orchestration'
|
||||
import { COMPUTER_COMMAND_SPECS } from './computer'
|
||||
import { ENVIRONMENT_COMMAND_SPECS } from './environment'
|
||||
import { AGENT_HOOK_COMMAND_SPECS } from './agent-hooks'
|
||||
import { DIAGNOSTICS_COMMAND_SPECS } from './diagnostics'
|
||||
import { EMULATOR_COMMAND_SPECS } from './emulator'
|
||||
import { LINEAR_COMMAND_SPECS } from './linear'
|
||||
|
||||
export const COMMAND_SPECS: CommandSpec[] = [
|
||||
...CORE_COMMAND_SPECS,
|
||||
...PROJECT_COMMAND_SPECS,
|
||||
...FILE_COMMAND_SPECS,
|
||||
...AUTOMATION_COMMAND_SPECS,
|
||||
...BROWSER_BASIC_COMMAND_SPECS,
|
||||
@@ -22,5 +25,6 @@ export const COMMAND_SPECS: CommandSpec[] = [
|
||||
...AGENT_HOOK_COMMAND_SPECS,
|
||||
...DIAGNOSTICS_COMMAND_SPECS,
|
||||
...ENVIRONMENT_COMMAND_SPECS,
|
||||
...LINEAR_COMMAND_SPECS,
|
||||
...EMULATOR_COMMAND_SPECS
|
||||
]
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import type { CommandSpec } from '../args'
|
||||
import { GLOBAL_FLAGS } from '../args'
|
||||
|
||||
export const LINEAR_COMMAND_SPECS: CommandSpec[] = [
|
||||
{
|
||||
path: ['linear', 'issue'],
|
||||
summary: 'Read Linear issue context for agents',
|
||||
usage:
|
||||
'orca linear issue [<id>] [--current] [--comments] [--children] [--depth <n>] [--attachments] [--relations] [--full] [--workspace <id>] [--json]',
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'current',
|
||||
'comments',
|
||||
'children',
|
||||
'depth',
|
||||
'attachments',
|
||||
'relations',
|
||||
'full',
|
||||
'workspace',
|
||||
'id'
|
||||
],
|
||||
positionalArgs: ['id'],
|
||||
examples: [
|
||||
'orca linear issue ENG-123',
|
||||
'orca linear issue --current --comments',
|
||||
'orca linear issue https://linear.app/acme/issue/ENG-123 --full --json'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['linear', 'search'],
|
||||
summary: 'Search connected Linear workspaces',
|
||||
usage: 'orca linear search <query> [--limit <n>] [--workspace <id>|all] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'limit', 'workspace', 'query'],
|
||||
positionalArgs: ['query'],
|
||||
examples: ['orca linear search "auth bug"', 'orca linear search ENG --workspace all --json']
|
||||
},
|
||||
{
|
||||
path: ['linear', 'team', 'list'],
|
||||
summary: 'List connected Linear teams',
|
||||
usage: 'orca linear team list [--workspace <id>|all] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'workspace'],
|
||||
examples: ['orca linear team list --workspace all --json']
|
||||
},
|
||||
{
|
||||
path: ['linear', 'team', 'members'],
|
||||
summary: 'List Linear team members',
|
||||
usage: 'orca linear team members --team <key|id> [--workspace <id>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'team', 'workspace'],
|
||||
examples: ['orca linear team members --team ENG --json']
|
||||
},
|
||||
{
|
||||
path: ['linear', 'team', 'states'],
|
||||
summary: 'List Linear team workflow states',
|
||||
usage: 'orca linear team states --team <key|id> [--workspace <id>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'team', 'workspace'],
|
||||
examples: ['orca linear team states --team ENG --json']
|
||||
},
|
||||
{
|
||||
path: ['linear', 'team', 'labels'],
|
||||
summary: 'List Linear team labels',
|
||||
usage: 'orca linear team labels --team <key|id> [--workspace <id>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'team', 'workspace'],
|
||||
examples: ['orca linear team labels --team ENG --json']
|
||||
},
|
||||
{
|
||||
path: ['linear', 'project', 'list'],
|
||||
summary: 'List connected Linear projects',
|
||||
usage:
|
||||
'orca linear project list [--query <text>] [--limit <n>] [--workspace <id>|all] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'query', 'limit', 'workspace'],
|
||||
examples: [
|
||||
'orca linear project list --query launch --json',
|
||||
'orca linear project list --workspace all --json'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['linear', 'list'],
|
||||
summary: 'List Linear issues for task triage',
|
||||
usage:
|
||||
'orca linear list [--filter assigned|created|all|completed|open] [--team <key|id>] [--limit <n>] [--workspace <id>|all] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'filter', 'team', 'limit', 'workspace'],
|
||||
examples: ['orca linear list --filter assigned --limit 10 --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', 'assignee', 'set'],
|
||||
summary: 'Assign a Linear issue',
|
||||
usage:
|
||||
'orca linear assignee set [<id>] [--current] (--me | --to-id <userId>) [--workspace <id>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'current', 'me', 'to-id', 'workspace', 'id'],
|
||||
positionalArgs: ['id'],
|
||||
examples: ['orca linear assignee set --current --me --json']
|
||||
},
|
||||
{
|
||||
path: ['linear', 'assignee', 'clear'],
|
||||
summary: 'Clear a Linear issue assignee',
|
||||
usage: 'orca linear assignee clear [<id>] [--current] [--workspace <id>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'current', 'workspace', 'id'],
|
||||
positionalArgs: ['id'],
|
||||
examples: ['orca linear assignee clear ENG-123 --json']
|
||||
},
|
||||
{
|
||||
path: ['linear', 'priority', 'set'],
|
||||
summary: 'Set a Linear issue priority',
|
||||
usage:
|
||||
'orca linear priority set [<id>] [--current] --to none|low|medium|high|urgent [--workspace <id>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'current', 'to', 'workspace', 'id'],
|
||||
positionalArgs: ['id'],
|
||||
examples: ['orca linear priority set --current --to high --json']
|
||||
},
|
||||
{
|
||||
path: ['linear', 'priority', 'clear'],
|
||||
summary: 'Clear a Linear issue priority',
|
||||
usage: 'orca linear priority clear [<id>] [--current] [--workspace <id>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'current', 'workspace', 'id'],
|
||||
positionalArgs: ['id'],
|
||||
examples: ['orca linear priority clear ENG-123 --json']
|
||||
},
|
||||
{
|
||||
path: ['linear', 'estimate', 'set'],
|
||||
summary: 'Set a Linear issue estimate',
|
||||
usage: 'orca linear estimate set [<id>] [--current] --to <number> [--workspace <id>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'current', 'to', 'workspace', 'id'],
|
||||
positionalArgs: ['id'],
|
||||
examples: ['orca linear estimate set --current --to 3 --json']
|
||||
},
|
||||
{
|
||||
path: ['linear', 'estimate', 'clear'],
|
||||
summary: 'Clear a Linear issue estimate',
|
||||
usage: 'orca linear estimate clear [<id>] [--current] [--workspace <id>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'current', 'workspace', 'id'],
|
||||
positionalArgs: ['id'],
|
||||
examples: ['orca linear estimate clear ENG-123 --json']
|
||||
},
|
||||
{
|
||||
path: ['linear', 'due-date', 'set'],
|
||||
summary: 'Set a Linear issue due date',
|
||||
usage:
|
||||
'orca linear due-date set [<id>] [--current] --to <yyyy-mm-dd> [--workspace <id>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'current', 'to', 'workspace', 'id'],
|
||||
positionalArgs: ['id'],
|
||||
examples: ['orca linear due-date set --current --to 2026-06-30 --json']
|
||||
},
|
||||
{
|
||||
path: ['linear', 'due-date', 'clear'],
|
||||
summary: 'Clear a Linear issue due date',
|
||||
usage: 'orca linear due-date clear [<id>] [--current] [--workspace <id>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'current', 'workspace', 'id'],
|
||||
positionalArgs: ['id'],
|
||||
examples: ['orca linear due-date clear ENG-123 --json']
|
||||
},
|
||||
{
|
||||
path: ['linear', 'label', 'add'],
|
||||
summary: 'Add labels to a Linear issue',
|
||||
usage:
|
||||
'orca linear label add [<id>] [--current] --label <labelId-or-exact-name>... [--workspace <id>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'current', 'label', 'workspace', 'id'],
|
||||
positionalArgs: ['id'],
|
||||
examples: ['orca linear label add --current --label Bug --json']
|
||||
},
|
||||
{
|
||||
path: ['linear', 'label', 'remove'],
|
||||
summary: 'Remove labels from a Linear issue',
|
||||
usage:
|
||||
'orca linear label remove [<id>] [--current] --label <labelId-or-exact-name>... [--workspace <id>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'current', 'label', 'workspace', 'id'],
|
||||
positionalArgs: ['id'],
|
||||
examples: ['orca linear label remove --current --label Bug --json']
|
||||
},
|
||||
{
|
||||
path: ['linear', 'label', 'set'],
|
||||
summary: 'Replace labels on a Linear issue',
|
||||
usage:
|
||||
'orca linear label set [<id>] [--current] --label <labelId-or-exact-name>... [--workspace <id>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'current', 'label', 'workspace', 'id'],
|
||||
positionalArgs: ['id'],
|
||||
examples: ['orca linear label set ENG-123 --label Bug --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|id>] [--project <projectId-or-exact-name>] [--state <stateId|exact-name>] [--assignee me|<userId>] [--priority none|low|medium|high|urgent] [--estimate <number>] [--due-date <yyyy-mm-dd>] [--label <labelId-or-exact-name>]... [--parent <id> | --parent-current] [--write-id <uuid>] [--workspace <id>] [--json]',
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'title',
|
||||
'body',
|
||||
'body-file',
|
||||
'team',
|
||||
'project',
|
||||
'state',
|
||||
'assignee',
|
||||
'priority',
|
||||
'estimate',
|
||||
'due-date',
|
||||
'label',
|
||||
'parent',
|
||||
'parent-current',
|
||||
'write-id',
|
||||
'workspace'
|
||||
],
|
||||
examples: [
|
||||
'orca linear create --title "Investigate flaky login" --team ENG --project "Launch"',
|
||||
'orca linear create --title "Follow-up bug" --parent-current --body-file - --json'
|
||||
],
|
||||
notes: ['Use --body-file - to read multiline issue bodies from stdin.']
|
||||
}
|
||||
]
|
||||
@@ -25,6 +25,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
|
||||
],
|
||||
notes: [
|
||||
'On Windows PowerShell, quote group addresses such as --to "@all" or --to "@worktree:<id>".',
|
||||
'worker_done and heartbeat must target a concrete coordinator terminal handle; use status for broadcast updates.',
|
||||
'Prefer --task-id/--dispatch-id/etc. over raw --payload JSON in worker commands; PowerShell strips JSON quotes easily.'
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { CommandSpec } from '../args'
|
||||
import { GLOBAL_FLAGS } from '../args'
|
||||
|
||||
export const PROJECT_COMMAND_SPECS: CommandSpec[] = [
|
||||
{
|
||||
path: ['project', 'list'],
|
||||
summary: 'List durable projects known to Orca',
|
||||
usage: 'orca project list [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS],
|
||||
examples: ['orca project list', 'orca project list --json']
|
||||
},
|
||||
{
|
||||
path: ['project', 'setups'],
|
||||
summary: 'List project host setups',
|
||||
usage: 'orca project setups [--project <id>] [--host <host-id>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'project', 'host'],
|
||||
notes: ['A setup means a project is available on a host at a concrete filesystem path.'],
|
||||
examples: [
|
||||
'orca project setups',
|
||||
'orca project setups --project github:stablyai/orca',
|
||||
'orca project setups --host local'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['project', 'setup-existing-folder'],
|
||||
summary: 'Make a project available on a host by importing an existing folder',
|
||||
usage:
|
||||
'orca project setup-existing-folder --project <id> --host <host-id> --path <path> [--kind git|folder] [--display-name <name>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'project', 'host', 'path', 'kind', 'display-name'],
|
||||
notes: ['For remote runtimes, --path must be an absolute path on the remote server.'],
|
||||
examples: [
|
||||
'orca project setup-existing-folder --project github:stablyai/orca --host local --path ~/orca',
|
||||
'orca project setup-existing-folder --project github:stablyai/orca --host runtime:gpu --path /home/me/orca --kind git --json'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['project', 'setup-clone'],
|
||||
summary: 'Make a project available on a host by cloning a repository',
|
||||
usage:
|
||||
'orca project setup-clone --project <id> --host <host-id> --url <clone-url> --destination <path> [--display-name <name>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'project', 'host', 'url', 'destination', 'display-name'],
|
||||
notes: [
|
||||
'For remote runtimes, --destination must be an absolute parent directory on the remote server.',
|
||||
'SSH targets are cloned through the desktop UI because the desktop client owns SSH connections.'
|
||||
],
|
||||
examples: [
|
||||
'orca project setup-clone --project github:stablyai/orca --host local --url https://github.com/stablyai/orca.git --destination ~/src',
|
||||
'orca project setup-clone --project github:stablyai/orca --host runtime:gpu --url https://github.com/stablyai/orca.git --destination /srv --json'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['project', 'setup-create'],
|
||||
summary: 'Create independent project host setup metadata',
|
||||
usage:
|
||||
'orca project setup-create --project <id> --host <host-id> [--setup-id <id>] [--path <path>] [--kind git|folder] [--display-name <name>] [--worktree-base-path <path>] [--git-username <name>] [--state ready|not-set-up|setting-up|error|unsupported] [--method imported-existing-folder|cloned|provisioned] [--json]',
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'project',
|
||||
'host',
|
||||
'setup-id',
|
||||
'path',
|
||||
'kind',
|
||||
'display-name',
|
||||
'worktree-base-path',
|
||||
'git-username',
|
||||
'state',
|
||||
'method'
|
||||
],
|
||||
notes: [
|
||||
'Creates setup metadata without registering a repo compatibility record.',
|
||||
'Use setup-existing-folder when Orca should import and manage an actual checkout path now.'
|
||||
],
|
||||
examples: [
|
||||
'orca project setup-create --project github:stablyai/orca --host runtime:gpu --state setting-up --method provisioned --json'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['project', 'setup-update'],
|
||||
summary: 'Update project host setup metadata',
|
||||
usage:
|
||||
'orca project setup-update --setup <setup-id> [--display-name <name>] [--path <path>] [--worktree-base-path <path>] [--git-username <name>] [--kind git|folder] [--state ready|not-set-up|setting-up|error|unsupported] [--method legacy-repo|imported-existing-folder|cloned|provisioned] [--json]',
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'setup',
|
||||
'display-name',
|
||||
'path',
|
||||
'worktree-base-path',
|
||||
'git-username',
|
||||
'kind',
|
||||
'state',
|
||||
'method'
|
||||
],
|
||||
notes: [
|
||||
'Repo-backed setups mirror safe fields onto the repo record.',
|
||||
'Path and availability state changes are only supported for independent setup records.'
|
||||
],
|
||||
examples: [
|
||||
'orca project setup-update --setup github:stablyai/orca::gpu --display-name "GPU VM"',
|
||||
'orca project setup-update --setup github:stablyai/orca::gpu --path /srv/orca --state ready --json'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['project', 'setup-delete'],
|
||||
summary: 'Remove a project host setup',
|
||||
usage: 'orca project setup-delete --setup <setup-id> [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'setup'],
|
||||
notes: [
|
||||
'Independent setups are removed directly.',
|
||||
'Repo-backed setups remove the registered repo compatibility record.'
|
||||
],
|
||||
examples: ['orca project setup-delete --setup github:stablyai/orca::gpu --json']
|
||||
}
|
||||
]
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Automation, AutomationRun } from '../shared/automations-types'
|
||||
import { getAutomationLegacyRepoId } from '../shared/automation-run-identity'
|
||||
import { formatAutomationPrecheckTimeout } from '../shared/automation-precheck'
|
||||
import { formatAutomationSchedule } from '../shared/automation-schedules'
|
||||
import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments'
|
||||
@@ -178,6 +179,17 @@ export function formatAutomationList(result: { automations: Automation[] }): str
|
||||
|
||||
export function formatAutomationShow(result: { automation: Automation }): string {
|
||||
const automation = result.automation
|
||||
const runContext = automation.runContext ?? null
|
||||
const projectLines = runContext
|
||||
? [
|
||||
`runProjectId: ${runContext.projectId}`,
|
||||
`runHostId: ${runContext.hostId}`,
|
||||
`projectHostSetupId: ${runContext.projectHostSetupId}`,
|
||||
`runRepoId: ${runContext.repoId}`,
|
||||
`runPath: ${runContext.path}`,
|
||||
`legacyRepoId: ${getAutomationLegacyRepoId(automation)}`
|
||||
]
|
||||
: [`legacyRepoId: ${getAutomationLegacyRepoId(automation)}`]
|
||||
return [
|
||||
`id: ${automation.id}`,
|
||||
`name: ${automation.name}`,
|
||||
@@ -193,7 +205,7 @@ export function formatAutomationShow(result: { automation: Automation }): string
|
||||
: 'none'
|
||||
}`,
|
||||
`nextRunAt: ${new Date(automation.nextRunAt).toISOString()}`,
|
||||
`projectId: ${automation.projectId}`,
|
||||
...projectLines,
|
||||
`workspaceMode: ${automation.workspaceMode}`,
|
||||
`workspaceId: ${automation.workspaceId ?? 'null'}`,
|
||||
`baseBranch: ${automation.baseBranch ?? 'null'}`,
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { ProjectHostSetup } from '../shared/types'
|
||||
import type { RuntimeClient } from './runtime-client'
|
||||
import { RuntimeClientError } from './runtime-client'
|
||||
|
||||
export type ProjectCreateTarget = {
|
||||
repoSelector: string
|
||||
setup: ProjectHostSetup
|
||||
}
|
||||
|
||||
function getPresentStringFlag(
|
||||
flags: Map<string, string | boolean>,
|
||||
name: string
|
||||
): string | undefined {
|
||||
if (!flags.has(name)) {
|
||||
return undefined
|
||||
}
|
||||
const value = flags.get(name)
|
||||
if (typeof value === 'string' && value.length > 0) {
|
||||
return value
|
||||
}
|
||||
throw new RuntimeClientError('invalid_argument', `Missing value for --${name}`)
|
||||
}
|
||||
|
||||
export function hasWorkspaceProjectTarget(flags: Map<string, string | boolean>): boolean {
|
||||
return flags.has('project') || flags.has('host') || flags.has('project-host-setup')
|
||||
}
|
||||
|
||||
export function assertWorkspaceTargetFlagsCompatible(flags: Map<string, string | boolean>): void {
|
||||
const hasProjectTarget = hasWorkspaceProjectTarget(flags)
|
||||
if (flags.has('repo') && hasProjectTarget) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Choose either --repo or project target flags, not both.'
|
||||
)
|
||||
}
|
||||
if (flags.has('host') && !flags.has('project') && !flags.has('project-host-setup')) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--host requires --project unless --project-host-setup is provided.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveProjectCreateRepoSelector(
|
||||
flags: Map<string, string | boolean>,
|
||||
client: RuntimeClient
|
||||
): Promise<string | undefined> {
|
||||
return (await resolveProjectCreateTarget(flags, client))?.repoSelector
|
||||
}
|
||||
|
||||
export async function resolveProjectCreateTarget(
|
||||
flags: Map<string, string | boolean>,
|
||||
client: RuntimeClient
|
||||
): Promise<ProjectCreateTarget | undefined> {
|
||||
const projectHostSetupId = getPresentStringFlag(flags, 'project-host-setup')
|
||||
const projectId = getPresentStringFlag(flags, 'project')
|
||||
const hostId = getPresentStringFlag(flags, 'host')
|
||||
if (!projectHostSetupId && !projectId && !hostId) {
|
||||
return undefined
|
||||
}
|
||||
const result = await client.call<{ setups: ProjectHostSetup[] }>('projectHostSetup.list')
|
||||
const setup = result.result.setups.find((candidate) => {
|
||||
if (candidate.setupState !== 'ready') {
|
||||
return false
|
||||
}
|
||||
if (projectHostSetupId) {
|
||||
return candidate.id === projectHostSetupId
|
||||
}
|
||||
return (
|
||||
candidate.projectId === projectId && (hostId === undefined || candidate.hostId === hostId)
|
||||
)
|
||||
})
|
||||
if (!setup) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
projectHostSetupId
|
||||
? `Project host setup is not ready or was not found: ${projectHostSetupId}`
|
||||
: `Project is not set up on the selected host: ${projectId}${hostId ? ` on ${hostId}` : ''}`
|
||||
)
|
||||
}
|
||||
return {
|
||||
repoSelector: `id:${setup.repoId}`,
|
||||
setup
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { vi } from 'vitest'
|
||||
import type { GlobalSettings, Repo } from '../../shared/types'
|
||||
import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree-id'
|
||||
import type {
|
||||
FirstWorkBranchRenameDeps,
|
||||
FirstWorkBranchRenameEvent
|
||||
} from './first-work-branch-rename'
|
||||
|
||||
export const REPO_ID = 'repo1'
|
||||
export const WORKTREE_ID = `${REPO_ID}${WORKTREE_ID_SEPARATOR}/repo/wt`
|
||||
const FOLDER_WORKSPACE_ID = 'folder-workspace-1'
|
||||
export const FOLDER_WORKTREE_ID = `folder:${FOLDER_WORKSPACE_ID}`
|
||||
const TAB_ID = 'tab-1'
|
||||
const PANE_KEY = `${TAB_ID}:leaf-1`
|
||||
|
||||
export const noUpstreamError = new Error("fatal: no upstream configured for branch 'Nautilus'")
|
||||
|
||||
export function gitResponder(opts: {
|
||||
currentBranch: string
|
||||
hasUpstream: boolean
|
||||
existingRefs?: string[]
|
||||
}) {
|
||||
return async (args: string[]) => {
|
||||
if (args[0] === 'rev-parse' && args.some((arg) => arg.includes('@{u}'))) {
|
||||
if (opts.hasUpstream) {
|
||||
return { stdout: 'origin/x\n', stderr: '' }
|
||||
}
|
||||
throw noUpstreamError
|
||||
}
|
||||
if (args[0] === 'rev-parse') {
|
||||
return { stdout: `${opts.currentBranch}\n`, stderr: '' }
|
||||
}
|
||||
if (args[0] === 'show-ref') {
|
||||
const ref = args.at(-1) ?? ''
|
||||
if ((opts.existingRefs ?? []).includes(ref)) {
|
||||
return { stdout: '', stderr: '' }
|
||||
}
|
||||
throw new Error('not found')
|
||||
}
|
||||
if (args[0] === 'branch' && args[1] === '-m') {
|
||||
return { stdout: '', stderr: '' }
|
||||
}
|
||||
throw new Error(`unexpected git args: ${args.join(' ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
type VitestMockFactory = typeof vi.fn
|
||||
type VitestMock = ReturnType<VitestMockFactory>
|
||||
|
||||
export function makeBranchRenameDeps(
|
||||
mockFn: VitestMockFactory,
|
||||
overrides: Partial<FirstWorkBranchRenameDeps> = {}
|
||||
): {
|
||||
deps: FirstWorkBranchRenameDeps
|
||||
onRenamed: VitestMock
|
||||
setDisplayName: VitestMock
|
||||
renameWorktreeFolder: VitestMock
|
||||
setRenameError: VitestMock
|
||||
} {
|
||||
const onRenamed = mockFn()
|
||||
const setDisplayName = mockFn()
|
||||
const renameWorktreeFolder = mockFn(async () => false)
|
||||
const setRenameError = mockFn()
|
||||
const settings = { autoRenameBranchFromWork: true } as unknown as GlobalSettings
|
||||
const repo = { id: REPO_ID, path: '/repo', connectionId: undefined } as unknown as Repo
|
||||
return {
|
||||
onRenamed,
|
||||
setDisplayName,
|
||||
renameWorktreeFolder,
|
||||
setRenameError,
|
||||
deps: {
|
||||
getSettings: () => settings,
|
||||
getRepo: () => repo,
|
||||
getAgentEnvResolvers: () => undefined,
|
||||
getCurrentDisplayName: () => 'Nautilus-8',
|
||||
canRenameOrcaCreatedBranch: () => true,
|
||||
setDisplayName,
|
||||
renameWorktreeFolder,
|
||||
setRenameError,
|
||||
resolveWorktreeIdForTab: () => WORKTREE_ID,
|
||||
onRenamed,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function workingEvent(
|
||||
overrides: Partial<FirstWorkBranchRenameEvent> = {}
|
||||
): FirstWorkBranchRenameEvent {
|
||||
return {
|
||||
paneKey: PANE_KEY,
|
||||
tabId: TAB_ID,
|
||||
worktreeId: undefined,
|
||||
state: 'working',
|
||||
prompt: 'Fix the auth bug',
|
||||
assistantMessage: undefined,
|
||||
isReplay: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
/* eslint-disable max-lines -- Why: the orchestrator tests cover local, SSH,
|
||||
retry, and post-generation race guards; splitting would duplicate mocks. */
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { GlobalSettings, Repo } from '../../shared/types'
|
||||
import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree-id'
|
||||
@@ -12,7 +10,8 @@ const {
|
||||
generateBranchNameMock,
|
||||
resolveTextGenerationParamsMock,
|
||||
prepareLocalEnvMock,
|
||||
computeBranchNameMock
|
||||
computeBranchNameMock,
|
||||
getConfiguredBranchPrefixMock
|
||||
} = vi.hoisted(() => ({
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
getGitUsernameMock: vi.fn(() => 'you'),
|
||||
@@ -21,7 +20,9 @@ const {
|
||||
generateBranchNameMock: vi.fn(),
|
||||
resolveTextGenerationParamsMock: vi.fn(),
|
||||
prepareLocalEnvMock: vi.fn(async () => ({ ok: true as const })),
|
||||
computeBranchNameMock: vi.fn((leaf: string) => `you/${leaf}`)
|
||||
computeBranchNameMock: vi.fn((leaf: string) => `you/${leaf}`),
|
||||
// Mirror computeBranchNameMock's `you/` strategy so prefix stripping is realistic.
|
||||
getConfiguredBranchPrefixMock: vi.fn((_settings: unknown, username: string | null) => username)
|
||||
}))
|
||||
|
||||
vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock }))
|
||||
@@ -35,95 +36,29 @@ vi.mock('../text-generation/commit-message-text-generation', () => ({
|
||||
vi.mock('../text-generation/commit-message-agent-environment', () => ({
|
||||
prepareLocalCommitMessageAgentEnv: prepareLocalEnvMock
|
||||
}))
|
||||
vi.mock('../ipc/worktree-logic', () => ({ computeBranchName: computeBranchNameMock }))
|
||||
vi.mock('../ipc/worktree-logic', () => ({
|
||||
computeBranchName: computeBranchNameMock,
|
||||
getConfiguredBranchPrefix: getConfiguredBranchPrefixMock
|
||||
}))
|
||||
|
||||
import {
|
||||
FIRST_WORK_BRANCH_RENAME_SETTLED_CACHE_LIMIT,
|
||||
maybeAutoRenameBranchOnFirstWork,
|
||||
resetFirstWorkBranchRenameState,
|
||||
type FirstWorkBranchRenameDeps,
|
||||
type FirstWorkBranchRenameEvent
|
||||
type FirstWorkBranchRenameDeps
|
||||
} from './first-work-branch-rename'
|
||||
import {
|
||||
FOLDER_WORKTREE_ID,
|
||||
REPO_ID,
|
||||
WORKTREE_ID,
|
||||
gitResponder,
|
||||
makeBranchRenameDeps,
|
||||
noUpstreamError,
|
||||
workingEvent
|
||||
} from './first-work-branch-rename-test-harness'
|
||||
|
||||
const REPO_ID = 'repo1'
|
||||
const WORKTREE_ID = `${REPO_ID}${WORKTREE_ID_SEPARATOR}/repo/wt`
|
||||
const TAB_ID = 'tab-1'
|
||||
const PANE_KEY = `${TAB_ID}:leaf-1`
|
||||
|
||||
const noUpstreamError = new Error("fatal: no upstream configured for branch 'Nautilus'")
|
||||
|
||||
function gitResponder(opts: {
|
||||
currentBranch: string
|
||||
hasUpstream: boolean
|
||||
existingRefs?: string[]
|
||||
}) {
|
||||
return async (args: string[]) => {
|
||||
if (args[0] === 'rev-parse' && args.some((arg) => arg.includes('@{u}'))) {
|
||||
if (opts.hasUpstream) {
|
||||
return { stdout: 'origin/x\n', stderr: '' }
|
||||
}
|
||||
throw noUpstreamError
|
||||
}
|
||||
if (args[0] === 'rev-parse') {
|
||||
return { stdout: `${opts.currentBranch}\n`, stderr: '' }
|
||||
}
|
||||
if (args[0] === 'show-ref') {
|
||||
const ref = args.at(-1) ?? ''
|
||||
if ((opts.existingRefs ?? []).includes(ref)) {
|
||||
return { stdout: '', stderr: '' }
|
||||
}
|
||||
throw new Error('not found')
|
||||
}
|
||||
if (args[0] === 'branch' && args[1] === '-m') {
|
||||
return { stdout: '', stderr: '' }
|
||||
}
|
||||
throw new Error(`unexpected git args: ${args.join(' ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
function makeDeps(overrides: Partial<FirstWorkBranchRenameDeps> = {}): {
|
||||
deps: FirstWorkBranchRenameDeps
|
||||
onRenamed: ReturnType<typeof vi.fn>
|
||||
setDisplayName: ReturnType<typeof vi.fn>
|
||||
setRenameError: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const onRenamed = vi.fn()
|
||||
const setDisplayName = vi.fn()
|
||||
const setRenameError = vi.fn()
|
||||
const settings = { autoRenameBranchFromWork: true } as unknown as GlobalSettings
|
||||
const repo = { id: REPO_ID, path: '/repo', connectionId: undefined } as unknown as Repo
|
||||
return {
|
||||
onRenamed,
|
||||
setDisplayName,
|
||||
setRenameError,
|
||||
deps: {
|
||||
getSettings: () => settings,
|
||||
getRepo: () => repo,
|
||||
getAgentEnvResolvers: () => undefined,
|
||||
getCurrentDisplayName: () => 'Nautilus-8',
|
||||
canRenameOrcaCreatedBranch: () => true,
|
||||
setDisplayName,
|
||||
setRenameError,
|
||||
resolveWorktreeIdForTab: () => WORKTREE_ID,
|
||||
onRenamed,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function workingEvent(
|
||||
overrides: Partial<FirstWorkBranchRenameEvent> = {}
|
||||
): FirstWorkBranchRenameEvent {
|
||||
return {
|
||||
paneKey: PANE_KEY,
|
||||
tabId: TAB_ID,
|
||||
worktreeId: undefined,
|
||||
state: 'working',
|
||||
prompt: 'Fix the auth bug',
|
||||
assistantMessage: undefined,
|
||||
isReplay: false,
|
||||
...overrides
|
||||
}
|
||||
function makeDeps(overrides: Partial<FirstWorkBranchRenameDeps> = {}) {
|
||||
return makeBranchRenameDeps(vi.fn, overrides)
|
||||
}
|
||||
|
||||
describe('maybeAutoRenameBranchOnFirstWork', () => {
|
||||
@@ -162,6 +97,64 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
|
||||
expect(onRenamed).toHaveBeenCalledWith(REPO_ID)
|
||||
})
|
||||
|
||||
it('asks to align the on-disk folder with the generated slug after renaming', async () => {
|
||||
const { deps, renameWorktreeFolder } = makeDeps()
|
||||
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
|
||||
expect(renameWorktreeFolder).toHaveBeenCalledWith(WORKTREE_ID, 'fix-auth')
|
||||
})
|
||||
|
||||
it('skips the redundant branch-rename notify when the folder rename succeeded', async () => {
|
||||
// The folder rename already pushed a worktrees:changed carrying the id mapping,
|
||||
// so onRenamed would only trigger a second, redundant renderer re-list.
|
||||
const { deps, onRenamed } = makeDeps({ renameWorktreeFolder: vi.fn(async () => true) })
|
||||
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
|
||||
expect(onRenamed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('survives a folder-rename failure without undoing the branch/display rename', async () => {
|
||||
const { deps, onRenamed, setDisplayName } = makeDeps({
|
||||
renameWorktreeFolder: vi.fn(async () => {
|
||||
throw new Error('git worktree move failed')
|
||||
})
|
||||
})
|
||||
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['branch', '-m', 'you/fix-auth'],
|
||||
expect.objectContaining({ cwd: '/repo/wt' })
|
||||
)
|
||||
expect(setDisplayName).toHaveBeenCalledWith(WORKTREE_ID, 'Fix auth')
|
||||
expect(onRenamed).toHaveBeenCalledWith(REPO_ID)
|
||||
})
|
||||
|
||||
it('strips a prefix the model leaked into the slug from both branch and display name', async () => {
|
||||
// Model ignored "no prefixes" and echoed `you/worktree-spinner`, which the
|
||||
// sanitizer folds to `you-worktree-spinner`; without stripping it would
|
||||
// double-prefix the branch (`you/you-...`) and show "You worktree spinner".
|
||||
generateBranchNameMock.mockResolvedValue({ success: true, slug: 'you-worktree-spinner' })
|
||||
const { deps, setDisplayName } = makeDeps()
|
||||
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['branch', '-m', 'you/worktree-spinner'],
|
||||
expect.objectContaining({ cwd: '/repo/wt' })
|
||||
)
|
||||
expect(setDisplayName).toHaveBeenCalledWith(WORKTREE_ID, 'Worktree spinner')
|
||||
})
|
||||
|
||||
it('skips the rename when the model echoes only the configured prefix', async () => {
|
||||
// The model emitted just `you` (the prefix); stripping leaves an empty slug,
|
||||
// so renaming would re-add the prefix and double it to `you/you`.
|
||||
generateBranchNameMock.mockResolvedValue({ success: true, slug: 'you' })
|
||||
const { deps, onRenamed, setRenameError } = makeDeps()
|
||||
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalledWith(
|
||||
['branch', '-m', expect.anything()],
|
||||
expect.anything()
|
||||
)
|
||||
expect(onRenamed).not.toHaveBeenCalled()
|
||||
// Benign terminal state: clear any stale badge, never raise a new one.
|
||||
expect(setRenameError).not.toHaveBeenCalledWith(WORKTREE_ID, expect.any(String))
|
||||
})
|
||||
|
||||
it('leaves a user-customized display name untouched while still renaming the branch', async () => {
|
||||
const { deps, setDisplayName } = makeDeps({ getCurrentDisplayName: () => 'My cool feature' })
|
||||
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
|
||||
@@ -192,6 +185,41 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renames a pending folder workspace title without touching git', async () => {
|
||||
const { deps, onRenamed, setDisplayName } = makeDeps({
|
||||
resolveWorktreeIdForTab: () => FOLDER_WORKTREE_ID,
|
||||
getFolderWorkspacePath: () => '/workspace/platform',
|
||||
isPendingFirstAgentMessageRename: () => true,
|
||||
getCurrentDisplayName: () => 'Platform workspace'
|
||||
})
|
||||
|
||||
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
|
||||
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
expect(resolveTextGenerationParamsMock).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'local',
|
||||
'branchName',
|
||||
null
|
||||
)
|
||||
expect(setDisplayName).toHaveBeenCalledWith(FOLDER_WORKTREE_ID, 'Fix auth')
|
||||
expect(onRenamed).toHaveBeenCalledWith(FOLDER_WORKTREE_ID)
|
||||
})
|
||||
|
||||
it('does not rename folder workspace titles without the pending marker', async () => {
|
||||
const { deps, setDisplayName } = makeDeps({
|
||||
resolveWorktreeIdForTab: () => FOLDER_WORKTREE_ID,
|
||||
getFolderWorkspacePath: () => '/workspace/platform',
|
||||
isPendingFirstAgentMessageRename: () => false
|
||||
})
|
||||
|
||||
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
|
||||
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
expect(generateBranchNameMock).not.toHaveBeenCalled()
|
||||
expect(setDisplayName).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores replayed events and non-working states', async () => {
|
||||
const { deps } = makeDeps()
|
||||
await maybeAutoRenameBranchOnFirstWork(workingEvent({ isReplay: true }), deps)
|
||||
@@ -339,7 +367,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
|
||||
expect(onRenamed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('suffixes when the generated branch name already exists', async () => {
|
||||
it('suffixes the branch, display name, and folder together on collision', async () => {
|
||||
gitExecFileAsyncMock.mockImplementation(
|
||||
gitResponder({
|
||||
currentBranch: 'you/Nautilus',
|
||||
@@ -347,12 +375,16 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
|
||||
existingRefs: ['refs/heads/you/fix-auth']
|
||||
})
|
||||
)
|
||||
const { deps } = makeDeps()
|
||||
const { deps, setDisplayName, renameWorktreeFolder } = makeDeps()
|
||||
await maybeAutoRenameBranchOnFirstWork(workingEvent(), deps)
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['branch', '-m', 'you/fix-auth-2'],
|
||||
expect.objectContaining({ cwd: '/repo/wt' })
|
||||
)
|
||||
// Display name and folder must follow the resolved (suffixed) leaf, not the
|
||||
// pre-suffix slug — otherwise they diverge from the branch.
|
||||
expect(setDisplayName).toHaveBeenCalledWith(WORKTREE_ID, 'Fix auth 2')
|
||||
expect(renameWorktreeFolder).toHaveBeenCalledWith(WORKTREE_ID, 'fix-auth-2')
|
||||
})
|
||||
|
||||
it('does not rename when the branch changes while generation is running', async () => {
|
||||
|
||||
@@ -5,18 +5,19 @@
|
||||
// summarize the prompt via the configured agent, and rename.
|
||||
import type { GlobalSettings, Repo } from '../../shared/types'
|
||||
import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../shared/worktree-id'
|
||||
import { parseWorkspaceKey } from '../../shared/workspace-scope'
|
||||
import { parsePaneKey } from '../../shared/stable-pane-id'
|
||||
import {
|
||||
humanizeBranchSlug,
|
||||
isAutoGeneratedCreatureBranchName
|
||||
isAutoGeneratedCreatureBranchName,
|
||||
stripConfiguredBranchPrefix
|
||||
} from '../../shared/branch-name-from-work'
|
||||
import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key'
|
||||
import { computeBranchName } from '../ipc/worktree-logic'
|
||||
import { computeBranchName, getConfiguredBranchPrefix } from '../ipc/worktree-logic'
|
||||
import { gitExecFileAsync } from '../git/runner'
|
||||
import { getGitUsername } from '../git/repo'
|
||||
import { getSshGitUsername } from '../git/git-username'
|
||||
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import type { SshGitProvider } from '../providers/ssh-git-provider'
|
||||
import {
|
||||
branchHasUpstream,
|
||||
renameCurrentBranch,
|
||||
@@ -25,13 +26,10 @@ import {
|
||||
} from '../git/branch-rename'
|
||||
import {
|
||||
generateBranchNameFromContext,
|
||||
resolveTextGenerationParams,
|
||||
type CommitMessageGenerationTarget
|
||||
resolveTextGenerationParams
|
||||
} from '../text-generation/commit-message-text-generation'
|
||||
import {
|
||||
prepareLocalCommitMessageAgentEnv,
|
||||
type CommitMessageAgentEnvironmentResolvers
|
||||
} from '../text-generation/commit-message-agent-environment'
|
||||
import type { CommitMessageAgentEnvironmentResolvers } from '../text-generation/commit-message-agent-environment'
|
||||
import { resolveGenerationTarget } from './first-work-generation-target'
|
||||
|
||||
export type FirstWorkBranchRenameEvent = {
|
||||
paneKey: string
|
||||
@@ -50,10 +48,16 @@ export type FirstWorkBranchRenameDeps = {
|
||||
getAgentEnvResolvers: () => CommitMessageAgentEnvironmentResolvers | undefined
|
||||
/** Current sidebar display name for the worktree, if one is stored. */
|
||||
getCurrentDisplayName: (worktreeId: string) => string | undefined
|
||||
/** Current workspace path for non-git folder workspaces. */
|
||||
getFolderWorkspacePath?: (worktreeId: string) => string | undefined
|
||||
/** True while a workspace title is waiting for the first agent message. */
|
||||
isPendingFirstAgentMessageRename?: (worktreeId: string) => boolean
|
||||
/** True only for Orca-created worktrees whose branch Orca is allowed to rename. */
|
||||
canRenameOrcaCreatedBranch: (worktreeId: string) => boolean
|
||||
/** Persist a new sidebar display name for the worktree. */
|
||||
setDisplayName: (worktreeId: string, displayName: string) => void
|
||||
/** Align the on-disk folder with the new branch leaf (best-effort, local-only). */
|
||||
renameWorktreeFolder?: (worktreeId: string, newLeaf: string) => Promise<boolean>
|
||||
/** Record (or clear with null) a user-facing auto-rename generation failure
|
||||
* so the sidebar can show a "rename failed" badge instead of silent retries. */
|
||||
setRenameError: (worktreeId: string, error: string | null) => void
|
||||
@@ -164,6 +168,18 @@ async function runAutoRename(
|
||||
return false
|
||||
}
|
||||
|
||||
const workspaceScope = parseWorkspaceKey(worktreeId)
|
||||
if (workspaceScope?.type === 'folder') {
|
||||
return runFolderWorkspaceTitleAutoRename(
|
||||
worktreeId,
|
||||
prompt,
|
||||
assistantMessage,
|
||||
deps,
|
||||
stop,
|
||||
retry
|
||||
)
|
||||
}
|
||||
|
||||
const repo = deps.getRepo(getRepoIdFromWorktreeId(worktreeId))
|
||||
const parsed = splitWorktreeId(worktreeId)
|
||||
if (!repo || !parsed) {
|
||||
@@ -241,26 +257,42 @@ async function runAutoRename(
|
||||
const username = provider
|
||||
? (await getSshGitUsername(provider, repo.path)) || null
|
||||
: getGitUsername(repo.path) || null
|
||||
// The model is told not to add a prefix, but sometimes echoes the configured
|
||||
// one (e.g. `tmchow/...`); strip it so it doesn't double-prefix the branch or
|
||||
// leak into the display name.
|
||||
const slug = stripConfiguredBranchPrefix(
|
||||
generated.slug,
|
||||
getConfiguredBranchPrefix(settings, username)
|
||||
)
|
||||
// Prefix-only model output strips to empty; renaming with it would just
|
||||
// re-add the prefix (`tmchow/tmchow`), so treat it as a benign skip.
|
||||
if (!slug) {
|
||||
return stop('model produced only the configured prefix', true)
|
||||
}
|
||||
const newBranch = await resolveUniqueBranchName(
|
||||
exec,
|
||||
generated.slug,
|
||||
slug,
|
||||
(slugLeaf) => computeBranchName(slugLeaf, settings, username),
|
||||
currentBranch
|
||||
)
|
||||
if (!newBranch || newBranch === currentBranch) {
|
||||
// Generation succeeded but yielded no distinct name — terminal and benign,
|
||||
// so clear any stale failure badge a prior transient attempt left behind.
|
||||
return stop(`no distinct unique branch name for slug "${generated.slug}"`, true)
|
||||
return stop(`no distinct unique branch name for slug "${slug}"`, true)
|
||||
}
|
||||
|
||||
await (provider
|
||||
? provider.renameCurrentBranch(worktreePath, newBranch)
|
||||
: renameCurrentBranch(exec, newBranch))
|
||||
|
||||
// resolveUniqueBranchName may have appended a collision suffix (`-2`, …), so
|
||||
// derive the sidebar name and on-disk folder from the *resolved* branch leaf,
|
||||
// not the pre-suffix slug, to keep branch, display, and folder aligned.
|
||||
const newBranchLeaf = newBranch.slice(newBranch.lastIndexOf('/') + 1)
|
||||
// Keep the sidebar name in sync with the branch — but only when it is still
|
||||
// the auto-generated creature name, so a name the user typed is left alone.
|
||||
const currentDisplayName = deps.getCurrentDisplayName(worktreeId)
|
||||
const newDisplayName = humanizeBranchSlug(generated.slug)
|
||||
const newDisplayName = humanizeBranchSlug(newBranchLeaf)
|
||||
const updateDisplay = !currentDisplayName || isAutoGeneratedCreatureBranchName(currentDisplayName)
|
||||
if (updateDisplay) {
|
||||
deps.setDisplayName(worktreeId, newDisplayName)
|
||||
@@ -268,32 +300,85 @@ async function runAutoRename(
|
||||
|
||||
// A successful rename clears any stale generation-failure surfaced earlier.
|
||||
deps.setRenameError(worktreeId, null)
|
||||
deps.onRenamed(repo.id)
|
||||
|
||||
// Align the on-disk folder with the new branch leaf. Best-effort and local-only:
|
||||
// a skip or failure (remote, Windows lock, dest taken) leaves the folder as-is
|
||||
// and must never undo the branch/display rename that already landed. Runs after
|
||||
// setDisplayName so the new display name rides along into the migrated identity.
|
||||
let folderRenamed = false
|
||||
if (deps.renameWorktreeFolder) {
|
||||
try {
|
||||
folderRenamed = await deps.renameWorktreeFolder(worktreeId, newBranchLeaf)
|
||||
} catch (error) {
|
||||
console.warn('[auto-branch-rename] folder rename failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// A successful folder rename already invalidated caches and pushed a
|
||||
// worktrees:changed carrying the id mapping; a second onRenamed would only
|
||||
// trigger a redundant renderer re-list. Otherwise notify for the branch rename.
|
||||
if (!folderRenamed) {
|
||||
deps.onRenamed(repo.id)
|
||||
}
|
||||
const displayLog = updateDisplay
|
||||
? `display "${currentDisplayName ?? ''}" -> "${newDisplayName}"`
|
||||
: `display kept ("${currentDisplayName}")`
|
||||
console.info(`[auto-branch-rename] renamed ${currentBranch} -> ${newBranch}; ${displayLog}`)
|
||||
const folderLog = folderRenamed ? '; folder renamed' : ''
|
||||
console.info(
|
||||
`[auto-branch-rename] renamed ${currentBranch} -> ${newBranch}; ${displayLog}${folderLog}`
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
async function resolveGenerationTarget(
|
||||
worktreePath: string,
|
||||
agentId: string,
|
||||
provider: SshGitProvider | null,
|
||||
deps: FirstWorkBranchRenameDeps
|
||||
): Promise<CommitMessageGenerationTarget | null> {
|
||||
if (provider) {
|
||||
return {
|
||||
kind: 'remote',
|
||||
cwd: worktreePath,
|
||||
execute: (plan, cwd, timeoutMs, operation) =>
|
||||
provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation),
|
||||
missingBinaryLocation: 'remote PATH'
|
||||
async function runFolderWorkspaceTitleAutoRename(
|
||||
worktreeId: string,
|
||||
prompt: string,
|
||||
assistantMessage: string | undefined,
|
||||
deps: FirstWorkBranchRenameDeps,
|
||||
stop: (reason: string, clearError?: boolean) => true,
|
||||
retry: (reason: string) => false
|
||||
): Promise<boolean> {
|
||||
if (deps.isPendingFirstAgentMessageRename?.(worktreeId) !== true) {
|
||||
return stop('folder workspace is not pending title rename', true)
|
||||
}
|
||||
const folderPath = deps.getFolderWorkspacePath?.(worktreeId)
|
||||
if (!folderPath) {
|
||||
return stop('folder workspace path unavailable')
|
||||
}
|
||||
|
||||
const settings = deps.getSettings()
|
||||
const resolvedParams = resolveTextGenerationParams(settings, 'local', 'branchName', null)
|
||||
if (!resolvedParams.ok) {
|
||||
deps.setRenameError(worktreeId, resolvedParams.error)
|
||||
return stop(`no generation agent: ${resolvedParams.error}`)
|
||||
}
|
||||
const target = await resolveGenerationTarget(
|
||||
folderPath,
|
||||
resolvedParams.params.agentId,
|
||||
null,
|
||||
deps
|
||||
)
|
||||
if (!target) {
|
||||
deps.setRenameError(worktreeId, 'Could not prepare the workspace-name generation environment.')
|
||||
return retry('could not prepare generation environment')
|
||||
}
|
||||
|
||||
const generated = await generateBranchNameFromContext(
|
||||
{ firstPrompt: prompt, assistantMessage },
|
||||
resolvedParams.params,
|
||||
target
|
||||
)
|
||||
if (!generated.success) {
|
||||
if (!generated.canceled) {
|
||||
deps.setRenameError(worktreeId, generated.error)
|
||||
}
|
||||
return retry(`generation failed: ${generated.error}`)
|
||||
}
|
||||
const localEnv = await prepareLocalCommitMessageAgentEnv(agentId, deps.getAgentEnvResolvers())
|
||||
if (!localEnv.ok) {
|
||||
return null
|
||||
}
|
||||
return { kind: 'local', cwd: worktreePath, ...(localEnv.env ? { env: localEnv.env } : {}) }
|
||||
|
||||
const newDisplayName = humanizeBranchSlug(generated.slug)
|
||||
deps.setDisplayName(worktreeId, newDisplayName)
|
||||
deps.setRenameError(worktreeId, null)
|
||||
deps.onRenamed(worktreeId)
|
||||
console.info(`[auto-branch-rename] renamed folder workspace title -> "${newDisplayName}"`)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { GlobalSettings, Repo } from '../../shared/types'
|
||||
import {
|
||||
renameWorktreeFolderOnFirstWork,
|
||||
type FirstWorkFolderRenameDeps
|
||||
} from './first-work-folder-rename'
|
||||
|
||||
const REPO = { id: 'repo1', path: '/repos/orca', connectionId: null } as unknown as Repo
|
||||
const SETTINGS = { nestWorkspaces: false, workspaceDir: '/ws' } as unknown as GlobalSettings
|
||||
const OLD_ID = 'repo1::/ws/cunner'
|
||||
|
||||
function makeDeps(overrides: Partial<FirstWorkFolderRenameDeps> = {}): FirstWorkFolderRenameDeps {
|
||||
return {
|
||||
getRepo: vi.fn(() => REPO),
|
||||
getSettings: vi.fn(() => SETTINGS),
|
||||
migrateWorktreeIdentity: vi.fn(),
|
||||
notifyWorktreeRenamed: vi.fn(),
|
||||
pathExists: vi.fn(async () => false),
|
||||
moveWorktree: vi.fn(async () => {}),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('renameWorktreeFolderOnFirstWork', () => {
|
||||
const originalPlatform = process.platform
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' })
|
||||
})
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
})
|
||||
|
||||
it('moves the folder and migrates identity on the happy path', async () => {
|
||||
const deps = makeDeps()
|
||||
const result = await renameWorktreeFolderOnFirstWork(OLD_ID, 'worktree-creation-spinner', deps)
|
||||
expect(result).toBe(true)
|
||||
expect(deps.moveWorktree).toHaveBeenCalledWith(
|
||||
'/repos/orca',
|
||||
'/ws/cunner',
|
||||
'/ws/worktree-creation-spinner'
|
||||
)
|
||||
expect(deps.migrateWorktreeIdentity).toHaveBeenCalledWith(
|
||||
OLD_ID,
|
||||
'repo1::/ws/worktree-creation-spinner'
|
||||
)
|
||||
expect(deps.notifyWorktreeRenamed).toHaveBeenCalledWith(
|
||||
'repo1',
|
||||
OLD_ID,
|
||||
'repo1::/ws/worktree-creation-spinner'
|
||||
)
|
||||
})
|
||||
|
||||
it('skips (no move) when the destination already exists', async () => {
|
||||
const deps = makeDeps({ pathExists: vi.fn(async () => true) })
|
||||
expect(await renameWorktreeFolderOnFirstWork(OLD_ID, 'taken', deps)).toBe(false)
|
||||
expect(deps.moveWorktree).not.toHaveBeenCalled()
|
||||
expect(deps.migrateWorktreeIdentity).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips remote worktrees without moving', async () => {
|
||||
const deps = makeDeps({ getRepo: vi.fn(() => ({ ...REPO, connectionId: 'ssh1' })) })
|
||||
expect(await renameWorktreeFolderOnFirstWork(OLD_ID, 'fix-auth', deps)).toBe(false)
|
||||
expect(deps.moveWorktree).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips runtime-owned worktrees without moving', async () => {
|
||||
const deps = makeDeps({
|
||||
getRepo: vi.fn(() => ({ ...REPO, executionHostId: 'runtime:gpu-vm' as const }))
|
||||
})
|
||||
expect(await renameWorktreeFolderOnFirstWork(OLD_ID, 'fix-auth', deps)).toBe(false)
|
||||
expect(deps.moveWorktree).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns false when the repo is unknown', async () => {
|
||||
const deps = makeDeps({ getRepo: vi.fn(() => undefined) })
|
||||
expect(await renameWorktreeFolderOnFirstWork(OLD_ID, 'fix-auth', deps)).toBe(false)
|
||||
expect(deps.moveWorktree).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
// Why: after the first-work branch+display rename, the worktree's on-disk folder
|
||||
// still carries its creature name (e.g. `cunner`), which is confusing once the
|
||||
// branch reads `worktree-creation-spinner`. This module aligns the folder with
|
||||
// the new branch leaf via `git worktree move`, then migrates Orca's path-derived
|
||||
// worktree identity so meta, tabs, and the live PTY session carry over. It is
|
||||
// best-effort and local-only — remote/Windows/locked/dest-taken all degrade to
|
||||
// "folder kept" without disturbing the rename that already succeeded.
|
||||
import type { GlobalSettings, Repo } from '../../shared/types'
|
||||
import { getRepoIdFromWorktreeId, splitWorktreeId } from '../../shared/worktree-id'
|
||||
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
||||
import { planWorktreeFolderRename } from '../ipc/worktree-folder-rename-target'
|
||||
|
||||
export type FirstWorkFolderRenameDeps = {
|
||||
getRepo: (repoId: string) => Repo | undefined
|
||||
getSettings: () => GlobalSettings
|
||||
/** Re-key all worktreeId-keyed state from the old (path-derived) id to the new. */
|
||||
migrateWorktreeIdentity: (oldWorktreeId: string, newWorktreeId: string) => void
|
||||
/** Invalidate caches + tell the renderer the worktree's id changed (old->new) so
|
||||
* it re-keys its state instead of treating the rename as a deletion. */
|
||||
notifyWorktreeRenamed: (repoId: string, oldWorktreeId: string, newWorktreeId: string) => void
|
||||
/** True when the path already exists — git worktree move refuses a taken dest. */
|
||||
pathExists: (path: string) => Promise<boolean>
|
||||
moveWorktree: (repoPath: string, oldPath: string, newPath: string) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a worktree's folder to match its work-derived branch leaf. Returns true
|
||||
* only when the folder was actually moved; false (folder kept) for every skip or
|
||||
* graceful-degrade case. Throws only on an unexpected git failure mid-move — the
|
||||
* caller swallows it so the branch/display rename is never undone.
|
||||
*/
|
||||
export async function renameWorktreeFolderOnFirstWork(
|
||||
worktreeId: string,
|
||||
newLeaf: string,
|
||||
deps: FirstWorkFolderRenameDeps
|
||||
): Promise<boolean> {
|
||||
const repo = deps.getRepo(getRepoIdFromWorktreeId(worktreeId))
|
||||
const parsed = splitWorktreeId(worktreeId)
|
||||
if (!repo || !parsed) {
|
||||
return false
|
||||
}
|
||||
const plan = planWorktreeFolderRename({
|
||||
repoId: repo.id,
|
||||
repoPath: repo.path,
|
||||
oldWorktreePath: parsed.worktreePath,
|
||||
newLeaf,
|
||||
settings: deps.getSettings(),
|
||||
platform: process.platform,
|
||||
isRemote: getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID
|
||||
})
|
||||
if (!plan) {
|
||||
return false
|
||||
}
|
||||
if (await deps.pathExists(plan.newPath)) {
|
||||
return false
|
||||
}
|
||||
await deps.moveWorktree(repo.path, plan.oldPath, plan.newPath)
|
||||
// Order: move first (point of no return), then re-key identity synchronously so
|
||||
// nothing interleaves before the worktree's state is re-bound to the new id.
|
||||
deps.migrateWorktreeIdentity(worktreeId, plan.newWorktreeId)
|
||||
deps.notifyWorktreeRenamed(repo.id, worktreeId, plan.newWorktreeId)
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { SshGitProvider } from '../providers/ssh-git-provider'
|
||||
import type { CommitMessageGenerationTarget } from '../text-generation/commit-message-text-generation'
|
||||
import {
|
||||
prepareLocalCommitMessageAgentEnv,
|
||||
type CommitMessageAgentEnvironmentResolvers
|
||||
} from '../text-generation/commit-message-agent-environment'
|
||||
|
||||
/** Resolve where the branch-name generation runs: a remote SSH provider when one
|
||||
* is present, else the local agent env (null when that env can't be prepared). */
|
||||
export async function resolveGenerationTarget(
|
||||
worktreePath: string,
|
||||
agentId: string,
|
||||
provider: SshGitProvider | null,
|
||||
deps: { getAgentEnvResolvers: () => CommitMessageAgentEnvironmentResolvers | undefined }
|
||||
): Promise<CommitMessageGenerationTarget | null> {
|
||||
if (provider) {
|
||||
return {
|
||||
kind: 'remote',
|
||||
cwd: worktreePath,
|
||||
execute: (plan, cwd, timeoutMs, operation) =>
|
||||
provider.executeCommitMessagePlan(plan, cwd, timeoutMs, operation),
|
||||
missingBinaryLocation: 'remote PATH'
|
||||
}
|
||||
}
|
||||
const localEnv = await prepareLocalCommitMessageAgentEnv(agentId, deps.getAgentEnvResolvers())
|
||||
if (!localEnv.ok) {
|
||||
return null
|
||||
}
|
||||
return { kind: 'local', cwd: worktreePath, ...(localEnv.env ? { env: localEnv.env } : {}) }
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Benchmark-style regression test for the OpenCode MessagePart flood.
|
||||
*
|
||||
* Drives the REAL agent-hook HTTP pipeline (loopback socket, body read,
|
||||
* JSON.parse, normalization, listener fanout) with two client behaviors:
|
||||
*
|
||||
* - "legacy plugin": one POST per streamed part update, each carrying the
|
||||
* FULL accumulated reply text (how plugin builds before the throttle fix
|
||||
* behaved) — O(n²) bytes per turn.
|
||||
* - "throttled plugin": leading + trailing-edge coalesced posts at 250ms
|
||||
* cadence with text capped at 4000 chars (current plugin behavior).
|
||||
*
|
||||
* The byte/post-count assertions are deterministic; wall-clock timings are
|
||||
* logged as benchmark evidence (see notes/windows-perf-progress.md) but not
|
||||
* asserted, to keep CI stable.
|
||||
*/
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
|
||||
const { getCohortAtEmitMock, trackMock } = vi.hoisted(() => ({
|
||||
getCohortAtEmitMock: vi.fn(),
|
||||
trackMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../telemetry/client', () => ({
|
||||
track: trackMock
|
||||
}))
|
||||
|
||||
vi.mock('../telemetry/cohort-classifier', () => ({
|
||||
getCohortAtEmit: getCohortAtEmitMock
|
||||
}))
|
||||
|
||||
import { AgentHookServer } from './server'
|
||||
|
||||
const PANE = makePaneKey('tab-bench', '99999999-9999-4999-8999-999999999999')
|
||||
|
||||
// A realistic long streaming reply: ~120 KB final text arriving in 400
|
||||
// part updates (OpenCode re-publishes the whole part per append).
|
||||
const FINAL_REPLY_CHARS = 120_000
|
||||
const LEGACY_PART_UPDATES = 400
|
||||
// Throttled plugin posts at most one MessagePart per 250ms. A ~30s turn
|
||||
// yields ~120 posts; we use that worst-case count with the 4000-char cap.
|
||||
const THROTTLED_POSTS = 120
|
||||
const THROTTLED_TEXT_CAP = 4_000
|
||||
|
||||
describe('OpenCode MessagePart flood benchmark', () => {
|
||||
let server: AgentHookServer
|
||||
let tempDir: string
|
||||
let listenerEvents: number
|
||||
|
||||
beforeEach(async () => {
|
||||
getCohortAtEmitMock.mockReturnValue({ nth_repo_added: 2 })
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'orca-hook-bench-'))
|
||||
server = new AgentHookServer()
|
||||
listenerEvents = 0
|
||||
server.setListener(() => {
|
||||
listenerEvents++
|
||||
})
|
||||
await server.start({ env: 'production', userDataPath: tempDir })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
server.stop()
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function postMessagePart(env: Record<string, string>, text: string): Promise<void> {
|
||||
const response = await fetch(`http://127.0.0.1:${env.ORCA_AGENT_HOOK_PORT}/hook/opencode`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': env.ORCA_AGENT_HOOK_TOKEN
|
||||
},
|
||||
body: JSON.stringify({
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-bench',
|
||||
worktreeId: 'wt-bench',
|
||||
env: 'production',
|
||||
payload: {
|
||||
hook_event_name: 'MessagePart',
|
||||
role: 'assistant',
|
||||
text,
|
||||
messageID: 'msg-bench',
|
||||
sessionID: 'session-bench'
|
||||
}
|
||||
})
|
||||
})
|
||||
expect(response.status).toBe(204)
|
||||
}
|
||||
|
||||
it('throttled plugin behavior cuts per-turn hook-pipeline bytes by >40x', async () => {
|
||||
const env = server.buildPtyEnv()
|
||||
expect(env.ORCA_AGENT_HOOK_PORT).toBeTruthy()
|
||||
|
||||
// Legacy: full accumulated text per part update.
|
||||
let legacyBytes = 0
|
||||
const legacyStart = performance.now()
|
||||
for (let i = 1; i <= LEGACY_PART_UPDATES; i++) {
|
||||
const text = 'x'.repeat(Math.floor((FINAL_REPLY_CHARS * i) / LEGACY_PART_UPDATES))
|
||||
legacyBytes += text.length
|
||||
await postMessagePart(env, text)
|
||||
}
|
||||
const legacyMs = performance.now() - legacyStart
|
||||
const legacyEvents = listenerEvents
|
||||
|
||||
listenerEvents = 0
|
||||
|
||||
// Throttled: bounded post count, bounded text.
|
||||
let throttledBytes = 0
|
||||
const throttledStart = performance.now()
|
||||
for (let i = 1; i <= THROTTLED_POSTS; i++) {
|
||||
const text = 'x'.repeat(THROTTLED_TEXT_CAP)
|
||||
throttledBytes += text.length
|
||||
await postMessagePart(env, text)
|
||||
}
|
||||
const throttledMs = performance.now() - throttledStart
|
||||
const throttledEvents = listenerEvents
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[bench] legacy: ${LEGACY_PART_UPDATES} posts, ${(legacyBytes / 1024 / 1024).toFixed(1)} MB, ` +
|
||||
`${legacyMs.toFixed(0)} ms, ${legacyEvents} listener fanouts | ` +
|
||||
`throttled: ${THROTTLED_POSTS} posts, ${(throttledBytes / 1024).toFixed(0)} KB, ` +
|
||||
`${throttledMs.toFixed(0)} ms, ${throttledEvents} listener fanouts`
|
||||
)
|
||||
|
||||
// Deterministic: the turn's total text volume through the main process
|
||||
// drops from O(n²) (~23 MB here) to O(posts × cap) (~470 KB here, >40x
|
||||
// less). Real turns stream far more than 400 part updates, so the
|
||||
// real-world ratio is larger still.
|
||||
expect(throttledBytes).toBeLessThan(legacyBytes / 40)
|
||||
expect(THROTTLED_POSTS).toBeLessThan(LEGACY_PART_UPDATES / 3 + 1)
|
||||
}, 120_000)
|
||||
})
|
||||
@@ -3842,6 +3842,38 @@ describe('OpenCode hook normalization', () => {
|
||||
expect(result?.payload.lastAssistantMessage).toBe('Hello! How can I help?')
|
||||
})
|
||||
|
||||
it('caps oversized MessagePart text from stale (pre-throttle) plugin builds', () => {
|
||||
// Why: plugin builds installed before the throttle/cap fix re-post the
|
||||
// full accumulated reply on every streamed part update. The listener must
|
||||
// bound the text so each event's status compare, IPC fanout, and renderer
|
||||
// store update stay O(cap) instead of O(reply length).
|
||||
const assistant = _internals.normalizeHookPayload(
|
||||
'opencode',
|
||||
buildBody({
|
||||
hook_event_name: 'MessagePart',
|
||||
role: 'assistant',
|
||||
text: 'a'.repeat(500_000)
|
||||
}),
|
||||
'production'
|
||||
)
|
||||
expect(assistant?.payload.lastAssistantMessage?.length).toBe(8_000)
|
||||
|
||||
// Why: prompt has always been single-line-capped at 200 by
|
||||
// normalizeAgentStatusObject; this asserts the oversized input still
|
||||
// flows through without blowing past that bound.
|
||||
const user = _internals.normalizeHookPayload(
|
||||
'opencode',
|
||||
buildBody({
|
||||
hook_event_name: 'MessagePart',
|
||||
role: 'user',
|
||||
text: 'u'.repeat(500_000),
|
||||
messageID: 'msg-cap'
|
||||
}),
|
||||
'production'
|
||||
)
|
||||
expect(user?.payload.prompt?.length).toBe(200)
|
||||
})
|
||||
|
||||
it('subsequent SessionIdle preserves cached prompt + assistant message', () => {
|
||||
_internals.normalizeHookPayload(
|
||||
'opencode',
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { basename, extname } from 'path'
|
||||
import {
|
||||
aiVaultAgentLabel,
|
||||
buildAiVaultResumeCommand,
|
||||
type AiVaultAgent,
|
||||
type AiVaultSession,
|
||||
type AiVaultSessionPreviewMessage
|
||||
} from '../../shared/ai-vault-types'
|
||||
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
|
||||
import {
|
||||
extractPreviewContentText,
|
||||
extractString,
|
||||
normalizePreviewText,
|
||||
timestampMs
|
||||
} from './session-scanner-values'
|
||||
|
||||
const SESSION_PREVIEW_MESSAGE_LIMIT = 5
|
||||
|
||||
export function createAccumulator(args: {
|
||||
agent: AiVaultAgent
|
||||
file: FileWithMtime
|
||||
sessionId: string
|
||||
}): SessionAccumulator {
|
||||
return {
|
||||
agent: args.agent,
|
||||
sessionId: args.sessionId,
|
||||
title: null,
|
||||
fallbackTitle: null,
|
||||
cwd: null,
|
||||
branch: null,
|
||||
model: null,
|
||||
filePath: args.file.path,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
modifiedAt: args.file.modifiedAt,
|
||||
messageCount: 0,
|
||||
totalTokens: 0,
|
||||
previewMessages: [],
|
||||
latestTimestampMs: 0
|
||||
}
|
||||
}
|
||||
|
||||
export function finalizeSession(
|
||||
accumulator: SessionAccumulator,
|
||||
platform: NodeJS.Platform,
|
||||
options: { codexHome?: string | null } = {}
|
||||
): AiVaultSession | null {
|
||||
const sessionId = accumulator.sessionId.trim()
|
||||
if (!sessionId) {
|
||||
return null
|
||||
}
|
||||
const title =
|
||||
accumulator.title ||
|
||||
accumulator.fallbackTitle ||
|
||||
`${aiVaultAgentLabel(accumulator.agent)} ${sessionId.slice(0, 8)}`
|
||||
|
||||
return {
|
||||
id: `${accumulator.agent}:${sessionId}:${accumulator.filePath}`,
|
||||
agent: accumulator.agent,
|
||||
sessionId,
|
||||
title,
|
||||
cwd: accumulator.cwd,
|
||||
branch: accumulator.branch,
|
||||
model: accumulator.model,
|
||||
filePath: accumulator.filePath,
|
||||
codexHome: accumulator.agent === 'codex' ? (options.codexHome ?? null) : null,
|
||||
createdAt: accumulator.createdAt,
|
||||
updatedAt: accumulator.updatedAt,
|
||||
modifiedAt: accumulator.modifiedAt,
|
||||
messageCount: accumulator.messageCount,
|
||||
totalTokens: accumulator.totalTokens,
|
||||
previewMessages: accumulator.previewMessages,
|
||||
resumeCommand: buildAiVaultResumeCommand({
|
||||
agent: accumulator.agent,
|
||||
sessionId,
|
||||
cwd: accumulator.cwd,
|
||||
platform,
|
||||
codexHome: options.codexHome
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function updateTimeline(accumulator: SessionAccumulator, timestamp: unknown): void {
|
||||
const parsed = timestampMs(timestamp)
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return
|
||||
}
|
||||
const iso = new Date(parsed).toISOString()
|
||||
if (!accumulator.createdAt || parsed < Date.parse(accumulator.createdAt)) {
|
||||
accumulator.createdAt = iso
|
||||
}
|
||||
if (!accumulator.updatedAt || parsed >= Date.parse(accumulator.updatedAt)) {
|
||||
accumulator.updatedAt = iso
|
||||
accumulator.latestTimestampMs = parsed
|
||||
}
|
||||
}
|
||||
|
||||
export function addPreviewMessage(
|
||||
accumulator: SessionAccumulator,
|
||||
args: {
|
||||
role: AiVaultSessionPreviewMessage['role']
|
||||
text: string | null
|
||||
timestamp?: unknown
|
||||
}
|
||||
): void {
|
||||
const text = normalizePreviewText(args.text ?? '')
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
accumulator.previewMessages.push({
|
||||
role: args.role,
|
||||
text,
|
||||
timestamp: timestampIso(args.timestamp)
|
||||
})
|
||||
if (accumulator.previewMessages.length > SESSION_PREVIEW_MESSAGE_LIMIT) {
|
||||
accumulator.previewMessages.shift()
|
||||
}
|
||||
}
|
||||
|
||||
export function addPreviewContent(
|
||||
accumulator: SessionAccumulator,
|
||||
role: AiVaultSessionPreviewMessage['role'],
|
||||
content: unknown,
|
||||
timestamp?: unknown
|
||||
): void {
|
||||
addPreviewMessage(accumulator, {
|
||||
role,
|
||||
text: extractPreviewContentText(content),
|
||||
timestamp
|
||||
})
|
||||
}
|
||||
|
||||
export function timestampIso(value: unknown): string | null {
|
||||
const parsed = timestampMs(value)
|
||||
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null
|
||||
}
|
||||
|
||||
export function updateLatestLocation(
|
||||
accumulator: SessionAccumulator,
|
||||
record: Record<string, unknown>
|
||||
): void {
|
||||
const timestamp = extractString(record.timestamp)
|
||||
const parsed = timestamp ? Date.parse(timestamp) : accumulator.latestTimestampMs
|
||||
if (!Number.isFinite(parsed) || parsed < accumulator.latestTimestampMs) {
|
||||
return
|
||||
}
|
||||
const cwd = extractString(record.cwd)
|
||||
const branch = extractString(record.gitBranch)
|
||||
if (cwd) {
|
||||
accumulator.cwd = cwd
|
||||
}
|
||||
if (branch) {
|
||||
accumulator.branch = branch
|
||||
}
|
||||
}
|
||||
|
||||
export function sessionSortTime(session: AiVaultSession): number {
|
||||
return Date.parse(session.updatedAt ?? session.modifiedAt)
|
||||
}
|
||||
|
||||
export function sessionIdFromFileName(filePath: string): string {
|
||||
const fileName = basename(filePath, extname(filePath))
|
||||
const match = fileName.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)
|
||||
return match?.[0] ?? fileName
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { dirname, resolve } from 'path'
|
||||
|
||||
export function codexHomeForSessionsDir(
|
||||
sessionsDir: string,
|
||||
defaultCodexHomeDir: string
|
||||
): string | null {
|
||||
const codexHome = dirname(sessionsDir)
|
||||
return codexHome === defaultCodexHomeDir ? null : codexHome
|
||||
}
|
||||
|
||||
export function uniqueCodexSessionsDirs(paths: readonly string[]): string[] {
|
||||
const seen = new Set<string>()
|
||||
const unique: string[] = []
|
||||
for (const path of paths) {
|
||||
const trimmed = path.trim()
|
||||
if (!trimmed) {
|
||||
continue
|
||||
}
|
||||
const key = resolve(trimmed)
|
||||
if (seen.has(key)) {
|
||||
continue
|
||||
}
|
||||
seen.add(key)
|
||||
unique.push(trimmed)
|
||||
}
|
||||
return unique
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { readdir, stat } from 'fs/promises'
|
||||
import { basename, delimiter, extname, join } from 'path'
|
||||
import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types'
|
||||
import type { FileWithMtime, SessionFileDiscovery } from './session-scanner-types'
|
||||
import { errorMessage } from './session-scanner-values'
|
||||
|
||||
export async function discoverFiles(args: {
|
||||
rootDir: string
|
||||
limit: number
|
||||
agent: AiVaultAgent
|
||||
issues: AiVaultScanIssue[]
|
||||
extensions: string[]
|
||||
filePredicate?: (path: string) => boolean
|
||||
}): Promise<SessionFileDiscovery> {
|
||||
const paths = await walkSessionFiles(args.rootDir, args.agent, args.issues, {
|
||||
extensions: new Set(args.extensions),
|
||||
filePredicate: args.filePredicate
|
||||
})
|
||||
const files: FileWithMtime[] = []
|
||||
for (const path of paths) {
|
||||
try {
|
||||
const fileStat = await stat(path)
|
||||
files.push({
|
||||
path,
|
||||
mtimeMs: fileStat.mtimeMs,
|
||||
modifiedAt: fileStat.mtime.toISOString()
|
||||
})
|
||||
} catch (err) {
|
||||
args.issues.push({ agent: args.agent, path, message: errorMessage(err) })
|
||||
}
|
||||
}
|
||||
return {
|
||||
agent: args.agent,
|
||||
rootDir: args.rootDir,
|
||||
files: files.sort((left, right) => right.mtimeMs - left.mtimeMs).slice(0, args.limit)
|
||||
}
|
||||
}
|
||||
|
||||
export async function discoverOpenClawFiles(args: {
|
||||
rootDirs: string[]
|
||||
limit: number
|
||||
issues: AiVaultScanIssue[]
|
||||
}): Promise<SessionFileDiscovery> {
|
||||
const discoveries = await Promise.all(
|
||||
args.rootDirs.map((rootDir) =>
|
||||
discoverFiles({
|
||||
rootDir: basename(rootDir) === 'agents' ? rootDir : join(rootDir, 'agents'),
|
||||
limit: args.limit,
|
||||
agent: 'openclaw',
|
||||
issues: args.issues,
|
||||
extensions: ['.jsonl'],
|
||||
filePredicate: (path) => path.split(/[\\/]/).includes('sessions')
|
||||
})
|
||||
)
|
||||
)
|
||||
const files = discoveries
|
||||
.flatMap((discovery) => discovery.files)
|
||||
.sort((left, right) => right.mtimeMs - left.mtimeMs)
|
||||
.slice(0, args.limit)
|
||||
return { agent: 'openclaw', rootDir: args.rootDirs.join(delimiter), files }
|
||||
}
|
||||
|
||||
export async function walkSessionFiles(
|
||||
dirPath: string,
|
||||
agent: AiVaultAgent,
|
||||
issues: AiVaultScanIssue[],
|
||||
options: {
|
||||
extensions: Set<string>
|
||||
filePredicate?: (path: string) => boolean
|
||||
}
|
||||
): Promise<string[]> {
|
||||
let entries
|
||||
try {
|
||||
entries = await readdir(dirPath, { withFileTypes: true })
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const files: string[] = []
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dirPath, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await walkSessionFiles(fullPath, agent, issues, options)))
|
||||
continue
|
||||
}
|
||||
if (
|
||||
entry.isFile() &&
|
||||
options.extensions.has(extname(entry.name).toLowerCase()) &&
|
||||
(options.filePredicate?.(fullPath) ?? true)
|
||||
) {
|
||||
files.push(fullPath)
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { createReadStream } from 'fs'
|
||||
import { readFile } from 'fs/promises'
|
||||
import { basename, 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 {
|
||||
addPreviewContent,
|
||||
addPreviewMessage,
|
||||
createAccumulator,
|
||||
finalizeSession,
|
||||
sessionIdFromFileName,
|
||||
updateTimeline
|
||||
} from './session-scanner-accumulator'
|
||||
import {
|
||||
arrayValue,
|
||||
asRecord,
|
||||
extractContentText,
|
||||
extractMessageText,
|
||||
extractPreviewContentText,
|
||||
extractString,
|
||||
firstString,
|
||||
normalizeTitleText,
|
||||
parseJsonObject,
|
||||
readJsonObjectIfExists,
|
||||
tokenTotal
|
||||
} from './session-scanner-values'
|
||||
|
||||
export async function parseRovoSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const metadata = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown)
|
||||
if (!metadata) {
|
||||
return null
|
||||
}
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'rovo',
|
||||
file,
|
||||
sessionId: basename(dirname(file.path))
|
||||
})
|
||||
accumulator.title = firstString(metadata, ['title', 'name', 'summary'])
|
||||
accumulator.cwd = firstString(metadata, [
|
||||
'workspace_path',
|
||||
'workspacePath',
|
||||
'workspace',
|
||||
'cwd',
|
||||
'working_directory',
|
||||
'workingDirectory',
|
||||
'project_path',
|
||||
'projectPath'
|
||||
])
|
||||
updateTimeline(
|
||||
accumulator,
|
||||
extractString(metadata.created_at) ?? extractString(metadata.createdAt)
|
||||
)
|
||||
updateTimeline(
|
||||
accumulator,
|
||||
extractString(metadata.updated_at) ?? extractString(metadata.updatedAt)
|
||||
)
|
||||
|
||||
const contextPath = join(dirname(file.path), 'session_context.json')
|
||||
const context = await readJsonObjectIfExists(contextPath)
|
||||
if (context) {
|
||||
consumeRovoSessionContext(accumulator, context)
|
||||
}
|
||||
|
||||
return finalizeSession(accumulator, platform)
|
||||
}
|
||||
|
||||
export function consumeRovoSessionContext(
|
||||
accumulator: SessionAccumulator,
|
||||
context: Record<string, unknown>
|
||||
): void {
|
||||
for (const message of arrayValue(context.messages)) {
|
||||
const record = asRecord(message)
|
||||
const role = extractString(record?.role)
|
||||
if (role === 'user' || role === 'assistant') {
|
||||
accumulator.messageCount++
|
||||
updateTimeline(accumulator, extractString(record?.timestamp))
|
||||
if (role === 'user') {
|
||||
accumulator.title ??= extractContentText(record?.content)
|
||||
}
|
||||
addPreviewContent(accumulator, role, record?.content, record?.timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
for (const historyEntry of arrayValue(context.message_history)) {
|
||||
consumeRovoHistoryEntry(accumulator, asRecord(historyEntry))
|
||||
}
|
||||
}
|
||||
|
||||
export function consumeRovoHistoryEntry(
|
||||
accumulator: SessionAccumulator,
|
||||
record: Record<string, unknown> | null
|
||||
): void {
|
||||
if (!record) {
|
||||
return
|
||||
}
|
||||
updateTimeline(accumulator, extractString(record.timestamp))
|
||||
const role = extractString(record.role) ?? rovoRoleFromKind(record.kind)
|
||||
if (role !== 'user' && role !== 'assistant') {
|
||||
return
|
||||
}
|
||||
const text = rovoPartsText(arrayValue(record.parts), role)
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
accumulator.messageCount++
|
||||
if (role === 'user') {
|
||||
accumulator.title ??= text
|
||||
}
|
||||
addPreviewMessage(accumulator, {
|
||||
role,
|
||||
text,
|
||||
timestamp: record.timestamp
|
||||
})
|
||||
}
|
||||
|
||||
export function rovoRoleFromKind(value: unknown): 'user' | 'assistant' | null {
|
||||
if (value === 'request') {
|
||||
return 'user'
|
||||
}
|
||||
if (value === 'response') {
|
||||
return 'assistant'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function rovoPartsText(parts: unknown[], role: 'user' | 'assistant'): string | null {
|
||||
const texts: string[] = []
|
||||
for (const part of parts) {
|
||||
const record = asRecord(part)
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
const kind = extractString(record.part_kind)
|
||||
if (role === 'user' && kind !== 'user-prompt' && kind !== 'text') {
|
||||
continue
|
||||
}
|
||||
if (role === 'assistant' && kind !== 'text') {
|
||||
continue
|
||||
}
|
||||
const text = extractString(record.content) ?? extractString(record.text)
|
||||
if (text) {
|
||||
texts.push(text)
|
||||
}
|
||||
}
|
||||
return normalizeTitleText(texts.join(' '))
|
||||
}
|
||||
|
||||
export async function parseMessageGraphSessionFile(
|
||||
agent: 'openclaw' | 'pi',
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent,
|
||||
file,
|
||||
sessionId: sessionIdFromFileName(file.path)
|
||||
})
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
|
||||
for await (const line of lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
updateTimeline(accumulator, extractString(record.timestamp))
|
||||
if (record.type === 'session') {
|
||||
const sessionId = extractString(record.id)
|
||||
if (sessionId) {
|
||||
accumulator.sessionId = sessionId
|
||||
}
|
||||
accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd
|
||||
continue
|
||||
}
|
||||
if (record.type === 'model_change') {
|
||||
accumulator.model = extractString(record.modelId) ?? accumulator.model
|
||||
continue
|
||||
}
|
||||
if (record.type !== 'message') {
|
||||
continue
|
||||
}
|
||||
const message = asRecord(record.message)
|
||||
const role = extractString(message?.role)
|
||||
if (role === 'user' || role === 'assistant') {
|
||||
accumulator.messageCount++
|
||||
if (role === 'user') {
|
||||
accumulator.title ??= extractMessageText(message)
|
||||
} else {
|
||||
accumulator.model = extractString(message?.model) ?? accumulator.model
|
||||
accumulator.totalTokens += tokenTotal(message?.usage)
|
||||
}
|
||||
addPreviewContent(accumulator, role, message?.content, record.timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
return finalizeSession(accumulator, platform)
|
||||
}
|
||||
|
||||
export async function parseDroidSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'droid',
|
||||
file,
|
||||
sessionId: sessionIdFromFileName(file.path)
|
||||
})
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
|
||||
for await (const line of lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
updateTimeline(accumulator, record.timestamp)
|
||||
if (record.type === 'session_start') {
|
||||
accumulator.sessionId = extractString(record.id) ?? accumulator.sessionId
|
||||
accumulator.title = normalizeTitleText(extractString(record.title) ?? '')
|
||||
accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd
|
||||
continue
|
||||
}
|
||||
if (record.type === 'system') {
|
||||
accumulator.cwd = extractString(record.cwd) ?? accumulator.cwd
|
||||
accumulator.model = extractString(record.model) ?? accumulator.model
|
||||
}
|
||||
const streamSessionId = extractString(record.session_id) ?? extractString(record.sessionId)
|
||||
if (streamSessionId) {
|
||||
accumulator.sessionId = streamSessionId
|
||||
}
|
||||
if (record.type === 'message') {
|
||||
const role = extractString(record.role) ?? extractString(asRecord(record.message)?.role)
|
||||
if (role === 'user' || role === 'assistant') {
|
||||
accumulator.messageCount++
|
||||
if (role === 'user') {
|
||||
accumulator.title ??=
|
||||
normalizeTitleText(extractString(record.text) ?? '') ||
|
||||
extractMessageText(asRecord(record.message))
|
||||
}
|
||||
addPreviewMessage(accumulator, {
|
||||
role,
|
||||
text:
|
||||
extractString(record.text) ??
|
||||
extractPreviewContentText(asRecord(record.message)?.content),
|
||||
timestamp: record.timestamp
|
||||
})
|
||||
}
|
||||
} else if (record.type === 'completion') {
|
||||
accumulator.messageCount++
|
||||
accumulator.totalTokens += tokenTotal(record.usage)
|
||||
addPreviewMessage(accumulator, {
|
||||
role: 'assistant',
|
||||
text: extractString(record.finalText),
|
||||
timestamp: record.timestamp
|
||||
})
|
||||
}
|
||||
}
|
||||
return finalizeSession(accumulator, platform)
|
||||
}
|
||||
@@ -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(' '))
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { createReadStream } from 'fs'
|
||||
import { readFile } from 'fs/promises'
|
||||
import { createInterface } from 'readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { CodexUsageSnapshot, FileWithMtime, SessionAccumulator } from './session-scanner-types'
|
||||
import {
|
||||
addPreviewContent,
|
||||
createAccumulator,
|
||||
finalizeSession,
|
||||
sessionIdFromFileName,
|
||||
updateLatestLocation,
|
||||
updateTimeline
|
||||
} from './session-scanner-accumulator'
|
||||
import {
|
||||
arrayValue,
|
||||
asRecord,
|
||||
claudeUsageTotal,
|
||||
extractContentText,
|
||||
extractGitBranch,
|
||||
extractMessageText,
|
||||
extractModel,
|
||||
extractString,
|
||||
normalizeCodexUsage,
|
||||
normalizeTitleText,
|
||||
parseJsonObject,
|
||||
subtractCodexUsage,
|
||||
tokenTotal
|
||||
} from './session-scanner-values'
|
||||
|
||||
export async function parseClaudeSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'claude',
|
||||
file,
|
||||
sessionId: sessionIdFromFileName(file.path)
|
||||
})
|
||||
let metaTitle: string | null = null
|
||||
let generatedTitle: string | null = null
|
||||
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
|
||||
for await (const line of lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof record.sessionId === 'string' && record.sessionId.trim()) {
|
||||
accumulator.sessionId = record.sessionId.trim()
|
||||
}
|
||||
updateTimeline(accumulator, extractString(record.timestamp))
|
||||
updateLatestLocation(accumulator, record)
|
||||
|
||||
if (record.type === 'custom-title') {
|
||||
accumulator.title = normalizeTitleText(extractString(record.customTitle) ?? '')
|
||||
continue
|
||||
}
|
||||
|
||||
if (record.type === 'ai-title') {
|
||||
generatedTitle ??= normalizeTitleText(extractString(record.aiTitle) ?? '')
|
||||
continue
|
||||
}
|
||||
|
||||
if (record.type === 'agent-name' && !generatedTitle) {
|
||||
metaTitle ??= normalizeTitleText(extractString(record.agentName) ?? '')
|
||||
continue
|
||||
}
|
||||
|
||||
if (record.type === 'user') {
|
||||
accumulator.messageCount++
|
||||
const title = extractMessageText(record.message)
|
||||
addPreviewContent(accumulator, 'user', asRecord(record.message)?.content, record.timestamp)
|
||||
if (title && record.isMeta !== true && !accumulator.title) {
|
||||
accumulator.title = title
|
||||
} else if (title && !metaTitle) {
|
||||
metaTitle = title
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (record.type === 'assistant') {
|
||||
accumulator.messageCount++
|
||||
const message = asRecord(record.message)
|
||||
addPreviewContent(accumulator, 'assistant', message?.content, record.timestamp)
|
||||
const model = extractString(message?.model)
|
||||
if (model) {
|
||||
accumulator.model = model
|
||||
}
|
||||
accumulator.totalTokens += claudeUsageTotal(message?.usage)
|
||||
}
|
||||
}
|
||||
|
||||
accumulator.fallbackTitle = generatedTitle ?? metaTitle
|
||||
return finalizeSession(accumulator, platform)
|
||||
}
|
||||
|
||||
export async function parseCodexSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
codexHome: string | null = null
|
||||
): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'codex',
|
||||
file,
|
||||
sessionId: sessionIdFromFileName(file.path)
|
||||
})
|
||||
let previousTotals: CodexUsageSnapshot | null = null
|
||||
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
|
||||
for await (const line of lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
|
||||
updateTimeline(accumulator, extractString(record.timestamp))
|
||||
|
||||
const payload = asRecord(record.payload)
|
||||
if (record.type === 'session_meta' && payload) {
|
||||
const sessionId = extractString(payload.id)
|
||||
if (sessionId) {
|
||||
accumulator.sessionId = sessionId
|
||||
}
|
||||
const cwd = extractString(payload.cwd)
|
||||
if (cwd) {
|
||||
accumulator.cwd = cwd
|
||||
}
|
||||
accumulator.branch = extractGitBranch(payload.git) ?? accumulator.branch
|
||||
continue
|
||||
}
|
||||
|
||||
if (record.type === 'turn_context' && payload) {
|
||||
const cwd = extractString(payload.cwd)
|
||||
if (cwd) {
|
||||
accumulator.cwd = cwd
|
||||
}
|
||||
const model = extractModel(payload)
|
||||
if (model) {
|
||||
accumulator.model = model
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (record.type === 'response_item' && payload.type === 'message') {
|
||||
accumulator.messageCount++
|
||||
if (payload.role === 'user' && !accumulator.title) {
|
||||
accumulator.title = extractContentText(payload.content)
|
||||
}
|
||||
addPreviewContent(
|
||||
accumulator,
|
||||
payload.role === 'assistant' ? 'assistant' : payload.role === 'user' ? 'user' : 'unknown',
|
||||
payload.content,
|
||||
record.timestamp
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if (record.type !== 'event_msg') {
|
||||
continue
|
||||
}
|
||||
|
||||
if (payload.type === 'user_message') {
|
||||
accumulator.messageCount++
|
||||
if (!accumulator.title) {
|
||||
accumulator.title = extractContentText(payload.message)
|
||||
}
|
||||
addPreviewContent(accumulator, 'user', payload.message, record.timestamp)
|
||||
continue
|
||||
}
|
||||
|
||||
if (payload.type === 'agent_message') {
|
||||
accumulator.messageCount++
|
||||
addPreviewContent(accumulator, 'assistant', payload.message, record.timestamp)
|
||||
continue
|
||||
}
|
||||
|
||||
if (payload.type !== 'token_count') {
|
||||
continue
|
||||
}
|
||||
|
||||
const info = asRecord(payload.info)
|
||||
if (!info) {
|
||||
continue
|
||||
}
|
||||
const totalUsage = normalizeCodexUsage(info.total_token_usage)
|
||||
const lastUsage = normalizeCodexUsage(info.last_token_usage)
|
||||
const delta = totalUsage ? subtractCodexUsage(totalUsage, previousTotals) : lastUsage
|
||||
if (totalUsage) {
|
||||
previousTotals = totalUsage
|
||||
}
|
||||
if (delta) {
|
||||
accumulator.totalTokens += delta.totalTokens
|
||||
}
|
||||
const model = extractModel(payload)
|
||||
if (model) {
|
||||
accumulator.model = model
|
||||
}
|
||||
}
|
||||
|
||||
return finalizeSession(accumulator, platform, { codexHome })
|
||||
}
|
||||
|
||||
export async function parseGeminiSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
if (file.path.endsWith('.jsonl')) {
|
||||
return parseGeminiJsonlSessionFile(file, platform)
|
||||
}
|
||||
|
||||
const record = asRecord(JSON.parse(await readFile(file.path, 'utf-8')) as unknown)
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'gemini',
|
||||
file,
|
||||
sessionId: extractString(record.sessionId) ?? sessionIdFromFileName(file.path)
|
||||
})
|
||||
updateTimeline(accumulator, extractString(record.startTime))
|
||||
updateTimeline(accumulator, extractString(record.lastUpdated))
|
||||
for (const message of arrayValue(record.messages)) {
|
||||
consumeGeminiMessage(accumulator, asRecord(message))
|
||||
}
|
||||
return finalizeSession(accumulator, platform)
|
||||
}
|
||||
|
||||
export async function parseGeminiJsonlSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'gemini',
|
||||
file,
|
||||
sessionId: sessionIdFromFileName(file.path)
|
||||
})
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
|
||||
for await (const line of lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
const setRecord = asRecord(record.$set)
|
||||
if (setRecord) {
|
||||
updateTimeline(accumulator, extractString(setRecord.lastUpdated))
|
||||
continue
|
||||
}
|
||||
const sessionId = extractString(record.sessionId)
|
||||
if (sessionId) {
|
||||
accumulator.sessionId = sessionId
|
||||
}
|
||||
updateTimeline(accumulator, extractString(record.startTime))
|
||||
updateTimeline(accumulator, extractString(record.lastUpdated))
|
||||
consumeGeminiMessage(accumulator, record)
|
||||
}
|
||||
|
||||
return finalizeSession(accumulator, platform)
|
||||
}
|
||||
|
||||
export function consumeGeminiMessage(
|
||||
accumulator: SessionAccumulator,
|
||||
record: Record<string, unknown> | null
|
||||
): void {
|
||||
if (!record) {
|
||||
return
|
||||
}
|
||||
updateTimeline(accumulator, extractString(record.timestamp))
|
||||
if (record.type === 'user') {
|
||||
accumulator.messageCount++
|
||||
accumulator.title ??= extractContentText(record.content)
|
||||
addPreviewContent(accumulator, 'user', record.content, record.timestamp)
|
||||
return
|
||||
}
|
||||
if (record.type === 'gemini') {
|
||||
accumulator.messageCount++
|
||||
addPreviewContent(accumulator, 'assistant', record.content, record.timestamp)
|
||||
const model = extractString(record.model)
|
||||
if (model) {
|
||||
accumulator.model = model
|
||||
}
|
||||
accumulator.totalTokens += tokenTotal(record.tokens)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { createReadStream } from 'fs'
|
||||
import { readFile, readdir } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { createInterface } from 'readline'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { FileWithMtime, SessionAccumulator } from './session-scanner-types'
|
||||
import {
|
||||
addPreviewContent,
|
||||
addPreviewMessage,
|
||||
createAccumulator,
|
||||
finalizeSession,
|
||||
sessionIdFromFileName,
|
||||
updateTimeline
|
||||
} from './session-scanner-accumulator'
|
||||
import {
|
||||
arrayValue,
|
||||
asRecord,
|
||||
copilotModelMetricsTotal,
|
||||
extractContentText,
|
||||
extractMessageText,
|
||||
extractPreviewContentText,
|
||||
extractString,
|
||||
extractTrustedFolder,
|
||||
findOpenCodeStorageRoot,
|
||||
normalizeTitleText,
|
||||
numberValue,
|
||||
parseJsonObject,
|
||||
timeObjectValue,
|
||||
tokenTotal
|
||||
} from './session-scanner-values'
|
||||
|
||||
export async function parseCopilotSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'copilot',
|
||||
file,
|
||||
sessionId: sessionIdFromFileName(file.path)
|
||||
})
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
|
||||
for await (const line of lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
updateTimeline(accumulator, extractString(record.timestamp))
|
||||
const data = asRecord(record.data)
|
||||
if (record.type === 'session.start' && data) {
|
||||
const sessionId = extractString(data.sessionId)
|
||||
if (sessionId) {
|
||||
accumulator.sessionId = sessionId
|
||||
}
|
||||
updateTimeline(accumulator, extractString(data.startTime))
|
||||
continue
|
||||
}
|
||||
if (record.type === 'session.model_change' && data) {
|
||||
accumulator.model = extractString(data.newModel) ?? accumulator.model
|
||||
continue
|
||||
}
|
||||
if (record.type === 'session.info' && data) {
|
||||
accumulator.cwd = extractTrustedFolder(data.message) ?? accumulator.cwd
|
||||
continue
|
||||
}
|
||||
if (record.type === 'user.message' && data) {
|
||||
accumulator.messageCount++
|
||||
accumulator.title ??= normalizeTitleText(
|
||||
extractString(data.transformedContent) ?? extractString(data.content) ?? ''
|
||||
)
|
||||
addPreviewMessage(accumulator, {
|
||||
role: 'user',
|
||||
text: extractString(data.transformedContent) ?? extractString(data.content),
|
||||
timestamp: record.timestamp
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (record.type === 'assistant.message' && data) {
|
||||
accumulator.messageCount++
|
||||
addPreviewMessage(accumulator, {
|
||||
role: 'assistant',
|
||||
text: extractString(data.content),
|
||||
timestamp: record.timestamp
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (record.type === 'session.shutdown' && data) {
|
||||
accumulator.model = extractString(data.currentModel) ?? accumulator.model
|
||||
accumulator.totalTokens += numberValue(data.currentTokens)
|
||||
accumulator.totalTokens += copilotModelMetricsTotal(data.modelMetrics)
|
||||
}
|
||||
}
|
||||
|
||||
return finalizeSession(accumulator, platform)
|
||||
}
|
||||
|
||||
export async function parseCursorSessionFile(
|
||||
file: FileWithMtime,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
const accumulator = createAccumulator({
|
||||
agent: 'cursor',
|
||||
file,
|
||||
sessionId: sessionIdFromFileName(file.path)
|
||||
})
|
||||
const lines = createInterface({
|
||||
input: createReadStream(file.path, { encoding: 'utf-8' }),
|
||||
crlfDelay: Infinity
|
||||
})
|
||||
|
||||
for await (const line of lines) {
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
continue
|
||||
}
|
||||
updateTimeline(accumulator, extractString(record.timestamp))
|
||||
const role = extractString(record.role)
|
||||
if (role === 'user' || role === 'assistant') {
|
||||
accumulator.messageCount++
|
||||
if (role === 'user') {
|
||||
accumulator.title ??=
|
||||
extractMessageText(record.message) ?? extractContentText(record.content)
|
||||
}
|
||||
addPreviewContent(
|
||||
accumulator,
|
||||
role,
|
||||
asRecord(record.message)?.content ?? record.content,
|
||||
record.timestamp
|
||||
)
|
||||
}
|
||||
}
|
||||
return finalizeSession(accumulator, platform)
|
||||
}
|
||||
|
||||
export async function parseOpenCodeSessionFile(
|
||||
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 sessionId = extractString(record.id) ?? sessionIdFromFileName(file.path)
|
||||
const accumulator = createAccumulator({ agent: 'opencode', file, sessionId })
|
||||
accumulator.title = normalizeTitleText(extractString(record.title) ?? '')
|
||||
accumulator.cwd = extractString(record.directory)
|
||||
updateTimeline(accumulator, timeObjectValue(record.time, 'created'))
|
||||
updateTimeline(accumulator, timeObjectValue(record.time, 'updated'))
|
||||
await consumeOpenCodeMessages(accumulator, findOpenCodeStorageRoot(file.path), sessionId)
|
||||
return finalizeSession(accumulator, platform)
|
||||
}
|
||||
|
||||
export async function consumeOpenCodeMessages(
|
||||
accumulator: SessionAccumulator,
|
||||
storageRoot: string | null,
|
||||
sessionId: string
|
||||
): Promise<void> {
|
||||
if (!storageRoot) {
|
||||
return
|
||||
}
|
||||
const messageDir = join(storageRoot, 'message', sessionId)
|
||||
let entries
|
||||
try {
|
||||
entries = await readdir(messageDir, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.json')) {
|
||||
continue
|
||||
}
|
||||
const message = asRecord(
|
||||
JSON.parse(await readFile(join(messageDir, entry.name), 'utf-8')) as unknown
|
||||
)
|
||||
if (!message) {
|
||||
continue
|
||||
}
|
||||
const role = extractString(message.role)
|
||||
if (role === 'user' || role === 'assistant') {
|
||||
accumulator.messageCount++
|
||||
updateTimeline(accumulator, timeObjectValue(message.time, 'created'))
|
||||
if (role === 'user') {
|
||||
accumulator.title ??= extractString(asRecord(message.summary)?.title)
|
||||
accumulator.title ??= extractString(asRecord(message.summary)?.body)
|
||||
}
|
||||
addPreviewMessage(accumulator, {
|
||||
role,
|
||||
text:
|
||||
extractPreviewContentText(message.content) ??
|
||||
extractString(asRecord(message.summary)?.body) ??
|
||||
extractString(asRecord(message.summary)?.title),
|
||||
timestamp: timeObjectValue(message.time, 'created')
|
||||
})
|
||||
accumulator.model =
|
||||
extractString(asRecord(message.model)?.modelID) ||
|
||||
extractString(message.modelID) ||
|
||||
accumulator.model
|
||||
accumulator.totalTokens += tokenTotal(message.tokens)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function parseHermesSessionFile(
|
||||
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 accumulator = createAccumulator({
|
||||
agent: 'hermes',
|
||||
file,
|
||||
sessionId: extractString(record.session_id) ?? sessionIdFromFileName(file.path)
|
||||
})
|
||||
accumulator.model = extractString(record.model)
|
||||
accumulator.cwd = extractString(record.cwd)
|
||||
updateTimeline(accumulator, extractString(record.session_start))
|
||||
updateTimeline(accumulator, extractString(record.last_updated))
|
||||
for (const message of arrayValue(record.messages)) {
|
||||
const messageRecord = asRecord(message)
|
||||
const role = extractString(messageRecord?.role)
|
||||
if (role === 'user' || role === 'assistant') {
|
||||
accumulator.messageCount++
|
||||
if (role === 'user') {
|
||||
accumulator.title ??= extractContentText(messageRecord?.content)
|
||||
}
|
||||
addPreviewContent(accumulator, role, messageRecord?.content)
|
||||
}
|
||||
}
|
||||
if (accumulator.messageCount === 0) {
|
||||
accumulator.messageCount = numberValue(record.message_count)
|
||||
}
|
||||
return finalizeSession(accumulator, platform)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { CodexUsageSnapshot } from './session-scanner-types'
|
||||
import { asRecord } from './session-scanner-values'
|
||||
|
||||
export function tokenTotal(value: unknown): number {
|
||||
const usage = asRecord(value)
|
||||
if (!usage) {
|
||||
return 0
|
||||
}
|
||||
const explicitTotal =
|
||||
numberValue(usage.total) || numberValue(usage.totalTokens) || numberValue(usage.total_tokens)
|
||||
if (explicitTotal > 0) {
|
||||
return explicitTotal
|
||||
}
|
||||
|
||||
const fields: unknown[] = [
|
||||
usage.input,
|
||||
usage.inputTokens,
|
||||
usage.input_tokens,
|
||||
usage.output,
|
||||
usage.outputTokens,
|
||||
usage.output_tokens,
|
||||
usage.cacheRead,
|
||||
usage.cacheReadTokens,
|
||||
usage.cache_read_input_tokens,
|
||||
usage.cacheWrite,
|
||||
usage.cacheWriteTokens,
|
||||
usage.cache_creation_input_tokens,
|
||||
usage.cached,
|
||||
usage.cachedInputTokens,
|
||||
usage.cached_input_tokens,
|
||||
usage.reasoning,
|
||||
usage.reasoningOutputTokens,
|
||||
usage.reasoning_output_tokens
|
||||
]
|
||||
return fields.reduce<number>((total, current) => total + numberValue(current), 0)
|
||||
}
|
||||
|
||||
export function copilotModelMetricsTotal(value: unknown): number {
|
||||
const metrics = asRecord(value)
|
||||
if (!metrics) {
|
||||
return 0
|
||||
}
|
||||
let total = 0
|
||||
for (const metric of Object.values(metrics)) {
|
||||
const record = asRecord(metric)
|
||||
const usage = asRecord(record?.usage)
|
||||
if (!usage) {
|
||||
continue
|
||||
}
|
||||
total += tokenTotal(usage)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
export function claudeUsageTotal(value: unknown): number {
|
||||
const usage = asRecord(value)
|
||||
if (!usage) {
|
||||
return 0
|
||||
}
|
||||
return (
|
||||
numberValue(usage.input_tokens) +
|
||||
numberValue(usage.output_tokens) +
|
||||
numberValue(usage.cache_read_input_tokens) +
|
||||
numberValue(usage.cache_creation_input_tokens)
|
||||
)
|
||||
}
|
||||
|
||||
export function normalizeCodexUsage(value: unknown): CodexUsageSnapshot | null {
|
||||
const usage = asRecord(value)
|
||||
if (!usage) {
|
||||
return null
|
||||
}
|
||||
const inputTokens = numberValue(usage.input_tokens)
|
||||
const cachedInputTokens = numberValue(usage.cached_input_tokens ?? usage.cache_read_input_tokens)
|
||||
const outputTokens = numberValue(usage.output_tokens)
|
||||
const reasoningOutputTokens = numberValue(usage.reasoning_output_tokens)
|
||||
const totalTokens = numberValue(usage.total_tokens)
|
||||
|
||||
return {
|
||||
inputTokens,
|
||||
cachedInputTokens,
|
||||
outputTokens,
|
||||
reasoningOutputTokens,
|
||||
totalTokens: totalTokens > 0 ? totalTokens : inputTokens + outputTokens
|
||||
}
|
||||
}
|
||||
|
||||
export function subtractCodexUsage(
|
||||
current: CodexUsageSnapshot,
|
||||
previous: CodexUsageSnapshot | null
|
||||
): CodexUsageSnapshot {
|
||||
return {
|
||||
inputTokens: Math.max(current.inputTokens - (previous?.inputTokens ?? 0), 0),
|
||||
cachedInputTokens: Math.max(current.cachedInputTokens - (previous?.cachedInputTokens ?? 0), 0),
|
||||
outputTokens: Math.max(current.outputTokens - (previous?.outputTokens ?? 0), 0),
|
||||
reasoningOutputTokens: Math.max(
|
||||
current.reasoningOutputTokens - (previous?.reasoningOutputTokens ?? 0),
|
||||
0
|
||||
),
|
||||
totalTokens: Math.max(current.totalTokens - (previous?.totalTokens ?? 0), 0)
|
||||
}
|
||||
}
|
||||
|
||||
export function numberValue(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { AiVaultAgent } from '../../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultScanIssue,
|
||||
AiVaultSession,
|
||||
AiVaultSessionPreviewMessage
|
||||
} from '../../shared/ai-vault-types'
|
||||
|
||||
export type AiVaultScanOptions = {
|
||||
claudeProjectsDir?: string
|
||||
codexSessionsDir?: string
|
||||
additionalCodexSessionsDirs?: readonly string[]
|
||||
geminiSessionsDir?: string
|
||||
copilotSessionsDir?: string
|
||||
cursorProjectsDir?: string
|
||||
opencodeStorageDir?: string
|
||||
grokSessionsDir?: string
|
||||
hermesSessionsDir?: string
|
||||
rovoSessionsDir?: string
|
||||
openclawStateDir?: string
|
||||
openclawLegacyStateDir?: string
|
||||
piSessionsDir?: string
|
||||
droidSessionsDir?: string
|
||||
droidProjectsDir?: string
|
||||
limit?: number
|
||||
limitPerAgent?: number
|
||||
platform?: NodeJS.Platform
|
||||
}
|
||||
|
||||
export type FileWithMtime = {
|
||||
path: string
|
||||
mtimeMs: number
|
||||
modifiedAt: string
|
||||
}
|
||||
|
||||
export type SessionFileCandidate = {
|
||||
agent: AiVaultAgent
|
||||
file: FileWithMtime
|
||||
codexHome: string | null
|
||||
}
|
||||
|
||||
export type SessionFileDiscovery = {
|
||||
agent: AiVaultAgent
|
||||
rootDir: string
|
||||
files: FileWithMtime[]
|
||||
}
|
||||
|
||||
export type SessionParseResult = {
|
||||
session: AiVaultSession | null
|
||||
issue: AiVaultScanIssue | null
|
||||
}
|
||||
|
||||
export type SessionAccumulator = {
|
||||
agent: AiVaultAgent
|
||||
sessionId: string
|
||||
title: string | null
|
||||
fallbackTitle: string | null
|
||||
cwd: string | null
|
||||
branch: string | null
|
||||
model: string | null
|
||||
filePath: string
|
||||
createdAt: string | null
|
||||
updatedAt: string | null
|
||||
modifiedAt: string
|
||||
messageCount: number
|
||||
totalTokens: number
|
||||
previewMessages: AiVaultSessionPreviewMessage[]
|
||||
latestTimestampMs: number
|
||||
}
|
||||
|
||||
export type CodexUsageSnapshot = {
|
||||
inputTokens: number
|
||||
cachedInputTokens: number
|
||||
outputTokens: number
|
||||
reasoningOutputTokens: number
|
||||
totalTokens: number
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { homedir } from 'os'
|
||||
import { basename, dirname, join } from 'path'
|
||||
import { readFile } from 'fs/promises'
|
||||
|
||||
const SESSION_PREVIEW_TEXT_LIMIT = 220
|
||||
const HIDDEN_USER_CONTEXT_BLOCK_PATTERN =
|
||||
/<(?:codex_internal_context\b[^>]*|goal_context)>[\s\S]*?<\/(?:codex_internal_context|goal_context)>/gi
|
||||
|
||||
export function timestampMs(value: unknown): number {
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Date.parse(value)
|
||||
return Number.isFinite(parsed) ? parsed : Number.NaN
|
||||
}
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||
return Number.NaN
|
||||
}
|
||||
return value > 1_000_000_000_000 ? value : value * 1000
|
||||
}
|
||||
|
||||
export function parseJsonObject(line: string): Record<string, unknown> | null {
|
||||
if (!line.trim()) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(line) as unknown
|
||||
return asRecord(parsed)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null
|
||||
}
|
||||
|
||||
export function extractString(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}
|
||||
|
||||
export function extractModel(value: unknown): string | null {
|
||||
const record = asRecord(value)
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
extractString(record.model) ||
|
||||
extractString(record.model_name) ||
|
||||
extractString(asRecord(record.metadata)?.model) ||
|
||||
extractString(asRecord(record.info)?.model) ||
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
export function extractGitBranch(value: unknown): string | null {
|
||||
const git = asRecord(value)
|
||||
if (!git) {
|
||||
return null
|
||||
}
|
||||
return extractString(git.branch) || extractString(git.current_branch)
|
||||
}
|
||||
|
||||
export function extractMessageText(value: unknown): string | null {
|
||||
const message = asRecord(value)
|
||||
if (!message) {
|
||||
return null
|
||||
}
|
||||
return extractContentText(message.content)
|
||||
}
|
||||
|
||||
export function extractContentText(value: unknown): string | null {
|
||||
if (typeof value === 'string') {
|
||||
return normalizeTitleText(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 normalizeTitleText(parts.join(' '))
|
||||
}
|
||||
|
||||
export function normalizeTitleText(value: string): string | null {
|
||||
const withoutReminders = value
|
||||
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, ' ')
|
||||
.replace(HIDDEN_USER_CONTEXT_BLOCK_PATTERN, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
if (!withoutReminders) {
|
||||
return null
|
||||
}
|
||||
if (/^# AGENTS\.md instructions for\b/i.test(withoutReminders)) {
|
||||
return null
|
||||
}
|
||||
if (/^<INSTRUCTIONS>/i.test(withoutReminders)) {
|
||||
return null
|
||||
}
|
||||
return withoutReminders.length > 96 ? `${withoutReminders.slice(0, 93)}...` : withoutReminders
|
||||
}
|
||||
|
||||
export function extractPreviewContentText(value: unknown): string | null {
|
||||
if (typeof value === 'string') {
|
||||
return normalizePreviewText(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 normalizePreviewText(parts.join(' '))
|
||||
}
|
||||
|
||||
export function normalizePreviewText(value: string): string | null {
|
||||
const normalized = value
|
||||
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, ' ')
|
||||
.replace(HIDDEN_USER_CONTEXT_BLOCK_PATTERN, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
if (!normalized) {
|
||||
return null
|
||||
}
|
||||
if (/^# AGENTS\.md instructions for\b/i.test(normalized) || /^<INSTRUCTIONS>/i.test(normalized)) {
|
||||
return null
|
||||
}
|
||||
return normalized.length > SESSION_PREVIEW_TEXT_LIMIT
|
||||
? `${normalized.slice(0, SESSION_PREVIEW_TEXT_LIMIT - 3)}...`
|
||||
: normalized
|
||||
}
|
||||
|
||||
export async function readJsonObjectIfExists(
|
||||
filePath: string
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
return asRecord(JSON.parse(await readFile(filePath, 'utf-8')) as unknown)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function arrayValue(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : []
|
||||
}
|
||||
|
||||
export function firstString(record: Record<string, unknown>, keys: string[]): string | null {
|
||||
for (const key of keys) {
|
||||
const value = extractString(record[key])
|
||||
if (value) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function extractTrustedFolder(value: unknown): string | null {
|
||||
const message = extractString(value)
|
||||
if (!message) {
|
||||
return null
|
||||
}
|
||||
return message.match(/^Folder (.+) has been added to trusted folders\.$/)?.[1] ?? null
|
||||
}
|
||||
|
||||
export function timeObjectValue(value: unknown, key: string): string | null {
|
||||
const record = asRecord(value)
|
||||
if (!record) {
|
||||
return null
|
||||
}
|
||||
const rawValue = record[key]
|
||||
if (typeof rawValue === 'string') {
|
||||
return rawValue
|
||||
}
|
||||
const parsed = timestampMs(rawValue)
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return null
|
||||
}
|
||||
return new Date(parsed).toISOString()
|
||||
}
|
||||
|
||||
export function findOpenCodeStorageRoot(filePath: string): string | null {
|
||||
const sessionDir = dirname(filePath)
|
||||
const sessionRoot = dirname(sessionDir)
|
||||
if (basename(sessionRoot) !== 'session') {
|
||||
return null
|
||||
}
|
||||
return dirname(sessionRoot)
|
||||
}
|
||||
|
||||
export function normalizePiSessionsDir(rawValue: string): string {
|
||||
const trimmed = rawValue.trim()
|
||||
if (!trimmed) {
|
||||
return join(homedir(), '.pi', 'agent', 'sessions')
|
||||
}
|
||||
const normalized = trimmed.replace(/[\\/]+$/, '')
|
||||
const leaf = basename(normalized)
|
||||
if (leaf === 'sessions') {
|
||||
return normalized
|
||||
}
|
||||
if (leaf === 'agent') {
|
||||
return join(normalized, 'sessions')
|
||||
}
|
||||
if (leaf === '.pi') {
|
||||
return join(normalized, 'agent', 'sessions')
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function clampPositiveInteger(value: number | undefined, fallback: number): number {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : fallback
|
||||
}
|
||||
|
||||
export function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
|
||||
export {
|
||||
claudeUsageTotal,
|
||||
copilotModelMetricsTotal,
|
||||
normalizeCodexUsage,
|
||||
numberValue,
|
||||
subtractCodexUsage,
|
||||
tokenTotal
|
||||
} from './session-scanner-token-values'
|
||||
@@ -0,0 +1,636 @@
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { AI_VAULT_AGENTS, buildAiVaultResumeCommand } from '../../shared/ai-vault-types'
|
||||
import { scanAiVaultSessions } from './session-scanner'
|
||||
|
||||
let tempRoots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
|
||||
tempRoots = []
|
||||
})
|
||||
|
||||
function isolatedScanRoots(root: string) {
|
||||
return {
|
||||
claudeProjectsDir: join(root, 'claude-projects'),
|
||||
codexSessionsDir: join(root, 'codex-sessions'),
|
||||
geminiSessionsDir: join(root, 'gemini-sessions'),
|
||||
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'),
|
||||
openclawLegacyStateDir: join(root, 'openclaw-legacy-state'),
|
||||
piSessionsDir: join(root, 'pi-sessions'),
|
||||
droidSessionsDir: join(root, 'droid-sessions'),
|
||||
droidProjectsDir: join(root, 'droid-projects')
|
||||
}
|
||||
}
|
||||
|
||||
function jsonLines(records: unknown[]): string {
|
||||
return records.map((record) => JSON.stringify(record)).join('\n')
|
||||
}
|
||||
|
||||
describe('scanAiVaultSessions', () => {
|
||||
it('indexes Claude and Codex transcripts with resume commands', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-'))
|
||||
tempRoots.push(root)
|
||||
const roots = isolatedScanRoots(root)
|
||||
const claudeRoot = roots.claudeProjectsDir
|
||||
const codexRoot = roots.codexSessionsDir
|
||||
await mkdir(join(claudeRoot, 'project'), { recursive: true })
|
||||
await mkdir(join(codexRoot, '2026', '05', '01'), { recursive: true })
|
||||
|
||||
await writeFile(
|
||||
join(claudeRoot, 'project', 'claude-session.jsonl'),
|
||||
[
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
sessionId: 'claude-session',
|
||||
timestamp: '2026-05-01T10:00:00.000Z',
|
||||
cwd: '/repo/app',
|
||||
gitBranch: 'feature/vault',
|
||||
isMeta: false,
|
||||
message: { role: 'user', content: 'Implement the vault panel' }
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
sessionId: 'claude-session',
|
||||
timestamp: '2026-05-01T10:02:00.000Z',
|
||||
cwd: '/repo/app',
|
||||
gitBranch: 'feature/vault',
|
||||
message: {
|
||||
model: 'claude-sonnet-4-5',
|
||||
usage: {
|
||||
input_tokens: 100,
|
||||
output_tokens: 40,
|
||||
cache_read_input_tokens: 10,
|
||||
cache_creation_input_tokens: 5
|
||||
}
|
||||
}
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'custom-title',
|
||||
sessionId: 'claude-session',
|
||||
timestamp: '2026-05-01T10:03:00.000Z',
|
||||
customTitle: 'Vault polish pass'
|
||||
})
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
await writeFile(
|
||||
join(
|
||||
codexRoot,
|
||||
'2026',
|
||||
'05',
|
||||
'01',
|
||||
'rollout-2026-05-01T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl'
|
||||
),
|
||||
[
|
||||
JSON.stringify({
|
||||
timestamp: '2026-05-01T11:00:00.000Z',
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: '019f0000-1111-7222-8333-444444444444',
|
||||
cwd: '/repo/app/packages/web',
|
||||
git: { branch: 'feature/codex-vault' }
|
||||
}
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-05-01T11:00:01.000Z',
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: '# AGENTS.md instructions for /repo/app <INSTRUCTIONS>' }
|
||||
]
|
||||
}
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-05-01T11:00:02.000Z',
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Fix the resume picker filters' }]
|
||||
}
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-05-01T11:00:03.000Z',
|
||||
type: 'turn_context',
|
||||
payload: { cwd: '/repo/app/packages/web', model: 'gpt-5.3-codex' }
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-05-01T11:00:04.000Z',
|
||||
type: 'event_msg',
|
||||
payload: {
|
||||
type: 'token_count',
|
||||
info: {
|
||||
total_token_usage: {
|
||||
input_tokens: 500,
|
||||
cached_input_tokens: 100,
|
||||
output_tokens: 125,
|
||||
reasoning_output_tokens: 25,
|
||||
total_tokens: 625
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-05-01T11:00:05.000Z',
|
||||
type: 'event_msg',
|
||||
payload: {
|
||||
type: 'token_count',
|
||||
info: {
|
||||
total_token_usage: {
|
||||
input_tokens: 500,
|
||||
cached_input_tokens: 100,
|
||||
output_tokens: 125,
|
||||
reasoning_output_tokens: 25,
|
||||
total_tokens: 625
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
const result = await scanAiVaultSessions({
|
||||
...roots,
|
||||
platform: 'darwin'
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions).toHaveLength(2)
|
||||
expect(result.sessions.map((session) => session.title).sort()).toEqual([
|
||||
'Fix the resume picker filters',
|
||||
'Vault polish pass'
|
||||
])
|
||||
|
||||
const claude = result.sessions.find((session) => session.agent === 'claude')
|
||||
expect(claude).toMatchObject({
|
||||
sessionId: 'claude-session',
|
||||
cwd: '/repo/app',
|
||||
branch: 'feature/vault',
|
||||
model: 'claude-sonnet-4-5',
|
||||
messageCount: 2,
|
||||
totalTokens: 155,
|
||||
resumeCommand: "cd '/repo/app' && claude --resume 'claude-session'"
|
||||
})
|
||||
|
||||
const codex = result.sessions.find((session) => session.agent === 'codex')
|
||||
expect(codex).toMatchObject({
|
||||
sessionId: '019f0000-1111-7222-8333-444444444444',
|
||||
cwd: '/repo/app/packages/web',
|
||||
branch: 'feature/codex-vault',
|
||||
model: 'gpt-5.3-codex',
|
||||
messageCount: 2,
|
||||
totalTokens: 625,
|
||||
resumeCommand: `cd '/repo/app/packages/web' && CODEX_HOME='${root}' codex resume '019f0000-1111-7222-8333-444444444444'`
|
||||
})
|
||||
})
|
||||
|
||||
it('indexes Codex sessions from Orca runtime homes with resumable commands', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-runtime-'))
|
||||
tempRoots.push(root)
|
||||
const roots = isolatedScanRoots(root)
|
||||
const runtimeHome = join(root, 'codex-runtime-home', 'home')
|
||||
const runtimeSessionsDir = join(runtimeHome, 'sessions')
|
||||
await mkdir(join(runtimeSessionsDir, '2026', '06', '04'), { recursive: true })
|
||||
|
||||
await writeFile(
|
||||
join(
|
||||
runtimeSessionsDir,
|
||||
'2026',
|
||||
'06',
|
||||
'04',
|
||||
'rollout-2026-06-04T23-58-22-019e9693-64fc-7370-9c18-7e625c595d0f.jsonl'
|
||||
),
|
||||
jsonLines([
|
||||
{
|
||||
timestamp: '2026-06-04T23:58:22.000Z',
|
||||
type: 'session_meta',
|
||||
payload: {
|
||||
id: '019e9693-64fc-7370-9c18-7e625c595d0f',
|
||||
cwd: '/Users/nwparker/orca/workspaces/orca/mem4'
|
||||
}
|
||||
},
|
||||
{
|
||||
timestamp: '2026-06-04T23:58:23.000Z',
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Resume this managed Codex session' }]
|
||||
}
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const result = await scanAiVaultSessions({
|
||||
...roots,
|
||||
additionalCodexSessionsDirs: [runtimeSessionsDir],
|
||||
platform: 'darwin'
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
agent: 'codex',
|
||||
sessionId: '019e9693-64fc-7370-9c18-7e625c595d0f',
|
||||
cwd: '/Users/nwparker/orca/workspaces/orca/mem4',
|
||||
codexHome: runtimeHome,
|
||||
resumeCommand: `cd '/Users/nwparker/orca/workspaces/orca/mem4' && CODEX_HOME='${runtimeHome}' codex resume '019e9693-64fc-7370-9c18-7e625c595d0f'`
|
||||
})
|
||||
})
|
||||
|
||||
it('skips hidden Codex context blocks when choosing session titles', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-codex-hidden-context-'))
|
||||
tempRoots.push(root)
|
||||
const roots = isolatedScanRoots(root)
|
||||
await mkdir(join(roots.codexSessionsDir, '2026', '06', '11'), { recursive: true })
|
||||
|
||||
await writeFile(
|
||||
join(roots.codexSessionsDir, '2026', '06', '11', 'rollout-hidden-context.jsonl'),
|
||||
jsonLines([
|
||||
{
|
||||
timestamp: '2026-06-11T10:00:00.000Z',
|
||||
type: 'session_meta',
|
||||
payload: { id: 'hidden-context-session', cwd: '/repo/app' }
|
||||
},
|
||||
{
|
||||
timestamp: '2026-06-11T10:00:01.000Z',
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '<codex_internal_context source="goal">\\nKeep going\\n</codex_internal_context>'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
timestamp: '2026-06-11T10:00:02.000Z',
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Fix the title shown in the session list' }]
|
||||
}
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const result = await scanAiVaultSessions({
|
||||
...roots,
|
||||
platform: 'darwin'
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
expect(result.sessions[0]?.title).toBe('Fix the title shown in the session list')
|
||||
expect(result.sessions[0]?.previewMessages.map((message) => message.text)).toEqual([
|
||||
'Fix the title shown in the session list'
|
||||
])
|
||||
})
|
||||
|
||||
it('indexes every supported agent transcript format with native resume commands', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-all-agents-'))
|
||||
tempRoots.push(root)
|
||||
const roots = isolatedScanRoots(root)
|
||||
|
||||
await mkdir(join(roots.claudeProjectsDir, 'project'), { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.claudeProjectsDir, 'project', 'claude-session.jsonl'),
|
||||
jsonLines([
|
||||
{
|
||||
type: 'user',
|
||||
sessionId: 'claude-session',
|
||||
timestamp: '2026-05-01T10:00:00.000Z',
|
||||
cwd: '/tmp/claude',
|
||||
message: { role: 'user', content: 'Claude title' }
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
await mkdir(join(roots.codexSessionsDir, '2026', '05', '01'), { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.codexSessionsDir, '2026', '05', '01', 'rollout-2026-codex-session.jsonl'),
|
||||
jsonLines([
|
||||
{
|
||||
timestamp: '2026-05-01T10:01:00.000Z',
|
||||
type: 'session_meta',
|
||||
payload: { id: 'codex-session', cwd: '/tmp/codex' }
|
||||
},
|
||||
{
|
||||
timestamp: '2026-05-01T10:01:01.000Z',
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'Codex title' }]
|
||||
}
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
await mkdir(roots.geminiSessionsDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.geminiSessionsDir, 'gemini-session.json'),
|
||||
JSON.stringify({
|
||||
sessionId: 'gemini-session',
|
||||
startTime: '2026-05-01T10:02:00.000Z',
|
||||
lastUpdated: '2026-05-01T10:02:01.000Z',
|
||||
messages: [
|
||||
{
|
||||
type: 'user',
|
||||
timestamp: '2026-05-01T10:02:00.000Z',
|
||||
content: [{ text: 'Gemini title' }]
|
||||
},
|
||||
{
|
||||
type: 'gemini',
|
||||
timestamp: '2026-05-01T10:02:01.000Z',
|
||||
model: 'gemini-2.5-pro',
|
||||
tokens: { input: 10, output: 5 }
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
await mkdir(roots.copilotSessionsDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.copilotSessionsDir, 'copilot-session.jsonl'),
|
||||
jsonLines([
|
||||
{
|
||||
type: 'session.start',
|
||||
data: { sessionId: 'copilot-session', startTime: '2026-05-01T10:03:00.000Z' },
|
||||
timestamp: '2026-05-01T10:03:00.000Z'
|
||||
},
|
||||
{
|
||||
type: 'session.info',
|
||||
data: {
|
||||
infoType: 'folder_trust',
|
||||
message: 'Folder /tmp/copilot has been added to trusted folders.'
|
||||
},
|
||||
timestamp: '2026-05-01T10:03:01.000Z'
|
||||
},
|
||||
{
|
||||
type: 'user.message',
|
||||
data: { transformedContent: 'Copilot title' },
|
||||
timestamp: '2026-05-01T10:03:02.000Z'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
await mkdir(join(roots.cursorProjectsDir, 'project', 'agent-transcripts'), { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.cursorProjectsDir, 'project', 'agent-transcripts', 'cursor-session.jsonl'),
|
||||
jsonLines([
|
||||
{
|
||||
role: 'user',
|
||||
message: { content: [{ type: 'text', text: 'Cursor title' }] }
|
||||
},
|
||||
{ role: 'assistant', message: { content: [{ type: 'text', text: 'Done' }] } }
|
||||
])
|
||||
)
|
||||
|
||||
await mkdir(join(roots.opencodeStorageDir, 'session', 'project'), { recursive: true })
|
||||
await mkdir(join(roots.opencodeStorageDir, 'message', 'opencode-session'), { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.opencodeStorageDir, 'session', 'project', 'ses_opencode.json'),
|
||||
JSON.stringify({
|
||||
id: 'opencode-session',
|
||||
directory: '/tmp/opencode',
|
||||
title: 'OpenCode title',
|
||||
time: { created: 1_777_634_000_000, updated: 1_777_634_001_000 }
|
||||
})
|
||||
)
|
||||
await writeFile(
|
||||
join(roots.opencodeStorageDir, 'message', 'opencode-session', 'msg_1.json'),
|
||||
JSON.stringify({
|
||||
role: 'user',
|
||||
summary: { title: 'OpenCode title' },
|
||||
time: { created: 1_777_634_000_000 },
|
||||
tokens: { input: 7, output: 3 }
|
||||
})
|
||||
)
|
||||
|
||||
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'),
|
||||
JSON.stringify({
|
||||
session_id: 'hermes-session',
|
||||
model: 'hermes-1',
|
||||
cwd: '/tmp/hermes',
|
||||
session_start: '2026-05-01T10:05:00.000Z',
|
||||
last_updated: '2026-05-01T10:05:01.000Z',
|
||||
messages: [{ role: 'user', content: 'Hermes title' }]
|
||||
})
|
||||
)
|
||||
|
||||
await mkdir(join(roots.rovoSessionsDir, 'rovo-session'), { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.rovoSessionsDir, 'rovo-session', 'metadata.json'),
|
||||
JSON.stringify({ title: 'Rovo title', workspace_path: '/tmp/rovo' })
|
||||
)
|
||||
await writeFile(
|
||||
join(roots.rovoSessionsDir, 'rovo-session', 'session_context.json'),
|
||||
JSON.stringify({
|
||||
message_history: [
|
||||
{
|
||||
kind: 'request',
|
||||
timestamp: '2026-05-01T10:06:00.000Z',
|
||||
parts: [{ part_kind: 'user-prompt', content: 'Rovo title' }]
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
await mkdir(join(roots.openclawStateDir, 'agents', 'default', 'sessions'), { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.openclawStateDir, 'agents', 'default', 'sessions', 'openclaw-session.jsonl'),
|
||||
jsonLines([
|
||||
{
|
||||
type: 'session',
|
||||
id: 'openclaw-session',
|
||||
timestamp: '2026-05-01T10:07:00.000Z',
|
||||
cwd: '/tmp/openclaw'
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
timestamp: '2026-05-01T10:07:01.000Z',
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'OpenClaw title' }] }
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
await mkdir(roots.piSessionsDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.piSessionsDir, 'pi-session.jsonl'),
|
||||
jsonLines([
|
||||
{
|
||||
type: 'session',
|
||||
id: 'pi-session',
|
||||
timestamp: '2026-05-01T10:08:00.000Z',
|
||||
cwd: '/tmp/pi'
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
timestamp: '2026-05-01T10:08:01.000Z',
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'Pi title' }] }
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
await mkdir(roots.droidSessionsDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.droidSessionsDir, 'droid-session.jsonl'),
|
||||
jsonLines([
|
||||
{
|
||||
type: 'system',
|
||||
session_id: 'droid-session',
|
||||
timestamp: '2026-05-01T10:09:00.000Z',
|
||||
model: 'droid-model',
|
||||
cwd: '/tmp/droid'
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
session_id: 'droid-session',
|
||||
timestamp: '2026-05-01T10:09:01.000Z',
|
||||
role: 'user',
|
||||
text: 'Droid title'
|
||||
},
|
||||
{
|
||||
type: 'completion',
|
||||
session_id: 'droid-session',
|
||||
timestamp: '2026-05-01T10:09:02.000Z',
|
||||
usage: { input_tokens: 2, output_tokens: 3 }
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const result = await scanAiVaultSessions({
|
||||
...roots,
|
||||
platform: 'darwin',
|
||||
limit: 20
|
||||
})
|
||||
|
||||
expect(result.issues).toEqual([])
|
||||
expect(new Set(result.sessions.map((session) => session.agent))).toEqual(
|
||||
new Set(AI_VAULT_AGENTS)
|
||||
)
|
||||
|
||||
const commandByAgent = new Map(
|
||||
result.sessions.map((session) => [session.agent, session.resumeCommand])
|
||||
)
|
||||
expect(commandByAgent.get('claude')).toBe(
|
||||
"cd '/tmp/claude' && claude --resume 'claude-session'"
|
||||
)
|
||||
expect(commandByAgent.get('codex')).toBe(
|
||||
`cd '/tmp/codex' && CODEX_HOME='${root}' codex resume 'codex-session'`
|
||||
)
|
||||
expect(commandByAgent.get('gemini')).toBe("gemini --resume 'gemini-session'")
|
||||
expect(commandByAgent.get('copilot')).toBe(
|
||||
"cd '/tmp/copilot' && copilot --resume='copilot-session'"
|
||||
)
|
||||
expect(commandByAgent.get('cursor')).toBe("cursor-agent --resume 'cursor-session'")
|
||||
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'"
|
||||
)
|
||||
expect(commandByAgent.get('rovo')).toBe(
|
||||
"cd '/tmp/rovo' && acli rovodev run --restore 'rovo-session'"
|
||||
)
|
||||
expect(commandByAgent.get('openclaw')).toBe(
|
||||
"cd '/tmp/openclaw' && openclaw --resume 'openclaw-session'"
|
||||
)
|
||||
expect(commandByAgent.get('pi')).toBe("cd '/tmp/pi' && pi --session 'pi-session'")
|
||||
expect(commandByAgent.get('droid')).toBe("cd '/tmp/droid' && droid --resume 'droid-session'")
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildAiVaultResumeCommand', () => {
|
||||
it('wraps Windows cwd changes in cmd so PowerShell and cmd launch the same resume command', () => {
|
||||
expect(
|
||||
buildAiVaultResumeCommand({
|
||||
agent: 'codex',
|
||||
sessionId: 'session-1',
|
||||
cwd: 'C:\\Users\\Ada Lovelace\\repo',
|
||||
platform: 'win32'
|
||||
})
|
||||
).toBe('cmd /d /s /c "cd /d ""C:\\Users\\Ada Lovelace\\repo"" && codex resume ""session-1"""')
|
||||
})
|
||||
|
||||
it('carries non-default Codex homes in copied resume commands', () => {
|
||||
expect(
|
||||
buildAiVaultResumeCommand({
|
||||
agent: 'codex',
|
||||
sessionId: 'session-1',
|
||||
cwd: '/repo/app',
|
||||
platform: 'darwin',
|
||||
codexHome: '/Users/ada/Library/Application Support/Orca/codex-runtime-home/home'
|
||||
})
|
||||
).toBe(
|
||||
"cd '/repo/app' && CODEX_HOME='/Users/ada/Library/Application Support/Orca/codex-runtime-home/home' codex resume 'session-1'"
|
||||
)
|
||||
|
||||
expect(
|
||||
buildAiVaultResumeCommand({
|
||||
agent: 'codex',
|
||||
sessionId: 'session-1',
|
||||
cwd: 'C:\\Users\\Ada Lovelace\\repo',
|
||||
platform: 'win32',
|
||||
codexHome: 'C:\\Users\\Ada\\AppData\\Roaming\\Orca\\codex-runtime-home\\home'
|
||||
})
|
||||
).toBe(
|
||||
'cmd /d /s /c "cd /d ""C:\\Users\\Ada Lovelace\\repo"" && set ""CODEX_HOME=C:\\Users\\Ada\\AppData\\Roaming\\Orca\\codex-runtime-home\\home"" && codex resume ""session-1"""'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,319 @@
|
||||
import { homedir } from 'os'
|
||||
import { basename, join } from 'path'
|
||||
import type {
|
||||
AiVaultListResult,
|
||||
AiVaultScanIssue,
|
||||
AiVaultSession
|
||||
} from '../../shared/ai-vault-types'
|
||||
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,
|
||||
parseRovoSessionFile
|
||||
} from './session-scanner-graph-parsers'
|
||||
import {
|
||||
parseClaudeSessionFile,
|
||||
parseCodexSessionFile,
|
||||
parseGeminiSessionFile
|
||||
} from './session-scanner-primary-parsers'
|
||||
import {
|
||||
parseCopilotSessionFile,
|
||||
parseCursorSessionFile,
|
||||
parseHermesSessionFile,
|
||||
parseOpenCodeSessionFile
|
||||
} from './session-scanner-secondary-parsers'
|
||||
import type {
|
||||
AiVaultScanOptions,
|
||||
SessionFileCandidate,
|
||||
SessionFileDiscovery,
|
||||
SessionParseResult
|
||||
} from './session-scanner-types'
|
||||
import {
|
||||
clampPositiveInteger,
|
||||
errorMessage,
|
||||
normalizePiSessionsDir
|
||||
} from './session-scanner-values'
|
||||
|
||||
const DEFAULT_LIMIT = 1000
|
||||
const DEFAULT_SCAN_LIMIT_PER_AGENT = 1000
|
||||
const SESSION_PARSE_CONCURRENCY = 8
|
||||
const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects')
|
||||
const DEFAULT_CODEX_HOME_DIR = join(homedir(), '.codex')
|
||||
const CODEX_HOME_DIR = process.env.CODEX_HOME?.trim() || DEFAULT_CODEX_HOME_DIR
|
||||
const CODEX_SESSIONS_DIR = join(CODEX_HOME_DIR, 'sessions')
|
||||
const GEMINI_SESSIONS_DIR = join(homedir(), '.gemini', 'tmp')
|
||||
const COPILOT_SESSIONS_DIR = join(
|
||||
process.env.COPILOT_HOME?.trim() || join(homedir(), '.copilot'),
|
||||
'session-state'
|
||||
)
|
||||
const CURSOR_PROJECTS_DIR = join(homedir(), '.cursor', 'projects')
|
||||
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 PI_SESSIONS_DIR = normalizePiSessionsDir(
|
||||
process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), '.pi', 'agent', 'sessions')
|
||||
)
|
||||
const DROID_SESSIONS_DIR = join(homedir(), '.factory', 'sessions')
|
||||
|
||||
export async function scanAiVaultSessions(
|
||||
options: AiVaultScanOptions = {}
|
||||
): Promise<AiVaultListResult> {
|
||||
const limit = clampPositiveInteger(options.limit, DEFAULT_LIMIT)
|
||||
const limitPerAgent = clampPositiveInteger(options.limitPerAgent, DEFAULT_SCAN_LIMIT_PER_AGENT)
|
||||
const platform = options.platform ?? process.platform
|
||||
const issues: AiVaultScanIssue[] = []
|
||||
const codexSessionsDirs = uniqueCodexSessionsDirs([
|
||||
options.codexSessionsDir ?? CODEX_SESSIONS_DIR,
|
||||
...(options.additionalCodexSessionsDirs ?? [])
|
||||
])
|
||||
|
||||
const discoveries = await Promise.all<SessionFileDiscovery>([
|
||||
discoverFiles({
|
||||
rootDir: options.claudeProjectsDir ?? CLAUDE_PROJECTS_DIR,
|
||||
limit: limitPerAgent,
|
||||
agent: 'claude',
|
||||
issues,
|
||||
extensions: ['.jsonl']
|
||||
}),
|
||||
...codexSessionsDirs.map((rootDir) =>
|
||||
discoverFiles({
|
||||
rootDir,
|
||||
limit: limitPerAgent,
|
||||
agent: 'codex',
|
||||
issues,
|
||||
extensions: ['.jsonl']
|
||||
})
|
||||
),
|
||||
discoverFiles({
|
||||
rootDir: options.geminiSessionsDir ?? GEMINI_SESSIONS_DIR,
|
||||
limit: limitPerAgent,
|
||||
agent: 'gemini',
|
||||
issues,
|
||||
extensions: ['.json', '.jsonl']
|
||||
}),
|
||||
discoverFiles({
|
||||
rootDir: options.copilotSessionsDir ?? COPILOT_SESSIONS_DIR,
|
||||
limit: limitPerAgent,
|
||||
agent: 'copilot',
|
||||
issues,
|
||||
extensions: ['.jsonl']
|
||||
}),
|
||||
discoverFiles({
|
||||
rootDir: options.cursorProjectsDir ?? CURSOR_PROJECTS_DIR,
|
||||
limit: limitPerAgent,
|
||||
agent: 'cursor',
|
||||
issues,
|
||||
extensions: ['.jsonl'],
|
||||
filePredicate: (path) => path.split(/[\\/]/).includes('agent-transcripts')
|
||||
}),
|
||||
discoverFiles({
|
||||
rootDir: join(options.opencodeStorageDir ?? OPENCODE_STORAGE_DIR, 'session'),
|
||||
limit: limitPerAgent,
|
||||
agent: 'opencode',
|
||||
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,
|
||||
agent: 'hermes',
|
||||
issues,
|
||||
extensions: ['.json'],
|
||||
filePredicate: (path) => basename(path).startsWith('session_')
|
||||
}),
|
||||
discoverFiles({
|
||||
rootDir: options.rovoSessionsDir ?? ROVO_SESSIONS_DIR,
|
||||
limit: limitPerAgent,
|
||||
agent: 'rovo',
|
||||
issues,
|
||||
extensions: ['.json'],
|
||||
filePredicate: (path) => basename(path) === 'metadata.json'
|
||||
}),
|
||||
discoverOpenClawFiles({
|
||||
rootDirs: [
|
||||
options.openclawStateDir ?? OPENCLAW_STATE_DIR,
|
||||
options.openclawLegacyStateDir ?? join(homedir(), '.clawdbot')
|
||||
],
|
||||
limit: limitPerAgent,
|
||||
issues
|
||||
}),
|
||||
discoverFiles({
|
||||
rootDir: options.piSessionsDir ?? PI_SESSIONS_DIR,
|
||||
limit: limitPerAgent,
|
||||
agent: 'pi',
|
||||
issues,
|
||||
extensions: ['.jsonl']
|
||||
}),
|
||||
discoverFiles({
|
||||
rootDir: options.droidSessionsDir ?? DROID_SESSIONS_DIR,
|
||||
limit: limitPerAgent,
|
||||
agent: 'droid',
|
||||
issues,
|
||||
extensions: ['.jsonl']
|
||||
}),
|
||||
discoverFiles({
|
||||
rootDir: options.droidProjectsDir ?? join(homedir(), '.factory', 'projects'),
|
||||
limit: limitPerAgent,
|
||||
agent: 'droid',
|
||||
issues,
|
||||
extensions: ['.jsonl']
|
||||
})
|
||||
])
|
||||
|
||||
const candidates = discoveries
|
||||
.flatMap((discovery) =>
|
||||
discovery.files.map(
|
||||
(file): SessionFileCandidate => ({
|
||||
agent: discovery.agent,
|
||||
file,
|
||||
codexHome:
|
||||
discovery.agent === 'codex'
|
||||
? codexHomeForSessionsDir(discovery.rootDir, DEFAULT_CODEX_HOME_DIR)
|
||||
: null
|
||||
})
|
||||
)
|
||||
)
|
||||
.sort((left, right) => right.file.mtimeMs - left.file.mtimeMs)
|
||||
|
||||
const parsedSessions = await parseSessionCandidates({
|
||||
candidates,
|
||||
limit,
|
||||
platform,
|
||||
issues
|
||||
})
|
||||
|
||||
const sessions = parsedSessions
|
||||
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
|
||||
.slice(0, limit)
|
||||
|
||||
return {
|
||||
sessions,
|
||||
issues,
|
||||
scannedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
async function parseSessionCandidates(args: {
|
||||
candidates: SessionFileCandidate[]
|
||||
limit: number
|
||||
platform: NodeJS.Platform
|
||||
issues: AiVaultScanIssue[]
|
||||
}): Promise<AiVaultSession[]> {
|
||||
const sessions: AiVaultSession[] = []
|
||||
let index = 0
|
||||
|
||||
while (index < args.candidates.length) {
|
||||
if (canStopParsingSessions(sessions, args.limit, args.candidates[index]?.file.mtimeMs)) {
|
||||
break
|
||||
}
|
||||
|
||||
const remaining = args.candidates.length - index
|
||||
const needed = Math.max(args.limit - sessions.length, 1)
|
||||
const batchSize = Math.min(SESSION_PARSE_CONCURRENCY, needed, remaining)
|
||||
const batch = args.candidates.slice(index, index + batchSize)
|
||||
const results = await Promise.all(
|
||||
batch.map((candidate) => parseSessionCandidate(candidate, args.platform))
|
||||
)
|
||||
|
||||
for (const result of results) {
|
||||
if (result.issue) {
|
||||
args.issues.push(result.issue)
|
||||
}
|
||||
if (result.session) {
|
||||
sessions.push(result.session)
|
||||
}
|
||||
}
|
||||
|
||||
index += batchSize
|
||||
}
|
||||
|
||||
return sessions
|
||||
}
|
||||
|
||||
async function parseSessionCandidate(
|
||||
candidate: SessionFileCandidate,
|
||||
platform: NodeJS.Platform
|
||||
): Promise<SessionParseResult> {
|
||||
try {
|
||||
const session = await parseAgentSessionFile(candidate, platform)
|
||||
return { session, issue: null }
|
||||
} catch (err) {
|
||||
return {
|
||||
session: null,
|
||||
issue: {
|
||||
agent: candidate.agent,
|
||||
path: candidate.file.path,
|
||||
message: errorMessage(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function parseAgentSessionFile(
|
||||
candidate: SessionFileCandidate,
|
||||
platform: NodeJS.Platform
|
||||
): Promise<AiVaultSession | null> {
|
||||
switch (candidate.agent) {
|
||||
case 'claude':
|
||||
return parseClaudeSessionFile(candidate.file, platform)
|
||||
case 'codex':
|
||||
return parseCodexSessionFile(candidate.file, platform, candidate.codexHome)
|
||||
case 'gemini':
|
||||
return parseGeminiSessionFile(candidate.file, platform)
|
||||
case 'copilot':
|
||||
return parseCopilotSessionFile(candidate.file, platform)
|
||||
case 'cursor':
|
||||
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':
|
||||
return parseRovoSessionFile(candidate.file, platform)
|
||||
case 'openclaw':
|
||||
return parseMessageGraphSessionFile('openclaw', candidate.file, platform)
|
||||
case 'pi':
|
||||
return parseMessageGraphSessionFile('pi', candidate.file, platform)
|
||||
case 'droid':
|
||||
return parseDroidSessionFile(candidate.file, platform)
|
||||
}
|
||||
}
|
||||
|
||||
function canStopParsingSessions(
|
||||
sessions: AiVaultSession[],
|
||||
limit: number,
|
||||
nextCandidateMtimeMs: number | undefined
|
||||
): boolean {
|
||||
if (sessions.length < limit || typeof nextCandidateMtimeMs !== 'number') {
|
||||
return false
|
||||
}
|
||||
const visibleCutoff = sessions
|
||||
.map(sessionSortTime)
|
||||
.sort((left, right) => right - left)
|
||||
.at(limit - 1)
|
||||
|
||||
// Transcript mtime is already our discovery bound and fallback sort key; older
|
||||
// files cannot displace the current visible set once the cutoff is newer.
|
||||
return typeof visibleCutoff === 'number' && nextCandidateMtimeMs < visibleCutoff
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type {
|
||||
Automation,
|
||||
AutomationRun,
|
||||
AutomationRunOutputSnapshot
|
||||
} from '../../shared/automations-types'
|
||||
import type { AutomationRunTargetResult } from './run-target-resolution'
|
||||
|
||||
const MAX_HEADLESS_OUTPUT_SNAPSHOT_CHARS = 256 * 1024
|
||||
|
||||
export type HeadlessAutomationDispatchLaunch = {
|
||||
workspaceId: string
|
||||
workspaceDisplayName?: string | null
|
||||
terminalSessionId: string | null
|
||||
completion?: Promise<{
|
||||
status: 'completed' | 'dispatch_failed'
|
||||
outputSnapshot?: AutomationRunOutputSnapshot | null
|
||||
error?: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
export type HeadlessAutomationDispatcher = (request: {
|
||||
automation: Automation
|
||||
run: AutomationRun
|
||||
target: Extract<AutomationRunTargetResult, { ok: true }>
|
||||
}) => Promise<HeadlessAutomationDispatchLaunch>
|
||||
|
||||
export function createHeadlessAutomationOutputSnapshotBuffer(): {
|
||||
append: (chunk: string) => void
|
||||
snapshot: () => AutomationRunOutputSnapshot | null
|
||||
} {
|
||||
const chunks: string[] = []
|
||||
let totalChars = 0
|
||||
let truncated = false
|
||||
|
||||
return {
|
||||
append(chunk): void {
|
||||
if (!chunk) {
|
||||
return
|
||||
}
|
||||
chunks.push(chunk)
|
||||
totalChars += chunk.length
|
||||
let overflowChars = totalChars - MAX_HEADLESS_OUTPUT_SNAPSHOT_CHARS
|
||||
while (overflowChars > 0 && chunks.length > 0) {
|
||||
const firstChunk = chunks[0]!
|
||||
if (firstChunk.length <= overflowChars) {
|
||||
chunks.shift()
|
||||
totalChars -= firstChunk.length
|
||||
overflowChars -= firstChunk.length
|
||||
truncated = true
|
||||
continue
|
||||
}
|
||||
chunks[0] = firstChunk.slice(overflowChars)
|
||||
totalChars -= overflowChars
|
||||
truncated = true
|
||||
overflowChars = 0
|
||||
}
|
||||
},
|
||||
snapshot(): AutomationRunOutputSnapshot | null {
|
||||
const content = chunks.join('').trim()
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
format: 'plain_text',
|
||||
content,
|
||||
capturedAt: Date.now(),
|
||||
truncated
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Store } from '../persistence'
|
||||
import type { Automation } from '../../shared/automations-types'
|
||||
import { getAutomationLegacyRepoId } from '../../shared/automation-run-identity'
|
||||
import { getRepoExecutionHostId, parseExecutionHostId } from '../../shared/execution-host'
|
||||
import type { ProjectHostSetup, Repo } from '../../shared/types'
|
||||
import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id'
|
||||
|
||||
export type AutomationRunTargetResult =
|
||||
| { ok: true; cwd: string; repo: Repo; setup?: ProjectHostSetup }
|
||||
| { ok: false; error: string }
|
||||
|
||||
type AutomationRunTargetOptions = {
|
||||
allowRemoteHostScheduling?: boolean
|
||||
}
|
||||
|
||||
function getLegacyPrecheckCwd(store: Store, automation: Automation): string | null {
|
||||
if (automation.workspaceMode === 'existing') {
|
||||
const parsed = automation.workspaceId
|
||||
? splitWorktreeIdForFilesystem(automation.workspaceId)
|
||||
: null
|
||||
return parsed?.worktreePath ?? null
|
||||
}
|
||||
return store.getRepo(getAutomationLegacyRepoId(automation))?.path ?? null
|
||||
}
|
||||
|
||||
export function resolveAutomationRunTarget(
|
||||
store: Store,
|
||||
automation: Automation,
|
||||
options: AutomationRunTargetOptions = {}
|
||||
): AutomationRunTargetResult {
|
||||
const context = automation.runContext ?? null
|
||||
if (!context) {
|
||||
const repo = store.getRepo(getAutomationLegacyRepoId(automation))
|
||||
const cwd = getLegacyPrecheckCwd(store, automation)
|
||||
if (!repo || !cwd) {
|
||||
return { ok: false, error: 'Automation run target is no longer available.' }
|
||||
}
|
||||
return { ok: true, cwd, repo }
|
||||
}
|
||||
const parsedHost = parseExecutionHostId(context.hostId)
|
||||
if (
|
||||
parsedHost?.kind === 'runtime' &&
|
||||
(!options.allowRemoteHostScheduling || automation.schedulerOwner !== 'remote_host_service')
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
'Remote-server automation scheduling is not available from this Orca client yet. Run this automation on the remote server or update Orca when durable remote scheduling is available.'
|
||||
}
|
||||
}
|
||||
|
||||
const setup = store
|
||||
.getProjectHostSetups()
|
||||
.find((candidate) => candidate.id === context.projectHostSetupId)
|
||||
if (!setup) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Project is not set up on the selected automation host anymore.'
|
||||
}
|
||||
}
|
||||
if (setup.setupState !== 'ready') {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Project setup on the selected automation host is ${setup.setupState}.`
|
||||
}
|
||||
}
|
||||
if (
|
||||
setup.projectId !== context.projectId ||
|
||||
setup.hostId !== context.hostId ||
|
||||
setup.repoId !== context.repoId
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Automation run target no longer matches the selected project host setup.'
|
||||
}
|
||||
}
|
||||
|
||||
const repo = store.getRepo(context.repoId)
|
||||
if (!repo) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Repository for the selected automation host is no longer available.'
|
||||
}
|
||||
}
|
||||
if (getRepoExecutionHostId(repo) !== context.hostId) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Repository is no longer attached to the selected automation host.'
|
||||
}
|
||||
}
|
||||
if (repo.path !== setup.path || context.path !== setup.path) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Project path for the selected automation host has changed.'
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, cwd: setup.path, repo, setup }
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Automation, AutomationRun, AutomationRunUsage } from '../../shared/automations-types'
|
||||
import type { ClaudeUsageStore } from '../claude-usage/store'
|
||||
import type { CodexUsageStore } from '../codex-usage/store'
|
||||
|
||||
function createUnavailableAutomationUsage(
|
||||
collectedAt: number,
|
||||
provider: AutomationRunUsage['provider'],
|
||||
unavailableReason: AutomationRunUsage['unavailableReason'],
|
||||
unavailableMessage: string
|
||||
): AutomationRunUsage {
|
||||
return {
|
||||
status: 'unavailable',
|
||||
provider,
|
||||
model: null,
|
||||
inputTokens: null,
|
||||
outputTokens: null,
|
||||
cacheReadTokens: null,
|
||||
cacheWriteTokens: null,
|
||||
reasoningOutputTokens: null,
|
||||
totalTokens: null,
|
||||
estimatedCostUsd: null,
|
||||
estimatedCostSource: null,
|
||||
providerSessionId: null,
|
||||
attribution: null,
|
||||
collectedAt,
|
||||
unavailableReason,
|
||||
unavailableMessage
|
||||
}
|
||||
}
|
||||
|
||||
function getAutomationUsageProvider(
|
||||
automation: Automation | undefined
|
||||
): AutomationRunUsage['provider'] {
|
||||
if (automation?.agentId === 'codex') {
|
||||
return 'codex'
|
||||
}
|
||||
if (automation?.agentId === 'claude') {
|
||||
return 'claude'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function collectAutomationRunUsage({
|
||||
automation,
|
||||
run,
|
||||
claudeUsage,
|
||||
codexUsage
|
||||
}: {
|
||||
automation: Automation | undefined
|
||||
run: AutomationRun
|
||||
claudeUsage: ClaudeUsageStore | null
|
||||
codexUsage: CodexUsageStore | null
|
||||
}): Promise<AutomationRunUsage> {
|
||||
const collectedAt = Date.now()
|
||||
const unavailable = (
|
||||
provider: AutomationRunUsage['provider'],
|
||||
unavailableReason: AutomationRunUsage['unavailableReason'],
|
||||
unavailableMessage: string
|
||||
): AutomationRunUsage =>
|
||||
createUnavailableAutomationUsage(collectedAt, provider, unavailableReason, unavailableMessage)
|
||||
|
||||
if (!automation || run.status !== 'completed') {
|
||||
return unavailable(
|
||||
getAutomationUsageProvider(automation),
|
||||
'run_not_finished',
|
||||
'Usage is only collected for completed automation runs.'
|
||||
)
|
||||
}
|
||||
if (automation.executionTargetType === 'ssh') {
|
||||
return unavailable(
|
||||
getAutomationUsageProvider(automation),
|
||||
'remote_usage_unavailable',
|
||||
'Remote automation usage is not available from local usage logs.'
|
||||
)
|
||||
}
|
||||
if (automation.agentId === 'claude') {
|
||||
if (!claudeUsage) {
|
||||
return unavailable('claude', 'scan_failed', 'Claude usage store is unavailable.')
|
||||
}
|
||||
return claudeUsage.getAutomationRunUsage({
|
||||
worktreeId: run.workspaceId,
|
||||
terminalSessionId: run.terminalSessionId,
|
||||
startedAt: run.startedAt,
|
||||
completedAt: collectedAt
|
||||
})
|
||||
}
|
||||
if (automation.agentId === 'codex') {
|
||||
if (!codexUsage) {
|
||||
return unavailable('codex', 'scan_failed', 'Codex usage store is unavailable.')
|
||||
}
|
||||
return codexUsage.getAutomationRunUsage({
|
||||
worktreeId: run.workspaceId,
|
||||
terminalSessionId: run.terminalSessionId,
|
||||
startedAt: run.startedAt,
|
||||
completedAt: collectedAt
|
||||
})
|
||||
}
|
||||
return unavailable(null, 'provider_unsupported', 'This agent does not report usage to Orca yet.')
|
||||
}
|
||||
@@ -105,6 +105,46 @@ describe('AutomationService prechecks', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not run scheduled prechecks when the selected host setup is stale', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-13T08:00:00Z'))
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo({ path: '/repo/current' }))
|
||||
const setup = store.getProjectHostSetups()[0]!
|
||||
const automation = store.createAutomation({
|
||||
name: 'Conditional check',
|
||||
prompt: 'Check the repo',
|
||||
precheck: {
|
||||
command: 'test -f ready',
|
||||
timeoutSeconds: 30
|
||||
},
|
||||
agentId: 'claude',
|
||||
projectId: 'r1',
|
||||
runContext: {
|
||||
kind: 'workspace-run',
|
||||
projectId: setup.projectId,
|
||||
hostId: setup.hostId,
|
||||
projectHostSetupId: setup.id,
|
||||
repoId: setup.repoId,
|
||||
path: '/repo/old'
|
||||
},
|
||||
workspaceMode: 'new_per_run',
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: new Date('2026-05-14T00:00:00Z').getTime()
|
||||
})
|
||||
const run = store.createAutomationRun(automation, Date.now(), 'scheduled')
|
||||
const service = new AutomationService(store, { tickMs: 60_000 })
|
||||
|
||||
const result = await service.runPrecheck(automation.id, run.id)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
command: 'test -f ready',
|
||||
exitCode: null,
|
||||
error: 'Project path for the selected automation host has changed.'
|
||||
})
|
||||
expect(runAutomationPrecheckMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not run prechecks for manual dispatches', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-13T08:00:00Z'))
|
||||
const store = await createStore()
|
||||
@@ -129,4 +169,65 @@ describe('AutomationService prechecks', () => {
|
||||
await expect(service.runPrecheck(automation.id, run.id)).resolves.toBeNull()
|
||||
expect(runAutomationPrecheckMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('honors scheduled prechecks before headless dispatch', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-12T08:59:00Z'))
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo())
|
||||
const automation = store.createAutomation({
|
||||
name: 'Conditional remote check',
|
||||
prompt: 'Check the repo',
|
||||
precheck: {
|
||||
command: 'test -f ready',
|
||||
timeoutSeconds: 30
|
||||
},
|
||||
agentId: 'claude',
|
||||
projectId: 'r1',
|
||||
workspaceMode: 'new_per_run',
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: new Date('2026-05-12T00:00:00Z').getTime()
|
||||
})
|
||||
runAutomationPrecheckMock.mockResolvedValue({
|
||||
command: 'test -f ready',
|
||||
exitCode: 1,
|
||||
timedOut: false,
|
||||
durationMs: 5,
|
||||
stdout: '',
|
||||
stderr: 'missing',
|
||||
stdoutTruncated: false,
|
||||
stderrTruncated: false,
|
||||
error: null,
|
||||
startedAt: Date.now(),
|
||||
completedAt: Date.now()
|
||||
})
|
||||
const headlessDispatcher = vi.fn()
|
||||
const service = new AutomationService(store, {
|
||||
tickMs: 60_000,
|
||||
allowRemoteHostScheduling: true,
|
||||
headlessDispatcher
|
||||
})
|
||||
const run = store.createAutomationRun(automation, Date.now(), 'scheduled')
|
||||
const requestHeadlessDispatch = (
|
||||
service as unknown as {
|
||||
requestHeadlessDispatch: (
|
||||
automationArg: typeof automation,
|
||||
runArg: typeof run,
|
||||
targetArg: { ok: true; cwd: string; repo: Repo }
|
||||
) => Promise<unknown>
|
||||
}
|
||||
).requestHeadlessDispatch.bind(service)
|
||||
|
||||
await requestHeadlessDispatch(automation, run, {
|
||||
ok: true,
|
||||
cwd: '/repo',
|
||||
repo: store.getRepo('r1')!
|
||||
})
|
||||
|
||||
expect(headlessDispatcher).not.toHaveBeenCalled()
|
||||
expect(store.listAutomationRuns(automation.id)[0]).toMatchObject({
|
||||
status: 'skipped_precheck',
|
||||
error: 'Precheck exited with code 1.'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import type { Repo } from '../../shared/types'
|
||||
import { toRuntimeExecutionHostId } from '../../shared/execution-host'
|
||||
import { AutomationService } from './service'
|
||||
|
||||
const testState = { dir: '' }
|
||||
@@ -124,6 +125,227 @@ describe('AutomationService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('skips dispatch when the selected project host setup is gone', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-13T08:00:00Z'))
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo())
|
||||
const automation = store.createAutomation({
|
||||
name: 'Manual check',
|
||||
prompt: 'Check the repo',
|
||||
agentId: 'claude',
|
||||
projectId: 'r1',
|
||||
runContext: {
|
||||
kind: 'workspace-run',
|
||||
projectId: 'project-1',
|
||||
hostId: 'local',
|
||||
projectHostSetupId: 'missing-setup',
|
||||
repoId: 'r1',
|
||||
path: '/repo'
|
||||
},
|
||||
workspaceMode: 'new_per_run',
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: new Date('2026-05-14T00:00:00Z').getTime()
|
||||
})
|
||||
const send = vi.fn()
|
||||
const service = new AutomationService(store, { tickMs: 60_000 })
|
||||
service.setWebContents({
|
||||
isDestroyed: () => false,
|
||||
send
|
||||
} as never)
|
||||
service.setRendererReady()
|
||||
|
||||
const run = await service.runNow(automation.id)
|
||||
|
||||
expect(run.status).toBe('skipped_unavailable')
|
||||
expect(run.error).toBe('Project is not set up on the selected automation host anymore.')
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips dispatch when the saved project host setup path is stale', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-13T08:00:00Z'))
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo({ path: '/repo/current' }))
|
||||
const setup = store.getProjectHostSetups()[0]!
|
||||
const automation = store.createAutomation({
|
||||
name: 'Manual check',
|
||||
prompt: 'Check the repo',
|
||||
agentId: 'claude',
|
||||
projectId: 'r1',
|
||||
runContext: {
|
||||
kind: 'workspace-run',
|
||||
projectId: setup.projectId,
|
||||
hostId: setup.hostId,
|
||||
projectHostSetupId: setup.id,
|
||||
repoId: setup.repoId,
|
||||
path: '/repo/old'
|
||||
},
|
||||
workspaceMode: 'new_per_run',
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: new Date('2026-05-14T00:00:00Z').getTime()
|
||||
})
|
||||
const send = vi.fn()
|
||||
const service = new AutomationService(store, { tickMs: 60_000 })
|
||||
service.setWebContents({
|
||||
isDestroyed: () => false,
|
||||
send
|
||||
} as never)
|
||||
service.setRendererReady()
|
||||
|
||||
const run = await service.runNow(automation.id)
|
||||
|
||||
expect(run.status).toBe('skipped_unavailable')
|
||||
expect(run.error).toBe('Project path for the selected automation host has changed.')
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips runtime-owned automations before desktop renderer dispatch', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-13T08:00:00Z'))
|
||||
const store = await createStore()
|
||||
const runtimeHostId = toRuntimeExecutionHostId('gpu-server')
|
||||
store.addRepo(makeRepo({ executionHostId: runtimeHostId }))
|
||||
const setup = store.getProjectHostSetups()[0]!
|
||||
const automation = store.createAutomation({
|
||||
name: 'Remote check',
|
||||
prompt: 'Check the remote repo',
|
||||
agentId: 'claude',
|
||||
projectId: 'r1',
|
||||
runContext: {
|
||||
kind: 'workspace-run',
|
||||
projectId: setup.projectId,
|
||||
hostId: runtimeHostId,
|
||||
projectHostSetupId: setup.id,
|
||||
repoId: setup.repoId,
|
||||
path: setup.path
|
||||
},
|
||||
workspaceMode: 'new_per_run',
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: new Date('2026-05-14T00:00:00Z').getTime()
|
||||
})
|
||||
const send = vi.fn()
|
||||
const service = new AutomationService(store, { tickMs: 60_000 })
|
||||
service.setWebContents({
|
||||
isDestroyed: () => false,
|
||||
send
|
||||
} as never)
|
||||
service.setRendererReady()
|
||||
|
||||
const run = await service.runNow(automation.id)
|
||||
|
||||
expect(run.status).toBe('skipped_unavailable')
|
||||
expect(run.error).toContain('Remote-server automation scheduling is not available')
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dispatches remote-host scheduled automations when service runs in serve mode', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-13T08:00:00Z'))
|
||||
const store = await createStore()
|
||||
const runtimeHostId = toRuntimeExecutionHostId('gpu-server')
|
||||
store.addRepo(makeRepo({ executionHostId: runtimeHostId }))
|
||||
const setup = store.getProjectHostSetups()[0]!
|
||||
const automation = store.createAutomation({
|
||||
name: 'Remote check',
|
||||
prompt: 'Check the remote repo',
|
||||
agentId: 'claude',
|
||||
projectId: 'r1',
|
||||
runContext: {
|
||||
kind: 'workspace-run',
|
||||
projectId: setup.projectId,
|
||||
hostId: runtimeHostId,
|
||||
projectHostSetupId: setup.id,
|
||||
repoId: setup.repoId,
|
||||
path: setup.path
|
||||
},
|
||||
workspaceMode: 'new_per_run',
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: new Date('2026-05-14T00:00:00Z').getTime()
|
||||
})
|
||||
const send = vi.fn()
|
||||
const service = new AutomationService(store, {
|
||||
tickMs: 60_000,
|
||||
allowRemoteHostScheduling: true
|
||||
})
|
||||
service.setWebContents({
|
||||
isDestroyed: () => false,
|
||||
send
|
||||
} as never)
|
||||
service.setRendererReady()
|
||||
|
||||
const run = await service.runNow(automation.id)
|
||||
|
||||
expect(run.status).toBe('dispatching')
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
'automations:dispatchRequested',
|
||||
expect.objectContaining({
|
||||
automation: expect.objectContaining({ schedulerOwner: 'remote_host_service' }),
|
||||
run: expect.objectContaining({ id: run.id, status: 'dispatching' })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('dispatches remote-host automations headlessly when no renderer is available', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-13T08:00:00Z'))
|
||||
const store = await createStore()
|
||||
const runtimeHostId = toRuntimeExecutionHostId('gpu-server')
|
||||
store.addRepo(makeRepo({ executionHostId: runtimeHostId }))
|
||||
const setup = store.getProjectHostSetups()[0]!
|
||||
const automation = store.createAutomation({
|
||||
name: 'Remote check',
|
||||
prompt: 'Check the remote repo',
|
||||
agentId: 'claude',
|
||||
projectId: 'r1',
|
||||
runContext: {
|
||||
kind: 'workspace-run',
|
||||
projectId: setup.projectId,
|
||||
hostId: runtimeHostId,
|
||||
projectHostSetupId: setup.id,
|
||||
repoId: setup.repoId,
|
||||
path: setup.path
|
||||
},
|
||||
workspaceMode: 'new_per_run',
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: new Date('2026-05-14T00:00:00Z').getTime()
|
||||
})
|
||||
const service = new AutomationService(store, {
|
||||
tickMs: 60_000,
|
||||
allowRemoteHostScheduling: true,
|
||||
headlessDispatcher: vi.fn().mockResolvedValue({
|
||||
workspaceId: 'remote-wt-1',
|
||||
workspaceDisplayName: 'Remote automation',
|
||||
terminalSessionId: 'remote-tab-1',
|
||||
completion: Promise.resolve({
|
||||
status: 'completed',
|
||||
outputSnapshot: {
|
||||
format: 'plain_text',
|
||||
content: 'Done.',
|
||||
capturedAt: Date.now(),
|
||||
truncated: false
|
||||
},
|
||||
error: null
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const run = await service.runNow(automation.id)
|
||||
|
||||
expect(run.status).toBe('dispatched')
|
||||
expect(run.workspaceId).toBe('remote-wt-1')
|
||||
expect(run.workspaceDisplayName).toBe('Remote automation')
|
||||
expect(run.terminalSessionId).toBe('remote-tab-1')
|
||||
await vi.waitFor(() =>
|
||||
expect(store.listAutomationRuns(automation.id)[0]).toMatchObject({
|
||||
status: 'completed',
|
||||
workspaceId: 'remote-wt-1',
|
||||
terminalSessionId: 'remote-tab-1',
|
||||
outputSnapshot: expect.objectContaining({ content: 'Done.' })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('attaches provider usage when a completed run can be attributed', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-13T10:00:00'))
|
||||
const store = await createStore()
|
||||
|
||||
+110
-97
@@ -6,13 +6,18 @@ import type {
|
||||
AutomationDispatchResult,
|
||||
AutomationPrecheckResult,
|
||||
AutomationRun,
|
||||
AutomationRunStatus,
|
||||
AutomationRunUsage
|
||||
AutomationRunStatus
|
||||
} from '../../shared/automations-types'
|
||||
import type { ClaudeUsageStore } from '../claude-usage/store'
|
||||
import type { CodexUsageStore } from '../codex-usage/store'
|
||||
import { splitWorktreeIdForFilesystem } from '../../shared/worktree-id'
|
||||
import { runAutomationPrecheck } from './precheck-runner'
|
||||
import { resolveAutomationRunTarget, type AutomationRunTargetResult } from './run-target-resolution'
|
||||
import { collectAutomationRunUsage } from './run-usage-collection'
|
||||
import type { HeadlessAutomationDispatcher } from './headless-dispatch'
|
||||
import {
|
||||
didAutomationPrecheckPass,
|
||||
formatAutomationPrecheckFailure
|
||||
} from '../../shared/automation-precheck'
|
||||
|
||||
const DEFAULT_TICK_MS = 60 * 1000
|
||||
|
||||
@@ -25,15 +30,25 @@ export class AutomationService {
|
||||
private evaluating = false
|
||||
private readonly claudeUsage: ClaudeUsageStore | null
|
||||
private readonly codexUsage: CodexUsageStore | null
|
||||
private readonly allowRemoteHostScheduling: boolean
|
||||
private readonly headlessDispatcher: HeadlessAutomationDispatcher | null
|
||||
|
||||
constructor(
|
||||
store: Store,
|
||||
opts: { tickMs?: number; claudeUsage?: ClaudeUsageStore; codexUsage?: CodexUsageStore } = {}
|
||||
opts: {
|
||||
tickMs?: number
|
||||
claudeUsage?: ClaudeUsageStore
|
||||
codexUsage?: CodexUsageStore
|
||||
allowRemoteHostScheduling?: boolean
|
||||
headlessDispatcher?: HeadlessAutomationDispatcher
|
||||
} = {}
|
||||
) {
|
||||
this.store = store
|
||||
this.tickMs = opts.tickMs ?? DEFAULT_TICK_MS
|
||||
this.claudeUsage = opts.claudeUsage ?? null
|
||||
this.codexUsage = opts.codexUsage ?? null
|
||||
this.allowRemoteHostScheduling = opts.allowRemoteHostScheduling ?? false
|
||||
this.headlessDispatcher = opts.headlessDispatcher ?? null
|
||||
}
|
||||
|
||||
setWebContents(webContents: WebContents | null): void {
|
||||
@@ -87,8 +102,10 @@ export class AutomationService {
|
||||
if (run.trigger !== 'scheduled' || !automation.precheck) {
|
||||
return null
|
||||
}
|
||||
const cwd = this.getPrecheckCwd(automation)
|
||||
if (!cwd) {
|
||||
const target = resolveAutomationRunTarget(this.store, automation, {
|
||||
allowRemoteHostScheduling: this.allowRemoteHostScheduling
|
||||
})
|
||||
if (!target.ok) {
|
||||
return {
|
||||
command: automation.precheck.command,
|
||||
exitCode: null,
|
||||
@@ -98,7 +115,7 @@ export class AutomationService {
|
||||
stderr: '',
|
||||
stdoutTruncated: false,
|
||||
stderrTruncated: false,
|
||||
error: 'Automation precheck target is no longer available.',
|
||||
error: target.error,
|
||||
startedAt: Date.now(),
|
||||
completedAt: Date.now()
|
||||
}
|
||||
@@ -107,8 +124,8 @@ export class AutomationService {
|
||||
precheck: automation.precheck,
|
||||
target:
|
||||
automation.executionTargetType === 'ssh'
|
||||
? { type: 'ssh', cwd, connectionId: automation.executionTargetId }
|
||||
: { type: 'local', cwd }
|
||||
? { type: 'ssh', cwd: target.cwd, connectionId: automation.executionTargetId }
|
||||
: { type: 'local', cwd: target.cwd }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -124,7 +141,12 @@ export class AutomationService {
|
||||
if (run.usage) {
|
||||
return run
|
||||
}
|
||||
const usage = await this.collectRunUsage(run)
|
||||
const usage = await collectAutomationRunUsage({
|
||||
automation: this.store.listAutomations().find((entry) => entry.id === run.automationId),
|
||||
run,
|
||||
claudeUsage: this.claudeUsage,
|
||||
codexUsage: this.codexUsage
|
||||
})
|
||||
return this.store.updateAutomationRun({
|
||||
runId: run.id,
|
||||
status: run.status,
|
||||
@@ -135,83 +157,6 @@ export class AutomationService {
|
||||
})
|
||||
}
|
||||
|
||||
private async collectRunUsage(run: AutomationRun): Promise<AutomationRunUsage> {
|
||||
const automation = this.store.listAutomations().find((entry) => entry.id === run.automationId)
|
||||
const collectedAt = Date.now()
|
||||
const unavailable = (
|
||||
provider: AutomationRunUsage['provider'],
|
||||
unavailableReason: AutomationRunUsage['unavailableReason'],
|
||||
unavailableMessage: string
|
||||
): AutomationRunUsage => ({
|
||||
status: 'unavailable',
|
||||
provider,
|
||||
model: null,
|
||||
inputTokens: null,
|
||||
outputTokens: null,
|
||||
cacheReadTokens: null,
|
||||
cacheWriteTokens: null,
|
||||
reasoningOutputTokens: null,
|
||||
totalTokens: null,
|
||||
estimatedCostUsd: null,
|
||||
estimatedCostSource: null,
|
||||
providerSessionId: null,
|
||||
attribution: null,
|
||||
collectedAt,
|
||||
unavailableReason,
|
||||
unavailableMessage
|
||||
})
|
||||
|
||||
if (!automation || run.status !== 'completed') {
|
||||
return unavailable(
|
||||
automation?.agentId === 'codex'
|
||||
? 'codex'
|
||||
: automation?.agentId === 'claude'
|
||||
? 'claude'
|
||||
: null,
|
||||
'run_not_finished',
|
||||
'Usage is only collected for completed automation runs.'
|
||||
)
|
||||
}
|
||||
if (automation.executionTargetType === 'ssh') {
|
||||
return unavailable(
|
||||
automation.agentId === 'codex'
|
||||
? 'codex'
|
||||
: automation.agentId === 'claude'
|
||||
? 'claude'
|
||||
: null,
|
||||
'remote_usage_unavailable',
|
||||
'Remote automation usage is not available from local usage logs.'
|
||||
)
|
||||
}
|
||||
if (automation.agentId === 'claude') {
|
||||
if (!this.claudeUsage) {
|
||||
return unavailable('claude', 'scan_failed', 'Claude usage store is unavailable.')
|
||||
}
|
||||
return this.claudeUsage.getAutomationRunUsage({
|
||||
worktreeId: run.workspaceId,
|
||||
terminalSessionId: run.terminalSessionId,
|
||||
startedAt: run.startedAt,
|
||||
completedAt: collectedAt
|
||||
})
|
||||
}
|
||||
if (automation.agentId === 'codex') {
|
||||
if (!this.codexUsage) {
|
||||
return unavailable('codex', 'scan_failed', 'Codex usage store is unavailable.')
|
||||
}
|
||||
return this.codexUsage.getAutomationRunUsage({
|
||||
worktreeId: run.workspaceId,
|
||||
terminalSessionId: run.terminalSessionId,
|
||||
startedAt: run.startedAt,
|
||||
completedAt: collectedAt
|
||||
})
|
||||
}
|
||||
return unavailable(
|
||||
null,
|
||||
'provider_unsupported',
|
||||
'This agent does not report usage to Orca yet.'
|
||||
)
|
||||
}
|
||||
|
||||
private async evaluateDueRuns(): Promise<void> {
|
||||
if (this.evaluating) {
|
||||
return
|
||||
@@ -230,16 +175,6 @@ export class AutomationService {
|
||||
}
|
||||
}
|
||||
|
||||
private getPrecheckCwd(automation: Automation): string | null {
|
||||
if (automation.workspaceMode === 'existing') {
|
||||
const parsed = automation.workspaceId
|
||||
? splitWorktreeIdForFilesystem(automation.workspaceId)
|
||||
: null
|
||||
return parsed?.worktreePath ?? null
|
||||
}
|
||||
return this.store.getRepo(automation.projectId)?.path ?? null
|
||||
}
|
||||
|
||||
private async evaluateAutomation(automation: Automation, now: number): Promise<void> {
|
||||
const scheduledFor = this.store.getLatestAutomationOccurrence(automation, now)
|
||||
if (scheduledFor === null) {
|
||||
@@ -267,8 +202,22 @@ export class AutomationService {
|
||||
automation: Automation,
|
||||
run: AutomationRun
|
||||
): Promise<AutomationRun> {
|
||||
const target = resolveAutomationRunTarget(this.store, automation, {
|
||||
allowRemoteHostScheduling: this.allowRemoteHostScheduling
|
||||
})
|
||||
if (!target.ok) {
|
||||
return this.store.updateAutomationRun({
|
||||
runId: run.id,
|
||||
status: 'skipped_unavailable',
|
||||
workspaceId: automation.workspaceId,
|
||||
error: target.error
|
||||
})
|
||||
}
|
||||
const webContents = this.webContents
|
||||
if (!webContents || webContents.isDestroyed() || !this.rendererReady) {
|
||||
if (this.headlessDispatcher) {
|
||||
return await this.requestHeadlessDispatch(automation, run, target)
|
||||
}
|
||||
return this.store.updateAutomationRun({
|
||||
runId: run.id,
|
||||
status: 'skipped_unavailable',
|
||||
@@ -286,6 +235,70 @@ export class AutomationService {
|
||||
webContents.send('automations:dispatchRequested', payload)
|
||||
return updated
|
||||
}
|
||||
|
||||
private async requestHeadlessDispatch(
|
||||
automation: Automation,
|
||||
run: AutomationRun,
|
||||
target: Extract<AutomationRunTargetResult, { ok: true }>
|
||||
): Promise<AutomationRun> {
|
||||
const precheckResult =
|
||||
run.trigger === 'scheduled' && automation.precheck
|
||||
? await this.runPrecheck(automation.id, run.id)
|
||||
: null
|
||||
if (precheckResult && !didAutomationPrecheckPass(precheckResult)) {
|
||||
return this.store.updateAutomationRun({
|
||||
runId: run.id,
|
||||
status: 'skipped_precheck',
|
||||
workspaceId: automation.workspaceId,
|
||||
precheckResult,
|
||||
error: formatAutomationPrecheckFailure(precheckResult)
|
||||
})
|
||||
}
|
||||
try {
|
||||
const launch = await this.headlessDispatcher!({ automation, run, target })
|
||||
const updated = this.store.updateAutomationRun({
|
||||
runId: run.id,
|
||||
status: 'dispatched',
|
||||
workspaceId: launch.workspaceId,
|
||||
workspaceDisplayName: launch.workspaceDisplayName ?? null,
|
||||
terminalSessionId: launch.terminalSessionId,
|
||||
error: null
|
||||
})
|
||||
if (launch.completion) {
|
||||
void launch.completion
|
||||
.then((completion) =>
|
||||
this.markDispatchResult({
|
||||
runId: run.id,
|
||||
status: completion.status,
|
||||
workspaceId: launch.workspaceId,
|
||||
workspaceDisplayName: launch.workspaceDisplayName ?? null,
|
||||
terminalSessionId: launch.terminalSessionId,
|
||||
precheckResult,
|
||||
outputSnapshot: completion.outputSnapshot ?? null,
|
||||
error: completion.error ?? null
|
||||
})
|
||||
)
|
||||
.catch((error) =>
|
||||
this.markDispatchResult({
|
||||
runId: run.id,
|
||||
status: 'dispatch_failed',
|
||||
workspaceId: launch.workspaceId,
|
||||
workspaceDisplayName: launch.workspaceDisplayName ?? null,
|
||||
terminalSessionId: launch.terminalSessionId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
)
|
||||
}
|
||||
return updated
|
||||
} catch (error) {
|
||||
return this.store.updateAutomationRun({
|
||||
runId: run.id,
|
||||
status: 'dispatch_failed',
|
||||
workspaceId: automation.workspaceId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isFinalRunStatus(status: AutomationRunStatus): boolean {
|
||||
|
||||
@@ -420,6 +420,47 @@ describe('setupGuestShortcutForwarding', () => {
|
||||
guestOffMock = vi.fn()
|
||||
})
|
||||
|
||||
it('commits Ctrl+Tab switching from focused guest pages on generic release events', () => {
|
||||
setupGuestShortcutForwarding({
|
||||
browserTabId,
|
||||
guest: makeGuest(),
|
||||
resolveRenderer: () => makeRenderer()
|
||||
})
|
||||
|
||||
const ctrlTabInput = { code: 'Tab', key: 'Tab', control: true, meta: false }
|
||||
const releaseInputs: Partial<Electron.Input>[] = [
|
||||
{
|
||||
type: 'keyUp',
|
||||
code: 'Control',
|
||||
key: 'Control',
|
||||
control: false,
|
||||
meta: false
|
||||
},
|
||||
{
|
||||
type: 'keyUp',
|
||||
code: 'Tab',
|
||||
key: 'Tab',
|
||||
control: false,
|
||||
meta: false
|
||||
}
|
||||
]
|
||||
|
||||
for (const releaseInput of releaseInputs) {
|
||||
rendererSendMock.mockClear()
|
||||
const keyDownPreventDefault = triggerBeforeInput(ctrlTabInput)
|
||||
const tabReleasePreventDefault = triggerBeforeInput({ ...ctrlTabInput, type: 'keyUp' })
|
||||
const keyUpPreventDefault = triggerBeforeInput(releaseInput)
|
||||
|
||||
expect(keyDownPreventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(tabReleasePreventDefault).not.toHaveBeenCalled()
|
||||
expect(keyUpPreventDefault).toHaveBeenCalledTimes(1)
|
||||
expect(rendererSendMock).toHaveBeenNthCalledWith(1, 'ui:ctrlTabKeyDown', {
|
||||
shiftKey: false
|
||||
})
|
||||
expect(rendererSendMock).toHaveBeenNthCalledWith(2, 'ui:ctrlTabKeyUp')
|
||||
}
|
||||
})
|
||||
|
||||
it('forwards browser page zoom shortcuts from focused guest pages', () => {
|
||||
setupGuestShortcutForwarding({
|
||||
browserTabId,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
redactKagiSessionToken
|
||||
} from '../../shared/browser-url'
|
||||
import {
|
||||
isRecentTabSwitcherCommitRelease,
|
||||
matchesRecentTabSwitcherChord,
|
||||
resolveWindowShortcutAction
|
||||
} from '../../shared/window-shortcut-policy'
|
||||
@@ -51,10 +52,6 @@ export function resolveGuestMouseWheelZoomDirection(
|
||||
return deltaY < 0 ? 'in' : 'out'
|
||||
}
|
||||
|
||||
function isControlKeyRelease(input: Electron.Input): boolean {
|
||||
return input.type === 'keyUp' && (input.code === 'ControlLeft' || input.code === 'ControlRight')
|
||||
}
|
||||
|
||||
export function setupGuestContextMenu(args: {
|
||||
browserTabId: string
|
||||
guest: Electron.WebContents
|
||||
@@ -268,17 +265,18 @@ export function setupGuestShortcutForwarding(args: {
|
||||
let ctrlTabSwitching = false
|
||||
const handler = (event: Electron.Event, input: Electron.Input): void => {
|
||||
const keybindings = getKeybindings?.()
|
||||
if (matchesRecentTabSwitcherChord(input, process.platform, keybindings)) {
|
||||
if (
|
||||
input.type === 'keyDown' &&
|
||||
matchesRecentTabSwitcherChord(input, process.platform, keybindings)
|
||||
) {
|
||||
event.preventDefault()
|
||||
if (input.type === 'keyDown') {
|
||||
ctrlTabSwitching = true
|
||||
const renderer = resolveRenderer(browserTabId)
|
||||
renderer?.send('ui:ctrlTabKeyDown', { shiftKey: input.shift === true })
|
||||
}
|
||||
ctrlTabSwitching = true
|
||||
const renderer = resolveRenderer(browserTabId)
|
||||
renderer?.send('ui:ctrlTabKeyDown', { shiftKey: input.shift === true })
|
||||
return
|
||||
}
|
||||
|
||||
if (ctrlTabSwitching && isControlKeyRelease(input)) {
|
||||
if (ctrlTabSwitching && isRecentTabSwitcherCommitRelease(input)) {
|
||||
event.preventDefault()
|
||||
ctrlTabSwitching = false
|
||||
const renderer = resolveRenderer(browserTabId)
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
applyRefreshedToken,
|
||||
isOauthTokenExpiring,
|
||||
parseClaudeOauthBlob,
|
||||
readRefreshToken,
|
||||
refreshClaudeOauthCredentials
|
||||
} from './oauth-refresh'
|
||||
|
||||
const { netFetchMock } = vi.hoisted(() => ({
|
||||
netFetchMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
net: { fetch: netFetchMock },
|
||||
session: { defaultSession: {} }
|
||||
}))
|
||||
|
||||
vi.mock('../network/proxy-settings', () => ({
|
||||
ensureElectronProxyFromEnvironment: vi.fn().mockResolvedValue({ source: 'none' })
|
||||
}))
|
||||
|
||||
const NOW = 1_700_000_000_000
|
||||
|
||||
function credentials(overrides: Record<string, unknown> = {}): string {
|
||||
return JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: 'old-access',
|
||||
refreshToken: 'old-refresh',
|
||||
expiresAt: NOW + 60 * 60 * 1000,
|
||||
scopes: ['user:inference', 'user:profile'],
|
||||
...overrides
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('parseClaudeOauthBlob', () => {
|
||||
it('returns the oauth block', () => {
|
||||
expect(parseClaudeOauthBlob(credentials())?.accessToken).toBe('old-access')
|
||||
})
|
||||
|
||||
it('returns null for non-JSON or missing block', () => {
|
||||
expect(parseClaudeOauthBlob('not json')).toBeNull()
|
||||
expect(parseClaudeOauthBlob('{}')).toBeNull()
|
||||
expect(parseClaudeOauthBlob('{"claudeAiOauth":[]}')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('readRefreshToken', () => {
|
||||
it('reads a present token', () => {
|
||||
expect(readRefreshToken(credentials())).toBe('old-refresh')
|
||||
})
|
||||
|
||||
it('returns null for blank or missing tokens', () => {
|
||||
expect(readRefreshToken(credentials({ refreshToken: ' ' }))).toBeNull()
|
||||
expect(readRefreshToken(credentials({ refreshToken: undefined }))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isOauthTokenExpiring', () => {
|
||||
it('is false when well within validity', () => {
|
||||
expect(isOauthTokenExpiring(credentials(), NOW)).toBe(false)
|
||||
})
|
||||
|
||||
it('is true within the 5-minute buffer', () => {
|
||||
expect(isOauthTokenExpiring(credentials({ expiresAt: NOW + 60 * 1000 }), NOW)).toBe(true)
|
||||
})
|
||||
|
||||
it('is true when already expired', () => {
|
||||
expect(isOauthTokenExpiring(credentials({ expiresAt: NOW - 1000 }), NOW)).toBe(true)
|
||||
})
|
||||
|
||||
it('treats missing/non-numeric expiry as expiring', () => {
|
||||
expect(isOauthTokenExpiring(credentials({ expiresAt: undefined }), NOW)).toBe(true)
|
||||
expect(isOauthTokenExpiring(credentials({ expiresAt: 'soon' }), NOW)).toBe(true)
|
||||
})
|
||||
|
||||
it('is false for credentials without an oauth block', () => {
|
||||
expect(isOauthTokenExpiring('{}', NOW)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyRefreshedToken', () => {
|
||||
it('rotates access + refresh token and recomputes expiry', () => {
|
||||
const updated = applyRefreshedToken(
|
||||
credentials(),
|
||||
{ access_token: 'new-access', expires_in: 3600, refresh_token: 'new-refresh' },
|
||||
NOW
|
||||
)
|
||||
const oauth = parseClaudeOauthBlob(updated!)!
|
||||
expect(oauth.accessToken).toBe('new-access')
|
||||
expect(oauth.refreshToken).toBe('new-refresh')
|
||||
expect(oauth.expiresAt).toBe(NOW + 3600 * 1000)
|
||||
})
|
||||
|
||||
it('keeps the existing refresh token when the server does not rotate it', () => {
|
||||
const updated = applyRefreshedToken(
|
||||
credentials(),
|
||||
{ access_token: 'new-access', expires_in: 3600 },
|
||||
NOW
|
||||
)
|
||||
expect(parseClaudeOauthBlob(updated!)!.refreshToken).toBe('old-refresh')
|
||||
})
|
||||
|
||||
it('preserves unrelated top-level fields', () => {
|
||||
const raw = JSON.stringify({
|
||||
claudeAiOauth: { accessToken: 'a', refreshToken: 'r' },
|
||||
somethingElse: { keep: true }
|
||||
})
|
||||
const updated = applyRefreshedToken(raw, { access_token: 'b' }, NOW)
|
||||
expect(JSON.parse(updated!).somethingElse).toEqual({ keep: true })
|
||||
})
|
||||
|
||||
it('splits scope string into scopes array', () => {
|
||||
const updated = applyRefreshedToken(
|
||||
credentials(),
|
||||
{ access_token: 'b', scope: 'user:inference user:profile' },
|
||||
NOW
|
||||
)
|
||||
expect(parseClaudeOauthBlob(updated!)!.scopes).toEqual(['user:inference', 'user:profile'])
|
||||
})
|
||||
|
||||
it('returns null when the response lacks an access token', () => {
|
||||
expect(applyRefreshedToken(credentials(), {}, NOW)).toBeNull()
|
||||
expect(applyRefreshedToken('not json', { access_token: 'b' }, NOW)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('refreshClaudeOauthCredentials', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns null without a refresh token (no network call)', async () => {
|
||||
const result = await refreshClaudeOauthCredentials(
|
||||
credentials({ refreshToken: undefined }),
|
||||
NOW
|
||||
)
|
||||
expect(result).toBeNull()
|
||||
expect(netFetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('posts a form-urlencoded refresh grant and persists the rotation', async () => {
|
||||
netFetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
access_token: 'fresh-access',
|
||||
expires_in: 3600,
|
||||
refresh_token: 'fresh-refresh'
|
||||
})
|
||||
})
|
||||
|
||||
const result = await refreshClaudeOauthCredentials(credentials(), NOW)
|
||||
|
||||
expect(netFetchMock).toHaveBeenCalledTimes(1)
|
||||
const [url, init] = netFetchMock.mock.calls[0]
|
||||
expect(url).toBe('https://platform.claude.com/v1/oauth/token')
|
||||
expect(init.method).toBe('POST')
|
||||
expect(init.headers['Content-Type']).toBe('application/x-www-form-urlencoded')
|
||||
const body = new URLSearchParams(init.body)
|
||||
expect(body.get('grant_type')).toBe('refresh_token')
|
||||
expect(body.get('refresh_token')).toBe('old-refresh')
|
||||
expect(body.get('client_id')).toBe('9d1c250a-e61b-44d9-88ed-5944d1962f5e')
|
||||
|
||||
const oauth = parseClaudeOauthBlob(result!)!
|
||||
expect(oauth.accessToken).toBe('fresh-access')
|
||||
expect(oauth.refreshToken).toBe('fresh-refresh')
|
||||
})
|
||||
|
||||
it('returns null on a non-ok response and logs the status for diagnosability', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
netFetchMock.mockResolvedValue({ ok: false, status: 429, json: async () => ({}) })
|
||||
expect(await refreshClaudeOauthCredentials(credentials(), NOW)).toBeNull()
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('429'))
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('returns null when the request throws (never rejects)', async () => {
|
||||
netFetchMock.mockRejectedValue(new Error('network down'))
|
||||
await expect(refreshClaudeOauthCredentials(credentials(), NOW)).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,174 @@
|
||||
import { net, session } from 'electron'
|
||||
import { ensureElectronProxyFromEnvironment } from '../network/proxy-settings'
|
||||
|
||||
// Why: the OAuth client id and token endpoint are the public Claude Code
|
||||
// values, verified against the installed `claude` binary (2.1.177) and the
|
||||
// claude-swap reference tool. Orca owns the refresh so a single-use refresh
|
||||
// token is rotated and persisted atomically, instead of being scraped back
|
||||
// after the CLI rotates it (the lossy path that strands stale tokens).
|
||||
const OAUTH_TOKEN_URL = 'https://platform.claude.com/v1/oauth/token'
|
||||
const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'
|
||||
|
||||
// Refresh slightly ahead of expiry so a token doesn't expire mid-launch. The
|
||||
// CLI uses the same 5-minute skew for its own refresh decision.
|
||||
const OAUTH_EXPIRY_BUFFER_MS = 5 * 60 * 1000
|
||||
const REFRESH_TIMEOUT_MS = 10_000
|
||||
|
||||
type ClaudeOauthBlob = {
|
||||
accessToken?: unknown
|
||||
refreshToken?: unknown
|
||||
expiresAt?: unknown
|
||||
scopes?: unknown
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type ClaudeCredentials = {
|
||||
claudeAiOauth?: ClaudeOauthBlob
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type TokenEndpointResponse = {
|
||||
access_token?: unknown
|
||||
expires_in?: unknown
|
||||
refresh_token?: unknown
|
||||
scope?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the `claudeAiOauth` object from a credentials JSON string.
|
||||
* Returns null when the string is not parseable or lacks the OAuth block.
|
||||
*/
|
||||
export function parseClaudeOauthBlob(credentialsJson: string): ClaudeOauthBlob | null {
|
||||
try {
|
||||
const parsed = JSON.parse(credentialsJson) as ClaudeCredentials
|
||||
const oauth = parsed?.claudeAiOauth
|
||||
return oauth && typeof oauth === 'object' && !Array.isArray(oauth) ? oauth : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Read a stored refresh token, or null when absent/blank. */
|
||||
export function readRefreshToken(credentialsJson: string): string | null {
|
||||
const oauth = parseClaudeOauthBlob(credentialsJson)
|
||||
const token = oauth?.refreshToken
|
||||
return typeof token === 'string' && token.trim() !== '' ? token.trim() : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the stored access token is expired or within the refresh buffer.
|
||||
*
|
||||
* A missing/non-numeric `expiresAt` is treated as "needs refresh" so a blob
|
||||
* with no usable expiry metadata still gets a proactive refresh attempt rather
|
||||
* than being trusted indefinitely. `now` is injectable for tests.
|
||||
*/
|
||||
export function isOauthTokenExpiring(credentialsJson: string, now: number = Date.now()): boolean {
|
||||
const oauth = parseClaudeOauthBlob(credentialsJson)
|
||||
if (!oauth) {
|
||||
return false
|
||||
}
|
||||
const expiresAt = oauth.expiresAt
|
||||
if (typeof expiresAt !== 'number' || !Number.isFinite(expiresAt)) {
|
||||
return true
|
||||
}
|
||||
return now + OAUTH_EXPIRY_BUFFER_MS >= expiresAt
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a token-endpoint response into the stored credentials, returning the
|
||||
* updated credentials JSON. Preserves every field the caller already had
|
||||
* (including the refresh token when the server does not rotate it) and only
|
||||
* overwrites what the response provides. Returns null on malformed input.
|
||||
*/
|
||||
export function applyRefreshedToken(
|
||||
credentialsJson: string,
|
||||
response: TokenEndpointResponse,
|
||||
now: number = Date.now()
|
||||
): string | null {
|
||||
let parsed: ClaudeCredentials
|
||||
try {
|
||||
parsed = JSON.parse(credentialsJson) as ClaudeCredentials
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const accessToken = response.access_token
|
||||
if (typeof accessToken !== 'string' || accessToken.trim() === '') {
|
||||
return null
|
||||
}
|
||||
const oauth: ClaudeOauthBlob = { ...parsed.claudeAiOauth }
|
||||
oauth.accessToken = accessToken
|
||||
if (typeof response.expires_in === 'number' && Number.isFinite(response.expires_in)) {
|
||||
oauth.expiresAt = now + response.expires_in * 1000
|
||||
}
|
||||
// Rotation: keep the existing refresh token unless the server issued a new
|
||||
// one. Single-use refresh tokens make persisting the rotated value the whole
|
||||
// point of owning refresh.
|
||||
if (typeof response.refresh_token === 'string' && response.refresh_token.trim() !== '') {
|
||||
oauth.refreshToken = response.refresh_token
|
||||
}
|
||||
if (typeof response.scope === 'string' && response.scope.trim() !== '') {
|
||||
oauth.scopes = response.scope.split(' ')
|
||||
}
|
||||
parsed.claudeAiOauth = oauth
|
||||
return JSON.stringify(parsed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the OAuth token for a stored credentials blob.
|
||||
*
|
||||
* Returns the updated credentials JSON (with the rotated refresh token and new
|
||||
* access token) on success, or null on any failure. Never throws — callers
|
||||
* treat null as "keep the existing credentials", so a transient network error
|
||||
* is never worse than today's behavior.
|
||||
*/
|
||||
export async function refreshClaudeOauthCredentials(
|
||||
credentialsJson: string,
|
||||
now: number = Date.now()
|
||||
): Promise<string | null> {
|
||||
const refreshToken = readRefreshToken(credentialsJson)
|
||||
if (!refreshToken) {
|
||||
return null
|
||||
}
|
||||
|
||||
await ensureElectronProxyFromEnvironment({
|
||||
proxySession: session.defaultSession,
|
||||
probeUrl: OAUTH_TOKEN_URL
|
||||
}).catch(() => {})
|
||||
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), REFRESH_TIMEOUT_MS)
|
||||
try {
|
||||
// Why: the `claude` CLI posts grant_type=refresh_token as
|
||||
// application/x-www-form-urlencoded with the public client id. net.fetch
|
||||
// routes through Chromium's stack so the env proxy bridge above applies.
|
||||
const res = await net.fetch(OAUTH_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_id: OAUTH_CLIENT_ID
|
||||
}).toString(),
|
||||
signal: controller.signal
|
||||
})
|
||||
if (!res.ok) {
|
||||
// Why: surface the status (never the token) so a throttle (429) or a
|
||||
// dead refresh token (400/401 invalid_grant) is diagnosable in the
|
||||
// field, instead of a silent null that looks identical to success.
|
||||
// Callers keep the existing credentials on null — a transient 429 just
|
||||
// means the still-valid token is reused until the next attempt.
|
||||
console.warn(`[claude-oauth-refresh] token endpoint returned ${res.status}`)
|
||||
return null
|
||||
}
|
||||
const data = (await res.json()) as TokenEndpointResponse
|
||||
return applyRefreshedToken(credentialsJson, data, now)
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[claude-oauth-refresh] token refresh request failed:',
|
||||
error instanceof Error ? error.message : error
|
||||
)
|
||||
return null
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { getDefaultSettings } from '../../shared/constants'
|
||||
import type { ClaudeManagedAccount, GlobalSettings } from '../../shared/types'
|
||||
import { isOauthTokenExpiring, refreshClaudeOauthCredentials } from './oauth-refresh'
|
||||
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
const testState = {
|
||||
@@ -43,6 +44,15 @@ vi.mock('electron', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
// Why: these tests exercise materialize/read-back/snapshot logic, not the
|
||||
// network OAuth refresh (covered by oauth-refresh.test.ts). Default the token
|
||||
// to "not expiring" so the proactive switch-in refresh never fires here and
|
||||
// existing expectations hold; individual tests can override these mocks.
|
||||
vi.mock('./oauth-refresh', () => ({
|
||||
isOauthTokenExpiring: vi.fn(() => false),
|
||||
refreshClaudeOauthCredentials: vi.fn(async () => null)
|
||||
}))
|
||||
|
||||
vi.mock('node:os', async () => {
|
||||
const actual = await vi.importActual<typeof import('node:os')>('node:os') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
|
||||
return {
|
||||
@@ -2872,7 +2882,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 +2922,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 () => {
|
||||
@@ -3553,4 +3564,181 @@ describe('ClaudeRuntimeAuthService', () => {
|
||||
expect(readManagedCredentialsForTest('account-1', managedAuthPath)).toBe(reauthedCredentials)
|
||||
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(reauthedCredentials)
|
||||
})
|
||||
|
||||
it('leaves host system-default credentials untouched before launch', async () => {
|
||||
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
|
||||
const expired = createClaudeCredentialsJson('system@example.com', 'system-expired', null, 1_000)
|
||||
writeFileSync(runtimeCredentialsPath, expired, 'utf-8')
|
||||
testState.scopedKeychainCredentials = expired
|
||||
testState.legacyKeychainCredentials = expired
|
||||
const settings = createSettings({
|
||||
activeClaudeManagedAccountId: null
|
||||
})
|
||||
const store = createStore(settings)
|
||||
|
||||
vi.mocked(isOauthTokenExpiring).mockReturnValue(true)
|
||||
vi.mocked(refreshClaudeOauthCredentials).mockResolvedValue(
|
||||
createClaudeCredentialsJson('system@example.com', 'system-refreshed')
|
||||
)
|
||||
|
||||
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
|
||||
const service = new ClaudeRuntimeAuthService(store as never)
|
||||
const preparation = await service.prepareForClaudeLaunch()
|
||||
|
||||
expect(isOauthTokenExpiring).not.toHaveBeenCalled()
|
||||
expect(refreshClaudeOauthCredentials).not.toHaveBeenCalled()
|
||||
expect(preparation.provenance).toBe('system')
|
||||
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(expired)
|
||||
expect(testState.scopedKeychainCredentials).toBe(expired)
|
||||
expect(testState.legacyKeychainCredentials).toBe(expired)
|
||||
|
||||
vi.mocked(isOauthTokenExpiring).mockReturnValue(false)
|
||||
vi.mocked(refreshClaudeOauthCredentials).mockResolvedValue(null)
|
||||
})
|
||||
|
||||
it('proactively refreshes and persists an expiring account on switch-in', async () => {
|
||||
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
|
||||
const account1Stale = createClaudeCredentialsJson('one@example.com', 'one-stale', null, 1_000)
|
||||
const account1Refreshed = createClaudeCredentialsJson(
|
||||
'one@example.com',
|
||||
'one-refreshed',
|
||||
null,
|
||||
9_999_999_999_999
|
||||
)
|
||||
const managedAuthPath1 = createManagedClaudeAuth(
|
||||
testState.userDataDir,
|
||||
'account-1',
|
||||
account1Stale
|
||||
)
|
||||
// Start on the system default (no active managed account), then switch in.
|
||||
const settings = createSettings({
|
||||
claudeManagedAccounts: [
|
||||
createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' })
|
||||
],
|
||||
activeClaudeManagedAccountId: null
|
||||
})
|
||||
const store = createStore(settings)
|
||||
|
||||
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
|
||||
const service = new ClaudeRuntimeAuthService(store as never)
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
// Now switch into account-1: token is expiring, so the service must refresh
|
||||
// and persist the rotation before materializing.
|
||||
vi.mocked(isOauthTokenExpiring).mockReturnValueOnce(true)
|
||||
vi.mocked(refreshClaudeOauthCredentials).mockResolvedValueOnce(account1Refreshed)
|
||||
store.updateSettings({ activeClaudeManagedAccountId: 'account-1' })
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
expect(refreshClaudeOauthCredentials).toHaveBeenCalledWith(account1Stale)
|
||||
expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(account1Refreshed)
|
||||
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(account1Refreshed)
|
||||
})
|
||||
|
||||
it('refreshes the active account with an expired token when no Claude PTY is live', async () => {
|
||||
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
|
||||
const expired = createClaudeCredentialsJson('one@example.com', 'one-expired', null, 1_000)
|
||||
const refreshedCreds = createClaudeCredentialsJson(
|
||||
'one@example.com',
|
||||
'one-refreshed',
|
||||
null,
|
||||
9_999_999_999_999
|
||||
)
|
||||
const managedAuthPath1 = createManagedClaudeAuth(testState.userDataDir, 'account-1', expired)
|
||||
// account-1 is ALREADY the active account (seeded), so this is a re-sync of
|
||||
// the active account, not a switch-in — the path that was previously missed.
|
||||
const settings = createSettings({
|
||||
claudeManagedAccounts: [
|
||||
createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' })
|
||||
],
|
||||
activeClaudeManagedAccountId: 'account-1'
|
||||
})
|
||||
const store = createStore(settings)
|
||||
|
||||
vi.mocked(isOauthTokenExpiring).mockReturnValue(true)
|
||||
vi.mocked(refreshClaudeOauthCredentials).mockResolvedValue(refreshedCreds)
|
||||
|
||||
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
|
||||
const service = new ClaudeRuntimeAuthService(store as never)
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
expect(refreshClaudeOauthCredentials).toHaveBeenCalled()
|
||||
expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(refreshedCreds)
|
||||
expect(readFileSync(runtimeCredentialsPath, 'utf-8')).toBe(refreshedCreds)
|
||||
|
||||
vi.mocked(isOauthTokenExpiring).mockReturnValue(false)
|
||||
vi.mocked(refreshClaudeOauthCredentials).mockResolvedValue(null)
|
||||
})
|
||||
|
||||
it('does not refresh the active account while a Claude PTY is live', async () => {
|
||||
const expired = createClaudeCredentialsJson('one@example.com', 'one-expired', null, 1_000)
|
||||
const managedAuthPath1 = createManagedClaudeAuth(testState.userDataDir, 'account-1', expired)
|
||||
const settings = createSettings({
|
||||
claudeManagedAccounts: [
|
||||
createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' })
|
||||
],
|
||||
activeClaudeManagedAccountId: 'account-1'
|
||||
})
|
||||
const store = createStore(settings)
|
||||
|
||||
vi.mocked(isOauthTokenExpiring).mockReturnValue(true)
|
||||
vi.mocked(refreshClaudeOauthCredentials).mockResolvedValue(
|
||||
createClaudeCredentialsJson('one@example.com', 'should-not-be-used', null, 9_999_999_999_999)
|
||||
)
|
||||
|
||||
const { markClaudePtySpawned, markClaudePtyExited } = await import('./live-pty-gate')
|
||||
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
|
||||
const service = new ClaudeRuntimeAuthService(store as never)
|
||||
|
||||
markClaudePtySpawned('pty-live-1')
|
||||
try {
|
||||
await service.syncForCurrentSelection()
|
||||
// A live Claude owns the credentials; refreshing here would race its
|
||||
// rotation, so the proactive refresh must be skipped entirely.
|
||||
expect(refreshClaudeOauthCredentials).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
markClaudePtyExited('pty-live-1')
|
||||
vi.mocked(isOauthTokenExpiring).mockReturnValue(false)
|
||||
vi.mocked(refreshClaudeOauthCredentials).mockResolvedValue(null)
|
||||
}
|
||||
})
|
||||
|
||||
it('adopts a rotated-refresh-token runtime credential on cold-start read-back', async () => {
|
||||
const runtimeCredentialsPath = join(testState.fakeHomeDir, '.claude', '.credentials.json')
|
||||
// Same expiry on both sides (cold start), but the runtime refresh token has
|
||||
// rotated — proof the CLI refreshed. Must be read back into managed storage.
|
||||
const managedCredentials = createClaudeCredentialsJson(
|
||||
'one@example.com',
|
||||
'one-old',
|
||||
null,
|
||||
3_000
|
||||
)
|
||||
const runtimeRotated = `${JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
email: 'one@example.com',
|
||||
accessToken: 'one-rotated',
|
||||
refreshToken: 'one-rotated-refresh',
|
||||
expiresAt: 3_000
|
||||
}
|
||||
})}\n`
|
||||
writeFileSync(runtimeCredentialsPath, runtimeRotated, 'utf-8')
|
||||
const managedAuthPath1 = createManagedClaudeAuth(
|
||||
testState.userDataDir,
|
||||
'account-1',
|
||||
managedCredentials
|
||||
)
|
||||
const settings = createSettings({
|
||||
claudeManagedAccounts: [
|
||||
createClaudeAccount('account-1', managedAuthPath1, { email: 'one@example.com' })
|
||||
],
|
||||
activeClaudeManagedAccountId: 'account-1'
|
||||
})
|
||||
const store = createStore(settings)
|
||||
|
||||
const { ClaudeRuntimeAuthService } = await import('./runtime-auth-service')
|
||||
const service = new ClaudeRuntimeAuthService(store as never)
|
||||
await service.syncForCurrentSelection()
|
||||
|
||||
expect(readManagedCredentialsForTest('account-1', managedAuthPath1)).toBe(runtimeRotated)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,6 +18,7 @@ import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import { getDefaultWslDistro, getWslHome, toWindowsWslPath } from '../wsl'
|
||||
import { buildEncodedWslBashCommand } from '../wsl-bash-command'
|
||||
import { hasLiveClaudePtys } from './live-pty-gate'
|
||||
import { isOauthTokenExpiring, refreshClaudeOauthCredentials } from './oauth-refresh'
|
||||
import { ClaudeRuntimePathResolver } from './runtime-paths'
|
||||
import {
|
||||
deleteActiveClaudeKeychainCredentialsStrict,
|
||||
@@ -211,8 +212,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'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -398,6 +403,25 @@ export class ClaudeRuntimeAuthService {
|
||||
if (this.lastSyncedAccountId !== activeAccount.id) {
|
||||
this.skipNextReadBackForAccountId = null
|
||||
}
|
||||
|
||||
// Why: own the OAuth refresh whenever no live `claude` owns these
|
||||
// credentials — both switching into an account and re-syncing the active
|
||||
// account with an expired token. A single-use refresh token is rotated and
|
||||
// persisted to managed storage atomically before we materialize it, so the
|
||||
// runtime never gets a stale token that fails with invalid_grant. Skipped
|
||||
// entirely while a Claude PTY is live: that process owns the credentials
|
||||
// and refreshing here would race its own rotation (double-rotation
|
||||
// invalidates one copy) — the read-back above preserves its refresh instead.
|
||||
if (!hasLiveClaudePtys()) {
|
||||
const refreshed = await this.refreshManagedAccountTokenIfNeeded(
|
||||
activeAccount,
|
||||
credentialsJson
|
||||
)
|
||||
if (refreshed) {
|
||||
credentialsJson = refreshed
|
||||
}
|
||||
}
|
||||
|
||||
const paths = this.pathResolver.getRuntimePaths()
|
||||
this.writeRuntimeCredentials(credentialsJson)
|
||||
if (process.platform === 'darwin') {
|
||||
@@ -481,15 +505,28 @@ export class ClaudeRuntimeAuthService {
|
||||
continue
|
||||
}
|
||||
// Why: on cold app start we cannot tell whether matching runtime
|
||||
// credentials are a fresh CLI refresh or stale state unless token
|
||||
// metadata proves runtime is newer than managed storage.
|
||||
// credentials are a fresh CLI refresh or stale state. Adopt when the
|
||||
// token expiry proves runtime is newer, OR the refresh token rotated
|
||||
// and runtime is not provably older. A rotated refresh token with
|
||||
// equal/missing expiry is a genuine CLI refresh we'd otherwise drop
|
||||
// (stranding a stale managed token); but if expiry proves runtime is
|
||||
// older, managed already holds the newer token (e.g. a prior read-back
|
||||
// or proactive refresh), so reject it.
|
||||
if (this.lastWrittenCredentialsJson === null) {
|
||||
if (
|
||||
!this.runtimeCredentialsAreFresher(
|
||||
const fresher = this.runtimeCredentialsAreFresher(
|
||||
runtimeContents.credentialsJson,
|
||||
match.managedCredentialsJson
|
||||
)
|
||||
const refreshTokenRotated =
|
||||
this.compareRefreshTokens(
|
||||
runtimeContents.credentialsJson,
|
||||
match.managedCredentialsJson
|
||||
)
|
||||
) {
|
||||
) === 'different'
|
||||
const older = this.runtimeCredentialsAreOlder(
|
||||
runtimeContents.credentialsJson,
|
||||
match.managedCredentialsJson
|
||||
)
|
||||
if (!fresher && !(refreshTokenRotated && !older)) {
|
||||
continue
|
||||
}
|
||||
} else if (
|
||||
@@ -1000,6 +1037,37 @@ export class ClaudeRuntimeAuthService {
|
||||
writeClaudeManagedAuthFile(managedAuthPath, '.credentials.json', credentialsJson)
|
||||
}
|
||||
|
||||
/**
|
||||
* Proactively refresh an account's OAuth token and persist the rotation to
|
||||
* managed storage. Returns the refreshed credentials JSON when a rotation was
|
||||
* stored, or null when no refresh happened (token still valid, no refresh
|
||||
* token, or the network call failed — in which case the caller keeps the
|
||||
* existing credentials, never worse than before).
|
||||
*
|
||||
* Caller guarantees this account is not the live/active one and runs inside
|
||||
* the serialized mutation queue, so a single-use refresh token can't be
|
||||
* rotated concurrently.
|
||||
*/
|
||||
private async refreshManagedAccountTokenIfNeeded(
|
||||
account: ClaudeManagedAccount,
|
||||
credentialsJson: string
|
||||
): Promise<string | null> {
|
||||
if (!isOauthTokenExpiring(credentialsJson)) {
|
||||
return null
|
||||
}
|
||||
const refreshed = await refreshClaudeOauthCredentials(credentialsJson)
|
||||
if (!refreshed || !this.isValidCredentialsJsonObject(refreshed)) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
await this.writeManagedCredentials(account, refreshed)
|
||||
} catch (error) {
|
||||
console.warn('[claude-runtime-auth] Failed to persist refreshed Claude token:', error)
|
||||
return null
|
||||
}
|
||||
return refreshed
|
||||
}
|
||||
|
||||
private readManagedOauthAccount(account: ClaudeManagedAccount): unknown {
|
||||
const managedAuthPath = this.getOwnedManagedAuthPath(account)
|
||||
if (!managedAuthPath) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -135,6 +135,10 @@ export class CodexRuntimeHomeService {
|
||||
return this.getRuntimeHomePath()
|
||||
}
|
||||
|
||||
getHostRuntimeHomePath(): string {
|
||||
return this.getRuntimeHomePath()
|
||||
}
|
||||
|
||||
private getWslSystemCodexHomePath(target: CodexAccountSelectionTarget): string | null {
|
||||
if (process.platform !== 'win32') {
|
||||
return null
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Dirent, Stats } from 'node:fs'
|
||||
import type * as FsPromises from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
|
||||
const { readdirMock, statMock } = vi.hoisted(() => ({
|
||||
readdirMock: vi.fn<(dirPath: string) => Promise<Dirent[]>>(),
|
||||
statMock: vi.fn<(filePath: string) => Promise<Stats>>()
|
||||
}))
|
||||
const { getLegacyCopiedCodexSessionBridgeScanPreferenceMock, readdirMock, statMock } = vi.hoisted(
|
||||
() => ({
|
||||
getLegacyCopiedCodexSessionBridgeScanPreferenceMock: vi.fn(),
|
||||
readdirMock: vi.fn<(dirPath: string) => Promise<Dirent[]>>(),
|
||||
statMock: vi.fn<(filePath: string) => Promise<Stats>>()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('fs/promises', async () => {
|
||||
const actual = await vi.importActual<typeof FsPromises>('fs/promises')
|
||||
@@ -29,7 +32,8 @@ vi.mock('../codex/codex-home-paths', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../codex/codex-session-bridge', () => ({
|
||||
getLegacyCopiedCodexSessionBridgeScanPreference: () => null
|
||||
getLegacyCopiedCodexSessionBridgeScanPreference:
|
||||
getLegacyCopiedCodexSessionBridgeScanPreferenceMock
|
||||
}))
|
||||
|
||||
function dirent(name: string, kind: 'directory' | 'file'): Dirent {
|
||||
@@ -45,6 +49,13 @@ const largeSessionEntries = Array.from({ length: FILE_COUNT }, (_, index) =>
|
||||
)
|
||||
|
||||
describe('listCodexSessionFiles large directories', () => {
|
||||
beforeEach(() => {
|
||||
getLegacyCopiedCodexSessionBridgeScanPreferenceMock.mockReset()
|
||||
getLegacyCopiedCodexSessionBridgeScanPreferenceMock.mockReturnValue(null)
|
||||
readdirMock.mockReset()
|
||||
statMock.mockReset()
|
||||
})
|
||||
|
||||
it('keeps nested session scans past the JavaScript spread-argument limit', async () => {
|
||||
readdirMock.mockImplementation(async (dirPath) => {
|
||||
if (dirPath === RUNTIME_SESSIONS_ROOT) {
|
||||
@@ -69,5 +80,6 @@ describe('listCodexSessionFiles large directories', () => {
|
||||
const { listCodexSessionFiles } = await import('./scanner')
|
||||
|
||||
await expect(listCodexSessionFiles()).resolves.toHaveLength(FILE_COUNT)
|
||||
expect(getLegacyCopiedCodexSessionBridgeScanPreferenceMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable max-lines -- Why: Codex discovery, incremental parsing, attribution, and aggregation all depend on the same event-normalization rules. Keeping them together makes the duplicate-snapshot logic easier to audit when usage totals look wrong. */
|
||||
import { basename, join, win32, posix } from 'path'
|
||||
import { createReadStream } from 'fs'
|
||||
import { createReadStream, existsSync } from 'fs'
|
||||
import { realpath, readdir, stat } from 'fs/promises'
|
||||
import { createInterface } from 'readline'
|
||||
import type { Repo } from '../../shared/types'
|
||||
@@ -55,6 +55,7 @@ type CodexUsageDeltaResolution =
|
||||
| { kind: 'baseline'; nextTotals: CodexUsageRawUsage }
|
||||
|
||||
const YIELD_EVERY_FILES = 10
|
||||
const YIELD_EVERY_DISCOVERY_ENTRIES = 100
|
||||
|
||||
function ensureNumber(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||
@@ -88,17 +89,24 @@ async function canonicalizePath(pathValue: string): Promise<string> {
|
||||
}
|
||||
|
||||
async function yieldToEventLoop(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
}
|
||||
|
||||
async function walkJsonlFiles(dirPath: string): Promise<string[]> {
|
||||
async function walkJsonlFiles(
|
||||
dirPath: string,
|
||||
progress: { entriesVisited: number } = { entriesVisited: 0 }
|
||||
): Promise<string[]> {
|
||||
const entries = await readdir(dirPath, { withFileTypes: true })
|
||||
const files: string[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
progress.entriesVisited += 1
|
||||
if (progress.entriesVisited % YIELD_EVERY_DISCOVERY_ENTRIES === 0) {
|
||||
await yieldToEventLoop()
|
||||
}
|
||||
const fullPath = join(dirPath, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
appendDiscoveredFiles(files, await walkJsonlFiles(fullPath))
|
||||
appendDiscoveredFiles(files, await walkJsonlFiles(fullPath, progress))
|
||||
continue
|
||||
}
|
||||
if (entry.isFile() && entry.name.endsWith('.jsonl')) {
|
||||
@@ -132,6 +140,10 @@ export function getCodexSessionDirectories(): string[] {
|
||||
)
|
||||
}
|
||||
|
||||
function hasLegacyCopiedSessionBridgeMarkers(): boolean {
|
||||
return existsSync(join(getOrcaManagedCodexHomePath(), '.orca-session-copies'))
|
||||
}
|
||||
|
||||
export async function listCodexSessionFiles(): Promise<string[]> {
|
||||
const files: string[] = []
|
||||
for (const dirPath of getCodexSessionDirectories()) {
|
||||
@@ -141,29 +153,37 @@ export async function listCodexSessionFiles(): Promise<string[]> {
|
||||
// Missing or unreadable history in one home should not hide the other.
|
||||
}
|
||||
}
|
||||
return dedupeCodexSessionFileAliases(files)
|
||||
return dedupeCodexSessionFileAliases(files, hasLegacyCopiedSessionBridgeMarkers())
|
||||
}
|
||||
|
||||
async function dedupeCodexSessionFileAliases(files: string[]): Promise<string[]> {
|
||||
async function dedupeCodexSessionFileAliases(
|
||||
files: string[],
|
||||
hasLegacyBridgeMarkers: boolean
|
||||
): Promise<string[]> {
|
||||
const excludedAliases = new Set<string>()
|
||||
for (const filePath of files) {
|
||||
const legacyCopyBridge = getLegacyCopiedCodexSessionBridgeScanPreference(filePath)
|
||||
if (!legacyCopyBridge) {
|
||||
continue
|
||||
}
|
||||
if (legacyCopyBridge.sourceSkipBytes !== null) {
|
||||
continue
|
||||
}
|
||||
excludedAliases.add(
|
||||
await getPhysicalFileAliasKey(
|
||||
legacyCopyBridge.preferManagedCopy ? legacyCopyBridge.sourcePath : filePath
|
||||
if (hasLegacyBridgeMarkers) {
|
||||
for (const [index, filePath] of files.entries()) {
|
||||
const legacyCopyBridge = getLegacyCopiedCodexSessionBridgeScanPreference(filePath)
|
||||
if ((index + 1) % YIELD_EVERY_DISCOVERY_ENTRIES === 0) {
|
||||
await yieldToEventLoop()
|
||||
}
|
||||
if (!legacyCopyBridge) {
|
||||
continue
|
||||
}
|
||||
if (legacyCopyBridge.sourceSkipBytes !== null) {
|
||||
continue
|
||||
}
|
||||
excludedAliases.add(
|
||||
await getPhysicalFileAliasKey(
|
||||
legacyCopyBridge.preferManagedCopy ? legacyCopyBridge.sourcePath : filePath
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const seenAliases = new Set<string>()
|
||||
const uniqueFiles: string[] = []
|
||||
for (const filePath of [...new Set(files)].sort()) {
|
||||
for (const [index, filePath] of [...new Set(files)].sort().entries()) {
|
||||
const aliasKey = await getCodexSessionFileAliasKey(filePath)
|
||||
if (excludedAliases.has(aliasKey)) {
|
||||
continue
|
||||
@@ -173,6 +193,9 @@ async function dedupeCodexSessionFileAliases(files: string[]): Promise<string[]>
|
||||
}
|
||||
seenAliases.add(aliasKey)
|
||||
uniqueFiles.push(filePath)
|
||||
if ((index + 1) % YIELD_EVERY_DISCOVERY_ENTRIES === 0) {
|
||||
await yieldToEventLoop()
|
||||
}
|
||||
}
|
||||
return uniqueFiles
|
||||
}
|
||||
@@ -191,8 +214,14 @@ async function getPhysicalFileAliasKey(filePath: string): Promise<string> {
|
||||
return `path:${await canonicalizePath(filePath)}`
|
||||
}
|
||||
|
||||
function getLegacySourceSkipBytesByPath(files: string[]): Map<string, number> {
|
||||
function getLegacySourceSkipBytesByPath(
|
||||
files: string[],
|
||||
hasLegacyBridgeMarkers = hasLegacyCopiedSessionBridgeMarkers()
|
||||
): Map<string, number> {
|
||||
const sourceSkipBytesByPath = new Map<string, number>()
|
||||
if (!hasLegacyBridgeMarkers) {
|
||||
return sourceSkipBytesByPath
|
||||
}
|
||||
for (const filePath of files) {
|
||||
const legacyCopyBridge = getLegacyCopiedCodexSessionBridgeScanPreference(filePath)
|
||||
if (!legacyCopyBridge || legacyCopyBridge.sourceSkipBytes === null) {
|
||||
@@ -734,7 +763,7 @@ function mergeSessions(
|
||||
for (const session of sessions) {
|
||||
const existing = target.get(session.sessionId)
|
||||
if (!existing) {
|
||||
target.set(session.sessionId, structuredClone(session))
|
||||
target.set(session.sessionId, cloneSessionForMerge(session))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -809,6 +838,15 @@ function mergeSessions(
|
||||
}
|
||||
}
|
||||
|
||||
function cloneSessionForMerge(session: CodexUsageSession): CodexUsageSession {
|
||||
return {
|
||||
...session,
|
||||
locationBreakdown: session.locationBreakdown.map((entry) => ({ ...entry })),
|
||||
modelBreakdown: session.modelBreakdown.map((entry) => ({ ...entry })),
|
||||
locationModelBreakdown: session.locationModelBreakdown.map((entry) => ({ ...entry }))
|
||||
}
|
||||
}
|
||||
|
||||
function mergeDailyAggregates(
|
||||
target: Map<string, CodexUsageDailyAggregate>,
|
||||
dailyAggregates: CodexUsageDailyAggregate[]
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
/* eslint-disable max-lines */
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { CodexUsagePersistedState } from './types'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as Fs from 'fs'
|
||||
import type {
|
||||
CodexUsageDailyAggregate,
|
||||
CodexUsagePersistedFile,
|
||||
CodexUsagePersistedState,
|
||||
CodexUsageSession
|
||||
} from './types'
|
||||
|
||||
const { getPathMock } = vi.hoisted(() => ({
|
||||
getPathMock: vi.fn(() => '/tmp/orca-test-userdata')
|
||||
@@ -12,11 +21,54 @@ vi.mock('electron', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
import { CodexUsageStore, normalizePersistedState } from './store'
|
||||
vi.mock('fs', async () => {
|
||||
const actual = await vi.importActual<typeof Fs>('fs')
|
||||
return {
|
||||
...actual,
|
||||
writeFileSync: vi.fn(actual.writeFileSync)
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./scanner', () => ({
|
||||
createWorktreeRefs: vi.fn(() => []),
|
||||
scanCodexUsageFiles: vi.fn()
|
||||
}))
|
||||
|
||||
import { CodexUsageStore, initCodexUsagePath, normalizePersistedState } from './store'
|
||||
import { scanCodexUsageFiles } from './scanner'
|
||||
|
||||
type ScanResult = {
|
||||
processedFiles: CodexUsagePersistedFile[]
|
||||
sessions: CodexUsageSession[]
|
||||
dailyAggregates: CodexUsageDailyAggregate[]
|
||||
}
|
||||
|
||||
function createDeferred<T>(): {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
reject: (reason?: unknown) => void
|
||||
} {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve
|
||||
reject = promiseReject
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
function createEmptyScanResult(): ScanResult {
|
||||
return {
|
||||
processedFiles: [],
|
||||
sessions: [],
|
||||
dailyAggregates: []
|
||||
}
|
||||
}
|
||||
|
||||
function createStoreWithState(state: Partial<CodexUsagePersistedState>): CodexUsageStore {
|
||||
const store = new CodexUsageStore({
|
||||
getRepos: () => [],
|
||||
getAllWorktreeMeta: () => ({}),
|
||||
getWorktreeMeta: () => undefined
|
||||
} as never)
|
||||
|
||||
@@ -39,11 +91,79 @@ function createStoreWithState(state: Partial<CodexUsagePersistedState>): CodexUs
|
||||
}
|
||||
|
||||
describe('CodexUsageStore', () => {
|
||||
let tempUserData: string
|
||||
|
||||
beforeEach(() => {
|
||||
tempUserData = mkdtempSync(join(tmpdir(), 'orca-codex-usage-store-'))
|
||||
getPathMock.mockReturnValue(tempUserData)
|
||||
initCodexUsagePath()
|
||||
vi.mocked(writeFileSync).mockClear()
|
||||
vi.mocked(scanCodexUsageFiles).mockReset()
|
||||
vi.mocked(scanCodexUsageFiles).mockResolvedValue(createEmptyScanResult())
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-04-10T12:00:00.000-04:00'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
rmSync(tempUserData, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('persists a successful refresh with one compact disk write', async () => {
|
||||
const store = createStoreWithState({
|
||||
schemaVersion: 3,
|
||||
scanState: {
|
||||
enabled: true,
|
||||
lastScanStartedAt: null,
|
||||
lastScanCompletedAt: null,
|
||||
lastScanError: null
|
||||
}
|
||||
})
|
||||
|
||||
await store.refresh(true)
|
||||
|
||||
expect(writeFileSync).toHaveBeenCalledTimes(1)
|
||||
const persistedJson = readFileSync(join(tempUserData, 'orca-codex-usage.json'), 'utf-8')
|
||||
expect(persistedJson).toBe(JSON.stringify(JSON.parse(persistedJson)))
|
||||
expect(persistedJson).not.toContain('\n')
|
||||
expect(JSON.parse(persistedJson).scanState).toMatchObject({
|
||||
enabled: true,
|
||||
lastScanStartedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(),
|
||||
lastScanCompletedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(),
|
||||
lastScanError: null
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps scan start visible in memory while scan-start persistence is skipped', async () => {
|
||||
const pendingScan = createDeferred<ScanResult>()
|
||||
vi.mocked(scanCodexUsageFiles).mockReturnValueOnce(pendingScan.promise)
|
||||
const store = createStoreWithState({
|
||||
schemaVersion: 3,
|
||||
scanState: {
|
||||
enabled: true,
|
||||
lastScanStartedAt: null,
|
||||
lastScanCompletedAt: null,
|
||||
lastScanError: 'previous failure'
|
||||
}
|
||||
})
|
||||
|
||||
const refreshPromise = store.refresh(true)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(store.getScanState()).toMatchObject({
|
||||
isScanning: true,
|
||||
lastScanStartedAt: new Date('2026-04-10T12:00:00.000-04:00').getTime(),
|
||||
lastScanError: null
|
||||
})
|
||||
expect(writeFileSync).not.toHaveBeenCalled()
|
||||
|
||||
pendingScan.resolve(createEmptyScanResult())
|
||||
await refreshPromise
|
||||
|
||||
expect(store.getScanState().isScanning).toBe(false)
|
||||
expect(writeFileSync).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reports no data for Orca scope when only non-Orca Codex usage exists', async () => {
|
||||
const store = createStoreWithState({
|
||||
sessions: [
|
||||
|
||||
@@ -358,7 +358,7 @@ export class CodexUsageStore {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
const tmpFile = `${usageFile}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
|
||||
writeFileSync(tmpFile, JSON.stringify(this.state, null, 2), 'utf-8')
|
||||
writeFileSync(tmpFile, JSON.stringify(this.state), 'utf-8')
|
||||
renameSync(tmpFile, usageFile)
|
||||
}
|
||||
|
||||
@@ -414,7 +414,7 @@ export class CodexUsageStore {
|
||||
|
||||
this.state.scanState.lastScanStartedAt = Date.now()
|
||||
this.state.scanState.lastScanError = null
|
||||
this.writeToDisk()
|
||||
// Why: start-only writes rewrite the full usage cache before scan results change.
|
||||
|
||||
this.scanPromise = (async () => {
|
||||
try {
|
||||
|
||||
@@ -70,7 +70,7 @@ describe('daemon bundle staleness', () => {
|
||||
{ mode: 0o600 }
|
||||
)
|
||||
|
||||
expect(isDaemonStaleForCurrentBundle(dir, socketPath, tokenPath, '1.2.3')).toBe(true)
|
||||
expect(await isDaemonStaleForCurrentBundle(dir, socketPath, tokenPath, '1.2.3')).toBe(true)
|
||||
} finally {
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
@@ -101,7 +101,7 @@ describe('daemon bundle staleness', () => {
|
||||
{ mode: 0o600 }
|
||||
)
|
||||
|
||||
expect(isDaemonStaleForCurrentBundle(dir, socketPath, tokenPath, '1.2.3')).toBe(false)
|
||||
expect(await isDaemonStaleForCurrentBundle(dir, socketPath, tokenPath, '1.2.3')).toBe(false)
|
||||
} finally {
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
@@ -131,7 +131,7 @@ describe('daemon bundle staleness', () => {
|
||||
{ mode: 0o600 }
|
||||
)
|
||||
|
||||
expect(isDaemonStaleForCurrentBundle(dir, socketPath, tokenPath, '1.2.3')).toBe(true)
|
||||
expect(await isDaemonStaleForCurrentBundle(dir, socketPath, tokenPath, '1.2.3')).toBe(true)
|
||||
} finally {
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
|
||||
@@ -42,7 +42,12 @@ describe('daemon health socket listener cleanup', () => {
|
||||
|
||||
const result = healthCheckDaemon(socketPath, tokenPath)
|
||||
socket.emit('connect')
|
||||
socket.emit('data', Buffer.from('{"type":"hello","ok":true}\n{"id":"health-1","ok":true}\n'))
|
||||
socket.emit(
|
||||
'data',
|
||||
Buffer.from(
|
||||
'{"type":"hello","ok":true}\n{"id":"health-1","ok":true}\n{"id":"health-2","ok":true}\n'
|
||||
)
|
||||
)
|
||||
|
||||
await expect(result).resolves.toBe(true)
|
||||
expect(socket.listenerCount('connect')).toBe(0)
|
||||
|
||||
@@ -74,15 +74,36 @@ describe('daemon health', () => {
|
||||
})
|
||||
|
||||
it('passes when a daemon answers ping', async () => {
|
||||
const ptySpawnHealthCheck = vi.fn(async () => {})
|
||||
const server = new DaemonServer({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
ptySpawnHealthCheck,
|
||||
spawnSubprocess: () => createMockSubprocess()
|
||||
})
|
||||
await server.start()
|
||||
|
||||
try {
|
||||
await expect(healthCheckDaemon(socketPath, tokenPath)).resolves.toBe(true)
|
||||
expect(ptySpawnHealthCheck).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
await server.shutdown()
|
||||
}
|
||||
})
|
||||
|
||||
it('fails when a protocol-healthy daemon cannot spawn PTYs', async () => {
|
||||
const server = new DaemonServer({
|
||||
socketPath,
|
||||
tokenPath,
|
||||
ptySpawnHealthCheck: vi.fn(async () => {
|
||||
throw new Error('stale node-pty helper')
|
||||
}),
|
||||
spawnSubprocess: () => createMockSubprocess()
|
||||
})
|
||||
await server.start()
|
||||
|
||||
try {
|
||||
await expect(healthCheckDaemon(socketPath, tokenPath)).resolves.toBe(false)
|
||||
} finally {
|
||||
await server.shutdown()
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/* oxlint-disable max-lines -- Why: pid validation shares process-identity
|
||||
helpers with kill escalation so the SIGKILL safety checks stay co-located. */
|
||||
import { execFileSync } from 'child_process'
|
||||
import { execFile, execFileSync } from 'child_process'
|
||||
import { existsSync, readFileSync, unlinkSync } from 'fs'
|
||||
import { connect, type Socket } from 'net'
|
||||
import { promisify } from 'util'
|
||||
import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from '../startup/startup-diagnostics'
|
||||
import { encodeNdjson } from './ndjson'
|
||||
import { getDaemonPidPath } from './daemon-spawner'
|
||||
import {
|
||||
@@ -135,12 +137,15 @@ export function healthCheckDaemon(socketPath: string, tokenPath: string): Promis
|
||||
settle(false)
|
||||
return
|
||||
}
|
||||
sock?.write(encodeNdjson({ id: 'health-1', type: 'ping' }))
|
||||
// Why: a protocol-live daemon with a stale cwd or node-pty helper
|
||||
// will answer ping but cannot create terminals, so reuse must check
|
||||
// the PTY spawn prerequisites too.
|
||||
sock?.write(encodeNdjson({ id: 'health-1', type: 'ptySpawnHealth' }))
|
||||
continue
|
||||
}
|
||||
|
||||
if (message.id === 'health-1') {
|
||||
settle(Boolean(message.ok))
|
||||
settle(message.ok === true)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -369,12 +374,50 @@ export function startTimeMatches(pid: number, expectedStartedAtMs: number | null
|
||||
return Math.abs(actualStartedAtMs - expectedStartedAtMs) <= START_TIME_TOLERANCE_MS
|
||||
}
|
||||
|
||||
function isDaemonProcess(
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
// Why: the only reliable command-line source on Windows is a CIM query, which
|
||||
// costs a full powershell.exe spawn (300-800ms cold, worse under Defender).
|
||||
// Async because the sync version measurably froze the Electron main thread at
|
||||
// startup for the whole spawn (benchmark: ~0.5s warm, 3s timeout cap cold).
|
||||
// Timed under ORCA_STARTUP_DIAGNOSTICS so the cold-start benchmark can
|
||||
// attribute startup cost to these checks.
|
||||
async function queryWindowsProcessCommandLine(pid: number): Promise<string | null> {
|
||||
const startedAt = performance.now()
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-Command',
|
||||
`(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: 3_000
|
||||
}
|
||||
)
|
||||
return stdout
|
||||
} catch {
|
||||
return null
|
||||
} finally {
|
||||
if (isStartupDiagnosticsEnabled()) {
|
||||
logStartupDiagnostic('daemon-pid-check', {
|
||||
t: Math.round(performance.now()),
|
||||
pid,
|
||||
ms: Math.round(performance.now() - startedAt)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function isDaemonProcess(
|
||||
pid: number,
|
||||
socketPath: string,
|
||||
tokenPath: string,
|
||||
startedAtMs: number | null
|
||||
): boolean {
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
} catch {
|
||||
@@ -382,30 +425,16 @@ function isDaemonProcess(
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
const output = execFileSync(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-Command',
|
||||
`(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: 3_000
|
||||
}
|
||||
)
|
||||
// Why: image names are too broad after PID reuse. Match the daemon entry
|
||||
// plus the exact socket/token args so we only kill the daemon for this
|
||||
// userData protocol endpoint.
|
||||
return (
|
||||
commandLineMatchesDaemon(output, socketPath, tokenPath) &&
|
||||
startTimeMatches(pid, startedAtMs)
|
||||
)
|
||||
} catch {
|
||||
const output = await queryWindowsProcessCommandLine(pid)
|
||||
if (output === null) {
|
||||
return false
|
||||
}
|
||||
// Why: image names are too broad after PID reuse. Match the daemon entry
|
||||
// plus the exact socket/token args so we only kill the daemon for this
|
||||
// userData protocol endpoint.
|
||||
return (
|
||||
commandLineMatchesDaemon(output, socketPath, tokenPath) && startTimeMatches(pid, startedAtMs)
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -429,25 +458,9 @@ function isDaemonProcess(
|
||||
}
|
||||
}
|
||||
|
||||
function getDaemonCommandLine(pid: number): string | null {
|
||||
async function getDaemonCommandLine(pid: number): Promise<string | null> {
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
return execFileSync(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoProfile',
|
||||
'-NonInteractive',
|
||||
'-Command',
|
||||
`(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
timeout: 3_000
|
||||
}
|
||||
)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return queryWindowsProcessCommandLine(pid)
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -466,14 +479,14 @@ function getDaemonCommandLine(pid: number): string | null {
|
||||
|
||||
export type DaemonLaunchIdentity = 'match' | 'mismatch' | 'unknown'
|
||||
|
||||
export function getDaemonLaunchIdentity(
|
||||
export async function getDaemonLaunchIdentity(
|
||||
runtimeDir: string,
|
||||
socketPath: string,
|
||||
tokenPath: string,
|
||||
expectedEntryPath: string,
|
||||
protocolVersion = PROTOCOL_VERSION
|
||||
): DaemonLaunchIdentity {
|
||||
const parsedPid = readVerifiedDaemonPid(runtimeDir, socketPath, tokenPath, protocolVersion)
|
||||
): Promise<DaemonLaunchIdentity> {
|
||||
const parsedPid = await readVerifiedDaemonPid(runtimeDir, socketPath, tokenPath, protocolVersion)
|
||||
if (!parsedPid) {
|
||||
return 'unknown'
|
||||
}
|
||||
@@ -486,19 +499,19 @@ export function getDaemonLaunchIdentity(
|
||||
// carries daemon-entry.js, so use it to stop dev worktrees from reusing a
|
||||
// daemon forked from a deleted sibling checkout. If command-line probing is
|
||||
// unavailable, fail open so we don't kill live sessions unnecessarily.
|
||||
const commandLine = getDaemonCommandLine(parsedPid.pid)
|
||||
const commandLine = await getDaemonCommandLine(parsedPid.pid)
|
||||
if (!commandLine) {
|
||||
return 'unknown'
|
||||
}
|
||||
return commandLine.includes(expectedEntryPath) ? 'match' : 'mismatch'
|
||||
}
|
||||
|
||||
function readVerifiedDaemonPid(
|
||||
async function readVerifiedDaemonPid(
|
||||
runtimeDir: string,
|
||||
socketPath: string,
|
||||
tokenPath: string,
|
||||
protocolVersion = PROTOCOL_VERSION
|
||||
): ParsedDaemonPid | null {
|
||||
): Promise<ParsedDaemonPid | null> {
|
||||
let parsedPid: ParsedDaemonPid | null
|
||||
try {
|
||||
parsedPid = parseDaemonPidFile(
|
||||
@@ -508,21 +521,24 @@ function readVerifiedDaemonPid(
|
||||
return null
|
||||
}
|
||||
|
||||
if (!parsedPid || !isDaemonProcess(parsedPid.pid, socketPath, tokenPath, parsedPid.startedAtMs)) {
|
||||
if (
|
||||
!parsedPid ||
|
||||
!(await isDaemonProcess(parsedPid.pid, socketPath, tokenPath, parsedPid.startedAtMs))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return parsedPid
|
||||
}
|
||||
|
||||
export function isDaemonStaleForCurrentBundle(
|
||||
export async function isDaemonStaleForCurrentBundle(
|
||||
runtimeDir: string,
|
||||
socketPath: string,
|
||||
tokenPath: string,
|
||||
currentAppVersion: string,
|
||||
protocolVersion = PROTOCOL_VERSION
|
||||
): boolean {
|
||||
const parsedPid = readVerifiedDaemonPid(runtimeDir, socketPath, tokenPath, protocolVersion)
|
||||
): Promise<boolean> {
|
||||
const parsedPid = await readVerifiedDaemonPid(runtimeDir, socketPath, tokenPath, protocolVersion)
|
||||
if (!parsedPid) {
|
||||
return false
|
||||
}
|
||||
@@ -547,7 +563,10 @@ export async function killStaleDaemon(
|
||||
let killedDaemon = false
|
||||
try {
|
||||
const parsedPid = parseDaemonPidFile(readFileSync(pidPath, 'utf8'))
|
||||
if (parsedPid && isDaemonProcess(parsedPid.pid, socketPath, tokenPath, parsedPid.startedAtMs)) {
|
||||
if (
|
||||
parsedPid &&
|
||||
(await isDaemonProcess(parsedPid.pid, socketPath, tokenPath, parsedPid.startedAtMs))
|
||||
) {
|
||||
const { pid, startedAtMs } = parsedPid
|
||||
process.kill(pid, 'SIGTERM')
|
||||
const deadline = Date.now() + KILL_WAIT_MS
|
||||
@@ -566,7 +585,7 @@ export async function killStaleDaemon(
|
||||
// window is long enough for the pid to be recycled if the original
|
||||
// daemon died during the wait. Without this, we'd SIGKILL an unrelated
|
||||
// process that happens to now own the same pid.
|
||||
if (!isDaemonProcess(pid, socketPath, tokenPath, startedAtMs)) {
|
||||
if (!(await isDaemonProcess(pid, socketPath, tokenPath, startedAtMs))) {
|
||||
console.warn('[daemon] Skipping SIGKILL for stale daemon: reason=pid_recycled')
|
||||
exited = true
|
||||
killedDaemon = true
|
||||
|
||||
@@ -20,6 +20,7 @@ const {
|
||||
writeFileSyncMock,
|
||||
netConnectMock,
|
||||
forkMock,
|
||||
checkDaemonHealthMock,
|
||||
healthCheckDaemonMock,
|
||||
getMacDaemonSystemResolverHealthMock,
|
||||
getDaemonLaunchIdentityMock,
|
||||
@@ -66,6 +67,7 @@ const {
|
||||
}
|
||||
})
|
||||
|
||||
const checkDaemonHealthMock = vi.fn(async () => 'healthy')
|
||||
const healthCheckDaemonMock = vi.fn(async () => true)
|
||||
const getMacDaemonSystemResolverHealthMock = vi.fn(() => 'healthy')
|
||||
const getDaemonLaunchIdentityMock = vi.fn(() => 'match')
|
||||
@@ -101,6 +103,7 @@ const {
|
||||
writeFileSyncMock,
|
||||
netConnectMock,
|
||||
forkMock,
|
||||
checkDaemonHealthMock,
|
||||
healthCheckDaemonMock,
|
||||
getMacDaemonSystemResolverHealthMock,
|
||||
getDaemonLaunchIdentityMock,
|
||||
@@ -172,6 +175,7 @@ vi.mock('child_process', () => ({ fork: forkMock }))
|
||||
vi.mock('net', () => ({ connect: netConnectMock }))
|
||||
|
||||
vi.mock('./daemon-health', () => ({
|
||||
checkDaemonHealth: checkDaemonHealthMock,
|
||||
getDaemonLaunchIdentity: getDaemonLaunchIdentityMock,
|
||||
getMacDaemonSystemResolverHealth: getMacDaemonSystemResolverHealthMock,
|
||||
healthCheckDaemon: healthCheckDaemonMock,
|
||||
@@ -268,7 +272,10 @@ async function importFresh() {
|
||||
setLocalPtyProviderMock.mockClear()
|
||||
unbindLocalProviderListenersMock.mockClear()
|
||||
rebindLocalProviderListenersMock.mockClear()
|
||||
checkDaemonHealthMock.mockClear()
|
||||
checkDaemonHealthMock.mockResolvedValue('healthy')
|
||||
healthCheckDaemonMock.mockClear()
|
||||
healthCheckDaemonMock.mockResolvedValue(true)
|
||||
getMacDaemonSystemResolverHealthMock.mockReset()
|
||||
getMacDaemonSystemResolverHealthMock.mockReturnValue('healthy')
|
||||
getDaemonLaunchIdentityMock.mockClear()
|
||||
@@ -1025,9 +1032,9 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
probeSocketExistsMock.mockImplementation(
|
||||
(p?: string) => p === '/fake/app/out/main/daemon-entry.js'
|
||||
)
|
||||
healthCheckDaemonMock.mockResolvedValueOnce(false)
|
||||
const mod = await importFresh()
|
||||
getAppPathMock.mockReturnValue('/fake/app/out/main')
|
||||
healthCheckDaemonMock.mockResolvedValue(false)
|
||||
await mod.initDaemonPtyProvider()
|
||||
|
||||
const launcher = spawnerInstances[0].launcher as (
|
||||
@@ -1068,8 +1075,8 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
})
|
||||
|
||||
it('removes detached daemon startup listeners after readiness', async () => {
|
||||
healthCheckDaemonMock.mockResolvedValueOnce(false)
|
||||
const mod = await importFresh()
|
||||
healthCheckDaemonMock.mockResolvedValue(false)
|
||||
await mod.initDaemonPtyProvider()
|
||||
|
||||
const launcher = spawnerInstances[0].launcher as (
|
||||
@@ -1123,8 +1130,8 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
})
|
||||
|
||||
it('removes detached daemon startup listeners after startup error', async () => {
|
||||
healthCheckDaemonMock.mockResolvedValueOnce(false)
|
||||
const mod = await importFresh()
|
||||
healthCheckDaemonMock.mockResolvedValue(false)
|
||||
await mod.initDaemonPtyProvider()
|
||||
|
||||
const launcher = spawnerInstances[0].launcher as (
|
||||
@@ -1167,6 +1174,132 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
|
||||
expect(child.unref).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves a health-check-failing daemon when it owns live sessions', async () => {
|
||||
const mod = await importFresh()
|
||||
await mod.initDaemonPtyProvider()
|
||||
|
||||
const requestMock = vi.fn(async (method: string) => {
|
||||
if (method === 'listSessions') {
|
||||
return {
|
||||
sessions: [{ sessionId: 'wt-1@@live', isAlive: true }]
|
||||
}
|
||||
}
|
||||
return {}
|
||||
})
|
||||
const disconnectMock = vi.fn()
|
||||
daemonClientMock.mockImplementationOnce(function MockDaemonClient() {
|
||||
return {
|
||||
ensureConnected: vi.fn(async () => {}),
|
||||
request: requestMock,
|
||||
disconnect: disconnectMock
|
||||
}
|
||||
})
|
||||
|
||||
const launcher = spawnerInstances[0].launcher as (
|
||||
socketPath: string,
|
||||
tokenPath: string
|
||||
) => Promise<{ shutdown(): Promise<void> }>
|
||||
healthCheckDaemonMock.mockResolvedValueOnce(false)
|
||||
|
||||
await launcher('/fake/socket', '/fake/token')
|
||||
|
||||
expect(requestMock).toHaveBeenCalledWith('listSessions', undefined)
|
||||
expect(disconnectMock).toHaveBeenCalledOnce()
|
||||
expect(killStaleDaemonMock).not.toHaveBeenCalled()
|
||||
expect(forkMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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()
|
||||
|
||||
const launcher = spawnerInstances[0].launcher as (
|
||||
socketPath: string,
|
||||
tokenPath: string
|
||||
) => Promise<{ shutdown(): Promise<void> }>
|
||||
healthCheckDaemonMock.mockResolvedValueOnce(false)
|
||||
forkMock.mockImplementationOnce(() => {
|
||||
const handlers: Record<string, ((arg?: unknown) => void)[]> = {
|
||||
message: [],
|
||||
error: [],
|
||||
exit: []
|
||||
}
|
||||
return {
|
||||
pid: 12345,
|
||||
on(event: string, cb: (arg?: unknown) => void) {
|
||||
handlers[event]?.push(cb)
|
||||
if (event === 'message') {
|
||||
queueMicrotask(() => cb({ type: 'ready' }))
|
||||
}
|
||||
return this
|
||||
},
|
||||
off(event: string, cb: (arg?: unknown) => void) {
|
||||
handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? []
|
||||
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).toHaveBeenCalledWith(
|
||||
'/fake/app/out/main/daemon-entry.js',
|
||||
['--socket', '/fake/socket', '--token', '/fake/token'],
|
||||
expect.objectContaining({ detached: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves a packaged healthy daemon when its app bundle is current', async () => {
|
||||
const mod = await importFresh()
|
||||
await mod.initDaemonPtyProvider()
|
||||
|
||||
@@ -40,6 +40,16 @@ import {
|
||||
unbindLocalProviderListeners,
|
||||
rebindLocalProviderListeners
|
||||
} from '../ipc/pty'
|
||||
import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from '../startup/startup-diagnostics'
|
||||
|
||||
// Why: daemon init runs concurrently with window load, so harness-side stderr
|
||||
// arrival times are useless — in-process `t` lets the startup benchmark derive
|
||||
// how long the daemon cold-start path actually took.
|
||||
function logDaemonMilestone(event: string, details: Record<string, unknown> = {}): void {
|
||||
if (isStartupDiagnosticsEnabled()) {
|
||||
logStartupDiagnostic(event, { t: Math.round(performance.now()), ...details })
|
||||
}
|
||||
}
|
||||
|
||||
let spawner: DaemonSpawner | null = null
|
||||
let adapter: DaemonPtyRouter | DaemonPtyAdapter | null = null
|
||||
@@ -184,10 +194,10 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
|
||||
// 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 = getDaemonLaunchIdentity(runtimeDir, socketPath, tokenPath, entryPath)
|
||||
const identity = await getDaemonLaunchIdentity(runtimeDir, socketPath, tokenPath, entryPath)
|
||||
const stalePackagedBundle =
|
||||
app.isPackaged &&
|
||||
isDaemonStaleForCurrentBundle(runtimeDir, socketPath, tokenPath, app.getVersion())
|
||||
(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.
|
||||
@@ -209,6 +219,20 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher {
|
||||
return createPreservedDaemonHandle(runtimeDir)
|
||||
}
|
||||
}
|
||||
} 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
|
||||
@@ -330,6 +354,15 @@ 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({
|
||||
@@ -341,6 +374,7 @@ export async function initDaemonPtyProvider(signal?: AbortSignal): Promise<void>
|
||||
// throws, a stale spawner would prevent shutdownDaemon() from cleaning up
|
||||
// correctly on retry.
|
||||
const info = await newSpawner.ensureRunning()
|
||||
logDaemonMilestone('daemon-current-ready')
|
||||
if (signal?.aborted) {
|
||||
// Why: startup fail-open may already have allowed fallback LocalPtyProvider
|
||||
// PTYs to spawn. A late daemon swap would strand those PTYs on the old owner.
|
||||
@@ -386,6 +420,7 @@ export async function initDaemonPtyProvider(signal?: AbortSignal): Promise<void>
|
||||
// before daemon init finishes. Rebind here so daemon PTYs still fan out
|
||||
// data/exit events through the renderer and runtime listeners.
|
||||
rebindLocalProviderListeners()
|
||||
logDaemonMilestone('daemon-init-done', { legacyAdapters: legacyAdapters.length })
|
||||
}
|
||||
|
||||
// Why: the Manage Sessions IPC handlers need read access to the current
|
||||
@@ -614,6 +649,28 @@ async function createLegacyDaemonAdapters(runtimeDir: string): Promise<DaemonPty
|
||||
const socketPath = getDaemonSocketPath(runtimeDir, protocolVersion)
|
||||
const tokenPath = getDaemonTokenPath(runtimeDir, protocolVersion)
|
||||
if (!(await probeSocket(socketPath))) {
|
||||
// Why: dead legacy daemons leave pid/token files behind forever (one per
|
||||
// protocol bump). A stale pid eventually gets recycled by an unrelated
|
||||
// process, turning any future identity check into a PowerShell spawn.
|
||||
// The socket is provably dead, so remove the leftovers — mirrors what
|
||||
// cleanupDaemonForProtocol already does for the current version.
|
||||
for (const stalePath of [
|
||||
getDaemonPidPath(runtimeDir, protocolVersion),
|
||||
getDaemonTokenPath(runtimeDir, protocolVersion)
|
||||
]) {
|
||||
try {
|
||||
unlinkSync(stalePath)
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
}
|
||||
if (process.platform !== 'win32' && existsSync(socketPath)) {
|
||||
try {
|
||||
unlinkSync(socketPath)
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Why: old daemon PTYs can be running long-lived agents during an app
|
||||
|
||||
@@ -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(() => ({
|
||||
@@ -33,7 +32,9 @@ function createMockSubprocess(): SubprocessHandle & {
|
||||
let onDataCb: ((data: string) => void) | null = null
|
||||
let onExitCb: ((code: number) => void) | null = null
|
||||
return {
|
||||
pid: 66666,
|
||||
// Why: getCwd falls back to OS pid lookup; a plausible fake pid can
|
||||
// collide with an unrelated local process and leak its cwd into tests.
|
||||
pid: 999_999_999,
|
||||
getForegroundProcess: vi.fn(() => null),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
@@ -56,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()) {
|
||||
@@ -170,6 +150,13 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
await adapter.shutdown(id, { immediate: false })
|
||||
expect(lastSubprocess.kill).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('force-kills immediately when requested', async () => {
|
||||
const { id } = await adapter.spawn({ cols: 80, rows: 24 })
|
||||
await adapter.shutdown(id, { immediate: true })
|
||||
expect(lastSubprocess.kill).not.toHaveBeenCalled()
|
||||
expect(lastSubprocess.forceKill).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sendSignal', () => {
|
||||
@@ -534,7 +521,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
|
||||
@@ -548,22 +535,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
|
||||
}
|
||||
@@ -575,30 +573,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)
|
||||
@@ -617,7 +623,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 () => {
|
||||
@@ -807,6 +814,27 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => {
|
||||
expect(existsSync(join(historyDir, getHistorySessionDirName(id)))).toBe(false)
|
||||
})
|
||||
|
||||
it('writes a final checkpoint before keepHistory shutdown', async () => {
|
||||
historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
|
||||
|
||||
const { id } = await historyAdapter.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: '/home/user',
|
||||
sessionId: 'sleep-checkpoint'
|
||||
})
|
||||
const checkpointSpy = vi.spyOn(historyAdapter.getHistoryManager()!, 'checkpoint')
|
||||
|
||||
lastSubprocess._simulateData('fresh output before sleep\r\n')
|
||||
await historyAdapter.shutdown(id, { immediate: true, keepHistory: true })
|
||||
|
||||
expect(checkpointSpy).toHaveBeenCalledWith(
|
||||
id,
|
||||
expect.objectContaining({ snapshotAnsi: expect.stringContaining('fresh output') })
|
||||
)
|
||||
expect(existsSync(join(historyDir, getHistorySessionDirName(id)))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns cold restore data when disk history has unclean shutdown', async () => {
|
||||
// Simulate a previous daemon crash: write history files without endedAt
|
||||
const sessionId = 'cold-restore-test'
|
||||
@@ -840,6 +868,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))
|
||||
|
||||
@@ -16,7 +16,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'
|
||||
@@ -73,11 +74,20 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
private sleepRestoreSessionIds = new Set<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) {
|
||||
@@ -93,6 +103,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 {
|
||||
@@ -185,6 +196,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 (coldRestore) {
|
||||
this.coldRestoreCache.set(sessionId, coldRestore)
|
||||
@@ -252,13 +264,13 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
}
|
||||
|
||||
async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise<void> {
|
||||
if (opts.keepHistory && this.historyManager && this.supportsCheckpoints) {
|
||||
// Why: sleep kills the live PTY before the periodic checkpoint may run.
|
||||
// Capture the daemon buffer now so wake can restore the pane users left.
|
||||
// Why: sleep/exact-stop kills the live PTY before the periodic checkpoint may run.
|
||||
// Force a final snapshot so wake can restore the pane users left.
|
||||
if (opts.keepHistory) {
|
||||
if (this.checkpointInFlight) {
|
||||
await this.checkpointInFlight
|
||||
}
|
||||
await this.checkpointSessions([id])
|
||||
await this.checkpointSessions([id], { final: true })
|
||||
const restoreInfo = this.historyReader?.detectColdRestore(id) ?? null
|
||||
const coldRestore = restoreInfo ? this.buildColdRestorePayload(restoreInfo) : null
|
||||
if (coldRestore) {
|
||||
@@ -266,7 +278,7 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
this.sleepRestoreSessionIds.add(id)
|
||||
}
|
||||
}
|
||||
await this.client.request('kill', { sessionId: id })
|
||||
await this.client.request('kill', { sessionId: id, immediate: opts.immediate ?? false })
|
||||
this.activeSessionIds.delete(id)
|
||||
this.dirtySessionVersions.delete(id)
|
||||
if (!opts.keepHistory) {
|
||||
@@ -387,7 +399,15 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
}
|
||||
|
||||
/** Called on app launch. Lists daemon sessions, kills orphans whose
|
||||
* workspaceId no longer exists, and caches alive session IDs. */
|
||||
* workspaceId no longer exists, and caches alive session IDs.
|
||||
*
|
||||
* IMPORTANT: a session id embeds the worktree id it was minted under, which is
|
||||
* the worktree's *path* at spawn time. When a worktree folder is renamed, its
|
||||
* id changes but live sessions keep the old id. Callers MUST therefore seed
|
||||
* `validWorktreeIds` with each live worktree's `WorktreeMeta.priorWorktreeIds`
|
||||
* (the pre-rename aliases) or those sessions will be reaped as false orphans.
|
||||
* This reconcile has no production caller yet; wire the alias in when it gains
|
||||
* one. */
|
||||
async reconcileOnStartup(validWorktreeIds: Set<string>): Promise<{
|
||||
alive: string[]
|
||||
killed: string[]
|
||||
@@ -647,17 +667,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
|
||||
@@ -673,16 +698,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))
|
||||
}
|
||||
@@ -697,6 +715,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,
|
||||
|
||||
@@ -64,8 +64,8 @@ export class DaemonPtyProvider {
|
||||
this.client.notify('resize', { sessionId: id, cols, rows })
|
||||
}
|
||||
|
||||
async shutdown(id: string, _opts: { immediate?: boolean; keepHistory?: boolean }): Promise<void> {
|
||||
await this.client.request('kill', { sessionId: id })
|
||||
async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise<void> {
|
||||
await this.client.request('kill', { sessionId: id, immediate: opts.immediate ?? false })
|
||||
}
|
||||
|
||||
onData(callback: (payload: { id: string; data: string }) => void): () => void {
|
||||
|
||||
@@ -145,6 +145,15 @@ describe('DaemonPtyRouter', () => {
|
||||
expect(current.hasPty).not.toHaveBeenCalledWith('legacy-session')
|
||||
})
|
||||
|
||||
it('fails listProcesses closed when any routed adapter cannot list sessions', async () => {
|
||||
const current = createAdapter('current', ['current-session'])
|
||||
const legacy = createAdapter('legacy', ['legacy-session'])
|
||||
vi.mocked(legacy.listProcesses).mockRejectedValueOnce(new Error('legacy unavailable'))
|
||||
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
|
||||
|
||||
await expect(router.listProcesses()).rejects.toThrow('legacy unavailable')
|
||||
})
|
||||
|
||||
it('merges startup reconciliation and updates route mappings', async () => {
|
||||
const current = createAdapter('current', [], {
|
||||
alive: ['current-alive'],
|
||||
|
||||
@@ -121,10 +121,10 @@ export class DaemonPtyRouter implements IPtyProvider {
|
||||
}
|
||||
|
||||
async listProcesses(): Promise<{ id: string; cwd: string; title: string }[]> {
|
||||
const results = await Promise.allSettled(
|
||||
this.allAdapters().map((adapter) => adapter.listProcesses())
|
||||
)
|
||||
return results.flatMap((result) => (result.status === 'fulfilled' ? result.value : []))
|
||||
// Why: runtime exact-stop/liveness flows must fail closed if any adapter
|
||||
// cannot provide a trustworthy process list.
|
||||
const results = await Promise.all(this.allAdapters().map((adapter) => adapter.listProcesses()))
|
||||
return results.flat()
|
||||
}
|
||||
|
||||
async getDefaultShell(): Promise<string> {
|
||||
|
||||
@@ -198,6 +198,20 @@ describe('DaemonServer', () => {
|
||||
expect(result).toEqual({ pong: true })
|
||||
})
|
||||
|
||||
it('replies with an error to unknown request types and keeps serving', async () => {
|
||||
await startServer()
|
||||
const c = await connectClient()
|
||||
|
||||
// Why: downgraded clients can send request types this daemon does not
|
||||
// know. Reject gracefully instead of crashing the session server.
|
||||
await expect(c.request('definitelyUnknownRequest', undefined)).rejects.toThrow(
|
||||
'Unknown request type: definitelyUnknownRequest'
|
||||
)
|
||||
await expect(c.request<{ pong: boolean }>('ping', undefined)).resolves.toEqual({
|
||||
pong: true
|
||||
})
|
||||
})
|
||||
|
||||
it('handles systemResolverHealth', async () => {
|
||||
await startServer()
|
||||
const c = await connectClient()
|
||||
|
||||
@@ -11,6 +11,7 @@ import { TerminalHost } from './terminal-host'
|
||||
import { DaemonStreamDataBatcher } from './daemon-stream-data-batcher'
|
||||
import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health'
|
||||
import type { SubprocessHandle } from './session'
|
||||
import { checkPtySpawnHealth } from './pty-subprocess'
|
||||
import {
|
||||
PROTOCOL_VERSION,
|
||||
NOTIFY_PREFIX,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
export type DaemonServerOptions = {
|
||||
socketPath: string
|
||||
tokenPath: string
|
||||
ptySpawnHealthCheck?: () => Promise<void>
|
||||
spawnSubprocess: (opts: {
|
||||
sessionId: string
|
||||
cols: number
|
||||
@@ -45,6 +47,7 @@ export class DaemonServer {
|
||||
private host: TerminalHost
|
||||
private socketPath: string
|
||||
private tokenPath: string
|
||||
private ptySpawnHealthCheck: () => Promise<void>
|
||||
|
||||
private clients = new Map<string, ConnectedClient>()
|
||||
private streamDataBatcher = new DaemonStreamDataBatcher((clientId) => this.clients.get(clientId))
|
||||
@@ -62,6 +65,7 @@ export class DaemonServer {
|
||||
this.tokenPath = opts.tokenPath
|
||||
this.token = randomUUID()
|
||||
this.host = new TerminalHost({ spawnSubprocess: opts.spawnSubprocess })
|
||||
this.ptySpawnHealthCheck = opts.ptySpawnHealthCheck ?? checkPtySpawnHealth
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
@@ -341,7 +345,7 @@ export class DaemonServer {
|
||||
|
||||
case 'kill':
|
||||
this.lastInputAtBySessionId.delete(request.payload.sessionId)
|
||||
this.host.kill(request.payload.sessionId)
|
||||
this.host.kill(request.payload.sessionId, { immediate: request.payload.immediate })
|
||||
return {}
|
||||
|
||||
case 'signal':
|
||||
@@ -369,12 +373,26 @@ 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 }
|
||||
|
||||
case 'systemResolverHealth':
|
||||
return { health: await readCurrentProcessMacSystemResolverHealth() }
|
||||
|
||||
case 'ptySpawnHealth':
|
||||
await this.ptySpawnHealthCheck()
|
||||
return { healthy: true }
|
||||
|
||||
case 'shutdown':
|
||||
if (request.payload.killSessions) {
|
||||
this.host.dispose()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,11 +5,27 @@ import {
|
||||
readFileSync,
|
||||
existsSync,
|
||||
rmSync,
|
||||
renameSync,
|
||||
unlinkSync
|
||||
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,7 +125,10 @@ 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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -121,9 +150,50 @@ export class HistoryManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
@@ -144,7 +214,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,
|
||||
@@ -153,19 +225,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.
|
||||
// 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`
|
||||
writeFileSync(tmpPath, data)
|
||||
renameSync(tmpPath, writer.checkpointPath)
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,15 @@ import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import type * as LocalPtyUtils from '../providers/local-pty-utils'
|
||||
|
||||
const { spawnMock, isPwshAvailableMock, validateWorkingDirectoryMock } = vi.hoisted(() => ({
|
||||
const {
|
||||
spawnMock,
|
||||
isPwshAvailableMock,
|
||||
validateWorkingDirectoryMock,
|
||||
resolveAgentForegroundProcessMock
|
||||
} = vi.hoisted(() => ({
|
||||
spawnMock: vi.fn(),
|
||||
isPwshAvailableMock: vi.fn(),
|
||||
resolveAgentForegroundProcessMock: vi.fn(),
|
||||
validateWorkingDirectoryMock: vi.fn((cwd: string) => {
|
||||
if (cwd.includes('definitely-missing')) {
|
||||
throw new Error(
|
||||
@@ -33,6 +39,10 @@ vi.mock('../providers/local-pty-utils', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../providers/agent-foreground-process', () => ({
|
||||
resolveAgentForegroundProcess: resolveAgentForegroundProcessMock
|
||||
}))
|
||||
|
||||
import { createPtySubprocess } from './pty-subprocess'
|
||||
|
||||
const ORCA_SHELL_WRAPPER_ENV = [
|
||||
@@ -75,6 +85,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
|
||||
@@ -196,6 +210,93 @@ 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('serves daemon Windows wrapper agent foreground from an async cache', async () => {
|
||||
const proc = mockPtyProcess()
|
||||
proc.process = 'node.exe'
|
||||
spawnMock.mockReturnValue(proc)
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
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.exe')
|
||||
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledWith(proc.pid, 'node.exe')
|
||||
|
||||
resolveForeground('codex')
|
||||
await vi.waitFor(() => expect(handle.getForegroundProcess()).toBe('codex'))
|
||||
} finally {
|
||||
if (platform) {
|
||||
Object.defineProperty(process, 'platform', platform)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('does not schedule foreground enrichment for arbitrary Windows TUIs', () => {
|
||||
const proc = mockPtyProcess()
|
||||
proc.process = 'vim.exe'
|
||||
spawnMock.mockReturnValue(proc)
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' })
|
||||
|
||||
try {
|
||||
const handle = createPtySubprocess({
|
||||
sessionId: 'test',
|
||||
cols: 80,
|
||||
rows: 24
|
||||
})
|
||||
|
||||
expect(handle.getForegroundProcess()).toBe('vim.exe')
|
||||
expect(resolveAgentForegroundProcessMock).not.toHaveBeenCalled()
|
||||
} 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'
|
||||
@@ -1015,12 +1116,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) })
|
||||
)
|
||||
})
|
||||
@@ -1057,14 +1153,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) })
|
||||
)
|
||||
})
|
||||
@@ -1092,14 +1181,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) })
|
||||
)
|
||||
})
|
||||
@@ -1127,14 +1209,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(),
|
||||
@@ -1219,14 +1294,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',
|
||||
@@ -1260,14 +1328,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' })
|
||||
})
|
||||
@@ -1350,14 +1411,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,8 +27,16 @@ 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 {
|
||||
isAgentForegroundWrapperProcess,
|
||||
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 FOREGROUND_AGENT_CACHE_TTL_MS = 1000
|
||||
const PTY_SPAWN_HEALTH_TIMEOUT_MS = 2_000
|
||||
|
||||
export type PtySubprocessOptions = {
|
||||
sessionId: string
|
||||
@@ -231,6 +239,75 @@ function formatPtySpawnError(err: unknown, shellPath: string, spawnCwd: string):
|
||||
return formatted
|
||||
}
|
||||
|
||||
export async function checkPtySpawnHealth(): Promise<void> {
|
||||
if (process.platform !== 'darwin') {
|
||||
return
|
||||
}
|
||||
|
||||
ensureNodePtySpawnHelperExecutable()
|
||||
preflightMacNodePtySpawnEnvironment()
|
||||
|
||||
const cwd = isExistingDirectory(process.env.ORCA_USER_DATA_PATH)
|
||||
? process.env.ORCA_USER_DATA_PATH
|
||||
: getDefaultCwd()
|
||||
|
||||
let proc: pty.IPty
|
||||
try {
|
||||
proc = pty.spawn('/bin/sh', ['-c', 'exit 0'], {
|
||||
name: 'xterm-256color',
|
||||
cols: 2,
|
||||
rows: 1,
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: 'xterm-256color'
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
throw formatPtySpawnError(err, '/bin/sh', cwd)
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let settled = false
|
||||
let exitDisposable: { dispose(): void } | undefined
|
||||
const finish = (error?: Error, opts?: { kill?: boolean }): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
exitDisposable?.dispose()
|
||||
if (opts?.kill) {
|
||||
try {
|
||||
proc.kill()
|
||||
} catch {
|
||||
// Best-effort cleanup for a short-lived health probe.
|
||||
}
|
||||
}
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
finish(new Error(`PTY spawn health check timed out after ${PTY_SPAWN_HEALTH_TIMEOUT_MS}ms`), {
|
||||
kill: true
|
||||
})
|
||||
}, PTY_SPAWN_HEALTH_TIMEOUT_MS)
|
||||
|
||||
// Why: ping only proves the daemon protocol is alive. A real short-lived
|
||||
// PTY spawn catches stale node-pty helper paths captured by this process.
|
||||
exitDisposable = proc.onExit(({ exitCode }) => {
|
||||
if (exitCode === 0) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
finish(new Error(`PTY spawn health check exited with code ${exitCode}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeForegroundProcessName(processName: string | null | undefined): string | null {
|
||||
const trimmed = processName?.trim().replace(/^["']|["']$/g, '') ?? ''
|
||||
if (!trimmed || trimmed === 'xterm-256color') {
|
||||
@@ -462,8 +539,52 @@ 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 || !proc.pid) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
!fallbackProcess ||
|
||||
isShellProcess(fallbackProcess) ||
|
||||
recognizeAgentProcess(fallbackProcess) ||
|
||||
!isAgentForegroundWrapperProcess(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
|
||||
@@ -489,7 +610,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)
|
||||
|
||||
@@ -7,6 +7,7 @@ import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
||||
import type * as ShellReadyModule from './shell-ready'
|
||||
import { getZshShellReadyMarkerRegistrationBlock } from '../shell-templates'
|
||||
|
||||
async function importFreshShellReady(): Promise<typeof ShellReadyModule> {
|
||||
vi.resetModules()
|
||||
@@ -16,6 +17,96 @@ async function importFreshShellReady(): Promise<typeof ShellReadyModule> {
|
||||
const describePosix = process.platform === 'win32' ? describe.skip : describe
|
||||
const hasBash = process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0
|
||||
const itWithBash = hasBash ? it : it.skip
|
||||
const hasZsh = process.platform !== 'win32' && spawnSync('zsh', ['--version']).status === 0
|
||||
const itWithZsh = hasZsh ? it : it.skip
|
||||
|
||||
const SHELL_READY_MARKER_OUTPUT = '\x1b]777;orca-shell-ready\x07'
|
||||
|
||||
// Why: the shell-ready marker is emitted from zle-line-init, which only fires
|
||||
// on a real TTY — spawn through node-pty instead of spawnSync.
|
||||
async function runInteractiveZshLogin(args: {
|
||||
tempHome: string
|
||||
wrapperZdotdir: string
|
||||
isDone: (output: string) => boolean
|
||||
}): Promise<string> {
|
||||
const pty = await import('node-pty')
|
||||
// Why: -o noglobalrcs skips /etc/zsh/* on CI runners, whose insecure (group-
|
||||
// writable) fpath dirs make the global compinit block on an interactive
|
||||
// "insecure directories" [y/n] prompt before zle-line-init ever fires. The
|
||||
// marker contract lives entirely in our ZDOTDIR files, which still load.
|
||||
const proc = pty.spawn('zsh', ['-o', 'noglobalrcs', '-l'], {
|
||||
name: 'xterm-256color',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: args.tempHome,
|
||||
env: {
|
||||
PATH: process.env.PATH ?? '/usr/bin:/bin',
|
||||
HOME: args.tempHome,
|
||||
TERM: 'xterm-256color',
|
||||
ZDOTDIR: args.wrapperZdotdir,
|
||||
ORCA_ORIG_ZDOTDIR: args.tempHome,
|
||||
ORCA_ZSHENV_SOURCE_DIR: args.tempHome,
|
||||
ORCA_SHELL_READY_MARKER: '1'
|
||||
}
|
||||
})
|
||||
let output = ''
|
||||
let settle = (): void => {}
|
||||
const done = new Promise<void>((resolve) => {
|
||||
settle = resolve
|
||||
})
|
||||
const deadline = setTimeout(settle, 10_000)
|
||||
proc.onData((chunk) => {
|
||||
output += chunk
|
||||
if (args.isDone(output)) {
|
||||
settle()
|
||||
}
|
||||
})
|
||||
await done
|
||||
clearTimeout(deadline)
|
||||
proc.kill()
|
||||
return output
|
||||
}
|
||||
|
||||
// Why: exercise an arbitrary interactive zsh rc (its own ZDOTDIR, no wrapper)
|
||||
// so a test can source the marker block directly — e.g. twice, to check the
|
||||
// registration is idempotent and keeps chaining the user's prior widget.
|
||||
async function runInteractiveZshRc(args: {
|
||||
zdotdir: string
|
||||
isDone: (output: string) => boolean
|
||||
}): Promise<string> {
|
||||
const pty = await import('node-pty')
|
||||
// Why: -o noglobalrcs skips /etc/zsh/* so the CI runner's global compinit
|
||||
// can't block on an insecure-directory [y/n] prompt before our marker fires.
|
||||
const proc = pty.spawn('zsh', ['-o', 'noglobalrcs', '-i'], {
|
||||
name: 'xterm-256color',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: args.zdotdir,
|
||||
env: {
|
||||
PATH: process.env.PATH ?? '/usr/bin:/bin',
|
||||
HOME: args.zdotdir,
|
||||
TERM: 'xterm-256color',
|
||||
ZDOTDIR: args.zdotdir,
|
||||
ORCA_SHELL_READY_MARKER: '1'
|
||||
}
|
||||
})
|
||||
let output = ''
|
||||
let settle = (): void => {}
|
||||
const done = new Promise<void>((resolve) => {
|
||||
settle = resolve
|
||||
})
|
||||
const deadline = setTimeout(settle, 10_000)
|
||||
proc.onData((chunk) => {
|
||||
output += chunk
|
||||
if (args.isDone(output)) {
|
||||
settle()
|
||||
}
|
||||
})
|
||||
await done
|
||||
clearTimeout(deadline)
|
||||
proc.kill()
|
||||
return output
|
||||
}
|
||||
|
||||
function runInteractiveBashRcfile(rcfileContent: string, tempDir: string): string {
|
||||
const rcfile = join(tempDir, 'bash-osc133-rcfile')
|
||||
@@ -237,6 +328,139 @@ describePosix('daemon shell-ready launch config', () => {
|
||||
expectFinalZdotdirRestoreContext(zlogin)
|
||||
})
|
||||
|
||||
it('owns zle-line-init for the shell-ready marker instead of an azhw hook', async () => {
|
||||
const { getShellReadyLaunchConfig } = await importFreshShellReady()
|
||||
|
||||
getShellReadyLaunchConfig('/bin/zsh')
|
||||
|
||||
const zlogin = readFileSync(join(userDataPath, 'shell-ready', 'zsh', '.zlogin'), 'utf8')
|
||||
expect(zlogin).toContain('zle -N zle-line-init __orca_prompt_mark')
|
||||
expect(zlogin).toContain('__orca_prev_line_init_fn="${widgets[zle-line-init]#user:}"')
|
||||
expect(zlogin).toContain('printf "\\033]777;orca-shell-ready\\007"')
|
||||
// Why: add-zle-hook-widget aborts its hook chain when an earlier hook
|
||||
// exits non-zero, so the marker must not be registered through it.
|
||||
expect(zlogin).not.toContain('add-zle-hook-widget line-init')
|
||||
// Why: re-source guard — skip re-capturing when we are already the bound
|
||||
// widget so the prior widget chain survives a second source.
|
||||
expect(zlogin).toContain('== "user:__orca_prompt_mark"')
|
||||
})
|
||||
|
||||
// Why: regression guard — oh-my-zsh vi-mode installs a raw zle-line-init
|
||||
// that returns non-zero when VI_MODE_SET_CURSOR is unset. Registering the
|
||||
// marker via add-zle-hook-widget let that failing widget abort the hook
|
||||
// chain, so the marker never fired and every queued startup command sat on
|
||||
// the daemon's pre-ready timeout (a 15s "bare shell" before the agent).
|
||||
itWithZsh(
|
||||
'emits the shell-ready marker even when a user zle-line-init widget fails (oh-my-zsh vi-mode shape)',
|
||||
async () => {
|
||||
const { getShellReadyLaunchConfig } = await importFreshShellReady()
|
||||
const config = getShellReadyLaunchConfig('/bin/zsh')
|
||||
const tempHome = mkdtempSync(join(tmpdir(), 'orca-zsh-vi-mode-'))
|
||||
writeFileSync(
|
||||
join(tempHome, '.zshrc'),
|
||||
[
|
||||
'function zle-line-init() {',
|
||||
' [[ "${VI_MODE_SET_CURSOR:-}" = true ]] || return',
|
||||
'}',
|
||||
'zle -N zle-line-init',
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
try {
|
||||
const output = await runInteractiveZshLogin({
|
||||
tempHome,
|
||||
wrapperZdotdir: config.env.ZDOTDIR,
|
||||
isDone: (current) => current.includes(SHELL_READY_MARKER_OUTPUT)
|
||||
})
|
||||
expect(output).toContain(SHELL_READY_MARKER_OUTPUT)
|
||||
} finally {
|
||||
rmSync(tempHome, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
15_000
|
||||
)
|
||||
|
||||
itWithZsh(
|
||||
'still runs user add-zle-hook-widget line-init hooks after the marker',
|
||||
async () => {
|
||||
const { getShellReadyLaunchConfig } = await importFreshShellReady()
|
||||
const config = getShellReadyLaunchConfig('/bin/zsh')
|
||||
const tempHome = mkdtempSync(join(tmpdir(), 'orca-zsh-azhw-'))
|
||||
const userHookOutput = 'ORCA-TEST-USER-HOOK'
|
||||
writeFileSync(
|
||||
join(tempHome, '.zshrc'),
|
||||
[
|
||||
`__orca_test_line_init_hook() { printf "${userHookOutput}" }`,
|
||||
'autoload -Uz add-zle-hook-widget',
|
||||
'zle -N __orca_test_line_init_hook',
|
||||
'add-zle-hook-widget line-init __orca_test_line_init_hook',
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
try {
|
||||
const output = await runInteractiveZshLogin({
|
||||
tempHome,
|
||||
wrapperZdotdir: config.env.ZDOTDIR,
|
||||
isDone: (current) =>
|
||||
current.includes(SHELL_READY_MARKER_OUTPUT) && current.includes(userHookOutput)
|
||||
})
|
||||
// Why: the marker widget chains to the previously installed widget, so
|
||||
// an azhw dispatcher registered by user config must keep dispatching.
|
||||
expect(output).toContain(SHELL_READY_MARKER_OUTPUT)
|
||||
expect(output).toContain(userHookOutput)
|
||||
expect(output.indexOf(SHELL_READY_MARKER_OUTPUT)).toBeLessThan(
|
||||
output.indexOf(userHookOutput)
|
||||
)
|
||||
} finally {
|
||||
rmSync(tempHome, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
15_000
|
||||
)
|
||||
|
||||
// Why: the marker block is normally sourced once per shell, but a re-source
|
||||
// (nested Orca, manual re-source) must stay idempotent — it must keep
|
||||
// chaining the user's original zle-line-init instead of clobbering the
|
||||
// captured function to empty and silently dropping it on later prompts.
|
||||
itWithZsh(
|
||||
'keeps chaining the prior zle-line-init widget when the marker block is sourced twice',
|
||||
async () => {
|
||||
const zdotdir = mkdtempSync(join(tmpdir(), 'orca-zsh-resource-'))
|
||||
const userHookOutput = 'ORCA-TEST-PRIOR-WIDGET'
|
||||
const block = getZshShellReadyMarkerRegistrationBlock('\\033]777;orca-shell-ready\\007')
|
||||
writeFileSync(
|
||||
join(zdotdir, '.zshrc'),
|
||||
[
|
||||
// A user widget that mimics oh-my-zsh vi-mode owning zle-line-init.
|
||||
`__orca_test_prior_widget() { printf "${userHookOutput}" }`,
|
||||
'zle -N zle-line-init __orca_test_prior_widget',
|
||||
block,
|
||||
// Second source of the exact same block — must not drop the chain.
|
||||
block,
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
try {
|
||||
const output = await runInteractiveZshRc({
|
||||
zdotdir,
|
||||
isDone: (current) =>
|
||||
current.includes(SHELL_READY_MARKER_OUTPUT) && current.includes(userHookOutput)
|
||||
})
|
||||
expect(output).toContain(SHELL_READY_MARKER_OUTPUT)
|
||||
expect(output).toContain(userHookOutput)
|
||||
expect(output.indexOf(SHELL_READY_MARKER_OUTPUT)).toBeLessThan(
|
||||
output.indexOf(userHookOutput)
|
||||
)
|
||||
// Why: idempotent — the marker must fire exactly once per prompt, not
|
||||
// duplicated by the second registration.
|
||||
expect(output.split(SHELL_READY_MARKER_OUTPUT)).toHaveLength(2)
|
||||
} finally {
|
||||
rmSync(zdotdir, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
15_000
|
||||
)
|
||||
|
||||
it('writes wrappers that restore OpenCode and Pi config after user startup files', async () => {
|
||||
const { getShellReadyLaunchConfig } = await importFreshShellReady()
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper'
|
||||
import {
|
||||
getZshEnvTemplate,
|
||||
getZshFinalZdotdirRestoreBlock,
|
||||
getZshShellReadyMarkerRegistrationBlock,
|
||||
getZshStartupFileSourceBlock
|
||||
} from '../shell-templates'
|
||||
|
||||
@@ -111,7 +112,7 @@ __orca_restore_agent_teams_path() {
|
||||
}
|
||||
__orca_restore_agent_teams_path
|
||||
# Why: user startup files may set the default OpenCode config after Orca's
|
||||
# spawn env; restore the PTY-scoped overlay before the first prompt.
|
||||
# spawn env; restore the Orca-managed config dir before the first prompt.
|
||||
[[ -n "\${ORCA_OPENCODE_CONFIG_DIR:-}" ]] && export OPENCODE_CONFIG_DIR="\${ORCA_OPENCODE_CONFIG_DIR}"
|
||||
# Why: bare shells carry both Pi and OMP shadows so a later typed OMP can
|
||||
# switch on demand. Keep Pi as the shell default unless this PTY is OMP-only.
|
||||
@@ -305,16 +306,7 @@ if [[ -z "\${ORCA_PI_CODING_AGENT_DIR:-}" && -n "\${ORCA_OMP_CODING_AGENT_DIR:-}
|
||||
fi
|
||||
${getPosixOmpShellWrapper()}
|
||||
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
|
||||
if [[ "\${ORCA_SHELL_READY_MARKER:-0}" == "1" ]]; then
|
||||
__orca_prompt_mark() {
|
||||
printf "${SHELL_READY_MARKER}"
|
||||
}
|
||||
# Why: zsh precmd fires before zle switches the PTY into line-editing mode,
|
||||
# so writing startup input there can be echoed once outside the prompt.
|
||||
autoload -Uz add-zle-hook-widget
|
||||
zle -N __orca_prompt_mark
|
||||
add-zle-hook-widget line-init __orca_prompt_mark
|
||||
fi
|
||||
${getZshShellReadyMarkerRegistrationBlock(SHELL_READY_MARKER)}
|
||||
${getZshFinalZdotdirRestoreBlock()}
|
||||
`
|
||||
const bashRc = getDaemonBashShellReadyRcfileContent()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user