Review found the owning documentation still describing the behaviour this branch replaced, in one case stacked directly above the new docstring saying the opposite. Holding a session proves containment, not ownership, so every text that called store membership the authoritative mapping was actively misleading for the module whose wrong answer misroutes every send. Corrected in the module docstring, the resolution module's precedence description, the sync-refs helper it points at, and the sync DOCUMENTATION.md table and rules. The debug report built its authoritative value membership-first, so for exactly the scenario this branch fixes it reported the parent directory and could raise a source-disagreement alert while routing was in fact correct. It now uses the same record-first order as the resolver. The CLI timeout comment claimed the wait and provisioning windows were additive while the code took the larger of the two. The server provisions the worktree inside session creation, before it waits for the session to go idle, so they do run in sequence: the windows are now summed and the tests pin both cases.
163 lines
6.2 KiB
TypeScript
163 lines
6.2 KiB
TypeScript
/**
|
|
* Sync refs — imperative access to sync state from non-React code.
|
|
*
|
|
* SyncProvider sets these refs on mount. Store actions (session-ui-store,
|
|
* session-actions) use them to read child-store domain data without hooks.
|
|
*/
|
|
|
|
import type { Config, OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
|
import type { ChildStoreManager } from "./child-store"
|
|
import { getSessionMaterializationStatus } from "./materialization"
|
|
import type { State } from "./types"
|
|
|
|
let _childStores: ChildStoreManager | null = null
|
|
let _directory: string = ""
|
|
let _registerSessionDirectory: ((sessionID: string, directory: string) => void) | null = null
|
|
const configListeners = new Set<(directory: string, config: Config) => void>()
|
|
let cachedSessionManager: ChildStoreManager | null = null
|
|
let cachedSessionSlices = new Map<string, State["session"]>()
|
|
let cachedSessionsById = new Map<string, State["session"][number]>()
|
|
let cachedSessionDirectoryById = new Map<string, string>()
|
|
|
|
export function setSyncRefs(
|
|
_sdk: OpencodeClient,
|
|
childStores: ChildStoreManager,
|
|
directory: string,
|
|
registerSessionDirectory?: (sessionID: string, directory: string) => void,
|
|
) {
|
|
_childStores = childStores
|
|
if (cachedSessionManager !== childStores) {
|
|
cachedSessionManager = null
|
|
cachedSessionSlices = new Map()
|
|
cachedSessionsById = new Map()
|
|
cachedSessionDirectoryById = new Map()
|
|
}
|
|
_directory = directory
|
|
if (registerSessionDirectory) {
|
|
_registerSessionDirectory = registerSessionDirectory
|
|
}
|
|
}
|
|
|
|
/** Pre-register a session→directory mapping in the routing index.
|
|
* Called from session-actions when creating sessions so SSE events
|
|
* arriving before session.created can be routed correctly. */
|
|
export function registerSessionDirectory(sessionID: string, directory: string) {
|
|
_registerSessionDirectory?.(sessionID, directory)
|
|
}
|
|
|
|
export function getSyncChildStores(): ChildStoreManager {
|
|
if (!_childStores) throw new Error("ChildStoreManager not initialized — is SyncProvider mounted?")
|
|
return _childStores
|
|
}
|
|
|
|
/** Read current directory's child store state. Returns undefined if not bootstrapped. */
|
|
export function getDirectoryState(directory?: string): State | undefined {
|
|
const stores = _childStores
|
|
if (!stores) return undefined
|
|
const dir = directory || _directory
|
|
if (!dir) return undefined
|
|
return stores.getState(dir)
|
|
}
|
|
|
|
/** Read resolved OpenCode config from a directory child store, if bootstrapped. */
|
|
export function getSyncConfig(directory?: string): Config | undefined {
|
|
const config = getDirectoryState(directory)?.config
|
|
return config && Object.keys(config).length > 0 ? config : undefined
|
|
}
|
|
|
|
export function subscribeToSyncConfigChanges(listener: (directory: string, config: Config) => void): () => void {
|
|
configListeners.add(listener)
|
|
return () => {
|
|
configListeners.delete(listener)
|
|
}
|
|
}
|
|
|
|
export function emitSyncConfigChanged(directory: string, config: Config): void {
|
|
if (!directory) return
|
|
for (const listener of configListeners) {
|
|
listener(directory, config)
|
|
}
|
|
}
|
|
|
|
/** Read sessions from current directory's child store */
|
|
export function getSyncSessions(directory?: string) {
|
|
return getDirectoryState(directory)?.session ?? []
|
|
}
|
|
|
|
/** Read sessions across all initialized child stores */
|
|
export function getAllSyncSessions() {
|
|
return Array.from(getAllSyncSessionMap().values())
|
|
}
|
|
|
|
/** Read the cached cross-directory session index, rebuilding only when a session slice changes. */
|
|
export function getAllSyncSessionMap(): ReadonlyMap<string, State["session"][number]> {
|
|
const stores = _childStores
|
|
if (!stores) return cachedSessionsById
|
|
|
|
let changed = cachedSessionManager !== stores || cachedSessionSlices.size !== stores.children.size
|
|
for (const [directory, store] of stores.children) {
|
|
if (cachedSessionSlices.get(directory) !== store.getState().session) {
|
|
changed = true
|
|
break
|
|
}
|
|
}
|
|
if (!changed) return cachedSessionsById
|
|
|
|
const nextSlices = new Map<string, State["session"]>()
|
|
const nextSessionsById = new Map<string, State["session"][number]>()
|
|
const nextDirectoriesById = new Map<string, string>()
|
|
for (const [directory, store] of stores.children) {
|
|
const sessions = store.getState().session
|
|
nextSlices.set(directory, sessions)
|
|
for (const session of sessions) {
|
|
if (!session?.id) continue
|
|
nextSessionsById.set(session.id, session)
|
|
nextDirectoriesById.set(session.id, directory)
|
|
}
|
|
}
|
|
cachedSessionManager = stores
|
|
cachedSessionSlices = nextSlices
|
|
cachedSessionsById = nextSessionsById
|
|
cachedSessionDirectoryById = nextDirectoriesById
|
|
return cachedSessionsById
|
|
}
|
|
|
|
/**
|
|
* Directory of a child store that holds this session.
|
|
*
|
|
* This reports containment, not ownership, and a session can be held by more
|
|
* than one store: a project's session list includes the sessions of its
|
|
* worktrees so the sidebar can group them, so the parent repository holds
|
|
* worktree sessions too. Ownership comes from the session record's own
|
|
* directory; this is the fallback for a record that carries none. Returns
|
|
* `null` when no initialized child store contains the session, which means
|
|
* "unknown", never "no directory".
|
|
*/
|
|
export function getSyncSessionDirectory(sessionId: string): string | null {
|
|
if (!sessionId) return null
|
|
getAllSyncSessionMap()
|
|
return cachedSessionDirectoryById.get(sessionId) ?? null
|
|
}
|
|
|
|
/** Read messages for a session from current directory's child store */
|
|
export function getSyncMessages(sessionId: string, directory?: string) {
|
|
return getDirectoryState(directory)?.message[sessionId] ?? []
|
|
}
|
|
|
|
/** Read renderability of a session snapshot from current directory's child store */
|
|
export function getSyncSessionMaterializationStatus(sessionId: string, directory?: string) {
|
|
const state = getDirectoryState(directory)
|
|
if (!state) return { hasMessages: false, renderable: false, missingPartMessageIDs: [] }
|
|
return getSessionMaterializationStatus(state, sessionId)
|
|
}
|
|
|
|
/** Read parts for a message from current directory's child store */
|
|
export function getSyncParts(messageId: string, directory?: string) {
|
|
return getDirectoryState(directory)?.part[messageId] ?? []
|
|
}
|
|
|
|
/** Read session status from current directory's child store */
|
|
export function getSyncSessionStatus(sessionId: string, directory?: string) {
|
|
return getDirectoryState(directory)?.session_status[sessionId]
|
|
}
|