mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
feat(wsl-mover): terminal gate, last-folder capture and exit proof for a WSL move (D1b-5b) (#28606)
* feat(wsl-mover): the terminal gate a WSL move passes before it pauses anything (D1b-5b) * feat(wsl-mover): capture each paused pane's last folder for the host's restore (D1b-5b) * feat(wsl-mover): prove a moved workspace has no live terminal before commit (D1b-5b) * fix(wsl-mover): a permission prompt blocks the move; the exit proof also checks recorded pane sessions (D1b-5b) * fix(wsl-mover): any agent waiting on a person blocks; the exit proof refuses an incomplete census (D1b-5b)
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionInfo } from '../../daemon/types'
|
||||
import { proveWslMoveTerminalsExited } from './wsl-move-exit-proof'
|
||||
|
||||
type Census = Pick<SessionInfo, 'sessionId' | 'isAlive'>[] | null
|
||||
|
||||
const repoKey = 'repo::\\\\wsl.localhost\\Ubuntu\\home\\dev\\app'
|
||||
const scope = (workspaceKeys: string[], sessionIds: string[] = []) => ({
|
||||
workspaceKeys: new Set(workspaceKeys),
|
||||
sessionIds: new Set(sessionIds)
|
||||
})
|
||||
const prove = (census: Census, keys = [repoKey], sessionIds: string[] = [], fallback = 0) =>
|
||||
proveWslMoveTerminalsExited(
|
||||
{ listSessions: async () => census, countFallbackSessions: () => fallback },
|
||||
scope(keys, sessionIds)
|
||||
)
|
||||
|
||||
describe('proveWslMoveTerminalsExited', () => {
|
||||
it('proves exit when no live session belongs to a moved workspace', async () => {
|
||||
await expect(
|
||||
prove([
|
||||
{ sessionId: `${repoKey}@@1`, isAlive: false },
|
||||
{ sessionId: 'other::C:\\src@@2', isAlive: true }
|
||||
])
|
||||
).resolves.toEqual({ verdict: 'exited' })
|
||||
})
|
||||
|
||||
it('names every live session of a moved workspace', async () => {
|
||||
const live = `${repoKey}@@3`
|
||||
await expect(prove([{ sessionId: live, isAlive: true }])).resolves.toEqual({
|
||||
verdict: 'live',
|
||||
sessionIds: [live]
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['the wsl$ spelling', 'repo::\\\\wsl$\\Ubuntu\\home\\dev\\app@@4'],
|
||||
['another distro-name case', 'repo:://wsl.localhost/ubuntu/home/dev/app@@5']
|
||||
])('matches a session minted under %s', async (_label, sessionId) => {
|
||||
await expect(prove([{ sessionId, isAlive: true }])).resolves.toEqual({
|
||||
verdict: 'live',
|
||||
sessionIds: [sessionId]
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the Linux part case-sensitive', async () => {
|
||||
await expect(
|
||||
prove([{ sessionId: 'repo::\\\\wsl.localhost\\Ubuntu\\home\\dev\\App@@6', isAlive: true }])
|
||||
).resolves.toEqual({ verdict: 'exited' })
|
||||
})
|
||||
|
||||
it('matches a folder workspace by its minted key', async () => {
|
||||
await expect(
|
||||
prove([{ sessionId: 'folder:abc@@1', isAlive: true }], ['folder:abc'])
|
||||
).resolves.toEqual({ verdict: 'live', sessionIds: ['folder:abc@@1'] })
|
||||
})
|
||||
|
||||
it('counts a live pane session whose id predates the minted format', async () => {
|
||||
await expect(
|
||||
prove([{ sessionId: 'legacy-uuid', isAlive: true }], [repoKey], ['legacy-uuid'])
|
||||
).resolves.toEqual({ verdict: 'live', sessionIds: ['legacy-uuid'] })
|
||||
})
|
||||
|
||||
it('never reads a census it could not take as exit', async () => {
|
||||
await expect(
|
||||
proveWslMoveTerminalsExited(
|
||||
{
|
||||
listSessions: async () => {
|
||||
throw new Error('daemon unreachable')
|
||||
},
|
||||
countFallbackSessions: () => 0
|
||||
},
|
||||
scope([repoKey])
|
||||
)
|
||||
).resolves.toEqual({ verdict: 'unverifiable', reason: 'daemon unreachable' })
|
||||
await expect(prove(null)).resolves.toMatchObject({ verdict: 'unverifiable' })
|
||||
})
|
||||
|
||||
it('cannot prove exit while terminals run outside the daemon', async () => {
|
||||
await expect(prove([], [repoKey], [], 1)).resolves.toMatchObject({ verdict: 'unverifiable' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* After quiesce, proof that no terminal of a moved workspace still runs on this desktop's daemon
|
||||
* (mover design, section 3.3). The local daemon can prove exit, so only a census it answered can
|
||||
* say `exited`; one it could not answer is `unverifiable`, never a guess (AGENTS.md: SSH Use Case).
|
||||
*/
|
||||
import { parsePtySessionId, WORKTREE_ID_SEPARATOR } from '../../../shared/pty-session-id-format'
|
||||
import { parseWslUncPath } from '../../../shared/wsl-paths'
|
||||
import type { SessionInfo } from '../../daemon/types'
|
||||
|
||||
export type WslMoveExitProof =
|
||||
| { verdict: 'exited' }
|
||||
| { verdict: 'live'; sessionIds: string[] }
|
||||
| { verdict: 'unverifiable'; reason: string }
|
||||
|
||||
export type WslMoveExitProofSources = {
|
||||
/** `listLiveDaemonSessions`: null unless every daemon generation answered. */
|
||||
listSessions: () => Promise<readonly Pick<SessionInfo, 'sessionId' | 'isAlive'>[] | null>
|
||||
/** In-process fallback PTYs from degraded mode, which no daemon census lists. */
|
||||
countFallbackSessions: () => number
|
||||
}
|
||||
|
||||
export async function proveWslMoveTerminalsExited(
|
||||
sources: WslMoveExitProofSources,
|
||||
scope: {
|
||||
/** Workspace keys as session ids mint them: `<repoId>::<path>` or `folder:<id>`. */
|
||||
workspaceKeys: ReadonlySet<string>
|
||||
/** The ids the moved panes recorded; covers a session whose id predates the minted format. */
|
||||
sessionIds: ReadonlySet<string>
|
||||
}
|
||||
): Promise<WslMoveExitProof> {
|
||||
let sessions: readonly Pick<SessionInfo, 'sessionId' | 'isAlive'>[] | null
|
||||
try {
|
||||
sessions = await sources.listSessions()
|
||||
} catch (error) {
|
||||
return {
|
||||
verdict: 'unverifiable',
|
||||
reason: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
if (sessions === null) {
|
||||
return { verdict: 'unverifiable', reason: 'a daemon generation did not answer' }
|
||||
}
|
||||
if (sources.countFallbackSessions() > 0) {
|
||||
return { verdict: 'unverifiable', reason: 'terminals are running outside the daemon' }
|
||||
}
|
||||
const scopeKeys = new Set([...scope.workspaceKeys].map(comparableWorkspaceKey))
|
||||
const live = sessions
|
||||
.filter((session) => {
|
||||
if (!session.isAlive) {
|
||||
return false
|
||||
}
|
||||
if (scope.sessionIds.has(session.sessionId)) {
|
||||
return true
|
||||
}
|
||||
const { worktreeId } = parsePtySessionId(session.sessionId)
|
||||
return worktreeId !== null && scopeKeys.has(comparableWorkspaceKey(worktreeId))
|
||||
})
|
||||
.map((session) => session.sessionId)
|
||||
return live.length > 0 ? { verdict: 'live', sessionIds: live } : { verdict: 'exited' }
|
||||
}
|
||||
|
||||
// `\\wsl$` and `\\wsl.localhost`, and any distro-name case, reach the same folder; the Linux part
|
||||
// stays case-sensitive.
|
||||
function comparableWorkspaceKey(key: string): string {
|
||||
const separator = key.indexOf(WORKTREE_ID_SEPARATOR)
|
||||
if (separator <= 0) {
|
||||
return key
|
||||
}
|
||||
const unc = parseWslUncPath(key.slice(separator + WORKTREE_ID_SEPARATOR.length))
|
||||
return unc
|
||||
? `${key.slice(0, separator)}${WORKTREE_ID_SEPARATOR}wsl:${unc.distro.toLowerCase()}:${unc.linuxPath}`
|
||||
: key
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ColdRestoreDetection } from '../../daemon/history-reader'
|
||||
import { captureWslMoveRestoreHints } from './wsl-move-restore-hints'
|
||||
|
||||
const restored = (cwd: string, hasUnreadableRecovery = false): ColdRestoreDetection => ({
|
||||
status: 'restored',
|
||||
sessionId: 's',
|
||||
hasUnreadableRecovery,
|
||||
restoreInfo: {
|
||||
snapshotAnsi: '',
|
||||
scrollbackAnsi: '',
|
||||
rehydrateSequences: '',
|
||||
cwd,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
modes: {
|
||||
bracketedPaste: false,
|
||||
mouseTracking: false,
|
||||
applicationCursor: false,
|
||||
alternateScreen: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('captureWslMoveRestoreHints', () => {
|
||||
it('keeps each pane its last folder in the Linux form the host opens', async () => {
|
||||
const answers: Record<string, ColdRestoreDetection> = {
|
||||
'wt@@a': restored('\\\\wsl.localhost\\Ubuntu\\home\\dev\\app\\src'),
|
||||
'wt@@b': restored('/home/dev/app/test'),
|
||||
'wt@@c': restored('\\\\wsl.localhost\\Debian\\home\\dev'),
|
||||
'wt@@d': restored('C:\\Users\\dev'),
|
||||
'wt@@e': { status: 'none' },
|
||||
'wt@@f': { status: 'unreadable', sessionId: 'wt@@f' },
|
||||
'wt@@g': restored('\\\\wsl$\\ubuntu\\home\\dev\\app\\docs'),
|
||||
'wt@@h': restored('//wsl.localhost/Ubuntu/home/dev/app/bin', true)
|
||||
}
|
||||
const seen: unknown[] = []
|
||||
const capture = await captureWslMoveRestoreHints(
|
||||
{
|
||||
detectColdRestoreState: async (sessionId, opts) => {
|
||||
seen.push(opts)
|
||||
return answers[sessionId] ?? { status: 'none' }
|
||||
}
|
||||
},
|
||||
['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'].map((leaf) => ({
|
||||
paneKey: `t:${leaf}`,
|
||||
sessionId: `wt@@${leaf}`
|
||||
})),
|
||||
'Ubuntu'
|
||||
)
|
||||
expect(capture).toEqual({
|
||||
hints: [
|
||||
{ paneKey: 't:a', cwd: '/home/dev/app/src' },
|
||||
{ paneKey: 't:b', cwd: '/home/dev/app/test' },
|
||||
{ paneKey: 't:g', cwd: '/home/dev/app/docs' },
|
||||
{ paneKey: 't:h', cwd: '/home/dev/app/bin' }
|
||||
],
|
||||
unreadablePaneKeys: ['t:f'],
|
||||
staleCwdPaneKeys: ['t:h']
|
||||
})
|
||||
// A sleep ends sessions cleanly; the read must not skip them for it.
|
||||
expect(seen[0]).toEqual({ ignoreCleanEnd: true, wslDistro: 'Ubuntu' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Each paused pane's last working directory, read from the daemon's own checkpoint after quiesce
|
||||
* (mover design, section 3.3). It lives nowhere else: without it a pane would reopen on the host
|
||||
* at its worktree root, which is a silent loss.
|
||||
*/
|
||||
import type { ColdRestoreDetection } from '../../daemon/history-reader'
|
||||
import { wslUncPathToLinux } from './wsl-move-path-rewriter'
|
||||
|
||||
export type WslMoveRestoreHint = { paneKey: string; cwd: string }
|
||||
|
||||
export type WslMoveRestoreHintCapture = {
|
||||
hints: WslMoveRestoreHint[]
|
||||
/** Panes whose checkpoint could not be read; the preview names them, the move still runs. */
|
||||
unreadablePaneKeys: string[]
|
||||
/** Panes restored from a checkpoint whose newer log was unreadable, so the folder may be older. */
|
||||
staleCwdPaneKeys: string[]
|
||||
}
|
||||
|
||||
type ColdRestoreReader = {
|
||||
detectColdRestoreState: (
|
||||
sessionId: string,
|
||||
opts?: { ignoreCleanEnd?: boolean; wslDistro?: string }
|
||||
) => Promise<ColdRestoreDetection>
|
||||
}
|
||||
|
||||
export async function captureWslMoveRestoreHints(
|
||||
reader: ColdRestoreReader,
|
||||
panes: readonly { paneKey: string; sessionId: string }[],
|
||||
distro: string
|
||||
): Promise<WslMoveRestoreHintCapture> {
|
||||
const capture: WslMoveRestoreHintCapture = {
|
||||
hints: [],
|
||||
unreadablePaneKeys: [],
|
||||
staleCwdPaneKeys: []
|
||||
}
|
||||
for (const pane of panes) {
|
||||
// A sleep ends the session cleanly; its checkpoint is exactly what a cold restore would read.
|
||||
const detection = await reader.detectColdRestoreState(pane.sessionId, {
|
||||
ignoreCleanEnd: true,
|
||||
wslDistro: distro
|
||||
})
|
||||
if (detection.status === 'unreadable') {
|
||||
capture.unreadablePaneKeys.push(pane.paneKey)
|
||||
continue
|
||||
}
|
||||
if (detection.status !== 'restored') {
|
||||
continue
|
||||
}
|
||||
if (detection.hasUnreadableRecovery) {
|
||||
capture.staleCwdPaneKeys.push(pane.paneKey)
|
||||
}
|
||||
// The checkpoint holds the bridge's UNC spelling or the shell's own Linux path; only a path
|
||||
// inside the moved distro means anything on the host.
|
||||
const { cwd } = detection.restoreInfo
|
||||
const linux = wslUncPathToLinux(cwd, distro) ?? (/^\/(?!\/)/.test(cwd) ? cwd : null)
|
||||
if (linux) {
|
||||
capture.hints.push({ paneKey: pane.paneKey, cwd: linux })
|
||||
}
|
||||
}
|
||||
return capture
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
MAX_ORCAD_MIGRATION_SCROLLBACK_SNAPSHOTS,
|
||||
MAX_ORCAD_MIGRATION_SCROLLBACK_TOTAL_BYTES
|
||||
} from '../../../shared/orcad-migration-scrollback'
|
||||
import { TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT } from '../../../shared/terminal-scrollback-limits'
|
||||
import { assessWslMoveTerminals, type WslMovePaneFacts } from './wsl-move-terminal-gate'
|
||||
|
||||
const pane = (paneKey: string, extra: Partial<WslMovePaneFacts> = {}): WslMovePaneFacts => ({
|
||||
paneKey,
|
||||
foreground: 'shell-idle',
|
||||
scrollbackBytes: 1024,
|
||||
...extra
|
||||
})
|
||||
const gate = (panes: WslMovePaneFacts[], runs: string[] = [], dispatches: string[] = []) =>
|
||||
assessWslMoveTerminals({ panes, runningAutomationRunIds: runs, activeDispatchIds: dispatches })
|
||||
|
||||
describe('assessWslMoveTerminals', () => {
|
||||
it('lets idle shells and resumable settled agents through', () => {
|
||||
expect(
|
||||
gate([
|
||||
pane('t1:a'),
|
||||
pane('t1:b', { agent: { state: 'done', resumable: true, ownsForeground: false } }),
|
||||
pane('t2:a', {
|
||||
agent: { state: 'done', resumable: true, ownsForeground: true },
|
||||
foreground: 'process-running'
|
||||
})
|
||||
])
|
||||
).toEqual({ verdict: 'ready', paneKeys: ['t1:a', 't1:b', 't2:a'] })
|
||||
})
|
||||
|
||||
it.each([
|
||||
[
|
||||
'an agent mid-turn',
|
||||
{ agent: { state: 'working', resumable: true, ownsForeground: true } },
|
||||
'agent-working',
|
||||
['wait', 'end']
|
||||
],
|
||||
[
|
||||
'an agent at a permission prompt',
|
||||
{ agent: { state: 'waiting', resumable: true, ownsForeground: true } },
|
||||
'agent-working',
|
||||
['wait', 'end']
|
||||
],
|
||||
[
|
||||
// Copilot, Pi and Freebuff report a pending ask or approval as 'blocked'.
|
||||
'an agent reported blocked on a person',
|
||||
{ agent: { state: 'blocked', resumable: true, ownsForeground: true } },
|
||||
'agent-working',
|
||||
['wait', 'end']
|
||||
],
|
||||
[
|
||||
'an agent with no session to resume',
|
||||
{ agent: { state: 'done', resumable: false, ownsForeground: true } },
|
||||
'agent-not-resumable',
|
||||
['end', 'skip-project']
|
||||
],
|
||||
['a dev server', { foreground: 'process-running' }, 'process-running', ['end', 'skip-project']],
|
||||
[
|
||||
'a dev server under a stale agent row',
|
||||
{
|
||||
foreground: 'process-running',
|
||||
agent: { state: 'done', resumable: true, ownsForeground: false }
|
||||
},
|
||||
'process-running',
|
||||
['end', 'skip-project']
|
||||
],
|
||||
[
|
||||
'a pane the daemon cannot answer for',
|
||||
{ foreground: 'unverifiable' },
|
||||
'unverifiable',
|
||||
['wait', 'skip-project']
|
||||
],
|
||||
[
|
||||
'a pane with no byte count',
|
||||
{ scrollbackBytes: Number.NaN },
|
||||
'unverifiable',
|
||||
['wait', 'skip-project']
|
||||
],
|
||||
[
|
||||
'a pane with a negative byte count',
|
||||
{ scrollbackBytes: -1 },
|
||||
'unverifiable',
|
||||
['wait', 'skip-project']
|
||||
],
|
||||
[
|
||||
'a pane over the scrollback cap',
|
||||
{ scrollbackBytes: TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT + 1 },
|
||||
'scrollback-too-large',
|
||||
['clear-scrollback']
|
||||
]
|
||||
] as const)('blocks %s, with the actions that unblock it', (_label, facts, kind, actions) => {
|
||||
expect(gate([pane('t1:a', facts)])).toEqual({
|
||||
verdict: 'blocked',
|
||||
blockers: [{ kind, ids: ['t1:a'], actions }]
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks on unfinished automation runs and orchestration dispatches in scope', () => {
|
||||
expect(gate([pane('t1:a')], ['run-1'], ['dispatch-1'])).toEqual({
|
||||
verdict: 'blocked',
|
||||
blockers: [
|
||||
{ kind: 'automation-running', ids: ['run-1'], actions: ['wait'] },
|
||||
{ kind: 'dispatch-running', ids: ['dispatch-1'], actions: ['wait'] }
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks rather than truncates when the move would exceed the transfer caps', () => {
|
||||
const many = Array.from({ length: MAX_ORCAD_MIGRATION_SCROLLBACK_SNAPSHOTS + 1 }, (_, i) =>
|
||||
pane(`t${i}:a`)
|
||||
)
|
||||
expect(gate(many)).toMatchObject({
|
||||
verdict: 'blocked',
|
||||
blockers: [{ kind: 'scrollback-too-large' }]
|
||||
})
|
||||
const perPane = Math.ceil(MAX_ORCAD_MIGRATION_SCROLLBACK_TOTAL_BYTES / 60)
|
||||
const big = Array.from({ length: 61 }, (_, i) => pane(`b${i}:a`, { scrollbackBytes: perPane }))
|
||||
expect(gate(big)).toMatchObject({
|
||||
verdict: 'blocked',
|
||||
blockers: [{ kind: 'scrollback-too-large' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('lets a move exactly at the transfer caps through', () => {
|
||||
const atCount = Array.from({ length: MAX_ORCAD_MIGRATION_SCROLLBACK_SNAPSHOTS }, (_, i) =>
|
||||
pane(`t${i}:a`)
|
||||
)
|
||||
expect(gate(atCount).verdict).toBe('ready')
|
||||
const perPane = MAX_ORCAD_MIGRATION_SCROLLBACK_TOTAL_BYTES / 64
|
||||
const atTotal = Array.from({ length: 64 }, (_, i) =>
|
||||
pane(`b${i}:a`, { scrollbackBytes: perPane })
|
||||
)
|
||||
expect(gate(atTotal).verdict).toBe('ready')
|
||||
})
|
||||
|
||||
it('reports a pane over its own cap together with a move over the total cap', () => {
|
||||
const over = TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT + 1
|
||||
const panes = [
|
||||
pane('huge:a', { scrollbackBytes: over }),
|
||||
...Array.from({ length: 52 }, (_, i) =>
|
||||
pane(`b${i}:a`, { scrollbackBytes: TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT })
|
||||
)
|
||||
]
|
||||
const verdict = gate(panes)
|
||||
expect(verdict.verdict).toBe('blocked')
|
||||
const blocker = verdict.verdict === 'blocked' ? verdict.blockers[0] : undefined
|
||||
expect(blocker?.kind).toBe('scrollback-too-large')
|
||||
expect(blocker?.ids).toHaveLength(53)
|
||||
expect(new Set(blocker?.ids).size).toBe(53)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* The WSL move's terminal gate (mover design, section 3.2): every pane in scope is either safe to
|
||||
* pause and restore on the host, or it blocks the move with the actions that would unblock it.
|
||||
* Nothing is ever ended to make progress. Unlike SSH, the local daemon can prove exit, so a pane it
|
||||
* cannot answer for is `unverifiable` and blocks rather than being guessed at.
|
||||
*/
|
||||
import type { AgentStatusState } from '../../../shared/agent-status-types'
|
||||
import {
|
||||
MAX_ORCAD_MIGRATION_SCROLLBACK_SNAPSHOTS,
|
||||
MAX_ORCAD_MIGRATION_SCROLLBACK_TOTAL_BYTES
|
||||
} from '../../../shared/orcad-migration-scrollback'
|
||||
import { TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT } from '../../../shared/terminal-scrollback-limits'
|
||||
|
||||
export type WslMovePaneFacts = {
|
||||
paneKey: string
|
||||
/** The daemon's foreground-process probe, the one the close-tab warning uses. */
|
||||
foreground: 'shell-idle' | 'process-running' | 'unverifiable'
|
||||
/**
|
||||
* The agent in the pane, if any, as the hook server reports it. `ownsForeground` is true only
|
||||
* when the foreground process is that agent; a stale row must not vouch for a dev server.
|
||||
*/
|
||||
agent?: { state: AgentStatusState; resumable: boolean; ownsForeground: boolean }
|
||||
scrollbackBytes: number
|
||||
}
|
||||
|
||||
export type WslMoveGateInputs = {
|
||||
panes: readonly WslMovePaneFacts[]
|
||||
/** Automation runs in scope that have not finished. */
|
||||
runningAutomationRunIds: readonly string[]
|
||||
/** Orchestration dispatches in scope that have not finished. */
|
||||
activeDispatchIds: readonly string[]
|
||||
}
|
||||
|
||||
export type WslMoveGateAction = 'wait' | 'end' | 'skip-project' | 'clear-scrollback'
|
||||
|
||||
export type WslMoveGateBlocker = {
|
||||
kind:
|
||||
| 'agent-working'
|
||||
| 'process-running'
|
||||
| 'agent-not-resumable'
|
||||
| 'unverifiable'
|
||||
| 'automation-running'
|
||||
| 'dispatch-running'
|
||||
| 'scrollback-too-large'
|
||||
ids: string[]
|
||||
actions: WslMoveGateAction[]
|
||||
}
|
||||
|
||||
export type WslMoveGateVerdict =
|
||||
| { verdict: 'ready'; paneKeys: string[] }
|
||||
| { verdict: 'blocked'; blockers: WslMoveGateBlocker[] }
|
||||
|
||||
// Mid-turn or waiting on a person: providers report a pending prompt as 'waiting' or 'blocked'.
|
||||
const AGENT_BUSY_STATES: ReadonlySet<AgentStatusState> = new Set(['working', 'waiting', 'blocked'])
|
||||
|
||||
export function assessWslMoveTerminals(inputs: WslMoveGateInputs): WslMoveGateVerdict {
|
||||
const blockers = new Map<WslMoveGateBlocker['kind'], WslMoveGateBlocker>()
|
||||
const block = (kind: WslMoveGateBlocker['kind'], id: string, actions: WslMoveGateAction[]) => {
|
||||
const existing = blockers.get(kind)
|
||||
if (!existing) {
|
||||
blockers.set(kind, { kind, ids: [id], actions })
|
||||
} else if (!existing.ids.includes(id)) {
|
||||
existing.ids.push(id)
|
||||
}
|
||||
}
|
||||
for (const pane of inputs.panes) {
|
||||
const { agent } = pane
|
||||
if (pane.foreground === 'unverifiable' || !isByteCount(pane.scrollbackBytes)) {
|
||||
block('unverifiable', pane.paneKey, ['wait', 'skip-project'])
|
||||
} else if (agent && AGENT_BUSY_STATES.has(agent.state)) {
|
||||
block('agent-working', pane.paneKey, ['wait', 'end'])
|
||||
} else if (pane.foreground === 'process-running' && !agent?.ownsForeground) {
|
||||
block('process-running', pane.paneKey, ['end', 'skip-project'])
|
||||
} else if (agent && !agent.resumable) {
|
||||
block('agent-not-resumable', pane.paneKey, ['end', 'skip-project'])
|
||||
}
|
||||
if (pane.scrollbackBytes > TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT) {
|
||||
block('scrollback-too-large', pane.paneKey, ['clear-scrollback'])
|
||||
}
|
||||
}
|
||||
for (const id of inputs.runningAutomationRunIds) {
|
||||
block('automation-running', id, ['wait'])
|
||||
}
|
||||
for (const id of inputs.activeDispatchIds) {
|
||||
block('dispatch-running', id, ['wait'])
|
||||
}
|
||||
// Over the transfer's caps is a blocker, never a truncation; reported together with any
|
||||
// per-pane overage so clearing one pane never uncovers a second blocker.
|
||||
const withScrollback = inputs.panes.filter(
|
||||
(pane) => isByteCount(pane.scrollbackBytes) && pane.scrollbackBytes > 0
|
||||
)
|
||||
const total = withScrollback.reduce((sum, pane) => sum + pane.scrollbackBytes, 0)
|
||||
if (
|
||||
total > MAX_ORCAD_MIGRATION_SCROLLBACK_TOTAL_BYTES ||
|
||||
withScrollback.length > MAX_ORCAD_MIGRATION_SCROLLBACK_SNAPSHOTS
|
||||
) {
|
||||
for (const pane of withScrollback) {
|
||||
block('scrollback-too-large', pane.paneKey, ['clear-scrollback'])
|
||||
}
|
||||
}
|
||||
return blockers.size > 0
|
||||
? { verdict: 'blocked', blockers: [...blockers.values()] }
|
||||
: { verdict: 'ready', paneKeys: inputs.panes.map((pane) => pane.paneKey) }
|
||||
}
|
||||
|
||||
function isByteCount(value: number): boolean {
|
||||
return Number.isSafeInteger(value) && value >= 0
|
||||
}
|
||||
Reference in New Issue
Block a user