mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
feat(daemon): retain pane key as optional session metadata
Daemon sessions already receive ORCA_PANE_KEY in the spawn env but drop it. Retain it on the Session and expose it additively in the session listing so upcoming daemon-hosted agent hook status can route bindings by pane. The env value is caller-supplied, so it is only retained when it parses as a real pane key (shape + length); anything else stores undefined rather than an arbitrary string. No protocol version bump: the field is optional and older adapters ignore unknown fields. Session-inventory types move to daemon-session-info.ts (re-exported from types.ts) so the listing shape can grow within the max-lines budget.
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
// Session-inventory shapes, split out of types.ts so the listing payload can
|
||||
// grow without pushing that entry point over its line budget. Re-exported from
|
||||
// `./types` so existing importers keep one entry point.
|
||||
import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority'
|
||||
|
||||
// ─── Session State Machine ──────────────────────────────────────────
|
||||
export type SessionState = 'created' | 'spawning' | 'running' | 'exiting' | 'exited'
|
||||
|
||||
export type ShellReadyState = 'pending' | 'ready' | 'timed_out' | 'unsupported'
|
||||
|
||||
export type SessionInfo = {
|
||||
sessionId: string
|
||||
incarnationId?: string
|
||||
state: SessionState
|
||||
shellState: ShellReadyState
|
||||
isAlive: boolean
|
||||
terminalHandle?: string
|
||||
/** Routing metadata for status bindings only, never an identity key — pane keys are reusable. */
|
||||
paneKey?: string
|
||||
wslDistro?: string | null
|
||||
pid: number | null
|
||||
cwd: string | null
|
||||
cols: number
|
||||
rows: number
|
||||
createdAt: number
|
||||
agentSessionOwners?: AgentSessionOwnerBinding[]
|
||||
}
|
||||
|
||||
export type ListSessionsResult = {
|
||||
sessions: SessionInfo[]
|
||||
}
|
||||
|
||||
// Why: SessionInfo + source protocol version, so the Manage Sessions UI can
|
||||
// label legacy-backed sessions. Populated by the router/adapter at RPC time;
|
||||
// never transmitted over the daemon wire (daemon only speaks its own
|
||||
// protocol version and doesn't know about other versions).
|
||||
export type DaemonSessionInfo = SessionInfo & {
|
||||
protocolVersion: number
|
||||
}
|
||||
@@ -80,6 +80,7 @@ export type SessionOptions = {
|
||||
cols: number
|
||||
rows: number
|
||||
terminalHandle?: string
|
||||
paneKey?: string
|
||||
launchAgent?: TuiAgent
|
||||
subprocess: SubprocessHandle
|
||||
shellReadySupported: boolean
|
||||
@@ -104,6 +105,8 @@ export class Session {
|
||||
readonly sessionId: string
|
||||
readonly incarnationId = randomUUID()
|
||||
readonly terminalHandle: string | null
|
||||
/** Routing metadata for status bindings only — never an identity key, since pane keys are reusable across sessions. */
|
||||
readonly paneKey: string | null
|
||||
readonly launchAgent: TuiAgent | null
|
||||
readonly wslDistro: string | null
|
||||
private _state: SessionState = 'running'
|
||||
@@ -136,6 +139,7 @@ export class Session {
|
||||
constructor(opts: SessionOptions) {
|
||||
this.sessionId = opts.sessionId
|
||||
this.terminalHandle = opts.terminalHandle ?? null
|
||||
this.paneKey = opts.paneKey ?? null
|
||||
this.launchAgent = opts.launchAgent ?? null
|
||||
this.wslDistro = opts.wslDistro ?? null
|
||||
this.subprocess = opts.subprocess
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { buildStartupCommandSubmission } from '../../shared/startup-command-submission'
|
||||
import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend'
|
||||
import { MAX_PANE_KEY_LEN, parsePaneKey } from '../../shared/stable-pane-id'
|
||||
import { getDaemonSessionResultMetadata } from './daemon-create-or-attach-result'
|
||||
import { normalizePtySize } from './daemon-pty-size'
|
||||
import { Session } from './session'
|
||||
@@ -23,6 +24,16 @@ type TerminalHostSessionCreateDependencies = {
|
||||
onSessionExit: (sessionId: string, generation: string | undefined) => void
|
||||
}
|
||||
|
||||
// Why: the spawn env is caller-supplied, so retain the pane key only when it
|
||||
// parses as one — an arbitrary string under `paneKey` would look authoritative
|
||||
// to every downstream reader.
|
||||
function resolvePaneKeyMetadata(value: string | undefined): string | undefined {
|
||||
if (!value || value.length > MAX_PANE_KEY_LEN || parsePaneKey(value) === null) {
|
||||
return undefined
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export async function createOrAttachTerminalSession(
|
||||
opts: InternalCreateOrAttachOptions,
|
||||
deps: TerminalHostSessionCreateDependencies
|
||||
@@ -99,6 +110,7 @@ export async function createOrAttachTerminalSession(
|
||||
cols: size.cols,
|
||||
rows: size.rows,
|
||||
terminalHandle: opts.env?.ORCA_TERMINAL_HANDLE,
|
||||
paneKey: resolvePaneKeyMetadata(opts.env?.ORCA_PANE_KEY),
|
||||
launchAgent: opts.launchAgent,
|
||||
subprocess,
|
||||
ownerBackend: resolvePtyOwnerBackend({
|
||||
|
||||
@@ -19,6 +19,7 @@ export function listLiveTerminalHostSessions(
|
||||
shellState: session.shellState,
|
||||
isAlive: true,
|
||||
...(session.terminalHandle ? { terminalHandle: session.terminalHandle } : {}),
|
||||
...(session.paneKey ? { paneKey: session.paneKey } : {}),
|
||||
wslDistro: session.wslDistro,
|
||||
pid: session.pid,
|
||||
cwd: session.getCwd(),
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SubprocessHandle } from './session'
|
||||
import { TerminalHost } from './terminal-host'
|
||||
|
||||
vi.mock('../pty-descendant-termination', () => ({ killWithDescendantSweep: vi.fn() }))
|
||||
|
||||
const PANE_KEY = 'tab-1:1b8c2f10-4a3e-4b1c-9d2e-7f6a5b4c3d2e'
|
||||
|
||||
function createSubprocess(): SubprocessHandle {
|
||||
let onExit: ((code: number) => void) | null = null
|
||||
return {
|
||||
pid: 99_999,
|
||||
getForegroundProcess: vi.fn(() => null),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(() => onExit?.(0)),
|
||||
forceKill: vi.fn(() => onExit?.(137)),
|
||||
signal: vi.fn(),
|
||||
onData: () => {},
|
||||
onExit: (callback) => {
|
||||
onExit = callback
|
||||
},
|
||||
dispose: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
describe('TerminalHost session pane identity', () => {
|
||||
let host: TerminalHost
|
||||
|
||||
afterEach(async () => {
|
||||
await host?.dispose()
|
||||
})
|
||||
|
||||
async function listWithPaneEnv(paneKey?: string) {
|
||||
host = new TerminalHost({ spawnSubprocess: () => createSubprocess() })
|
||||
await host.createOrAttach({
|
||||
sessionId: 'pane-test',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
env: {
|
||||
ORCA_TERMINAL_HANDLE: 'term_abc',
|
||||
...(paneKey === undefined ? {} : { ORCA_PANE_KEY: paneKey })
|
||||
},
|
||||
streamClient: { onData: vi.fn(), onExit: vi.fn() }
|
||||
})
|
||||
return host.listSessions()[0]
|
||||
}
|
||||
|
||||
it('retains and lists a valid ORCA_PANE_KEY', async () => {
|
||||
expect(await listWithPaneEnv(PANE_KEY)).toMatchObject({
|
||||
terminalHandle: 'term_abc',
|
||||
paneKey: PANE_KEY
|
||||
})
|
||||
})
|
||||
|
||||
// Why: an absent key must omit the field, not emit null — older adapters read
|
||||
// the listing structurally and a null would read as "known to have no pane".
|
||||
it('omits paneKey entirely when ORCA_PANE_KEY is absent', async () => {
|
||||
const listed = await listWithPaneEnv(undefined)
|
||||
expect(listed).not.toHaveProperty('paneKey')
|
||||
expect(listed.terminalHandle).toBe('term_abc')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['no delimiter', 'tab-1'],
|
||||
['non-UUID leaf', 'tab-1:pane-3'],
|
||||
['extra delimiter', 'tab-1:2:1b8c2f10-4a3e-4b1c-9d2e-7f6a5b4c3d2e'],
|
||||
['empty tabId', ':1b8c2f10-4a3e-4b1c-9d2e-7f6a5b4c3d2e'],
|
||||
['empty value', ''],
|
||||
['overlong', `${'t'.repeat(300)}:1b8c2f10-4a3e-4b1c-9d2e-7f6a5b4c3d2e`]
|
||||
])('drops a malformed ORCA_PANE_KEY (%s)', async (_label, value) => {
|
||||
expect(await listWithPaneEnv(value)).not.toHaveProperty('paneKey')
|
||||
})
|
||||
})
|
||||
@@ -16,7 +16,6 @@ import type { TuiAgent } from '../../shared/types'
|
||||
import type { PtyStartupIngressIntent } from '../../shared/pty-startup-ingress'
|
||||
import type {
|
||||
AgentSessionExecutionClaim,
|
||||
AgentSessionOwnerBinding,
|
||||
AgentSessionSurfaceBinding
|
||||
} from '../../shared/agent-session-host-authority'
|
||||
import type * as HistorySeedProtocol from './terminal-history-seed-transfer-protocol'
|
||||
@@ -38,10 +37,9 @@ export {
|
||||
supportsPtyStartupIngress
|
||||
} from './daemon-protocol-version'
|
||||
|
||||
// ─── Session State Machine ──────────────────────────────────────────
|
||||
export type SessionState = 'created' | 'spawning' | 'running' | 'exiting' | 'exited'
|
||||
|
||||
export type ShellReadyState = 'pending' | 'ready' | 'timed_out' | 'unsupported'
|
||||
// Session state machine + session-inventory shapes live in daemon-session-info.ts;
|
||||
// re-exported so existing importers keep one types entry point.
|
||||
export * from './daemon-session-info'
|
||||
|
||||
// The on-disk checkpoint.json shape lives in daemon-checkpoint-file.ts (it
|
||||
// depends only on TerminalModes here) — re-exported so existing importers of
|
||||
@@ -341,10 +339,6 @@ export type GetSnapshotResult = {
|
||||
snapshot: TerminalSnapshot | null
|
||||
}
|
||||
|
||||
export type ListSessionsResult = {
|
||||
sessions: SessionInfo[]
|
||||
}
|
||||
|
||||
export type ShutdownIfIdleResult = {
|
||||
retiring: boolean
|
||||
}
|
||||
@@ -355,30 +349,6 @@ export type SystemResolverHealthResult = {
|
||||
health: SystemResolverHealth
|
||||
}
|
||||
|
||||
export type SessionInfo = {
|
||||
sessionId: string
|
||||
incarnationId?: string
|
||||
state: SessionState
|
||||
shellState: ShellReadyState
|
||||
isAlive: boolean
|
||||
terminalHandle?: string
|
||||
wslDistro?: string | null
|
||||
pid: number | null
|
||||
cwd: string | null
|
||||
cols: number
|
||||
rows: number
|
||||
createdAt: number
|
||||
agentSessionOwners?: AgentSessionOwnerBinding[]
|
||||
}
|
||||
|
||||
// Why: SessionInfo + source protocol version, so the Manage Sessions UI can
|
||||
// label legacy-backed sessions. Populated by the router/adapter at RPC time;
|
||||
// never transmitted over the daemon wire (daemon only speaks its own
|
||||
// protocol version and doesn't know about other versions).
|
||||
export type DaemonSessionInfo = SessionInfo & {
|
||||
protocolVersion: number
|
||||
}
|
||||
|
||||
// Stream-socket event shapes live in daemon-stream-events.ts; re-exported so
|
||||
// existing importers keep one types entry point.
|
||||
export * from './daemon-stream-events'
|
||||
|
||||
@@ -184,6 +184,25 @@ describe('pty:management IPC handlers', () => {
|
||||
expect(result.sessions.map((s) => s.sessionId)).toEqual(['preserved-1'])
|
||||
})
|
||||
|
||||
// Why: paneKey ships as an additive optional field with no protocol bump, so
|
||||
// the client parsing path must pass it through rather than shape-validate.
|
||||
it('passes an unknown-to-older-clients paneKey through the client parsing path', async () => {
|
||||
const paneKey = 'tab-1:1b8c2f10-4a3e-4b1c-9d2e-7f6a5b4c3d2e'
|
||||
const current = makeAdapter(5, [makeSession('with-pane', { paneKey })])
|
||||
const { registerDaemonManagementHandlers } = await importFresh()
|
||||
getDaemonProviderMock.mockReturnValue(await makeRouter(current))
|
||||
registerDaemonManagementHandlers()
|
||||
|
||||
const handlers = buildHandlerMap()
|
||||
const result = (await handlers['pty:management:listSessions']({})) as {
|
||||
sessions: DaemonSessionInfo[]
|
||||
}
|
||||
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
expect(result.sessions[0]).toMatchObject({ sessionId: 'with-pane', paneKey })
|
||||
expect(result.sessions[0]?.protocolVersion).toBe(5)
|
||||
})
|
||||
|
||||
it('clears degraded mode after durable fresh-spawn routing recovers', async () => {
|
||||
const current = makeAdapter(5, [makeSession('preserved-1')])
|
||||
const provider = await makeDegradedProvider(current)
|
||||
|
||||
@@ -59,7 +59,7 @@ import {
|
||||
extractAgentProviderSession,
|
||||
type AgentProviderSessionMetadata
|
||||
} from './agent-session-resume'
|
||||
import { parsePaneKey } from './stable-pane-id'
|
||||
import { MAX_PANE_KEY_LEN, parsePaneKey } from './stable-pane-id'
|
||||
import {
|
||||
isCompactContinuationUserTurnText,
|
||||
isKnownHarnessInjectedUserTurnText
|
||||
@@ -104,8 +104,8 @@ function capOpenCodeHookText(text: string): string {
|
||||
: text
|
||||
}
|
||||
|
||||
/** Bound paneKey size (real keys are well under 200); caps per-pane caches against pathological input. Exported so non-HTTP ingest (`ingestRemote`) applies the same cap as defense-in-depth. */
|
||||
export const MAX_PANE_KEY_LEN = 200
|
||||
// Re-exported so existing importers (`ingestRemote` and the hook server) keep one entry point.
|
||||
export { MAX_PANE_KEY_LEN }
|
||||
const CLAUDE_PROMPT_ID_RE = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i
|
||||
|
||||
export function normalizeClaudePromptId(value: unknown): string | undefined {
|
||||
|
||||
@@ -11,6 +11,9 @@ export type StablePaneId = string & { readonly [stablePaneIdBrand]: true }
|
||||
export type TerminalLeafId = StablePaneId & { readonly [terminalLeafIdBrand]: true }
|
||||
export type PaneKey = string & { readonly [paneKeyBrand]: true }
|
||||
|
||||
/** Bound paneKey size (real keys are well under 200); caps per-pane caches and untrusted ingest against pathological input. Lives here so validators can cap before `parsePaneKey` without pulling in the hook-listener graph. */
|
||||
export const MAX_PANE_KEY_LEN = 200
|
||||
|
||||
export function isStablePaneId(value: string): value is StablePaneId {
|
||||
return UUID_RE.test(value)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user