Session directory resolution had no precedence contract: the selection-time directory short-circuited every lookup, and a persisted runtime value was consulted before the authoritative record. A worktree session selected before its directory store bootstrapped kept the active-directory fallback, and that guess was persisted, so it survived reloads and restarts. Directory resolution now lives in one module and orders sources by whether the server confirmed the path, not by whether the value is local or synced: authoritative (the child store that holds the session) > server-confirmed selection > worktree attachment/metadata (the requested path, pre-canonical) > remembered. A guessed selection is no longer persisted, remembered, or ranked. Chips read the same resolution the composer used, so queue keys cannot diverge. Queued auto-send could strand an item indefinitely: backoff, missing send configuration, and the recent-abort window all returned without scheduling a wake-up, so the queue only retried when an unrelated status or directory change re-ran the effect. A retry scheduler now wakes it at the earliest known time. A rejected send rolls the optimistic message back while the composer stays silent for transport failures, which makes it indistinguishable from nothing happening. Failures are now recorded to a bounded in-memory log surfaced in the About diagnostics report, alongside a directory-resolution breakdown, plus __opencodeDebug.diagnoseSessionDirectory() and getRecentSendFailures(). Prompted by a report of worktree prompting silently failing. That failure was not reproduced locally, so the diagnostics are what will identify it.
161 lines
6.1 KiB
TypeScript
161 lines
6.1 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 the child store that actually holds this session.
|
|
*
|
|
* This is the authoritative session→directory mapping: a session is present in
|
|
* exactly the store for the directory it belongs to, regardless of whether the
|
|
* server populated `session.directory` on the record itself. 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]
|
|
}
|