mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
Merge main and reconcile host metadata follow-up
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
|
||||
|
||||
@@ -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}` }
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
+110
@@ -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,
|
||||
|
||||
+149
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
+3
-6
@@ -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'
|
||||
)
|
||||
})
|
||||
@@ -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
Reference in New Issue
Block a user