mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
fix(ci): split the structured session-tabs sync and give static analysis mobile types
The local structured session-tabs sync module outgrew the 300-line cap once it took on generation fencing, so split it along its real seams instead of raising the cap: the generation/cursor fence, snapshot projection, snapshot apply, inventory refresh, and the subscription loop. The original path stays as a barrel so no importer moves. Repoint the host-session-mirror settle census at the apply module, which owns two receipts now — the snapshot it mirrors in, and the toggle-off teardown that retracts what it published. The teardown receipt is named rather than anonymous so the pin says which direction it settles. The changed-code quality gate lints mobile files and resolves their types from mobile/node_modules, but mobile is a separate pnpm project that the root install never populates, so every mobile type degraded to an `error` type and the gate reported phantom findings. Install mobile dependencies in static analysis when the diff touches mobile, gated on a new classifier output.
This commit is contained in:
@@ -28,6 +28,7 @@ jobs:
|
||||
outputs:
|
||||
should_run: ${{ steps.filter.outputs.should_run }}
|
||||
native_cache_changed: ${{ steps.filter.outputs.native_cache_changed }}
|
||||
mobile_dependencies: ${{ steps.filter.outputs.mobile_dependencies }}
|
||||
static_analysis: ${{ steps.filter.outputs.static_analysis }}
|
||||
typecheck: ${{ steps.filter.outputs.typecheck }}
|
||||
git_compatibility: ${{ steps.filter.outputs.git_compatibility }}
|
||||
@@ -95,6 +96,15 @@ jobs:
|
||||
- name: Enforce type-aware code-quality baseline
|
||||
run: pnpm run audit:code-quality:type-aware
|
||||
|
||||
# Why: the changed-code gate lints mobile files too, and its type-aware pass
|
||||
# resolves types from mobile/node_modules. Mobile is a separate pnpm project,
|
||||
# so the root install above leaves it empty and every mobile type degrades to
|
||||
# an `error` type — reported as phantom findings against the changed lines.
|
||||
- name: Install mobile dependencies
|
||||
if: needs.code_paths.outputs.mobile_dependencies == 'true'
|
||||
working-directory: mobile
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Enforce changed-code quality
|
||||
run: pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}"
|
||||
|
||||
|
||||
@@ -261,6 +261,14 @@ export function shouldRunPrChecks(changedFiles) {
|
||||
return changedFiles.some((file) => !isDocsOnlyPath(file) && !isDesktopIrrelevantPath(file))
|
||||
}
|
||||
|
||||
export function needsMobileDependencies(changedFiles) {
|
||||
// Why: static analysis lints CHANGED files, mobile ones included, and its
|
||||
// type-aware pass resolves types from mobile/node_modules. Mobile is a
|
||||
// separate pnpm project, so without this the root-only install leaves every
|
||||
// mobile type an `error` type and the gate reports phantom findings.
|
||||
return changedFiles.length === 0 || changedFiles.some((file) => file.startsWith('mobile/'))
|
||||
}
|
||||
|
||||
export function classifyPrJobs(changedFiles) {
|
||||
const emptyDiff = changedFiles.length === 0
|
||||
const shouldRun = shouldRunPrChecks(changedFiles)
|
||||
@@ -274,6 +282,7 @@ export function classifyPrJobs(changedFiles) {
|
||||
return {
|
||||
should_run: shouldRun,
|
||||
native_cache_changed: shouldRun && (emptyDiff || changedFiles.some(isNativeCacheInputPath)),
|
||||
mobile_dependencies: needsMobileDependencies(changedFiles),
|
||||
...jobs
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,6 +305,20 @@ describe('per-job path classification', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Why: static analysis lints changed mobile files with a type-aware pass, and
|
||||
// mobile is a separate pnpm project. Without its node_modules every mobile type
|
||||
// resolves to an `error` type and the changed-code gate fails on phantom
|
||||
// findings, which is exactly how a react-test-renderer union broke a PR.
|
||||
it('installs mobile dependencies exactly when mobile files change', () => {
|
||||
expect(classifyPrJobs([]).mobile_dependencies).toBe(true)
|
||||
expect(classifyPrJobs(['README.md']).mobile_dependencies).toBe(false)
|
||||
expect(classifyPrJobs(['src/main/index.ts']).mobile_dependencies).toBe(false)
|
||||
expect(
|
||||
classifyPrJobs(['src/main/index.ts', 'mobile/src/session/a.test.ts']).mobile_dependencies
|
||||
).toBe(true)
|
||||
expect(classifyPrJobs(['mobile/package.json']).mobile_dependencies).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps unit-test-only diffs out of packaging', () => {
|
||||
expectClassification(['src/main/git/git-status.test.ts'], {
|
||||
git_compatibility: true
|
||||
@@ -343,6 +357,20 @@ describe('PR Checks skip wiring', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('gives static analysis the mobile types its type-aware pass resolves', () => {
|
||||
expect(prWorkflow.jobs.code_paths.outputs.mobile_dependencies).toBe(
|
||||
'${{ steps.filter.outputs.mobile_dependencies }}'
|
||||
)
|
||||
const steps = prWorkflow.jobs.static_analysis.steps
|
||||
const install = steps.findIndex((step) => step.name === 'Install mobile dependencies')
|
||||
const gate = steps.findIndex((step) => step.name === 'Enforce changed-code quality')
|
||||
expect(install).toBeGreaterThan(-1)
|
||||
expect(install).toBeLessThan(gate)
|
||||
expect(steps[install].if).toBe("needs.code_paths.outputs.mobile_dependencies == 'true'")
|
||||
expect(steps[install]['working-directory']).toBe('mobile')
|
||||
expect(steps[install].run).toContain('--frozen-lockfile')
|
||||
})
|
||||
|
||||
it('keeps the cheap root-directory guard on docs-only PRs', () => {
|
||||
expect(prWorkflow.jobs.root_directory_guard.if).toBeUndefined()
|
||||
expect(prWorkflow.jobs.root_directory_guard.needs).toBeUndefined()
|
||||
|
||||
@@ -150,8 +150,9 @@ describe('host-session-mirror settle census', () => {
|
||||
'runtime/web-session-tabs-sync/visibility-resume-repair.ts': 1,
|
||||
// The eager post-create session.tabs.list refresh.
|
||||
'runtime/web-runtime-session-snapshot.ts': 1,
|
||||
// The local structured-session inventory/subscription frame.
|
||||
'runtime/local-structured-session-tabs-sync.ts': 1
|
||||
// The local structured-session mirror owns two: the inventory/subscription
|
||||
// frame, and the toggle-off teardown that retracts the tabs it published.
|
||||
'runtime/local-structured-session-tabs-sync/snapshot-apply.ts': 2
|
||||
})
|
||||
})
|
||||
|
||||
@@ -196,14 +197,19 @@ describe('host-session-mirror settle census', () => {
|
||||
// Hydration and mirror receipts remain pinned by their extracted owners:
|
||||
// the global singular frame owns two hydration completions and the global
|
||||
// inventory frame one, initial loading owns one, active subscription owns
|
||||
// two mirror settles, and visibility resume repair owns one.
|
||||
// two mirror settles, and visibility resume repair owns one. The local
|
||||
// structured-session apply module owns one settle per direction: the
|
||||
// snapshot it mirrors in, and the teardown that retracts it.
|
||||
'runtime/web-session-tabs-sync/active-session-subscription.ts': { settle: 2 },
|
||||
'runtime/web-session-tabs-sync/global-session-events.ts': { settleHydration: 2 },
|
||||
'runtime/web-session-tabs-sync/global-session-inventory-event.ts': { settleHydration: 1 },
|
||||
'runtime/web-session-tabs-sync/load-initial.ts': { settleHydration: 1 },
|
||||
'runtime/web-session-tabs-sync/visibility-resume-repair.ts': { settle: 1 },
|
||||
'runtime/web-runtime-session-snapshot.ts': { settleMirror: 1 },
|
||||
'runtime/local-structured-session-tabs-sync.ts': { settleStructuredSessionMirror: 1 }
|
||||
'runtime/local-structured-session-tabs-sync/snapshot-apply.ts': {
|
||||
settleStructuredSessionClear: 1,
|
||||
settleStructuredSessionMirror: 1
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,309 +1,22 @@
|
||||
import { useEffect } from 'react'
|
||||
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
|
||||
import { useAppStore } from '../store'
|
||||
import type { WorktreeRuntimeOwnerState } from '../lib/worktree-runtime-owner'
|
||||
import { getExecutionHostIdForWorktree } from '../lib/worktree-runtime-owner'
|
||||
import { applyWebSessionTabsSnapshot, applyWebSessionTabsStorePatch } from './web-session-tabs-sync'
|
||||
import type { WebSessionTabsSyncState } from './web-session-tabs-sync'
|
||||
import {
|
||||
noteRetiredValue,
|
||||
sameSessionTabsPublicationLineage
|
||||
} from './web-session-tabs-sync/publisher-identity-fences'
|
||||
import type { SessionTabsPublicationEpochHistory } from './web-session-tabs-sync/state'
|
||||
import { refreshLocalRuntimeCapabilities } from './local-runtime-capabilities'
|
||||
import {
|
||||
knownStructuredSessionWorktreeIds,
|
||||
removeStructuredSessionTabsForVersions,
|
||||
type StructuredSessionTabPublicationVersion
|
||||
} from './local-structured-session-tab-retirement'
|
||||
import { clearLocalStructuredSessionTabs } from './local-structured-session-tabs-sync/snapshot-apply'
|
||||
import { startLocalStructuredSessionTabsSync } from './local-structured-session-tabs-sync/subscription'
|
||||
|
||||
export const LOCAL_STRUCTURED_SESSION_OWNER = 'local-structured-session'
|
||||
let localStructuredSessionTabsRestorePromise: Promise<void> | null = null
|
||||
let localStructuredSessionSyncGeneration = 0
|
||||
const localStructuredSessionVersionByWorktree = new Map<
|
||||
string,
|
||||
StructuredSessionTabPublicationVersion
|
||||
>()
|
||||
const localStructuredSessionEpochHistoryByWorktree = new Map<
|
||||
string,
|
||||
SessionTabsPublicationEpochHistory
|
||||
>()
|
||||
|
||||
export function resetLocalStructuredSessionVersionForTests(): void {
|
||||
localStructuredSessionSyncGeneration += 1
|
||||
localStructuredSessionVersionByWorktree.clear()
|
||||
localStructuredSessionEpochHistoryByWorktree.clear()
|
||||
}
|
||||
|
||||
type SessionTabsEvent =
|
||||
| (RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' })
|
||||
| { type: 'snapshots'; snapshots: RuntimeMobileSessionTabsResult[] }
|
||||
| { type: 'end' }
|
||||
|
||||
export function projectLocalStructuredSessionTabs(
|
||||
snapshot: RuntimeMobileSessionTabsResult
|
||||
): RuntimeMobileSessionTabsResult {
|
||||
const structuredIds = new Set(
|
||||
snapshot.tabs.filter((tab) => tab.type === 'agent-session').map((tab) => tab.id)
|
||||
)
|
||||
const visibleHostTabIds = structuredIds
|
||||
const visibleIds = structuredIds
|
||||
const projectedTabGroups = snapshot.tabGroups
|
||||
?.map((group) => ({
|
||||
...group,
|
||||
tabOrder: group.tabOrder.filter((id) => visibleHostTabIds.has(id)),
|
||||
activeTabId:
|
||||
group.activeTabId && visibleHostTabIds.has(group.activeTabId) ? group.activeTabId : null,
|
||||
recentTabIds: group.recentTabIds?.filter((id) => visibleHostTabIds.has(id))
|
||||
}))
|
||||
.filter((group) => group.tabOrder.length > 0)
|
||||
|
||||
return {
|
||||
...snapshot,
|
||||
activeTabId: visibleIds.has(snapshot.activeTabId ?? '') ? snapshot.activeTabId : null,
|
||||
activeTabType:
|
||||
snapshot.activeTabId && visibleIds.has(snapshot.activeTabId) ? snapshot.activeTabType : null,
|
||||
activeGroupId:
|
||||
snapshot.activeGroupId &&
|
||||
projectedTabGroups?.some((group) => group.id === snapshot.activeGroupId)
|
||||
? snapshot.activeGroupId
|
||||
: (projectedTabGroups?.[0]?.id ?? null),
|
||||
tabs: snapshot.tabs.filter((tab) => visibleIds.has(tab.id)),
|
||||
tabGroups: projectedTabGroups,
|
||||
// Why: group membership locates chats; the renderer's split tree remains locally authoritative.
|
||||
tabGroupLayout: undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function applyStructuredSessionTabSnapshots(
|
||||
snapshots: readonly RuntimeMobileSessionTabsResult[],
|
||||
owner = LOCAL_STRUCTURED_SESSION_OWNER
|
||||
): void {
|
||||
const settleStructuredSessionMirror = applyWebSessionTabsStorePatch(
|
||||
(state) => applyLocalStructuredSessionTabSnapshots(state, snapshots, owner),
|
||||
{ frames: [] }
|
||||
)
|
||||
settleStructuredSessionMirror()
|
||||
}
|
||||
|
||||
export function removeLocalStructuredSessionTabs<
|
||||
State extends WebSessionTabsSyncState & WorktreeRuntimeOwnerState
|
||||
>(state: State, owner = LOCAL_STRUCTURED_SESSION_OWNER, now = Date.now()): State {
|
||||
return removeStructuredSessionTabsForVersions(
|
||||
state,
|
||||
localStructuredSessionVersionByWorktree,
|
||||
owner,
|
||||
now
|
||||
)
|
||||
}
|
||||
|
||||
export function clearLocalStructuredSessionTabs(): void {
|
||||
// Fence responses from the previous enabled instance before clearing its mirror.
|
||||
localStructuredSessionSyncGeneration += 1
|
||||
const settle = applyWebSessionTabsStorePatch((state) => removeLocalStructuredSessionTabs(state), {
|
||||
frames: []
|
||||
})
|
||||
settle()
|
||||
localStructuredSessionTabsRestorePromise = null
|
||||
localStructuredSessionVersionByWorktree.clear()
|
||||
localStructuredSessionEpochHistoryByWorktree.clear()
|
||||
}
|
||||
|
||||
export function applyLocalStructuredSessionTabSnapshots<
|
||||
State extends WebSessionTabsSyncState & WorktreeRuntimeOwnerState
|
||||
>(
|
||||
state: State,
|
||||
snapshots: readonly RuntimeMobileSessionTabsResult[],
|
||||
owner = LOCAL_STRUCTURED_SESSION_OWNER,
|
||||
now = Date.now()
|
||||
): State {
|
||||
let next = state
|
||||
for (const snapshot of snapshots) {
|
||||
// Why: the execution host owns its tabs; local inventory must not rewrite paired or SSH panes.
|
||||
if (getExecutionHostIdForWorktree(next, snapshot.worktree) !== 'local') {
|
||||
continue
|
||||
}
|
||||
const prior = localStructuredSessionVersionByWorktree.get(snapshot.worktree)
|
||||
const sharesLineage = Boolean(
|
||||
prior && sameSessionTabsPublicationLineage(prior.publicationEpoch, snapshot.publicationEpoch)
|
||||
)
|
||||
const epochHistory = localStructuredSessionEpochHistoryByWorktree.get(snapshot.worktree)
|
||||
if (epochHistory?.retired.includes(snapshot.publicationEpoch) && !sharesLineage) {
|
||||
continue
|
||||
}
|
||||
if (prior && sharesLineage && snapshot.snapshotVersion <= prior.snapshotVersion) {
|
||||
continue
|
||||
}
|
||||
const patch = applyWebSessionTabsSnapshot(
|
||||
next,
|
||||
projectLocalStructuredSessionTabs(snapshot),
|
||||
owner,
|
||||
now,
|
||||
{
|
||||
contentScope: 'agent-session',
|
||||
preserveLocalLayout: true,
|
||||
terminalPtyMode: 'local'
|
||||
}
|
||||
)
|
||||
next = patch === next ? next : ({ ...next, ...patch } as State)
|
||||
localStructuredSessionVersionByWorktree.set(snapshot.worktree, {
|
||||
publicationEpoch: snapshot.publicationEpoch,
|
||||
snapshotVersion: snapshot.snapshotVersion
|
||||
})
|
||||
localStructuredSessionEpochHistoryByWorktree.set(
|
||||
snapshot.worktree,
|
||||
noteRetiredValue(epochHistory, snapshot.publicationEpoch, 8)
|
||||
)
|
||||
}
|
||||
// Drop publisher cursors for worktrees that no longer exist. Without this,
|
||||
// every deleted worktree leaves an entry for the lifetime of the renderer.
|
||||
const knownWorktreeIds = knownStructuredSessionWorktreeIds(next)
|
||||
for (const worktreeId of localStructuredSessionVersionByWorktree.keys()) {
|
||||
if (!knownWorktreeIds.has(worktreeId)) {
|
||||
localStructuredSessionVersionByWorktree.delete(worktreeId)
|
||||
localStructuredSessionEpochHistoryByWorktree.delete(worktreeId)
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
export function restoreLocalStructuredSessionTabsOnce(
|
||||
expectedGeneration = localStructuredSessionSyncGeneration
|
||||
): Promise<void> {
|
||||
localStructuredSessionTabsRestorePromise ??= refreshLocalRuntimeCapabilities()
|
||||
.then(() => refreshLocalStructuredSessionTabs(expectedGeneration))
|
||||
.then(() => undefined)
|
||||
.catch((error) => {
|
||||
localStructuredSessionTabsRestorePromise = null
|
||||
throw error
|
||||
})
|
||||
return localStructuredSessionTabsRestorePromise
|
||||
}
|
||||
|
||||
/** Fetch the current host inventory even after the startup restore has settled. */
|
||||
export function refreshLocalStructuredSessionTabs(
|
||||
expectedGeneration = localStructuredSessionSyncGeneration
|
||||
): Promise<RuntimeMobileSessionTabsResult[]> {
|
||||
return window.api.runtime
|
||||
.call({ method: 'session.tabs.listAll', params: {} })
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error('structured session inventory unavailable')
|
||||
}
|
||||
const result = response.result as { snapshots?: RuntimeMobileSessionTabsResult[] }
|
||||
const snapshots = result.snapshots ?? []
|
||||
if (expectedGeneration === localStructuredSessionSyncGeneration) {
|
||||
applyStructuredSessionTabSnapshots(snapshots)
|
||||
}
|
||||
return snapshots
|
||||
})
|
||||
}
|
||||
|
||||
export async function startLocalStructuredSessionTabsSync(args: {
|
||||
isDisposed: () => boolean
|
||||
setUnsubscribe: (unsubscribe: () => void) => void
|
||||
}): Promise<void> {
|
||||
const syncGeneration = localStructuredSessionSyncGeneration
|
||||
const isCurrent = (): boolean =>
|
||||
!args.isDisposed() && syncGeneration === localStructuredSessionSyncGeneration
|
||||
const capabilities = await refreshLocalRuntimeCapabilities()
|
||||
if (!isCurrent()) {
|
||||
return
|
||||
}
|
||||
const supported = capabilities.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)
|
||||
await restoreLocalStructuredSessionTabsOnce(syncGeneration)
|
||||
if (!isCurrent()) {
|
||||
return
|
||||
}
|
||||
if (!supported) {
|
||||
return
|
||||
}
|
||||
let subscriptionGeneration = 0
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let reconnectAttempt = 0
|
||||
let activeHandle: { unsubscribe: () => void } | null = null
|
||||
const scheduleSubscribeRetry = (): void => {
|
||||
if (!isCurrent() || reconnectTimer !== null) {
|
||||
return
|
||||
}
|
||||
const reconnectDelay = Math.min(250 * 2 ** reconnectAttempt, 5000)
|
||||
reconnectAttempt += 1
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null
|
||||
void refreshLocalStructuredSessionTabs(syncGeneration)
|
||||
.catch((error) => console.warn('[structured-session-tabs] resync failed', error))
|
||||
.finally(() => {
|
||||
if (isCurrent()) {
|
||||
void subscribeCurrent().catch((error) => {
|
||||
console.warn('[structured-session-tabs] resubscribe failed', error)
|
||||
scheduleSubscribeRetry()
|
||||
})
|
||||
}
|
||||
})
|
||||
}, reconnectDelay)
|
||||
}
|
||||
const subscribeCurrent = async (): Promise<void> => {
|
||||
if (!isCurrent()) {
|
||||
return
|
||||
}
|
||||
const generation = ++subscriptionGeneration
|
||||
let handle: { unsubscribe: () => void } | null = null
|
||||
handle = await window.api.runtime.subscribe(
|
||||
{ method: 'session.tabs.subscribeAll', params: {} },
|
||||
(response) => {
|
||||
if (!isCurrent() || generation !== subscriptionGeneration) {
|
||||
return
|
||||
}
|
||||
if (!response.ok) {
|
||||
// A streaming RPC can terminate with an error response before its
|
||||
// handle resolves; fence that generation and retry the subscription.
|
||||
subscriptionGeneration += 1
|
||||
handle?.unsubscribe()
|
||||
if (activeHandle === handle) {
|
||||
activeHandle = null
|
||||
}
|
||||
scheduleSubscribeRetry()
|
||||
return
|
||||
}
|
||||
const event = response.result as SessionTabsEvent
|
||||
if (event.type === 'snapshots') {
|
||||
applyStructuredSessionTabSnapshots(event.snapshots)
|
||||
} else if (event.type === 'snapshot' || event.type === 'updated') {
|
||||
applyStructuredSessionTabSnapshots([event])
|
||||
} else if (event.type === 'end' && generation === subscriptionGeneration) {
|
||||
// Reattach with one refresh so a runtime-restart boundary cannot strand stale tabs.
|
||||
subscriptionGeneration += 1
|
||||
handle?.unsubscribe()
|
||||
if (activeHandle === handle) {
|
||||
activeHandle = null
|
||||
}
|
||||
if (reconnectTimer !== null) {
|
||||
clearTimeout(reconnectTimer)
|
||||
}
|
||||
scheduleSubscribeRetry()
|
||||
}
|
||||
}
|
||||
)
|
||||
if (!isCurrent() || generation !== subscriptionGeneration) {
|
||||
handle.unsubscribe()
|
||||
} else {
|
||||
activeHandle = handle
|
||||
}
|
||||
}
|
||||
args.setUnsubscribe(() => {
|
||||
if (reconnectTimer !== null) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
activeHandle?.unsubscribe()
|
||||
activeHandle = null
|
||||
})
|
||||
void subscribeCurrent().catch((error) => {
|
||||
console.warn('[structured-session-tabs] subscribe failed', error)
|
||||
scheduleSubscribeRetry()
|
||||
})
|
||||
}
|
||||
export { resetLocalStructuredSessionVersionForTests } from './local-structured-session-tabs-sync/inventory-generation-fence'
|
||||
export {
|
||||
refreshLocalStructuredSessionTabs,
|
||||
restoreLocalStructuredSessionTabsOnce
|
||||
} from './local-structured-session-tabs-sync/inventory-refresh'
|
||||
export {
|
||||
applyLocalStructuredSessionTabSnapshots,
|
||||
applyStructuredSessionTabSnapshots,
|
||||
clearLocalStructuredSessionTabs,
|
||||
LOCAL_STRUCTURED_SESSION_OWNER,
|
||||
removeLocalStructuredSessionTabs
|
||||
} from './local-structured-session-tabs-sync/snapshot-apply'
|
||||
export { projectLocalStructuredSessionTabs } from './local-structured-session-tabs-sync/snapshot-projection'
|
||||
export { startLocalStructuredSessionTabsSync } from './local-structured-session-tabs-sync/subscription'
|
||||
|
||||
export function useLocalStructuredSessionTabsSync(): void {
|
||||
const ready = useAppStore(
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import type { SessionTabsPublicationEpochHistory } from '../web-session-tabs-sync/state'
|
||||
import type { StructuredSessionTabPublicationVersion } from '../local-structured-session-tab-retirement'
|
||||
|
||||
// Everything a toggle-off must invalidate: which publisher instance the renderer
|
||||
// is listening to, which publication it already accepted per worktree, and the
|
||||
// one-shot startup restore. A response in flight for a superseded instance must
|
||||
// never reach the mirror, so every async entry point carries the generation it
|
||||
// was started under and re-checks it before applying.
|
||||
let syncGeneration = 0
|
||||
let restorePromise: Promise<void> | null = null
|
||||
|
||||
export const localStructuredSessionVersionByWorktree = new Map<
|
||||
string,
|
||||
StructuredSessionTabPublicationVersion
|
||||
>()
|
||||
export const localStructuredSessionEpochHistoryByWorktree = new Map<
|
||||
string,
|
||||
SessionTabsPublicationEpochHistory
|
||||
>()
|
||||
|
||||
export function localStructuredSessionGeneration(): number {
|
||||
return syncGeneration
|
||||
}
|
||||
|
||||
export function isCurrentLocalStructuredSessionGeneration(generation: number): boolean {
|
||||
return generation === syncGeneration
|
||||
}
|
||||
|
||||
/** Retire the current publisher instance: responses already in flight stop applying. */
|
||||
export function supersedeLocalStructuredSessionGeneration(): void {
|
||||
syncGeneration += 1
|
||||
}
|
||||
|
||||
// Separate from superseding because a teardown still has to publish the retiring
|
||||
// cursors as retracted tabs before it may forget them.
|
||||
export function forgetLocalStructuredSessionPublicationCursors(): void {
|
||||
localStructuredSessionVersionByWorktree.clear()
|
||||
localStructuredSessionEpochHistoryByWorktree.clear()
|
||||
}
|
||||
|
||||
export function dropLocalStructuredSessionRestoreLatch(): void {
|
||||
restorePromise = null
|
||||
}
|
||||
|
||||
/** Latch the startup restore, releasing it on failure so a retry can re-run it. */
|
||||
export function latchLocalStructuredSessionRestore(start: () => Promise<void>): Promise<void> {
|
||||
restorePromise ??= start().catch((error: unknown) => {
|
||||
restorePromise = null
|
||||
throw error
|
||||
})
|
||||
return restorePromise
|
||||
}
|
||||
|
||||
export function resetLocalStructuredSessionVersionForTests(): void {
|
||||
supersedeLocalStructuredSessionGeneration()
|
||||
forgetLocalStructuredSessionPublicationCursors()
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
|
||||
import { refreshLocalRuntimeCapabilities } from '../local-runtime-capabilities'
|
||||
import {
|
||||
isCurrentLocalStructuredSessionGeneration,
|
||||
latchLocalStructuredSessionRestore,
|
||||
localStructuredSessionGeneration
|
||||
} from './inventory-generation-fence'
|
||||
import { applyStructuredSessionTabSnapshots } from './snapshot-apply'
|
||||
|
||||
export function restoreLocalStructuredSessionTabsOnce(
|
||||
expectedGeneration = localStructuredSessionGeneration()
|
||||
): Promise<void> {
|
||||
return latchLocalStructuredSessionRestore(() =>
|
||||
refreshLocalRuntimeCapabilities()
|
||||
.then(() => refreshLocalStructuredSessionTabs(expectedGeneration))
|
||||
.then(() => undefined)
|
||||
)
|
||||
}
|
||||
|
||||
/** Fetch the current host inventory even after the startup restore has settled. */
|
||||
export function refreshLocalStructuredSessionTabs(
|
||||
expectedGeneration = localStructuredSessionGeneration()
|
||||
): Promise<RuntimeMobileSessionTabsResult[]> {
|
||||
return window.api.runtime
|
||||
.call({ method: 'session.tabs.listAll', params: {} })
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error('structured session inventory unavailable')
|
||||
}
|
||||
const result = response.result as { snapshots?: RuntimeMobileSessionTabsResult[] }
|
||||
const snapshots = result.snapshots ?? []
|
||||
if (isCurrentLocalStructuredSessionGeneration(expectedGeneration)) {
|
||||
applyStructuredSessionTabSnapshots(snapshots)
|
||||
}
|
||||
return snapshots
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
|
||||
import type { WorktreeRuntimeOwnerState } from '../../lib/worktree-runtime-owner'
|
||||
import { getExecutionHostIdForWorktree } from '../../lib/worktree-runtime-owner'
|
||||
import {
|
||||
applyWebSessionTabsSnapshot,
|
||||
applyWebSessionTabsStorePatch
|
||||
} from '../web-session-tabs-sync'
|
||||
import type { WebSessionTabsSyncState } from '../web-session-tabs-sync'
|
||||
import {
|
||||
noteRetiredValue,
|
||||
sameSessionTabsPublicationLineage
|
||||
} from '../web-session-tabs-sync/publisher-identity-fences'
|
||||
import {
|
||||
knownStructuredSessionWorktreeIds,
|
||||
removeStructuredSessionTabsForVersions
|
||||
} from '../local-structured-session-tab-retirement'
|
||||
import {
|
||||
dropLocalStructuredSessionRestoreLatch,
|
||||
forgetLocalStructuredSessionPublicationCursors,
|
||||
localStructuredSessionEpochHistoryByWorktree,
|
||||
localStructuredSessionVersionByWorktree,
|
||||
supersedeLocalStructuredSessionGeneration
|
||||
} from './inventory-generation-fence'
|
||||
import { projectLocalStructuredSessionTabs } from './snapshot-projection'
|
||||
|
||||
export const LOCAL_STRUCTURED_SESSION_OWNER = 'local-structured-session'
|
||||
|
||||
export function applyStructuredSessionTabSnapshots(
|
||||
snapshots: readonly RuntimeMobileSessionTabsResult[],
|
||||
owner = LOCAL_STRUCTURED_SESSION_OWNER
|
||||
): void {
|
||||
const settleStructuredSessionMirror = applyWebSessionTabsStorePatch(
|
||||
(state) => applyLocalStructuredSessionTabSnapshots(state, snapshots, owner),
|
||||
{ frames: [] }
|
||||
)
|
||||
settleStructuredSessionMirror()
|
||||
}
|
||||
|
||||
export function removeLocalStructuredSessionTabs<
|
||||
State extends WebSessionTabsSyncState & WorktreeRuntimeOwnerState
|
||||
>(state: State, owner = LOCAL_STRUCTURED_SESSION_OWNER, now = Date.now()): State {
|
||||
return removeStructuredSessionTabsForVersions(
|
||||
state,
|
||||
localStructuredSessionVersionByWorktree,
|
||||
owner,
|
||||
now
|
||||
)
|
||||
}
|
||||
|
||||
export function clearLocalStructuredSessionTabs(): void {
|
||||
// Fence responses from the previous enabled instance before clearing its mirror.
|
||||
supersedeLocalStructuredSessionGeneration()
|
||||
const settleStructuredSessionClear = applyWebSessionTabsStorePatch(
|
||||
(state) => removeLocalStructuredSessionTabs(state),
|
||||
{ frames: [] }
|
||||
)
|
||||
settleStructuredSessionClear()
|
||||
dropLocalStructuredSessionRestoreLatch()
|
||||
forgetLocalStructuredSessionPublicationCursors()
|
||||
}
|
||||
|
||||
export function applyLocalStructuredSessionTabSnapshots<
|
||||
State extends WebSessionTabsSyncState & WorktreeRuntimeOwnerState
|
||||
>(
|
||||
state: State,
|
||||
snapshots: readonly RuntimeMobileSessionTabsResult[],
|
||||
owner = LOCAL_STRUCTURED_SESSION_OWNER,
|
||||
now = Date.now()
|
||||
): State {
|
||||
let next = state
|
||||
for (const snapshot of snapshots) {
|
||||
// Why: the execution host owns its tabs; local inventory must not rewrite paired or SSH panes.
|
||||
if (getExecutionHostIdForWorktree(next, snapshot.worktree) !== 'local') {
|
||||
continue
|
||||
}
|
||||
const prior = localStructuredSessionVersionByWorktree.get(snapshot.worktree)
|
||||
const sharesLineage = Boolean(
|
||||
prior && sameSessionTabsPublicationLineage(prior.publicationEpoch, snapshot.publicationEpoch)
|
||||
)
|
||||
const epochHistory = localStructuredSessionEpochHistoryByWorktree.get(snapshot.worktree)
|
||||
if (epochHistory?.retired.includes(snapshot.publicationEpoch) && !sharesLineage) {
|
||||
continue
|
||||
}
|
||||
if (prior && sharesLineage && snapshot.snapshotVersion <= prior.snapshotVersion) {
|
||||
continue
|
||||
}
|
||||
const patch = applyWebSessionTabsSnapshot(
|
||||
next,
|
||||
projectLocalStructuredSessionTabs(snapshot),
|
||||
owner,
|
||||
now,
|
||||
{
|
||||
contentScope: 'agent-session',
|
||||
preserveLocalLayout: true,
|
||||
terminalPtyMode: 'local'
|
||||
}
|
||||
)
|
||||
next = patch === next ? next : ({ ...next, ...patch } as State)
|
||||
localStructuredSessionVersionByWorktree.set(snapshot.worktree, {
|
||||
publicationEpoch: snapshot.publicationEpoch,
|
||||
snapshotVersion: snapshot.snapshotVersion
|
||||
})
|
||||
localStructuredSessionEpochHistoryByWorktree.set(
|
||||
snapshot.worktree,
|
||||
noteRetiredValue(epochHistory, snapshot.publicationEpoch, 8)
|
||||
)
|
||||
}
|
||||
// Drop publisher cursors for worktrees that no longer exist. Without this,
|
||||
// every deleted worktree leaves an entry for the lifetime of the renderer.
|
||||
const knownWorktreeIds = knownStructuredSessionWorktreeIds(next)
|
||||
for (const worktreeId of localStructuredSessionVersionByWorktree.keys()) {
|
||||
if (!knownWorktreeIds.has(worktreeId)) {
|
||||
localStructuredSessionVersionByWorktree.delete(worktreeId)
|
||||
localStructuredSessionEpochHistoryByWorktree.delete(worktreeId)
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
|
||||
|
||||
/** Narrow a host inventory snapshot to the structured agent-session tabs it publishes. */
|
||||
export function projectLocalStructuredSessionTabs(
|
||||
snapshot: RuntimeMobileSessionTabsResult
|
||||
): RuntimeMobileSessionTabsResult {
|
||||
const structuredIds = new Set(
|
||||
snapshot.tabs.filter((tab) => tab.type === 'agent-session').map((tab) => tab.id)
|
||||
)
|
||||
const visibleHostTabIds = structuredIds
|
||||
const visibleIds = structuredIds
|
||||
const projectedTabGroups = snapshot.tabGroups
|
||||
?.map((group) => ({
|
||||
...group,
|
||||
tabOrder: group.tabOrder.filter((id) => visibleHostTabIds.has(id)),
|
||||
activeTabId:
|
||||
group.activeTabId && visibleHostTabIds.has(group.activeTabId) ? group.activeTabId : null,
|
||||
recentTabIds: group.recentTabIds?.filter((id) => visibleHostTabIds.has(id))
|
||||
}))
|
||||
.filter((group) => group.tabOrder.length > 0)
|
||||
|
||||
return {
|
||||
...snapshot,
|
||||
activeTabId: visibleIds.has(snapshot.activeTabId ?? '') ? snapshot.activeTabId : null,
|
||||
activeTabType:
|
||||
snapshot.activeTabId && visibleIds.has(snapshot.activeTabId) ? snapshot.activeTabType : null,
|
||||
activeGroupId:
|
||||
snapshot.activeGroupId &&
|
||||
projectedTabGroups?.some((group) => group.id === snapshot.activeGroupId)
|
||||
? snapshot.activeGroupId
|
||||
: (projectedTabGroups?.[0]?.id ?? null),
|
||||
tabs: snapshot.tabs.filter((tab) => visibleIds.has(tab.id)),
|
||||
tabGroups: projectedTabGroups,
|
||||
// Why: group membership locates chats; the renderer's split tree remains locally authoritative.
|
||||
tabGroupLayout: undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types'
|
||||
import { refreshLocalRuntimeCapabilities } from '../local-runtime-capabilities'
|
||||
import {
|
||||
isCurrentLocalStructuredSessionGeneration,
|
||||
localStructuredSessionGeneration
|
||||
} from './inventory-generation-fence'
|
||||
import {
|
||||
refreshLocalStructuredSessionTabs,
|
||||
restoreLocalStructuredSessionTabsOnce
|
||||
} from './inventory-refresh'
|
||||
import { applyStructuredSessionTabSnapshots } from './snapshot-apply'
|
||||
|
||||
type SessionTabsEvent =
|
||||
| (RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' })
|
||||
| { type: 'snapshots'; snapshots: RuntimeMobileSessionTabsResult[] }
|
||||
| { type: 'end' }
|
||||
|
||||
export async function startLocalStructuredSessionTabsSync(args: {
|
||||
isDisposed: () => boolean
|
||||
setUnsubscribe: (unsubscribe: () => void) => void
|
||||
}): Promise<void> {
|
||||
const syncGeneration = localStructuredSessionGeneration()
|
||||
const isCurrent = (): boolean =>
|
||||
!args.isDisposed() && isCurrentLocalStructuredSessionGeneration(syncGeneration)
|
||||
const capabilities = await refreshLocalRuntimeCapabilities()
|
||||
if (!isCurrent()) {
|
||||
return
|
||||
}
|
||||
const supported = capabilities.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)
|
||||
await restoreLocalStructuredSessionTabsOnce(syncGeneration)
|
||||
if (!isCurrent()) {
|
||||
return
|
||||
}
|
||||
if (!supported) {
|
||||
return
|
||||
}
|
||||
let subscriptionGeneration = 0
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let reconnectAttempt = 0
|
||||
let activeHandle: { unsubscribe: () => void } | null = null
|
||||
const scheduleSubscribeRetry = (): void => {
|
||||
if (!isCurrent() || reconnectTimer !== null) {
|
||||
return
|
||||
}
|
||||
const reconnectDelay = Math.min(250 * 2 ** reconnectAttempt, 5000)
|
||||
reconnectAttempt += 1
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null
|
||||
void refreshLocalStructuredSessionTabs(syncGeneration)
|
||||
.catch((error) => console.warn('[structured-session-tabs] resync failed', error))
|
||||
.finally(() => {
|
||||
if (isCurrent()) {
|
||||
void subscribeCurrent().catch((error) => {
|
||||
console.warn('[structured-session-tabs] resubscribe failed', error)
|
||||
scheduleSubscribeRetry()
|
||||
})
|
||||
}
|
||||
})
|
||||
}, reconnectDelay)
|
||||
}
|
||||
const subscribeCurrent = async (): Promise<void> => {
|
||||
if (!isCurrent()) {
|
||||
return
|
||||
}
|
||||
const generation = ++subscriptionGeneration
|
||||
let handle: { unsubscribe: () => void } | null = null
|
||||
handle = await window.api.runtime.subscribe(
|
||||
{ method: 'session.tabs.subscribeAll', params: {} },
|
||||
(response) => {
|
||||
if (!isCurrent() || generation !== subscriptionGeneration) {
|
||||
return
|
||||
}
|
||||
if (!response.ok) {
|
||||
// A streaming RPC can terminate with an error response before its
|
||||
// handle resolves; fence that generation and retry the subscription.
|
||||
subscriptionGeneration += 1
|
||||
handle?.unsubscribe()
|
||||
if (activeHandle === handle) {
|
||||
activeHandle = null
|
||||
}
|
||||
scheduleSubscribeRetry()
|
||||
return
|
||||
}
|
||||
const event = response.result as SessionTabsEvent
|
||||
if (event.type === 'snapshots') {
|
||||
applyStructuredSessionTabSnapshots(event.snapshots)
|
||||
} else if (event.type === 'snapshot' || event.type === 'updated') {
|
||||
applyStructuredSessionTabSnapshots([event])
|
||||
} else if (event.type === 'end' && generation === subscriptionGeneration) {
|
||||
// Reattach with one refresh so a runtime-restart boundary cannot strand stale tabs.
|
||||
subscriptionGeneration += 1
|
||||
handle?.unsubscribe()
|
||||
if (activeHandle === handle) {
|
||||
activeHandle = null
|
||||
}
|
||||
if (reconnectTimer !== null) {
|
||||
clearTimeout(reconnectTimer)
|
||||
}
|
||||
scheduleSubscribeRetry()
|
||||
}
|
||||
}
|
||||
)
|
||||
if (!isCurrent() || generation !== subscriptionGeneration) {
|
||||
handle.unsubscribe()
|
||||
} else {
|
||||
activeHandle = handle
|
||||
}
|
||||
}
|
||||
args.setUnsubscribe(() => {
|
||||
if (reconnectTimer !== null) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
activeHandle?.unsubscribe()
|
||||
activeHandle = null
|
||||
})
|
||||
void subscribeCurrent().catch((error) => {
|
||||
console.warn('[structured-session-tabs] subscribe failed', error)
|
||||
scheduleSubscribeRetry()
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user