mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
refactor(runtime-core): clean up Phase 3-3.8 code (#26894)
* refactor(runtime-core): clean up Phase 3-3.8 code - worktree.ps: summary lookup is a closure, not a 4-arg callback - relay: shared RelayStreamAckWindow for fs and git response streams - relay: terminal-frame slots live on RelayStreamRegistry - one isBinaryBuffer/BINARY_PROBE_BYTES in shared/binary-buffer - one automation owner fence on the controller, no casts - drop unchecked Store cast in remote repo registration - speech download reuses the shared abort-aware delay, no casts - RelayRuntimeServices: drop leftover handler list and param casts - use shared errorMessage() in audited files Co-Authored-By: Claude <noreply@anthropic.com> * test(cross-version-wire): pass worktree ps summary lookup in both builds' shapes --------- Co-authored-by: m4air <m4air@Mac.localdomain> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
This commit is contained in:
co-authored by
Claude
m4air
m4air
parent
3eea6a5368
commit
cbc6f54e1c
@@ -5,6 +5,7 @@ import { MAX_AUTOMATION_PRECHECK_OUTPUT_CHARS } from '../../shared/automation-pr
|
||||
import { getSshConnectionManager } from '../ssh/ssh-target-registry'
|
||||
import { shellEscape } from '../ssh/ssh-connection-utils'
|
||||
import { admitSelfInitiatedTreeKill } from '../own-chromium-tree-kill-guard'
|
||||
import { errorMessage } from '../../shared/error-message'
|
||||
|
||||
type AutomationPrecheckExecutionTarget =
|
||||
| {
|
||||
@@ -266,11 +267,7 @@ async function runSshPrecheck(
|
||||
const channel = await connection.exec(remoteCommand)
|
||||
return await runSshChannelPrecheck({ precheck, channel, startedAt })
|
||||
} catch (error) {
|
||||
return failedPrecheckResult(
|
||||
precheck,
|
||||
startedAt,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
return failedPrecheckResult(precheck, startedAt, errorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { extname } from 'node:path'
|
||||
import { localLogFileIdentity } from '../../ai-vault/local-log-tail-reader'
|
||||
import { BINARY_PROBE_BYTES, isBinaryBuffer } from '../../../shared/binary-buffer'
|
||||
import {
|
||||
fileTooLargeError,
|
||||
openLocalRegularFile,
|
||||
@@ -9,7 +10,6 @@ import {
|
||||
|
||||
// Why: Monaco degrades features on large files like VS Code, so a 5MB block would needlessly lock out ordinary JSON/log files.
|
||||
export const MAX_TEXT_FILE_SIZE = 50 * 1024 * 1024 // 50MB
|
||||
export const BINARY_PROBE_BYTES = 8192
|
||||
// Why: previewable binaries are base64 blobs (not parsed as text), and local IPC has no frame limit (unlike the relay's 10MB), so 50MB is safe.
|
||||
export const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 // 50MB
|
||||
export const PREVIEWABLE_BINARY_MIME_TYPES: Record<string, string> = {
|
||||
@@ -90,17 +90,6 @@ export async function readLocalLogSnapshot(filePath: string): Promise<LocalFileC
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if a buffer appears to be binary (contains null bytes in first 8KB). */
|
||||
export function isBinaryBuffer(buffer: Buffer): boolean {
|
||||
const len = Math.min(buffer.length, BINARY_PROBE_BYTES)
|
||||
for (let i = 0; i < len; i++) {
|
||||
if (buffer[i] === 0) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function isDirectoryEntry(entry: {
|
||||
isDirectory(): boolean
|
||||
isSymbolicLink(): boolean
|
||||
|
||||
@@ -15,6 +15,7 @@ import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-option
|
||||
import { listLocalRepoWorktreesStrict } from '../repo-worktrees'
|
||||
import { listRegisteredPtys, registerPty } from './pty-registry'
|
||||
import { getVerifiedLocalFolderWorkspaceKeys } from './verified-local-folder-workspaces'
|
||||
import { errorMessage } from '../../shared/error-message'
|
||||
|
||||
type HydrationStore = Store
|
||||
|
||||
@@ -58,10 +59,7 @@ export function hydrateLocalPtyRegistryAtBoot(store: HydrationStore): Promise<vo
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn(
|
||||
'[memory] Boot-time pty-registry hydration failed:',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
console.warn('[memory] Boot-time pty-registry hydration failed:', errorMessage(error))
|
||||
})
|
||||
.finally(() => {
|
||||
clearTimeout(deadline)
|
||||
@@ -139,7 +137,7 @@ async function hydrateLocalPtyRegistry(
|
||||
complete = false
|
||||
console.warn(
|
||||
'[memory] Worktree enumeration failed during pty-registry hydration:',
|
||||
result.reason instanceof Error ? result.reason.message : String(result.reason)
|
||||
errorMessage(result.reason)
|
||||
)
|
||||
continue
|
||||
}
|
||||
@@ -260,7 +258,7 @@ async function collectSessionInfos(
|
||||
throwIfSignalAborted(signal)
|
||||
console.warn(
|
||||
'[memory] listSessions failed for one adapter during hydration:',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
errorMessage(error)
|
||||
)
|
||||
return { complete: false, sessions: [] }
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { errorMessage } from '../../shared/error-message'
|
||||
|
||||
export const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED'
|
||||
export const SSH_PTY_IDENTITY_MISMATCH_ERROR = 'SSH_PTY_IDENTITY_MISMATCH'
|
||||
/**
|
||||
@@ -13,12 +15,12 @@ export const SSH_PTY_SOURCE_RESTORE_REQUIRED_ERROR = 'SSH_PTY_SOURCE_RESTORE_REQ
|
||||
export const SSH_PTY_HELD_BY_PREVIOUS_RELAY_ERROR = 'SSH_PTY_HELD_BY_PREVIOUS_RELAY'
|
||||
|
||||
export function isSshPtyNotFoundError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const message = errorMessage(error)
|
||||
return /PTY ".+" not found/i.test(message)
|
||||
}
|
||||
|
||||
export function isSshPtyIdentityMismatchError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const message = errorMessage(error)
|
||||
return message.includes(SSH_PTY_IDENTITY_MISMATCH_ERROR) || /identity mismatch/i.test(message)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export { readSftpDirectory } from './ssh-sftp-directory-listing'
|
||||
import { extname } from 'node:path'
|
||||
import type { SFTPWrapper, Stats } from 'ssh2'
|
||||
import { IMAGE_FILE_MIME_TYPES } from '../../shared/image-file-extensions'
|
||||
import { isBinaryBuffer } from '../../shared/binary-buffer'
|
||||
import { capturePathExistence, type PathExistenceResult } from '../../shared/path-existence-batch'
|
||||
import type { SearchResult } from '../../shared/code-search-types'
|
||||
import type { DirEntry } from '../../shared/filesystem-entry-types'
|
||||
@@ -23,10 +24,9 @@ import {
|
||||
import { fileStatFromSftpStats, lstatViaSftp, statViaSftp } from './ssh-filesystem-provider-sftp'
|
||||
import type { FileReadLimits, FileReadResult, FileStat, IFilesystemProvider } from './types'
|
||||
|
||||
// Why: same caps and probe window as the relay's fs.readFile so previews behave identically.
|
||||
// Why: same caps as the relay's fs.readFile so previews behave identically.
|
||||
const MAX_TEXT_FILE_SIZE = 10 * 1024 * 1024
|
||||
const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024
|
||||
const BINARY_PROBE_BYTES = 8192
|
||||
const SFTP_NO_SUCH_FILE = 2
|
||||
const PREVIEW_MIME_TYPES: Record<string, string> = {
|
||||
...IMAGE_FILE_MIME_TYPES,
|
||||
@@ -55,10 +55,6 @@ function normalizeSftpError(error: unknown): Error {
|
||||
return err
|
||||
}
|
||||
|
||||
function isBinaryBuffer(buffer: Buffer): boolean {
|
||||
return buffer.subarray(0, BINARY_PROBE_BYTES).includes(0)
|
||||
}
|
||||
|
||||
export class SshSftpFilesystemProvider implements IFilesystemProvider {
|
||||
private readonly channel: SftpFilesystemChannel
|
||||
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { fetchManagedAccountUsage } from '../claude-fetcher'
|
||||
import { fetchCodexRateLimits } from '../codex-fetcher'
|
||||
import { RateLimitServicePolling } from './service-polling'
|
||||
import {
|
||||
INACTIVE_CODEX_PROBE_STAGGER_MS,
|
||||
INACTIVE_FETCH_DEBOUNCE_MS,
|
||||
delayUnlessAborted
|
||||
} from './service-types'
|
||||
import { INACTIVE_CODEX_PROBE_STAGGER_MS, INACTIVE_FETCH_DEBOUNCE_MS } from './service-types'
|
||||
import { delayUnlessAborted } from '../../../shared/abort-aware-delay'
|
||||
|
||||
export abstract class RateLimitServiceInactiveAccounts extends RateLimitServicePolling {
|
||||
async fetchInactiveClaudeAccountsOnOpen(): Promise<void> {
|
||||
|
||||
@@ -162,23 +162,6 @@ export function normalizeClaudeConfigDir(dir: string | null | undefined): string
|
||||
return trimmed || null
|
||||
}
|
||||
|
||||
export function delayUnlessAborted(ms: number, signal: AbortSignal): Promise<void> {
|
||||
if (signal.aborted) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const onAbort = (): void => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
resolve()
|
||||
}, ms)
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
export function isSameUsageWindow(
|
||||
a: ProviderRateLimits['session'],
|
||||
b: ProviderRateLimits['session']
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests.
|
||||
import { OrcaRuntimeWithPtyForegroundProcessReads } from './orca-runtime-pty-foreground-process-reads'
|
||||
import type {
|
||||
AutomationOwnerFenceOperation,
|
||||
AutomationOwnerPrecondition,
|
||||
AutomationDestination
|
||||
} from '../../shared/automation-owner-precondition'
|
||||
@@ -25,33 +24,29 @@ import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import { runtimeWorktreeIdsEqual } from './runtime-worktree-path-identity'
|
||||
|
||||
export class OrcaRuntimeWithAutomationOperations extends OrcaRuntimeWithPtyForegroundProcessReads {
|
||||
protected fenceAutomationOwner(
|
||||
id: string,
|
||||
private readAutomationRuns<T>(
|
||||
automationId: string | undefined,
|
||||
expectedOwner: AutomationOwnerPrecondition | undefined,
|
||||
operation: AutomationOwnerFenceOperation
|
||||
): void {
|
||||
if (!this.store?.assertAutomationOwnerFence) {
|
||||
if (expectedOwner) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
return
|
||||
read: () => T
|
||||
): T {
|
||||
if (expectedOwner && !automationId) {
|
||||
throw new Error('An expected owner requires an automation id.')
|
||||
}
|
||||
this.store.assertAutomationOwnerFence({ id, expectedOwner, operation })
|
||||
return this.automation.withExternalProbePriority(() => {
|
||||
if (automationId) {
|
||||
this.automation.assertOwner(automationId, expectedOwner, 'read')
|
||||
}
|
||||
return read()
|
||||
})
|
||||
}
|
||||
|
||||
listAutomationRuns(
|
||||
automationId?: string,
|
||||
expectedOwner?: AutomationOwnerPrecondition
|
||||
): AutomationRun[] {
|
||||
if (expectedOwner && !automationId) {
|
||||
throw new Error('An expected owner requires an automation id.')
|
||||
}
|
||||
return this.automation.withExternalProbePriority(() => {
|
||||
if (automationId) {
|
||||
this.fenceAutomationOwner(automationId, expectedOwner, 'read')
|
||||
}
|
||||
return this.automation.listRuns(automationId)
|
||||
})
|
||||
return this.readAutomationRuns(automationId, expectedOwner, () =>
|
||||
this.automation.listRuns(automationId)
|
||||
)
|
||||
}
|
||||
|
||||
listAutomationRunsPage(
|
||||
@@ -60,20 +55,14 @@ export class OrcaRuntimeWithAutomationOperations extends OrcaRuntimeWithPtyForeg
|
||||
limit?: number,
|
||||
cursor?: string
|
||||
) {
|
||||
if (expectedOwner && !automationId) {
|
||||
throw new Error('An expected owner requires an automation id.')
|
||||
}
|
||||
return this.automation.withExternalProbePriority(() => {
|
||||
if (automationId) {
|
||||
this.fenceAutomationOwner(automationId, expectedOwner, 'read')
|
||||
}
|
||||
return this.automation.listRunsPage(automationId, limit, cursor)
|
||||
})
|
||||
return this.readAutomationRuns(automationId, expectedOwner, () =>
|
||||
this.automation.listRunsPage(automationId, limit, cursor)
|
||||
)
|
||||
}
|
||||
|
||||
showAutomation(id: string, expectedOwner?: AutomationOwnerPrecondition): Automation {
|
||||
const automation = this.automation.show(id)
|
||||
this.fenceAutomationOwner(id, expectedOwner, 'read')
|
||||
this.automation.assertOwner(id, expectedOwner, 'read')
|
||||
return automation
|
||||
}
|
||||
|
||||
@@ -107,11 +96,11 @@ export class OrcaRuntimeWithAutomationOperations extends OrcaRuntimeWithPtyForeg
|
||||
updateAutomation(
|
||||
id: string,
|
||||
updates: RuntimeAutomationUpdateInput,
|
||||
options?: unknown
|
||||
options?: { expectedOwner?: AutomationOwnerPrecondition; destination?: AutomationDestination }
|
||||
): Promise<Automation> {
|
||||
return this.automation.withExternalProbePriority(() => {
|
||||
const source = this.automationChangeSelector(id)
|
||||
return this.automation.update(id, updates, options as never).then((automation) => {
|
||||
return this.automation.update(id, updates, options).then((automation) => {
|
||||
this.publishAutomationDefinitionChange(source, this.automationChangeSelector(automation.id))
|
||||
return automation
|
||||
})
|
||||
@@ -136,7 +125,7 @@ export class OrcaRuntimeWithAutomationOperations extends OrcaRuntimeWithPtyForeg
|
||||
expectedOwner?: AutomationOwnerPrecondition
|
||||
): Promise<AutomationRun> {
|
||||
return this.automation.withExternalProbePriority(() =>
|
||||
this.automation.runNow(id, expectedOwner as never)
|
||||
this.automation.runNow(id, expectedOwner)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@ import { OrcaRuntimeWithStartTuiIdleVisibleReadProbe } from './orca-runtime-star
|
||||
import { DEFAULT_WORKTREE_PS_LIMIT } from './orca-runtime-postlude'
|
||||
import type { RuntimeWorktreePsResult } from '../../shared/runtime-types'
|
||||
import { buildRuntimeWorktreePsSummaries } from './runtime-worktree-ps-summaries'
|
||||
import { buildRuntimeWorktreeSummaryPathIndex } from './runtime-worktree-summary-paths'
|
||||
import {
|
||||
buildRuntimeWorktreeSummaryPathIndex,
|
||||
createRuntimeWorktreePsSummaryLookup
|
||||
} from './runtime-worktree-summary-paths'
|
||||
import {
|
||||
applyRuntimeWorktreePsSessionActivity,
|
||||
applyRuntimeWorktreePsTerminalActivity
|
||||
@@ -85,18 +88,13 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis
|
||||
platformByRepoId
|
||||
})
|
||||
|
||||
const runtimeWorktreeSummaryPathIndex = buildRuntimeWorktreeSummaryPathIndex(
|
||||
const getSummary = createRuntimeWorktreePsSummaryLookup(
|
||||
summaries,
|
||||
resolvedWorktrees,
|
||||
platformByRepoId
|
||||
buildRuntimeWorktreeSummaryPathIndex(summaries, resolvedWorktrees, platformByRepoId)
|
||||
)
|
||||
const missingRuntimeWorktreeIds = new Set<string>()
|
||||
const session = this.store?.getWorkspaceSession?.()
|
||||
const countedPtyIds = new Set<string>()
|
||||
const workingTerminalEvidenceByWorktreeId = applyRuntimeWorktreePsTerminalActivity({
|
||||
summaries,
|
||||
pathIndex: runtimeWorktreeSummaryPathIndex,
|
||||
missingIds: missingRuntimeWorktreeIds,
|
||||
freshPtyLiveness,
|
||||
countedPtyIds,
|
||||
leaves: this.leaves.values(),
|
||||
@@ -105,37 +103,27 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis
|
||||
session,
|
||||
getPaneKey: (leaf) => this.makeRuntimePaneKey(leaf),
|
||||
getTitleDisplayClear: (ptyId) => this.getPtyTitleDisplayClear(ptyId),
|
||||
getSummary: (summaryMap, pathIndex, missingIds, worktreeId) =>
|
||||
this.getSummaryForRuntimeWorktreeId(summaryMap, pathIndex, missingIds, worktreeId)
|
||||
getSummary
|
||||
})
|
||||
applyRuntimeWorktreePsUnverifiableTerminals({
|
||||
summaries,
|
||||
pathIndex: runtimeWorktreeSummaryPathIndex,
|
||||
missingIds: missingRuntimeWorktreeIds,
|
||||
countedPtyIds,
|
||||
leaves: this.leaves.values(),
|
||||
ptysById: this.ptysById,
|
||||
getLivenessVerdict: (ptyId) => this.getPtyLivenessVerdict(ptyId),
|
||||
getSummary: (summaryMap, pathIndex, missingIds, worktreeId) =>
|
||||
this.getSummaryForRuntimeWorktreeId(summaryMap, pathIndex, missingIds, worktreeId)
|
||||
getSummary
|
||||
})
|
||||
const { mirroredWorktreeIdByTabId, connectedPtyEvidence } =
|
||||
applyRuntimeWorktreePsSessionActivity({
|
||||
store: this.store,
|
||||
summaries,
|
||||
repoById,
|
||||
pathIndex: runtimeWorktreeSummaryPathIndex,
|
||||
missingIds: missingRuntimeWorktreeIds,
|
||||
ptysById: this.ptysById,
|
||||
tabs: this.tabs,
|
||||
getTerminalHandlesForPty: (ptyId) => this.getExistingTerminalHandlesForPtyId(ptyId),
|
||||
getSummary: (summaryMap, pathIndex, missingIds, worktreeId) =>
|
||||
this.getSummaryForRuntimeWorktreeId(summaryMap, pathIndex, missingIds, worktreeId)
|
||||
getSummary
|
||||
})
|
||||
attachRuntimeWorktreeAgentRows({
|
||||
summaries,
|
||||
pathIndex: runtimeWorktreeSummaryPathIndex,
|
||||
missingWorktreeIds: missingRuntimeWorktreeIds,
|
||||
workingTerminalEvidenceByWorktreeId,
|
||||
rowSources: collectRuntimeWorktreeAgentSources({
|
||||
mirroredWorktreeIdByTabId,
|
||||
@@ -144,8 +132,7 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis
|
||||
hookSnapshots: this.getAgentStatusSnapshotFn?.() ?? []
|
||||
}),
|
||||
orchestrationByPaneKey: this.agentOrchestrationProjection.buildByPaneKey(),
|
||||
getSummary: (summaryMap, pathIndex, missingIds, worktreeId) =>
|
||||
this.getSummaryForRuntimeWorktreeId(summaryMap, pathIndex, missingIds, worktreeId)
|
||||
getSummary
|
||||
})
|
||||
|
||||
const sorted = [...summaries.values()].sort(compareWorktreePs)
|
||||
|
||||
@@ -32,7 +32,6 @@ import { RuntimeMobileSpeechCatalog } from './runtime-mobile-speech-catalog'
|
||||
import { RuntimeMobileDictationController } from './runtime-mobile-dictation-controller'
|
||||
import { RuntimeProjectHostSetupController } from './runtime-project-host-setup-controller'
|
||||
import { addRemoteRepoFromPath } from '../ipc/repos/remote-repo-registration'
|
||||
import type { Store } from '../persistence'
|
||||
import { RuntimeProjectGroupController } from './runtime-project-group-controller'
|
||||
import { RuntimeNestedRepoImport } from './runtime-nested-repo-import'
|
||||
import { RuntimeRepositoryRegistrationController } from './runtime-repository-registration-controller'
|
||||
@@ -253,7 +252,7 @@ export class OrcaRuntimeWithPreservedBranchCleanup extends OrcaRuntimeWithTermin
|
||||
(this as RuntimeCommandSurfaceHost<this>).addRepo(path, kind, hostId),
|
||||
addRemoteRepo: async (remote) => {
|
||||
// The same registration the desktop IPC handler uses, so both surfaces agree on SSH hosts.
|
||||
const result = await addRemoteRepoFromPath(this.requireStore() as unknown as Store, remote)
|
||||
const result = await addRemoteRepoFromPath(this.requireStore(), remote)
|
||||
if ('error' in result) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../shared/pty-liveness-verd
|
||||
import type { RuntimeTerminalClose } from '../../shared/runtime-types'
|
||||
import type { RuntimePtyTabCloseAuthority } from './runtime-terminal-state-records'
|
||||
import { parsePaneKey } from '../../shared/stable-pane-id'
|
||||
import { errorMessage } from '../../shared/error-message'
|
||||
|
||||
/** How an explicit close's stop of its addressed PTY ended. */
|
||||
type ExplicitCloseStop = { stopped: boolean; pendingKillRecorded: boolean }
|
||||
@@ -27,10 +28,7 @@ export class OrcaRuntimeWithStopExplicitlyClosedTabPtys extends OrcaRuntimeWithF
|
||||
try {
|
||||
stopped = await this.ptyController.stopAndWait(ptyId, { deadlineMs })
|
||||
} catch (error) {
|
||||
this.markPtyLivenessUnverifiable(
|
||||
ptyId,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
this.markPtyLivenessUnverifiable(ptyId, errorMessage(error))
|
||||
}
|
||||
// Preserve an observed exit when a broader inventory check could not finish.
|
||||
if (
|
||||
|
||||
@@ -10,10 +10,7 @@ import type { TerminalAgent } from '../../shared/terminal-agent'
|
||||
import { selectRuntimeHookAgentRowForPane } from './runtime-mobile-agent-status-projection'
|
||||
import { isTuiAgent } from '../../shared/tui-agent-config'
|
||||
import { resolvePublishedPaneAgentIdentity } from '../../shared/published-pane-agent-identity'
|
||||
import type { RuntimeTerminalSummary, RuntimeWorktreePsSummary } from '../../shared/runtime-types'
|
||||
import type { RuntimeWorktreeSummaryPathIndex } from './runtime-worktree-summary-paths'
|
||||
import { parseRuntimeWorktreeId } from './runtime-worktree-path-identity'
|
||||
import { findRuntimeWorktreeSummaryByPath } from './runtime-worktree-summary-paths'
|
||||
import type { RuntimeTerminalSummary } from '../../shared/runtime-types'
|
||||
import type { ResolvedWorktree } from './runtime-worktree-path-identity'
|
||||
import { getLatestLeafTitle } from './runtime-worktree-status-projection'
|
||||
import { parseAppSshPtyId } from '../../shared/ssh-pty-id'
|
||||
@@ -108,38 +105,6 @@ export class OrcaRuntimeWithWriteOrchestrationPointerPty extends OrcaRuntimeWith
|
||||
return agentIdentity ? { agentIdentity } : {}
|
||||
}
|
||||
|
||||
protected getSummaryForRuntimeWorktreeId(
|
||||
summaries: Map<string, RuntimeWorktreePsSummary>,
|
||||
runtimeWorktreeSummaryPathIndex: RuntimeWorktreeSummaryPathIndex,
|
||||
missingRuntimeWorktreeIds: Set<string>,
|
||||
runtimeWorktreeId: string
|
||||
): RuntimeWorktreePsSummary | null {
|
||||
const exact = summaries.get(runtimeWorktreeId)
|
||||
if (exact) {
|
||||
return exact
|
||||
}
|
||||
if (missingRuntimeWorktreeIds.has(runtimeWorktreeId)) {
|
||||
return null
|
||||
}
|
||||
const parsed = parseRuntimeWorktreeId(runtimeWorktreeId)
|
||||
if (!parsed) {
|
||||
return null
|
||||
}
|
||||
const comparisonPlatform =
|
||||
runtimeWorktreeSummaryPathIndex.platformByRepoId.get(parsed.repoId) ?? process.platform
|
||||
const indexed = findRuntimeWorktreeSummaryByPath(
|
||||
runtimeWorktreeSummaryPathIndex,
|
||||
parsed.repoId,
|
||||
parsed.worktreePath,
|
||||
comparisonPlatform
|
||||
)
|
||||
if (indexed) {
|
||||
return indexed
|
||||
}
|
||||
missingRuntimeWorktreeIds.add(runtimeWorktreeId)
|
||||
return null
|
||||
}
|
||||
|
||||
protected buildTerminalSummary(
|
||||
leaf: RuntimeLeafRecord,
|
||||
worktreesById: Map<string, ResolvedWorktree>,
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { Worktree } from '../../shared/worktree/types'
|
||||
import type { RuntimeStore } from './runtime-store-contract'
|
||||
import type { AutomationListParams, AutomationListResult } from '../../shared/automation-list-scope'
|
||||
import type {
|
||||
AutomationOwnerFenceOperation,
|
||||
AutomationOwnerPrecondition,
|
||||
AutomationDestination
|
||||
} from '../../shared/automation-owner-precondition'
|
||||
@@ -209,26 +210,27 @@ export class RuntimeAutomationController {
|
||||
if (!this.service) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const service = this.service
|
||||
return await runAutomationNowFenced({
|
||||
automationId: id,
|
||||
service,
|
||||
fence: () => {
|
||||
if (!this.store?.assertAutomationOwnerFence) {
|
||||
if (expectedOwner) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
return
|
||||
}
|
||||
this.store.assertAutomationOwnerFence({
|
||||
id,
|
||||
expectedOwner,
|
||||
operation: 'execute'
|
||||
})
|
||||
}
|
||||
service: this.service,
|
||||
fence: () => this.assertOwner(id, expectedOwner, 'execute')
|
||||
})
|
||||
}
|
||||
|
||||
assertOwner(
|
||||
id: string,
|
||||
expectedOwner: AutomationOwnerPrecondition | undefined,
|
||||
operation: AutomationOwnerFenceOperation
|
||||
): void {
|
||||
if (!this.store?.assertAutomationOwnerFence) {
|
||||
if (expectedOwner) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
return
|
||||
}
|
||||
this.store.assertAutomationOwnerFence({ id, expectedOwner, operation })
|
||||
}
|
||||
|
||||
private copyPatchValues(
|
||||
updates: RuntimeAutomationUpdateInput,
|
||||
patch: AutomationUpdateInput
|
||||
|
||||
@@ -187,13 +187,3 @@ export function isRuntimeDirectoryEntry(entry: {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function isBinaryBuffer(buffer: Buffer): boolean {
|
||||
const len = Math.min(buffer.length, 8192)
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
if (buffer[i] === 0) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
NodeFileReadTooLargeError,
|
||||
readNodeFileWithinLimit
|
||||
} from '../../shared/node-bounded-file-reader'
|
||||
import { isBinaryBuffer } from './runtime-file-command-host'
|
||||
import { isBinaryBuffer } from '../../shared/binary-buffer'
|
||||
import type {
|
||||
DocPreviewFileAccessRequest,
|
||||
DocPreviewFileAccessResult
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
localTerminalArtifactRoots,
|
||||
readFileHandleBufferBounded
|
||||
} from './runtime-file-commands-terminal-artifact-access'
|
||||
import { isBinaryBuffer } from './runtime-file-command-host'
|
||||
import { isBinaryBuffer } from '../../shared/binary-buffer'
|
||||
import type { RuntimeFilePreviewResult } from '../../shared/runtime-types'
|
||||
import {
|
||||
isPathInsideOrEqual,
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
MOBILE_FILE_READ_MAX_BYTES,
|
||||
previewableBinaryByteLimit
|
||||
} from './runtime-file-commands-mobile-file-list-limit'
|
||||
import { isBinaryBuffer, isMobileBinaryPath } from './runtime-file-command-host'
|
||||
import { isBinaryBuffer } from '../../shared/binary-buffer'
|
||||
import { isMobileBinaryPath } from './runtime-file-command-host'
|
||||
import {
|
||||
assertTerminalArtifactContentUnchanged,
|
||||
assertTerminalFileGrantFresh,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
createRuntimeTransportMetadata,
|
||||
sweepOrphanedRuntimeSockets
|
||||
} from './runtime-rpc-socket-metadata'
|
||||
import { errorMessage } from '../../../shared/error-message'
|
||||
|
||||
export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch {
|
||||
async start(): Promise<void> {
|
||||
@@ -51,7 +52,7 @@ export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch {
|
||||
reply(JSON.stringify(response))
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const message = errorMessage(error)
|
||||
// Why: best-effort id recovery so the client can correlate the error frame to its pending request.
|
||||
let id = 'unknown'
|
||||
try {
|
||||
|
||||
@@ -59,8 +59,6 @@ function attach(summaries: AgentSessionStatusSummary[]): RuntimeWorktreePsSummar
|
||||
const summariesById = new Map<string, RuntimeWorktreePsSummary>([[WORKTREE_ID, row]])
|
||||
attachRuntimeWorktreeAgentRows({
|
||||
summaries: summariesById,
|
||||
pathIndex: { byPath: new Map(), byRealPath: new Map() } as never,
|
||||
missingWorktreeIds: new Set(),
|
||||
workingTerminalEvidenceByWorktreeId: new Map(),
|
||||
rowSources: collectRuntimeWorktreeAgentSources({
|
||||
mirroredWorktreeIdByTabId: new Map(),
|
||||
@@ -72,7 +70,7 @@ function attach(summaries: AgentSessionStatusSummary[]): RuntimeWorktreePsSummar
|
||||
hookSnapshots: store.getStatusSnapshot()
|
||||
}),
|
||||
orchestrationByPaneKey: null,
|
||||
getSummary: (map, _p, _m, id) => map.get(id) ?? null
|
||||
getSummary: (id) => summariesById.get(id) ?? null
|
||||
})
|
||||
return row
|
||||
}
|
||||
|
||||
@@ -109,11 +109,9 @@ function ingest(summary: AgentSessionStatusSummary, children: AgentChildWorkEvid
|
||||
hasHostSidebarActivity: false,
|
||||
agents: []
|
||||
} as unknown as RuntimeWorktreePsSummary
|
||||
const summaries = new Map([[WORKSPACE_ID, row]])
|
||||
attachRuntimeWorktreeAgentRows({
|
||||
summaries: new Map([[WORKSPACE_ID, row]]),
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `getSummary` below resolves every row by id, so the path index is never read.
|
||||
pathIndex: { byPath: new Map(), byRealPath: new Map() } as never,
|
||||
missingWorktreeIds: new Set(),
|
||||
summaries,
|
||||
workingTerminalEvidenceByWorktreeId: new Map(),
|
||||
rowSources: collectRuntimeWorktreeAgentSources({
|
||||
mirroredWorktreeIdByTabId: new Map(),
|
||||
@@ -125,7 +123,7 @@ function ingest(summary: AgentSessionStatusSummary, children: AgentChildWorkEvid
|
||||
hookSnapshots
|
||||
}),
|
||||
orchestrationByPaneKey: null,
|
||||
getSummary: (map, _p, _m, id) => map.get(id) ?? null
|
||||
getSummary: (id) => summaries.get(id) ?? null
|
||||
})
|
||||
return { status: hookSnapshots[0], ps: row.agents[0] }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { isFreshNonDoneAgentStatus } from '../../shared/agent-status-types'
|
||||
import type { RuntimeWorktreeAgentRow, RuntimeWorktreePsSummary } from '../../shared/runtime-types'
|
||||
import { mergeWorktreeSummaryStatus } from './runtime-worktree-status-projection'
|
||||
import type { RuntimeWorktreeSummaryPathIndex } from './runtime-worktree-summary-paths'
|
||||
import type { RuntimeWorktreePsSummaryLookup } from './runtime-worktree-summary-paths'
|
||||
import type { RuntimeWorkingTerminalEvidence } from './runtime-worktree-ps-activity'
|
||||
import type { RuntimeWorktreeAgentSource } from './runtime-worktree-agent-source'
|
||||
export type { RuntimeAgentRowSnapshot } from './runtime-hook-agent-row-selection'
|
||||
@@ -14,20 +14,13 @@ type OrchestrationDisplay = {
|
||||
|
||||
export function attachRuntimeWorktreeAgentRows(args: {
|
||||
summaries: Map<string, RuntimeWorktreePsSummary>
|
||||
pathIndex: RuntimeWorktreeSummaryPathIndex
|
||||
missingWorktreeIds: Set<string>
|
||||
rowSources: ReadonlyMap<string, RuntimeWorktreeAgentSource>
|
||||
workingTerminalEvidenceByWorktreeId: ReadonlyMap<
|
||||
string,
|
||||
readonly RuntimeWorkingTerminalEvidence[]
|
||||
>
|
||||
orchestrationByPaneKey: Record<string, OrchestrationDisplay> | null | undefined
|
||||
getSummary: (
|
||||
summaries: Map<string, RuntimeWorktreePsSummary>,
|
||||
pathIndex: RuntimeWorktreeSummaryPathIndex,
|
||||
missingWorktreeIds: Set<string>,
|
||||
worktreeId: string
|
||||
) => RuntimeWorktreePsSummary | null
|
||||
getSummary: RuntimeWorktreePsSummaryLookup
|
||||
}): void {
|
||||
const { rowSources } = args
|
||||
const now = Date.now()
|
||||
@@ -37,12 +30,7 @@ export function attachRuntimeWorktreeAgentRows(args: {
|
||||
if (!worktreeId) {
|
||||
continue
|
||||
}
|
||||
const summary = args.getSummary(
|
||||
args.summaries,
|
||||
args.pathIndex,
|
||||
args.missingWorktreeIds,
|
||||
worktreeId
|
||||
)
|
||||
const summary = args.getSummary(worktreeId)
|
||||
if (!summary) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execu
|
||||
import { parsePaneKey } from '../../shared/stable-pane-id'
|
||||
import type { RuntimeStore } from './runtime-store-contract'
|
||||
import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
|
||||
import type { RuntimeWorktreeSummaryPathIndex } from './runtime-worktree-summary-paths'
|
||||
import type { RuntimeWorktreePsSummaryLookup } from './runtime-worktree-summary-paths'
|
||||
import {
|
||||
getLatestPtyTitle,
|
||||
getLeafDisplayRecord,
|
||||
@@ -18,13 +18,6 @@ import {
|
||||
} from './runtime-worktree-status-projection'
|
||||
import { runtimeWorktreeIdsEqual } from './runtime-worktree-path-identity'
|
||||
|
||||
type SummaryLookup = (
|
||||
summaries: Map<string, RuntimeWorktreePsSummary>,
|
||||
pathIndex: RuntimeWorktreeSummaryPathIndex,
|
||||
missingIds: Set<string>,
|
||||
worktreeId: string
|
||||
) => RuntimeWorktreePsSummary | null
|
||||
|
||||
export type RuntimeWorkingTerminalEvidence = {
|
||||
paneKey: string | null
|
||||
ptyId: string | null
|
||||
@@ -32,9 +25,6 @@ export type RuntimeWorkingTerminalEvidence = {
|
||||
}
|
||||
|
||||
export function applyRuntimeWorktreePsTerminalActivity(args: {
|
||||
summaries: Map<string, RuntimeWorktreePsSummary>
|
||||
pathIndex: RuntimeWorktreeSummaryPathIndex
|
||||
missingIds: Set<string>
|
||||
freshPtyLiveness: ReadonlySet<string> | null
|
||||
/** Filled with every PTY this pass counts as live. */
|
||||
countedPtyIds: Set<string>
|
||||
@@ -44,7 +34,7 @@ export function applyRuntimeWorktreePsTerminalActivity(args: {
|
||||
session: WorkspaceSessionState | null | undefined
|
||||
getPaneKey: (leaf: RuntimeLeafRecord) => string
|
||||
getTitleDisplayClear: (ptyId: string) => TitleDisplayClear | null
|
||||
getSummary: SummaryLookup
|
||||
getSummary: RuntimeWorktreePsSummaryLookup
|
||||
}): Map<string, RuntimeWorkingTerminalEvidence[]> {
|
||||
const workingEvidence = new Map<string, RuntimeWorkingTerminalEvidence[]>()
|
||||
const savedTabOwnerById = new Map<string, { worktreeId: string; title: string }>()
|
||||
@@ -78,12 +68,7 @@ export function applyRuntimeWorktreePsTerminalActivity(args: {
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const summary = args.getSummary(
|
||||
args.summaries,
|
||||
args.pathIndex,
|
||||
args.missingIds,
|
||||
leaf.worktreeId
|
||||
)
|
||||
const summary = args.getSummary(leaf.worktreeId)
|
||||
if (!summary) {
|
||||
continue
|
||||
}
|
||||
@@ -146,12 +131,7 @@ export function applyRuntimeWorktreePsTerminalActivity(args: {
|
||||
if (!owner) {
|
||||
continue
|
||||
}
|
||||
const summary = args.getSummary(
|
||||
args.summaries,
|
||||
args.pathIndex,
|
||||
args.missingIds,
|
||||
owner.worktreeId
|
||||
)
|
||||
const summary = args.getSummary(owner.worktreeId)
|
||||
if (!summary) {
|
||||
continue
|
||||
}
|
||||
@@ -197,13 +177,11 @@ export function applyRuntimeWorktreePsSessionActivity(args: {
|
||||
store: RuntimeStore | null
|
||||
summaries: Map<string, RuntimeWorktreePsSummary>
|
||||
repoById: ReadonlyMap<string, Repo>
|
||||
pathIndex: RuntimeWorktreeSummaryPathIndex
|
||||
missingIds: Set<string>
|
||||
ptysById: ReadonlyMap<string, RuntimePtyWorktreeRecord>
|
||||
tabs: ReadonlyMap<string, RuntimeSyncedTab>
|
||||
/** Non-minting: a listing must not issue handles, only recognise the ones already bound. */
|
||||
getTerminalHandlesForPty: (ptyId: string) => readonly string[]
|
||||
getSummary: SummaryLookup
|
||||
getSummary: RuntimeWorktreePsSummaryLookup
|
||||
}): {
|
||||
mirroredWorktreeIdByTabId: Map<string, string>
|
||||
connectedPtyEvidence: {
|
||||
@@ -229,7 +207,7 @@ export function applyRuntimeWorktreePsSessionActivity(args: {
|
||||
if (tabs.length === 0) {
|
||||
continue
|
||||
}
|
||||
const summary = args.getSummary(args.summaries, args.pathIndex, args.missingIds, worktreeId)
|
||||
const summary = args.getSummary(worktreeId)
|
||||
if (summary && tabs.some((tab) => tab.ptyId && args.ptysById.get(tab.ptyId)?.connected)) {
|
||||
summary.hasHostSidebarActivity = true
|
||||
}
|
||||
@@ -238,18 +216,13 @@ export function applyRuntimeWorktreePsSessionActivity(args: {
|
||||
if (tabs.length === 0) {
|
||||
continue
|
||||
}
|
||||
const summary = args.getSummary(args.summaries, args.pathIndex, args.missingIds, worktreeId)
|
||||
const summary = args.getSummary(worktreeId)
|
||||
if (summary) {
|
||||
summary.hasHostSidebarActivity = true
|
||||
}
|
||||
}
|
||||
if (session.activeWorktreeId) {
|
||||
const summary = args.getSummary(
|
||||
args.summaries,
|
||||
args.pathIndex,
|
||||
args.missingIds,
|
||||
session.activeWorktreeId
|
||||
)
|
||||
const summary = args.getSummary(session.activeWorktreeId)
|
||||
if (summary) {
|
||||
summary.isActive = true
|
||||
}
|
||||
|
||||
@@ -9,18 +9,14 @@ describe('unverifiable terminal attribution', () => {
|
||||
['wt-new', {}]
|
||||
])
|
||||
applyRuntimeWorktreePsUnverifiableTerminals({
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the counters this pass writes are read back.
|
||||
summaries: summaries as Map<string, RuntimeWorktreePsSummary>,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: getSummary below ignores the index.
|
||||
pathIndex: {} as never,
|
||||
missingIds: new Set(),
|
||||
countedPtyIds: new Set(),
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pass reads only ptyId and worktreeId.
|
||||
leaves: [{ ptyId: 'pty-1', worktreeId: 'wt-old' } as never],
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pass reads only ptyId and worktreeId.
|
||||
ptysById: new Map([['pty-1', { ptyId: 'pty-1', worktreeId: 'wt-new' } as never]]),
|
||||
getLivenessVerdict: () => ({ status: 'unverifiable', reason: 'lost contact' }),
|
||||
getSummary: (all, _index, _missing, worktreeId) => all.get(worktreeId) ?? null
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the counters this pass writes are read back.
|
||||
getSummary: (id) => (summaries.get(id) as RuntimeWorktreePsSummary | undefined) ?? null
|
||||
})
|
||||
expect(summaries.get('wt-new')?.unverifiableTerminalCount).toBe(1)
|
||||
expect(summaries.get('wt-old')?.unverifiableTerminalCount).toBeUndefined()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types'
|
||||
import type { PtyLivenessVerdict } from '../../shared/pty-liveness-verdict'
|
||||
import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
|
||||
import type { RuntimeWorktreeSummaryPathIndex } from './runtime-worktree-summary-paths'
|
||||
import type { RuntimeWorktreePsSummaryLookup } from './runtime-worktree-summary-paths'
|
||||
|
||||
/**
|
||||
* Counts each terminal that was not counted live but whose host only lost contact.
|
||||
@@ -9,19 +8,11 @@ import type { RuntimeWorktreeSummaryPathIndex } from './runtime-worktree-summary
|
||||
* the row read as no terminals at all (docs/reference/ssh-execution-boundary.md).
|
||||
*/
|
||||
export function applyRuntimeWorktreePsUnverifiableTerminals(args: {
|
||||
summaries: Map<string, RuntimeWorktreePsSummary>
|
||||
pathIndex: RuntimeWorktreeSummaryPathIndex
|
||||
missingIds: Set<string>
|
||||
countedPtyIds: ReadonlySet<string>
|
||||
leaves: Iterable<RuntimeLeafRecord>
|
||||
ptysById: ReadonlyMap<string, RuntimePtyWorktreeRecord>
|
||||
getLivenessVerdict: (ptyId: string) => PtyLivenessVerdict | null
|
||||
getSummary: (
|
||||
summaries: Map<string, RuntimeWorktreePsSummary>,
|
||||
pathIndex: RuntimeWorktreeSummaryPathIndex,
|
||||
missingIds: Set<string>,
|
||||
worktreeId: string
|
||||
) => RuntimeWorktreePsSummary | null
|
||||
getSummary: RuntimeWorktreePsSummaryLookup
|
||||
}): void {
|
||||
// Like the live pass, the host's PTY record owns the worktree; a pane only fills in a PTY with no record.
|
||||
const ownerByPtyId = new Map<string, string>()
|
||||
@@ -40,7 +31,7 @@ export function applyRuntimeWorktreePsUnverifiableTerminals(args: {
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const summary = args.getSummary(args.summaries, args.pathIndex, args.missingIds, worktreeId)
|
||||
const summary = args.getSummary(worktreeId)
|
||||
if (summary) {
|
||||
summary.unverifiableTerminalCount = (summary.unverifiableTerminalCount ?? 0) + 1
|
||||
summary.hasHostSidebarActivity = true
|
||||
|
||||
@@ -130,10 +130,9 @@ function worktreeFor(store: AgentHookServer): RuntimeWorktreePsSummary {
|
||||
status: 'inactive',
|
||||
agents: []
|
||||
} as unknown as RuntimeWorktreePsSummary
|
||||
const summaries = new Map([[WORKTREE_ID, row]])
|
||||
attachRuntimeWorktreeAgentRows({
|
||||
summaries: new Map([[WORKTREE_ID, row]]),
|
||||
pathIndex: { byPath: new Map(), byRealPath: new Map() } as never,
|
||||
missingWorktreeIds: new Set(),
|
||||
summaries,
|
||||
workingTerminalEvidenceByWorktreeId: new Map(),
|
||||
rowSources: collectRuntimeWorktreeAgentSources({
|
||||
mirroredWorktreeIdByTabId: new Map(),
|
||||
@@ -145,7 +144,7 @@ function worktreeFor(store: AgentHookServer): RuntimeWorktreePsSummary {
|
||||
hookSnapshots: store.getStatusSnapshot()
|
||||
}),
|
||||
orchestrationByPaneKey: null,
|
||||
getSummary: (map, _paths, _missing, id) => map.get(id) ?? null
|
||||
getSummary: (id) => summaries.get(id) ?? null
|
||||
})
|
||||
return row
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types'
|
||||
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
|
||||
import { worktreePathComparisonKey } from '../ipc/worktree-path-comparison'
|
||||
import { parseRuntimeWorktreeId } from './runtime-worktree-path-identity'
|
||||
|
||||
type ResolvedWorktreePath = { id: string; repoId: string; path: string }
|
||||
type RuntimeWorktreeSummaryPathCandidate = { summary: RuntimeWorktreePsSummary; order: number }
|
||||
@@ -55,6 +56,40 @@ export function buildRuntimeWorktreeSummaryPathIndex(
|
||||
return index
|
||||
}
|
||||
|
||||
export type RuntimeWorktreePsSummaryLookup = (worktreeId: string) => RuntimeWorktreePsSummary | null
|
||||
|
||||
export function createRuntimeWorktreePsSummaryLookup(
|
||||
summaries: ReadonlyMap<string, RuntimeWorktreePsSummary>,
|
||||
pathIndex: RuntimeWorktreeSummaryPathIndex
|
||||
): RuntimeWorktreePsSummaryLookup {
|
||||
const missingIds = new Set<string>()
|
||||
return (worktreeId) => {
|
||||
const exact = summaries.get(worktreeId)
|
||||
if (exact) {
|
||||
return exact
|
||||
}
|
||||
if (missingIds.has(worktreeId)) {
|
||||
return null
|
||||
}
|
||||
const parsed = parseRuntimeWorktreeId(worktreeId)
|
||||
if (!parsed) {
|
||||
return null
|
||||
}
|
||||
const platform = pathIndex.platformByRepoId.get(parsed.repoId) ?? process.platform
|
||||
const indexed = findRuntimeWorktreeSummaryByPath(
|
||||
pathIndex,
|
||||
parsed.repoId,
|
||||
parsed.worktreePath,
|
||||
platform
|
||||
)
|
||||
if (indexed) {
|
||||
return indexed
|
||||
}
|
||||
missingIds.add(worktreeId)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function findRuntimeWorktreeSummaryByPath(
|
||||
index: RuntimeWorktreeSummaryPathIndex,
|
||||
repoId: string,
|
||||
|
||||
@@ -73,21 +73,3 @@ export function describeInterruptedDownload(
|
||||
`Model download interrupted at ${received} after ${attempts} attempts: ${causeMessage}`
|
||||
)
|
||||
}
|
||||
|
||||
export function sleepUnlessAborted(ms: number, signal: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (signal.aborted) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
const onAbort = (): void => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
resolve()
|
||||
}, ms)
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,11 +6,11 @@ import {
|
||||
MAX_RETRY_AFTER_MS,
|
||||
MAX_TOTAL_DOWNLOAD_REQUESTS,
|
||||
describeInterruptedDownload,
|
||||
sleepUnlessAborted,
|
||||
type DownloadTotals
|
||||
} from './speech-model-download-response'
|
||||
import { isRetryableDownloadError, type HttpStatusError } from '../network/transient-download-error'
|
||||
import { SpeechModelHttpDownload } from './speech-model-http-download'
|
||||
import { delayUnlessAborted } from '../../shared/abort-aware-delay'
|
||||
|
||||
export abstract class SpeechModelDownloadTransport extends SpeechModelHttpDownload {
|
||||
protected getPartialDownloadBytes(filePath: string): number {
|
||||
@@ -72,7 +72,7 @@ export abstract class SpeechModelDownloadTransport extends SpeechModelHttpDownlo
|
||||
`Model download exceeded its expected size (${receivedBytes} of ${totals.totalBytes} bytes)`
|
||||
)
|
||||
}
|
||||
const incompleteResponse = new Error(
|
||||
const incompleteResponse: HttpStatusError = new Error(
|
||||
`Model download response ended at ${receivedBytes} of ${totals.totalBytes} bytes`
|
||||
)
|
||||
if (receivedBytes > offset) {
|
||||
@@ -80,9 +80,8 @@ export abstract class SpeechModelDownloadTransport extends SpeechModelHttpDownlo
|
||||
noProgressStreak = 0
|
||||
continue
|
||||
}
|
||||
const retryableIncompleteResponse = incompleteResponse as HttpStatusError
|
||||
retryableIncompleteResponse.retryable = true
|
||||
throw retryableIncompleteResponse
|
||||
incompleteResponse.retryable = true
|
||||
throw incompleteResponse
|
||||
} catch (err) {
|
||||
if (isAborted() || signal.aborted) {
|
||||
throw err
|
||||
@@ -99,11 +98,11 @@ export abstract class SpeechModelDownloadTransport extends SpeechModelHttpDownlo
|
||||
if (noProgressStreak >= MAX_NO_PROGRESS_ATTEMPTS) {
|
||||
throw describeInterruptedDownload(err, receivedBytes, totals.totalBytes, requestCount)
|
||||
}
|
||||
const retryAfterMs = (err as HttpStatusError).retryAfterMs
|
||||
const statusError: HttpStatusError | undefined = err instanceof Error ? err : undefined
|
||||
const retryAfterMs = statusError?.retryAfterMs
|
||||
if (retryAfterMs !== undefined && retryAfterMs > MAX_RETRY_AFTER_MS) {
|
||||
const statusCode = (err as HttpStatusError).httpStatusCode
|
||||
throw new Error(
|
||||
`HTTP ${statusCode}; server requested retry after ${Math.ceil(retryAfterMs / 1_000)} seconds`
|
||||
`HTTP ${statusError?.httpStatusCode}; server requested retry after ${Math.ceil(retryAfterMs / 1_000)} seconds`
|
||||
)
|
||||
}
|
||||
console.warn(
|
||||
@@ -111,7 +110,7 @@ export abstract class SpeechModelDownloadTransport extends SpeechModelHttpDownlo
|
||||
modelId,
|
||||
err
|
||||
)
|
||||
await sleepUnlessAborted(
|
||||
await delayUnlessAborted(
|
||||
retryAfterMs ??
|
||||
DOWNLOAD_RETRY_DELAYS_MS[
|
||||
Math.min(Math.max(0, noProgressStreak - 1), DOWNLOAD_RETRY_DELAYS_MS.length - 1)
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
TERMINAL_SCROLLBACK_REPLAY_BYTE_LIMIT,
|
||||
TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT
|
||||
} from '../shared/terminal-scrollback-limits'
|
||||
import { errorMessage } from '../shared/error-message'
|
||||
|
||||
const SNAPSHOT_DIR_NAME = 'terminal-scrollback'
|
||||
const REF_PREFIX = 'v1'
|
||||
@@ -156,9 +157,7 @@ export function writeTerminalScrollbackSnapshotSync(args: {
|
||||
}
|
||||
return ref
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[terminal-scrollback] Failed to write snapshot: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
console.warn(`[terminal-scrollback] Failed to write snapshot: ${errorMessage(err)}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -188,9 +187,7 @@ export async function writeTerminalScrollbackSnapshot(args: {
|
||||
renamed = true
|
||||
return ref
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[terminal-scrollback] Failed to write snapshot: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
console.warn(`[terminal-scrollback] Failed to write snapshot: ${errorMessage(err)}`)
|
||||
return null
|
||||
} finally {
|
||||
if (!renamed) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { mergeGitConfigEnvProtocol } from '../shared/git-credential-prompt-env'
|
||||
import { terminateRelaySubprocessTree } from './subprocess-tree-termination'
|
||||
import { RelayAgentProcessLifetime } from './relay-agent-process-lifetime'
|
||||
import { resolveLoginShellEnvironment } from '../main/startup/login-shell-environment'
|
||||
import { errorMessage } from '../shared/error-message'
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 60_000
|
||||
const MAX_TIMEOUT_MS = 5 * 60 * 1000
|
||||
@@ -234,7 +235,7 @@ export class AgentExecHandler {
|
||||
stderr: '',
|
||||
exitCode: null,
|
||||
timedOut: false,
|
||||
spawnError: error instanceof Error ? error.message : String(error)
|
||||
spawnError: errorMessage(error)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { open } from 'node:fs/promises'
|
||||
import { throwIfAiVaultScanCancelled } from '../main/ai-vault/ai-vault-scan-cancellation'
|
||||
import { BinarySessionTranscriptError } from '../main/ai-vault/remote-session-content-lines'
|
||||
import { BINARY_PROBE_BYTES, isBinaryBuffer } from './fs-handler-utils'
|
||||
import { BINARY_PROBE_BYTES, isBinaryBuffer } from '../shared/binary-buffer'
|
||||
import { readNodeFileWithinLimit } from '../shared/node-bounded-file-reader'
|
||||
|
||||
/** The same open handle supplies the probe and stream, including across renames. */
|
||||
|
||||
@@ -4,15 +4,14 @@ import { extname } from 'node:path'
|
||||
import type { RelayDispatcher, RequestContext } from './dispatcher'
|
||||
import { STREAM_ACK_WINDOW_CHUNKS, STREAM_CHUNK_SIZE } from './protocol'
|
||||
import type { RelayStreamRegistry } from './fs-stream-registry'
|
||||
import { reserveTerminalFrameSlot } from './fs-stream-terminal-frame-slots'
|
||||
import { BINARY_PROBE_BYTES, isBinaryBuffer } from '../shared/binary-buffer'
|
||||
import {
|
||||
BINARY_PROBE_BYTES,
|
||||
IMAGE_MIME_TYPES,
|
||||
MAX_PREVIEWABLE_BINARY_SIZE,
|
||||
MAX_TEXT_FILE_SIZE,
|
||||
isBinaryBuffer,
|
||||
isBinaryFilePrefix
|
||||
} from './fs-handler-utils'
|
||||
import { errorMessage as describeError } from '../shared/error-message'
|
||||
|
||||
export async function readRelayFileContent(filePath: string) {
|
||||
const stats = await stat(filePath)
|
||||
@@ -124,7 +123,7 @@ async function prepareRelayFileStream(
|
||||
|
||||
// Why: reserved before the fd opens so a refusal costs nothing, and released only
|
||||
// once the terminal frame settles — see reserveTerminalFrameSlot.
|
||||
const releaseTerminalFrameSlot = reserveTerminalFrameSlot(registry, context.clientId)
|
||||
const releaseTerminalFrameSlot = registry.reserveTerminalFrameSlot(context.clientId)
|
||||
let handle: FileHandle | undefined
|
||||
let streamId: number
|
||||
try {
|
||||
@@ -267,7 +266,7 @@ async function pumpChunks(
|
||||
} else {
|
||||
endReason = 'error'
|
||||
errorCode = code ?? 'ESTREAMREAD'
|
||||
errorMessage = err instanceof Error ? err.message : String(err)
|
||||
errorMessage = describeError(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,9 +309,7 @@ async function pumpChunks(
|
||||
process.stderr.write(`[relay] stream stale id=${streamId}\n`)
|
||||
}
|
||||
} catch (err) {
|
||||
process.stderr.write(
|
||||
`[relay] stream notify failed id=${streamId}: ${err instanceof Error ? err.message : String(err)}\n`
|
||||
)
|
||||
process.stderr.write(`[relay] stream notify failed id=${streamId}: ${describeError(err)}\n`)
|
||||
}
|
||||
} finally {
|
||||
// Why: the fd goes back first — a terminal frame that can never be delivered must not
|
||||
|
||||
@@ -4,9 +4,9 @@ import { chmod, open, realpath, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import type { FileHandle } from 'node:fs/promises'
|
||||
import { basename, dirname, extname, join } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { isBinaryBuffer } from '../shared/binary-buffer'
|
||||
import {
|
||||
IMAGE_MIME_TYPES,
|
||||
isBinaryBuffer,
|
||||
MAX_PREVIEWABLE_BINARY_SIZE,
|
||||
MAX_TEXT_FILE_SIZE
|
||||
} from './fs-handler-utils'
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
SEARCH_TIMEOUT_MS as SHARED_SEARCH_TIMEOUT_MS
|
||||
} from '../shared/text-search'
|
||||
import { IMAGE_FILE_MIME_TYPES } from '../shared/image-file-extensions'
|
||||
import { BINARY_PROBE_BYTES, isBinaryBuffer } from '../shared/binary-buffer'
|
||||
import type { SearchResult as SharedSearchResult } from '../shared/code-search-types'
|
||||
import {
|
||||
absorbPendingRipgrepSpawnError,
|
||||
@@ -44,7 +45,6 @@ export const MAX_TEXT_FILE_SIZE = 10 * 1024 * 1024
|
||||
// Reads above the legacy 16MB single-frame budget go through fs.readFileStream,
|
||||
// which chunks at STREAM_CHUNK_SIZE; see docs/relay-file-stream-design.md.
|
||||
export const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024
|
||||
export const BINARY_PROBE_BYTES = 8192
|
||||
export const SEARCH_TIMEOUT_MS = SHARED_SEARCH_TIMEOUT_MS
|
||||
export const DEFAULT_MAX_RESULTS = 2000
|
||||
|
||||
@@ -53,18 +53,6 @@ export const IMAGE_MIME_TYPES: Record<string, string> = {
|
||||
'.pdf': 'application/pdf'
|
||||
}
|
||||
|
||||
// ─── Binary detection ────────────────────────────────────────────────
|
||||
|
||||
export function isBinaryBuffer(buffer: Buffer): boolean {
|
||||
const len = Math.min(buffer.length, 8192)
|
||||
for (let i = 0; i < len; i++) {
|
||||
if (buffer[i] === 0) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export async function isBinaryFilePrefix(
|
||||
filePath: string,
|
||||
releaseHandle: (handle: Awaited<ReturnType<typeof open>>) => Promise<void> = (handle) =>
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import type { FileHandle } from 'node:fs/promises'
|
||||
import { settlesWithin } from './settles-within'
|
||||
import { MAX_CONCURRENT_STREAMS, RelayErrorCode, STREAM_ACK_STALL_RECHECK_MS } from './protocol'
|
||||
import { MAX_CONCURRENT_STREAMS, RelayErrorCode } from './protocol'
|
||||
import { RelayStreamAckWindow } from './relay-stream-ack-window'
|
||||
|
||||
type StreamEntry = {
|
||||
handle: FileHandle
|
||||
aborted: boolean
|
||||
/** Highest chunk seq the client acknowledged (in-order; -1 = none yet). */
|
||||
ackedThroughSeq: number
|
||||
/** Pumps parked on the ack credit window. Woken by acks, abort, release,
|
||||
* and a periodic stall recheck so a vanished client cannot strand a pump. */
|
||||
ackWaiters: Set<() => void>
|
||||
}
|
||||
type StreamEntry = { handle: FileHandle; ack: RelayStreamAckWindow }
|
||||
|
||||
// Why: a stalled but connected client can hold a terminal-frame slot open indefinitely.
|
||||
const OPERATION_SETTLE_DEADLINE_MS = 10_000
|
||||
@@ -29,6 +22,11 @@ export class RelayStreamRegistry {
|
||||
private closing = new Map<number, Promise<void>>()
|
||||
private closeFailures = new Map<number, unknown>()
|
||||
private operations = new Set<Promise<void>>()
|
||||
// Why: terminal frames ride the non-dropping control lane, which destroys the link at 256 frames / 1 MB.
|
||||
// Holding a per-client slot until the frame settles caps queued terminal frames at
|
||||
// MAX_CONCURRENT_STREAMS per client (the control queue is per client); each slot is a
|
||||
// registry operation, so shutdown waits for undelivered terminal frames too.
|
||||
private pendingTerminalFramesByClient = new Map<number, number>()
|
||||
|
||||
constructor(private readonly operationDeadlineMs = OPERATION_SETTLE_DEADLINE_MS) {}
|
||||
|
||||
@@ -49,6 +47,31 @@ export class RelayStreamRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/** Holds a per-client slot until a stream's terminal frame settles; see comment. */
|
||||
reserveTerminalFrameSlot(clientId: number): () => void {
|
||||
const pending = this.pendingTerminalFramesByClient.get(clientId) ?? 0
|
||||
if (pending >= MAX_CONCURRENT_STREAMS) {
|
||||
throw new TooManyStreamsError()
|
||||
}
|
||||
const finish = this.beginOperation()
|
||||
this.pendingTerminalFramesByClient.set(clientId, pending + 1)
|
||||
let released = false
|
||||
return () => {
|
||||
if (released) {
|
||||
return
|
||||
}
|
||||
released = true
|
||||
finish()
|
||||
const remaining = (this.pendingTerminalFramesByClient.get(clientId) ?? 1) - 1
|
||||
// Drop the entry at zero so a long-lived registry cannot accumulate one per detached client.
|
||||
if (remaining <= 0) {
|
||||
this.pendingTerminalFramesByClient.delete(clientId)
|
||||
return
|
||||
}
|
||||
this.pendingTerminalFramesByClient.set(clientId, remaining)
|
||||
}
|
||||
}
|
||||
|
||||
register(handle: FileHandle): number {
|
||||
if (this.disposed) {
|
||||
throw new Error('relay_file_stream_shutdown_fenced')
|
||||
@@ -66,25 +89,16 @@ export class RelayStreamRegistry {
|
||||
|
||||
private retainHandle(handle: FileHandle): number {
|
||||
const streamId = this.nextId++
|
||||
this.streams.set(streamId, {
|
||||
handle,
|
||||
aborted: false,
|
||||
ackedThroughSeq: -1,
|
||||
ackWaiters: new Set()
|
||||
})
|
||||
this.streams.set(streamId, { handle, ack: new RelayStreamAckWindow() })
|
||||
return streamId
|
||||
}
|
||||
|
||||
abort(streamId: number): void {
|
||||
const entry = this.streams.get(streamId)
|
||||
if (entry) {
|
||||
entry.aborted = true
|
||||
this.wakeAckWaiters(entry)
|
||||
}
|
||||
this.streams.get(streamId)?.ack.abort()
|
||||
}
|
||||
|
||||
isAborted(streamId: number): boolean {
|
||||
return this.streams.get(streamId)?.aborted ?? true
|
||||
return this.streams.get(streamId)?.ack.aborted ?? true
|
||||
}
|
||||
|
||||
get(streamId: number): StreamEntry | undefined {
|
||||
@@ -92,55 +106,24 @@ export class RelayStreamRegistry {
|
||||
}
|
||||
|
||||
recordAck(streamId: number, seq: number): void {
|
||||
const entry = this.streams.get(streamId)
|
||||
if (!entry || typeof seq !== 'number' || !Number.isFinite(seq)) {
|
||||
return
|
||||
}
|
||||
if (seq > entry.ackedThroughSeq) {
|
||||
entry.ackedThroughSeq = seq
|
||||
}
|
||||
this.wakeAckWaiters(entry)
|
||||
this.streams.get(streamId)?.ack.recordAck(seq)
|
||||
}
|
||||
|
||||
ackedThroughSeq(streamId: number): number {
|
||||
return this.streams.get(streamId)?.ackedThroughSeq ?? Number.MAX_SAFE_INTEGER
|
||||
return this.streams.get(streamId)?.ack.ackedThroughSeq ?? Number.MAX_SAFE_INTEGER
|
||||
}
|
||||
|
||||
/** Resolves on the next ack/abort/release for this stream, or after the
|
||||
* stall-recheck interval so callers can re-evaluate staleness. */
|
||||
waitForAck(streamId: number): Promise<void> {
|
||||
const entry = this.streams.get(streamId)
|
||||
if (!entry || entry.aborted) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
let settled = false
|
||||
const timer = setTimeout(() => finish(), STREAM_ACK_STALL_RECHECK_MS)
|
||||
timer.unref?.()
|
||||
const finish = (): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
entry.ackWaiters.delete(finish)
|
||||
resolve()
|
||||
}
|
||||
entry.ackWaiters.add(finish)
|
||||
})
|
||||
return this.streams.get(streamId)?.ack.wait() ?? Promise.resolve()
|
||||
}
|
||||
|
||||
/** Wake every parked pump (all streams) so it re-checks staleness — used
|
||||
* when a client detaches and its acks will never arrive. */
|
||||
wakeAllAckWaiters(): void {
|
||||
for (const entry of this.streams.values()) {
|
||||
this.wakeAckWaiters(entry)
|
||||
}
|
||||
}
|
||||
|
||||
private wakeAckWaiters(entry: StreamEntry): void {
|
||||
for (const waiter of Array.from(entry.ackWaiters)) {
|
||||
waiter()
|
||||
entry.ack.wake()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { MAX_CONCURRENT_STREAMS } from './protocol'
|
||||
import { TooManyStreamsError, type RelayStreamRegistry } from './fs-stream-registry'
|
||||
|
||||
/**
|
||||
* Terminal frames (fs.streamEnd/fs.streamError) ride the control lane, which does not
|
||||
* drop on overflow — it destroys the link at 256 queued frames / 1 MB. A stream's
|
||||
* registry slot is gone the moment its last chunk is read, so on a socket that is not
|
||||
* draining, back-to-back reads could stack one undelivered terminal frame each until
|
||||
* that budget blew. Holding the slot until the frame settles keeps the number of queued
|
||||
* terminal frames at MAX_CONCURRENT_STREAMS, far below the killing threshold; the
|
||||
* overflow now costs one refused read (TooManyStreams, which clients already handle)
|
||||
* instead of the whole connection.
|
||||
*
|
||||
* Counted per client, because the control queue this protects is per client: one peer whose
|
||||
* socket stopped draining must not refuse reads for every other peer on the same relay.
|
||||
* Counted independently of released file descriptors, and each slot is a registry operation
|
||||
* so shutdown waits for undelivered terminal frames too.
|
||||
*/
|
||||
const pendingTerminalFramesByClient = new WeakMap<RelayStreamRegistry, Map<number, number>>()
|
||||
|
||||
export function reserveTerminalFrameSlot(
|
||||
registry: RelayStreamRegistry,
|
||||
clientId: number
|
||||
): () => void {
|
||||
let byClient = pendingTerminalFramesByClient.get(registry)
|
||||
if (!byClient) {
|
||||
byClient = new Map()
|
||||
pendingTerminalFramesByClient.set(registry, byClient)
|
||||
}
|
||||
const pending = byClient.get(clientId) ?? 0
|
||||
if (pending >= MAX_CONCURRENT_STREAMS) {
|
||||
throw new TooManyStreamsError()
|
||||
}
|
||||
const finish = registry.beginOperation()
|
||||
byClient.set(clientId, pending + 1)
|
||||
let released = false
|
||||
return () => {
|
||||
if (released) {
|
||||
return
|
||||
}
|
||||
released = true
|
||||
finish()
|
||||
const remaining = (byClient.get(clientId) ?? 1) - 1
|
||||
// Drop the entry at zero so a long-lived registry cannot accumulate one per detached client.
|
||||
if (remaining <= 0) {
|
||||
byClient.delete(clientId)
|
||||
return
|
||||
}
|
||||
byClient.set(clientId, remaining)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Streams large RPC responses onto the bulk lane in chunks instead of one
|
||||
// JSON-RPC frame, so a big reply cannot head-of-line-block interactive pty.data
|
||||
// echo on the shared SSH channel. Mirrors the fs read-stream credit-window
|
||||
// pattern (see fs-handler-file-read.ts) but the payload is an in-memory
|
||||
// echo on the shared SSH channel. Shares RelayStreamAckWindow with the fs
|
||||
// read stream (see fs-handler-file-read.ts) but the payload is an in-memory
|
||||
// serialized string rather than a file handle.
|
||||
//
|
||||
// ONE REGISTRY PER RELAY. The `git.*` method names below are the shipped wire
|
||||
@@ -18,18 +18,13 @@ import {
|
||||
GIT_RESPONSE_CHUNK_SIZE,
|
||||
GIT_RESPONSE_STREAM_THRESHOLD,
|
||||
STREAM_ACK_WINDOW_CHUNKS,
|
||||
STREAM_ACK_STALL_RECHECK_MS,
|
||||
type GitResponseStreamMarker
|
||||
} from './protocol'
|
||||
import { settlesWithin } from './settles-within'
|
||||
import { RelayStreamAckWindow } from './relay-stream-ack-window'
|
||||
import { errorMessage } from '../shared/error-message'
|
||||
|
||||
type GitResponseStreamEntry = {
|
||||
ownerClientId: number
|
||||
aborted: boolean
|
||||
/** Highest chunk seq the client acknowledged (in-order; -1 = none yet). */
|
||||
ackedThroughSeq: number
|
||||
ackWaiters: Set<() => void>
|
||||
}
|
||||
type GitResponseStreamEntry = { ownerClientId: number; ack: RelayStreamAckWindow }
|
||||
|
||||
/** Serialized git responses are chunked as base64 so multi-byte UTF-8
|
||||
* sequences never split across a chunk boundary (the client concatenates the
|
||||
@@ -58,36 +53,22 @@ export class GitResponseStreamRegistry {
|
||||
throw new Error('relay_response_stream_shutdown_fenced')
|
||||
}
|
||||
const streamId = this.nextId++
|
||||
this.streams.set(streamId, {
|
||||
ownerClientId,
|
||||
aborted: false,
|
||||
ackedThroughSeq: -1,
|
||||
ackWaiters: new Set()
|
||||
})
|
||||
this.streams.set(streamId, { ownerClientId, ack: new RelayStreamAckWindow() })
|
||||
return streamId
|
||||
}
|
||||
|
||||
recordAck(streamId: number, seq: number, clientId: number): void {
|
||||
const entry = this.streams.get(streamId)
|
||||
if (
|
||||
!entry ||
|
||||
entry.ownerClientId !== clientId ||
|
||||
typeof seq !== 'number' ||
|
||||
!Number.isFinite(seq)
|
||||
) {
|
||||
if (!entry || entry.ownerClientId !== clientId) {
|
||||
return
|
||||
}
|
||||
if (seq > entry.ackedThroughSeq) {
|
||||
entry.ackedThroughSeq = seq
|
||||
}
|
||||
this.wake(entry)
|
||||
entry.ack.recordAck(seq)
|
||||
}
|
||||
|
||||
abort(streamId: number, clientId: number): void {
|
||||
const entry = this.streams.get(streamId)
|
||||
if (entry?.ownerClientId === clientId) {
|
||||
entry.aborted = true
|
||||
this.wake(entry)
|
||||
entry.ack.abort()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,38 +76,10 @@ export class GitResponseStreamRegistry {
|
||||
* detaches and its acks will never arrive. */
|
||||
wakeAll(): void {
|
||||
for (const entry of this.streams.values()) {
|
||||
this.wake(entry)
|
||||
entry.ack.wake()
|
||||
}
|
||||
}
|
||||
|
||||
private wake(entry: GitResponseStreamEntry): void {
|
||||
for (const waiter of Array.from(entry.ackWaiters)) {
|
||||
waiter()
|
||||
}
|
||||
}
|
||||
|
||||
private waitForAck(streamId: number): Promise<void> {
|
||||
const entry = this.streams.get(streamId)
|
||||
if (!entry || entry.aborted) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
let settled = false
|
||||
const finish = (): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
entry.ackWaiters.delete(finish)
|
||||
resolve()
|
||||
}
|
||||
const timer = setTimeout(finish, STREAM_ACK_STALL_RECHECK_MS)
|
||||
timer.unref?.()
|
||||
entry.ackWaiters.add(finish)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a stream for `payload`, kick off the bulk-lane pump on a later
|
||||
* task (so the sentinel response reaches the client first), and return the
|
||||
@@ -188,24 +141,24 @@ export class GitResponseStreamRegistry {
|
||||
endReason = 'stale'
|
||||
break
|
||||
}
|
||||
if (entry.aborted) {
|
||||
if (entry.ack.aborted) {
|
||||
endReason = 'aborted'
|
||||
break
|
||||
}
|
||||
// Why: credit window — the client acks each chunk, bounding how many
|
||||
// bulk bytes a keystroke echo can queue behind on the shared channel.
|
||||
while (
|
||||
seq - entry.ackedThroughSeq > STREAM_ACK_WINDOW_CHUNKS &&
|
||||
seq - entry.ack.ackedThroughSeq > STREAM_ACK_WINDOW_CHUNKS &&
|
||||
!context.isStale() &&
|
||||
!entry.aborted
|
||||
!entry.ack.aborted
|
||||
) {
|
||||
await this.waitForAck(streamId)
|
||||
await entry.ack.wait()
|
||||
}
|
||||
if (context.isStale()) {
|
||||
endReason = 'stale'
|
||||
break
|
||||
}
|
||||
if (entry.aborted) {
|
||||
if (entry.ack.aborted) {
|
||||
endReason = 'aborted'
|
||||
break
|
||||
}
|
||||
@@ -222,17 +175,17 @@ export class GitResponseStreamRegistry {
|
||||
chunks[seq] = ''
|
||||
}
|
||||
// Why: disposal may abort while the final chunk write is in flight.
|
||||
if (endReason === 'end' && !entry.aborted && !context.isStale()) {
|
||||
if (endReason === 'end' && !entry.ack.aborted && !context.isStale()) {
|
||||
await dispatcher.notifyBulk('git.responseEnd', { streamId }, { clientId })
|
||||
}
|
||||
} catch (err) {
|
||||
if (!context.isStale() && !entry.aborted) {
|
||||
if (!context.isStale() && !entry.ack.aborted) {
|
||||
try {
|
||||
await dispatcher.notifyBulk(
|
||||
'git.responseError',
|
||||
{
|
||||
streamId,
|
||||
message: err instanceof Error ? err.message : String(err)
|
||||
message: errorMessage(err)
|
||||
},
|
||||
{ clientId }
|
||||
)
|
||||
@@ -250,8 +203,7 @@ export class GitResponseStreamRegistry {
|
||||
disposeAll(): void {
|
||||
this.disposed = true
|
||||
for (const entry of this.streams.values()) {
|
||||
entry.aborted = true
|
||||
this.wake(entry)
|
||||
entry.ack.abort()
|
||||
}
|
||||
this.streams.clear()
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from './relay-endpoint-credential-publication'
|
||||
import { SKILL_RELAY_CAPABILITIES } from './skill-install-handler'
|
||||
import { publishRelayPid } from './relay-pid-publication'
|
||||
import { errorMessage } from '../shared/error-message'
|
||||
|
||||
export async function runRelayDaemon(options: RelayLaunchOptions): Promise<void> {
|
||||
if (options.detached && options.logFile) {
|
||||
@@ -30,9 +31,7 @@ export async function runRelayDaemon(options: RelayLaunchOptions): Promise<void>
|
||||
} catch (reapError) {
|
||||
// Why log rather than swallow: exit must still win, but this line is the only
|
||||
// forensic trace a crashed remote daemon leaves behind for an orphaned shell.
|
||||
relayLogLine(
|
||||
`[relay] Fatal PTY reap failed: ${reapError instanceof Error ? reapError.message : String(reapError)}`
|
||||
)
|
||||
relayLogLine(`[relay] Fatal PTY reap failed: ${errorMessage(reapError)}`)
|
||||
}
|
||||
socketOwnership.cleanup()
|
||||
process.exit(1)
|
||||
@@ -112,9 +111,7 @@ export async function runRelayDaemon(options: RelayLaunchOptions): Promise<void>
|
||||
reconnectListener.setEndpointCredential(publishRelayEndpointCredential(options.credentialFile))
|
||||
agentHooks.publishEndpointFile()
|
||||
} catch (error) {
|
||||
relayLogLine(
|
||||
`[relay] Startup failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
relayLogLine(`[relay] Startup failed: ${errorMessage(error)}`)
|
||||
process.exit(1)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { relayLogLine } from './relay-diagnostic-log'
|
||||
import { SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD } from '../shared/ssh-types'
|
||||
import type { RelayDispatcher } from './dispatcher'
|
||||
import { errorMessage } from '../shared/error-message'
|
||||
|
||||
type RelayGraceLifecycleOptions = {
|
||||
dispatcher: RelayDispatcher
|
||||
@@ -139,9 +140,7 @@ export class RelayGraceLifecycle {
|
||||
.catch((error) => {
|
||||
this.shutdownInFlight = false
|
||||
this.options.reopenOwnedProcesses()
|
||||
relayLogLine(
|
||||
`[relay] Shutdown deferred: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
relayLogLine(`[relay] Shutdown deferred: ${errorMessage(error)}`)
|
||||
if (this.options.readSocketClientCount() === 0) {
|
||||
this.start('shutdown deferred', { retryDeferredShutdown: true })
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { RelayPtySourcePublication } from './relay-pty-source-publication'
|
||||
import { SkillInstallHandler } from './skill-install-handler'
|
||||
import { relayLogLine } from './relay-diagnostic-log'
|
||||
import { remoteCliRequestTimeoutMs } from './remote-cli-timeout'
|
||||
import { errorMessage } from '../shared/error-message'
|
||||
|
||||
export class RelayRuntimeServices {
|
||||
readonly ptyHandler: PtyHandler
|
||||
@@ -36,7 +37,6 @@ export class RelayRuntimeServices {
|
||||
private readonly responseStreams: GitResponseStreamRegistry
|
||||
private readonly aiVaultService: ReturnType<typeof createRelayAiVaultService> | null
|
||||
private readonly sessionSearch: { dispose(): void } | null
|
||||
private readonly registeredHandlers: readonly unknown[]
|
||||
|
||||
constructor(
|
||||
readonly dispatcher: RelayDispatcher,
|
||||
@@ -76,12 +76,12 @@ export class RelayRuntimeServices {
|
||||
this.ptyHandler.shutdownForWorktreePath(rootPath)
|
||||
)
|
||||
this.gitHandler = new GitHandler(dispatcher, context, watchRegistry, responseStreams)
|
||||
const preflightHandler = new PreflightHandler(dispatcher)
|
||||
new PreflightHandler(dispatcher)
|
||||
this.skillInstallHandler = new SkillInstallHandler(dispatcher)
|
||||
const externalAutomationsHandler = new ExternalAutomationsHandler(dispatcher)
|
||||
const portScanHandler = new PortScanHandler(dispatcher)
|
||||
new ExternalAutomationsHandler(dispatcher)
|
||||
new PortScanHandler(dispatcher)
|
||||
this.agentExecHandler = new AgentExecHandler(dispatcher)
|
||||
const workspaceSessionHandler = new WorkspaceSessionHandler(dispatcher)
|
||||
new WorkspaceSessionHandler(dispatcher)
|
||||
const relayPlatform = parseUnameToRelayPlatform(process.platform, process.arch)
|
||||
const hostPlatform = relayPlatform ? getRemoteHostPlatform(relayPlatform) : undefined
|
||||
this.aiVaultService = hostPlatform ? createRelayAiVaultService(homedir(), hostPlatform) : null
|
||||
@@ -96,23 +96,9 @@ export class RelayRuntimeServices {
|
||||
dataRoot: join(homedir(), RELAY_REMOTE_DIR),
|
||||
roots: { executionHostId: LOCAL_EXECUTION_HOST_ID },
|
||||
settings: DEFAULT_AI_VAULT_SEARCH_SETTINGS,
|
||||
onError: (error) =>
|
||||
relayLogLine(
|
||||
`[relay] session search: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
onError: (error) => relayLogLine(`[relay] session search: ${errorMessage(error)}`)
|
||||
})
|
||||
this.registeredHandlers = [
|
||||
preflightHandler,
|
||||
this.skillInstallHandler,
|
||||
externalAutomationsHandler,
|
||||
portScanHandler,
|
||||
this.agentExecHandler,
|
||||
workspaceSessionHandler,
|
||||
new AiVaultHandler(dispatcher, {
|
||||
hostPlatform,
|
||||
service: this.aiVaultService ?? undefined
|
||||
})
|
||||
]
|
||||
new AiVaultHandler(dispatcher, { hostPlatform, service: this.aiVaultService ?? undefined })
|
||||
|
||||
registerRelayPluginHostCallHandlers(
|
||||
dispatcher,
|
||||
@@ -146,14 +132,10 @@ export class RelayRuntimeServices {
|
||||
*/
|
||||
async disposeExitOnlyServices(): Promise<void> {
|
||||
await this.skillInstallHandler.dispose().catch((error) => {
|
||||
relayLogLine(
|
||||
`[relay] Skill upload cleanup failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
relayLogLine(`[relay] Skill upload cleanup failed: ${errorMessage(error)}`)
|
||||
})
|
||||
await this.aiVaultService?.dispose().catch((error) => {
|
||||
relayLogLine(
|
||||
`[relay] AI Vault sidecar shutdown failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
relayLogLine(`[relay] AI Vault sidecar shutdown failed: ${errorMessage(error)}`)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -167,26 +149,25 @@ export class RelayRuntimeServices {
|
||||
this.sessionSearch?.dispose()
|
||||
this.fsHandler.dispose()
|
||||
this.gitHandler.dispose()
|
||||
void this.registeredHandlers
|
||||
}
|
||||
|
||||
private registerSessionHandlers(context: RelayContext): void {
|
||||
this.dispatcher.onNotification('session.registerRoot', (params) => {
|
||||
const rootPath = params.rootPath as string
|
||||
if (rootPath) {
|
||||
context.registerRoot(rootPath)
|
||||
const registerRoot = (params: Record<string, unknown>): void => {
|
||||
if (typeof params.rootPath === 'string' && params.rootPath) {
|
||||
context.registerRoot(params.rootPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
this.dispatcher.onNotification('session.registerRoot', (params) => registerRoot(params))
|
||||
this.dispatcher.onRequest('session.registerRoot', async (params) => {
|
||||
const rootPath = params.rootPath as string
|
||||
if (rootPath) {
|
||||
context.registerRoot(rootPath)
|
||||
}
|
||||
registerRoot(params)
|
||||
return { ok: true }
|
||||
})
|
||||
this.dispatcher.onRequest('session.resolveHome', async (params) => ({
|
||||
resolvedPath: expandTilde(params.path as string)
|
||||
}))
|
||||
this.dispatcher.onRequest('session.resolveHome', async (params) => {
|
||||
if (typeof params.path !== 'string') {
|
||||
throw new Error('session.resolveHome: path must be a string')
|
||||
}
|
||||
return { resolvedPath: expandTilde(params.path) }
|
||||
})
|
||||
}
|
||||
|
||||
private registerRemoteCliRoutes(): void {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { STREAM_ACK_STALL_RECHECK_MS } from './protocol'
|
||||
|
||||
/** Waiters wake on ack, abort, or a stall recheck so a vanished client cannot strand a pump. */
|
||||
export class RelayStreamAckWindow {
|
||||
aborted = false
|
||||
/** Highest chunk seq the client acknowledged (in-order; -1 = none yet). */
|
||||
ackedThroughSeq = -1
|
||||
private readonly waiters = new Set<() => void>()
|
||||
|
||||
recordAck(seq: number): void {
|
||||
if (typeof seq !== 'number' || !Number.isFinite(seq)) {
|
||||
return
|
||||
}
|
||||
if (seq > this.ackedThroughSeq) {
|
||||
this.ackedThroughSeq = seq
|
||||
}
|
||||
this.wake()
|
||||
}
|
||||
|
||||
abort(): void {
|
||||
this.aborted = true
|
||||
this.wake()
|
||||
}
|
||||
|
||||
wake(): void {
|
||||
for (const waiter of Array.from(this.waiters)) {
|
||||
waiter()
|
||||
}
|
||||
}
|
||||
|
||||
wait(): Promise<void> {
|
||||
if (this.aborted) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise<void>((resolve) => {
|
||||
let settled = false
|
||||
const finish = (): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
this.waiters.delete(finish)
|
||||
resolve()
|
||||
}
|
||||
const timer = setTimeout(finish, STREAM_ACK_STALL_RECHECK_MS)
|
||||
timer.unref?.()
|
||||
this.waiters.add(finish)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { isWatcherProcessFailure } from '../main/ipc/parcel-watcher-process-fail
|
||||
import type { PromiseSettlementWaiters } from '../shared/promise-settlement-waiters'
|
||||
import type { RequestContext } from './dispatcher'
|
||||
import type { RelayWatcherTeardownState } from './relay-watcher-teardown-tracker'
|
||||
import { errorMessage } from '../shared/error-message'
|
||||
|
||||
export async function startInitialRelayWatch(
|
||||
state: RelayWatcherTeardownState,
|
||||
@@ -42,7 +43,7 @@ export async function awaitRelayWatcherSetupForClient(
|
||||
if (expectedAbort) {
|
||||
return
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const message = errorMessage(error)
|
||||
process.stderr.write(`[relay] File watcher not available for ${state.rootPath}: ${message}\n`)
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
export function delayUnlessAborted(ms: number, signal: AbortSignal): Promise<void> {
|
||||
if (signal.aborted) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const onAbort = (): void => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
resolve()
|
||||
}, ms)
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
})
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
// A NUL byte in the first chunk is git's own heuristic for "this is binary".
|
||||
const BINARY_SNIFF_BYTES = 8192
|
||||
export const BINARY_PROBE_BYTES = 8192
|
||||
|
||||
export function isBinaryBuffer(buffer: Buffer): boolean {
|
||||
const len = Math.min(buffer.length, BINARY_SNIFF_BYTES)
|
||||
const len = Math.min(buffer.length, BINARY_PROBE_BYTES)
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
if (buffer[i] === 0) {
|
||||
return true
|
||||
|
||||
@@ -79,8 +79,9 @@ function publishRow(host: HostRowModules, mainAgent: Record<string, unknown>): R
|
||||
stateStartedAt: NOW - 60_000,
|
||||
mainAgent
|
||||
}
|
||||
const summaries = new Map([[WORKTREE_ID, summary]])
|
||||
host.attachRuntimeWorktreeAgentRows({
|
||||
summaries: new Map([[WORKTREE_ID, summary]]),
|
||||
summaries,
|
||||
pathIndex: { byPath: new Map(), byRealPath: new Map() },
|
||||
missingWorktreeIds: new Set(),
|
||||
workingTerminalEvidenceByWorktreeId: new Map(),
|
||||
@@ -94,8 +95,8 @@ function publishRow(host: HostRowModules, mainAgent: Record<string, unknown>): R
|
||||
}
|
||||
}),
|
||||
orchestrationByPaneKey: null,
|
||||
getSummary: (map: Map<string, unknown>, _paths: unknown, _missing: unknown, id: string) =>
|
||||
map.get(id) ?? null
|
||||
// Old builds pass (summaries, pathIndex, missingIds, id); newer ones pass (id). The id is last in both.
|
||||
getSummary: (...args: unknown[]) => summaries.get(String(args.at(-1))) ?? null
|
||||
})
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: JSON.parse of the rows the host just attached, which are plain objects.
|
||||
const [row] = JSON.parse(JSON.stringify(summary.agents)) as Row[]
|
||||
|
||||
@@ -97,8 +97,9 @@ function publishRows(host: HostRowModules): Record<string, AgentRow> {
|
||||
hasHostSidebarActivity: false,
|
||||
agents: []
|
||||
}
|
||||
const summaries = new Map([[WORKTREE_ID, summary]])
|
||||
host.attachRuntimeWorktreeAgentRows({
|
||||
summaries: new Map([[WORKTREE_ID, summary]]),
|
||||
summaries,
|
||||
pathIndex: { byPath: new Map(), byRealPath: new Map() },
|
||||
missingWorktreeIds: new Set(),
|
||||
workingTerminalEvidenceByWorktreeId: new Map(),
|
||||
@@ -112,8 +113,8 @@ function publishRows(host: HostRowModules): Record<string, AgentRow> {
|
||||
}
|
||||
}),
|
||||
orchestrationByPaneKey: null,
|
||||
getSummary: (map: Map<string, unknown>, _paths: unknown, _missing: unknown, id: string) =>
|
||||
map.get(id) ?? null
|
||||
// Old builds pass (summaries, pathIndex, missingIds, id); newer ones pass (id). The id is last in both.
|
||||
getSummary: (...args: unknown[]) => summaries.get(String(args.at(-1))) ?? null
|
||||
})
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: JSON.parse of the rows the host just attached, which are plain objects keyed by paneKey.
|
||||
const rows = JSON.parse(JSON.stringify(summary.agents)) as AgentRow[]
|
||||
|
||||
Reference in New Issue
Block a user