Merge main and reconcile host metadata follow-up

This commit is contained in:
Neil
2026-09-05 16:57:43 -07:00
180 changed files with 7585 additions and 1808 deletions
+96
View File
@@ -10,6 +10,102 @@
}
},
"gates": [
{
"id": "terminal-output.prestarted-shell-snapshot-adoption",
"title": "Prestarted shell adoption paints covered output once",
"maturity": "experimental",
"protection": "partial",
"owner": "terminal-runtime",
"layer": "renderer-transport-and-live-electron",
"surfaces": [
"backend-created first terminal",
"daemon snapshot adoption",
"deferred live output"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["local", "daemon", "wsl", "ssh", "remote-runtime"],
"coveredPlatforms": ["macos", "linux", "windows"],
"coveredProviders": ["local", "daemon", "wsl"],
"coverageNotes": "macOS daemon-backed Electron journey verifies same PID and terminal identity plus rendered output. Focused renderer contracts pass on Linux, Windows and WSL. Neighboring SSH model and replay contracts pass locally; no new live SSH or paired-runtime journey.",
"motivatingLinks": [
"https://github.com/user-attachments/assets/e8c6d1dc-6150-4c3d-b55a-3d12efefdd04",
"https://github.com/user-attachments/assets/b0328f88-34ac-4d51-8119-9efe17072435"
],
"invariant": "Adopting a prestarted terminal preserves its existing process and paints snapshot-covered startup output once while retaining subsequent live output. Missing sequence proof or blank snapshots must not authorize dropping output.",
"oracle": "Pass snapshot sequence and proven zero keyboard flags through real IPC transport projection. Deliver snapshot-covered and newer output before reattach resolves; drain replay parse callbacks and require one startup marker and the newer output. Repeat with no sequence and blank snapshot to retain unproven bytes. In Electron select a prestarted workspace, type a generated marker and compare PID and stable terminal identities before and after.",
"commands": [
"pnpm test src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts",
"pnpm test src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-live-overlap.test.ts src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-ssh-reconnect-model-paint.test.ts",
"pnpm test src/renderer/src/components/terminal-pane/pty-connection src/renderer/src/components/terminal-pane/pty-transport"
],
"testFiles": [
"src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts",
"src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts"
],
"assertionRefs": [
{
"file": "src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts",
"assertions": [
"zero and nonzero snapshot sequence and proven zero keyboard flags survive IPC projection"
]
},
{
"file": "src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts",
"assertions": [
"startup output covered by the snapshot is painted once",
"new output remains visible",
"legacy unsequenced and blank snapshots retain bytes"
]
}
],
"evidenceRuns": [
{
"date": "2026-09-04",
"runner": "local",
"platform": "macos",
"command": "pnpm test src/renderer/src/components/terminal-pane/pty-transport-connect-spawn.test.ts src/renderer/src/components/terminal-pane/pty-connection-deferred-reattach-live-output.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-snapshot-live-overlap.test.ts src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts src/renderer/src/components/terminal-pane/pty-connection/reattach-payload-ssh-reconnect-model-paint.test.ts",
"result": "passed",
"durationSeconds": 2.34,
"summary": "5 suites / 48 tests pass. Focused 2-suite runs independently pass 27 tests on Linux, Windows and WSL."
},
{
"date": "2026-09-04",
"runner": "local",
"platform": "macos",
"command": "pnpm test src/renderer/src/components/terminal-pane/pty-connection src/renderer/src/components/terminal-pane/pty-transport",
"result": "passed",
"durationSeconds": 5.67,
"summary": "Broader connection/transport gate: 77 files and 813 tests passed, including neighboring restore, reconnect, input and replay behavior. Log: artifacts/worktree-create/orca-draft-replay-broader-gate.log."
}
],
"runtimeBudget": {
"p95Seconds": 15,
"scope": "focused renderer transport and deferred-adoption contracts"
},
"flakeHistory": {
"status": "unknown",
"evidence": "Focused local and remote runs pass; no CI soak history."
},
"redGreenEvidence": {
"status": "partial",
"evidence": "Metadata tests fail before forwarding. Corrected parse-draining regression observes two startup markers when the baseline installation is removed, and one after restoration. Initial missing-live-output failure was a harness parse-drain omission and is not red proof. Before/fixed Electron screenshots show duplicate/single startup output."
},
"performanceBudget": {
"required": true,
"evidence": "Reuses existing snapshot baseline reconciliation with no new scan, timer or subprocess. Corrected daemon-backed rendered trial reaches replay at 116.6 ms and generated keyboard output at 177 ms after selecting the prestarted workspace. This measures selection/adoption, not ordinary composer creation."
},
"promotionCriteria": [
"Meet manifest CI and soak policy.",
"Retain intentional-break and rendered identity/output proof.",
"Exercise live SSH and paired-runtime snapshot adoption before claiming full provider coverage."
],
"knownGaps": [
"Composer draft creation and cancellation are not implemented by this gate.",
"No new live SSH, Windows or WSL UI run; remote evidence is focused contract tests.",
"Mixed-version snapshots without sequence proof intentionally retain legacy behavior."
],
"demotionRule": "Keep experimental or demote if adoption duplicates covered output, drops newer or unproven output, changes terminal ownership, or flakes without explanation."
},
{
"id": "cmd-j-tabs.host-qualified-candidate-ownership",
"title": "Cmd-J tab candidates retain execution-host ownership",
+1 -1
View File
@@ -616,7 +616,7 @@ if (!isHelpOrVersion && process.env.ORCA_DEV_INSTANCE_LABEL) {
// Why: automation launches this app while someone is working; announce that the
// window will come up without taking the foreground so the mode is visible in logs.
if (!isHelpOrVersion && process.env.ORCA_BACKGROUND_LAUNCH === '1') {
console.error('[orca-dev] Background launch: window shows without stealing focus')
console.error('[orca-dev] Background launch: window stays off screen; automate through CDP')
}
let forwardedExtras = []
if (!userPassedPort && !isHelpOrVersion) {
+3
View File
@@ -253,6 +253,9 @@ export const electronViteConfig: UserConfig = {
'agent-hooks/managed-agent-hook-controls': resolve(
'src/main/agent-hooks/managed-agent-hook-controls.ts'
),
'codex/managed-home-shell-preflight': resolve(
'src/main/codex/managed-home-shell-preflight.ts'
),
// Why: account import mutates the user's macOS Keychain from the CLI.
'claude-accounts/keychain': resolve('src/main/claude-accounts/keychain.ts')
},
@@ -14,9 +14,9 @@ import {
describeActiveToolCall,
formatActiveToolLabel,
formatToolCallCount,
isCommandToolName,
selectActiveToolCall
} from '../../../src/shared/native-chat-tool-activity'
import { isShellActivityToolCall } from '../../../src/shared/native-chat-tool-icon'
import type { NativeChatBlock } from '../../../src/shared/native-chat-types'
import { colors } from '../theme/mobile-theme'
import { styles } from './mobile-native-chat-message-styles'
@@ -195,7 +195,10 @@ export function ToolRun({
}
callCount ||= pairs.length
const summary = summarizeToolRun(blocks)
const ActiveToolIcon = activeCall && isCommandToolName(activeCall.name) ? SquareTerminal : Wrench
// The call's input, not its word: Codex names a classified shell row
// `read`/`search`/`list` and keeps the command it ran, while Claude's `Read`
// shares that word and ran none.
const ActiveToolIcon = activeCall && isShellActivityToolCall(activeCall) ? SquareTerminal : Wrench
return (
<View style={styles.toolRun}>
<View style={styles.toolRunHeader}>
+4 -1
View File
@@ -56,7 +56,10 @@ vi.mock('../runtime-client', () => {
vi.mock('../../main/agent-hooks/managed-agent-hook-controls', () => ({
applyAgentStatusHooksEnabled: applyAgentStatusHooksEnabledMock,
getManagedAgentHookStatuses: getManagedAgentHookStatusesMock,
getManagedAgentHookStatuses: getManagedAgentHookStatusesMock
}))
vi.mock('../../main/codex/managed-home-shell-preflight', () => ({
prepareManagedCodexHomeBeforeShellLaunch: prepareManagedCodexHomeBeforeShellLaunchMock
}))
+5 -5
View File
@@ -15,11 +15,7 @@ import { getDefaultPersistedState } from '../../shared/constants'
import { normalizeDisabledTuiAgents } from '../../shared/tui-agent-selection'
import type { GlobalSettings } from '../../shared/global-settings-types'
import type { PersistedState } from '../../shared/persisted-state-types'
import {
applyAgentStatusHooksEnabled,
getManagedAgentHookStatuses,
prepareManagedCodexHomeBeforeShellLaunch
} from '../../main/agent-hooks/managed-agent-hook-controls'
import { prepareManagedCodexHomeBeforeShellLaunch } from '../../main/codex/managed-home-shell-preflight'
type AgentHookCommandResult = {
enabled: boolean
@@ -194,6 +190,8 @@ async function setAgentHooksEnabled(
client: RuntimeClient,
enabled: boolean
): Promise<AgentHookCommandResult> {
const { applyAgentStatusHooksEnabled, getManagedAgentHookStatuses } =
await import('../../main/agent-hooks/managed-agent-hook-controls.js')
const updatedRuntime = await updateRunningRuntime(client, enabled)
const offlineUpdate = updatedRuntime ? null : updateEnabledOnDisk(enabled)
const settingsPath = offlineUpdate?.settingsPath ?? getDataPath()
@@ -234,6 +232,8 @@ export const AGENT_HOOK_HANDLERS: Record<string, CommandHandler> = {
})
},
'agent hooks status': async ({ json }) => {
const { getManagedAgentHookStatuses } =
await import('../../main/agent-hooks/managed-agent-hook-controls.js')
const result: AgentHookCommandResult = {
enabled: readHookSettingsFromDisk().agentStatusHooksEnabled,
settingsPath: getDataPath(),
@@ -566,7 +566,7 @@ describe('Claude stream-json connection', () => {
)
})
it('reports a self-exit with its status and stderr, and leaves its tree unverifiable', async () => {
it('reports a self-exit with its status, stderr, and observed tree verdict', async () => {
const scenario = scriptScenario([{ stderr: 'claude: not signed in\n' }, { exit: 1 }])
let exit: Error | null = null
const connection = await open(launchFor(scenario), {
@@ -579,10 +579,11 @@ describe('Claude stream-json connection', () => {
// The status and stderr are the only diagnostic a refused start leaves behind.
expect((exit as unknown as Error).message).toMatch(/exited \(code 1\): claude: not signed in/)
expect(connection.closed).toBe(true)
// The root's exit is first-hand, but it left before a descendant snapshot
// could be armed, so close() has no tree proof to offer and says so.
await expect(connection.close()).resolves.toBe(false)
expect(connection.exitVerdict).toEqual({ root: 'exited', tree: 'unverifiable' })
// Stderr-triggered capture can win or lose the race with this real child's exit.
const closed = await connection.close()
expect(connection.exitVerdict.root).toBe('exited')
expect(['exited', 'unverifiable']).toContain(connection.exitVerdict.tree)
expect(closed).toBe(connection.exitVerdict.tree === 'exited')
})
it.runIf(process.platform !== 'win32')(
@@ -158,4 +158,42 @@ describe('stopClaudeBackgroundTasks', () => {
await stopClaudeBackgroundTasks(session, undefined, () => current)
expect(stopTask).toHaveBeenCalledTimes(1)
})
it('stops only the requested live task id', async () => {
const backgroundTasks = new ClaudeBackgroundTaskTracker()
backgroundTasks.observe({
type: 'system',
subtype: 'background_tasks_changed',
tasks: [
{ task_id: 'task-one', task_type: 'local_agent' },
{ task_id: 'task-two', task_type: 'local_bash' }
]
})
const stopTask = vi.fn(async (_taskId: string) => {})
const session = { backgroundTasks, connection: { stopTask } } as unknown as ClaudeSession
await expect(
stopClaudeBackgroundTasks(session, 5_000, () => true, 'task-two')
).resolves.toEqual({ cancelled: true })
expect(stopTask).toHaveBeenCalledWith('task-two', { timeoutMs: 5_000 })
expect(stopTask).toHaveBeenCalledTimes(1)
})
it('refuses a stale or unknown task id without a provider call', async () => {
const backgroundTasks = new ClaudeBackgroundTaskTracker()
backgroundTasks.observe({
type: 'system',
subtype: 'task_started',
task_id: 'task-live',
task_type: 'local_agent',
is_backgrounded: true
})
const stopTask = vi.fn(async (_taskId: string) => {})
const session = { backgroundTasks, connection: { stopTask } } as unknown as ClaudeSession
await expect(
stopClaudeBackgroundTasks(session, undefined, () => true, 'task-stale')
).resolves.toEqual({ cancelled: false })
expect(stopTask).not.toHaveBeenCalled()
})
})
@@ -46,9 +46,12 @@ export async function cancelClaudeTurn(
export async function stopClaudeBackgroundTasks(
session: ClaudeSession,
timeoutMs: number | undefined,
isCurrent: ClaudeTurnCancellationGuard = () => true
isCurrent: ClaudeTurnCancellationGuard = () => true,
taskId?: string
): Promise<{ cancelled: boolean }> {
const taskIds = session.backgroundTasks.stoppableTaskIds
const stoppableTaskIds = session.backgroundTasks.stoppableTaskIds
const taskIds =
taskId === undefined ? stoppableTaskIds : stoppableTaskIds.includes(taskId) ? [taskId] : []
let cancelled = false
for (const taskId of taskIds) {
if (!isCurrent()) {
@@ -30,6 +30,7 @@ import {
settleClaudeExitedSession
} from './claude-structured-session-close'
import { readClaudeTranscriptLeafWithReproof } from './claude-transcript-branch-proof'
import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire'
export type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution'
export type {
@@ -40,6 +41,11 @@ export type {
const DISPATCH_ACK_TIMEOUT_MS = 10_000
function backgroundTaskState(session: ClaudeSession): AgentSessionBackgroundTaskState | null {
const state = session.backgroundTasks.state
return state ? { ...state, supportsTaskStop: true } : null
}
export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAdapter {
private readonly sessions = new Map<string, ClaudeSession>()
private readonly acquisitions = new ClaudeAcquisitionRegistry()
@@ -192,7 +198,10 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
session?.translator?.handle(event)
this.deps.onEvent?.(event)
if (backgroundTasksChanged) {
this.deps.onBackgroundTasksChanged?.(event.sessionId, session?.backgroundTasks.state ?? null)
this.deps.onBackgroundTasksChanged?.(
event.sessionId,
session ? backgroundTaskState(session) : null
)
}
}
@@ -233,18 +242,25 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
stopBackgroundTasks: StructuredAgentSessionAdapter['stopBackgroundTasks'] = (input) => {
const session = this.session(input.sessionId)
const acquisitionGeneration = session.acquisitionGeneration
return stopClaudeBackgroundTasks(session, this.deps.requestTimeoutMs, () =>
Boolean(
this.sessions.get(input.sessionId) === session &&
session.fence === input.fence &&
session.acquisitionGeneration === acquisitionGeneration &&
session.backgroundTasks.state
)
return stopClaudeBackgroundTasks(
session,
this.deps.requestTimeoutMs,
() =>
Boolean(
this.sessions.get(input.sessionId) === session &&
session.fence === input.fence &&
session.acquisitionGeneration === acquisitionGeneration &&
session.backgroundTasks.state
),
input.taskId
)
}
backgroundTaskState: NonNullable<StructuredAgentSessionAdapter['backgroundTaskState']> = (
sessionId
) => this.sessions.get(sessionId)?.backgroundTasks.state
) => {
const session = this.sessions.get(sessionId)
return session ? backgroundTaskState(session) : undefined
}
answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (input) =>
answerClaudePrompt(this.session(input.sessionId), input)
setOption: StructuredAgentSessionAdapter['setOption'] = (input) =>
@@ -53,7 +53,11 @@ describe('Claude published session close lifecycle', () => {
is_backgrounded: true
})
expect(backgroundStates).toEqual([
{ state: 'monitoring', tasks: [{ id: 'background-1', kind: 'agent' }] }
{
state: 'monitoring',
tasks: [{ id: 'background-1', kind: 'agent' }],
supportsTaskStop: true
}
])
const session = (
adapter as unknown as {
@@ -68,7 +72,11 @@ describe('Claude published session close lifecycle', () => {
expect(events.filter((event) => event.type === 'handle')).toHaveLength(0)
expect(disposeTranslator).toHaveBeenCalledOnce()
expect(backgroundStates).toEqual([
{ state: 'monitoring', tasks: [{ id: 'background-1', kind: 'agent' }] },
{
state: 'monitoring',
tasks: [{ id: 'background-1', kind: 'agent' }],
supportsTaskStop: true
},
null
])
@@ -0,0 +1,71 @@
import { readRecord, readString } from './codex-item-field-readers'
import type { CodexThreadItem } from './codex-thread-item-identity'
/**
* Codex's own classification of a shell call: the tool name to show, and the
* fields worth lifting into `input` for the shared label helper (a file target,
* a search term, a scanned root). A `Map`, not an object — an object index
* answers `__proto__` with a truthy non-string. Every other action type stays an
* unclassified `shell` row.
*
* Nothing is invented for a field Codex sends as null: a stand-in path is a
* claim about a target, and the label helper turns any path into a file link.
*/
type CommandActionClass = {
name: string
/** Action field to the `input` key it lifts to. A scan root and a listed
* directory lift to `directory`, never `path`: the label helper reads `path`
* as a file target, which mobile turns into a tappable open-file link. */
keys: Readonly<Record<string, string>>
}
const COMMAND_ACTION_CLASSES = new Map<string, CommandActionClass>([
['read', { name: 'read', keys: { path: 'path' } }],
['search', { name: 'search', keys: { query: 'query', path: 'directory' } }],
['listFiles', { name: 'list', keys: { path: 'directory' } }]
])
/** The one class every classified `commandActions` entry agrees on, with the
* fields they all agree on; null leaves the row exactly as a Codex that sends no
* classification renders it. `cat a.txt && ls src` classifies as two different
* things, and naming that row after either would drop the other, so it stays a
* `shell` row that shows the whole command. */
export function commandActionFacts(
item: CodexThreadItem
): { name: string; fields: Record<string, string> } | null {
const actions = item.commandActions
if (!Array.isArray(actions)) {
return null
}
let matched: { class: CommandActionClass; fields: Record<string, string> } | null = null
for (const action of actions) {
const record = readRecord(action)
const type = readString(record, 'type')
const classified = type === null ? undefined : COMMAND_ACTION_CLASSES.get(type)
if (classified === undefined) {
continue
}
if (matched === null) {
const fields: Record<string, string> = {}
for (const [source, lifted] of Object.entries(classified.keys)) {
const value = readString(record, source)
if (value !== null) {
fields[lifted] = value
}
}
matched = { class: classified, fields }
continue
}
if (matched.class.name !== classified.name) {
return null
}
// The same class twice keeps the class, but only a target both entries name.
for (const [source, lifted] of Object.entries(matched.class.keys)) {
const kept = matched.fields[lifted]
if (kept !== undefined && readString(record, source) !== kept) {
delete matched.fields[lifted]
}
}
}
return matched === null ? null : { name: matched.class.name, fields: matched.fields }
}
@@ -0,0 +1,45 @@
// Field readers for the loosely-typed records Codex sends on thread items.
export function readRecord(value: unknown): Record<string, unknown> {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {}
}
export function readString(source: Record<string, unknown>, key: string): string | null {
const value = source[key]
return typeof value === 'string' && value.length > 0 ? value : null
}
export function readFirstString(
source: Record<string, unknown>,
keys: readonly string[]
): string | null {
for (const key of keys) {
const value = readString(source, key)
if (value !== null) {
return value
}
}
return null
}
export function readTextContent(source: Record<string, unknown>, key: string): string | null {
const direct = readString(source, key)
if (direct) {
return direct
}
const value = source[key]
if (!Array.isArray(value)) {
return null
}
const parts = value.flatMap((part) => {
if (typeof part === 'string') {
return part.length > 0 ? [part] : []
}
if (typeof part !== 'object' || part === null) {
return []
}
const text = readString(part as Record<string, unknown>, 'text')
return text ? [text] : []
})
return parts.length > 0 ? parts.join('\n') : null
}
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest'
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import {
briefToolArg,
createToolInputDisplay,
describeToolInput
} from '../../shared/native-chat-tool-summary'
import {
codexItemBody,
codexItemIdentity,
@@ -13,6 +18,13 @@ import {
type CodexThreadItem
} from './codex-structured-item-translation'
/** The tool-call input a Codex item lands on, which is what the row label and
* the collapsed run header are both derived from. */
function toolCallInput(item: CodexThreadItem): unknown {
const body = codexItemBody(item)
return body !== null && body.kind === 'tool-call' ? body.input : null
}
const THREAD_ID = 'thread-abc'
const TURN_ID = 'turn-1'
@@ -195,6 +207,279 @@ describe('codex item bodies', () => {
})
})
it('names a classified read command by its class and keeps the raw command', () => {
const body = codexItemBody({
type: 'commandExecution',
id: 'item-read',
command: "sed -n '1,200p' notes.txt",
cwd: '/repo',
status: 'completed',
exitCode: 0,
commandActions: [
{
type: 'read',
command: "sed -n '1,200p' notes.txt",
name: 'notes.txt',
path: '/repo/notes.txt'
}
]
})
expect(body).toEqual({
kind: 'tool-call',
name: 'read',
// `name` is the target's basename, which `path` already carries and no
// label ever reads, so it stays out of the bounded journal payload.
input: { command: "sed -n '1,200p' notes.txt", cwd: '/repo', path: '/repo/notes.txt' },
state: 'completed'
})
// `read` is the one class that keeps `path`, so its row stays a tappable
// file on mobile — the other half of the rule `list`/`search` obey below.
const display = createToolInputDisplay(body?.kind === 'tool-call' ? body.input : null)
expect(display.filePath).toBe('/repo/notes.txt')
expect(display.label).toBe('/repo/notes.txt')
})
it('carries a classified search query so the row labels by term, not scan root', () => {
expect(
codexItemBody({
type: 'commandExecution',
id: 'item-search',
command: 'rg -n --no-heading beta .',
cwd: '/repo',
status: 'inProgress',
commandActions: [
{ type: 'search', command: 'rg -n --no-heading beta .', query: 'beta', path: '.' }
]
})
).toEqual({
kind: 'tool-call',
name: 'search',
input: { command: 'rg -n --no-heading beta .', cwd: '/repo', query: 'beta', directory: '.' },
state: 'running'
})
})
it('omits a null classified field rather than standing it in as a target', () => {
expect(
codexItemBody({
type: 'commandExecution',
id: 'item-search-bare',
command: 'rg beta',
cwd: '/repo',
status: 'completed',
exitCode: 0,
commandActions: [{ type: 'search', command: 'rg beta', query: null, path: null }]
})
).toEqual({
kind: 'tool-call',
name: 'search',
input: { command: 'rg beta', cwd: '/repo' },
state: 'completed'
})
})
it('names a classified listFiles command `list` and invents no target for a null path', () => {
const body = codexItemBody({
type: 'commandExecution',
id: 'item-list',
command: 'ls',
cwd: '/repo',
status: 'completed',
exitCode: 0,
commandActions: [{ type: 'listFiles', command: 'ls', path: null }]
})
expect(body).toEqual({
kind: 'tool-call',
name: 'list',
input: { command: 'ls', cwd: '/repo' },
state: 'completed'
})
// A stand-in `.` reaches mobile as a tappable "open file" link onto a
// directory, which can only fail. The raw command is the honest label.
const display = createToolInputDisplay(body?.kind === 'tool-call' ? body.input : null)
expect(display.filePath).toBeNull()
expect(display.label).toBe('ls')
})
it('keeps the shell row when one command did two different classified things', () => {
// `cat a.txt && ls src` classifies as a read and a listing; naming the row
// after either drops the other.
expect(
codexItemBody({
type: 'commandExecution',
id: 'item-mixed',
command: 'cat a.txt && ls src',
cwd: '/repo',
status: 'completed',
exitCode: 0,
commandActions: [
{ type: 'read', command: 'cat a.txt', name: 'a.txt', path: 'a.txt' },
{ type: 'listFiles', command: 'ls src', path: 'src' }
]
})
).toEqual({
kind: 'tool-call',
name: 'shell',
input: { command: 'cat a.txt && ls src', cwd: '/repo' },
state: 'completed'
})
})
it('keeps one class run twice, naming no target when the two disagree', () => {
expect(
codexItemBody({
type: 'commandExecution',
id: 'item-two-reads',
command: 'cat a.ts && cat b.ts',
cwd: '/repo',
status: 'completed',
exitCode: 0,
commandActions: [
{ type: 'read', command: 'cat a.ts', path: 'a.ts' },
{ type: 'read', command: 'cat b.ts', path: 'b.ts' }
]
})
).toEqual({
kind: 'tool-call',
name: 'read',
input: { command: 'cat a.ts && cat b.ts', cwd: '/repo' },
state: 'completed'
})
})
it('keeps a target both entries of one class name', () => {
expect(
codexItemBody({
type: 'commandExecution',
id: 'item-same-read',
command: 'head a.ts && tail a.ts',
cwd: '/repo',
status: 'completed',
exitCode: 0,
commandActions: [
{ type: 'read', command: 'head a.ts', path: 'a.ts' },
{ type: 'read', command: 'tail a.ts', path: 'a.ts' }
]
})
).toMatchObject({ name: 'read', input: { path: 'a.ts' } })
})
it('keeps the listed directory as a label, never as a file target', () => {
const body = codexItemBody({
type: 'commandExecution',
id: 'item-list-path',
command: 'ls src',
cwd: '/repo',
status: 'completed',
exitCode: 0,
commandActions: [{ type: 'listFiles', command: 'ls src', path: 'src' }]
})
expect(body).toMatchObject({ name: 'list', input: { directory: 'src' } })
// Under `path` this reaches mobile as a tappable open-file link onto a
// directory — the same dead link a stand-in `.` would have produced.
const display = createToolInputDisplay(body?.kind === 'tool-call' ? body.input : null)
expect(display.filePath).toBeNull()
expect(display.label).toBe('src')
})
it('keeps a scan root off the file-target key even when the search has no term', () => {
const body = codexItemBody({
type: 'commandExecution',
id: 'item-search-root',
command: 'rg --files src',
cwd: '/repo',
status: 'completed',
exitCode: 0,
commandActions: [{ type: 'search', command: 'rg --files src', query: null, path: 'src' }]
})
expect(body).toMatchObject({ name: 'search', input: { directory: 'src' } })
// `path` is only excluded from the file target while a query is present, so
// a term-less search under it would link to the folder it scanned.
expect(
createToolInputDisplay(body?.kind === 'tool-call' ? body.input : null).filePath
).toBeNull()
})
it('leaves the other classes without a stand-in target', () => {
expect(
codexItemBody({
type: 'commandExecution',
id: 'item-read-null',
command: 'cat',
cwd: '/repo',
status: 'completed',
exitCode: 0,
commandActions: [{ type: 'read', command: 'cat', path: null, name: null }]
})
).toEqual({
kind: 'tool-call',
name: 'read',
input: { command: 'cat', cwd: '/repo' },
state: 'completed'
})
})
it('skips unclassified actions to reach the first classified one', () => {
expect(
codexItemBody({
type: 'commandExecution',
id: 'item-piped',
command: 'true && cat a.ts',
cwd: '/repo',
status: 'completed',
exitCode: 0,
commandActions: [
{ type: 'unknown', command: 'true' },
{ type: 'read', command: 'cat a.ts', name: 'a.ts', path: 'a.ts' }
]
})
).toMatchObject({ name: 'read', input: { path: 'a.ts' } })
})
it('falls back to the unclassified shell row for absent or malformed commandActions', () => {
const shellRow = {
kind: 'tool-call',
name: 'shell',
input: { command: 'ls', cwd: '/tmp' },
state: 'completed'
}
const base = {
type: 'commandExecution',
id: 'item-fallback',
command: 'ls',
cwd: '/tmp',
status: 'completed',
exitCode: 0
}
expect(codexItemBody(base)).toEqual(shellRow)
expect(codexItemBody({ ...base, commandActions: null })).toEqual(shellRow)
expect(codexItemBody({ ...base, commandActions: [] })).toEqual(shellRow)
expect(
codexItemBody({ ...base, commandActions: [{ type: 'unknown', command: 'ls' }] })
).toEqual(shellRow)
expect(codexItemBody({ ...base, commandActions: 'read' })).toEqual(shellRow)
expect(codexItemBody({ ...base, commandActions: [null, 7, 'read', {}, { type: 5 }] })).toEqual(
shellRow
)
// The classification table is a Map because an object index answers
// `__proto__`/`constructor` with a truthy non-string tool name.
expect(
codexItemBody({ ...base, commandActions: [{ type: '__proto__', command: 'ls' }] })
).toEqual(shellRow)
expect(
codexItemBody({ ...base, commandActions: [{ type: 'constructor', command: 'ls' }] })
).toEqual(shellRow)
// The rollout-file shape is a different lane and never reaches app-server.
expect(
codexItemBody({ ...base, parsedCmd: [{ type: 'read', cmd: 'ls', path: 'a.ts' }] })
).toEqual(shellRow)
})
it('accepts snake-case command completion output and preserves blob evidence', () => {
const output = 'x'.repeat(1_100_000)
const translated = codexJournalItem({
@@ -298,10 +583,226 @@ describe('codex item bodies', () => {
})
expect(codexItemBody({ type: 'reasoning', id: 'r' })).toBeNull()
expect(codexItemBody({ type: 'agentMessage', id: 'm', text: '' })).toBeNull()
expect(codexItemBody({ type: 'webSearch', id: 'w' })).toMatchObject({
expect(codexItemBody({ type: 'somethingCodexAddedLater', id: 'x' })).toMatchObject({
kind: 'status',
text: 'codex · item:webSearch',
providerFrame: { provider: 'codex', kind: 'item:webSearch' }
text: 'codex · item:somethingCodexAddedLater',
providerFrame: { provider: 'codex', kind: 'item:somethingCodexAddedLater' }
})
})
it('gives an mcp tool call a typed body with its own arguments as input', () => {
expect(
codexItemBody({
type: 'mcpToolCall',
id: 'mcp-1',
server: 'weather',
tool: 'get_forecast',
status: 'completed',
arguments: { city: 'Oslo' },
result: { content: [{ type: 'text', text: '12C' }] }
})
).toEqual({
kind: 'tool-call',
// Server-qualified, and the arguments stay top level so the row label can
// read `query`/`command`/`file_path` out of them.
name: 'weather/get_forecast',
input: { city: 'Oslo' },
state: 'completed',
output: { head: '12C', byteLength: 3, truncated: false, digest: expect.any(String) }
})
})
it('passes an mcp tool name through with no casing transform', () => {
// Downstream dispatch is exact-match on raw identifiers, so every shape —
// bare snake_case included — has to survive byte-identical.
for (const tool of ['get_forecast', 'mcp__server__tool', 'ns.tool', 'urn:tool', 'listTools']) {
expect(
codexItemBody({ type: 'mcpToolCall', id: 'm', tool, status: 'inProgress' }),
tool
).toMatchObject({ kind: 'tool-call', name: tool, state: 'running' })
expect(
codexItemBody({ type: 'mcpToolCall', id: 'm', server: 'srv', tool, status: 'inProgress' }),
tool
).toMatchObject({ kind: 'tool-call', name: `srv/${tool}`, state: 'running' })
}
})
it('falls back to the bare tool, then to `mcp`, when the item is under-specified', () => {
expect(
codexItemBody({ type: 'mcpToolCall', id: 'm', tool: 'get_forecast', status: 'inProgress' })
).toMatchObject({ name: 'get_forecast' })
expect(
codexItemBody({ type: 'mcpToolCall', id: 'm', server: '', tool: 'ping', status: 'completed' })
).toMatchObject({ name: 'ping' })
expect(
codexItemBody({ type: 'mcpToolCall', id: 'm', server: 'weather', status: 'completed' })
).toMatchObject({ name: 'mcp' })
})
it('keeps non-object mcp arguments addressable and empty ones off the label', () => {
// `arguments` is arbitrary JSON upstream; a scalar or array must still reach
// the row rather than being dropped or unwrapped into a bare value.
expect(
codexItemBody({ type: 'mcpToolCall', id: 'm', tool: 't', arguments: 'raw text' })
).toMatchObject({ input: { arguments: 'raw text' } })
expect(
codexItemBody({ type: 'mcpToolCall', id: 'm', tool: 't', arguments: [1, 2] })
).toMatchObject({ input: { arguments: [1, 2] } })
// `arguments` is required on the wire, so `{}` — not an absent key — is what
// an argument-less MCP tool sends, and passing it through labels the row `{}`.
expect(codexItemBody({ type: 'mcpToolCall', id: 'm', tool: 't', arguments: {} })).toEqual({
kind: 'tool-call',
name: 't',
input: null,
state: 'running'
})
expect(codexItemBody({ type: 'mcpToolCall', id: 'm', tool: 't' })).toMatchObject({
input: null
})
expect(
codexItemBody({ type: 'mcpToolCall', id: 'm', tool: 't', arguments: null })
).toMatchObject({ input: null })
})
it('renders an argument-less mcp call as a bare server/tool row', () => {
const input = toolCallInput({
type: 'mcpToolCall',
id: 'm',
server: 'srv',
tool: 'list_tools',
arguments: {}
})
expect(describeToolInput(input)).toBe('')
expect(briefToolArg(input)).toBe('')
})
it('reports an mcp error as a failed call carrying the server message', () => {
expect(
codexItemBody({
type: 'mcpToolCall',
id: 'mcp-2',
server: 's',
tool: 'ping',
status: 'completed',
error: { message: 'server unreachable' }
})
).toMatchObject({
kind: 'tool-call',
name: 's/ping',
state: 'failed',
output: { head: 'server unreachable', truncated: false }
})
})
it('models a web search as a tool call that runs until codex sends the action', () => {
// The start frame Codex actually emits: empty query, no action. Nothing is
// labelable yet, so the input is absent rather than a hull of null keys.
expect(codexItemBody({ type: 'webSearch', id: 'w', query: '', action: null })).toEqual({
kind: 'tool-call',
name: 'web_search',
input: null,
state: 'running'
})
expect(
codexItemBody({
type: 'webSearch',
id: 'w',
query: 'orca release notes',
action: { type: 'search', query: 'orca release notes', queries: null },
results: null
})
).toEqual({
kind: 'tool-call',
name: 'web_search',
input: {
query: 'orca release notes',
description: 'search',
action: { type: 'search', query: 'orca release notes', queries: null }
},
state: 'completed'
})
})
it('carries the web search hits as the call output', () => {
const results = [{ title: 'Orca 1.0', url: 'https://example.com/notes' }]
expect(
codexItemBody({
type: 'webSearch',
id: 'w',
query: 'orca release notes',
action: { type: 'search', query: 'orca release notes', queries: null },
results
})
).toMatchObject({
kind: 'tool-call',
name: 'web_search',
state: 'completed',
output: { head: JSON.stringify(results), truncated: false }
})
// Nothing to show is no output block at all, not an empty one.
for (const empty of [undefined, null, []]) {
expect(
codexItemBody({
type: 'webSearch',
id: 'w',
query: 'q',
action: { type: 'search' },
results: empty
}),
String(empty)
).not.toHaveProperty('output')
}
})
it('labels every web search shape without falling back to raw JSON', () => {
// Both the row label and the run header read top-level input keys only, so a
// shape whose detail sits inside `action` renders as the input's raw JSON.
const url = 'https://example.com/docs/page'
const shapes: [string, unknown, string, string][] = [
['started', null, '', ''],
[
'search',
{ type: 'search', query: 'a sample query', queries: null },
'a sample query',
'a sample query'
],
['openPage', { type: 'openPage', url }, url, ''],
[
'findInPage',
{ type: 'findInPage', url, pattern: 'a needle' },
'a sample query',
'a sample query'
],
['other', { type: 'other' }, 'other', '']
]
for (const [name, action, label, brief] of shapes) {
// Codex leaves the item's own `query` empty on most completed searches.
const query = name === 'search' || name === 'findInPage' ? 'a sample query' : ''
const input = toolCallInput({ type: 'webSearch', id: 'w', query, action })
expect(describeToolInput(input), name).toBe(label)
expect(briefToolArg(input), name).toBe(brief)
}
})
it('leaves subagent items on the generic row until a real renderer exists', () => {
expect(
codexJournalItem({
type: 'subAgentActivity',
id: 'a-1',
kind: 'started',
agentThreadId: 'thread-child',
agentPath: '/root/list_directory'
})
).toMatchObject({
handled: false,
body: { kind: 'status', providerFrame: { kind: 'item:subAgentActivity' } }
})
})
it('drops the sleep item, which codex itself renders as nothing', () => {
expect(codexJournalItem({ type: 'sleep', id: 's-1', durationMs: 20_000 })).toEqual({
body: null,
handled: true
})
})
@@ -1,7 +1,4 @@
import type {
AgentJournalItemBody,
AgentJournalItemIdentity
} from '../../shared/agent-session-journal-types'
import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types'
import type { NativeChatBlock } from '../../shared/native-chat-types'
import {
boundInlineText,
@@ -9,116 +6,27 @@ import {
DEFAULT_JOURNAL_PAYLOAD_LIMITS
} from '../native-chat/agent-session-journal/journal-payload-bounds'
import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame'
import type { CodexTurnOrdinals } from './codex-turn-ordinals'
import { commandActionFacts } from './codex-command-action-class'
import {
readFirstString,
readRecord,
readString,
readTextContent
} from './codex-item-field-readers'
import type { CodexThreadItem } from './codex-thread-item-identity'
export {
codexItemIdentity,
isCodexMessageItemType,
readCodexThreadItem,
type CodexThreadItem
} from './codex-thread-item-identity'
export {
CodexTurnOrdinals,
MAX_CODEX_TURN_ORDINAL_BYTES,
MAX_CODEX_TURN_ORDINAL_ENTRIES
} from './codex-turn-ordinals'
// Codex thread items → journal item bodies and durable identities.
//
// THE ORDINAL RULE, and why it is not "index within the turn". Codex renumbers
// item ids positionally on resume (`item-1`…`item-N` across the whole thread),
// and a resumed turn does NOT contain every item the live turn emitted —
// reasoning and command execution are dropped from persisted history. Numbering
// by live position would therefore shift every message after the first tool
// call and hand the user a duplicate of the assistant's answer after a resume.
//
// So the ordinal counts MESSAGE items only, and the same projection is applied
// to the live stream and to a resumed turn's item list. Any other item type —
// including ones this build does not model — is skipped identically on both
// sides, which is what makes the key survive a Codex release that adds one.
/** Only these carry a durable `(threadId, turnId, ordinal)` identity. */
const CODEX_MESSAGE_ITEM_TYPES = new Set(['userMessage', 'agentMessage'])
export type CodexThreadItem = {
type: string
id: string
[key: string]: unknown
}
export function isCodexMessageItemType(type: string): boolean {
return CODEX_MESSAGE_ITEM_TYPES.has(type)
}
export function readCodexThreadItem(value: unknown): CodexThreadItem | null {
if (typeof value !== 'object' || value === null) {
return null
}
const record = value as Record<string, unknown>
return typeof record.type === 'string' && typeof record.id === 'string'
? (record as CodexThreadItem)
: null
}
function readRecord(value: unknown): Record<string, unknown> {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {}
}
/**
* Durable identity for a Codex item, or null for one that has none.
*
* Non-message items fall back to the `orca` namespace keyed by the Codex item
* id. That id is unstable across resume, so those rows are live-session detail
* that a recovered journal simply will not contain — which is correct: Codex
* itself does not persist them either.
*/
export function codexItemIdentity(input: {
threadId: string
turnId: string | null
item: CodexThreadItem
ordinals: CodexTurnOrdinals
}): AgentJournalItemIdentity {
const { item, turnId } = input
if (turnId && isCodexMessageItemType(item.type)) {
return {
provider: 'codex',
threadId: input.threadId,
turnId,
ordinal: input.ordinals.ordinalFor(input.threadId, turnId, item.id)
}
}
return { provider: 'orca', clientMessageId: `codex-item:${input.threadId}:${item.id}` }
}
function readString(source: Record<string, unknown>, key: string): string | null {
const value = source[key]
return typeof value === 'string' && value.length > 0 ? value : null
}
function readFirstString(source: Record<string, unknown>, keys: readonly string[]): string | null {
for (const key of keys) {
const value = readString(source, key)
if (value !== null) {
return value
}
}
return null
}
function readTextContent(source: Record<string, unknown>, key: string): string | null {
const direct = readString(source, key)
if (direct) {
return direct
}
const value = source[key]
if (!Array.isArray(value)) {
return null
}
const parts = value.flatMap((part) => {
if (typeof part === 'string') {
return part.length > 0 ? [part] : []
}
if (typeof part !== 'object' || part === null) {
return []
}
const text = readString(part as Record<string, unknown>, 'text')
return text ? [text] : []
})
return parts.length > 0 ? parts.join('\n') : null
}
// Codex thread items → journal item bodies.
/** `userMessage` carries structured content parts; `agentMessage` a flat text. */
export function codexMessageBlocks(item: CodexThreadItem): NativeChatBlock[] {
@@ -178,12 +86,14 @@ export type CodexJournalItem = {
function commandItem(item: CodexThreadItem): CodexJournalItem {
const output = readFirstString(item, ['aggregatedOutput', 'aggregated_output'])
const bounded = output === null ? null : boundInlineText(output, DEFAULT_JOURNAL_PAYLOAD_LIMITS)
const parsed = commandActionFacts(item)
return {
body: {
kind: 'tool-call',
name: 'shell',
name: parsed?.name ?? 'shell',
// Raw command and cwd stay so the expanded view still shows what ran.
input: boundToolInput(
{ command: item.command ?? null, cwd: item.cwd ?? null },
{ command: item.command ?? null, cwd: item.cwd ?? null, ...parsed?.fields },
DEFAULT_JOURNAL_PAYLOAD_LIMITS
),
state: commandState(item),
@@ -225,6 +135,85 @@ function fileChangeItem(item: CodexThreadItem): CodexJournalItem {
}
}
/** The tool name reaches the row verbatim — downstream dispatch (diff renderer,
* question parsers, input previews) matches raw identifiers, so any casing
* transform would silently miss them. `server/` qualifies it so two servers
* exposing the same tool stay distinguishable and neither shadows a built-in. */
function mcpToolCallName(item: CodexThreadItem): string {
const tool = readString(item, 'tool')
const server = readString(item, 'server')
return tool === null ? 'mcp' : server === null ? tool : `${server}/${tool}`
}
/** Row-label derivation only reads top-level keys, so the call's own arguments
* have to be the input itself. `arguments` is arbitrary JSON upstream: a
* non-object stays addressable under a key rather than being dropped, while a
* no-argument call — `{}` on the wire, the shape every argument-less MCP tool
* sends — becomes null so the row reads as a bare `server/tool` instead of a
* literal `{}`. */
function mcpToolArguments(value: unknown): unknown {
if (typeof value !== 'object' || value === null) {
return value === null || value === undefined ? null : { arguments: value }
}
return Array.isArray(value) ? { arguments: value } : Object.keys(value).length > 0 ? value : null
}
function mcpToolCallItem(item: CodexThreadItem): CodexJournalItem {
const failure = readString(readRecord(item.error), 'message')
const text = failure ?? readTextContent(readRecord(item.result), 'content')
const bounded = text === null ? null : boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS)
return {
body: {
kind: 'tool-call',
name: mcpToolCallName(item),
input: boundToolInput(mcpToolArguments(item.arguments), DEFAULT_JOURNAL_PAYLOAD_LIMITS),
state: failure === null ? commandState(item) : 'failed',
...(bounded === null ? {} : { output: bounded.bounded })
},
handled: true
}
}
/** A row label is read off top-level keys only, so the action's own labelable
* fields are hoisted beside the query while `action` stays whole for the
* expanded detail. The action `type` lands on `description`, the lowest-ranked
* label key, so it names only an action that carries nothing better. */
function webSearchInput(item: CodexThreadItem): Record<string, unknown> | null {
const action = readRecord(item.action)
const fields: [string, unknown][] = [
['url', readString(action, 'url')],
['pattern', readString(action, 'pattern')],
['description', readString(action, 'type')],
['action', item.action ?? null]
]
const query = readString(item, 'query') ?? readString(action, 'query')
const present = fields.filter(([, value]) => value !== null)
// A blank `query` is the run header's "this call has no brief argument"
// signal; drop the key and the header stands the row's raw JSON in for one.
return query === null && present.length === 0
? null
: { query: query ?? '', ...Object.fromEntries(present) }
}
/** `webSearch` carries no status: Codex starts it with an empty query and a null
* action, then sends the action, so `action` is the completion signal — a
* completed item's own `query` is routinely still empty. The hits arrive on
* `results` and are the call's output. */
function webSearchItem(item: CodexThreadItem): CodexJournalItem {
const hits = Array.isArray(item.results) && item.results.length > 0 ? item.results : null
const bounded = hits && boundInlineText(JSON.stringify(hits), DEFAULT_JOURNAL_PAYLOAD_LIMITS)
return {
body: {
kind: 'tool-call',
name: 'web_search',
input: boundToolInput(webSearchInput(item), DEFAULT_JOURNAL_PAYLOAD_LIMITS),
state: item.action === null || item.action === undefined ? 'running' : 'completed',
...(bounded === null ? {} : { output: bounded.bounded })
},
handled: true
}
}
/**
* Journal body for a Codex item, or null for one with nothing to render.
*
@@ -248,6 +237,12 @@ export function codexJournalItem(item: CodexThreadItem): CodexJournalItem {
if (item.type === 'fileChange') {
return fileChangeItem(item)
}
if (item.type === 'mcpToolCall') {
return mcpToolCallItem(item)
}
if (item.type === 'webSearch') {
return webSearchItem(item)
}
if (item.type === 'reasoning' || item.type === 'plan') {
const text =
readTextContent(item, 'text') ??
@@ -0,0 +1,65 @@
import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types'
import type { CodexTurnOrdinals } from './codex-turn-ordinals'
// Codex thread items → durable journal identities.
//
// THE ORDINAL RULE, and why it is not "index within the turn". Codex renumbers
// item ids positionally on resume (`item-1`…`item-N` across the whole thread),
// and a resumed turn does NOT contain every item the live turn emitted —
// reasoning and command execution are dropped from persisted history. Numbering
// by live position would therefore shift every message after the first tool
// call and hand the user a duplicate of the assistant's answer after a resume.
//
// So the ordinal counts MESSAGE items only, and the same projection is applied
// to the live stream and to a resumed turn's item list. Any other item type —
// including ones this build does not model — is skipped identically on both
// sides, which is what makes the key survive a Codex release that adds one.
/** Only these carry a durable `(threadId, turnId, ordinal)` identity. */
const CODEX_MESSAGE_ITEM_TYPES = new Set(['userMessage', 'agentMessage'])
export type CodexThreadItem = {
type: string
id: string
[key: string]: unknown
}
export function isCodexMessageItemType(type: string): boolean {
return CODEX_MESSAGE_ITEM_TYPES.has(type)
}
export function readCodexThreadItem(value: unknown): CodexThreadItem | null {
if (typeof value !== 'object' || value === null) {
return null
}
const record = value as Record<string, unknown>
return typeof record.type === 'string' && typeof record.id === 'string'
? (record as CodexThreadItem)
: null
}
/**
* Durable identity for a Codex item, or null for one that has none.
*
* Non-message items fall back to the `orca` namespace keyed by the Codex item
* id. That id is unstable across resume, so those rows are live-session detail
* that a recovered journal simply will not contain — which is correct: Codex
* itself does not persist them either.
*/
export function codexItemIdentity(input: {
threadId: string
turnId: string | null
item: CodexThreadItem
ordinals: CodexTurnOrdinals
}): AgentJournalItemIdentity {
const { item, turnId } = input
if (turnId && isCodexMessageItemType(item.type)) {
return {
provider: 'codex',
threadId: input.threadId,
turnId,
ordinal: input.ordinals.ordinalFor(input.threadId, turnId, item.id)
}
}
return { provider: 'orca', clientMessageId: `codex-item:${input.threadId}:${item.id}` }
}
+71 -3
View File
@@ -21,7 +21,7 @@ describe('getBranchConflictKindViaExec', () => {
await expect(getBranchConflictKindViaExec(exec, 'feature/fix')).resolves.toBe('remote')
expect(calls).toEqual([
['rev-parse', '--verify', 'refs/heads/feature/fix'],
['rev-parse', '--verify', '--quiet', 'refs/heads/feature/fix'],
['remote'],
['show-ref', '--verify', '--quiet', '--', 'refs/remotes/foo/bar/feature/fix'],
['show-ref', '--verify', '--quiet', '--', 'refs/remotes/origin/feature/fix']
@@ -41,7 +41,10 @@ describe('getBranchConflictKindViaExec', () => {
await expect(
getBranchConflictKindViaExec(exec, 'feature/fix', 'origin/feature/fix')
).resolves.toBeNull()
expect(calls).toEqual([['rev-parse', '--verify', 'refs/heads/feature/fix'], ['remote']])
expect(calls).toEqual([
['rev-parse', '--verify', '--quiet', 'refs/heads/feature/fix'],
['remote']
])
})
it('keeps longest configured remote-name matching semantics', async () => {
@@ -176,7 +179,7 @@ describe('getBranchConflictKindViaExec batched remote probe', () => {
getBranchConflictKindViaExec(exec, 'feature', undefined, {}, batched)
).resolves.toBeNull()
expect(calls).toEqual([
['rev-parse', '--verify', 'refs/heads/feature'],
['rev-parse', '--verify', '--quiet', 'refs/heads/feature'],
['remote'],
['cat-file', '--batch-check']
])
@@ -254,3 +257,68 @@ describe('getBranchConflictKindViaExec batched remote probe', () => {
expect(calls.filter((argv) => argv[0] === 'show-ref')).toHaveLength(3)
})
})
describe('branch conflict with existing-branch adoption', () => {
const absent = () => Object.assign(new Error('missing'), { code: 1, stderr: '' })
it('skips adoption and its commit probe for a proven missing local ref', async () => {
const exec = vi.fn(async (argv: string[]) => {
if (argv[0] === 'rev-parse') {
throw absent()
}
return { stdout: '' }
})
const adopt = vi.fn(async () => false)
await expect(
getBranchConflictKindViaExec(exec, 'new', undefined, {}, undefined, adopt)
).resolves.toBeNull()
expect(adopt).not.toHaveBeenCalled()
expect(exec).toHaveBeenCalledTimes(2)
})
it('allows an existing branch without querying remote refs', async () => {
const exec = vi.fn(async () => ({ stdout: 'a'.repeat(40) }))
const adopt = vi.fn(async () => true)
await expect(
getBranchConflictKindViaExec(exec, 'existing', undefined, {}, undefined, adopt)
).resolves.toBeNull()
expect(adopt).toHaveBeenCalledOnce()
expect(exec).toHaveBeenCalledOnce()
})
it('retains conflicts for refs whose objects cannot be adopted as commits', async () => {
const exec = vi.fn(async () => ({ stdout: 'a'.repeat(40) }))
const adopt = vi.fn(async () => false)
await expect(
getBranchConflictKindViaExec(exec, 'dangling', undefined, {}, undefined, adopt)
).resolves.toBe('local')
expect(adopt).toHaveBeenCalledOnce()
expect(exec).toHaveBeenCalledTimes(2)
})
it.each([
Object.assign(new Error('transport'), { code: 1, stderr: 'transport failed' }),
Object.assign(new Error('timeout'), { code: 'ETIMEDOUT' })
])('still attempts adoption after an undecided ref probe: %s', async (error) => {
const exec = vi.fn(async () => {
throw error
})
const adopt = vi.fn(async () => true)
await expect(
getBranchConflictKindViaExec(exec, 'existing', undefined, {}, undefined, adopt)
).resolves.toBeNull()
expect(adopt).toHaveBeenCalledOnce()
})
it('rechecks a ref that disappeared while adoption was running', async () => {
const exec = vi
.fn()
.mockResolvedValueOnce({ stdout: 'a'.repeat(40) })
.mockRejectedValueOnce(absent())
.mockResolvedValueOnce({ stdout: '' })
await expect(
getBranchConflictKindViaExec(exec, 'removed', undefined, {}, undefined, async () => false)
).resolves.toBeNull()
expect(exec).toHaveBeenCalledTimes(3)
})
})
+24 -10
View File
@@ -3,6 +3,7 @@ import { gitExecOptions, type LocalGitExecOptions } from './repo-default-base-re
import { gitExecFileAsync } from './runner'
import { isSafeGitRefName } from '../../shared/git-status-upstream-ref'
import {
isShowRefNoMatchError,
probeAnyExactRef,
probeAnyExactRefBatched,
type ExactRefProbeExec,
@@ -29,16 +30,17 @@ function canQueryRemoteBranchName(branchName: string): boolean {
return !branchName.startsWith('-') && isSafeGitRefName(`refs/heads/${branchName}`)
}
async function hasGitRefAsync(
async function probeLocalBranchRef(
exec: ExactRefProbeExec,
ref: string,
options: ExactRefProbeExecOptions
): Promise<boolean> {
): Promise<'present' | 'absent' | 'unknown'> {
try {
const { stdout } = await runGit(exec, ['rev-parse', '--verify', ref], options)
return stdout.trim().length > 0
} catch {
return false
// Quiet absence avoids retrying the WSL probe through a login shell.
const { stdout } = await runGit(exec, ['rev-parse', '--verify', '--quiet', ref], options)
return stdout.trim().length > 0 ? 'present' : 'unknown'
} catch (error) {
return isShowRefNoMatchError(error) ? 'absent' : 'unknown'
}
}
@@ -105,7 +107,8 @@ export async function getBranchConflictKindViaExec(
branchName: string,
allowedBaseRef?: string,
options: ExactRefProbeExecOptions = {},
batchedExec?: ExactRefProbeStdinExec
batchedExec?: ExactRefProbeStdinExec,
allowLocalBranch?: () => Promise<boolean>
): Promise<BranchConflictKind | null> {
if (!canQueryRemoteBranchName(branchName)) {
return null
@@ -114,7 +117,16 @@ export async function getBranchConflictKindViaExec(
// are quiet, so introducing a smaller implicit cap would only make a large
// remote configuration look like a missing conflict.
const probeOptions: ExactRefProbeExecOptions = options
if (await hasGitRefAsync(exec, `refs/heads/${branchName}`, probeOptions)) {
const localRef = `refs/heads/${branchName}`
let presence = await probeLocalBranchRef(exec, localRef, probeOptions)
if (allowLocalBranch && presence !== 'absent') {
if (await allowLocalBranch()) {
return null
}
// Adoption can span ref changes; preserve the fresh conflict check after it fails.
presence = await probeLocalBranchRef(exec, localRef, probeOptions)
}
if (presence === 'present') {
return 'local'
}
@@ -142,7 +154,8 @@ export function getBranchConflictKind(
path: string,
branchName: string,
allowedBaseRef?: string,
options: LocalGitExecOptions = {}
options: LocalGitExecOptions = {},
allowLocalBranch?: () => Promise<boolean>
): Promise<BranchConflictKind | null> {
const execOptions = gitExecOptions(path, options)
const runLocalGit = (
@@ -168,7 +181,8 @@ export function getBranchConflictKind(
// one `show-ref` subprocess per remote -- the exact cost the batch exists to remove.
// `show-ref --verify --quiet` prints nothing and is read by exit code, so it needs
// no fence; the capture wrapper preserves the payload's exit status either way.
(argv, commandOptions) => runLocalGit(argv, commandOptions, true)
(argv, commandOptions) => runLocalGit(argv, commandOptions, true),
allowLocalBranch
)
}
@@ -17,6 +17,7 @@ vi.mock('../observability/instrumentation', () => ({
}))
vi.mock('../diagnostics/main-thread-churn-probe', () => ({ recordSubprocessSpawn: vi.fn() }))
import { getBranchConflictKind } from './repo-branch-conflict'
import { pendingWslDirectGitReadEnvironment } from './command-runner/git-command-resolution'
import { gitExecFileAsync, gitSpawn, gitStreamStdout } from './runner'
import {
@@ -584,6 +585,33 @@ describe('WSL direct Git reads', () => {
})
})
it('checks a missing branch conflict without retrying through a login shell', async () => {
await withPlatform('win32', async () => {
seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT)
execFileMock.mockImplementation((_command, args: string[], _options, callback) => {
const child = createMockChild()
queueMicrotask(() => {
const missingRef = args.join(' ').includes('rev-parse')
const quiet = args.includes('--quiet')
const code = missingRef ? (quiet ? 1 : 128) : 0
callback?.(
code ? Object.assign(new Error('missing ref'), { code }) : null,
'',
missingRef && !quiet ? 'fatal: Needed a single revision' : ''
)
child.emit('close', code, null)
})
return child
})
await expect(
getBranchConflictKind(String.raw`\\wsl.localhost\Ubuntu\repo`, 'new-feature')
).resolves.toBeNull()
expect(execFileMock).toHaveBeenCalledTimes(2)
expect(execFileMock.mock.calls[0]?.[1]).toContain('--quiet')
})
})
it('keeps the fast path when direct and login Git both report an expected failure', async () => {
await withPlatform('win32', async () => {
seedWslGitReadEnvironmentForTests(DISTRO, LOGIN_ENVIRONMENT)
@@ -199,7 +199,7 @@ describe('addWorktree', () => {
})
const worktreeAddCall = gitExecFileAsyncMock.mock.calls.find(
([argv]) => Array.isArray(argv) && argv[0] === 'worktree' && argv[1] === 'add'
([argv]) => Array.isArray(argv) && argv.includes('worktree') && argv.includes('add')
)
expect(worktreeAddCall?.[1]).toMatchObject({ timeout: WORKTREE_ADD_TIMEOUT_MS })
expect(WORKTREE_ADD_TIMEOUT_MS).toBeGreaterThan(0)
@@ -214,7 +214,7 @@ describe('addWorktree', () => {
})
const worktreeAddCall = gitExecFileAsyncMock.mock.calls.find(
([argv]) => Array.isArray(argv) && argv[0] === 'worktree' && argv[1] === 'add'
([argv]) => Array.isArray(argv) && argv.includes('worktree') && argv.includes('add')
)
expect(worktreeAddCall?.[1]).toMatchObject({ timeout: 600_000 })
})
@@ -1,5 +1,5 @@
// addWorktree: fast-forwarding the local base ref (reset --hard / update-ref) and its safety bailouts.
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
gitExecFileAsyncMock,
@@ -32,11 +32,14 @@ import { registerWorktreeSuiteHooks } from './worktree-test-harness'
registerWorktreeSuiteHooks()
describe('addWorktree', () => {
afterEach(() => vi.restoreAllMocks())
const resolveCreationBaseConfigWrite = () => {
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch.<branch>.base
}
beforeEach(() => {
// These branch-safety assertions use POSIX argv; Windows flags have separate coverage.
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
gitExecFileAsyncMock.mockReset()
gitExecFileSyncMock.mockReset()
translateWslOutputPathsMock.mockClear()
@@ -1,5 +1,5 @@
// addWorktree: advisory local-base-ref update suggestions when the refresh setting is off.
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
gitExecFileAsyncMock,
@@ -32,7 +32,10 @@ import { registerWorktreeSuiteHooks } from './worktree-test-harness'
registerWorktreeSuiteHooks()
describe('addWorktree', () => {
afterEach(() => vi.restoreAllMocks())
beforeEach(() => {
// These branch-safety assertions use POSIX argv; Windows flags have separate coverage.
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
gitExecFileAsyncMock.mockReset()
gitExecFileSyncMock.mockReset()
translateWslOutputPathsMock.mockClear()
+11 -4
View File
@@ -12,7 +12,7 @@ import {
getLocalBaseRefUpdateSuggestionForWorktreeCreate,
refreshLocalBaseRefForWorktreeCreate
} from './worktree-base-refresh'
import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe'
import { resolveWorktreeBaseCommitOid } from './worktree-base-ref-probe'
import type {
AddWorktreeOptions,
AddWorktreeResult,
@@ -23,6 +23,7 @@ import { bumpWorktreeScanGeneration } from './worktree-scan-cache'
export type WorktreeAddBaseContext = AddWorktreeResult & {
effectiveBase: string
effectiveBaseOid?: string
}
export async function resolveWorktreeAddBaseContext(
@@ -31,9 +32,11 @@ export async function resolveWorktreeAddBaseContext(
refreshLocalBaseRef: boolean,
options: AddWorktreeOptions
): Promise<WorktreeAddBaseContext> {
const effectiveBase = await resolveWorktreeAddBaseRef(baseBranch, (qualifiedRef) =>
hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options)
)
let effectiveBaseOid: string | null = null
const effectiveBase = await resolveWorktreeAddBaseRef(baseBranch, async (qualifiedRef) => {
effectiveBaseOid = await resolveWorktreeBaseCommitOid(repoPath, qualifiedRef, options)
return effectiveBaseOid !== null
})
const localBaseRefRefresh = refreshLocalBaseRef
? await refreshLocalBaseRefForWorktreeCreate(
repoPath,
@@ -55,6 +58,10 @@ export async function resolveWorktreeAddBaseContext(
: undefined
return {
effectiveBase,
// Refresh/suggestion work can span ref changes; only reuse the immediate resolution probe.
...(!refreshLocalBaseRef && !options.suggestLocalBaseRefUpdate && effectiveBaseOid
? { effectiveBaseOid }
: {}),
...(localBaseRefRefresh ? { localBaseRefRefresh } : {}),
...(localBaseRefUpdateSuggestion ? { localBaseRefUpdateSuggestion } : {})
}
@@ -0,0 +1,77 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { expect, it } from 'vitest'
import { createWorktreePreparationLockReason } from '../../shared/worktree/create-preparation'
import { gitExecFileAsync } from './runner'
import {
discardPreparedWorktree,
finalizePreparedWorktree,
prepareWorktreeCreateCheckout
} from './worktree-create-preparation'
// Opt in on Windows with a running distro; all Git commands use the production WSL router.
const wslDistro = process.env.ORCA_TEST_WSL_DISTRO
it.skipIf(process.platform !== 'win32' || !wslDistro)(
'prepares, retargets, moves and cleans up a real WSL checkout from Windows',
async () => {
const fixtureParent = process.env.ORCA_TEST_WSL_ROOT ?? `\\\\wsl.localhost\\${wslDistro}\\tmp`
const root = await mkdtemp(join(fixtureParent, 'orca-create-route-'))
const repoPath = join(root, 'repo')
const preparedPath = join(root, 'prepared checkout')
const finalPath = join(root, 'final checkout')
const options = { wslDistro, timeout: 60_000 }
const git = async (cwd: string, args: string[]): Promise<string> =>
(await gitExecFileAsync(args, { cwd, ...options })).stdout.trim()
try {
await mkdir(repoPath)
await git(repoPath, ['init', '--quiet'])
expect(await git(repoPath, ['rev-parse', '--show-toplevel'])).toMatch(/^\/(?!\/)/)
await git(repoPath, ['symbolic-ref', 'HEAD', 'refs/heads/main'])
await git(repoPath, ['config', 'user.name', 'Test User'])
await git(repoPath, ['config', 'user.email', 'test@example.com'])
await writeFile(join(repoPath, 'version.txt'), 'one\n')
await git(repoPath, ['add', 'version.txt'])
await git(repoPath, ['commit', '--quiet', '-m', 'initial'])
await prepareWorktreeCreateCheckout(
repoPath,
preparedPath,
'main',
createWorktreePreparationLockReason('real-wsl-test'),
options
)
expect(await git(repoPath, ['worktree', 'list', '--porcelain'])).toContain(
'locked orca-create-preparation:v1:'
)
await writeFile(join(repoPath, 'version.txt'), 'two\n')
await git(repoPath, ['commit', '--quiet', '-am', 'advance base'])
const target = await git(repoPath, ['rev-parse', 'HEAD'])
await finalizePreparedWorktree(
repoPath,
preparedPath,
finalPath,
'feature/routed',
'main',
false,
options
)
expect(await git(finalPath, ['rev-parse', 'HEAD'])).toBe(target)
expect(await git(finalPath, ['symbolic-ref', '--short', 'HEAD'])).toBe('feature/routed')
expect(await git(finalPath, ['status', '--porcelain'])).toBe('')
expect(await readFile(join(finalPath, 'version.txt'), 'utf8')).toBe('two\n')
expect(await git(finalPath, ['config', '--get', 'branch.feature/routed.base'])).toBe(
'refs/heads/main'
)
expect(await git(repoPath, ['worktree', 'list', '--porcelain'])).not.toContain('locked ')
await discardPreparedWorktree(repoPath, finalPath, options)
expect(
(await git(repoPath, ['worktree', 'list', '--porcelain'])).match(/^worktree /gm)
).toHaveLength(1)
} finally {
await rm(root, { recursive: true, force: true })
}
},
120_000
)
+27 -14
View File
@@ -195,25 +195,38 @@ export async function finalizePreparedWorktree(
}
try {
return await runWithGitReadCacheInvalidation(async () => {
const baseContext = await resolveWorktreeAddBaseContext(
repoPath,
baseBranch,
refreshLocalBaseRef,
finalizeGitOptions
)
const [targetHeadResult, preparedHeadResult] = await Promise.all([
gitExecFileAsync(
['rev-parse', '--verify', `${baseContext.effectiveBase}^{commit}`],
gitExecOptions(repoPath, finalizeGitOptions)
),
const [targetResult, preparedResult] = await Promise.allSettled([
(async () => {
const baseContext = await resolveWorktreeAddBaseContext(
repoPath,
baseBranch,
refreshLocalBaseRef,
finalizeGitOptions
)
const targetHead =
baseContext.effectiveBaseOid ??
(
await gitExecFileAsync(
['rev-parse', '--verify', `${baseContext.effectiveBase}^{commit}`],
gitExecOptions(repoPath, finalizeGitOptions)
)
).stdout.trim()
return { baseContext, targetHead }
})(),
gitExecFileAsync(
['rev-parse', '--verify', 'HEAD'],
gitExecOptions(preparedPath, finalizeGitOptions)
)
])
const { stdout: targetHeadOutput } = targetHeadResult
const targetHead = targetHeadOutput.trim()
const { stdout: preparedHeadOutput } = preparedHeadResult
// Settle both reads before failure cleanup can remove the prepared checkout.
if (targetResult.status === 'rejected') {
throw targetResult.reason
}
if (preparedResult.status === 'rejected') {
throw preparedResult.reason
}
const { baseContext, targetHead } = targetResult.value
const preparedHeadOutput = preparedResult.value.stdout
if (preparedHeadOutput.trim() !== targetHead) {
await gitExecFileAsync(
[...windowsLongPathGitArgs(preparedPath), 'reset', '--hard', targetHead],
@@ -0,0 +1,98 @@
import { beforeEach, expect, it, vi } from 'vitest'
const gitExec = vi.hoisted(() => vi.fn())
vi.mock('./runner', () => ({ gitExecFileAsync: gitExec }))
vi.mock('./worktree-base-refresh', () => ({
refreshLocalBaseRefForWorktreeCreate: vi.fn(),
getLocalBaseRefUpdateSuggestionForWorktreeCreate: vi.fn()
}))
vi.mock('./status', () => ({ runWithGitReadCacheInvalidation: (run: () => unknown) => run() }))
vi.mock('./wsl-linked-worktree-git-routing', () => ({
invalidateWslLinkedWorktreeGitRouting: vi.fn()
}))
import { finalizePreparedWorktree } from './worktree-create-preparation'
const originalOid = '1'.repeat(40)
const refreshedOid = '2'.repeat(40)
beforeEach(() => {
gitExec.mockReset().mockImplementation(async (args: string[]) => ({
stdout:
args[0] === 'rev-parse'
? args.includes('--quiet') || args.at(-1) === 'HEAD'
? originalOid
: refreshedOid
: ''
}))
})
it('reuses the current base-resolution oid and preserves WSL routing', async () => {
await finalizePreparedWorktree('/repo', '/prepared', '/final', 'feature', 'main', false, {
wslDistro: 'Ubuntu',
timeout: 8000
})
const revisions = gitExec.mock.calls.filter(([args]) => args[0] === 'rev-parse')
expect(revisions.map(([args]) => args)).toEqual([
['rev-parse', '--verify', '--quiet', 'refs/heads/main^{commit}'],
['rev-parse', '--verify', 'HEAD']
])
expect(gitExec.mock.calls.find(([args]) => args.includes('checkout'))?.[0]).toContain(originalOid)
expect(gitExec.mock.calls.some(([args]) => args.includes('reset'))).toBe(false)
for (const [, options] of gitExec.mock.calls) {
expect(options).toMatchObject({ wslDistro: 'Ubuntu', timeout: 8000 })
}
})
it.each([
{ base: 'refs/heads/main', refresh: false, options: {} },
{ base: 'main', refresh: true, options: {} },
{ base: 'main', refresh: false, options: { suggestLocalBaseRefUpdate: true } }
])('re-reads the target for $base, refresh=$refresh, options=$options', async (test) => {
await finalizePreparedWorktree(
'/repo',
'/prepared',
'/final',
'feature',
test.base,
test.refresh,
test.options
)
expect(gitExec).toHaveBeenCalledWith(
['rev-parse', '--verify', 'refs/heads/main^{commit}'],
expect.objectContaining({ cwd: '/repo' })
)
expect(gitExec.mock.calls.find(([args]) => args.includes('reset'))?.[0]).toContain(refreshedOid)
expect(gitExec.mock.calls.find(([args]) => args.includes('checkout'))?.[0]).toContain(
refreshedOid
)
})
it('starts both independent probes before either resolves and settles them before failure', async () => {
let resolveBase!: (value: { stdout: string }) => void
let rejectPrepared!: (reason: Error) => void
gitExec.mockImplementation((args: string[]) => {
if (args.includes('--quiet')) {
return new Promise((resolve) => (resolveBase = resolve))
}
if (args.at(-1) === 'HEAD') {
return new Promise((_, reject) => (rejectPrepared = reject))
}
return Promise.resolve({ stdout: '' })
})
let settled = false
const error = new Error('prepared HEAD unreadable')
const result = finalizePreparedWorktree('/repo', '/prepared', '/final', 'feature', 'main')
const checked = expect(result).rejects.toBe(error)
void result.then(
() => (settled = true),
() => (settled = true)
)
await vi.waitFor(() => expect(gitExec).toHaveBeenCalledTimes(2))
rejectPrepared(error)
await Promise.resolve()
expect(settled).toBe(false)
resolveBase({ stdout: originalOid })
await checked
expect(gitExec.mock.calls.some(([args]) => args.includes('move'))).toBe(false)
})
+21
View File
@@ -16,6 +16,7 @@ vi.mock('../wsl', () => ({
import {
computeWorktreePath,
computeWorktreePathAsync,
computeWorkspaceRootAsync,
getWorktreePathSettings
} from './worktree-logic'
import {
@@ -32,6 +33,26 @@ describe('computeWorktreePath WSL layout', () => {
parseWslPathMock.mockReset()
})
it('reuses an asynchronously resolved root for every name candidate without a sync probe', async () => {
parseWslPathMock.mockReturnValue({ distro: 'Ubuntu', linuxPath: '/home/jin/repo' })
const repoPath = String.raw`\\wsl.localhost\Ubuntu\home\jin\repo`
const home = String.raw`\\wsl.localhost\Ubuntu\home\jin`
const settings = { workspaceDir: 'C:\\workspaces', nestWorkspaces: true }
let resolveHome!: (home: string) => void
getWslHomeAsyncMock.mockReturnValue(new Promise<string>((resolve) => (resolveHome = resolve)))
const pendingRoot = computeWorkspaceRootAsync(repoPath, settings)
expect(getWslHomeMock).not.toHaveBeenCalled()
resolveHome(home)
const root = await pendingRoot
for (const name of ['feature', 'feature-2', 'feature-3']) {
expect(computeWorktreePath(name, repoPath, settings, root)).toBe(
win32.join(home, 'orca', 'workspaces', 'repo', name)
)
}
expect(getWslHomeAsyncMock).toHaveBeenCalledExactlyOnceWith('Ubuntu')
expect(getWslHomeMock).not.toHaveBeenCalled()
})
it('places WSL repo worktrees under the distro home workspace root', () => {
parseWslPathMock.mockReturnValue({
distro: 'Ubuntu',
+5 -4
View File
@@ -103,12 +103,13 @@ export function ensurePathWithinWorkspace(targetPath: string, workspaceDir: stri
export function computeWorktreePath(
sanitizedName: string,
repoPath: string,
settings: WorktreePathSettings
settings: WorktreePathSettings,
workspaceRoot?: string
): string {
return computeWorktreePathFromWorkspaceRoot(
sanitizedName,
repoPath,
computeWorkspaceRoot(repoPath, settings),
workspaceRoot ?? computeWorkspaceRoot(repoPath, settings),
settings.nestWorkspaces
)
}
@@ -130,7 +131,7 @@ function computeWorktreePathFromWorkspaceRoot(
}
/** Async twin of computeWorktreePath. Same result; resolves the WSL home without blocking the main
* thread, so callers off the create path never freeze the app on a stopped distro. */
* thread, so callers never freeze the app on a stopped distro. */
export async function computeWorktreePathAsync(
sanitizedName: string,
repoPath: string,
@@ -147,7 +148,7 @@ export async function computeWorktreePathAsync(
/** Async twin of computeWorkspaceRoot. Same result; the WSL home probe spawns `wsl.exe`, so
* background preparation uses this variant rather than blocking the Electron main thread for up
* to the probe timeout. The sync twin below still serves callers that cannot await (allowed-roots
* resolution, the create click, CLI create, watch targets, worktree trash). */
* resolution, CLI create, watch targets, worktree trash). */
export async function computeWorkspaceRootAsync(
repoPath: string,
settings: { workspaceDir: string; wslMirrorDistro?: string }
+29 -13
View File
@@ -87,7 +87,7 @@ import {
computeValidatedBranchName,
computeWorktreePath,
computeRemoteWorktreePath,
computeWorkspaceRoot,
computeWorkspaceRootAsync,
ensurePathWithinWorkspace,
getWorktreeCreationLayout,
getWorktreePathSettings,
@@ -2444,7 +2444,7 @@ export async function createLocalWorktree(
emitCreateWorktreeProgress(mainWindow, 'fetching', args.creationId)
}
}
const workspaceRoot = computeWorkspaceRoot(repo.path, worktreePathSettings)
const workspaceRoot = await computeWorkspaceRootAsync(repo.path, worktreePathSettings)
// Why: this validation doesn't depend on remote refs, so it can overlap a required remote-tracking base refresh.
const primarySetupScript = getEffectiveHooks(repo)?.scripts.setup
@@ -2530,19 +2530,33 @@ export async function createLocalWorktree(
username,
localWorktreeGitOptions
)
checkoutExistingBranch = await canCheckoutExistingLocalBranch(
repo.path,
branchName,
baseBranch,
localWorktreeGitOptions
)
if (checkoutExistingBranch && !selectedExistingLocalBranchName) {
// Why: suffix retries may need a new path, but an existing-branch checkout must keep the user-selected branch, not a sibling.
selectedExistingLocalBranchName = branchName
const tryExistingBranch = async (): Promise<boolean> => {
checkoutExistingBranch = await canCheckoutExistingLocalBranch(
repo.path,
branchName,
baseBranch,
localWorktreeGitOptions
)
return checkoutExistingBranch
}
// Explicit branch selections retain the adoption-first path.
const preferExistingBranch = Boolean(
args.branchNameOverride || selectedExistingLocalBranchName
)
checkoutExistingBranch = preferExistingBranch && (await tryExistingBranch())
lastBranchConflictKind = checkoutExistingBranch
? null
: await getBranchConflictKind(repo.path, branchName, baseBranch, localWorktreeGitOptions)
: await getBranchConflictKind(
repo.path,
branchName,
baseBranch,
localWorktreeGitOptions,
preferExistingBranch ? undefined : tryExistingBranch
)
if (checkoutExistingBranch && !selectedExistingLocalBranchName) {
// Path retries must retain the adopted branch.
selectedExistingLocalBranchName = branchName
}
const allowedPushTargetRemoteConflict =
lastBranchConflictKind &&
isAllowedPushTargetRemoteConflict(lastBranchConflictKind, branchName, args)
@@ -2604,7 +2618,7 @@ export async function createLocalWorktree(
}
worktreePath = ensurePathWithinWorkspace(
computeWorktreePath(effectiveSanitizedName, repo.path, worktreePathSettings),
computeWorktreePath(effectiveSanitizedName, repo.path, worktreePathSettings, workspaceRoot),
workspaceRoot
)
if (existsSync(worktreePath)) {
@@ -3010,6 +3024,8 @@ export async function createLocalWorktree(
}
})
// Startup resolves the new id before lifecycle notifications invalidate runtime caches.
runtime?.invalidateWorktreeCatalog?.(repo.id)
const stagedStartup = await timing.time('spawn_startup_terminal', () =>
spawnLocalStartupAndSetupTerminals({
runtime,
@@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { resolve } from 'node:path'
import type { CreateWorktreeResult } from '../../shared/worktree/create-types'
import { resolveRegisteredWorktreePath } from './registered-worktree-roots-cache'
import { computeWorkspaceRootAsync } from './worktree-logic'
import type * as WorktreeLogic from './worktree-logic'
import {
listWorktreesMock,
describeCreatedWorktreeMock,
@@ -78,11 +80,13 @@ vi.mock('../setup-hook-env-vars', async (importOriginal) =>
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('./worktree-logic', async (importOriginal) =>
(await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock(
(await importOriginal()) as Record<string, unknown>
)
)
vi.mock('./worktree-logic', async (importOriginal) => {
const actual = await importOriginal<typeof WorktreeLogic>()
return {
...(await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock(actual),
computeWorkspaceRootAsync: vi.fn(actual.computeWorkspaceRootAsync)
}
})
vi.mock('../terminal-history-deletion', async () =>
(await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock()
)
@@ -409,15 +413,23 @@ describe('registerWorktreeHandlers', () => {
}
])
await handlers['worktrees:create'](null, {
const root = Promise.withResolvers<string>()
vi.mocked(computeWorkspaceRootAsync).mockReturnValueOnce(root.promise)
const create = handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'feature'
})
expect(computeWorktreePathMock).toHaveBeenCalledWith('feature', '/workspace/repo', {
nestWorkspaces: false,
workspaceDir: '../worktrees'
})
await vi.waitFor(() => expect(computeWorkspaceRootAsync).toHaveBeenCalled())
expect(addWorktreeMock).not.toHaveBeenCalled()
root.resolve('/workspace/worktrees')
await create
expect(computeWorktreePathMock).toHaveBeenCalledWith(
'feature',
'/workspace/repo',
{ nestWorkspaces: false, workspaceDir: '../worktrees' },
'/workspace/worktrees'
)
expect(addWorktreeMock).toHaveBeenCalledWith(
'/workspace/repo',
'../worktrees/feature',
@@ -689,6 +701,10 @@ describe('registerWorktreeHandlers', () => {
expect(setupCommand).toBe('bash /workspace/repo/.git/orca/setup-runner.sh')
expect(result.setup).toBeUndefined()
expect(result.startupTerminal).toEqual({ spawned: true, surface: 'visible' })
expect(runtimeStub.invalidateWorktreeCatalog).toHaveBeenCalledWith('repo-1')
expect(runtimeStub.invalidateWorktreeCatalog.mock.invocationCallOrder[0]).toBeLessThan(
runtimeStub.createTerminal.mock.invocationCallOrder[0]
)
expect(result.timing?.phases.map((phase) => phase.phase)).toEqual(
expect.arrayContaining([
'git_worktree_add',
+15 -8
View File
@@ -1,4 +1,5 @@
import { type Mock, vi } from 'vitest'
import type { computeWorktreePath } from './worktree-logic'
import type { HandlerMap } from './worktrees-test-ipc-surface'
/** Loose signature: one mock stands in for many unrelated module exports. */
@@ -78,13 +79,7 @@ export const resolveSetupRunnerShellMock: ModuleMock = vi.fn()
export const runHookMock: ModuleMock = vi.fn()
export const hasHooksFileMock: ModuleMock = vi.fn()
export const loadHooksMock: ModuleMock = vi.fn()
export const computeWorktreePathMock: Mock<
(
sanitizedName: string,
repoPath: string,
settings: { nestWorkspaces: boolean; workspaceDir: string }
) => string
> = vi.fn()
export const computeWorktreePathMock: Mock<typeof computeWorktreePath> = vi.fn()
export const ensurePathWithinWorkspaceMock: StringArgMock = vi.fn()
export const gitExecFileAsyncMock: GitArgvMock = vi.fn()
export const getSshGitProviderMock: StringArgMock = vi.fn()
@@ -138,7 +133,19 @@ export const gitRepoModuleMock = () => ({
resolveDefaultBaseRefWithLocalGit: resolveDefaultBaseRefWithLocalGitMock,
resolveDefaultBaseRefViaExec: resolveDefaultBaseRefViaExecMock,
getDefaultRemote: getDefaultRemoteMock,
getBranchConflictKind: getBranchConflictKindMock
getBranchConflictKind: async (
repoPath: string,
branch: string,
base?: string,
options?: { wslDistro?: string },
allowLocalBranch?: () => Promise<boolean>
) => {
// These handler tests stub ref presence; policy tests cover the absent-ref fast path.
if (allowLocalBranch && (await allowLocalBranch())) {
return null
}
return getBranchConflictKindMock(repoPath, branch, base, options)
}
})
export const githubClientModuleMock = () => ({
@@ -12,6 +12,7 @@ export type WorktreeRuntimeStub = {
clearOptimisticReconcileToken: ReturnType<typeof vi.fn>
resolveManagedMrBase: ReturnType<typeof vi.fn>
createTerminal: ReturnType<typeof vi.fn>
invalidateWorktreeCatalog: ReturnType<typeof vi.fn>
splitTerminal: ReturnType<typeof vi.fn>
notifyWorktreesChangedForRemoteClients: ReturnType<typeof vi.fn>
closeFileWatchersForRemoval: ReturnType<typeof vi.fn>
@@ -38,6 +39,7 @@ export function createWorktreeRuntimeStub(): WorktreeRuntimeStub {
title: null,
surface: 'visible'
}),
invalidateWorktreeCatalog: vi.fn(),
splitTerminal: vi.fn().mockResolvedValue({
handle: 'term-setup',
tabId: 'tab-startup',
@@ -0,0 +1,196 @@
// An empty chat beside a pre-SQLite journal explains itself.
//
// The SQLite move shipped no importer, so a session whose history is a
// `log.jsonl` founds a fresh empty journal beside it and looks exactly like a
// chat created seconds ago. One status row is the difference.
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import Database from '../../sqlite/sync-database'
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types'
import { projectStructuredItemsToNativeChat } from '../../../shared/structured-agent-session-projection'
import { openJournalDatabase } from './journal-database'
import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema'
import { JOURNAL_FILE_FORMAT_REMNANT_DISCLOSURE_IDENTITY } from './journal-file-format-remnant'
import { loadJournal } from './journal-open'
import { journalDatabaseFile } from './journal-paths'
import type { AgentSessionJournal } from './journal-store'
import type { openAgentSessionJournal } from './journal-store-factory'
import { createTrackedJournalOpener } from './journal-store-test-open'
const IDENTITY: AgentSessionJournalIdentity = {
sessionId: 'session-1',
workspaceId: 'ws-1',
hostId: 'host-1',
agent: 'codex',
providerHandle: { kind: 'codex', threadId: 'thread-1' }
}
const DISCLOSURE_ITEM_ID = agentJournalItemKey(JOURNAL_FILE_FORMAT_REMNANT_DISCLOSURE_IDENTITY)
let root: string
let clock = 1_000
const journals = createTrackedJournalOpener()
function open(overrides: Partial<Parameters<typeof openAgentSessionJournal>[0]> = {}) {
return journals.open({
identity: IDENTITY,
journalDir: root,
now: () => (clock += 1),
mintEpoch: () => `epoch-${clock}`,
...overrides
})
}
function writeRemnant(name = 'log.jsonl'): Promise<void> {
return writeFile(join(root, name), '{"kind":"epoch","v":1,"seq":1}\n', 'utf8')
}
function disclosure(journal: AgentSessionJournal): string | null {
const row = journal.snapshot().items.find((entry) => entry.itemId === DISCLOSURE_ITEM_ID)
return row?.body.kind === 'status' ? row.body.text : null
}
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-journal-remnant-'))
clock = 1_000
})
afterEach(async () => {
await journals.closeAll()
await rm(root, { recursive: true, force: true })
})
describe('a chat whose history is still in the pre-SQLite format', () => {
it('says how to carry on, and where the transcript is', async () => {
await writeRemnant()
const journal = await open()
expect(disclosure(journal)).toContain('send a message to pick up where you left off')
expect(disclosure(journal)).toContain(join(root, 'log.jsonl'))
expect(disclosure(journal)).toContain('Codex')
})
// Both files is the normal shape of a pre-SQLite directory: every epoch roll
// staged a snapshot whether or not anything compacted into it, so preferring
// the snapshot would name an empty file for ~every affected chat.
it('names the log, not the snapshot staged beside it', async () => {
await writeRemnant('log.jsonl')
await writeRemnant('snapshot.json')
const journal = await open()
expect(disclosure(journal)).toContain(join(root, 'log.jsonl'))
expect(disclosure(journal)).not.toContain('snapshot.json')
})
it('falls back to the snapshot when a chat has no log beside it', async () => {
await writeRemnant('snapshot.json')
const journal = await open()
expect(disclosure(journal)).toContain(join(root, 'snapshot.json'))
})
it('says nothing to a chat that is genuinely new', async () => {
const journal = await open()
expect(journal.snapshot().items).toEqual([])
})
// Counting rows proves nothing here — the append upserts by identity, so a
// second append would still leave exactly one. The revision is what moves.
it('does not re-append the row on a later open', async () => {
await writeRemnant()
const first = await open()
const firstRevision = first
.snapshot()
.items.find((e) => e.itemId === DISCLOSURE_ITEM_ID)?.revision
await first.close()
const reopened = await open()
const row = reopened.snapshot().items.find((e) => e.itemId === DISCLOSURE_ITEM_ID)
expect(firstRevision).toBe(1)
expect(row?.revision).toBe(1)
expect(reopened.cursor().sequence).toBe(2)
})
// The epoch commit and this append are separate transactions; if the append is
// lost the epoch exists but holds nothing, and every later open takes the
// adopt branch. The offer has to survive that.
it('offers the message again when a committed epoch holds nothing', async () => {
const founded = await open()
await founded.close()
await writeRemnant()
const reopened = await open()
expect(disclosure(reopened)).toContain(join(root, 'log.jsonl'))
})
// A repair's epoch is the marker that history was deleted and never rebuilt,
// and any row that is not the repair's own disclosure retires it. Appending
// here would silently stop the session ever asking the provider for that
// history — with the journal still holding none.
it('stays out of a journal this open just repaired', async () => {
const journal = await open()
await journal.appendItem(
{ provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 },
{ kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'history' }] },
{ fence: 1 }
)
await journal.close()
// Deleting the anchor leaves every row unanchored: replay keeps nothing, so
// the repair publishes an empty `unreconcilable_prefix` epoch and — costing
// no malformed row — appends no disclosure of its own. That is the one state
// where this branch and a repair meet.
const opened = openJournalDatabase(journalDatabaseFile(root))
try {
opened.db.prepare('DELETE FROM journal_rows WHERE seq = ?').run(1)
} finally {
opened.db.close()
}
await writeRemnant()
const repaired = await open()
expect(disclosure(repaired)).toBeNull()
// Still asking the provider for the history the repair dropped.
expect(loadJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: true })
})
// A latched journal loads empty, so it reaches the same branch — and an append
// into one throws, which would make the session unopenable rather than read-only.
it('writes nothing into a journal latched by a newer schema', async () => {
const founded = await open()
await founded.close()
const db = new Database(journalDatabaseFile(root))
try {
db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`)
} finally {
db.close()
}
await writeRemnant()
const latched = await open()
expect(latched.isReadOnly).toBe(true)
expect(disclosure(latched)).toBeNull()
})
// A row nothing projects is a row nobody reads.
it('renders in the transcript as a system line', async () => {
await writeRemnant()
const journal = await open()
const messages = projectStructuredItemsToNativeChat(journal.snapshot().items)
expect(messages).toHaveLength(1)
expect(messages[0]?.role).toBe('system')
})
})
@@ -0,0 +1,60 @@
// A journal directory left behind by the pre-SQLite file format.
//
// Not `journal-legacy-import.ts`, which reads the PROVIDER's own transcript.
// This is Orca's own `log.jsonl`, which no build after the SQLite move reads.
// Nothing imports it, so the session it belonged to opens empty and is
// indistinguishable from a chat created seconds ago — same `session_created`
// epoch, same empty timeline. The remnant is the one durable fact that tells
// them apart, so the empty session says where its history went and how to carry
// on instead of silently claiming it never had any.
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types'
import { boundJournalStatusText } from './journal-prompt-body-bounds'
import { formatAgentTypeLabel } from '../../../shared/agent-type-label'
import type { AgentType } from '../../../shared/agent-status-types'
/** The remnant's transcript, or null when the directory never held one.
*
* `log.jsonl` first, and the order matters: every epoch roll staged a
* `snapshot.json` whether or not anything was ever compacted into it, so the
* file's existence says nothing about where the history lives. Measured across
* a real profile, `compactedThrough` was 0 in all 80 — the log holds the
* transcript and the snapshot is the fallback for a session that has no log. */
export function findJournalFileFormatRemnant(journalDir: string): string | null {
for (const name of ['log.jsonl', 'snapshot.json']) {
const path = join(journalDir, name)
if (existsSync(path)) {
return path
}
}
return null
}
/** One stable identity, so a reopen upserts the same row instead of adding one. */
export const JOURNAL_FILE_FORMAT_REMNANT_DISCLOSURE_IDENTITY: AgentJournalItemIdentity = {
provider: 'orca',
clientMessageId: 'journal-file-format-remnant'
}
/** How to carry on. The session attaches on the record's own provider handle, so it
* still names the conversation the transcript no longer shows — whether the provider
* itself still holds that thread is its own business, hence "points at". */
export function journalFileFormatRemnantDisclosure(input: {
transcriptPath: string
agent: AgentType
}): { identity: AgentJournalItemIdentity; body: { kind: 'status'; text: string } } {
return {
identity: JOURNAL_FILE_FORMAT_REMNANT_DISCLOSURE_IDENTITY,
body: {
kind: 'status',
text: boundJournalStatusText(
`This chat's history was saved in an older format Orca no longer reads, so it starts ` +
`empty. The session still points at the same ${formatAgentTypeLabel(input.agent)} ` +
`conversation — send a message to pick up where you left off. The original ` +
`transcript is on the session's host at \`${input.transcriptPath}\``
)
}
}
}
@@ -1,7 +1,16 @@
import { mkdir } from 'node:fs/promises'
import type { AgentType } from '../../../shared/agent-status-types'
import {
findJournalFileFormatRemnant,
journalFileFormatRemnantDisclosure
} from './journal-file-format-remnant'
import type { JournalLoad } from './journal-open'
import { journalRepairDisclosure, type JournalRepairDisclosure } from './journal-repair-disclosure'
/** What any of this file's disclosures hands the store — a repair's, or the
* pre-SQLite notice's. Same shape, and neither is only a repair. */
type JournalDisclosure = JournalRepairDisclosure
export async function ensureJournalDir(journalDir: string): Promise<void> {
await mkdir(journalDir, { recursive: true })
}
@@ -32,6 +41,7 @@ export async function openJournalStoreState(input: {
body: JournalRepairDisclosure['body'],
fence: number
) => Promise<unknown>
agent: AgentType
highestFence: () => number
malformedRows: () => number
setMalformedRows: (count: number) => void
@@ -40,6 +50,7 @@ export async function openJournalStoreState(input: {
const loaded = input.loaded !== undefined ? input.loaded : input.replay()
if (!loaded) {
input.start()
await discloseFileFormatRemnant(input)
return
}
input.adopt(loaded)
@@ -59,4 +70,43 @@ export async function openJournalStoreState(input: {
const disclosure = journalRepairDisclosure({ malformedRows: input.malformedRows() })
await input.appendDisclosure(disclosure.identity, disclosure.body, input.highestFence())
}
// Founding the epoch and appending the row are two transactions, and a
// committed epoch sends every later open down this branch instead. Anything
// that interrupts between them — a quit during startup restore, a failed
// append — would otherwise lose the message for good. An epoch holding nothing
// is exactly the state that append was owed, so offer it again.
//
// Never onto a repair, though: `loaded.state` is the PRE-repair load, so a
// journal this open just emptied looks identical. The repair's epoch is the
// marker that its history was deleted and never rebuilt, and any row that is
// not the repair's own disclosure retires it — this row would silently stop
// the session ever asking the provider for that history again.
if (!loaded.corrupt && loaded.state.items.size === 0 && loaded.state.submissions.size === 0) {
await discloseFileFormatRemnant(input)
}
}
/** Says what happened to a chat whose history is in the abandoned file format.
* Upserts by a constant identity, so the offer above is exactly-once in effect:
* once the row exists the epoch is no longer empty. */
async function discloseFileFormatRemnant(input: {
journalDir: string
agent: AgentType
appendDisclosure: (
identity: JournalDisclosure['identity'],
body: JournalDisclosure['body'],
fence: number
) => Promise<unknown>
highestFence: () => number
readOnly: () => boolean
}): Promise<void> {
if (input.readOnly()) {
return
}
const transcriptPath = findJournalFileFormatRemnant(input.journalDir)
if (!transcriptPath) {
return
}
const disclosure = journalFileFormatRemnantDisclosure({ transcriptPath, agent: input.agent })
await input.appendDisclosure(disclosure.identity, disclosure.body, input.highestFence())
}
@@ -41,6 +41,7 @@ export function restoreJournalStore(
adopt: host.adopt,
appendDisclosure: (identity, body, fence) =>
host.journal().appendItem(identity, body, { fence }),
agent: host.identity.agent,
highestFence: () => host.state().highestFence,
malformedRows: host.malformedRows,
setMalformedRows: host.setMalformedRows,
@@ -112,4 +112,52 @@ describe('provider frame classification catalog', () => {
// An item type nobody has dispositioned still falls through visibly.
expect(classifyProviderFrame('codex', 'item:futureThing', {})).toBe('timeline-substantive')
})
it('chromes the one unmodelled codex item type that carries no content', () => {
expect(classifyProviderFrame('codex', 'item:sleep', { id: 's', durationMs: 20_000 })).toBe(
'status-chrome'
)
// Payload inspection still outranks the item catalog, so chroming a type
// cannot swallow one that reports a failure.
expect(classifyProviderFrame('codex', 'item:sleep', { id: 's', status: 'failed' })).toBe(
'error-surface'
)
})
it('keeps subagent items visible — the only evidence a spawned agent is working', () => {
expect(
classifyProviderFrame('codex', 'item:subAgentActivity', {
id: 'a-1',
kind: 'started',
agentThreadId: 'thread-child',
agentPath: '/root/list_directory'
})
).toBe('timeline-substantive')
expect(
classifyProviderFrame('codex', 'item:collabAgentToolCall', {
id: 'c-1',
tool: 'spawn',
status: 'inProgress',
senderThreadId: 'thread-root',
receiverThreadIds: ['thread-child'],
agentsStates: {}
})
).toBe('timeline-substantive')
})
it('leaves content-bearing codex item types on the visible fallback', () => {
// Each carries text or a path a user would want: review output, the image
// the agent looked at or generated, injected hook prompt text.
for (const type of [
'imageView',
'imageGeneration',
'enteredReviewMode',
'exitedReviewMode',
'hookPrompt'
]) {
expect(classifyProviderFrame('codex', `item:${type}`, { id: 'i' }), type).toBe(
'timeline-substantive'
)
}
})
})
@@ -197,7 +197,12 @@ function hasProviderError(payload: unknown): boolean {
const CODEX_ITEM_CLASSIFICATIONS: Record<string, ProviderFrameClassification> = {
// The `thread/compacted` notification is already chrome; its item form is the
// same event and must not read as a mysterious opcode row.
contextCompaction: 'status-chrome'
contextCompaction: 'status-chrome',
// `{id, durationMs}` and nothing else — Codex's own transcript renders it as
// nothing at all. Every other item type this build does not model carries text
// a user would want (review output, an image path, hook prompt text, subagent
// progress), so those keep their visible fallback row.
sleep: 'status-chrome'
}
function notificationKind(kind: string): string {
@@ -137,7 +137,11 @@ export type StructuredAgentSessionAdapter = {
turnId: string
fence: number
}): Promise<{ cancelled: boolean }>
stopBackgroundTasks?(input: { sessionId: string; fence: number }): Promise<{ cancelled: boolean }>
stopBackgroundTasks?(input: {
sessionId: string
fence: number
taskId?: string
}): Promise<{ cancelled: boolean }>
backgroundTaskState?(sessionId: string): AgentSessionBackgroundTaskState | null | undefined
/** Fires the provider callback for an approval or a question. The wire calls
* this only after the durable compare-and-set won, so it runs exactly once. */
@@ -8,7 +8,7 @@ import type {
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import { readAgentSessionHistory } from './agent-session-history-page'
function providerSessionMetadata(
export function structuredAgentSessionProviderSessionMetadata(
record: AgentSessionRecord | null
): AgentProviderSessionMetadata | undefined {
const head = record ? agentSessionProviderHandleChainHead(record.providerHandleChain) : null
@@ -27,7 +27,7 @@ export function readStructuredAgentSessionHistoryResult(input: {
}): AgentSessionHistoryResult {
const result = readAgentSessionHistory(input.journal, input.request)
const fence = input.record?.lease.runtimeFence
const providerSession = providerSessionMetadata(input.record)
const providerSession = structuredAgentSessionProviderSessionMetadata(input.record)
if (fence === undefined) {
return providerSession ? { ...result, providerSession } : result
}
@@ -19,6 +19,8 @@ import type {
StructuredAgentSessionHostSession
} from './structured-agent-session-host-types'
import { releaseStoredStructuredAgentSessionOwner } from './structured-agent-session-lease-release'
import { resumeHeldStructuredAgentSession } from './structured-agent-session-hold-resume'
import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire'
export type StructuredAgentSessionLifetimeContext = {
deps: StructuredAgentSessionHostDeps
@@ -67,6 +69,27 @@ export async function evictHeldStructuredAgentSession(
)
}
/** The first hold on a childless session: reconcile the lease, settle recovery, then attach. */
export async function resumeStructuredAgentSessionForHold(
context: StructuredAgentSessionLifetimeContext & {
reconcileLeases: (sessionId: string) => Promise<AgentSessionWireRefusal | null>
},
sessionId: string,
attach: Parameters<typeof resumeHeldStructuredAgentSession>[0]['attach']
): Promise<void> {
const unreconciled = await context.reconcileLeases(sessionId)
if (unreconciled) {
throw new Error(unreconciled.code)
}
await context.runtimeState.resolveRecovery(sessionId)
await resumeHeldStructuredAgentSession({
sessionId,
deps: context.deps,
now: context.now,
attach
})
}
export function createStructuredAgentSessionHolds(
context: StructuredAgentSessionLifetimeContext,
input: {
@@ -78,6 +78,7 @@ export function cancelStructuredAgentSessionTurn(
envelope: AgentSessionMutationEnvelope
turnId: string
scope?: 'background-tasks'
taskId?: string
}
): Promise<AgentSessionMutationResult<AgentSessionCancelResult>> {
return mutate(context, caller, params.envelope, cancelPlan(params))
@@ -2,17 +2,7 @@
// Mutations share one durable admission path and serialize per session.
import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record'
import type {
AgentSessionAttachResult,
AgentSessionHistoryRequest,
AgentSessionHistoryResult,
AgentSessionHandoffRequest,
AgentSessionHandoffResult,
AgentSessionHandoffStatus,
AgentSessionMutationResult,
AgentSessionOptionsResult,
AgentSessionWireRefusal
} from '../../../shared/agent-session-wire'
import type * as SessionWire from '../../../shared/agent-session-wire'
import type { AgentSessionAttachParams } from './structured-agent-session-attach'
import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission'
import { createRestartReconciler } from './structured-agent-session-restart-reconcile'
@@ -33,13 +23,13 @@ import { attachStructuredAgentSession } from './structured-agent-session-attach-
import {
createStructuredAgentSessionHolds,
evictHeldStructuredAgentSession,
resumeStructuredAgentSessionForHold,
type StructuredAgentSessionLifetimeContext
} from './structured-agent-session-host-lifetime'
import type {
StructuredAgentSessionHolds,
StructuredAgentSessionHoldOptions
} from './structured-agent-session-holds'
import { resumeHeldStructuredAgentSession } from './structured-agent-session-hold-resume'
import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context'
import { listStructuredAgentSessionTabs } from './structured-agent-session-host-tabs'
import {
@@ -57,6 +47,7 @@ import type {
StructuredAgentSessionHostDeps,
StructuredAgentSessionHostSession
} from './structured-agent-session-host-types'
import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed'
import { StructuredAgentSessionEventRecovery } from './structured-agent-session-event-recovery'
import { StructuredAgentSessionBackgroundTaskChannel } from './structured-agent-session-background-task-channel'
import { withTimeout } from '../../../shared/promise-timeout-fallback'
@@ -66,10 +57,19 @@ const HANDOFF_DRAIN_TIMEOUT_MS = 5_000
export class StructuredAgentSessionHost {
private readonly sessions = new Map<string, StructuredAgentSessionHostSession>()
private readonly subscribers = new AgentSessionSubscribers()
private readonly statusFeed = new StructuredAgentSessionStatusFeed({
sessions: this.sessions,
getRecord: (sessionId) => this.deps.store.getRecord(sessionId),
now: () => this.now()
})
private readonly subscribers = new AgentSessionSubscribers({
onJournalPublished: (sessionId, journal) => this.statusFeed.publish(sessionId, journal)
})
private readonly tasks = new StructuredAgentSessionTaskQueue()
private readonly runtimeState: StructuredAgentSessionHostRuntimeState
private readonly reconcileLeases: (sessionId: string) => Promise<AgentSessionWireRefusal | null>
private readonly reconcileLeases: (
sessionId: string
) => Promise<SessionWire.AgentSessionWireRefusal | null>
private readonly handoffs: StructuredAgentSessionHostHandoff
private readonly readableRestorer: StructuredAgentSessionReadableRestorer
private readonly restartRestore = new StructuredAgentSessionRestartRestoreGate()
@@ -112,7 +112,12 @@ export class StructuredAgentSessionHost {
now: this.now
})
this.holds = createStructuredAgentSessionHolds(this.lifetimeContext(), {
resume: (sessionId) => this.resumeForHold(sessionId),
resume: (sessionId) =>
resumeStructuredAgentSessionForHold(
{ ...this.lifetimeContext(), reconcileLeases: this.reconcileLeases },
sessionId,
(params) => this.attach({ callerKey: 'trusted-local:surface-hold' }, params)
),
evict: (sessionId) => this.close(sessionId)
})
this.readableRestorer = new StructuredAgentSessionReadableRestorer({
@@ -125,7 +130,10 @@ export class StructuredAgentSessionHost {
hasSession: (sessionId) => this.sessions.has(sessionId),
// Site 10: cannot overwrite a live entry — the restorer returns early on
// `hasSession` inside the same serialized step as this `set`.
onReadable: (sessionId, restored) => this.sessions.set(sessionId, restored),
onReadable: (sessionId, restored) => {
this.sessions.set(sessionId, restored)
this.statusFeed.publish(sessionId)
},
restoreHandoff: (sessionId) => this.handoffs.restore(sessionId)
})
this.eventRecovery = new StructuredAgentSessionEventRecovery({
@@ -160,20 +168,6 @@ export class StructuredAgentSessionHost {
/** That surface is gone. The child outlives it by the release grace, and by any running turn. */
release = (sessionId: string, holderId: string): void => this.holds.release(sessionId, holderId)
private async resumeForHold(sessionId: string): Promise<void> {
const unreconciled = await this.reconcileLeases(sessionId)
if (unreconciled) {
throw new Error(unreconciled.code)
}
await this.runtimeState.resolveRecovery(sessionId)
await resumeHeldStructuredAgentSession({
sessionId,
deps: this.deps,
now: () => this.now(),
attach: (params) => this.attach({ callerKey: 'trusted-local:surface-hold' }, params)
})
}
handleAdapterEvent = (event: Parameters<StructuredAgentSessionEventRecovery['handle']>[0]) =>
this.eventRecovery.handle(event)
@@ -250,7 +244,7 @@ export class StructuredAgentSessionHost {
attach(
caller: StructuredAgentSessionCaller,
params: AgentSessionAttachParams
): Promise<AgentSessionMutationResult<AgentSessionAttachResult>> {
): Promise<SessionWire.AgentSessionMutationResult<SessionWire.AgentSessionAttachResult>> {
return attachStructuredAgentSession(this.attachContext(), caller.callerKey, params)
}
@@ -316,22 +310,23 @@ export class StructuredAgentSessionHost {
requestHandoff = (
caller: StructuredAgentSessionCaller,
params: AgentSessionHandoffRequest
): Promise<AgentSessionMutationResult<AgentSessionHandoffResult>> =>
params: SessionWire.AgentSessionHandoffRequest
): Promise<SessionWire.AgentSessionMutationResult<SessionWire.AgentSessionHandoffResult>> =>
this.handoffs.request(caller.callerKey, params)
readOptions = (sessionId: string): Promise<AgentSessionOptionsResult> =>
readOptions = (sessionId: string): Promise<SessionWire.AgentSessionOptionsResult> =>
readStructuredAgentSessionOptions(this.mutationContext(), sessionId)
async handoffStatus(sessionId: string): Promise<AgentSessionHandoffStatus> {
async handoffStatus(sessionId: string): Promise<SessionWire.AgentSessionHandoffStatus> {
this.requireSession(sessionId)
return this.serialize(sessionId, () =>
refreshRecoverableStructuredHandoffStatus(this.handoffs, this.deps.store, sessionId)
)
}
history = (request: AgentSessionHistoryRequest): AgentSessionHistoryResult =>
this.backgroundTasks.history(request)
history = (
request: SessionWire.AgentSessionHistoryRequest
): SessionWire.AgentSessionHistoryResult => this.backgroundTasks.history(request)
subscribe = (input: AgentSessionSubscribeInput): (() => void) =>
this.backgroundTasks.subscribe(input)
@@ -342,6 +337,11 @@ export class StructuredAgentSessionHost {
) => this.backgroundTasks.publish(sessionId, state)
unsubscribe = (sessionId: string, id: string): void => this.subscribers.close(sessionId, id)
/** Every session's projected status for session lists; unlike `subscribe`, retains nothing. */
subscribeStatus = (
subscriber: Parameters<StructuredAgentSessionStatusFeed['subscribe']>[0]
): (() => void) => this.statusFeed.subscribe(subscriber)
private requireSession(sessionId: string): StructuredAgentSessionHostSession {
const session = this.sessions.get(sessionId)
if (!session) {
@@ -76,15 +76,21 @@ export function cancelPlan(params: {
envelope: AgentSessionMutationEnvelope
turnId: string
scope?: 'background-tasks'
taskId?: string
}): MutationPlan<AgentSessionCancelResult> {
return {
method: 'agentSession.cancel',
fields: { turnId: params.turnId, ...(params.scope ? { scope: params.scope } : {}) },
fields: {
turnId: params.turnId,
...(params.scope ? { scope: params.scope } : {}),
...(params.taskId ? { taskId: params.taskId } : {})
},
run: (ctx) =>
performCancel(ctx, {
clientOperationId: params.envelope.clientOperationId,
turnId: params.turnId,
...(params.scope ? { scope: params.scope } : {})
...(params.scope ? { scope: params.scope } : {}),
...(params.taskId ? { taskId: params.taskId } : {})
}),
// Interrupting twice would kill a turn the client never asked to stop, so a
// replay reports the turn as already handled instead.
@@ -0,0 +1,110 @@
// Read restore decides whether a session comes back at all.
//
// A chat still in the pre-SQLite format has no `journal.db`, so the probe that
// loads one reports nothing. Reading that as "no session" is what removed these
// chats: an unpublished session is also what prunes its tab out of the saved
// workspace, so the tab is gone before anything can explain itself.
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import { journalDirectoryFor } from '../agent-session-journal/journal-paths'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import { restoreStructuredAgentSessionRead } from './structured-agent-session-read-restore'
const SESSION_ID = 'codex_read_restore_fixture'
const WORKSPACE_ID = 'repo-1::/tmp/workspace'
const RECORD = {
schemaVersion: 2,
sessionId: SESSION_ID,
location: {
executionHostId: 'local',
wslDistro: null,
workspaceId: WORKSPACE_ID,
workspaceKind: 'git-worktree'
},
provider: 'codex',
providerHandleChain: [
{
linkId: 'codex-1-thread-1',
handle: { provider: 'codex', threadId: 'thread-1' },
origin: 'created',
mintedAtFence: 1,
observedAt: 1
}
],
accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex-home' },
createdAt: 1,
updatedAt: 2,
lease: { sessionId: SESSION_ID, runtimeKind: 'native', runtimeFence: 1 }
} as unknown as AgentSessionRecord
const store = {
getRecord: (sessionId: string) => (sessionId === SESSION_ID ? RECORD : null)
} as unknown as AgentSessionRecordStore
let journalRoot: string
const opened: AgentSessionJournal[] = []
async function writeRemnant(name: string): Promise<string> {
const dir = journalDirectoryFor(journalRoot, {
workspaceId: WORKSPACE_ID,
sessionId: SESSION_ID
})
await mkdir(dir, { recursive: true })
await writeFile(join(dir, name), '{"kind":"epoch","v":1,"seq":1}\n', 'utf8')
return join(dir, name)
}
beforeEach(async () => {
journalRoot = await mkdtemp(join(tmpdir(), 'orca-read-restore-'))
})
afterEach(async () => {
await Promise.allSettled(opened.splice(0).map((journal) => journal.close()))
await rm(journalRoot, { recursive: true, force: true })
})
describe('a session whose journal is still the pre-SQLite format', () => {
it('is published, carrying the message that explains it', async () => {
const transcript = await writeRemnant('log.jsonl')
const restored = await restoreStructuredAgentSessionRead(store, journalRoot, SESSION_ID)
expect(restored).not.toBeNull()
opened.push(restored!.journal)
const disclosed = restored!.journal
.snapshot()
.items.map((entry) => (entry.body.kind === 'status' ? entry.body.text : ''))
expect(disclosed.join('')).toContain(transcript)
// Publishing it costs no agent process; acquisition still waits for the user.
expect(restored!.hasProviderChild).toBe(false)
})
it('is published for a remnant whose log is gone', async () => {
await writeRemnant('snapshot.json')
const restored = await restoreStructuredAgentSessionRead(store, journalRoot, SESSION_ID)
expect(restored).not.toBeNull()
opened.push(restored!.journal)
})
it('still drops a session with neither a journal nor a remnant', async () => {
const restored = await restoreStructuredAgentSessionRead(store, journalRoot, SESSION_ID)
expect(restored).toBeNull()
})
it('still drops a session with no record', async () => {
await writeRemnant('log.jsonl')
const restored = await restoreStructuredAgentSessionRead(store, journalRoot, 'unknown-session')
expect(restored).toBeNull()
})
})
@@ -3,6 +3,7 @@ import type {
AgentSessionRecord
} from '../../../shared/agent-session-record'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import { findJournalFileFormatRemnant } from '../agent-session-journal/journal-file-format-remnant'
import { loadJournal } from '../agent-session-journal/journal-open'
import { journalDirectoryFor } from '../agent-session-journal/journal-paths'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
@@ -40,15 +41,27 @@ export async function restoreStructuredAgentSessionRead(
sessionId
})
const loaded = loadJournal(journalDir, sessionId)
if (!loaded || loaded.corrupt) {
if (loaded?.corrupt) {
return null
}
// A session still in the pre-SQLite format has no `journal.db` to load. Dropping
// it here leaves it unpublished, which is also what prunes its tab out of the
// saved workspace — so the chat disappears with nowhere to explain itself.
if (!loaded && !findJournalFileFormatRemnant(journalDir)) {
return null
}
const journal = await openAgentSessionJournal({
identity: journalIdentityFor(record, params),
journalDir,
loaded
// Omitted, not `null`: the store reads `null` as "replay already ran and
// found nothing" and founds a fresh epoch. In process the probe above is the
// previous statement, so the window is zero-width; this holds the line for a
// database another process creates in between.
...(loaded ? { loaded } : {})
})
// Read restore opens the journal and nothing else: no adapter call, so no provider child.
// Read restore opens the journal and nothing else: no adapter call, so no
// provider child. Opening it can still write — a session whose history is in
// the old format founds its epoch and commits the row explaining that here.
return {
journal,
params,
@@ -0,0 +1,149 @@
// Startup restore has to publish status, not just index the session.
//
// A tab nobody reopens after a restart still owes the sidebar a row. The host restores such a
// session read-only, without a provider child, so the only thing that can surface its state is
// the status publication the restore wiring makes.
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import type {
AgentSessionMutationEnvelope,
AgentSessionStatusEvent
} from '../../../shared/agent-session-wire'
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
import { StructuredAgentSessionHost } from './structured-agent-session-host'
import {
HOST_TEST_NOW as NOW,
HOST_TEST_SESSION as SESSION,
HOST_TEST_THREAD as THREAD,
hostTestAttachParams,
hostTestMessage,
hostTestOperationId,
resetHostTestOperationIds
} from './structured-agent-session-host-test-data'
const CALLER = { callerKey: 'client-1' }
const hosts: StructuredAgentSessionHost[] = []
let root = ''
function adapter(): StructuredAgentSessionAdapter {
return {
acquire: async ({ fence, spawnToken }) => ({
process: { hostId: 'local', pid: 4242, processStartTimeMs: 1_700_000_000_000, spawnToken },
link: {
linkId: `link-${fence}`,
handle: { provider: 'codex', threadId: THREAD },
origin: 'created',
mintedAtFence: fence,
observedAt: NOW
}
}),
dispatch: async () => ({
state: 'accepted',
providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 }
}),
cancelTurn: async () => ({ cancelled: true }),
answerPrompt: async () => undefined,
setOption: async () => undefined
}
}
function createHost(store: AgentSessionRecordStore): StructuredAgentSessionHost {
const host = new StructuredAgentSessionHost({
store,
adapter: adapter(),
journalRoot: root,
claimKeyId: 'key-1',
mintSpawnToken: () => 'spawn-a',
probeOwner: async () => ({
outcome: 'indeterminate',
reason: 'read does not need ownership'
}),
now: () => NOW
})
hosts.push(host)
return host
}
function sendEnvelope(
store: AgentSessionRecordStore,
fields: Record<string, unknown>
): AgentSessionMutationEnvelope {
return {
sessionId: SESSION,
clientOperationId: hostTestOperationId(),
expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method: 'agentSession.send',
sessionId: SESSION,
fields
})
}
}
/** Persists one turn, then hands back a restarted host over the same directories. */
async function restartWithPersistedTurn(): Promise<StructuredAgentSessionHost> {
root = await mkdtemp(join(tmpdir(), 'orca-restart-status-'))
resetHostTestOperationIds()
const directory = join(root, 'store')
const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' })
const host = createHost(store)
expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true })
const body = hostTestMessage('persisted conversation')
await host.send(CALLER, { envelope: sendEnvelope(store, { body }), body })
await host.flushAllStreamedEvents()
return createHost(await AgentSessionRecordStore.open({ directory, hostId: 'local' }))
}
afterEach(async () => {
await Promise.all(hosts.splice(0).map((host) => host.flushAllStreamedEvents()))
await rm(root, { recursive: true, force: true })
root = ''
})
describe('structured session restart status publication', () => {
// Served by the subscribe-time re-projection rather than the restore's own publish, so this
// covers what a restored journal projects — not the restore wiring. The test below pins that.
it('projects the persisted turn of a session restored without a provider', async () => {
const restarted = await restartWithPersistedTurn()
await restarted.restoreReadableSessions()
const events: AgentSessionStatusEvent[] = []
restarted.subscribeStatus({ id: 'session-list', emit: (event) => events.push(event) })
expect(events).toEqual([
{
type: 'snapshot',
sessions: [
expect.objectContaining({
sessionId: SESSION,
workspaceId: 'workspace-1',
agent: 'codex',
status: 'idle',
latestPrompt: 'persisted conversation'
})
]
}
])
})
it('publishes a restored session to a list already sitting on the stream', async () => {
const restarted = await restartWithPersistedTurn()
const events: AgentSessionStatusEvent[] = []
restarted.subscribeStatus({ id: 'session-list', emit: (event) => events.push(event) })
expect(events).toEqual([{ type: 'snapshot', sessions: [] }])
await restarted.restoreReadableSessions()
// The restore wiring publishes; without it this list never hears about the session at all.
expect(events.at(-1)).toEqual({
type: 'status',
session: expect.objectContaining({ sessionId: SESSION, status: 'idle' })
})
})
})
@@ -0,0 +1,242 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire'
import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open'
import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed'
const SESSION = 'status-session'
const TURN_IDENTITY = {
provider: 'codex',
threadId: 'thread-1',
turnId: 'turn-1',
ordinal: 0
} as const
const USER_IDENTITY = {
provider: 'codex',
threadId: 'thread-1',
turnId: 'turn-1',
ordinal: 1
} as const
let root: string
const journals = createTrackedJournalOpener()
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-agent-status-feed-'))
})
afterEach(async () => {
await journals.closeAll()
await rm(root, { recursive: true, force: true })
})
async function openJournal(sessionId = SESSION) {
return journals.open({
identity: {
sessionId,
workspaceId: 'workspace-1',
hostId: 'local',
agent: 'codex',
providerHandle: { kind: 'codex', threadId: 'thread-1' }
},
journalDir: join(root, sessionId)
})
}
function indexed(session: { journal: Awaited<ReturnType<typeof openJournal>> }) {
return {
journal: session.journal,
params: { location: { workspaceId: 'workspace-1' }, provider: 'codex' as const }
}
}
function feedFor(sessions: Map<string, { journal: Awaited<ReturnType<typeof openJournal>> }>) {
let now = 1_000
const feed = new StructuredAgentSessionStatusFeed({
sessions: {
get: (sessionId: string) => {
const session = sessions.get(sessionId)
return session ? indexed(session) : undefined
},
[Symbol.iterator]: function* () {
for (const [sessionId, session] of sessions) {
yield [sessionId, indexed(session)] as const
}
}
} as unknown as ReadonlyMap<string, ReturnType<typeof indexed>>,
getRecord: () => null,
now: () => (now += 1)
})
const events: AgentSessionStatusEvent[] = []
const dispose = feed.subscribe({ id: 'list-1', emit: (event) => events.push(event) })
return { feed, events, dispose }
}
describe('StructuredAgentSessionStatusFeed', () => {
it('opens with every readable session and reports no status before a persisted turn', async () => {
const journal = await openJournal()
const { events } = feedFor(new Map([[SESSION, { journal }]]))
expect(events).toEqual([
{
type: 'snapshot',
sessions: [
{
sessionId: SESSION,
workspaceId: 'workspace-1',
agent: 'codex',
status: null,
latestPrompt: '',
updatedAt: expect.any(Number)
}
]
}
])
})
it('publishes working, then idle once the running marker is tombstoned, and never a repeat', async () => {
const journal = await openJournal()
const { feed, events } = feedFor(new Map([[SESSION, { journal }]]))
await journal.appendItem(
USER_IDENTITY,
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'write a poem' }] },
{ fence: 1 }
)
await journal.appendItem(
TURN_IDENTITY,
{ kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } },
{ fence: 1 }
)
feed.publish(SESSION)
feed.publish(SESSION)
expect(events.slice(1)).toEqual([
{
type: 'status',
session: expect.objectContaining({
sessionId: SESSION,
status: 'working',
latestPrompt: 'write a poem'
})
}
])
await journal.appendTombstone(TURN_IDENTITY, { fence: 1 })
feed.publish(SESSION)
expect(events.at(-1)).toEqual({
type: 'status',
session: expect.objectContaining({ sessionId: SESSION, status: 'idle' })
})
expect(events).toHaveLength(3)
})
it('reports a pending approval as attention', async () => {
const journal = await openJournal()
const { feed, events } = feedFor(new Map([[SESSION, { journal }]]))
await journal.appendItem(
USER_IDENTITY,
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'run it' }] },
{ fence: 1 }
)
await journal.appendItem(
TURN_IDENTITY,
{
kind: 'approval',
title: 'Run command?',
detail: null,
options: [{ id: 'yes', label: 'Allow' }],
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
},
{ fence: 1 }
)
feed.publish(SESSION)
expect(events.at(-1)).toEqual({
type: 'status',
session: expect.objectContaining({ status: 'attention' })
})
})
it('keeps the last projection for an evicted session and serves it to a new subscriber', async () => {
const journal = await openJournal()
const sessions = new Map([[SESSION, { journal }]])
const { feed, events } = feedFor(sessions)
await journal.appendItem(
USER_IDENTITY,
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] },
{ fence: 1 }
)
feed.publish(SESSION)
expect(events.at(-1)).toEqual({
type: 'status',
session: expect.objectContaining({ status: 'idle', latestPrompt: 'hello' })
})
// Eviction drops the host's index entry; the projection it already made stays true.
sessions.delete(SESSION)
feed.publish(SESSION)
const late: AgentSessionStatusEvent[] = []
feed.subscribe({ id: 'list-late', emit: (event) => late.push(event) })
expect(events).toHaveLength(2)
expect(late).toEqual([
{
type: 'snapshot',
sessions: [expect.objectContaining({ sessionId: SESSION, status: 'idle' })]
}
])
})
it('tells the sitting subscribers about a change a new subscriber re-projected', async () => {
const journal = await openJournal()
const { feed, events } = feedFor(new Map([[SESSION, { journal }]]))
// Journal appends and the feed's publish are separate queue submissions, so the journal
// can already hold the turn when a second client connects and re-projects it.
await journal.appendItem(
USER_IDENTITY,
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] },
{ fence: 1 }
)
const late: AgentSessionStatusEvent[] = []
feed.subscribe({ id: 'list-late', emit: (event) => late.push(event) })
expect(events.at(-1)).toEqual({
type: 'status',
session: expect.objectContaining({ status: 'idle', latestPrompt: 'hello' })
})
// The arriving subscriber reads that same state once, from its snapshot.
expect(late).toEqual([
{
type: 'snapshot',
sessions: [expect.objectContaining({ status: 'idle', latestPrompt: 'hello' })]
}
])
// The cache is not left holding a value nobody was told about.
feed.publish(SESSION)
expect(events).toHaveLength(2)
})
it('ends a closed subscriber and keeps publishing to the rest', async () => {
const journal = await openJournal()
const { feed, events, dispose } = feedFor(new Map([[SESSION, { journal }]]))
const others: AgentSessionStatusEvent[] = []
feed.subscribe({ id: 'list-2', emit: (event) => others.push(event) })
dispose()
await journal.appendItem(
USER_IDENTITY,
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] },
{ fence: 1 }
)
feed.publish(SESSION)
expect(events.at(-1)).toEqual({ type: 'end' })
expect(others.at(-1)).toEqual({
type: 'status',
session: expect.objectContaining({ status: 'idle' })
})
})
})
@@ -0,0 +1,130 @@
// The host's answer to "what is every structured session doing", fanned out to session lists.
//
// A client used to learn whether a turn was running by replaying the journal through its own
// reducer, which tied the answer to whichever surface happened to hold a reader open: hide the
// chat and the sidebar froze on the last thing it had heard. The host always has the journal, so
// it projects the status once per journal publication and sends only the changes.
//
// The last projection is kept after the session's provider child is evicted: an idle session is
// still idle without a process, and a renderer that reloads must not lose every settled row until
// each chat is reopened. Restart is the one boundary that forgets, and restoring readable sessions
// republishes them.
import { agentProviderSessionsEqual } from '../../../shared/agent-session-resume'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type {
AgentSessionStatusEvent,
AgentSessionStatusSummary
} from '../../../shared/agent-session-wire'
import { projectStructuredAgentSessionStatusSummary } from '../../../shared/structured-agent-session-projection'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import { structuredAgentSessionProviderSessionMetadata } from './structured-agent-session-history-result'
export type StructuredAgentSessionStatusSubscriber = {
id: string
emit: (event: AgentSessionStatusEvent) => void
}
type StatusFeedSession = {
journal: AgentSessionJournal
params: { location: { workspaceId: string }; provider: AgentSessionRecord['provider'] }
}
export type StructuredAgentSessionStatusFeedDeps = {
sessions: ReadonlyMap<string, StatusFeedSession>
getRecord: (sessionId: string) => AgentSessionRecord | null
now: () => number
}
function summariesEqual(a: AgentSessionStatusSummary, b: AgentSessionStatusSummary): boolean {
return (
a.workspaceId === b.workspaceId &&
a.agent === b.agent &&
a.status === b.status &&
a.latestPrompt === b.latestPrompt &&
agentProviderSessionsEqual(undefined, a.providerSession, b.providerSession)
)
}
export class StructuredAgentSessionStatusFeed {
private readonly subscribers = new Map<string, StructuredAgentSessionStatusSubscriber>()
private readonly published = new Map<string, AgentSessionStatusSummary>()
constructor(private readonly deps: StructuredAgentSessionStatusFeedDeps) {}
/** Opens with every session this host has projected, live ones re-read, then only changes. */
subscribe(subscriber: StructuredAgentSessionStatusSubscriber): () => void {
// Re-project before registering: a change found here has to reach the subscribers that
// already read the old value, and the arriving one carries it in its snapshot instead.
for (const [sessionId] of this.deps.sessions) {
this.publish(sessionId)
}
this.subscribers.set(subscriber.id, subscriber)
this.emit(subscriber, { type: 'snapshot', sessions: [...this.published.values()] })
return () => this.unsubscribe(subscriber.id)
}
unsubscribe(id: string): void {
const subscriber = this.subscribers.get(id)
if (!subscriber) {
return
}
this.subscribers.delete(id)
try {
subscriber.emit({ type: 'end' })
} catch {
// The transport is already gone; teardown must remain idempotent.
}
}
/** Re-projects one session after its journal changed; equal projections are not re-sent. */
publish(sessionId: string, journal?: AgentSessionJournal): void {
const session = this.deps.sessions.get(sessionId)
if (!session) {
return
}
const summary = this.summaryFor(sessionId, session, journal ?? session.journal)
const previous = this.published.get(sessionId)
if (previous && summariesEqual(previous, summary)) {
return
}
this.published.set(sessionId, summary)
this.broadcast({ type: 'status', session: summary })
}
private summaryFor(
sessionId: string,
session: StatusFeedSession,
journal: AgentSessionJournal
): AgentSessionStatusSummary {
// An unreadable journal projects as "no turn": the chat itself shows the reset.
const items = journal.isReadOnly ? [] : journal.snapshot().items
const providerSession = structuredAgentSessionProviderSessionMetadata(
this.deps.getRecord(sessionId)
)
return {
sessionId,
workspaceId: session.params.location.workspaceId,
agent: session.params.provider,
...projectStructuredAgentSessionStatusSummary(items),
...(providerSession ? { providerSession } : {}),
updatedAt: this.deps.now()
}
}
private broadcast(event: AgentSessionStatusEvent): void {
// A Map skips entries deleted mid-iteration, so a failing subscriber can drop itself here.
for (const subscriber of this.subscribers.values()) {
this.emit(subscriber, event)
}
}
/** A dead transport must not poison every later publication. */
private emit(subscriber: StructuredAgentSessionStatusSubscriber, event: AgentSessionStatusEvent) {
try {
subscriber.emit(event)
} catch {
this.subscribers.delete(subscriber.id)
}
}
}
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types'
import type {
AgentSessionHandoffStatus,
AgentSessionStatusEvent,
AgentSessionSubscribeEvent
} from '../../../shared/agent-session-wire'
import {
@@ -16,6 +17,7 @@ import { journalDatabaseFile } from '../agent-session-journal/journal-paths'
import { insertJournalRow } from '../agent-session-journal/journal-row-table'
import type { JournalRow } from '../agent-session-journal/journal-row-schema'
import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open'
import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed'
import { AgentSessionSubscribers } from './structured-agent-session-subscribers'
const SESSION = 'subscriber-session'
@@ -70,6 +72,96 @@ describe('AgentSessionSubscribers', () => {
])
})
it('reports every content publication to the journal hook, subscribed or not', async () => {
const journal = await journals.open({
identity: {
sessionId: SESSION,
workspaceId: 'workspace-1',
hostId: 'local',
agent: 'codex',
providerHandle: { kind: 'codex', threadId: 'thread-1' }
},
journalDir: join(root, 'hook-journal')
})
const published: string[] = []
const subscribers = new AgentSessionSubscribers({
onJournalPublished: (sessionId, published_journal) => {
expect(published_journal).toBe(journal)
published.push(sessionId)
}
})
subscribers.publish(SESSION, journal)
subscribers.reset(SESSION, journal, 'epoch_changed', 1)
subscribers.snapshot(SESSION, journal, 1)
subscribers.handoff(SESSION, 1, {
owner: 'native',
direction: null,
phase: 'idle',
stage: null,
operationId: null
})
expect(published).toEqual([SESSION, SESSION, SESSION])
})
it('settles a session nobody is reading, from running to idle', async () => {
// The defect this whole feed exists for: status used to come from a transcript reader, so a
// session with no open pane had no reader and froze on whatever it last said. Nothing here
// ever calls `subscribers.open`.
const journal = await journals.open({
identity: {
sessionId: SESSION,
workspaceId: 'workspace-1',
hostId: 'local',
agent: 'codex',
providerHandle: { kind: 'codex', threadId: 'thread-1' }
},
journalDir: join(root, 'unread-journal')
})
const statusFeed = new StructuredAgentSessionStatusFeed({
sessions: new Map([
[
SESSION,
{ journal, params: { location: { workspaceId: 'workspace-1' }, provider: 'codex' } }
]
]),
getRecord: () => null,
now: () => 1_000
})
const subscribers = new AgentSessionSubscribers({
onJournalPublished: (sessionId, published) => statusFeed.publish(sessionId, published)
})
const statuses: AgentSessionStatusEvent[] = []
statusFeed.subscribe({ id: 'session-list', emit: (event) => statuses.push(event) })
const turn = { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 } as const
await journal.appendItem(
{ ...turn, ordinal: 1 },
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'write a poem' }] },
{ fence: 1 }
)
await journal.appendItem(
turn,
{ kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } },
{ fence: 1 }
)
subscribers.publish(SESSION, journal)
expect(statuses.at(-1)).toEqual({
type: 'status',
session: expect.objectContaining({ status: 'working', latestPrompt: 'write a poem' })
})
await journal.appendTombstone(turn, { fence: 1 })
subscribers.publish(SESSION, journal)
expect(statuses.at(-1)).toEqual({
type: 'status',
session: expect.objectContaining({ status: 'idle' })
})
})
it('publishes handoff-only changes without serializing a transcript snapshot', async () => {
const journal = await journals.open({
identity: {
@@ -36,9 +36,17 @@ type Subscriber = {
fence: number
}
export type AgentSessionSubscribersHooks = {
/** Fires after any publication that can change journal content, whether or not anyone
* is subscribed to the transcript: session lists project status from this same edge. */
onJournalPublished?: (sessionId: string, journal: AgentSessionJournal) => void
}
export class AgentSessionSubscribers {
private readonly bySession = new Map<string, Map<string, Subscriber>>()
constructor(private readonly hooks: AgentSessionSubscribersHooks = {}) {}
/** Opens the stream with a bounded tail page or, when the client's cursor
* still resolves, with the rows it missed. Returns the disposer. */
open(input: {
@@ -99,6 +107,7 @@ export class AgentSessionSubscribers {
for (const subscriber of this.subscribers(sessionId)) {
this.deliver(subscriber, journal)
}
this.hooks.onJournalPublished?.(sessionId, journal)
}
/** Force every subscriber back to a bounded tail page — recovery, epoch
@@ -123,6 +132,7 @@ export class AgentSessionSubscribers {
subscriber.cursor = page.liveCursor ?? page.window.nextCursor
subscriber.fence = fence
}
this.hooks.onJournalPublished?.(sessionId, journal)
}
snapshot(
@@ -143,6 +153,7 @@ export class AgentSessionSubscribers {
subscriber.cursor = page.liveCursor ?? page.window.nextCursor
subscriber.fence = fence
}
this.hooks.onJournalPublished?.(sessionId, journal)
}
handoff(sessionId: string, fence: number, handoff: AgentSessionHandoffStatus): void {
@@ -104,4 +104,40 @@ describe('performCancel', () => {
expect(cancelTurn).not.toHaveBeenCalled()
expect(journal.snapshot().items).toEqual([])
})
it('routes one background task id without interrupting the foreground turn or writing a row', async () => {
root = await mkdtemp(join(tmpdir(), 'orca-background-task-targeted-cancel-'))
const journal = await journals.open({ identity: IDENTITY, journalDir: root })
const cancelTurn = vi.fn(async () => ({ cancelled: true }))
const stopBackgroundTasks = vi.fn(async () => ({ cancelled: true }))
const ctx: AgentSessionTurnContext = {
sessionId: 'session-1',
journal,
fence: 1,
adapter: { cancelTurn, stopBackgroundTasks } as unknown as StructuredAgentSessionAdapter,
persistOptions: async () => undefined,
resolvedBy: 'client-1',
publish: vi.fn(),
now: () => 1
}
const result = await performCancel(ctx, {
clientOperationId: 'cancel-background-task-2',
turnId: 'background-tasks',
scope: 'background-tasks',
taskId: 'task-2'
})
expect(result).toEqual({
ok: true,
value: { turnId: 'background-tasks', cancelled: true }
})
expect(stopBackgroundTasks).toHaveBeenCalledWith({
sessionId: 'session-1',
fence: 1,
taskId: 'task-2'
})
expect(cancelTurn).not.toHaveBeenCalled()
expect(journal.snapshot().items).toEqual([])
})
})
@@ -150,6 +150,7 @@ export async function performCancel(
clientOperationId: string
turnId: string
scope?: 'background-tasks'
taskId?: string
}
): Promise<TurnOutcome<AgentSessionCancelResult>> {
let cancelled = false
@@ -159,7 +160,8 @@ export async function performCancel(
? (
await ctx.adapter.stopBackgroundTasks?.({
sessionId: ctx.sessionId,
fence: ctx.fence
fence: ctx.fence,
...(input.taskId ? { taskId: input.taskId } : {})
})
)?.cancelled === true
: (
@@ -172,6 +172,7 @@ describe('LocalPtyProvider', () => {
expect(second).toEqual({
id: 'serve-session-1',
incarnationId: first.incarnationId,
pid: 12345,
isReattach: true,
// Why published: this attach really moved the PTY, unlike daemon/relay attach, so main
@@ -220,7 +221,11 @@ describe('LocalPtyProvider', () => {
attachOnly: true
})
expect(result).toMatchObject({ id: first.id, isReattach: true })
expect(result).toMatchObject({
id: first.id,
incarnationId: first.incarnationId,
isReattach: true
})
expect(spawnMock).not.toHaveBeenCalled()
})
@@ -1,6 +1,7 @@
import type { PtySpawnResult } from './types'
import {
pendingLocalPtySpawns,
ptyIncarnations,
ptyProcesses,
ptyWslDistroById,
type PendingLocalPtySpawn
@@ -60,6 +61,7 @@ export function reattachLocalPty(id: string, cols: number, rows: number): PtySpa
}
return {
id,
...(ptyIncarnations.has(id) ? { incarnationId: ptyIncarnations.get(id) } : {}),
pid: existing.pid,
...(ptyWslDistroById.has(id) ? { wslDistro: ptyWslDistroById.get(id) ?? null } : {}),
isReattach: true,
@@ -599,6 +599,46 @@ describe('a structured Claude session over agentSession.*', () => {
`claude:${PROVIDER_SESSION}:assistant-leaf`
)
claude.live().handlers.onMessage?.({
type: 'system',
subtype: 'background_tasks_changed',
session_id: PROVIDER_SESSION,
uuid: 'background-roster',
tasks: [
{ task_id: 'task-one', task_type: 'local_agent', description: 'First task' },
{ task_id: 'task-two', task_type: 'local_bash', description: 'Second task' }
]
})
const itemsBeforeTaskStop = itemsOf(stream)
const targetedStopFields = {
turnId: 'background-tasks',
scope: 'background-tasks',
taskId: 'task-two'
}
await expect(
ok('agentSession.cancel', {
envelope: envelope('agentSession.cancel', targetedStopFields, created.fence),
...targetedStopFields
})
).resolves.toMatchObject({ turnId: 'background-tasks', cancelled: true })
expect(claude.live().calls.filter((entry) => entry.subtype === 'stop_task')).toEqual([
{ subtype: 'stop_task', params: { taskId: 'task-two' } }
])
expect(itemsOf(stream)).toEqual(itemsBeforeTaskStop)
const staleStopFields = {
turnId: 'background-tasks',
scope: 'background-tasks',
taskId: 'task-stale'
}
await expect(
ok('agentSession.cancel', {
envelope: envelope('agentSession.cancel', staleStopFields, created.fence),
...staleStopFields
})
).resolves.toMatchObject({ turnId: 'background-tasks', cancelled: false })
expect(claude.live().calls.filter((entry) => entry.subtype === 'stop_task')).toHaveLength(1)
const answeredPermission = Promise.resolve(
claude.live().handlers.canUseTool?.('Bash', { command: 'ls' }, {
requestId: 'permission-1',
+11 -2
View File
@@ -176,8 +176,17 @@ describe('OrcaRuntimeService.fetchRemoteWithCache', () => {
expect(caches.fetchLastCompletedAt.has('/repo/cache-0::origin')).toBe(false)
})
it.each(['main', 'a'.repeat(40), 'refs/remotes/main', ''])(
'does not launch Git for a base without a remote/branch separator: %s',
it.each([
'main',
'a'.repeat(40),
'refs/remotes/main',
'',
'origin/',
'/main',
'refs/remotes/origin/',
'refs/remotes//main'
])(
'does not launch Git for a base without both remote and branch components: %s',
async (base) => {
const runtime = new OrcaRuntimeService(null)
await expect(runtime.resolveRemoteTrackingBase('/repo/e', base)).resolves.toBeNull()
@@ -153,6 +153,11 @@ export class OrcaRuntimeWithRefreshRepoWorktreeScan extends OrcaRuntimeWithListK
}
}
invalidateWorktreeCatalog(repoId: string): void {
this.invalidateResolvedWorktreeCache()
this.invalidateWorktreeScanCacheForRepo(repoId)
}
protected invalidateSshWorktreeScanCacheInternal(targetId: string): void {
const repos = this.store?.getRepos() ?? []
const affectedRepos = repos.filter((repo) => getRepoSshConnectionId(repo) === targetId)
@@ -50,7 +50,13 @@ describe('OrcaRuntimeService', () => {
pushTarget: { remoteName: 'origin', branchName: 'feature/fix' }
})
expect(getBranchConflictKind).toHaveBeenCalledWith(TEST_REPO_PATH, 'feature/fix', 'abc123')
expect(getBranchConflictKind).toHaveBeenCalledWith(
TEST_REPO_PATH,
'feature/fix',
'abc123',
{},
undefined
)
expect(getPRForBranchMock).toHaveBeenCalledWith(TEST_REPO_PATH, 'feature/fix')
expect(addWorktree).toHaveBeenCalledWith(
TEST_REPO_PATH,
@@ -165,7 +171,9 @@ describe('OrcaRuntimeService', () => {
expect(getBranchConflictKind).toHaveBeenCalledWith(
TEST_REPO_PATH,
'feature/bitbucket',
'abc123'
'abc123',
{},
undefined
)
expect(getHostedReviewForBranchMock).toHaveBeenCalledWith(
expect.objectContaining({
@@ -533,10 +533,14 @@ describe('OrcaRuntimeService', () => {
branchNameOverride: 'feature/something'
})
// Why: an explicit branch override adopts the local branch before the conflict
// probe, so no lazy adoption callback is handed to getBranchConflictKind.
expect(getBranchConflictKind).toHaveBeenCalledWith(
TEST_REPO_PATH,
'feature/something',
'origin/feature/something'
'origin/feature/something',
{},
undefined
)
expect(addWorktree).toHaveBeenCalledWith(
TEST_REPO_PATH,
@@ -376,7 +376,18 @@ describe('OrcaRuntimeService', () => {
TEST_REPO_PATH,
'runtime-wsl',
'origin/main',
{ wslDistro: 'Ubuntu' }
{ wslDistro: 'Ubuntu' },
expect.any(Function)
)
// Why: the lazy adoption callback is only invoked when the conflict probe
// sees a local ref, so drive it here to prove adoption also routes via WSL.
const adoptLocalBranch = vi
.mocked(getBranchConflictKind)
.mock.calls.findLast((call) => call[1] === 'runtime-wsl')?.[4]
await expect(adoptLocalBranch?.()).resolves.toBe(false)
expect(gitSpy).toHaveBeenCalledWith(
['rev-parse', '--verify', '--quiet', 'refs/heads/runtime-wsl^{commit}'],
{ cwd: TEST_REPO_PATH, wslDistro: 'Ubuntu' }
)
expect(getPRForBranchMock).toHaveBeenCalledWith(
TEST_REPO_PATH,
@@ -152,9 +152,13 @@ export const CancelParams = z
.object({
envelope: MutationEnvelope,
turnId: Identifier('Invalid turn id'),
scope: z.literal('background-tasks').optional()
scope: z.literal('background-tasks').optional(),
taskId: Identifier('Invalid task id').optional()
})
.strict()
.refine((value) => value.taskId === undefined || value.scope === 'background-tasks', {
message: 'A task id requires background-task scope'
})
export const RespondParams = z
.object({
@@ -0,0 +1,62 @@
// `agentSession.subscribeStatus` — every structured session's projected status on one stream.
//
// Session lists read turn state from here instead of replaying transcripts: one stream per client
// covers every session, and unlike a transcript subscription it retains none of them.
import { defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core'
import { requireStructuredHost as requireHost } from './structured-agent-session-gate'
import { structuredAgentSessionStatusSubscriptionId } from './structured-agent-session-subscription-id'
/** Ties a stream to both ends that can close it — the runtime's subscription registry and the
* transport abort — so either one runs `onClose` exactly once. */
export function bindStructuredAgentSessionStream(
ctx: RpcContext,
subscriptionId: string,
onClose: () => void
): { isClosed: () => boolean } {
let closed = false
let releaseTransportSubscription = (): void => {}
const onTransportAbort = (): void => releaseTransportSubscription()
const cleanup = (): void => {
closed = true
ctx.signal?.removeEventListener('abort', onTransportAbort)
onClose()
}
let registration: { releaseIfCurrent: () => void }
if (typeof ctx.runtime.registerOwnedSubscriptionCleanup === 'function') {
registration = ctx.runtime.registerOwnedSubscriptionCleanup(
subscriptionId,
cleanup,
ctx.connectionId
)
} else {
ctx.runtime.registerSubscriptionCleanup(subscriptionId, cleanup, ctx.connectionId)
registration = { releaseIfCurrent: () => ctx.runtime.cleanupSubscription(subscriptionId) }
}
releaseTransportSubscription = registration.releaseIfCurrent
ctx.signal?.addEventListener('abort', onTransportAbort, { once: true })
if (ctx.signal?.aborted) {
onTransportAbort()
}
return { isClosed: () => closed }
}
export const STRUCTURED_AGENT_SESSION_STATUS_METHODS: RpcAnyMethod[] = [
defineStreamingMethod({
name: 'agentSession.subscribeStatus',
params: null,
handler: async (_params, ctx, emit) => {
const host = requireHost(ctx)
const subscriptionId = structuredAgentSessionStatusSubscriptionId(ctx)
let dispose = (): void => {}
const stream = bindStructuredAgentSessionStream(ctx, subscriptionId, () => dispose())
if (stream.isClosed()) {
return
}
dispose = host.subscribeStatus({ id: subscriptionId, emit })
if (stream.isClosed()) {
dispose()
}
}
})
]
@@ -0,0 +1,29 @@
// Subscription ids for the streaming `agentSession.*` methods.
//
// Shared control multiplexes several streams over one socket, so the frame id keeps one
// subscriber from evicting another. It is appended only when present: collapsing a missing
// frame id to a constant is the collision the rule exists to prevent.
import type { RpcContext } from '../core'
const SUBSCRIPTION_PREFIX = 'agentSession'
function withFrameId(ctx: RpcContext, base: string): string {
return ctx.requestId ? `${base}:${ctx.requestId}` : base
}
/** The id a session's streams share before the frame id. `unsubscribe` addresses this
* directly and sweeps `${base}:` to reach every frame under it. */
export function structuredAgentSessionSubscriptionBase(ctx: RpcContext, sessionId: string): string {
return `${SUBSCRIPTION_PREFIX}:${ctx.connectionId ?? 'local'}:${sessionId}`
}
/** One session's transcript stream. */
export function structuredAgentSessionSubscriptionId(ctx: RpcContext, sessionId: string): string {
return withFrameId(ctx, structuredAgentSessionSubscriptionBase(ctx, sessionId))
}
/** The status feed, which is per connection rather than per session. */
export function structuredAgentSessionStatusSubscriptionId(ctx: RpcContext): string {
return withFrameId(ctx, `${SUBSCRIPTION_PREFIX}.status:${ctx.connectionId ?? 'local'}`)
}
@@ -2,8 +2,14 @@
// accepts once they can.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
import type { AgentSessionJournal } from '../../../native-chat/agent-session-journal/journal-store'
import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host'
import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry'
import {
StructuredAgentSessionStatusFeed,
type StructuredAgentSessionStatusSubscriber
} from '../../../native-chat/agent-session-wire/structured-agent-session-status-feed'
import {
RUNTIME_CAPABILITIES,
RUNTIME_PROTOCOL_VERSION,
@@ -64,6 +70,44 @@ function request(method: string, params: unknown): RpcRequest {
let hostCalls: Record<string, ReturnType<typeof vi.fn>>
let runtimeCalls: Record<string, ReturnType<typeof vi.fn>>
const STATUS_SESSION = 'session-status'
const STATUS_ITEMS: AgentJournalRenderItem[] = [
{
itemId: 'user-1',
sequence: 1,
revision: 1,
observedAt: 1,
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'write a poem' }] }
},
{
itemId: 'turn-1',
sequence: 2,
revision: 1,
observedAt: 2,
body: { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }
}
]
/** One indexed session over a journal that reads back fixed items; the projection is real. */
function statusFeed(): StructuredAgentSessionStatusFeed {
return new StructuredAgentSessionStatusFeed({
sessions: new Map([
[
STATUS_SESSION,
{
journal: {
isReadOnly: false,
snapshot: () => ({ items: STATUS_ITEMS })
} as unknown as AgentSessionJournal,
params: { location: { workspaceId: 'workspace-1' }, provider: 'codex' as const }
}
]
]),
getRecord: () => null,
now: () => 1_000
})
}
function hostStub(): StructuredAgentSessionHost {
hostCalls = {
attach: vi.fn(async () => ({
@@ -122,6 +166,11 @@ function hostStub(): StructuredAgentSessionHost {
})),
history: vi.fn(() => ({ ok: true, page: { items: [] } })),
subscribe: vi.fn(() => () => undefined),
// A real feed, so the snapshot this method hands back is a genuine projection rather
// than a shape the stub restated.
subscribeStatus: vi.fn((subscriber: StructuredAgentSessionStatusSubscriber) =>
statusFeed().subscribe(subscriber)
),
unsubscribe: vi.fn()
}
return hostCalls as unknown as StructuredAgentSessionHost
@@ -240,7 +289,7 @@ describe('capability gating', () => {
}
// Bump deliberately: the whole agentSession.* surface is behind the structured capability,
// so an additive method is invisible to old clients and needs no protocol bump.
expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(17)
expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(18)
})
it('hides the surface from a declared client that did not advertise it', async () => {
@@ -480,6 +529,20 @@ describe('method routing', () => {
])
})
it('routes an optional background task id through cancellation', async () => {
const params = {
envelope: envelope(),
turnId: 'background-tasks',
scope: 'background-tasks' as const,
taskId: 'task-2'
}
const response = await call('agentSession.cancel', params, STRUCTURED_CLIENT)
expect(response).toMatchObject({ ok: true })
expect(hostCalls.cancel).toHaveBeenCalledWith(expect.anything(), params)
})
it('routes the structured handoff mutation through the host', async () => {
const response = await call('agentSession.requestHandoff', {
envelope: envelope(),
@@ -510,6 +573,21 @@ describe('parameter validation', () => {
})
})
it('rejects invalid or unscoped background task ids', async () => {
await rejects('agentSession.cancel', {
envelope: envelope(),
turnId: 'background-tasks',
scope: 'background-tasks',
taskId: ' task-2'
})
await rejects('agentSession.cancel', {
envelope: envelope(),
turnId: 'turn-1',
taskId: 'task-2'
})
expect(hostCalls.cancel).not.toHaveBeenCalled()
})
it('refuses to let a client author anything but a user turn', async () => {
await rejects(
'agentSession.send',
@@ -583,3 +661,31 @@ describe('parameter validation', () => {
expect(response).toMatchObject({ ok: true })
})
})
describe('agentSession.subscribeStatus', () => {
it('is invisible to a client without the structured capability', async () => {
const reply = await call('agentSession.subscribeStatus', null, { clientKind: 'runtime' })
expect(reply.ok).toBe(false)
expect(hostCalls.subscribeStatus).not.toHaveBeenCalled()
})
it('opens the host status feed with a projected snapshot as its first reply', async () => {
const reply = await call('agentSession.subscribeStatus', null, STRUCTURED_CLIENT)
expect(reply).toMatchObject({
ok: true,
result: {
type: 'snapshot',
sessions: [
{
sessionId: STATUS_SESSION,
workspaceId: 'workspace-1',
agent: 'codex',
status: 'working',
latestPrompt: 'write a poem'
}
]
}
})
expect(hostCalls.subscribeStatus).toHaveBeenCalledOnce()
})
})
@@ -21,6 +21,14 @@ import {
import type { AgentSessionAttachParams } from '../../../native-chat/agent-session-wire/structured-agent-session-attach'
import { STRUCTURED_AGENT_SESSION_HOLD_METHODS } from './structured-agent-session-hold'
import { resolveUncommittedStructuredCreate } from './structured-agent-session-precommit-refusal'
import {
bindStructuredAgentSessionStream,
STRUCTURED_AGENT_SESSION_STATUS_METHODS
} from './structured-agent-session-status-stream'
import {
structuredAgentSessionSubscriptionBase as subscriptionBaseFor,
structuredAgentSessionSubscriptionId as subscriptionIdFor
} from './structured-agent-session-subscription-id'
import {
AttachParams,
CancelParams,
@@ -37,15 +45,6 @@ import {
UnsubscribeParams
} from './structured-agent-session-schemas'
const SUBSCRIPTION_PREFIX = 'agentSession'
function subscriptionIdFor(ctx: RpcContext, sessionId: string): string {
const base = `${SUBSCRIPTION_PREFIX}:${ctx.connectionId ?? 'local'}:${sessionId}`
// Shared control multiplexes several streams over one socket; the frame id
// keeps one subscriber from evicting another on the same session.
return ctx.requestId ? `${base}:${ctx.requestId}` : base
}
/**
* The attach-shaped entries take the location from the client instead of resolving it from a
* worktree, so they never reach the worktree-resolving create-support check. Ask the executing
@@ -245,33 +244,12 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [
// Retain-only: reading history must never be what starts a provider process. Current clients
// explicitly hold every open surface before subscribing.
const streamHolder = `subscription:${subscriptionId}`
let closed = false
let dispose = (): void => {}
let releaseTransportSubscription = (): void => {}
const onTransportAbort = (): void => releaseTransportSubscription()
const cleanup = () => {
closed = true
ctx.signal?.removeEventListener('abort', onTransportAbort)
const stream = bindStructuredAgentSessionStream(ctx, subscriptionId, () => {
dispose()
host.release(params.sessionId, streamHolder)
}
let registration: { releaseIfCurrent: () => void }
if (typeof ctx.runtime.registerOwnedSubscriptionCleanup === 'function') {
registration = ctx.runtime.registerOwnedSubscriptionCleanup(
subscriptionId,
cleanup,
ctx.connectionId
)
} else {
ctx.runtime.registerSubscriptionCleanup(subscriptionId, cleanup, ctx.connectionId)
registration = { releaseIfCurrent: () => ctx.runtime.cleanupSubscription(subscriptionId) }
}
releaseTransportSubscription = registration.releaseIfCurrent
ctx.signal?.addEventListener('abort', onTransportAbort, { once: true })
if (ctx.signal?.aborted) {
onTransportAbort()
}
if (closed) {
})
if (stream.isClosed()) {
return
}
// The host emits the opening snapshot (or the missed batch) synchronously
@@ -282,7 +260,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [
emit,
...(params.cursor ? { cursor: params.cursor } : {})
})
if (closed) {
if (stream.isClosed()) {
dispose()
} else {
// Fire-and-forget, but never unhandled: a resume that refuses leaves the stream holding a
@@ -300,8 +278,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [
params: UnsubscribeParams,
handler: async (params, ctx) => {
requireHost(ctx)
const connection = ctx.connectionId ?? 'local'
const base = `${SUBSCRIPTION_PREFIX}:${connection}:${params.sessionId}`
const base = subscriptionBaseFor(ctx, params.sessionId)
if (params.subscriptionId) {
ctx.runtime.cleanupSubscription(`${base}:${params.subscriptionId}`)
return { unsubscribed: true }
@@ -311,5 +288,6 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [
return { unsubscribed: true }
}
}),
...STRUCTURED_AGENT_SESSION_HOLD_METHODS
...STRUCTURED_AGENT_SESSION_HOLD_METHODS,
...STRUCTURED_AGENT_SESSION_STATUS_METHODS
]
@@ -110,23 +110,31 @@ export async function resolveRuntimeLocalWorktreeCreateCandidate(args: {
args.username,
args.localWorktreeGitOptions
)
checkoutExistingBranch = await canCheckoutExistingLocalBranch(
args.repo.path,
branchName,
args.baseBranch,
...args.localWorktreeGitOptionArgs
)
if (checkoutExistingBranch && !selectedExistingLocalBranchName) {
selectedExistingLocalBranchName = branchName
const tryExistingBranch = async (): Promise<boolean> => {
checkoutExistingBranch = await canCheckoutExistingLocalBranch(
args.repo.path,
branchName,
args.baseBranch,
...args.localWorktreeGitOptionArgs
)
return checkoutExistingBranch
}
const preferExistingBranch = Boolean(
args.request.branchNameOverride || selectedExistingLocalBranchName
)
checkoutExistingBranch = preferExistingBranch && (await tryExistingBranch())
branchConflictKind = checkoutExistingBranch
? null
: await getBranchConflictKind(
args.repo.path,
branchName,
args.baseBranch,
...args.localWorktreeGitOptionArgs
args.localWorktreeGitOptions,
preferExistingBranch ? undefined : tryExistingBranch
)
if (checkoutExistingBranch && !selectedExistingLocalBranchName) {
selectedExistingLocalBranchName = branchName
}
const allowedPushTargetRemoteConflict =
branchConflictKind &&
isAllowedPushTargetRemoteConflict(branchConflictKind, branchName, args.request)
@@ -231,7 +231,7 @@ export class RuntimeRemoteFetchController {
? baseBranch.slice(remoteRefPrefix.length)
: baseBranch
// A remote-tracking base needs both a configured remote and a branch component.
if (!shortBaseBranch.includes('/')) {
if (shortBaseBranch.indexOf('/') <= 0 || shortBaseBranch.endsWith('/')) {
return null
}
let remotes: string[]
@@ -273,6 +273,22 @@ describe('worktree scan admin-fingerprint gate', () => {
}
})
it('resolves a just-created id after invalidation even within both cache TTLs', async () => {
const { runtime, list } = makeRuntime()
listWorktreesStrictMock.mockResolvedValueOnce([
{ path: REPO_PATH, head: 'abc', branch: 'main', isBare: false, isMainWorktree: true }
])
await list()
await expect(runtime.showManagedWorktree(`id:${WORKTREE_ID}`)).rejects.toThrow(
'selector_not_found'
)
runtime.invalidateWorktreeCatalog(REPO_ID)
await expect(runtime.showManagedWorktree(`id:${WORKTREE_ID}`)).resolves.toMatchObject({
id: WORKTREE_ID
})
expect(scanCount()).toBe(2)
})
it('scans when the probe cannot describe the repo', async () => {
vi.useFakeTimers()
try {
@@ -78,6 +78,34 @@ describe('createMainWindow', () => {
}
}
it.each(['darwin', 'linux', 'win32'] as const)(
'keeps explicit background startup hidden through ready/load/fallback on %s',
(platform) => {
vi.useFakeTimers()
vi.stubEnv('ORCA_BACKGROUND_LAUNCH', '1')
const { browserWindowInstance, windowHandlers } = createStartupRevealWindowFixture()
const showInactive = vi.fn()
Object.assign(browserWindowInstance, { showInactive })
try {
withPlatform(platform, () => {
createMainWindow(createStartupRevealStore(true) as never, { revealOnDidFinishLoad: true })
const revealAfterLoad = browserWindowInstance.webContents.on.mock.calls.find(
([event]) => event === 'did-finish-load'
)?.[1]
expect(revealAfterLoad).toBeTypeOf('function')
revealAfterLoad?.()
windowHandlers['ready-to-show']()
vi.advanceTimersByTime(10_000)
expect(browserWindowInstance.show).not.toHaveBeenCalled()
expect(showInactive).not.toHaveBeenCalled()
expect(browserWindowInstance.maximize).not.toHaveBeenCalled()
})
} finally {
vi.unstubAllEnvs()
}
}
)
it('ignores duplicate ready-to-show events after startup maximize has already run', () => {
const { browserWindowInstance, windowHandlers } = createStartupRevealWindowFixture()
+26 -1
View File
@@ -1,5 +1,5 @@
import type { App, BrowserWindow } from 'electron'
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { focusExistingMainWindow } from './focus-existing-window'
type FakeWindowOptions = {
@@ -78,7 +78,32 @@ function makeTimer(): {
}
}
afterEach(() => vi.unstubAllEnvs())
describe('focusExistingMainWindow', () => {
it.each(['darwin', 'linux', 'win32'] as const)(
'never restores or activates a background window on %s',
(platform) => {
vi.stubEnv('ORCA_BACKGROUND_LAUNCH', '1')
vi.stubEnv('ORCA_E2E_FOREGROUND', '1')
const app = makeFakeApp()
const window = makeFakeWindow({ minimized: true })
const timer = makeTimer()
focusExistingMainWindow({
app,
getWindow: () => window,
openWindow: vi.fn(),
platform,
setTimeout: timer.setTimeout
})
expect(app.focus).not.toHaveBeenCalled()
for (const call of Object.values(window.calls)) {
expect(call).not.toHaveBeenCalled()
}
expect(timer.scheduledMs()).toEqual([])
}
)
it('aggressively foregrounds an existing Windows window on second launch', () => {
const app = makeFakeApp()
const window = makeFakeWindow()
+6 -2
View File
@@ -1,5 +1,9 @@
import type { App, BrowserWindow } from 'electron'
import { isBackgroundLaunch, showWindowWithoutStealingFocus } from './foreground-activation-policy'
import {
isBackgroundLaunch,
isWindowlessLaunch,
showWindowWithoutStealingFocus
} from './foreground-activation-policy'
type FocusTimer = (callback: () => void, ms: number) => unknown
@@ -34,7 +38,7 @@ function safelyFocusApp(app: Pick<App, 'focus'>): void {
}
export function safelyRevealWindow(window: BrowserWindow): void {
if (window.isDestroyed()) {
if (window.isDestroyed() || isWindowlessLaunch()) {
return
}
if (window.isMinimized()) {
@@ -32,6 +32,10 @@ describe('isBackgroundLaunch', () => {
expect(isBackgroundLaunch({})).toBe(false)
})
it('keeps an explicit background request despite inherited foreground flags', () => {
expect(isBackgroundLaunch({ ORCA_BACKGROUND_LAUNCH: '1', ORCA_E2E_FOREGROUND: '1' })).toBe(true)
})
it('lets native-focus specs opt back into the foreground', () => {
expect(isBackgroundLaunch({ ORCA_E2E_HEADFUL: '1', ORCA_E2E_FOREGROUND: '1' })).toBe(false)
expect(isWindowlessLaunch({ ORCA_E2E_HEADLESS: '1', ORCA_E2E_FOREGROUND: '1' })).toBe(false)
@@ -39,10 +43,17 @@ describe('isBackgroundLaunch', () => {
})
describe('isWindowlessLaunch', () => {
it('is headless-only; a headful run still paints', () => {
it('keeps explicit background launches hidden while headful E2E can paint', () => {
expect(isWindowlessLaunch({ ORCA_E2E_HEADLESS: '1' })).toBe(true)
expect(isWindowlessLaunch({ ORCA_E2E_HEADLESS: '1', ORCA_E2E_HEADFUL: '1' })).toBe(false)
expect(isWindowlessLaunch({ ORCA_BACKGROUND_LAUNCH: '1' })).toBe(false)
expect(isWindowlessLaunch({ ORCA_BACKGROUND_LAUNCH: '1' })).toBe(true)
expect(
isWindowlessLaunch({
ORCA_BACKGROUND_LAUNCH: '1',
ORCA_E2E_HEADFUL: '1',
ORCA_E2E_FOREGROUND: '1'
})
).toBe(true)
})
})
@@ -54,9 +65,16 @@ describe('showWindowWithoutStealingFocus', () => {
expect(window.showInactive).not.toHaveBeenCalled()
})
it('shows a background window without activating it', () => {
it('never reveals an explicitly background window', () => {
const window = makeWindow()
showWindowWithoutStealingFocus(window, { ORCA_BACKGROUND_LAUNCH: '1' })
expect(window.showInactive).not.toHaveBeenCalled()
expect(window.show).not.toHaveBeenCalled()
})
it('still reveals explicitly headful E2E without activation', () => {
const window = makeWindow()
showWindowWithoutStealingFocus(window, { ORCA_E2E_HEADFUL: '1' })
expect(window.showInactive).toHaveBeenCalledOnce()
expect(window.show).not.toHaveBeenCalled()
})
@@ -83,18 +101,21 @@ describe('applyBackgroundActivationPolicy', () => {
}
}
it('drops the macOS Dock tile and menu bar for headless runs', () => {
const app = makeApp()
expect(
applyBackgroundActivationPolicy({
app,
env: { ORCA_E2E_HEADLESS: '1' },
platform: 'darwin'
})
).toBe(true)
expect(app.dock.hide).toHaveBeenCalledOnce()
expect(app.setActivationPolicy).toHaveBeenCalledWith('accessory')
})
it.each(['ORCA_E2E_HEADLESS', 'ORCA_BACKGROUND_LAUNCH'])(
'drops the macOS Dock tile and menu bar for %s',
(flag) => {
const app = makeApp()
expect(
applyBackgroundActivationPolicy({
app,
env: { [flag]: '1' },
platform: 'darwin'
})
).toBe(true)
expect(app.dock.hide).toHaveBeenCalledOnce()
expect(app.setActivationPolicy).toHaveBeenCalledWith('accessory')
}
)
it('leaves a headful or user launch with its normal Dock presence', () => {
const headful = makeApp()
+12 -10
View File
@@ -5,8 +5,8 @@ import { app as electronApp, type BrowserWindow } from 'electron'
* validation). These runs may use the machine, but must never take the OS
* foreground away from whatever the developer is doing.
*
* ORCA_BACKGROUND_LAUNCH=1 opts a normal launch in; ORCA_E2E_FOREGROUND=1 opts
* back out for the few specs whose subject *is* native focus (IME, key events).
* ORCA_BACKGROUND_LAUNCH=1 keeps automation off screen. Native-focus specs
* can use ORCA_E2E_FOREGROUND=1 only without an explicit background request.
*/
type ActivationPolicyApp = {
@@ -19,19 +19,21 @@ type PolicyEnv = Readonly<Record<string, string | undefined>>
/** True when this process must not steal focus, raise windows, or activate the app. */
export function isBackgroundLaunch(env: PolicyEnv = process.env): boolean {
if (env.ORCA_BACKGROUND_LAUNCH === '1') {
return true
}
if (env.ORCA_E2E_FOREGROUND === '1') {
return false
}
return (
env.ORCA_BACKGROUND_LAUNCH === '1' ||
env.ORCA_E2E_HEADLESS === '1' ||
env.ORCA_E2E_HEADFUL === '1'
)
return env.ORCA_E2E_HEADLESS === '1' || env.ORCA_E2E_HEADFUL === '1'
}
/** True when no window should reach the screen at all (headless E2E; Playwright drives via CDP). */
/** True when no window should reach the screen at all (background or headless E2E; Playwright drives via CDP). */
export function isWindowlessLaunch(env: PolicyEnv = process.env): boolean {
return isBackgroundLaunch(env) && env.ORCA_E2E_HEADLESS === '1' && env.ORCA_E2E_HEADFUL !== '1'
return (
env.ORCA_BACKGROUND_LAUNCH === '1' ||
(isBackgroundLaunch(env) && env.ORCA_E2E_HEADLESS === '1' && env.ORCA_E2E_HEADFUL !== '1')
)
}
/**
@@ -63,7 +65,7 @@ export function applyBackgroundActivationPolicy(
/**
* Reveal a window without taking the foreground: hidden entirely when windowless,
* `showInactive()` (visible, not raised over the active app) in background launches.
* `showInactive()` for explicitly headful E2E runs.
*/
export function showWindowWithoutStealingFocus(
window: BrowserWindow,
@@ -0,0 +1,260 @@
import React from 'react'
import { MoreHorizontal, Pause, Pencil, Play, Trash2 } from 'lucide-react'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import type {
ExternalAutomationAction,
ExternalAutomationJob,
ExternalAutomationManager
} from '../../../../shared/automations-types'
import type { SshConnectionState } from '../../../../shared/ssh-types'
import type { ExternalAutomationListEntry } from './external-automation-list-entries'
import type { ExternalAutomationScope } from './external-automation-scope-client'
import {
formatExternalDate,
getExternalProviderLabel,
getExternalTargetKindLabel
} from './external-automation-display'
import { getExternalAutomationScheduleDisplay } from './external-automation-schedule-display'
import { getExternalAutomationActionDisabledMessage } from './external-automation-source-availability'
import { AUTOMATIONS_TABLE_GRID_CLASS } from './automations-table-layout'
import {
LIST_TABLE_ROW_CLASS,
LIST_TABLE_ROW_SELECTED_CLASS,
LIST_TABLE_STICKY_ROW_CELL_CLASS
} from '@/lib/list-table-layout'
import { isPortaledRowMenuClick, isRowActivationKey } from '@/lib/list-row-interaction'
import { getExternalAutomationLastRunSnapshot } from './automation-list-last-run'
import { AutomationListLastRunCell } from './AutomationListLastRunCell'
import { AutomationListStatusCell } from './AutomationListStatusCell'
import { translate } from '@/i18n/i18n'
export type AutomationListExternalRowProps = {
entry: ExternalAutomationListEntry
selectedExternalKey: string | null | undefined
relativeNow: number
sshConnectionStates: ReadonlyMap<string, Pick<SshConnectionState, 'status'>>
externalActionKey: string | null
onSelect: (entryKey: string) => void
onRequestAction: (
manager: ExternalAutomationManager,
job: ExternalAutomationJob,
action: ExternalAutomationAction,
scope: ExternalAutomationScope
) => void
onEdit: (
manager: ExternalAutomationManager,
job: ExternalAutomationJob,
scope: ExternalAutomationScope
) => void
}
export function AutomationListExternalRow({
entry,
selectedExternalKey,
relativeNow,
sshConnectionStates,
externalActionKey,
onSelect,
onRequestAction,
onEdit
}: AutomationListExternalRowProps): React.JSX.Element {
const providerLabel = getExternalProviderLabel(entry.manager)
const targetKindLabel = getExternalTargetKindLabel(entry.manager)
const isSelected = selectedExternalKey === entry.key
const sshStatus =
entry.manager.target.type === 'ssh'
? sshConnectionStates.get(entry.manager.target.connectionId)?.status
: undefined
const disabledMessage = getExternalAutomationActionDisabledMessage({
manager: entry.manager,
providerLabel,
targetKindLabel,
sshStatus,
actionInProgress: externalActionKey !== null
})
const actionDisabled = disabledMessage !== null
const scheduleLabel = getExternalAutomationScheduleDisplay(entry.manager, entry.job).label
const hostLabel = entry.manager.targetLabel || entry.manager.label || 'Local'
const projectLabel = entry.job.workdir ?? providerLabel
const nextRunLabel = entry.job.enabled
? formatExternalDate(entry.job.nextRunAt, relativeNow)
: translate('auto.components.automations.AutomationsPage.paused', 'Paused')
const lastRunSnapshot = getExternalAutomationLastRunSnapshot(entry.job)
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<div
role="button"
tabIndex={0}
data-current={isSelected ? 'true' : undefined}
onClick={(event) => {
// Why: Radix portals menus out of the row DOM, but React still
// bubbles those clicks here — ignore so menu actions don't open detail.
if (isPortaledRowMenuClick(event)) {
return
}
onSelect(entry.key)
}}
onKeyDown={(event) => {
if (!isRowActivationKey(event)) {
return
}
event.preventDefault()
onSelect(entry.key)
}}
className={cn(
AUTOMATIONS_TABLE_GRID_CLASS,
LIST_TABLE_ROW_CLASS,
isSelected && LIST_TABLE_ROW_SELECTED_CLASS
)}
>
<span className={LIST_TABLE_STICKY_ROW_CELL_CLASS}>
<span className="min-w-0 truncate font-medium">{entry.job.name}</span>
</span>
<span className="min-w-0 truncate text-muted-foreground" title={scheduleLabel}>
{scheduleLabel}
</span>
<span className="min-w-0 truncate text-muted-foreground" title={projectLabel}>
{projectLabel}
</span>
<span className="min-w-0 truncate text-muted-foreground" title={hostLabel}>
{hostLabel}
</span>
<span className="min-w-0 truncate text-muted-foreground" title={nextRunLabel}>
{nextRunLabel}
</span>
<AutomationListLastRunCell snapshot={lastRunSnapshot} now={relativeNow} />
<AutomationListStatusCell enabled={entry.job.enabled} />
<span className="truncate text-center text-xs text-muted-foreground">
{providerLabel}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="size-7 text-muted-foreground"
aria-label={translate(
'auto.components.automations.AutomationsPage.rowActions',
'Automation actions'
)}
onClick={(event) => event.stopPropagation()}
>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
disabled={actionDisabled}
onSelect={() => onRequestAction(entry.manager, entry.job, 'run', entry.scope)}
>
<Play className="size-3.5" />
<span className="min-w-0 truncate">
{disabledMessage ??
translate('auto.components.automations.AutomationsPage.2faecab10b', 'Run Now')}
</span>
</DropdownMenuItem>
{entry.manager.provider === 'hermes' ? (
<DropdownMenuItem
disabled={!entry.manager.canManage || externalActionKey !== null}
onSelect={() => onEdit(entry.manager, entry.job, entry.scope)}
>
<Pencil className="size-3.5" />
{translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')}
</DropdownMenuItem>
) : null}
<DropdownMenuItem
disabled={actionDisabled}
onSelect={() =>
onRequestAction(
entry.manager,
entry.job,
entry.job.enabled ? 'pause' : 'resume',
entry.scope
)
}
>
{entry.job.enabled ? <Pause className="size-3.5" /> : <Play className="size-3.5" />}
{entry.job.enabled
? translate('auto.components.automations.AutomationsPage.b457436d6a', 'Pause')
: translate('auto.components.automations.AutomationsPage.376631ef2b', 'Resume')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
disabled={actionDisabled}
onSelect={() => onRequestAction(entry.manager, entry.job, 'delete', entry.scope)}
>
<Trash2 className="size-3.5" />
{translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
<ContextMenuItem
disabled={actionDisabled}
onSelect={() => onRequestAction(entry.manager, entry.job, 'run', entry.scope)}
>
<Play className="size-3.5" />
<span className="min-w-0 truncate">
{disabledMessage ??
translate('auto.components.automations.AutomationsPage.2faecab10b', 'Run Now')}
</span>
</ContextMenuItem>
{entry.manager.provider === 'hermes' ? (
<ContextMenuItem
disabled={!entry.manager.canManage || externalActionKey !== null}
onSelect={() => onEdit(entry.manager, entry.job, entry.scope)}
>
<Pencil className="size-3.5" />
{translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')}
</ContextMenuItem>
) : null}
<ContextMenuItem
disabled={actionDisabled}
onSelect={() =>
onRequestAction(
entry.manager,
entry.job,
entry.job.enabled ? 'pause' : 'resume',
entry.scope
)
}
>
{entry.job.enabled ? <Pause className="size-3.5" /> : <Play className="size-3.5" />}
{entry.job.enabled
? translate('auto.components.automations.AutomationsPage.b457436d6a', 'Pause')
: translate('auto.components.automations.AutomationsPage.376631ef2b', 'Resume')}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
disabled={actionDisabled}
onSelect={() => onRequestAction(entry.manager, entry.job, 'delete', entry.scope)}
>
<Trash2 className="size-3.5" />
{translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}
@@ -1,285 +1,23 @@
import React from 'react'
import { MoreHorizontal, Pause, Pencil, Play, Trash2 } from 'lucide-react'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import type {
ExternalAutomationAction,
ExternalAutomationJob,
ExternalAutomationManager
} from '../../../../shared/automations-types'
import type { SshConnectionState } from '../../../../shared/ssh-types'
import type { ExternalAutomationListEntry } from './external-automation-list-entries'
import type { ExternalAutomationScope } from './external-automation-scope-client'
import {
formatExternalDate,
getExternalProviderLabel,
getExternalTargetKindLabel
} from './external-automation-display'
import { getExternalAutomationScheduleDisplay } from './external-automation-schedule-display'
import { getExternalAutomationActionDisabledMessage } from './external-automation-source-availability'
import { AUTOMATIONS_TABLE_GRID_CLASS } from './automations-table-layout'
import {
LIST_TABLE_ROW_CLASS,
LIST_TABLE_ROW_SELECTED_CLASS,
LIST_TABLE_STICKY_ROW_CELL_CLASS
} from '@/lib/list-table-layout'
import { isPortaledRowMenuClick, isRowActivationKey } from '@/lib/list-row-interaction'
import { getExternalAutomationLastRunSnapshot } from './automation-list-last-run'
import { AutomationListLastRunCell } from './AutomationListLastRunCell'
import { AutomationListStatusCell } from './AutomationListStatusCell'
import { translate } from '@/i18n/i18n'
AutomationListExternalRow,
type AutomationListExternalRowProps
} from './AutomationListExternalRow'
export type AutomationListExternalRowsProps = Omit<AutomationListExternalRowProps, 'entry'> & {
entries: readonly ExternalAutomationListEntry[]
}
export function AutomationListExternalRows({
entries,
selectedExternalKey,
relativeNow,
sshConnectionStates,
externalActionKey,
onSelect,
onRequestAction,
onEdit
}: {
entries: readonly ExternalAutomationListEntry[]
selectedExternalKey: string | null | undefined
relativeNow: number
sshConnectionStates: ReadonlyMap<string, Pick<SshConnectionState, 'status'>>
externalActionKey: string | null
onSelect: (entryKey: string) => void
onRequestAction: (
manager: ExternalAutomationManager,
job: ExternalAutomationJob,
action: ExternalAutomationAction,
scope: ExternalAutomationScope
) => void
onEdit: (
manager: ExternalAutomationManager,
job: ExternalAutomationJob,
scope: ExternalAutomationScope
) => void
}): React.JSX.Element {
...rowProps
}: AutomationListExternalRowsProps): React.JSX.Element {
return (
<>
{entries.map((entry) => {
const providerLabel = getExternalProviderLabel(entry.manager)
const targetKindLabel = getExternalTargetKindLabel(entry.manager)
const isSelected = selectedExternalKey === entry.key
const sshStatus =
entry.manager.target.type === 'ssh'
? sshConnectionStates.get(entry.manager.target.connectionId)?.status
: undefined
const disabledMessage = getExternalAutomationActionDisabledMessage({
manager: entry.manager,
providerLabel,
targetKindLabel,
sshStatus,
actionInProgress: externalActionKey !== null
})
const actionDisabled = disabledMessage !== null
const scheduleLabel = getExternalAutomationScheduleDisplay(entry.manager, entry.job).label
const hostLabel = entry.manager.targetLabel || entry.manager.label || 'Local'
const projectLabel = entry.job.workdir ?? providerLabel
const nextRunLabel = entry.job.enabled
? formatExternalDate(entry.job.nextRunAt, relativeNow)
: translate('auto.components.automations.AutomationsPage.paused', 'Paused')
const lastRunSnapshot = getExternalAutomationLastRunSnapshot(entry.job)
return (
<ContextMenu key={entry.key}>
<ContextMenuTrigger asChild>
<div
role="button"
tabIndex={0}
data-current={isSelected ? 'true' : undefined}
onClick={(event) => {
// Why: Radix portals menus out of the row DOM, but React still
// bubbles those clicks here — ignore so menu actions don't open detail.
if (isPortaledRowMenuClick(event)) {
return
}
onSelect(entry.key)
}}
onKeyDown={(event) => {
if (!isRowActivationKey(event)) {
return
}
event.preventDefault()
onSelect(entry.key)
}}
className={cn(
AUTOMATIONS_TABLE_GRID_CLASS,
LIST_TABLE_ROW_CLASS,
isSelected && LIST_TABLE_ROW_SELECTED_CLASS
)}
>
<span className={LIST_TABLE_STICKY_ROW_CELL_CLASS}>
<span className="min-w-0 truncate font-medium">{entry.job.name}</span>
</span>
<span className="min-w-0 truncate text-muted-foreground" title={scheduleLabel}>
{scheduleLabel}
</span>
<span className="min-w-0 truncate text-muted-foreground" title={projectLabel}>
{projectLabel}
</span>
<span className="min-w-0 truncate text-muted-foreground" title={hostLabel}>
{hostLabel}
</span>
<span className="min-w-0 truncate text-muted-foreground" title={nextRunLabel}>
{nextRunLabel}
</span>
<AutomationListLastRunCell snapshot={lastRunSnapshot} now={relativeNow} />
<AutomationListStatusCell enabled={entry.job.enabled} />
<span className="truncate text-center text-xs text-muted-foreground">
{providerLabel}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="size-7 text-muted-foreground"
aria-label={translate(
'auto.components.automations.AutomationsPage.rowActions',
'Automation actions'
)}
onClick={(event) => event.stopPropagation()}
>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
disabled={actionDisabled}
onSelect={() => onRequestAction(entry.manager, entry.job, 'run', entry.scope)}
>
<Play className="size-3.5" />
<span className="min-w-0 truncate">
{disabledMessage ??
translate(
'auto.components.automations.AutomationsPage.2faecab10b',
'Run Now'
)}
</span>
</DropdownMenuItem>
{entry.manager.provider === 'hermes' ? (
<DropdownMenuItem
disabled={!entry.manager.canManage || externalActionKey !== null}
onSelect={() => onEdit(entry.manager, entry.job, entry.scope)}
>
<Pencil className="size-3.5" />
{translate(
'auto.components.automations.AutomationsPage.f4612e3f78',
'Edit'
)}
</DropdownMenuItem>
) : null}
<DropdownMenuItem
disabled={actionDisabled}
onSelect={() =>
onRequestAction(
entry.manager,
entry.job,
entry.job.enabled ? 'pause' : 'resume',
entry.scope
)
}
>
{entry.job.enabled ? (
<Pause className="size-3.5" />
) : (
<Play className="size-3.5" />
)}
{entry.job.enabled
? translate(
'auto.components.automations.AutomationsPage.b457436d6a',
'Pause'
)
: translate(
'auto.components.automations.AutomationsPage.376631ef2b',
'Resume'
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
disabled={actionDisabled}
onSelect={() =>
onRequestAction(entry.manager, entry.job, 'delete', entry.scope)
}
>
<Trash2 className="size-3.5" />
{translate(
'auto.components.automations.AutomationsPage.15e0bfb13b',
'Delete'
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
<ContextMenuItem
disabled={actionDisabled}
onSelect={() => onRequestAction(entry.manager, entry.job, 'run', entry.scope)}
>
<Play className="size-3.5" />
<span className="min-w-0 truncate">
{disabledMessage ??
translate('auto.components.automations.AutomationsPage.2faecab10b', 'Run Now')}
</span>
</ContextMenuItem>
{entry.manager.provider === 'hermes' ? (
<ContextMenuItem
disabled={!entry.manager.canManage || externalActionKey !== null}
onSelect={() => onEdit(entry.manager, entry.job, entry.scope)}
>
<Pencil className="size-3.5" />
{translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')}
</ContextMenuItem>
) : null}
<ContextMenuItem
disabled={actionDisabled}
onSelect={() =>
onRequestAction(
entry.manager,
entry.job,
entry.job.enabled ? 'pause' : 'resume',
entry.scope
)
}
>
{entry.job.enabled ? <Pause className="size-3.5" /> : <Play className="size-3.5" />}
{entry.job.enabled
? translate('auto.components.automations.AutomationsPage.b457436d6a', 'Pause')
: translate('auto.components.automations.AutomationsPage.376631ef2b', 'Resume')}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
disabled={actionDisabled}
onSelect={() => onRequestAction(entry.manager, entry.job, 'delete', entry.scope)}
>
<Trash2 className="size-3.5" />
{translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
})}
{entries.map((entry) => (
<AutomationListExternalRow key={entry.key} entry={entry} {...rowProps} />
))}
</>
)
}
@@ -0,0 +1,391 @@
import React from 'react'
import { MoreHorizontal, Pause, Pencil, Play, Trash2 } from 'lucide-react'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { AgentIcon } from '@/lib/agent-catalog'
import { cn } from '@/lib/utils'
import type { AutomationRun } from '../../../../shared/automations-types'
import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity'
import { formatUiAutomationSchedule } from './automation-schedule-label'
import {
getExecutionHostLabel,
getLocalExecutionHostLabel,
getRepoExecutionHostId
} from '../../../../shared/execution-host'
import type { SshConnectionState } from '../../../../shared/ssh-types'
import type { ProjectHostSetup } from '../../../../shared/project-types'
import type { Repo } from '../../../../shared/repo-types'
import type { Worktree } from '../../../../shared/worktree/types'
import type { RuntimeStatus } from '../../../../shared/runtime-types'
import type { TaskSourceHostAvailability } from '../task-source-context-summary'
import type { AutomationRowAction } from './automation-captured-owner'
import type { AutomationHostTarget } from './automation-host-client'
import {
getAutomationRowLastRunSnapshot,
getLocalAutomationLastRunSnapshot
} from './automation-list-last-run'
import { AutomationListLastRunCell } from './AutomationListLastRunCell'
import { formatAutomationDateTimeWithRelative } from './automation-page-parts'
import { getAutomationTargetAvailability } from './automation-target-availability'
import { getAgentLabel } from './automation-draft-model'
import type { AutomationListRow } from './automation-list-row-identity'
import {
formatAutomationCost,
formatAutomationTokens,
type AutomationUsageSummary
} from './automation-usage-model'
import { AUTOMATIONS_TABLE_GRID_CLASS } from './automations-table-layout'
import {
LIST_TABLE_ROW_CLASS,
LIST_TABLE_ROW_SELECTED_CLASS,
LIST_TABLE_STICKY_ROW_CELL_CLASS
} from '@/lib/list-table-layout'
import { isPortaledRowMenuClick, isRowActivationKey } from '@/lib/list-row-interaction'
import { AutomationListStatusCell } from './AutomationListStatusCell'
import { translate } from '@/i18n/i18n'
export type AutomationListLocalRowProps = {
row: AutomationListRow
selectedRowKey: string | null | undefined
isSelectedLocal: boolean
lastRunByAutomationId: ReadonlyMap<string, AutomationRun>
relativeNow: number
repoMap: ReadonlyMap<string, Repo>
worktreeMap: ReadonlyMap<string, Worktree>
repoForRow?: (row: AutomationListRow) => Repo | undefined
worktreeForRow?: (row: AutomationListRow, repo: Repo | undefined) => Worktree | undefined
projectHostSetups: readonly ProjectHostSetup[]
sshConnectionStates: ReadonlyMap<string, Pick<SshConnectionState, 'status'>>
runtimeStatusByEnvironmentId: ReadonlyMap<
string,
{ status: RuntimeStatus | null; checkedAt: number }
>
hostTargetFor: (row: AutomationListRow) => AutomationHostTarget | null
automationSourceHostAvailabilityByRowKey: ReadonlyMap<string, TaskSourceHostAvailability[]>
hostLabelById?: ReadonlyMap<string, string>
isActionEnabled?: (row: AutomationListRow, action: AutomationRowAction) => boolean
onSelect: (rowKey: string) => void
onRunNow: (row: AutomationListRow) => void
onEdit: (row: AutomationListRow) => void
onToggle: (row: AutomationListRow) => void
onDelete: (row: AutomationListRow) => void
}
const EMPTY_HOST_LABELS: ReadonlyMap<string, string> = new Map()
function automationUsageText(summary: AutomationUsageSummary | undefined): string {
if (!summary || summary.unavailableRuns > 0) {
return summary?.knownRuns
? usageAmountText(summary)
: translate(
'auto.components.automations.AutomationsPage.usageUnavailable',
'Usage unavailable'
)
}
return summary.knownRuns > 0
? usageAmountText(summary)
: translate('auto.components.automations.AutomationsPage.noRunUsageYet', 'No run usage yet')
}
function usageAmountText(summary: AutomationUsageSummary): string {
return translate(
'auto.components.automations.AutomationsPage.runUsageSummary',
'{{cost}} est. · {{tokens}} tokens',
{
cost: formatAutomationCost(summary.estimatedCostUsd),
tokens: formatAutomationTokens(summary.totalTokens)
}
)
}
export function AutomationListLocalRow({
row,
selectedRowKey,
isSelectedLocal,
lastRunByAutomationId,
relativeNow,
repoMap,
worktreeMap,
repoForRow,
worktreeForRow,
projectHostSetups,
sshConnectionStates,
runtimeStatusByEnvironmentId,
hostTargetFor,
automationSourceHostAvailabilityByRowKey,
hostLabelById = EMPTY_HOST_LABELS,
isActionEnabled,
onSelect,
onRunNow,
onEdit,
onToggle,
onDelete
}: AutomationListLocalRowProps): React.JSX.Element {
const allows = (row: AutomationListRow, action: AutomationRowAction): boolean =>
isActionEnabled?.(row, action) ?? true
const { automation } = row
const automationRepo = repoForRow?.(row) ?? repoMap.get(getAutomationRunRepoId(automation))
const automationWorktree = automation.workspaceId
? (worktreeForRow?.(row, automationRepo) ?? worktreeMap.get(automation.workspaceId))
: null
const automationRunAvailability = getAutomationTargetAvailability({
automation,
repo: automationRepo,
workspace: automationWorktree,
projectHostSetups,
sshConnectionStates,
runtimeStatusByEnvironmentId,
automationHostTarget: hostTargetFor(row),
sourceHostAvailability: automationSourceHostAvailabilityByRowKey.get(row.key)
})
const projectLabel =
automationRepo?.displayName ??
translate('auto.components.automations.AutomationsPage.13118faadf', 'Unknown project')
const scheduleLabel = formatUiAutomationSchedule(automation.rrule)
const nextRunLabel = automation.enabled
? formatAutomationDateTimeWithRelative(automation.nextRunAt, relativeNow)
: translate('auto.components.automations.enablement.paused', 'Paused')
const isSelected = isSelectedLocal && selectedRowKey === row.key
const agentLabel = getAgentLabel(automation.agentId)
const hostId =
automation.runContext?.hostId ??
(automationRepo ? getRepoExecutionHostId(automationRepo) : null)
const hostLabel =
row.hostLabel ||
(hostId
? (hostLabelById.get(hostId) ?? getExecutionHostLabel(hostId))
: getLocalExecutionHostLabel())
const agentTooltipLabel = `${agentLabel} · ${hostLabel} · ${automationUsageText(row.usageSummary ?? undefined)}`
const canRunNow = automationRunAvailability.canRunNow && allows(row, 'run')
const lastRun = lastRunByAutomationId.get(automation.id)
// Without a fetched run, the row's projected summary carries the newest
// retained run's status — the list never downloads run history for this.
const lastRunSnapshot = lastRun
? getLocalAutomationLastRunSnapshot(automation, lastRun)
: getAutomationRowLastRunSnapshot(row)
const actionItems = (
<>
<MenuRunItem
disabled={!canRunNow}
label={
automationRunAvailability.canRunNow
? translate('auto.components.automations.AutomationsPage.2faecab10b', 'Run Now')
: automationRunAvailability.message
}
onSelect={() => onRunNow(row)}
/>
<MenuItem
disabled={!allows(row, 'edit')}
icon={<Pencil className="size-3.5" />}
label={translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')}
onSelect={() => onEdit(row)}
/>
<MenuItem
disabled={!allows(row, 'toggle')}
icon={automation.enabled ? <Pause className="size-3.5" /> : <Play className="size-3.5" />}
label={
automation.enabled
? translate('auto.components.automations.AutomationsPage.b457436d6a', 'Pause')
: translate('auto.components.automations.AutomationsPage.376631ef2b', 'Resume')
}
onSelect={() => onToggle(row)}
/>
<MenuSeparator />
<MenuItem
disabled={!allows(row, 'delete')}
icon={<Trash2 className="size-3.5" />}
label={translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}
variant="destructive"
onSelect={() => onDelete(row)}
/>
</>
)
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<div
role="button"
tabIndex={0}
data-automation-row-id={row.key}
data-current={isSelected ? 'true' : undefined}
onClick={(event) => {
// Why: Radix portals menus out of the row DOM, but React still
// bubbles those clicks here — ignore so menu actions don't open detail.
if (isPortaledRowMenuClick(event)) {
return
}
onSelect(row.key)
}}
onKeyDown={(event) => {
if (!isRowActivationKey(event)) {
return
}
event.preventDefault()
onSelect(row.key)
}}
className={cn(
AUTOMATIONS_TABLE_GRID_CLASS,
LIST_TABLE_ROW_CLASS,
isSelected && LIST_TABLE_ROW_SELECTED_CLASS
)}
>
<span className={LIST_TABLE_STICKY_ROW_CELL_CLASS}>
<span className="min-w-0 truncate font-medium">{automation.name}</span>
</span>
<span className="min-w-0 truncate text-muted-foreground" title={scheduleLabel}>
{scheduleLabel}
</span>
<span className="min-w-0 truncate text-muted-foreground" title={projectLabel}>
{projectLabel}
</span>
<span className="min-w-0 truncate text-muted-foreground" title={hostLabel}>
{hostLabel}
</span>
<span className="min-w-0 truncate text-muted-foreground" title={nextRunLabel}>
{nextRunLabel}
</span>
<AutomationListLastRunCell snapshot={lastRunSnapshot} now={relativeNow} />
<AutomationListStatusCell enabled={automation.enabled} />
<Tooltip>
<TooltipTrigger asChild>
<span
className="flex items-center justify-center text-muted-foreground"
aria-label={agentTooltipLabel}
>
<AgentIcon agent={automation.agentId} size={16} />
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{agentTooltipLabel}
</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="size-7 text-muted-foreground"
aria-label={translate(
'auto.components.automations.AutomationListLocalRows.c92c9463c6',
'Automation actions'
)}
onClick={(event) => event.stopPropagation()}
>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
disabled={!canRunNow}
onSelect={() => {
if (canRunNow) {
onRunNow(row)
}
}}
>
<Play className="size-3.5" />
<span className="min-w-0 truncate">
{automationRunAvailability.canRunNow
? translate('auto.components.automations.AutomationsPage.2faecab10b', 'Run Now')
: automationRunAvailability.message}
</span>
</DropdownMenuItem>
<DropdownMenuItem disabled={!allows(row, 'edit')} onSelect={() => onEdit(row)}>
<Pencil className="size-3.5" />
{translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')}
</DropdownMenuItem>
<DropdownMenuItem disabled={!allows(row, 'toggle')} onSelect={() => onToggle(row)}>
{automation.enabled ? (
<Pause className="size-3.5" />
) : (
<Play className="size-3.5" />
)}
{automation.enabled
? translate('auto.components.automations.AutomationsPage.b457436d6a', 'Pause')
: translate('auto.components.automations.AutomationsPage.376631ef2b', 'Resume')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
disabled={!allows(row, 'delete')}
onSelect={() => onDelete(row)}
>
<Trash2 className="size-3.5" />
{translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-48">{actionItems}</ContextMenuContent>
</ContextMenu>
)
}
function MenuRunItem({
disabled,
label,
onSelect
}: {
disabled: boolean
label: string
onSelect: () => void
}): React.JSX.Element {
return (
<ContextMenuItem
disabled={disabled}
onSelect={(event) => {
if (disabled) {
event.preventDefault()
return
}
onSelect()
}}
>
<Play className="size-3.5" />
<span className="min-w-0 truncate">{label}</span>
</ContextMenuItem>
)
}
function MenuItem({
disabled,
icon,
label,
onSelect,
variant
}: {
disabled?: boolean
icon: React.ReactNode
label: string
onSelect: () => void
variant?: 'destructive'
}): React.JSX.Element {
return (
<ContextMenuItem disabled={disabled} variant={variant} onSelect={onSelect}>
{icon}
{label}
</ContextMenuItem>
)
}
function MenuSeparator(): React.JSX.Element {
return <ContextMenuSeparator />
}
@@ -1,414 +1,20 @@
import React from 'react'
import { MoreHorizontal, Pause, Pencil, Play, Trash2 } from 'lucide-react'
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { AgentIcon } from '@/lib/agent-catalog'
import { cn } from '@/lib/utils'
import type { AutomationRun } from '../../../../shared/automations-types'
import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity'
import { formatUiAutomationSchedule } from './automation-schedule-label'
import {
getExecutionHostLabel,
getLocalExecutionHostLabel,
getRepoExecutionHostId
} from '../../../../shared/execution-host'
import type { SshConnectionState } from '../../../../shared/ssh-types'
import type { ProjectHostSetup } from '../../../../shared/project-types'
import type { Repo } from '../../../../shared/repo-types'
import type { Worktree } from '../../../../shared/worktree/types'
import type { RuntimeStatus } from '../../../../shared/runtime-types'
import type { TaskSourceHostAvailability } from '../task-source-context-summary'
import type { AutomationRowAction } from './automation-captured-owner'
import type { AutomationHostTarget } from './automation-host-client'
import {
getAutomationRowLastRunSnapshot,
getLocalAutomationLastRunSnapshot
} from './automation-list-last-run'
import { AutomationListLastRunCell } from './AutomationListLastRunCell'
import { formatAutomationDateTimeWithRelative } from './automation-page-parts'
import { getAutomationTargetAvailability } from './automation-target-availability'
import { getAgentLabel } from './automation-draft-model'
import type { AutomationListRow } from './automation-list-row-identity'
import {
formatAutomationCost,
formatAutomationTokens,
type AutomationUsageSummary
} from './automation-usage-model'
import { AUTOMATIONS_TABLE_GRID_CLASS } from './automations-table-layout'
import {
LIST_TABLE_ROW_CLASS,
LIST_TABLE_ROW_SELECTED_CLASS,
LIST_TABLE_STICKY_ROW_CELL_CLASS
} from '@/lib/list-table-layout'
import { isPortaledRowMenuClick, isRowActivationKey } from '@/lib/list-row-interaction'
import { AutomationListStatusCell } from './AutomationListStatusCell'
import { translate } from '@/i18n/i18n'
import { AutomationListLocalRow, type AutomationListLocalRowProps } from './AutomationListLocalRow'
export type AutomationListLocalRowsProps = {
export type AutomationListLocalRowsProps = Omit<AutomationListLocalRowProps, 'row'> & {
rows: readonly AutomationListRow[]
selectedRowKey: string | null | undefined
isSelectedLocal: boolean
lastRunByAutomationId: ReadonlyMap<string, AutomationRun>
relativeNow: number
repoMap: ReadonlyMap<string, Repo>
worktreeMap: ReadonlyMap<string, Worktree>
repoForRow?: (row: AutomationListRow) => Repo | undefined
worktreeForRow?: (row: AutomationListRow, repo: Repo | undefined) => Worktree | undefined
projectHostSetups: readonly ProjectHostSetup[]
sshConnectionStates: ReadonlyMap<string, Pick<SshConnectionState, 'status'>>
runtimeStatusByEnvironmentId: ReadonlyMap<
string,
{ status: RuntimeStatus | null; checkedAt: number }
>
hostTargetFor: (row: AutomationListRow) => AutomationHostTarget | null
automationSourceHostAvailabilityByRowKey: ReadonlyMap<string, TaskSourceHostAvailability[]>
hostLabelById?: ReadonlyMap<string, string>
isActionEnabled?: (row: AutomationListRow, action: AutomationRowAction) => boolean
onSelect: (rowKey: string) => void
onRunNow: (row: AutomationListRow) => void
onEdit: (row: AutomationListRow) => void
onToggle: (row: AutomationListRow) => void
onDelete: (row: AutomationListRow) => void
}
const EMPTY_HOST_LABELS: ReadonlyMap<string, string> = new Map()
function automationUsageText(summary: AutomationUsageSummary | undefined): string {
if (!summary || summary.unavailableRuns > 0) {
return summary?.knownRuns
? usageAmountText(summary)
: translate(
'auto.components.automations.AutomationsPage.usageUnavailable',
'Usage unavailable'
)
}
return summary.knownRuns > 0
? usageAmountText(summary)
: translate('auto.components.automations.AutomationsPage.noRunUsageYet', 'No run usage yet')
}
function usageAmountText(summary: AutomationUsageSummary): string {
return translate(
'auto.components.automations.AutomationsPage.runUsageSummary',
'{{cost}} est. · {{tokens}} tokens',
{
cost: formatAutomationCost(summary.estimatedCostUsd),
tokens: formatAutomationTokens(summary.totalTokens)
}
)
}
export function AutomationListLocalRows({
rows,
selectedRowKey,
isSelectedLocal,
lastRunByAutomationId,
relativeNow,
repoMap,
worktreeMap,
repoForRow,
worktreeForRow,
projectHostSetups,
sshConnectionStates,
runtimeStatusByEnvironmentId,
hostTargetFor,
automationSourceHostAvailabilityByRowKey,
hostLabelById = EMPTY_HOST_LABELS,
isActionEnabled,
onSelect,
onRunNow,
onEdit,
onToggle,
onDelete
...rowProps
}: AutomationListLocalRowsProps): React.JSX.Element {
const allows = (row: AutomationListRow, action: AutomationRowAction): boolean =>
isActionEnabled?.(row, action) ?? true
return (
<>
{rows.map((row) => {
const { automation } = row
const automationRepo = repoForRow?.(row) ?? repoMap.get(getAutomationRunRepoId(automation))
const automationWorktree = automation.workspaceId
? (worktreeForRow?.(row, automationRepo) ?? worktreeMap.get(automation.workspaceId))
: null
const automationRunAvailability = getAutomationTargetAvailability({
automation,
repo: automationRepo,
workspace: automationWorktree,
projectHostSetups,
sshConnectionStates,
runtimeStatusByEnvironmentId,
automationHostTarget: hostTargetFor(row),
sourceHostAvailability: automationSourceHostAvailabilityByRowKey.get(row.key)
})
const projectLabel =
automationRepo?.displayName ??
translate('auto.components.automations.AutomationsPage.13118faadf', 'Unknown project')
const scheduleLabel = formatUiAutomationSchedule(automation.rrule)
const nextRunLabel = automation.enabled
? formatAutomationDateTimeWithRelative(automation.nextRunAt, relativeNow)
: translate('auto.components.automations.enablement.paused', 'Paused')
const isSelected = isSelectedLocal && selectedRowKey === row.key
const agentLabel = getAgentLabel(automation.agentId)
const hostId =
automation.runContext?.hostId ??
(automationRepo ? getRepoExecutionHostId(automationRepo) : null)
const hostLabel =
row.hostLabel ||
(hostId
? (hostLabelById.get(hostId) ?? getExecutionHostLabel(hostId))
: getLocalExecutionHostLabel())
const agentTooltipLabel = `${agentLabel} · ${hostLabel} · ${automationUsageText(row.usageSummary ?? undefined)}`
const canRunNow = automationRunAvailability.canRunNow && allows(row, 'run')
const lastRun = lastRunByAutomationId.get(automation.id)
// Without a fetched run, the row's projected summary carries the newest
// retained run's status — the list never downloads run history for this.
const lastRunSnapshot = lastRun
? getLocalAutomationLastRunSnapshot(automation, lastRun)
: getAutomationRowLastRunSnapshot(row)
const actionItems = (
<>
<MenuRunItem
disabled={!canRunNow}
label={
automationRunAvailability.canRunNow
? translate('auto.components.automations.AutomationsPage.2faecab10b', 'Run Now')
: automationRunAvailability.message
}
onSelect={() => onRunNow(row)}
/>
<MenuItem
disabled={!allows(row, 'edit')}
icon={<Pencil className="size-3.5" />}
label={translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')}
onSelect={() => onEdit(row)}
/>
<MenuItem
disabled={!allows(row, 'toggle')}
icon={
automation.enabled ? <Pause className="size-3.5" /> : <Play className="size-3.5" />
}
label={
automation.enabled
? translate('auto.components.automations.AutomationsPage.b457436d6a', 'Pause')
: translate('auto.components.automations.AutomationsPage.376631ef2b', 'Resume')
}
onSelect={() => onToggle(row)}
/>
<MenuSeparator />
<MenuItem
disabled={!allows(row, 'delete')}
icon={<Trash2 className="size-3.5" />}
label={translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}
variant="destructive"
onSelect={() => onDelete(row)}
/>
</>
)
return (
<ContextMenu key={row.key}>
<ContextMenuTrigger asChild>
<div
role="button"
tabIndex={0}
data-automation-row-id={row.key}
data-current={isSelected ? 'true' : undefined}
onClick={(event) => {
// Why: Radix portals menus out of the row DOM, but React still
// bubbles those clicks here — ignore so menu actions don't open detail.
if (isPortaledRowMenuClick(event)) {
return
}
onSelect(row.key)
}}
onKeyDown={(event) => {
if (!isRowActivationKey(event)) {
return
}
event.preventDefault()
onSelect(row.key)
}}
className={cn(
AUTOMATIONS_TABLE_GRID_CLASS,
LIST_TABLE_ROW_CLASS,
isSelected && LIST_TABLE_ROW_SELECTED_CLASS
)}
>
<span className={LIST_TABLE_STICKY_ROW_CELL_CLASS}>
<span className="min-w-0 truncate font-medium">{automation.name}</span>
</span>
<span className="min-w-0 truncate text-muted-foreground" title={scheduleLabel}>
{scheduleLabel}
</span>
<span className="min-w-0 truncate text-muted-foreground" title={projectLabel}>
{projectLabel}
</span>
<span className="min-w-0 truncate text-muted-foreground" title={hostLabel}>
{hostLabel}
</span>
<span className="min-w-0 truncate text-muted-foreground" title={nextRunLabel}>
{nextRunLabel}
</span>
<AutomationListLastRunCell snapshot={lastRunSnapshot} now={relativeNow} />
<AutomationListStatusCell enabled={automation.enabled} />
<Tooltip>
<TooltipTrigger asChild>
<span
className="flex items-center justify-center text-muted-foreground"
aria-label={agentTooltipLabel}
>
<AgentIcon agent={automation.agentId} size={16} />
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{agentTooltipLabel}
</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="size-7 text-muted-foreground"
aria-label={translate(
'auto.components.automations.AutomationListLocalRows.c92c9463c6',
'Automation actions'
)}
onClick={(event) => event.stopPropagation()}
>
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
disabled={!canRunNow}
onSelect={() => {
if (canRunNow) {
onRunNow(row)
}
}}
>
<Play className="size-3.5" />
<span className="min-w-0 truncate">
{automationRunAvailability.canRunNow
? translate(
'auto.components.automations.AutomationsPage.2faecab10b',
'Run Now'
)
: automationRunAvailability.message}
</span>
</DropdownMenuItem>
<DropdownMenuItem disabled={!allows(row, 'edit')} onSelect={() => onEdit(row)}>
<Pencil className="size-3.5" />
{translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')}
</DropdownMenuItem>
<DropdownMenuItem
disabled={!allows(row, 'toggle')}
onSelect={() => onToggle(row)}
>
{automation.enabled ? (
<Pause className="size-3.5" />
) : (
<Play className="size-3.5" />
)}
{automation.enabled
? translate(
'auto.components.automations.AutomationsPage.b457436d6a',
'Pause'
)
: translate(
'auto.components.automations.AutomationsPage.376631ef2b',
'Resume'
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
disabled={!allows(row, 'delete')}
onSelect={() => onDelete(row)}
>
<Trash2 className="size-3.5" />
{translate(
'auto.components.automations.AutomationsPage.15e0bfb13b',
'Delete'
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-48">{actionItems}</ContextMenuContent>
</ContextMenu>
)
})}
{rows.map((row) => (
<AutomationListLocalRow key={row.key} row={row} {...rowProps} />
))}
</>
)
}
function MenuRunItem({
disabled,
label,
onSelect
}: {
disabled: boolean
label: string
onSelect: () => void
}): React.JSX.Element {
return (
<ContextMenuItem
disabled={disabled}
onSelect={(event) => {
if (disabled) {
event.preventDefault()
return
}
onSelect()
}}
>
<Play className="size-3.5" />
<span className="min-w-0 truncate">{label}</span>
</ContextMenuItem>
)
}
function MenuItem({
disabled,
icon,
label,
onSelect,
variant
}: {
disabled?: boolean
icon: React.ReactNode
label: string
onSelect: () => void
variant?: 'destructive'
}): React.JSX.Element {
return (
<ContextMenuItem disabled={disabled} variant={variant} onSelect={onSelect}>
{icon}
{label}
</ContextMenuItem>
)
}
function MenuSeparator(): React.JSX.Element {
return <ContextMenuSeparator />
}
@@ -0,0 +1,51 @@
import React from 'react'
import { ArrowDown, ArrowUp } from 'lucide-react'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import type { AutomationListSort, AutomationListSortField } from './automation-list-view'
export function AutomationListSortHeader({
field,
label,
sort,
onSort
}: {
field: AutomationListSortField
label: string
sort: AutomationListSort | null
onSort: (field: AutomationListSortField) => void
}): React.JSX.Element {
const active = sort?.field === field
const direction = active ? sort.direction : null
// Why: one interpolated key per direction — word order and punctuation around
// the column name differ per language.
const sortedLabel =
direction === 'asc'
? translate(
'auto.components.automations.AutomationListSortHeader.sortedAscending',
'{{value0}}, sorted ascending',
{ value0: label }
)
: direction === 'desc'
? translate(
'auto.components.automations.AutomationListSortHeader.sortedDescending',
'{{value0}}, sorted descending',
{ value0: label }
)
: null
return (
<button
type="button"
onClick={() => onSort(field)}
aria-label={sortedLabel ?? label}
className={cn(
'flex min-w-0 items-center gap-1 rounded-sm text-left text-[11px] font-medium tracking-[0.08em] uppercase select-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none',
active && 'text-foreground'
)}
>
<span className="truncate">{label}</span>
{direction === 'asc' ? <ArrowUp aria-hidden="true" className="size-3 shrink-0" /> : null}
{direction === 'desc' ? <ArrowDown aria-hidden="true" className="size-3 shrink-0" /> : null}
</button>
)
}
@@ -1,7 +1,8 @@
// @vitest-environment happy-dom
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import userEvent from '@testing-library/user-event'
import { AutomationListTableHeader } from './AutomationListTableHeader'
import {
LIST_TABLE_HEADER_CLASS,
@@ -43,3 +44,45 @@ describe('AutomationListTableHeader', () => {
expect(nameCell.className).toBe(LIST_TABLE_STICKY_HEADER_CELL_CLASS)
})
})
describe('AutomationListTableHeader sorting', () => {
afterEach(cleanup)
it('exposes only the orderable columns as buttons', () => {
render(<AutomationListTableHeader sort={null} onSort={() => {}} />)
expect(screen.getAllByRole('button').map((button) => button.textContent)).toEqual([
'Name',
'Last run'
])
})
it('reports the sorted column and direction in the accessible name', () => {
const { rerender } = render(
<AutomationListTableHeader sort={{ field: 'name', direction: 'asc' }} onSort={() => {}} />
)
expect(screen.getByRole('button', { name: 'Name, sorted ascending' })).toBeDefined()
expect(screen.getByRole('button', { name: 'Last run' })).toBeDefined()
rerender(
<AutomationListTableHeader sort={{ field: 'lastRun', direction: 'desc' }} onSort={() => {}} />
)
expect(screen.getByRole('button', { name: 'Last run, sorted descending' })).toBeDefined()
expect(screen.getByRole('button', { name: 'Name' })).toBeDefined()
})
it('requests a sort for the clicked column', async () => {
const onSort = vi.fn()
render(<AutomationListTableHeader sort={null} onSort={onSort} />)
await userEvent.click(screen.getByRole('button', { name: 'Last run' }))
expect(onSort.mock.calls).toEqual([['lastRun']])
})
it('stays non-interactive when the list cannot be sorted', () => {
render(<AutomationListTableHeader />)
expect(screen.queryAllByRole('button')).toEqual([])
})
})
@@ -5,34 +5,85 @@ import {
LIST_TABLE_HEADER_CLASS,
LIST_TABLE_STICKY_HEADER_CELL_CLASS
} from '@/lib/list-table-layout'
import { AutomationListSortHeader } from './AutomationListSortHeader'
import type { AutomationListSort, AutomationListSortField } from './automation-list-view'
export function AutomationListTableHeader(): React.JSX.Element {
const labels = [
['auto.components.automations.AutomationsPage.tableName', 'Name'],
['auto.components.automations.AutomationDetail.18763ded26', 'Schedule'],
['auto.components.automations.AutomationsPage.tableProject', 'Project'],
['auto.components.automations.AutomationsPage.tableHost', 'Host'],
['auto.components.automations.AutomationDetail.578ff46987', 'Next run'],
['auto.components.automations.AutomationsPage.tableLastRun', 'Last run'],
['auto.components.automations.AutomationsPage.tableStatus', 'Status'],
['auto.components.automations.AutomationDetail.2df8970cd5', 'Agent']
] as const
type HeaderColumn = {
key: string
fallback: string
/** Absent for columns the list cannot order by. */
sortField?: AutomationListSortField
}
const COLUMNS: readonly HeaderColumn[] = [
{
key: 'auto.components.automations.AutomationsPage.tableName',
fallback: 'Name',
sortField: 'name'
},
{
key: 'auto.components.automations.AutomationDetail.18763ded26',
fallback: 'Schedule'
},
{
key: 'auto.components.automations.AutomationsPage.tableProject',
fallback: 'Project'
},
{
key: 'auto.components.automations.AutomationsPage.tableHost',
fallback: 'Host'
},
{
key: 'auto.components.automations.AutomationDetail.578ff46987',
fallback: 'Next run'
},
{
key: 'auto.components.automations.AutomationsPage.tableLastRun',
fallback: 'Last run',
sortField: 'lastRun'
},
{
key: 'auto.components.automations.AutomationsPage.tableStatus',
fallback: 'Status'
},
{
key: 'auto.components.automations.AutomationDetail.2df8970cd5',
fallback: 'Agent'
}
]
export function AutomationListTableHeader({
sort = null,
onSort
}: {
sort?: AutomationListSort | null
onSort?: (field: AutomationListSortField) => void
} = {}): React.JSX.Element {
return (
<div className={`${AUTOMATIONS_TABLE_GRID_CLASS} ${LIST_TABLE_HEADER_CLASS}`}>
{labels.map(([key, fallback], index) => (
<span
key={key}
className={
index === 0
? LIST_TABLE_STICKY_HEADER_CELL_CLASS
: index === labels.length - 1
? 'text-center'
: undefined
}
>
{translate(key, fallback)}
</span>
))}
{COLUMNS.map((column, index) => {
const label = translate(column.key, column.fallback)
const className =
index === 0
? LIST_TABLE_STICKY_HEADER_CELL_CLASS
: index === COLUMNS.length - 1
? 'text-center'
: undefined
return (
<span key={column.key} className={className}>
{column.sortField && onSort ? (
<AutomationListSortHeader
field={column.sortField}
label={label}
sort={sort}
onSort={onSort}
/>
) : (
label
)}
</span>
)
})}
<span className="sr-only">
{translate('auto.components.automations.AutomationsPage.tableActions', 'Actions')}
</span>
@@ -11,7 +11,12 @@ import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import { AutomationsListPanel } from './AutomationsListPanel'
import { EMPTY_AUTOMATION_LIST_FILTER } from './automation-list-view'
import {
buildAutomationListViewItems,
EMPTY_AUTOMATION_LIST_FILTER,
type AutomationListSort,
type AutomationListSortField
} from './automation-list-view'
import type { AutomationHostCatalogView } from './use-automation-host-catalog'
import {
makeAutomation,
@@ -49,7 +54,13 @@ const HOST_CATALOG = {
status: 'all',
announceFallback: false
},
rows: { rows: [], automations: [], capturedOwners: new Map(), groups: [], answered: true },
rows: {
rows: [],
automations: [],
capturedOwners: new Map(),
groups: [],
answered: true
},
loadCounts: { failedHostCount: 0, totalHostCount: 1 },
selectHost: () => undefined,
recover: () => undefined,
@@ -70,6 +81,8 @@ function renderPanel(
selectExternalKey?: (key: string | null) => void
externalEntries?: readonly ExternalAutomationListEntry[]
setActivePaneTab?: (tab: AutomationPaneTab) => void
listSort?: AutomationListSort | null
onListSortChange?: (field: AutomationListSortField) => void
} = {}
): void {
const externalEntries = options.externalEntries ?? []
@@ -95,8 +108,12 @@ function renderPanel(
externalManagersUncheckedNotice={uncheckedNotice}
onSelectHost={() => undefined}
onRecoverHost={() => undefined}
filteredRows={rows}
filteredExternalAutomationEntries={externalEntries}
sortedListItems={buildAutomationListViewItems({
rows,
externalEntries
})}
listSort={options.listSort ?? null}
onListSortChange={options.onListSortChange ?? (() => undefined)}
selectedRowKey={options.selectedRowKey ?? null}
selectedExternalKey={options.selectedExternalKey ?? null}
relativeNow={0}
@@ -221,7 +238,11 @@ describe('AutomationsListPanel enter key navigation', () => {
const input = searchField()
expect(input).not.toBeNull()
const enter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
const enter = new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true
})
input?.dispatchEvent(enter)
expect(enter.defaultPrevented).toBe(true)
@@ -252,7 +273,11 @@ describe('AutomationsListPanel enter key navigation', () => {
const input = searchField()
expect(input).not.toBeNull()
const enter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
const enter = new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true
})
input?.dispatchEvent(enter)
expect(enter.defaultPrevented).toBe(true)
@@ -272,7 +297,11 @@ describe('AutomationsListPanel enter key navigation', () => {
const input = searchField()
expect(input).not.toBeNull()
const enter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
const enter = new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true
})
input?.dispatchEvent(enter)
expect(detailOpened).toBe(false)
@@ -23,13 +23,19 @@ import {
import type { AutomationListRow } from './automation-list-row-identity'
import type { AutomationPaneTab } from './automation-page-state'
import { AutomationListFilterPills } from './AutomationListFilterMenu'
import { isAutomationListFilterActive, type AutomationListFilter } from './automation-list-view'
import {
isAutomationListFilterActive,
type AutomationListFilter,
type AutomationListSort,
type AutomationListSortField,
type AutomationListViewItem
} from './automation-list-view'
import { automationHostFilterStableKey } from '../../../../shared/automation-host-filter'
import type { AutomationTemplate } from './automation-templates'
import type { ExternalAutomationListEntry } from './external-automation-list-entries'
import type { ExternalAutomationScope } from './external-automation-scope-client'
import { AutomationListLocalRows } from './AutomationListLocalRows'
import { AutomationListExternalRows } from './AutomationListExternalRows'
import { AutomationListLocalRow } from './AutomationListLocalRow'
import { AutomationListExternalRow } from './AutomationListExternalRow'
import { AutomationHostFilterNotice, AutomationHostLoadSummary } from './AutomationHostFilterNotice'
import { AutomationListEmptyView } from './AutomationListEmptyView'
import { resolveAutomationListEmptyState } from './automation-list-empty-state'
@@ -63,8 +69,10 @@ type AutomationsListPanelProps = {
action: AutomationHostRecoveryAction,
entry?: AutomationHostCatalogEntry | null
) => void
filteredRows: readonly AutomationListRow[]
filteredExternalAutomationEntries: readonly ExternalAutomationListEntry[]
/** Both collections as one list in render order; the sort spans local and external rows. */
sortedListItems: readonly AutomationListViewItem[]
listSort: AutomationListSort | null
onListSortChange: (field: AutomationListSortField) => void
selectedRowKey: string | null
selectedExternalKey: string | null
selectedExternal?: ExternalAutomationListEntry | null
@@ -124,8 +132,9 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS
externalManagersUncheckedNotice,
onSelectHost,
onRecoverHost,
filteredRows,
filteredExternalAutomationEntries,
sortedListItems,
listSort,
onListSortChange,
selectedRowKey,
selectedExternalKey,
relativeNow,
@@ -161,18 +170,20 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS
// Hosts moved into the Filters menu, so its toolbar row is the focus fallback now.
const toolbarRef = useRef<HTMLDivElement>(null)
const pendingKeyboardScrollRef = useRef(false)
const rowKeys = React.useMemo(() => filteredRows.map((row) => row.key), [filteredRows])
const visibleItems = React.useMemo(
() => [
...filteredRows.map((row) => ({ kind: 'local' as const, id: row.key })),
...filteredExternalAutomationEntries.map((entry) => ({
kind: 'external' as const,
id: entry.key
}))
],
[filteredExternalAutomationEntries, filteredRows]
// Why: keyboard traversal and focus recovery read render order, which the sort owns.
const rowKeys = React.useMemo(
() => sortedListItems.filter((item) => item.kind === 'local').map((item) => item.id),
[sortedListItems]
)
useAutomationListFocusRecovery({ rowKeys, containerRef: listRef, fallbackRef: toolbarRef })
const visibleItems = React.useMemo(
() => sortedListItems.map((item) => ({ kind: item.kind, id: item.id })),
[sortedListItems]
)
useAutomationListFocusRecovery({
rowKeys,
containerRef: listRef,
fallbackRef: toolbarRef
})
const handleSearchArrowNavigate = React.useCallback(
(key: AutomationListArrowKey) => {
const next = getAutomationListArrowNavigationTarget({
@@ -331,24 +342,30 @@ export function AutomationsListPanel(props: AutomationsListPanelProps): React.JS
>
{hasFilteredListItems ? (
<div className="min-w-full w-fit">
<AutomationListTableHeader />
<AutomationListTableHeader sort={listSort} onSort={onListSortChange} />
<div className="divide-y divide-border/50">
<AutomationListLocalRows {...rowProps} rows={filteredRows} />
<AutomationListExternalRows
entries={filteredExternalAutomationEntries}
selectedExternalKey={selectedExternalKey}
relativeNow={relativeNow}
sshConnectionStates={sshConnectionStates}
externalActionKey={externalActionKey}
onSelect={(entryKey) => {
selectAutomationRow(null)
selectExternalKey(entryKey)
setActivePaneTab('overview')
onOpenDetail()
}}
onRequestAction={requestExternalAction}
onEdit={openEditExternalDialog}
/>
{sortedListItems.map((item) =>
item.kind === 'local' ? (
<AutomationListLocalRow key={item.id} {...rowProps} row={item.row} />
) : (
<AutomationListExternalRow
key={item.id}
entry={item.entry}
selectedExternalKey={selectedExternalKey}
relativeNow={relativeNow}
sshConnectionStates={sshConnectionStates}
externalActionKey={externalActionKey}
onSelect={(entryKey) => {
selectAutomationRow(null)
selectExternalKey(entryKey)
setActivePaneTab('overview')
onOpenDetail()
}}
onRequestAction={requestExternalAction}
onEdit={openEditExternalDialog}
/>
)
)}
</div>
</div>
) : (
@@ -19,7 +19,6 @@ import {
addRuntimeProject,
api,
installAutomationsPageHarness,
listedRow,
mocks,
renderPage,
runtimeHost,
@@ -30,6 +29,7 @@ import {
scopedList,
settleHostQueries
} from './automations-page-test-harness'
import { listedRow } from './automations-page-listed-items'
import { makeAutomation, REPO_ID, WORKSPACE_ID } from './automations-page-fixtures'
import type { Repo } from '../../../../shared/repo-types'
import type { ProjectHostSetup } from '../../../../shared/project-types'
@@ -22,6 +22,7 @@ import {
SELF_PRECONDITION,
settleHostQueries
} from './automations-page-test-harness'
import { listedRows } from './automations-page-listed-items'
import { makeAutomation } from './automations-page-fixtures'
installAutomationsPageHarness()
@@ -36,9 +37,7 @@ async function collidingHosts(): Promise<void> {
}
function selectDesktopRow(): string {
const row = mocks.listPanel?.filteredRows.find(
(candidate) => candidate.automation.name === 'Desktop nightly'
)
const row = listedRows().find((candidate) => candidate.automation.name === 'Desktop nightly')
expect(row).toBeDefined()
return row?.key ?? ''
}
@@ -58,9 +57,7 @@ describe('AutomationsPage row actions under a colliding automation id', () => {
await renderPage()
await settleHostQueries()
const remote = mocks.listPanel?.filteredRows.find(
(candidate) => candidate.automation.name === 'Remote nightly'
)
const remote = listedRows().find((candidate) => candidate.automation.name === 'Remote nightly')
await act(async () => {
mocks.listPanel?.selectAutomationRow(remote?.key ?? '')
})
@@ -20,6 +20,7 @@ import {
RUNTIME_SELF_FILTER,
settleHostQueries
} from './automations-page-test-harness'
import { listedExternalEntries } from './automations-page-listed-items'
import { makeExternalManager } from './automations-page-fixtures'
installAutomationsPageHarness()
@@ -117,7 +118,7 @@ describe('AutomationsPage external manager probes', () => {
await renderPage()
await settleHostQueries()
expect(mocks.listPanel?.filteredExternalAutomationEntries).toEqual([])
expect(listedExternalEntries()).toEqual([])
})
it('drops the previous host rows when the selection moves, not when the new probe lands', async () => {
@@ -127,7 +128,7 @@ describe('AutomationsPage external manager probes', () => {
const { rerender } = await renderPage()
await settleHostQueries()
expect(mocks.listPanel?.filteredExternalAutomationEntries.length).toBeGreaterThan(0)
expect(listedExternalEntries().length).toBeGreaterThan(0)
// The new host never answers, so anything still listed belongs to the old one.
api.automations.listExternalManagerForOwner.mockImplementation(
@@ -137,7 +138,7 @@ describe('AutomationsPage external manager probes', () => {
await rerender()
await settleHostQueries()
expect(mocks.listPanel?.filteredExternalAutomationEntries).toEqual([])
expect(listedExternalEntries()).toEqual([])
})
it('reports a host it could not check rather than showing it as clean', async () => {
@@ -15,7 +15,6 @@ import {
addRuntimeProject,
api,
installAutomationsPageHarness,
listedRow,
mocks,
renderPage,
runtimeHost,
@@ -26,6 +25,7 @@ import {
scopedList,
settleHostQueries
} from './automations-page-test-harness'
import { listedRow } from './automations-page-listed-items'
import { makeAutomation } from './automations-page-fixtures'
installAutomationsPageHarness()
@@ -22,6 +22,7 @@ import {
SELF_PRECONDITION,
settleHostQueries
} from './automations-page-test-harness'
import { listedRows } from './automations-page-listed-items'
import { makeAutomation, makeRun } from './automations-page-fixtures'
installAutomationsPageHarness()
@@ -69,7 +70,7 @@ describe('AutomationsPage refresh', () => {
await renderPage()
expect(mocks.listPanel?.filteredRows[0]?.usageSummary).toEqual(usageSummary)
expect(listedRows()[0]?.usageSummary).toEqual(usageSummary)
})
it('does not re-list through the active runtime just because one is selected', async () => {
@@ -231,9 +232,7 @@ describe('AutomationsPage multi-host selection', () => {
)
).toEqual(['Desktop nightly', 'Remote nightly'])
const remote = mocks.listPanel?.filteredRows.find(
(row) => row.automation.name === 'Remote nightly'
)
const remote = listedRows().find((row) => row.automation.name === 'Remote nightly')
await act(async () => {
mocks.listPanel?.selectAutomationRow(remote?.key ?? '')
})
@@ -16,12 +16,12 @@ import type { Automation } from '../../../../shared/automations-types'
import {
api,
installAutomationsPageHarness,
listedRow,
mocks,
renderPage,
scopedList,
settleHostQueries
} from './automations-page-test-harness'
import { listedRow, listedRows } from './automations-page-listed-items'
import { makeAutomation } from './automations-page-fixtures'
installAutomationsPageHarness()
@@ -42,7 +42,7 @@ function desktopStoreHolds(automations: Automation[]): void {
/** The next-run column reads this; the mocked list panel renders only names. */
function listedNextRunAt(): number | null | undefined {
return mocks.listPanel?.filteredRows[0]?.automation.nextRunAt
return listedRows()[0]?.automation.nextRunAt
}
describe('AutomationsPage run visibility', () => {
@@ -24,13 +24,13 @@ import {
api,
DESKTOP_SELF_OWNER,
installAutomationsPageHarness,
listedRow,
mocks,
renderPage,
rows,
scopedList,
SELF_PRECONDITION
} from './automations-page-test-harness'
import { listedRow, listedExternalEntries } from './automations-page-listed-items'
import {
makeAutomation,
makeExternalManager,
@@ -147,7 +147,7 @@ describe('AutomationsPage list rendering', () => {
api.automations.updateExternalForOwner.mockResolvedValue(undefined)
await renderPage()
const entry = mocks.listPanel?.filteredExternalAutomationEntries[0]
const entry = listedExternalEntries()[0]
if (!entry) {
throw new Error('no external entry to edit')
}
@@ -177,7 +177,7 @@ describe('AutomationsPage list rendering', () => {
api.automations.runExternalActionForOwner.mockResolvedValue(undefined)
await renderPage()
const entry = mocks.listPanel?.filteredExternalAutomationEntries[0]
const entry = listedExternalEntries()[0]
if (!entry) {
throw new Error('no external entry to act on')
}
@@ -217,7 +217,7 @@ describe('AutomationsPage list rendering', () => {
api.automations.listExternalRunsForOwner.mockResolvedValue({ runs: [], total: 0 })
const { container } = await renderPage()
const entry = mocks.listPanel?.filteredExternalAutomationEntries[0]
const entry = listedExternalEntries()[0]
if (!entry) {
throw new Error('no external entry to read runs for')
}
@@ -1,6 +1,7 @@
import React from 'react'
import type { AutomationsPageController } from './use-automations-page-controller'
import { AutomationsListPanel } from './AutomationsListPanel'
import { nextAutomationListSort } from './automation-list-view'
export function AutomationsPageListPanel({
controller,
@@ -45,8 +46,6 @@ export function AutomationsPageListPanel({
hasListItems,
hasFilteredListItems,
isListSearchQueryTooLarge,
filteredRows,
filteredExternalAutomationEntries,
selectedRow,
selectedExternal,
searchCounts
@@ -79,8 +78,9 @@ export function AutomationsPageListPanel({
void pageRefresh.refresh()
}
}}
filteredRows={filteredRows}
filteredExternalAutomationEntries={filteredExternalAutomationEntries}
sortedListItems={list.sortedListItems}
listSort={local.listSort}
onListSortChange={(field) => local.setListSort(nextAutomationListSort(local.listSort, field))}
selectedRowKey={selectedRow?.key ?? null}
selectedExternalKey={local.selectedExternalKey}
selectedExternal={selectedExternal}
@@ -5,32 +5,34 @@ import {
type AutomationListSort,
type AutomationListViewItem
} from './automation-list-view'
import { unscopedAutomationListRows } from './automation-list-row-identity'
import { makeAutomation } from './automations-page-fixtures'
const locale = vi.hoisted(() => ({ value: 'en' }))
vi.mock('@/i18n/i18n', () => ({ getIntlLocale: () => locale.value }))
afterEach(() => {
vi.restoreAllMocks()
locale.value = 'en'
})
function rows(count = 512): AutomationListViewItem[] {
function items(count = 512): AutomationListViewItem[] {
const names = ['Alpha', 'álpha', 'Ångström', 'Zebra', 'Örebro', 'I', 'ı', 'İ', 'job 10', 'job 2']
return buildAutomationListViewItems({
automations: Array.from({ length: count }, (_, index) =>
makeAutomation({ id: `job-${index}`, name: names[(index * 7) % names.length] })
rows: unscopedAutomationListRows(
Array.from({ length: count }, (_, index) =>
makeAutomation({
id: `job-${index}`,
name: names[(index * 7) % names.length]
})
)
),
externalEntries: [],
runs: []
externalEntries: []
})
}
function previousOrder(items: AutomationListViewItem[], sort: AutomationListSort) {
/** The pre-collator comparator, resolving options on every comparison. */
function previousOrder(list: AutomationListViewItem[], sort: AutomationListSort, locale: string) {
function compare(left: AutomationListViewItem, right: AutomationListViewItem) {
const value =
sort.field === 'name'
? left.name.localeCompare(right.name, locale.value, { sensitivity: 'base' })
? left.name.localeCompare(right.name, locale, { sensitivity: 'base' })
: (left.lastRunAt ?? 0) - (right.lastRunAt ?? 0)
return value !== 0
? sort.direction === 'asc'
@@ -38,37 +40,35 @@ function previousOrder(items: AutomationListViewItem[], sort: AutomationListSort
: -value
: left.id.localeCompare(right.id)
}
return [...items].sort(compare)
return [...list].sort(compare)
}
describe('automation list collation', () => {
it.each(['en', 'sv', 'tr', 'ja'])(
'preserves %s ordering, tie-breaks and input identity',
(language) => {
locale.value = language
const items = rows()
const original = [...items]
(locale) => {
const list = items()
const original = [...list]
for (const direction of ['asc', 'desc'] as const) {
const sort = { field: 'name', direction } as const
const expected = previousOrder(items, sort)
const result = sortAutomationListViewItems(items, sort)
const expected = previousOrder(list, sort, locale)
const result = sortAutomationListViewItems(list, sort, locale)
expect(result).toEqual(expected)
expect(result.every((row, index) => row === expected[index])).toBe(true)
}
expect(items).toEqual(original)
expect(list).toEqual(original)
}
)
it('resolves collation once per name sort and responds to locale changes', () => {
const items = rows()
it('resolves collation once per name sort and follows the locale it is given', () => {
const list = items()
const OriginalCollator = Intl.Collator
const construct = vi.spyOn(Intl, 'Collator').mockImplementation(function (locales, options) {
return new OriginalCollator(locales, options)
})
const compare = vi.spyOn(String.prototype, 'localeCompare')
sortAutomationListViewItems(items, { field: 'name', direction: 'asc' })
locale.value = 'sv'
sortAutomationListViewItems(items, { field: 'name', direction: 'desc' })
sortAutomationListViewItems(list, { field: 'name', direction: 'asc' }, 'en')
sortAutomationListViewItems(list, { field: 'name', direction: 'desc' }, 'sv')
expect(construct.mock.calls).toEqual([
['en', { sensitivity: 'base' }],
['sv', { sensitivity: 'base' }]
@@ -76,16 +76,39 @@ describe('automation list collation', () => {
expect(compare.mock.calls.filter((args) => args.length >= 3)).toHaveLength(0)
})
it('orders by row key, not the bare automation ID, so hosts cannot collapse', () => {
const duplicate = makeAutomation({ id: 'shared', name: 'Same' })
const list = buildAutomationListViewItems({
rows: [
{
key: 'row|host-b|shared',
automation: duplicate,
hostLabel: 'b',
usageSummary: null
},
{
key: 'row|host-a|shared',
automation: duplicate,
hostLabel: 'a',
usageSummary: null
}
],
externalEntries: []
})
const sorted = sortAutomationListViewItems(list, { field: 'name', direction: 'asc' }, 'en')
expect(sorted.map((item) => item.id)).toEqual(['row|host-a|shared', 'row|host-b|shared'])
})
it('does not construct collation for unsorted, time-sorted or trivial lists', () => {
const items = rows()
const list = items()
const construct = vi.spyOn(Intl, 'Collator')
expect(sortAutomationListViewItems(items, null)).toEqual(items)
expect(sortAutomationListViewItems(list, null, 'en')).toEqual(list)
const sort = { field: 'lastRun', direction: 'desc' } as const
expect(sortAutomationListViewItems(items, sort)).toEqual(previousOrder(items, sort))
expect(sortAutomationListViewItems([], { field: 'name', direction: 'asc' })).toEqual([])
expect(sortAutomationListViewItems(list, sort, 'en')).toEqual(previousOrder(list, sort, 'en'))
expect(sortAutomationListViewItems([], { field: 'name', direction: 'asc' }, 'en')).toEqual([])
expect(
sortAutomationListViewItems(items.slice(0, 1), { field: 'name', direction: 'asc' })
).toEqual(items.slice(0, 1))
sortAutomationListViewItems(list.slice(0, 1), { field: 'name', direction: 'asc' }, 'en')
).toEqual(list.slice(0, 1))
expect(construct).not.toHaveBeenCalled()
})
})
@@ -1,7 +1,6 @@
import { describe, expect, it } from 'vitest'
import type {
Automation,
AutomationRun,
AutomationRunStatus,
ExternalAutomationJob,
ExternalAutomationManager
@@ -48,31 +47,6 @@ function makeAutomation(overrides: Partial<Automation> = {}): Automation {
}
}
function makeRun(overrides: Partial<AutomationRun> = {}): AutomationRun {
return {
id: 'run-1',
automationId: 'automation-1',
title: 'Zebra job',
scheduledFor: 10,
status: 'completed',
trigger: 'scheduled',
workspaceId: 'worktree-1',
sessionKind: 'terminal',
chatSessionId: null,
terminalSessionId: null,
terminalPaneKey: null,
terminalPtyId: null,
outputSnapshot: null,
precheckResult: null,
usage: null,
error: null,
startedAt: 20,
dispatchedAt: 30,
createdAt: 10,
...overrides
}
}
function makeExternalEntry(
overrides: Partial<ExternalAutomationJob> = {}
): ExternalAutomationListEntry {
@@ -118,22 +92,69 @@ function makeExternalEntry(
}
}
/** A catalog row with an optional projected last-run status, keyed like a real host row. */
function makeCatalogRow(
id: string,
overrides: Partial<Automation> = {},
lastRunStatus?: AutomationRunStatus
): AutomationListRow {
return {
key: `row|host|${id}`,
automation: makeAutomation({ id, ...overrides }),
hostLabel: 'This computer',
usageSummary: lastRunStatus
? {
knownRuns: 1,
unavailableRuns: 0,
inputTokens: 0,
outputTokens: 0,
cacheTokens: 0,
reasoningOutputTokens: 0,
totalTokens: 0,
estimatedCostUsd: null,
lastRunStatus,
lastRunAt: 111
}
: null
}
}
const rowKey = (id: string): string => `row|host|${id}`
describe('automation-list-view', () => {
it('counts and detects active filters', () => {
expect(isAutomationListFilterActive({ status: 'all', lastRun: 'all', agentIds: [] })).toBe(
false
)
expect(isAutomationListFilterActive({ status: 'paused', lastRun: 'all', agentIds: [] })).toBe(
true
)
expect(countAutomationListFilters({ status: 'paused', lastRun: 'failed', agentIds: [] })).toBe(
2
)
expect(
isAutomationListFilterActive({
status: 'all',
lastRun: 'all',
agentIds: []
})
).toBe(false)
expect(
isAutomationListFilterActive({
status: 'paused',
lastRun: 'all',
agentIds: []
})
).toBe(true)
expect(
countAutomationListFilters({
status: 'paused',
lastRun: 'failed',
agentIds: []
})
).toBe(2)
})
it('toggles sort direction and defaults last run to newest first', () => {
expect(nextAutomationListSort(null, 'name')).toEqual({ field: 'name', direction: 'asc' })
expect(nextAutomationListSort(null, 'lastRun')).toEqual({ field: 'lastRun', direction: 'desc' })
expect(nextAutomationListSort(null, 'name')).toEqual({
field: 'name',
direction: 'asc'
})
expect(nextAutomationListSort(null, 'lastRun')).toEqual({
field: 'lastRun',
direction: 'desc'
})
expect(nextAutomationListSort({ field: 'name', direction: 'asc' }, 'name')).toEqual({
field: 'name',
direction: 'desc'
@@ -146,82 +167,62 @@ describe('automation-list-view', () => {
it('filters by enabled state and last-run outcome', () => {
const items = applyAutomationListView({
automations: [
makeAutomation({ id: 'paused', name: 'Paused', enabled: false }),
makeAutomation({ id: 'ok', name: 'Healthy' })
rows: [
makeCatalogRow('paused', { name: 'Paused', enabled: false }, 'completed'),
makeCatalogRow('ok', { name: 'Healthy' }, 'dispatch_failed')
],
externalEntries: [makeExternalEntry()],
runs: [
makeRun({ automationId: 'paused', status: 'completed' }),
makeRun({ automationId: 'ok', status: 'dispatch_failed' })
],
filter: { status: 'enabled', lastRun: 'failed', agentIds: [] },
sort: null
sort: null,
locale: 'en'
})
expect(items.map((item) => item.id)).toEqual(['ok', 'manager-1:job-1'])
expect(items.map((item) => item.id)).toEqual([rowKey('ok'), 'manager-1:job-1'])
})
it('filters local rows by multiple agents and leaves external rows out of agent scopes', () => {
const items = applyAutomationListView({
automations: [
makeAutomation({ id: 'codex-job', agentId: 'codex' }),
makeAutomation({ id: 'claude-job', agentId: 'claude' })
rows: [
makeCatalogRow('codex-job', { agentId: 'codex' }),
makeCatalogRow('claude-job', { agentId: 'claude' })
],
externalEntries: [makeExternalEntry()],
runs: [],
filter: { status: 'all', lastRun: 'all', agentIds: ['codex', 'claude'] },
sort: null
sort: null,
locale: 'en'
})
expect(items.map((item) => item.id)).toEqual(['codex-job', 'claude-job'])
expect(items.map((item) => item.id)).toEqual([rowKey('codex-job'), rowKey('claude-job')])
})
it('counts an agent filter alongside status and last-run filters', () => {
expect(isAutomationListFilterActive({ status: 'all', lastRun: 'all', agentIds: [] })).toBe(
false
)
expect(
countAutomationListFilters({ status: 'paused', lastRun: 'failed', agentIds: ['codex'] })
isAutomationListFilterActive({
status: 'all',
lastRun: 'all',
agentIds: []
})
).toBe(false)
expect(
countAutomationListFilters({
status: 'paused',
lastRun: 'failed',
agentIds: ['codex']
})
).toBe(3)
})
it('sorts by name across local and external rows', () => {
const items = applyAutomationListView({
automations: [makeAutomation({ name: 'Zebra job' })],
rows: [makeCatalogRow('zebra', { name: 'Zebra job' })],
externalEntries: [makeExternalEntry({ name: 'Alpha digest' })],
runs: [],
filter: { status: 'all', lastRun: 'all', agentIds: [] },
sort: { field: 'name', direction: 'asc' }
sort: { field: 'name', direction: 'asc' },
locale: 'en'
})
expect(items.map((item) => item.name)).toEqual(['Alpha digest', 'Zebra job'])
})
it('filters catalog rows by status, agent, and the projected last-run status', () => {
function makeCatalogRow(
id: string,
overrides: Partial<Automation>,
lastRunStatus?: AutomationRunStatus
): AutomationListRow {
return {
key: `row|host|${id}`,
automation: makeAutomation({ id, ...overrides }),
hostLabel: 'This computer',
usageSummary: lastRunStatus
? {
knownRuns: 1,
unavailableRuns: 0,
inputTokens: 0,
outputTokens: 0,
cacheTokens: 0,
reasoningOutputTokens: 0,
totalTokens: 0,
estimatedCostUsd: null,
lastRunStatus,
lastRunAt: 111
}
: null
}
}
const rows = [
makeCatalogRow('paused-codex', { enabled: false, agentId: 'codex' }),
makeCatalogRow('failed-claude', { agentId: 'claude' }, 'dispatch_failed'),
@@ -229,9 +230,10 @@ describe('automation-list-view', () => {
makeCatalogRow('never-codex', { agentId: 'codex' })
]
const ids = (filter: Partial<AutomationListFilter>) =>
filterAutomationListRows(rows, { ...EMPTY_AUTOMATION_LIST_FILTER, ...filter }).map(
(row) => row.automation.id
)
filterAutomationListRows(rows, {
...EMPTY_AUTOMATION_LIST_FILTER,
...filter
}).map((row) => row.automation.id)
expect(ids({ status: 'paused' })).toEqual(['paused-codex'])
expect(ids({ agentIds: ['claude'] })).toEqual(['failed-claude'])
@@ -249,7 +251,10 @@ describe('automation-list-view', () => {
catalogRef:
targetId === null
? null
: { authority: { kind: 'desktop' }, selector: { kind: 'ssh', targetId } },
: {
authority: { kind: 'desktop' },
selector: { kind: 'ssh', targetId }
},
hostLabel: targetId ?? '',
usageSummary: null
})
@@ -257,9 +262,10 @@ describe('automation-list-view', () => {
const keyOf = (row: AutomationListRow): string =>
row.catalogRef ? hostStableKey(row.catalogRef) : ''
const ids = (hostStableKeys: readonly string[]) =>
filterAutomationListRows(rows, { ...EMPTY_AUTOMATION_LIST_FILTER, hostStableKeys }).map(
(row) => row.automation.id
)
filterAutomationListRows(rows, {
...EMPTY_AUTOMATION_LIST_FILTER,
hostStableKeys
}).map((row) => row.automation.id)
// Multi-select is any-of; a pre-catalog row names no host and is excluded.
expect(ids([keyOf(rows[0]), keyOf(rows[1])])).toEqual(['on-a', 'on-b'])
@@ -290,15 +296,22 @@ describe('automation-list-view', () => {
it('sorts by last run newest first and keeps never-run rows last', () => {
const items = applyAutomationListView({
automations: [
makeAutomation({ id: 'old', name: 'Old' }),
makeAutomation({ id: 'never', name: 'Never' })
rows: [
makeCatalogRow('old', {
name: 'Old',
lastRunAt: Date.parse('2026-08-11T09:00:00Z')
}),
makeCatalogRow('never', { name: 'Never' })
],
externalEntries: [makeExternalEntry({ lastRunAt: '2026-08-12T09:00:00Z' })],
runs: [makeRun({ automationId: 'old', dispatchedAt: Date.parse('2026-08-11T09:00:00Z') })],
filter: { status: 'all', lastRun: 'all', agentIds: [] },
sort: { field: 'lastRun', direction: 'desc' }
sort: { field: 'lastRun', direction: 'desc' },
locale: 'en'
})
expect(items.map((item) => item.id)).toEqual(['manager-1:job-1', 'old', 'never'])
expect(items.map((item) => item.id)).toEqual([
'manager-1:job-1',
rowKey('old'),
rowKey('never')
])
})
})
@@ -1,5 +1,3 @@
import { getIntlLocale } from '@/i18n/i18n'
import type { Automation, AutomationRun } from '../../../../shared/automations-types'
import type { TuiAgent } from '../../../../shared/tui-agent'
import { hostStableKey } from '../../../../shared/automation-owner-key'
import type { AutomationListRow } from './automation-list-row-identity'
@@ -7,8 +5,6 @@ import type { ExternalAutomationListEntry } from './external-automation-list-ent
import {
getAutomationRowLastRunSnapshot,
getExternalAutomationLastRunSnapshot,
getLocalAutomationLastRunSnapshot,
indexLatestAutomationRuns,
type AutomationLastRunSnapshot
} from './automation-list-last-run'
@@ -22,6 +18,13 @@ export type AutomationListSort = {
direction: AutomationListSortDirection
}
/**
* A row and an external job flattened to what the shared list renders and sorts.
*
* `id` is the row's own key, never the bare automation ID: under All hosts two
* authorities can return the same ID, and the sort tie-break decides render
* order, so a bare ID would collapse them. See `automation-list-row-identity`.
*/
export type AutomationListViewItem =
| {
kind: 'local'
@@ -31,7 +34,7 @@ export type AutomationListViewItem =
lastRunAt: number | null
lastRun: AutomationLastRunSnapshot
agentId: TuiAgent
automation: Automation
row: AutomationListRow
}
| {
kind: 'external'
@@ -117,30 +120,26 @@ function matchesLastRunFilter(
return snapshot.tone === filter
}
/** Flattens the two rendered collections into one sortable list, preserving row identity. */
export function buildAutomationListViewItems({
automations,
externalEntries,
runs
rows,
externalEntries
}: {
automations: readonly Automation[]
rows: readonly AutomationListRow[]
externalEntries: readonly ExternalAutomationListEntry[]
runs: readonly AutomationRun[]
}): AutomationListViewItem[] {
const lastRunByAutomationId = indexLatestAutomationRuns(runs)
const locals: AutomationListViewItem[] = automations.map((automation) => {
const lastRun = getLocalAutomationLastRunSnapshot(
automation,
lastRunByAutomationId.get(automation.id)
)
const locals: AutomationListViewItem[] = rows.map((row) => {
// Why: the same snapshot the row cell renders, so the sort matches the column.
const lastRun = getAutomationRowLastRunSnapshot(row)
return {
kind: 'local',
id: automation.id,
name: automation.name,
enabled: automation.enabled,
id: row.key,
name: row.automation.name,
enabled: row.automation.enabled,
lastRunAt: lastRun.at,
lastRun,
agentId: automation.agentId,
automation
agentId: row.automation.agentId,
row
}
})
const externals: AutomationListViewItem[] = externalEntries.map((entry) => {
@@ -217,34 +216,21 @@ export function filterExternalAutomationListEntries(
)
}
export function filterAutomationListViewItems(
items: readonly AutomationListViewItem[],
filter: AutomationListFilter
): AutomationListViewItem[] {
if (!isAutomationListFilterActive(filter)) {
return [...items]
}
return items.filter(
(item) =>
matchesStatusFilter(item.enabled, filter.status) &&
matchesLastRunFilter(item.lastRun, filter.lastRun) &&
(filter.agentIds.length === 0 ||
(item.agentId !== null && filter.agentIds.includes(item.agentId)))
)
}
/**
* `locale` is a parameter, not a `getIntlLocale()` read, so callers memoizing this
* can declare it — a hidden read is invisible to a dependency array.
*/
export function sortAutomationListViewItems(
items: readonly AutomationListViewItem[],
sort: AutomationListSort | null
sort: AutomationListSort | null,
locale: string
): AutomationListViewItem[] {
if (!sort || items.length < 2) {
return [...items]
}
const next = [...items]
const compareNames =
sort.field === 'name'
? new Intl.Collator(getIntlLocale(), { sensitivity: 'base' }).compare
: null
sort.field === 'name' ? new Intl.Collator(locale, { sensitivity: 'base' }).compare : null
next.sort((left, right) => {
const compared = compareNames
? compareNames(left.name, right.name)
@@ -257,24 +243,26 @@ export function sortAutomationListViewItems(
return next
}
/** The rendered list: filter each collection with its own rules, then sort as one. */
export function applyAutomationListView({
automations,
rows,
externalEntries,
runs,
filter,
sort
sort,
locale
}: {
automations: readonly Automation[]
rows: readonly AutomationListRow[]
externalEntries: readonly ExternalAutomationListEntry[]
runs: readonly AutomationRun[]
filter: AutomationListFilter
sort: AutomationListSort | null
locale: string
}): AutomationListViewItem[] {
return sortAutomationListViewItems(
filterAutomationListViewItems(
buildAutomationListViewItems({ automations, externalEntries, runs }),
filter
),
sort
buildAutomationListViewItems({
rows: filterAutomationListRows(rows, filter),
externalEntries: filterExternalAutomationListEntries(externalEntries, filter)
}),
sort,
locale
)
}
@@ -0,0 +1,32 @@
/**
* What the page actually listed, read back from the mocked list panel.
*
* Tests act through the same authority-qualified keys and render order the
* user's click carries, rather than synthesizing either.
*/
import type { AutomationListRow } from './automation-list-row-identity'
import type { ExternalAutomationListEntry } from './external-automation-list-entries'
import { mocks } from './automations-page-test-harness'
function listedItems() {
return mocks.listPanel?.sortedListItems ?? []
}
/** Local rows the page listed, in render order. */
export function listedRows(): readonly AutomationListRow[] {
return listedItems().flatMap((item) => (item.kind === 'local' ? [item.row] : []))
}
/** External entries the page listed, in render order. */
export function listedExternalEntries(): readonly ExternalAutomationListEntry[] {
return listedItems().flatMap((item) => (item.kind === 'external' ? [item.entry] : []))
}
export function listedRow(automationId: string): AutomationListRow {
const row = listedRows().find((entry) => entry.automation.id === automationId)
if (!row) {
throw new Error(`no listed row for ${automationId}`)
}
return row
}
@@ -27,6 +27,7 @@ import type { AutomationHostCatalogView } from './use-automation-host-catalog'
import type { AutomationCreateDestinationControl } from './use-automation-create-destination'
import type { ExternalAutomationListEntry } from './external-automation-list-entries'
import type { AutomationListRow } from './automation-list-row-identity'
import type { AutomationListViewItem } from './automation-list-view'
import { resetAutomationCapabilityProbes } from './automation-scoped-list-client'
import {
addRuntimeProject as addRuntimeProjectFixture,
@@ -39,7 +40,7 @@ export const RUNTIME_REPO_ID = RUNTIME_REPO_ID_FIXTURE
export const RUNTIME_WORKSPACE_ID = RUNTIME_WORKSPACE_ID_FIXTURE
export type ListPanelProps = {
filteredExternalAutomationEntries: ExternalAutomationListEntry[]
sortedListItems: readonly AutomationListViewItem[]
selectedExternal: ExternalAutomationListEntry | null
openEditExternalDialog: (
manager: ExternalAutomationListEntry['manager'],
@@ -55,7 +56,6 @@ export type ListPanelProps = {
) => void
hasListItems: boolean
hasFilteredListItems: boolean
filteredRows: readonly AutomationListRow[]
selectedRowKey: string | null
selectedExternalKey: string | null
hostCatalog: AutomationHostCatalogView
@@ -211,30 +211,31 @@ vi.mock('./AutomationsListPanel', () => ({
return (
<div data-testid="list-panel">
<button aria-label="Refresh automations" onClick={props.onRefresh} />
{props.filteredRows.map((row) => (
<button
type="button"
data-testid="automation-row"
key={row.key}
onClick={() => selectAutomationRow(row.key)}
>
{row.automation.name}
</button>
))}
{props.filteredExternalAutomationEntries.map((entry) => (
<button
type="button"
data-testid="external-row"
key={entry.key}
onClick={() => {
props.selectAutomationRow(null)
props.selectExternalKey(entry.key)
props.onOpenDetail()
}}
>
{entry.job.name}
</button>
))}
{props.sortedListItems.map((item) =>
item.kind === 'local' ? (
<button
type="button"
data-testid="automation-row"
key={item.id}
onClick={() => selectAutomationRow(item.id)}
>
{item.row.automation.name}
</button>
) : (
<button
type="button"
data-testid="external-row"
key={item.id}
onClick={() => {
props.selectAutomationRow(null)
props.selectExternalKey(item.id)
props.onOpenDetail()
}}
>
{item.entry.job.name}
</button>
)
)}
{props.hasListItems ? null : <div data-testid="empty-state" />}
</div>
)
@@ -407,18 +408,6 @@ export async function refreshOnFocus(): Promise<void> {
})
}
/**
* The row the page actually listed for an ID, so tests act through the same
* authority-qualified key the user's click carries rather than a synthesized one.
*/
export function listedRow(automationId: string): AutomationListRow {
const row = mocks.listPanel?.filteredRows.find((entry) => entry.automation.id === automationId)
if (!row) {
throw new Error(`no listed row for ${automationId}`)
}
return row
}
export function rows(container: HTMLElement, testId: string): string[] {
return [...container.querySelectorAll(`[data-testid="${testId}"]`)].map(
(node) => node.textContent ?? ''
@@ -4,9 +4,12 @@ import { buildExternalAutomationListEntries } from './external-automation-list-e
import { externalAutomationScopeEntries } from './external-automation-scope-gating'
import { externalAutomationUncheckedNotice } from './external-automation-unchecked-hosts'
import {
buildAutomationListViewItems,
filterAutomationListRows,
filterExternalAutomationListEntries
filterExternalAutomationListEntries,
sortAutomationListViewItems
} from './automation-list-view'
import { getIntlLocale } from '@/i18n/i18n'
import { unscopedAutomationListRows } from './automation-list-row-identity'
import { useAutomationHostCatalog } from './use-automation-host-catalog'
import { useAutomationListSearch } from './use-automation-list-search'
@@ -28,6 +31,7 @@ export function useAutomationsPageListState({
failedAuthorityKeys,
listSearchQuery,
listFilter,
listSort,
selectedRowKey,
selectedExternalKey,
selectedAutomationRuns,
@@ -129,6 +133,21 @@ export function useAutomationsPageListState({
() => externalAutomationUncheckedNotice(scopedExternal.failures, hostCatalog.entries),
[hostCatalog.entries, scopedExternal.failures]
)
// Why: a language switch changes collation without touching rows, so the locale
// has to reach the memo as a value.
const sortLocale = getIntlLocale()
const sortedListItems = useMemo(
() =>
sortAutomationListViewItems(
buildAutomationListViewItems({
rows: filteredRows,
externalEntries: filteredExternalAutomationEntries
}),
listSort,
sortLocale
),
[filteredExternalAutomationEntries, filteredRows, listSort, sortLocale]
)
return {
hostCatalog,
@@ -146,6 +165,7 @@ export function useAutomationsPageListState({
isListSearchQueryTooLarge,
filteredRows,
filteredExternalAutomationEntries,
sortedListItems,
hasListItems,
hasFilteredListItems,
searchCounts,
@@ -12,7 +12,11 @@ import type { AutomationActionNotice } from './automation-row-action-dispatch'
import type { AutomationHostCatalogEntry } from './automation-host-catalog-types'
import type { AutomationCreateDestination } from './automation-create-destination'
import type { AutomationListRow } from './automation-list-row-identity'
import { EMPTY_AUTOMATION_LIST_FILTER, type AutomationListFilter } from './automation-list-view'
import {
EMPTY_AUTOMATION_LIST_FILTER,
type AutomationListFilter,
type AutomationListSort
} from './automation-list-view'
import type {
AutomationPaneTab,
AutomationRunPageOrigin,
@@ -54,6 +58,7 @@ export function useAutomationsPageLocalState(store: AutomationsPageStoreState) {
const [isSaving, setIsSaving] = useState(false)
const [listSearchQuery, setListSearchQuery] = useState('')
const [listFilter, setListFilter] = useState<AutomationListFilter>(EMPTY_AUTOMATION_LIST_FILTER)
const [listSort, setListSort] = useState<AutomationListSort | null>(null)
const [createOpen, setCreateOpen] = useState(false)
const [createTarget, setCreateTarget] = useState<AutomationCreateTarget>('orca')
const [editingAutomationId, setEditingAutomationId] = useState<string | null>(null)
@@ -178,6 +183,8 @@ export function useAutomationsPageLocalState(store: AutomationsPageStoreState) {
setListSearchQuery,
listFilter,
setListFilter,
listSort,
setListSort,
createOpen,
setCreateOpen,
createTarget,
@@ -0,0 +1,57 @@
// @vitest-environment happy-dom
import { createElement } from 'react'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, expect, it } from 'vitest'
import { BrowserFavicon } from './browser-favicon'
afterEach(cleanup)
const faviconUrl = 'https://example.test/favicon.ico'
const icon = (loading = false, url: string | null = faviconUrl) =>
createElement(BrowserFavicon, { faviconUrl: url, loading })
it('retries a failed icon after a same-origin reload completes', () => {
const view = render(icon())
fireEvent.error(view.container.querySelector('img')!)
expect(view.container.querySelector('img')).toBeNull()
view.rerender(icon(true))
expect(view.container.querySelector('img')).toBeNull()
view.rerender(icon(false))
expect(view.container.querySelector('img')?.getAttribute('src')).toBe(faviconUrl)
fireEvent.error(view.container.querySelector('img')!)
view.rerender(icon(false))
expect(view.container.querySelector('img')).toBeNull()
view.rerender(icon(true))
view.rerender(icon(false))
expect(view.container.querySelector('img')).not.toBeNull()
})
it('keeps a working image mounted throughout a reload', () => {
const view = render(icon())
const image = view.container.querySelector('img')
view.rerender(icon(true))
expect(view.container.querySelector('img')).toBe(image)
view.rerender(icon(false))
expect(view.container.querySelector('img')).toBe(image)
})
it('retries an image that failed during initial loading when loading finishes', () => {
const view = render(icon(true))
fireEvent.error(view.container.querySelector('img')!)
view.rerender(icon(false))
expect(view.container.querySelector('img')).not.toBeNull()
})
it('still resets failures when the favicon URL changes or clears', () => {
const view = render(icon())
fireEvent.error(view.container.querySelector('img')!)
view.rerender(icon(false, null))
view.rerender(icon())
expect(view.container.querySelector('img')).not.toBeNull()
fireEvent.error(view.container.querySelector('img')!)
view.rerender(icon(false, 'https://other.test/favicon.ico'))
expect(view.container.querySelector('img')?.getAttribute('src')).toBe(
'https://other.test/favicon.ico'
)
})
+12 -16
View File
@@ -1,34 +1,30 @@
import { useState } from 'react'
import { Globe } from 'lucide-react'
import { cn } from '@/lib/utils'
function displayableFaviconUrl(faviconUrl: string | null | undefined): string | null {
const trimmed = faviconUrl?.trim()
if (!trimmed) {
return null
}
if (trimmed.startsWith('data:image/')) {
return trimmed
}
try {
const url = new URL(trimmed)
return url.protocol === 'http:' || url.protocol === 'https:' ? trimmed : null
} catch {
return null
}
}
import { displayableFaviconUrl } from './browser-pane/describe-page/browser-favicon-url'
export function BrowserFavicon({
faviconUrl,
loading = false,
className,
fallbackClassName
}: {
faviconUrl: string | null | undefined
loading?: boolean
className?: string
fallbackClassName?: string
}): React.JSX.Element {
const displayUrl = displayableFaviconUrl(faviconUrl)
const [failedUrl, setFailedUrl] = useState<string | null>(null)
const [previousLoading, setPreviousLoading] = useState(loading)
// Retry after navigation settles, when cookies and connectivity may have recovered.
if (previousLoading !== loading) {
setPreviousLoading(loading)
if (!loading) {
setFailedUrl(null)
}
}
// Why: reset during render on any favicon identity change — including a clear to null while
// a page loads — so navigating back to the same url retries instead of keeping the fallback.

Some files were not shown because too many files have changed in this diff Show More