refactor(tui): make data sync owner-driven
This commit is contained in:
@@ -24,7 +24,8 @@ import type { JSX } from "@opentui/solid"
|
|||||||
|
|
||||||
interface LocationCollection<Value> {
|
interface LocationCollection<Value> {
|
||||||
list(location?: LocationRef): Value[] | undefined
|
list(location?: LocationRef): Value[] | undefined
|
||||||
refresh(location?: LocationRef): Promise<void>
|
sync(location?: LocationRef): Promise<void>
|
||||||
|
invalidate(location?: LocationRef): void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Data {
|
export interface Data {
|
||||||
@@ -42,37 +43,45 @@ export interface Data {
|
|||||||
status(sessionID: string): "idle" | "running"
|
status(sessionID: string): "idle" | "running"
|
||||||
readonly pending: {
|
readonly pending: {
|
||||||
list(sessionID: string): SessionPendingInfo[]
|
list(sessionID: string): SessionPendingInfo[]
|
||||||
refresh(sessionID: string): Promise<void>
|
sync(sessionID: string): Promise<void>
|
||||||
|
invalidate(sessionID: string): void
|
||||||
}
|
}
|
||||||
refresh(sessionID: string): Promise<void>
|
sync(sessionID: string): Promise<void>
|
||||||
|
invalidate(sessionID: string): void
|
||||||
readonly message: {
|
readonly message: {
|
||||||
list(sessionID: string): SessionMessageInfo[]
|
list(sessionID: string): SessionMessageInfo[]
|
||||||
get(sessionID: string, messageID: string): SessionMessageInfo | undefined
|
get(sessionID: string, messageID: string): SessionMessageInfo | undefined
|
||||||
refresh(sessionID: string): Promise<void>
|
sync(sessionID: string): Promise<void>
|
||||||
|
invalidate(sessionID: string): void
|
||||||
}
|
}
|
||||||
readonly permission: {
|
readonly permission: {
|
||||||
list(sessionID: string): PermissionV2Request[] | undefined
|
list(sessionID: string): PermissionV2Request[] | undefined
|
||||||
refresh(sessionID: string): Promise<void>
|
sync(sessionID: string): Promise<void>
|
||||||
|
invalidate(sessionID: string): void
|
||||||
}
|
}
|
||||||
readonly form: {
|
readonly form: {
|
||||||
list(sessionID: string, location?: LocationRef): Array<FormInfo & { readonly location?: LocationRef }> | undefined
|
list(sessionID: string, location?: LocationRef): Array<FormInfo & { readonly location?: LocationRef }> | undefined
|
||||||
refresh(sessionID: string, location?: LocationRef): Promise<void>
|
sync(sessionID: string, location?: LocationRef): Promise<void>
|
||||||
|
invalidate(sessionID: string, location?: LocationRef): void
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
readonly project: {
|
readonly project: {
|
||||||
readonly permission: {
|
readonly permission: {
|
||||||
list(projectID: string): PermissionSavedInfo[] | undefined
|
list(projectID: string): PermissionSavedInfo[] | undefined
|
||||||
refresh(projectID: string): Promise<void>
|
sync(projectID: string): Promise<void>
|
||||||
|
invalidate(projectID: string): void
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
readonly shell: {
|
readonly shell: {
|
||||||
list(location?: LocationRef): ShellInfo[]
|
list(location?: LocationRef): ShellInfo[]
|
||||||
get(id: string): ShellInfo | undefined
|
get(id: string): ShellInfo | undefined
|
||||||
refresh(location?: LocationRef): Promise<void>
|
sync(location?: LocationRef): Promise<void>
|
||||||
|
invalidate(location?: LocationRef): void
|
||||||
}
|
}
|
||||||
readonly location: {
|
readonly location: {
|
||||||
default(): LocationRef
|
default(): LocationRef
|
||||||
refresh(location?: LocationRef): Promise<void>
|
sync(location?: LocationRef): Promise<void>
|
||||||
|
invalidate(location?: LocationRef): void
|
||||||
readonly agent: LocationCollection<AgentInfo>
|
readonly agent: LocationCollection<AgentInfo>
|
||||||
readonly command: LocationCollection<CommandInfo>
|
readonly command: LocationCollection<CommandInfo>
|
||||||
readonly integration: LocationCollection<IntegrationInfo>
|
readonly integration: LocationCollection<IntegrationInfo>
|
||||||
|
|||||||
@@ -467,11 +467,10 @@ async function connected(
|
|||||||
toast: ReturnType<typeof useToast>,
|
toast: ReturnType<typeof useToast>,
|
||||||
onConnected?: OnIntegrationConnected,
|
onConnected?: OnIntegrationConnected,
|
||||||
) {
|
) {
|
||||||
await Promise.all([
|
data.location.integration.invalidate()
|
||||||
data.location.integration.refresh(),
|
data.location.model.invalidate()
|
||||||
data.location.model.refresh(),
|
data.location.provider.invalidate()
|
||||||
data.location.provider.refresh(),
|
await Promise.all([data.location.integration.sync(), data.location.model.sync(), data.location.provider.sync()])
|
||||||
])
|
|
||||||
toast.show({ variant: "success", message: `Connected ${integration.name}` })
|
toast.show({ variant: "success", message: `Connected ${integration.name}` })
|
||||||
if (onConnected) {
|
if (onConnected) {
|
||||||
onConnected(providerID(data, integration.id))
|
onConnected(providerID(data, integration.id))
|
||||||
@@ -498,11 +497,10 @@ async function disconnected(
|
|||||||
dialog: ReturnType<typeof useDialog>,
|
dialog: ReturnType<typeof useDialog>,
|
||||||
toast: ReturnType<typeof useToast>,
|
toast: ReturnType<typeof useToast>,
|
||||||
) {
|
) {
|
||||||
await Promise.all([
|
data.location.integration.invalidate()
|
||||||
data.location.integration.refresh(),
|
data.location.model.invalidate()
|
||||||
data.location.model.refresh(),
|
data.location.provider.invalidate()
|
||||||
data.location.provider.refresh(),
|
await Promise.all([data.location.integration.sync(), data.location.model.sync(), data.location.provider.sync()])
|
||||||
])
|
|
||||||
toast.show({ variant: "success", message: `Disconnected ${name}` })
|
toast.show({ variant: "success", message: `Disconnected ${name}` })
|
||||||
dialog.clear()
|
dialog.clear()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function DialogSkill(props: DialogSkillProps) {
|
|||||||
.then(async () => {
|
.then(async () => {
|
||||||
const current = data.location.skill.list(props.location)
|
const current = data.location.skill.list(props.location)
|
||||||
if (current) return current
|
if (current) return current
|
||||||
await data.location.skill.refresh(props.location)
|
await data.location.skill.sync(props.location)
|
||||||
return data.location.skill.list(props.location) ?? []
|
return data.location.skill.list(props.location) ?? []
|
||||||
})
|
})
|
||||||
// Catch so the rejected resource never reaches the memo below: reading
|
// Catch so the rejected resource never reaches the memo below: reading
|
||||||
|
|||||||
@@ -1055,7 +1055,7 @@ export function Prompt(props: PromptProps) {
|
|||||||
} else {
|
} else {
|
||||||
move.startSubmit()
|
move.startSubmit()
|
||||||
if (!session) {
|
if (!session) {
|
||||||
await data.session.refresh(sessionID)
|
await data.session.sync(sessionID)
|
||||||
session = data.session.get(sessionID)
|
session = data.session.get(sessionID)
|
||||||
}
|
}
|
||||||
if (session?.agent !== agent.id) {
|
if (session?.agent !== agent.id) {
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
|||||||
const directory = result.directory
|
const directory = result.directory
|
||||||
if (!directory) throw new Error("No project copy directory returned")
|
if (!directory) throw new Error("No project copy directory returned")
|
||||||
|
|
||||||
// Call a location-based route to make sure it's bootstrapped before moving on.
|
// Call a location-based route to initialize it before moving on.
|
||||||
await client.api.location.get({ location: { directory } })
|
await client.api.location.get({ location: { directory } })
|
||||||
|
|
||||||
setProgress("Creating session")
|
setProgress("Creating session")
|
||||||
@@ -139,7 +139,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
|||||||
async function resolveSession(sessionID: string) {
|
async function resolveSession(sessionID: string) {
|
||||||
const session = data.session.get(sessionID)
|
const session = data.session.get(sessionID)
|
||||||
if (session) return session
|
if (session) return session
|
||||||
await data.session.refresh(sessionID).catch(() => undefined)
|
await data.session.sync(sessionID).catch(() => undefined)
|
||||||
return data.session.get(sessionID)
|
return data.session.get(sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+278
-206
@@ -1,7 +1,7 @@
|
|||||||
// Client data layer: apply server events and cache API reads into a Solid store.
|
// Client data layer: apply server events and cache API reads into a Solid store.
|
||||||
// Prefer straightforward projection. Do not add generation counters, stale-response
|
// Prefer straightforward projection. Do not add generation counters, stale-response
|
||||||
// merges, live/history overlays, or other race machinery here—last write wins.
|
// merges, live/history overlays, or other race machinery here—last write wins.
|
||||||
// Reconnect may re-bootstrap; that is enough. UI and the server own ordering concerns.
|
// Reconnect invalidates cached reads; active UI owners decide what to sync again.
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AgentInfo,
|
AgentInfo,
|
||||||
@@ -31,7 +31,7 @@ import type { Plugin } from "@opencode-ai/plugin/v2/tui"
|
|||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { createSimpleContext } from "./helper"
|
import { createSimpleContext } from "./helper"
|
||||||
import { useClient } from "./client"
|
import { useClient } from "./client"
|
||||||
import { createSignal, onCleanup } from "solid-js"
|
import { createEffect, createSignal, onCleanup } from "solid-js"
|
||||||
|
|
||||||
export type DataSessionStatus = "idle" | "running"
|
export type DataSessionStatus = "idle" | "running"
|
||||||
|
|
||||||
@@ -66,11 +66,10 @@ type Store = {
|
|||||||
// true root is not yet loaded). The value is a flat deduplicated list of every
|
// true root is not yet loaded). The value is a flat deduplicated list of every
|
||||||
// session ID in that family, including the key itself once its info arrives.
|
// session ID in that family, including the key itself once its info arrives.
|
||||||
family: Record<string, string[]>
|
family: Record<string, string[]>
|
||||||
status: Record<string, DataSessionStatus>
|
active: Record<string, DataSessionStatus>
|
||||||
message: Record<string, SessionMessageInfo[]>
|
message: Record<string, SessionMessageInfo[]>
|
||||||
pending: Record<string, SessionPendingInfo[]>
|
pending: Record<string, SessionPendingInfo[]>
|
||||||
input: Record<string, string[]>
|
input: Record<string, string[]>
|
||||||
compaction: Record<string, string[]>
|
|
||||||
permission: Record<string, PermissionV2Request[]>
|
permission: Record<string, PermissionV2Request[]>
|
||||||
// Pending forms keyed by owner: a session ID or the temporary "global" elicitation sentinel.
|
// Pending forms keyed by owner: a session ID or the temporary "global" elicitation sentinel.
|
||||||
form: Record<string, FormWithLocation[]>
|
form: Record<string, FormWithLocation[]>
|
||||||
@@ -89,6 +88,37 @@ function locationQuery(ref?: LocationRef) {
|
|||||||
return ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined
|
return ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createSync() {
|
||||||
|
const state = new Map<string, true | Promise<void>>()
|
||||||
|
return {
|
||||||
|
run(key: string, load: () => Promise<void>) {
|
||||||
|
const active = state.get(key)
|
||||||
|
if (active === true) return Promise.resolve()
|
||||||
|
if (active) return active
|
||||||
|
const pending = load()
|
||||||
|
.then(() => {
|
||||||
|
if (state.get(key) === pending) state.set(key, true)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (state.get(key) === pending) state.delete(key)
|
||||||
|
})
|
||||||
|
state.set(key, pending)
|
||||||
|
return pending
|
||||||
|
},
|
||||||
|
complete(key: string) {
|
||||||
|
if (state.has(key)) return
|
||||||
|
state.set(key, true)
|
||||||
|
},
|
||||||
|
invalidate(key?: string) {
|
||||||
|
if (key) {
|
||||||
|
state.delete(key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.clear()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const { use: useData, provider: DataProvider } = createSimpleContext({
|
export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||||
name: "Data",
|
name: "Data",
|
||||||
init: () => {
|
init: () => {
|
||||||
@@ -96,11 +126,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
session: {
|
session: {
|
||||||
info: {},
|
info: {},
|
||||||
family: {},
|
family: {},
|
||||||
status: {},
|
active: {},
|
||||||
message: {},
|
message: {},
|
||||||
pending: {},
|
pending: {},
|
||||||
input: {},
|
input: {},
|
||||||
compaction: {},
|
|
||||||
permission: {},
|
permission: {},
|
||||||
form: {},
|
form: {},
|
||||||
},
|
},
|
||||||
@@ -115,16 +144,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
directory: process.cwd(),
|
directory: process.cwd(),
|
||||||
})
|
})
|
||||||
const messageIndex = new Map<string, Map<string, number>>()
|
const messageIndex = new Map<string, Map<string, number>>()
|
||||||
let bootstrapping: Promise<void> | undefined
|
const sync = createSync()
|
||||||
let connected = false
|
|
||||||
|
|
||||||
function setSessionStatus(sessionID: string, status: DataSessionStatus) {
|
function setSessionActive(sessionID: string, status: DataSessionStatus) {
|
||||||
setStore("session", "status", sessionID, status)
|
setStore("session", "active", sessionID, status)
|
||||||
}
|
|
||||||
|
|
||||||
function addCompaction(sessionID: string, inputID: string) {
|
|
||||||
if (store.session.compaction[sessionID]?.includes(inputID)) return
|
|
||||||
setStore("session", "compaction", sessionID, [...(store.session.compaction[sessionID] ?? []), inputID])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function addPending(item: SessionPendingInfo) {
|
function addPending(item: SessionPendingInfo) {
|
||||||
@@ -142,16 +165,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeCompaction(sessionID: string, inputID?: string) {
|
|
||||||
if (!inputID || !store.session.compaction[sessionID]?.includes(inputID)) return
|
|
||||||
setStore(
|
|
||||||
"session",
|
|
||||||
"compaction",
|
|
||||||
sessionID,
|
|
||||||
store.session.compaction[sessionID].filter((id) => id !== inputID),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = {
|
const message = {
|
||||||
update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map<string, number>) => void) {
|
update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map<string, number>) => void) {
|
||||||
setStore(
|
setStore(
|
||||||
@@ -226,7 +239,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
return current
|
return current
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register one session into the family index. Idempotent: refreshing an
|
// Register one session into the family index. Idempotent: syncing an
|
||||||
// existing session never duplicates its ID. When a tentative family keyed by
|
// existing session never duplicates its ID. When a tentative family keyed by
|
||||||
// sessionID exists (descendants arrived while sessionID's own info was
|
// sessionID exists (descendants arrived while sessionID's own info was
|
||||||
// absent) but sessionID turns out to have a parent, fold the orphan subtree
|
// absent) but sessionID turns out to have a parent, fold the orphan subtree
|
||||||
@@ -254,15 +267,19 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
|
|
||||||
function removeSession(sessionID: string) {
|
function removeSession(sessionID: string) {
|
||||||
messageIndex.delete(sessionID)
|
messageIndex.delete(sessionID)
|
||||||
|
sync.invalidate(`session:${sessionID}`)
|
||||||
|
sync.invalidate(`session.pending:${sessionID}`)
|
||||||
|
sync.invalidate(`session.message:${sessionID}`)
|
||||||
|
sync.invalidate(`session.permission:${sessionID}`)
|
||||||
|
sync.invalidate(`session.form:${sessionID}:`)
|
||||||
setStore(
|
setStore(
|
||||||
"session",
|
"session",
|
||||||
produce((draft) => {
|
produce((draft) => {
|
||||||
delete draft.info[sessionID]
|
delete draft.info[sessionID]
|
||||||
delete draft.status[sessionID]
|
delete draft.active[sessionID]
|
||||||
delete draft.message[sessionID]
|
delete draft.message[sessionID]
|
||||||
delete draft.pending[sessionID]
|
delete draft.pending[sessionID]
|
||||||
delete draft.input[sessionID]
|
delete draft.input[sessionID]
|
||||||
delete draft.compaction[sessionID]
|
|
||||||
delete draft.permission[sessionID]
|
delete draft.permission[sessionID]
|
||||||
delete draft.form[sessionID]
|
delete draft.form[sessionID]
|
||||||
for (const [rootID, family] of Object.entries(draft.family)) {
|
for (const [rootID, family] of Object.entries(draft.family)) {
|
||||||
@@ -277,7 +294,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
function handleEvent(event: OpenCodeEvent) {
|
function handleEvent(event: OpenCodeEvent) {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "session.created":
|
case "session.created":
|
||||||
void result.session.refresh(event.data.sessionID)
|
result.session.invalidate(event.data.sessionID)
|
||||||
|
void result.session.sync(event.data.sessionID)
|
||||||
break
|
break
|
||||||
case "session.deleted":
|
case "session.deleted":
|
||||||
removeSession(event.data.sessionID)
|
removeSession(event.data.sessionID)
|
||||||
@@ -290,19 +308,21 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
})
|
})
|
||||||
break
|
break
|
||||||
case "catalog.updated":
|
case "catalog.updated":
|
||||||
void Promise.all([
|
result.location.model.invalidate(event.location)
|
||||||
result.location.model.refresh(event.location),
|
result.location.provider.invalidate(event.location)
|
||||||
result.location.provider.refresh(event.location),
|
void Promise.all([result.location.model.sync(event.location), result.location.provider.sync(event.location)])
|
||||||
])
|
|
||||||
break
|
break
|
||||||
case "agent.updated":
|
case "agent.updated":
|
||||||
void result.location.agent.refresh(event.location)
|
result.location.agent.invalidate(event.location)
|
||||||
|
void result.location.agent.sync(event.location)
|
||||||
break
|
break
|
||||||
case "command.updated":
|
case "command.updated":
|
||||||
void result.location.command.refresh(event.location)
|
result.location.command.invalidate(event.location)
|
||||||
|
void result.location.command.sync(event.location)
|
||||||
break
|
break
|
||||||
case "skill.updated":
|
case "skill.updated":
|
||||||
void result.location.skill.refresh(event.location)
|
result.location.skill.invalidate(event.location)
|
||||||
|
void result.location.skill.sync(event.location)
|
||||||
break
|
break
|
||||||
case "session.agent.selected":
|
case "session.agent.selected":
|
||||||
if (store.session.info[event.data.sessionID])
|
if (store.session.info[event.data.sessionID])
|
||||||
@@ -666,7 +686,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
})
|
})
|
||||||
break
|
break
|
||||||
case "session.execution.started":
|
case "session.execution.started":
|
||||||
setSessionStatus(event.data.sessionID, "running")
|
setSessionActive(event.data.sessionID, "running")
|
||||||
break
|
break
|
||||||
case "session.compaction.admitted":
|
case "session.compaction.admitted":
|
||||||
addPending({
|
addPending({
|
||||||
@@ -676,11 +696,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
timeCreated: event.created,
|
timeCreated: event.created,
|
||||||
type: "compaction",
|
type: "compaction",
|
||||||
})
|
})
|
||||||
addCompaction(event.data.sessionID, event.data.inputID)
|
|
||||||
break
|
break
|
||||||
case "session.compaction.started":
|
case "session.compaction.started":
|
||||||
removePending(event.data.sessionID, event.data.inputID)
|
removePending(event.data.sessionID, event.data.inputID)
|
||||||
removeCompaction(event.data.sessionID, event.data.inputID)
|
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
message.update(event.data.sessionID, (draft, index) => {
|
||||||
message.append(draft, index, {
|
message.append(draft, index, {
|
||||||
id: event.data.inputID ?? messageIDFromEvent(event.id),
|
id: event.data.inputID ?? messageIDFromEvent(event.id),
|
||||||
@@ -696,7 +714,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
case "session.execution.succeeded":
|
case "session.execution.succeeded":
|
||||||
case "session.execution.failed":
|
case "session.execution.failed":
|
||||||
case "session.execution.interrupted":
|
case "session.execution.interrupted":
|
||||||
setSessionStatus(event.data.sessionID, "idle")
|
setSessionActive(event.data.sessionID, "idle")
|
||||||
message.update(event.data.sessionID, (draft) => {
|
message.update(event.data.sessionID, (draft) => {
|
||||||
const currentAssistant = message.activeAssistant(draft)
|
const currentAssistant = message.activeAssistant(draft)
|
||||||
if (currentAssistant) currentAssistant.retry = undefined
|
if (currentAssistant) currentAssistant.retry = undefined
|
||||||
@@ -758,7 +776,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
break
|
break
|
||||||
case "session.compaction.failed":
|
case "session.compaction.failed":
|
||||||
removePending(event.data.sessionID, event.data.inputID)
|
removePending(event.data.sessionID, event.data.inputID)
|
||||||
removeCompaction(event.data.sessionID, event.data.inputID)
|
|
||||||
message.update(event.data.sessionID, (draft, index) => {
|
message.update(event.data.sessionID, (draft, index) => {
|
||||||
const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running")
|
const position = draft.findLastIndex((item) => item.type === "compaction" && item.status === "running")
|
||||||
const current = draft[position]
|
const current = draft[position]
|
||||||
@@ -837,22 +854,28 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
)
|
)
|
||||||
break
|
break
|
||||||
case "reference.updated":
|
case "reference.updated":
|
||||||
void result.location.reference.refresh()
|
result.location.reference.invalidate()
|
||||||
|
void result.location.reference.sync()
|
||||||
break
|
break
|
||||||
case "integration.updated":
|
case "integration.updated":
|
||||||
|
result.location.integration.invalidate(event.location)
|
||||||
|
result.location.model.invalidate(event.location)
|
||||||
|
result.location.provider.invalidate(event.location)
|
||||||
void Promise.all([
|
void Promise.all([
|
||||||
result.location.integration.refresh(event.location),
|
result.location.integration.sync(event.location),
|
||||||
result.location.model.refresh(event.location),
|
result.location.model.sync(event.location),
|
||||||
result.location.provider.refresh(event.location),
|
result.location.provider.sync(event.location),
|
||||||
])
|
])
|
||||||
break
|
break
|
||||||
// Authenticating an MCP integration reconnects its server, which emits mcp.status.changed,
|
// Authenticating an MCP integration reconnects its server, which emits mcp.status.changed,
|
||||||
// so the mcp list refreshes here rather than off integration.updated.
|
// so the mcp list syncs here rather than off integration.updated.
|
||||||
case "mcp.status.changed":
|
case "mcp.status.changed":
|
||||||
void result.location.mcp.server.refresh(event.location)
|
result.location.mcp.server.invalidate(event.location)
|
||||||
|
void result.location.mcp.server.sync(event.location)
|
||||||
break
|
break
|
||||||
case "mcp.resources.changed":
|
case "mcp.resources.changed":
|
||||||
void result.location.mcp.resource.refresh(event.location)
|
result.location.mcp.resource.invalidate(event.location)
|
||||||
|
void result.location.mcp.resource.sync(event.location)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -883,7 +906,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
status(sessionID: string) {
|
status(sessionID: string) {
|
||||||
return store.session.status[sessionID] ?? "idle"
|
return store.session.active[sessionID] ?? "idle"
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
list(sessionID: string) {
|
list(sessionID: string) {
|
||||||
@@ -893,19 +916,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
return store.session.input[sessionID]?.includes(inputID) ?? false
|
return store.session.input[sessionID]?.includes(inputID) ?? false
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
compaction: {
|
|
||||||
list(sessionID: string) {
|
|
||||||
return store.session.compaction[sessionID] ?? []
|
|
||||||
},
|
|
||||||
async refresh(sessionID: string) {
|
|
||||||
await result.session.pending.refresh(sessionID)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
pending: {
|
pending: {
|
||||||
list(sessionID: string) {
|
list(sessionID: string) {
|
||||||
return store.session.pending[sessionID] ?? []
|
return store.session.pending[sessionID] ?? []
|
||||||
},
|
},
|
||||||
async refresh(sessionID: string) {
|
sync(sessionID: string) {
|
||||||
|
return sync.run(`session.pending:${sessionID}`, async () => {
|
||||||
const pending = await client.api.session.pending.list({ sessionID })
|
const pending = await client.api.session.pending.list({ sessionID })
|
||||||
setStore("session", "pending", sessionID, reconcile(pending))
|
setStore("session", "pending", sessionID, reconcile(pending))
|
||||||
setStore(
|
setStore(
|
||||||
@@ -914,17 +930,20 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
sessionID,
|
sessionID,
|
||||||
reconcile(pending.filter((item) => item.type !== "compaction").map((item) => item.id)),
|
reconcile(pending.filter((item) => item.type !== "compaction").map((item) => item.id)),
|
||||||
)
|
)
|
||||||
setStore(
|
})
|
||||||
"session",
|
},
|
||||||
"compaction",
|
invalidate(sessionID: string) {
|
||||||
sessionID,
|
sync.invalidate(`session.pending:${sessionID}`)
|
||||||
reconcile(pending.filter((item) => item.type === "compaction").map((item) => item.id)),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async refresh(sessionID: string) {
|
sync(sessionID: string) {
|
||||||
|
return sync.run(`session:${sessionID}`, async () => {
|
||||||
setStore("session", "info", sessionID, await client.api.session.get({ sessionID }))
|
setStore("session", "info", sessionID, await client.api.session.get({ sessionID }))
|
||||||
registerSession(sessionID)
|
registerSession(sessionID)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
invalidate(sessionID: string) {
|
||||||
|
sync.invalidate(`session:${sessionID}`)
|
||||||
},
|
},
|
||||||
message: {
|
message: {
|
||||||
list(sessionID: string) {
|
list(sessionID: string) {
|
||||||
@@ -935,18 +954,30 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
const position = messageIndex.get(sessionID)?.get(messageID)
|
const position = messageIndex.get(sessionID)?.get(messageID)
|
||||||
return position === undefined ? undefined : messages?.[position]
|
return position === undefined ? undefined : messages?.[position]
|
||||||
},
|
},
|
||||||
async refresh(sessionID: string) {
|
sync(sessionID: string) {
|
||||||
const messages = (await client.api.message.list({ sessionID, limit: 200, order: "desc" })).data.toReversed()
|
return sync.run(`session.message:${sessionID}`, async () => {
|
||||||
|
const messages = (
|
||||||
|
await client.api.message.list({ sessionID, limit: 200, order: "desc" })
|
||||||
|
).data.toReversed()
|
||||||
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
|
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
|
||||||
setStore("session", "message", sessionID, reconcile(messages))
|
setStore("session", "message", sessionID, reconcile(messages))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
invalidate(sessionID: string) {
|
||||||
|
sync.invalidate(`session.message:${sessionID}`)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
permission: {
|
permission: {
|
||||||
list(sessionID: string) {
|
list(sessionID: string) {
|
||||||
return store.session.permission[sessionID]
|
return store.session.permission[sessionID]
|
||||||
},
|
},
|
||||||
async refresh(sessionID: string) {
|
sync(sessionID: string) {
|
||||||
|
return sync.run(`session.permission:${sessionID}`, async () => {
|
||||||
setStore("session", "permission", sessionID, await client.api.permission.list({ sessionID }))
|
setStore("session", "permission", sessionID, await client.api.permission.list({ sessionID }))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
invalidate(sessionID: string) {
|
||||||
|
sync.invalidate(`session.permission:${sessionID}`)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
form: {
|
form: {
|
||||||
@@ -957,23 +988,33 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
const key = locationKey(ref)
|
const key = locationKey(ref)
|
||||||
return forms?.filter((form) => form.location && locationKey(form.location) === key)
|
return forms?.filter((form) => form.location && locationKey(form.location) === key)
|
||||||
},
|
},
|
||||||
async refresh(sessionID: string, ref?: LocationRef) {
|
sync(sessionID: string, ref?: LocationRef) {
|
||||||
|
const key = `session.form:${sessionID}:${sessionID === "global" ? locationKey(ref ?? defaultLocation()) : ""}`
|
||||||
|
return sync.run(key, async () => {
|
||||||
if (sessionID === "global") {
|
if (sessionID === "global") {
|
||||||
const response = await client.api.form.request.list({ location: locationQuery(ref ?? defaultLocation()) })
|
const response = await client.api.form.request.list({
|
||||||
|
location: locationQuery(ref ?? defaultLocation()),
|
||||||
|
})
|
||||||
const location = {
|
const location = {
|
||||||
directory: response.location.directory,
|
directory: response.location.directory,
|
||||||
workspaceID: response.location.workspaceID,
|
workspaceID: response.location.workspaceID,
|
||||||
}
|
}
|
||||||
const key = locationKey(location)
|
const locationID = locationKey(location)
|
||||||
setStore("session", "form", sessionID, [
|
setStore("session", "form", sessionID, [
|
||||||
...(store.session.form[sessionID] ?? []).filter(
|
...(store.session.form[sessionID] ?? []).filter(
|
||||||
(form) => form.location && locationKey(form.location) !== key,
|
(form) => form.location && locationKey(form.location) !== locationID,
|
||||||
),
|
),
|
||||||
...response.data.filter((form) => form.sessionID === "global").map((form) => ({ ...form, location })),
|
...response.data.filter((form) => form.sessionID === "global").map((form) => ({ ...form, location })),
|
||||||
])
|
])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setStore("session", "form", sessionID, await client.api.form.list({ sessionID }))
|
setStore("session", "form", sessionID, await client.api.form.list({ sessionID }))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
invalidate(sessionID: string, ref?: LocationRef) {
|
||||||
|
sync.invalidate(
|
||||||
|
`session.form:${sessionID}:${sessionID === "global" ? locationKey(ref ?? defaultLocation()) : ""}`,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -982,8 +1023,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
list(projectID: string) {
|
list(projectID: string) {
|
||||||
return store.project.permission[projectID]
|
return store.project.permission[projectID]
|
||||||
},
|
},
|
||||||
async refresh(projectID: string) {
|
sync(projectID: string) {
|
||||||
|
return sync.run(`project.permission:${projectID}`, async () => {
|
||||||
setStore("project", "permission", projectID, await client.api.permission.saved.list({ projectID }))
|
setStore("project", "permission", projectID, await client.api.permission.saved.list({ projectID }))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
invalidate(projectID: string) {
|
||||||
|
sync.invalidate(`project.permission:${projectID}`)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -996,53 +1042,109 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
.map((data) => data.shell?.[id])
|
.map((data) => data.shell?.[id])
|
||||||
.find((shell) => shell !== undefined)
|
.find((shell) => shell !== undefined)
|
||||||
},
|
},
|
||||||
async refresh(ref?: LocationRef) {
|
sync(ref?: LocationRef) {
|
||||||
const result = await client.api.shell.list({ location: locationQuery(ref) })
|
const id = locationKey(ref ?? defaultLocation())
|
||||||
const key = locationKey(result.location)
|
return sync.run(`location.shell:${id}`, async () => {
|
||||||
|
const response = await client.api.shell.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||||
|
const key = locationKey(response.location)
|
||||||
setStore("location", key, {
|
setStore("location", key, {
|
||||||
...store.location[key],
|
...store.location[key],
|
||||||
shell: Object.fromEntries(result.data.map((info) => [info.id, info])),
|
shell: Object.fromEntries(response.data.map((info) => [info.id, info])),
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
invalidate(ref?: LocationRef) {
|
||||||
|
sync.invalidate(`location.shell:${locationKey(ref ?? defaultLocation())}`)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
location: {
|
location: {
|
||||||
default() {
|
default() {
|
||||||
return defaultLocation()
|
return defaultLocation()
|
||||||
},
|
},
|
||||||
async refresh(ref?: LocationRef) {
|
async sync(ref?: LocationRef) {
|
||||||
const location = await client.api.location.get({ location: locationQuery(ref ?? defaultLocation()) })
|
const current = ref ?? defaultLocation()
|
||||||
|
await sync.run(`location:${locationKey(current)}`, async () => {
|
||||||
|
const location = await client.api.location.get({ location: locationQuery(current) })
|
||||||
const key = locationKey(location)
|
const key = locationKey(location)
|
||||||
if (!store.location[key]) setStore("location", key, {})
|
if (!store.location[key]) setStore("location", key, {})
|
||||||
if (!ref) setDefaultLocation({ directory: location.directory, workspaceID: location.workspaceID })
|
if (!ref) setDefaultLocation({ directory: location.directory, workspaceID: location.workspaceID })
|
||||||
|
})
|
||||||
|
const location = ref ?? defaultLocation()
|
||||||
|
await Promise.all([
|
||||||
|
result.location.agent.sync(location),
|
||||||
|
result.location.command.sync(location),
|
||||||
|
result.location.integration.sync(location),
|
||||||
|
result.location.mcp.server.sync(location),
|
||||||
|
result.location.mcp.resource.sync(location),
|
||||||
|
result.location.model.sync(location),
|
||||||
|
result.location.provider.sync(location),
|
||||||
|
result.location.reference.sync(location),
|
||||||
|
result.location.skill.sync(location),
|
||||||
|
result.shell.sync(location),
|
||||||
|
result.session.form.sync("global", location),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
invalidate(ref?: LocationRef) {
|
||||||
|
const location = ref ?? defaultLocation()
|
||||||
|
sync.invalidate(`location:${locationKey(location)}`)
|
||||||
|
result.location.agent.invalidate(location)
|
||||||
|
result.location.command.invalidate(location)
|
||||||
|
result.location.integration.invalidate(location)
|
||||||
|
result.location.mcp.server.invalidate(location)
|
||||||
|
result.location.mcp.resource.invalidate(location)
|
||||||
|
result.location.model.invalidate(location)
|
||||||
|
result.location.provider.invalidate(location)
|
||||||
|
result.location.reference.invalidate(location)
|
||||||
|
result.location.skill.invalidate(location)
|
||||||
|
result.shell.invalidate(location)
|
||||||
|
result.session.form.invalidate("global", location)
|
||||||
},
|
},
|
||||||
agent: {
|
agent: {
|
||||||
list(location?: LocationRef) {
|
list(location?: LocationRef) {
|
||||||
return store.location[locationKey(location ?? defaultLocation())]?.agent
|
return store.location[locationKey(location ?? defaultLocation())]?.agent
|
||||||
},
|
},
|
||||||
async refresh(ref?: LocationRef) {
|
sync(ref?: LocationRef) {
|
||||||
const result = await client.api.agent.list({ location: locationQuery(ref ?? defaultLocation()) })
|
const id = locationKey(ref ?? defaultLocation())
|
||||||
const key = locationKey(result.location)
|
return sync.run(`location.agent:${id}`, async () => {
|
||||||
setStore("location", key, { ...store.location[key], agent: result.data })
|
const response = await client.api.agent.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||||
|
const key = locationKey(response.location)
|
||||||
|
setStore("location", key, { ...store.location[key], agent: response.data })
|
||||||
|
})
|
||||||
|
},
|
||||||
|
invalidate(ref?: LocationRef) {
|
||||||
|
sync.invalidate(`location.agent:${locationKey(ref ?? defaultLocation())}`)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
command: {
|
command: {
|
||||||
list(location?: LocationRef) {
|
list(location?: LocationRef) {
|
||||||
return store.location[locationKey(location ?? defaultLocation())]?.command
|
return store.location[locationKey(location ?? defaultLocation())]?.command
|
||||||
},
|
},
|
||||||
async refresh(ref?: LocationRef) {
|
sync(ref?: LocationRef) {
|
||||||
const result = await client.api.command.list({ location: locationQuery(ref ?? defaultLocation()) })
|
const id = locationKey(ref ?? defaultLocation())
|
||||||
const key = locationKey(result.location)
|
return sync.run(`location.command:${id}`, async () => {
|
||||||
setStore("location", key, { ...store.location[key], command: result.data })
|
const response = await client.api.command.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||||
|
const key = locationKey(response.location)
|
||||||
|
setStore("location", key, { ...store.location[key], command: response.data })
|
||||||
|
})
|
||||||
|
},
|
||||||
|
invalidate(ref?: LocationRef) {
|
||||||
|
sync.invalidate(`location.command:${locationKey(ref ?? defaultLocation())}`)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
integration: {
|
integration: {
|
||||||
list(location?: LocationRef) {
|
list(location?: LocationRef) {
|
||||||
return store.location[locationKey(location ?? defaultLocation())]?.integration
|
return store.location[locationKey(location ?? defaultLocation())]?.integration
|
||||||
},
|
},
|
||||||
async refresh(ref?: LocationRef) {
|
sync(ref?: LocationRef) {
|
||||||
const result = await client.api.integration.list({ location: locationQuery(ref ?? defaultLocation()) })
|
const id = locationKey(ref ?? defaultLocation())
|
||||||
const key = locationKey(result.location)
|
return sync.run(`location.integration:${id}`, async () => {
|
||||||
setStore("location", key, { ...store.location[key], integration: result.data })
|
const response = await client.api.integration.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||||
|
const key = locationKey(response.location)
|
||||||
|
setStore("location", key, { ...store.location[key], integration: response.data })
|
||||||
|
})
|
||||||
|
},
|
||||||
|
invalidate(ref?: LocationRef) {
|
||||||
|
sync.invalidate(`location.integration:${locationKey(ref ?? defaultLocation())}`)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mcp: {
|
mcp: {
|
||||||
@@ -1050,26 +1152,40 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
list(location?: LocationRef) {
|
list(location?: LocationRef) {
|
||||||
return store.location[locationKey(location ?? defaultLocation())]?.mcp?.server
|
return store.location[locationKey(location ?? defaultLocation())]?.mcp?.server
|
||||||
},
|
},
|
||||||
async refresh(ref?: LocationRef) {
|
sync(ref?: LocationRef) {
|
||||||
const result = await client.api.mcp.list({ location: locationQuery(ref ?? defaultLocation()) })
|
const id = locationKey(ref ?? defaultLocation())
|
||||||
const key = locationKey(result.location)
|
return sync.run(`location.mcp.server:${id}`, async () => {
|
||||||
|
const response = await client.api.mcp.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||||
|
const key = locationKey(response.location)
|
||||||
setStore("location", key, {
|
setStore("location", key, {
|
||||||
...store.location[key],
|
...store.location[key],
|
||||||
mcp: { ...store.location[key]?.mcp, server: result.data },
|
mcp: { ...store.location[key]?.mcp, server: response.data },
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
invalidate(ref?: LocationRef) {
|
||||||
|
sync.invalidate(`location.mcp.server:${locationKey(ref ?? defaultLocation())}`)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
resource: {
|
resource: {
|
||||||
list(location?: LocationRef) {
|
list(location?: LocationRef) {
|
||||||
return store.location[locationKey(location ?? defaultLocation())]?.mcp?.resource
|
return store.location[locationKey(location ?? defaultLocation())]?.mcp?.resource
|
||||||
},
|
},
|
||||||
async refresh(ref?: LocationRef) {
|
sync(ref?: LocationRef) {
|
||||||
const result = await client.api.mcp.resource.catalog({ location: locationQuery(ref ?? defaultLocation()) })
|
const id = locationKey(ref ?? defaultLocation())
|
||||||
const key = locationKey(result.location)
|
return sync.run(`location.mcp.resource:${id}`, async () => {
|
||||||
|
const response = await client.api.mcp.resource.catalog({
|
||||||
|
location: locationQuery(ref ?? defaultLocation()),
|
||||||
|
})
|
||||||
|
const key = locationKey(response.location)
|
||||||
setStore("location", key, {
|
setStore("location", key, {
|
||||||
...store.location[key],
|
...store.location[key],
|
||||||
mcp: { ...store.location[key]?.mcp, resource: result.data.resources },
|
mcp: { ...store.location[key]?.mcp, resource: response.data.resources },
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
invalidate(ref?: LocationRef) {
|
||||||
|
sync.invalidate(`location.mcp.resource:${locationKey(ref ?? defaultLocation())}`)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1077,50 +1193,89 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
list(location?: LocationRef) {
|
list(location?: LocationRef) {
|
||||||
return store.location[locationKey(location ?? defaultLocation())]?.model
|
return store.location[locationKey(location ?? defaultLocation())]?.model
|
||||||
},
|
},
|
||||||
async refresh(ref?: LocationRef) {
|
sync(ref?: LocationRef) {
|
||||||
const result = await client.api.model.list({ location: locationQuery(ref ?? defaultLocation()) })
|
const id = locationKey(ref ?? defaultLocation())
|
||||||
const key = locationKey(result.location)
|
return sync.run(`location.model:${id}`, async () => {
|
||||||
setStore("location", key, { ...store.location[key], model: result.data })
|
const response = await client.api.model.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||||
|
const key = locationKey(response.location)
|
||||||
|
setStore("location", key, { ...store.location[key], model: response.data })
|
||||||
|
})
|
||||||
|
},
|
||||||
|
invalidate(ref?: LocationRef) {
|
||||||
|
sync.invalidate(`location.model:${locationKey(ref ?? defaultLocation())}`)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
provider: {
|
provider: {
|
||||||
list(location?: LocationRef) {
|
list(location?: LocationRef) {
|
||||||
return store.location[locationKey(location ?? defaultLocation())]?.provider
|
return store.location[locationKey(location ?? defaultLocation())]?.provider
|
||||||
},
|
},
|
||||||
async refresh(ref?: LocationRef) {
|
sync(ref?: LocationRef) {
|
||||||
const result = await client.api.provider.list({ location: locationQuery(ref ?? defaultLocation()) })
|
const id = locationKey(ref ?? defaultLocation())
|
||||||
const key = locationKey(result.location)
|
return sync.run(`location.provider:${id}`, async () => {
|
||||||
setStore("location", key, { ...store.location[key], provider: result.data })
|
const response = await client.api.provider.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||||
|
const key = locationKey(response.location)
|
||||||
|
setStore("location", key, { ...store.location[key], provider: response.data })
|
||||||
|
})
|
||||||
|
},
|
||||||
|
invalidate(ref?: LocationRef) {
|
||||||
|
sync.invalidate(`location.provider:${locationKey(ref ?? defaultLocation())}`)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
reference: {
|
reference: {
|
||||||
list(location?: LocationRef) {
|
list(location?: LocationRef) {
|
||||||
return store.location[locationKey(location ?? defaultLocation())]?.reference
|
return store.location[locationKey(location ?? defaultLocation())]?.reference
|
||||||
},
|
},
|
||||||
async refresh(ref?: LocationRef) {
|
sync(ref?: LocationRef) {
|
||||||
const result = await client.api.reference.list({ location: locationQuery(ref ?? defaultLocation()) })
|
const id = locationKey(ref ?? defaultLocation())
|
||||||
const key = locationKey(result.location)
|
return sync.run(`location.reference:${id}`, async () => {
|
||||||
setStore("location", key, { ...store.location[key], reference: result.data })
|
const response = await client.api.reference.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||||
|
const key = locationKey(response.location)
|
||||||
|
setStore("location", key, { ...store.location[key], reference: response.data })
|
||||||
|
})
|
||||||
|
},
|
||||||
|
invalidate(ref?: LocationRef) {
|
||||||
|
sync.invalidate(`location.reference:${locationKey(ref ?? defaultLocation())}`)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
skill: {
|
skill: {
|
||||||
list(location?: LocationRef) {
|
list(location?: LocationRef) {
|
||||||
return store.location[locationKey(location ?? defaultLocation())]?.skill
|
return store.location[locationKey(location ?? defaultLocation())]?.skill
|
||||||
},
|
},
|
||||||
async refresh(ref?: LocationRef) {
|
sync(ref?: LocationRef) {
|
||||||
const result = await client.api.skill.list({ location: locationQuery(ref ?? defaultLocation()) })
|
const id = locationKey(ref ?? defaultLocation())
|
||||||
const key = locationKey(result.location)
|
return sync.run(`location.skill:${id}`, async () => {
|
||||||
setStore("location", key, { ...store.location[key], skill: result.data })
|
const response = await client.api.skill.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||||
|
const key = locationKey(response.location)
|
||||||
|
setStore("location", key, { ...store.location[key], skill: response.data })
|
||||||
|
})
|
||||||
|
},
|
||||||
|
invalidate(ref?: LocationRef) {
|
||||||
|
sync.invalidate(`location.skill:${locationKey(ref ?? defaultLocation())}`)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
result satisfies Plugin.Context["data"]
|
result satisfies Plugin.Context["data"]
|
||||||
|
|
||||||
async function bootstrap() {
|
createEffect(() => {
|
||||||
if (bootstrapping) return bootstrapping
|
if (client.connection.status() === "connected") return
|
||||||
bootstrapping = Promise.allSettled([
|
sync.invalidate()
|
||||||
client.api.session
|
})
|
||||||
|
|
||||||
|
onCleanup(
|
||||||
|
client.event.listen(({ details }) => {
|
||||||
|
if (details.type === "server.connected") {
|
||||||
|
void client.api.session
|
||||||
|
.active()
|
||||||
|
.then((active) => {
|
||||||
|
setStore(
|
||||||
|
"session",
|
||||||
|
"active",
|
||||||
|
reconcile(Object.fromEntries(Object.keys(active).map((sessionID) => [sessionID, "running" as const]))),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
void client.api.session
|
||||||
.list({
|
.list({
|
||||||
limit: 50,
|
limit: 50,
|
||||||
order: "desc",
|
order: "desc",
|
||||||
@@ -1135,95 +1290,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||||||
for (const session of response.data) draft[session.id] = session
|
for (const session of response.data) draft[session.id] = session
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
for (const session of response.data) registerSession(session.id)
|
for (const session of response.data) {
|
||||||
}),
|
sync.complete(`session:${session.id}`)
|
||||||
client.api.permission.request.list({ location: locationQuery(defaultLocation()) }).then((response) => {
|
registerSession(session.id)
|
||||||
const permissions = response.data.reduce<Record<string, PermissionV2Request[]>>(
|
|
||||||
(result, request) => ({
|
|
||||||
...result,
|
|
||||||
[request.sessionID]: [...(result[request.sessionID] ?? []), request],
|
|
||||||
}),
|
|
||||||
{},
|
|
||||||
)
|
|
||||||
setStore("session", "permission", reconcile(permissions))
|
|
||||||
}),
|
|
||||||
client.api.form.request.list({ location: locationQuery(defaultLocation()) }).then((response) => {
|
|
||||||
const location = {
|
|
||||||
directory: response.location.directory,
|
|
||||||
workspaceID: response.location.workspaceID,
|
|
||||||
}
|
}
|
||||||
const forms = response.data.reduce<Record<string, FormWithLocation[]>>(
|
|
||||||
(result, form) => ({
|
|
||||||
...result,
|
|
||||||
[form.sessionID]: [
|
|
||||||
...(result[form.sessionID] ?? []),
|
|
||||||
form.sessionID === "global" ? { ...form, location } : form,
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
{},
|
|
||||||
)
|
|
||||||
setStore("session", "form", reconcile(forms))
|
|
||||||
}),
|
|
||||||
result.location.refresh(),
|
|
||||||
result.location.agent.refresh(),
|
|
||||||
result.location.integration.refresh(),
|
|
||||||
result.location.mcp.server.refresh(),
|
|
||||||
result.location.mcp.resource.refresh(),
|
|
||||||
result.location.model.refresh(),
|
|
||||||
result.location.provider.refresh(),
|
|
||||||
result.location.reference.refresh(),
|
|
||||||
result.location.command.refresh(),
|
|
||||||
result.location.skill.refresh(),
|
|
||||||
result.shell.refresh(),
|
|
||||||
])
|
|
||||||
.then(async (settled) => {
|
|
||||||
for (const failure of settled.filter((item) => item.status === "rejected"))
|
|
||||||
console.error("Failed to refresh default location data", failure.reason)
|
|
||||||
const key = locationKey(defaultLocation())
|
|
||||||
const locations = new Map(
|
|
||||||
Object.values(store.session.info).map(
|
|
||||||
(session) => [locationKey(session.location), session.location] as const,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const refreshed = await Promise.allSettled(
|
|
||||||
Array.from(locations)
|
|
||||||
.filter(([location]) => location !== key)
|
|
||||||
.map(([, location]) => result.session.form.refresh("global", location)),
|
|
||||||
)
|
|
||||||
for (const failure of refreshed.filter((item) => item.status === "rejected"))
|
|
||||||
console.error("Failed to refresh global forms", failure.reason)
|
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.catch((error) => console.error("Failed to preload sessions", error))
|
||||||
bootstrapping = undefined
|
|
||||||
})
|
|
||||||
return bootstrapping
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshActive() {
|
|
||||||
void client.api.session
|
|
||||||
.active()
|
|
||||||
.then((active) => {
|
|
||||||
setStore(
|
|
||||||
"session",
|
|
||||||
"status",
|
|
||||||
reconcile(Object.fromEntries(Object.keys(active).map((sessionID) => [sessionID, "running" as const]))),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.catch(() => undefined)
|
|
||||||
}
|
|
||||||
|
|
||||||
onCleanup(
|
|
||||||
client.event.listen(({ details }) => {
|
|
||||||
if (details.type === "server.connected") {
|
|
||||||
const messages = connected ? Object.keys(store.session.message) : []
|
|
||||||
const compactions = connected ? Object.keys(store.session.compaction) : []
|
|
||||||
connected = true
|
|
||||||
refreshActive()
|
|
||||||
void Promise.allSettled([
|
|
||||||
bootstrap(),
|
|
||||||
...messages.map(result.session.message.refresh),
|
|
||||||
...compactions.map(result.session.compaction.refresh),
|
|
||||||
])
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handleEvent(details)
|
handleEvent(details)
|
||||||
|
|||||||
@@ -1,13 +1,35 @@
|
|||||||
import type { LocationRef } from "@opencode-ai/client"
|
import type { LocationRef } from "@opencode-ai/client"
|
||||||
import { createContext, createSignal, useContext, type Accessor, type ParentProps, type Setter } from "solid-js"
|
import { createContext, createSignal, onCleanup, useContext, type Accessor, type ParentProps } from "solid-js"
|
||||||
|
import { useClient } from "./client"
|
||||||
|
import { useData } from "./data"
|
||||||
|
|
||||||
const context = createContext<{
|
const context = createContext<{
|
||||||
current: Accessor<LocationRef | undefined>
|
current: Accessor<LocationRef | undefined>
|
||||||
set: Setter<LocationRef | undefined>
|
set: (location?: LocationRef) => void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
export function LocationProvider(props: ParentProps) {
|
export function LocationProvider(props: ParentProps) {
|
||||||
const [current, set] = createSignal<LocationRef>()
|
const client = useClient()
|
||||||
|
const data = useData()
|
||||||
|
const [current, setCurrent] = createSignal<LocationRef>()
|
||||||
|
|
||||||
|
function sync(location?: LocationRef) {
|
||||||
|
if (!location) return
|
||||||
|
const defaultLocation = data.location.default()
|
||||||
|
const target =
|
||||||
|
location.directory === defaultLocation.directory && location.workspaceID === defaultLocation.workspaceID
|
||||||
|
? undefined
|
||||||
|
: location
|
||||||
|
void data.location.sync(target).catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
function set(location?: LocationRef) {
|
||||||
|
setCurrent(location)
|
||||||
|
if (client.connection.status() === "connected") sync(location)
|
||||||
|
}
|
||||||
|
|
||||||
|
onCleanup(client.event.on("server.connected", () => sync(current())))
|
||||||
|
|
||||||
return <context.Provider value={{ current, set }}>{props.children}</context.Provider>
|
return <context.Provider value={{ current, set }}>{props.children}</context.Provider>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -184,23 +184,22 @@ export function Session() {
|
|||||||
const rows = createSessionRows(() => route.sessionID)
|
const rows = createSessionRows(() => route.sessionID)
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(descendantSessionIDs, (sessionIDs) => {
|
on([descendantSessionIDs, () => client.connection.status()], ([sessionIDs, status]) => {
|
||||||
|
if (status !== "connected") return
|
||||||
void Promise.all(
|
void Promise.all(
|
||||||
sessionIDs.flatMap((sessionID) => [
|
sessionIDs.flatMap((sessionID) => [data.session.permission.sync(sessionID), data.session.form.sync(sessionID)]),
|
||||||
data.session.permission.refresh(sessionID),
|
|
||||||
data.session.form.refresh(sessionID),
|
|
||||||
]),
|
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
if (client.connection.status() !== "connected") return
|
||||||
const sessionID = route.sessionID
|
const sessionID = route.sessionID
|
||||||
void (async () => {
|
void (async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
data.session.refresh(sessionID),
|
data.session.sync(sessionID),
|
||||||
data.session.permission.refresh(sessionID),
|
data.session.permission.sync(sessionID),
|
||||||
data.session.form.refresh(sessionID),
|
data.session.form.sync(sessionID),
|
||||||
])
|
])
|
||||||
const info = data.session.get(sessionID)
|
const info = data.session.get(sessionID)
|
||||||
if (!info) {
|
if (!info) {
|
||||||
@@ -212,13 +211,6 @@ export function Session() {
|
|||||||
navigate({ type: "home" })
|
navigate({ type: "home" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
void data.session.form.refresh("global", info.location).catch((error) =>
|
|
||||||
toast.show({
|
|
||||||
message: `Failed to refresh global forms: ${errorMessage(error)}`,
|
|
||||||
variant: "error",
|
|
||||||
duration: 5000,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
project.workspace.set(info.location.workspaceID)
|
project.workspace.set(info.location.workspaceID)
|
||||||
editor.reconnect(info.location.directory)
|
editor.reconnect(info.location.directory)
|
||||||
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
|
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/c
|
|||||||
import { createEffect, on, onCleanup, type Accessor } from "solid-js"
|
import { createEffect, on, onCleanup, type Accessor } from "solid-js"
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { useData } from "../../context/data"
|
import { useData } from "../../context/data"
|
||||||
|
import { useClient } from "../../context/client"
|
||||||
|
|
||||||
export type PartRef = {
|
export type PartRef = {
|
||||||
messageID: string
|
messageID: string
|
||||||
@@ -29,6 +30,7 @@ export type SessionRow =
|
|||||||
|
|
||||||
export function createSessionRows(sessionID: Accessor<string>) {
|
export function createSessionRows(sessionID: Accessor<string>) {
|
||||||
const data = useData()
|
const data = useData()
|
||||||
|
const client = useClient()
|
||||||
const [rows, setRows] = createStore<SessionRow[]>([])
|
const [rows, setRows] = createStore<SessionRow[]>([])
|
||||||
const revertBoundary = () => data.session.get(sessionID())?.revert?.messageID
|
const revertBoundary = () => data.session.get(sessionID())?.revert?.messageID
|
||||||
|
|
||||||
@@ -42,9 +44,10 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
|||||||
rows.splice(
|
rows.splice(
|
||||||
position === -1 ? rows.length : position,
|
position === -1 ? rows.length : position,
|
||||||
0,
|
0,
|
||||||
...data.session.compaction
|
...data.session.pending
|
||||||
.list(sessionID())
|
.list(sessionID())
|
||||||
.map((inputID): SessionRow => ({ type: "compaction-queued", inputID })),
|
.filter((item) => item.type === "compaction")
|
||||||
|
.map((item): SessionRow => ({ type: "compaction-queued", inputID: item.id })),
|
||||||
)
|
)
|
||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
@@ -67,10 +70,11 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(sessionID, (id) => {
|
on([sessionID, () => client.connection.status()], ([id, status]) => {
|
||||||
|
if (status !== "connected") return
|
||||||
setRows(reconcile(reduce()))
|
setRows(reconcile(reduce()))
|
||||||
void data.session.compaction.refresh(id).catch(() => undefined)
|
void data.session.pending.sync(id).catch(() => undefined)
|
||||||
void data.session.message.refresh(id).then(
|
void data.session.message.sync(id).then(
|
||||||
() => {
|
() => {
|
||||||
if (sessionID() !== id) return
|
if (sessionID() !== id) return
|
||||||
setRows(reconcile(reduce()))
|
setRows(reconcile(reduce()))
|
||||||
@@ -89,7 +93,11 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
|||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => data.session.compaction.list(sessionID()).map((inputID) => inputID),
|
() =>
|
||||||
|
data.session.pending
|
||||||
|
.list(sessionID())
|
||||||
|
.filter((item) => item.type === "compaction")
|
||||||
|
.map((item) => item.id),
|
||||||
() => setRows(reconcile(reduce())),
|
() => setRows(reconcile(reduce())),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import { testRender } from "@opentui/solid"
|
|||||||
import type { OpenCodeEvent } from "@opencode-ai/client"
|
import type { OpenCodeEvent } from "@opencode-ai/client"
|
||||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
import { onMount } from "solid-js"
|
import { createEffect, onMount, type ParentProps } from "solid-js"
|
||||||
import { ProjectProvider } from "../../../src/context/project"
|
import { ProjectProvider } from "../../../src/context/project"
|
||||||
import { ClientProvider, useClient } from "../../../src/context/client"
|
import { ClientProvider, useClient } from "../../../src/context/client"
|
||||||
import { DataProvider, useData } from "../../../src/context/data"
|
import { DataProvider as DataProviderBase, useData } from "../../../src/context/data"
|
||||||
|
import { LocationProvider, useSetLocation } from "../../../src/context/location"
|
||||||
import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows"
|
import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows"
|
||||||
import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client"
|
import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client"
|
||||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||||
@@ -32,6 +33,24 @@ function emitEvent(events: ReturnType<typeof createEventStream>, event: OpenCode
|
|||||||
events.emit({ ...event, location: { directory } })
|
events.emit({ ...event, location: { directory } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DataProvider(props: ParentProps) {
|
||||||
|
return (
|
||||||
|
<DataProviderBase>
|
||||||
|
<LocationProvider>
|
||||||
|
<SyncLocation />
|
||||||
|
{props.children}
|
||||||
|
</LocationProvider>
|
||||||
|
</DataProviderBase>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SyncLocation() {
|
||||||
|
const data = useData()
|
||||||
|
const setLocation = useSetLocation()
|
||||||
|
createEffect(() => setLocation(data.location.default()))
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function durable(sessionID: string, seq?: number): { aggregateID: string; seq: number; version: 1 }
|
function durable(sessionID: string, seq?: number): { aggregateID: string; seq: number; version: 1 }
|
||||||
function durable<const Version extends number>(
|
function durable<const Version extends number>(
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
@@ -64,16 +83,13 @@ test("bootstraps MCP data for the TUI location", async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await wait(() => requests.length === 2)
|
await wait(() => requests.length === 2)
|
||||||
expect(requests.map((url) => url.searchParams.get("location[directory]"))).toEqual([
|
expect(requests.map((url) => url.searchParams.get("location[directory]"))).toEqual([directory, directory])
|
||||||
process.cwd(),
|
|
||||||
process.cwd(),
|
|
||||||
])
|
|
||||||
} finally {
|
} finally {
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("refreshes MCP status when a connection settles during bootstrap", async () => {
|
test("syncs MCP status when a connection settles during bootstrap", async () => {
|
||||||
const events = createEventStream()
|
const events = createEventStream()
|
||||||
let mcpRequests = 0
|
let mcpRequests = 0
|
||||||
let resolveModels!: (response: Response) => void
|
let resolveModels!: (response: Response) => void
|
||||||
@@ -190,9 +206,9 @@ test("refreshes resources into reactive getters", async () => {
|
|||||||
expect(data.session.get("ses_test")).toBeUndefined()
|
expect(data.session.get("ses_test")).toBeUndefined()
|
||||||
expect(data.location.agent.list(location)).toBeUndefined()
|
expect(data.location.agent.list(location)).toBeUndefined()
|
||||||
|
|
||||||
await data.session.refresh("ses_test")
|
await data.session.sync("ses_test")
|
||||||
await data.session.message.refresh("ses_test")
|
await data.session.message.sync("ses_test")
|
||||||
await data.location.agent.refresh()
|
await data.location.agent.sync()
|
||||||
|
|
||||||
expect(data.session.get("ses_test")?.title).toBe("Test session")
|
expect(data.session.get("ses_test")?.title).toBe("Test session")
|
||||||
expect(data.session.message.list("ses_test").map((message) => message.id)).toEqual(["msg_first", "msg_second"])
|
expect(data.session.message.list("ses_test").map((message) => message.id)).toEqual(["msg_first", "msg_second"])
|
||||||
@@ -243,7 +259,7 @@ test("applies absolute usage events to session info", async () => {
|
|||||||
))
|
))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await data.session.refresh(sessionID)
|
await data.session.sync(sessionID)
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_usage_2",
|
id: "evt_usage_2",
|
||||||
created: 2,
|
created: 2,
|
||||||
@@ -328,7 +344,7 @@ test("truncates committed revert messages without changing lifetime usage", asyn
|
|||||||
))
|
))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await data.session.refresh(sessionID)
|
await data.session.sync(sessionID)
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_revert_boundary_started",
|
id: "evt_revert_boundary_started",
|
||||||
created: 1,
|
created: 1,
|
||||||
@@ -467,7 +483,7 @@ test("updates session location when moved", async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await mounted
|
await mounted
|
||||||
await data.session.refresh("ses_test")
|
await data.session.sync("ses_test")
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_moved_1",
|
id: "evt_moved_1",
|
||||||
created: 1,
|
created: 1,
|
||||||
@@ -525,7 +541,7 @@ test("restores running manual compaction before applying live deltas", async ()
|
|||||||
))
|
))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await data.session.message.refresh("session-compaction")
|
await data.session.message.sync("session-compaction")
|
||||||
expect(data.session.message.get("session-compaction", "message-compaction")).toMatchObject({
|
expect(data.session.message.get("session-compaction", "message-compaction")).toMatchObject({
|
||||||
type: "compaction",
|
type: "compaction",
|
||||||
status: "running",
|
status: "running",
|
||||||
@@ -548,7 +564,7 @@ test("restores running manual compaction before applying live deltas", async ()
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("reconnects the event stream and bootstraps fresh data", async () => {
|
test("reconnects the event stream and resyncs active data", async () => {
|
||||||
const events = createEventStream()
|
const events = createEventStream()
|
||||||
const requests = { active: 0, event: 0, message: 0, model: 0 }
|
const requests = { active: 0, event: 0, message: 0, model: 0 }
|
||||||
let resolveActive!: (response: Response) => void
|
let resolveActive!: (response: Response) => void
|
||||||
@@ -621,7 +637,7 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
|
|||||||
try {
|
try {
|
||||||
await wait(() => data.location.model.list()?.[0]?.id === "model-1")
|
await wait(() => data.location.model.list()?.[0]?.id === "model-1")
|
||||||
await wait(() => data.session.status("session-stale") === "running")
|
await wait(() => data.session.status("session-stale") === "running")
|
||||||
await data.session.message.refresh("session-stale")
|
await data.session.message.sync("session-stale")
|
||||||
expect(data.session.message.get("session-stale", "message-stale")?.id).toBe("message-stale")
|
expect(data.session.message.get("session-stale", "message-stale")?.id).toBe("message-stale")
|
||||||
expect(client.connection.status()).toBe("connected")
|
expect(client.connection.status()).toBe("connected")
|
||||||
expect(client.connection.attempt()).toBe(0)
|
expect(client.connection.attempt()).toBe(0)
|
||||||
@@ -633,6 +649,7 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
|
|||||||
|
|
||||||
await wait(() => requests.active === 2 && client.connection.status() === "connected", 4000)
|
await wait(() => requests.active === 2 && client.connection.status() === "connected", 4000)
|
||||||
resolveActive(json({ data: { "session-new": { type: "running" } } }))
|
resolveActive(json({ data: { "session-new": { type: "running" } } }))
|
||||||
|
void data.session.message.sync("session-stale")
|
||||||
|
|
||||||
await wait(() => data.location.model.list()?.[0]?.id === "model-2", 4000)
|
await wait(() => data.location.model.list()?.[0]?.id === "model-2", 4000)
|
||||||
await wait(() => data.session.status("session-stale") === "idle")
|
await wait(() => data.session.status("session-stale") === "idle")
|
||||||
@@ -664,8 +681,10 @@ test("completes exploration when a queued prompt is promoted", async () => {
|
|||||||
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
|
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
|
||||||
}, events)
|
}, events)
|
||||||
let rows!: ReturnType<typeof createSessionRows>
|
let rows!: ReturnType<typeof createSessionRows>
|
||||||
|
let client!: ReturnType<typeof useClient>
|
||||||
|
|
||||||
function Probe() {
|
function Probe() {
|
||||||
|
client = useClient()
|
||||||
rows = createSessionRows(() => sessionID)
|
rows = createSessionRows(() => sessionID)
|
||||||
return <box />
|
return <box />
|
||||||
}
|
}
|
||||||
@@ -683,6 +702,7 @@ test("completes exploration when a queued prompt is promoted", async () => {
|
|||||||
))
|
))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await wait(() => client.connection.status() === "connected")
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_step_started",
|
id: "evt_step_started",
|
||||||
created: 1,
|
created: 1,
|
||||||
@@ -954,7 +974,7 @@ test("tracks session status from active sessions and execution events", async ()
|
|||||||
try {
|
try {
|
||||||
await wait(() => data.session.status("session-active") === "running")
|
await wait(() => data.session.status("session-active") === "running")
|
||||||
expect(data.session.status("session-idle")).toBe("idle")
|
expect(data.session.status("session-idle")).toBe("idle")
|
||||||
await data.session.refresh("session-live")
|
await data.session.sync("session-live")
|
||||||
|
|
||||||
settled = true
|
settled = true
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
@@ -1021,7 +1041,7 @@ test("tracks session status from active sessions and execution events", async ()
|
|||||||
})
|
})
|
||||||
await wait(() => data.session.status("session-live") === "idle")
|
await wait(() => data.session.status("session-live") === "idle")
|
||||||
|
|
||||||
await data.session.refresh("session-failed")
|
await data.session.sync("session-failed")
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_failed_execution_started",
|
id: "evt_failed_execution_started",
|
||||||
created: 0,
|
created: 0,
|
||||||
@@ -1184,7 +1204,7 @@ test("tracks session status from active sessions and execution events", async ()
|
|||||||
durable: durable("session-manual", 1),
|
durable: durable("session-manual", 1),
|
||||||
data: { sessionID: "session-manual", inputID: "message-compaction" },
|
data: { sessionID: "session-manual", inputID: "message-compaction" },
|
||||||
})
|
})
|
||||||
await wait(() => data.session.compaction.list("session-manual").includes("message-compaction"))
|
await wait(() => data.session.pending.list("session-manual").some((item) => item.id === "message-compaction"))
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_manual_compaction_started",
|
id: "evt_manual_compaction_started",
|
||||||
created: 1,
|
created: 1,
|
||||||
@@ -1202,10 +1222,8 @@ test("tracks session status from active sessions and execution events", async ()
|
|||||||
const message = data.session.message.get("session-manual", "message-compaction")
|
const message = data.session.message.get("session-manual", "message-compaction")
|
||||||
return message?.type === "compaction" && message.status === "running" && message.summary === "Streamed summary"
|
return message?.type === "compaction" && message.status === "running" && message.summary === "Streamed summary"
|
||||||
})
|
})
|
||||||
expect(data.session.compaction.list("session-manual")).toEqual([])
|
expect(data.session.pending.list("session-manual")).toEqual([])
|
||||||
const compactionRow = manualRows.find(
|
const compactionRow = manualRows.find((row) => row.type === "message" && row.messageID === "message-compaction")
|
||||||
(row) => row.type === "message" && row.messageID === "message-compaction",
|
|
||||||
)
|
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_manual_compaction_ended",
|
id: "evt_manual_compaction_ended",
|
||||||
created: 3,
|
created: 3,
|
||||||
@@ -1247,9 +1265,7 @@ test("tracks session status from active sessions and execution events", async ()
|
|||||||
const message = data.session.message.get("session-live", "msg_compaction_started")
|
const message = data.session.message.get("session-live", "msg_compaction_started")
|
||||||
return message?.type === "compaction" && message.status === "running" && message.summary === "Live summary"
|
return message?.type === "compaction" && message.status === "running" && message.summary === "Live summary"
|
||||||
})
|
})
|
||||||
const autoCompactionRow = rows.find(
|
const autoCompactionRow = rows.find((row) => row.type === "message" && row.messageID === "msg_compaction_started")
|
||||||
(row) => row.type === "message" && row.messageID === "msg_compaction_started",
|
|
||||||
)
|
|
||||||
|
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_compaction_ended",
|
id: "evt_compaction_ended",
|
||||||
@@ -1301,9 +1317,11 @@ test("restores queued compaction from durable pending input", async () => {
|
|||||||
}, events)
|
}, events)
|
||||||
let data!: ReturnType<typeof useData>
|
let data!: ReturnType<typeof useData>
|
||||||
let rows!: ReturnType<typeof createSessionRows>
|
let rows!: ReturnType<typeof createSessionRows>
|
||||||
|
let client!: ReturnType<typeof useClient>
|
||||||
|
|
||||||
function Probe() {
|
function Probe() {
|
||||||
data = useData()
|
data = useData()
|
||||||
|
client = useClient()
|
||||||
rows = createSessionRows(() => sessionID)
|
rows = createSessionRows(() => sessionID)
|
||||||
return <box />
|
return <box />
|
||||||
}
|
}
|
||||||
@@ -1321,8 +1339,9 @@ test("restores queued compaction from durable pending input", async () => {
|
|||||||
))
|
))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await wait(() => data.session.compaction.list(sessionID).length === 2)
|
await wait(() => client.connection.status() === "connected")
|
||||||
expect(data.session.compaction.list(sessionID)).toEqual([
|
await wait(() => data.session.pending.list(sessionID).length === 2)
|
||||||
|
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual([
|
||||||
"message-compaction-queued",
|
"message-compaction-queued",
|
||||||
"message-compaction-later",
|
"message-compaction-later",
|
||||||
])
|
])
|
||||||
@@ -1359,8 +1378,8 @@ test("restores queued compaction from durable pending input", async () => {
|
|||||||
inputID: "message-compaction-queued",
|
inputID: "message-compaction-queued",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await wait(() => data.session.compaction.list(sessionID).length === 1)
|
await wait(() => data.session.pending.list(sessionID).length === 1)
|
||||||
expect(data.session.compaction.list(sessionID)).toEqual(["message-compaction-later"])
|
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["message-compaction-later"])
|
||||||
|
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
id: "evt_compaction_ended",
|
id: "evt_compaction_ended",
|
||||||
@@ -1369,15 +1388,12 @@ test("restores queued compaction from durable pending input", async () => {
|
|||||||
durable: durable(sessionID, 5),
|
durable: durable(sessionID, 5),
|
||||||
data: { sessionID, reason: "manual", text: "Summary", recent: "" },
|
data: { sessionID, reason: "manual", text: "Summary", recent: "" },
|
||||||
})
|
})
|
||||||
expect(data.session.compaction.list(sessionID)).toEqual(["message-compaction-later"])
|
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["message-compaction-later"])
|
||||||
|
|
||||||
pending = []
|
pending = []
|
||||||
emitEvent(events, {
|
data.session.pending.invalidate(sessionID)
|
||||||
id: "evt_reconnected",
|
await data.session.pending.sync(sessionID)
|
||||||
type: "server.connected",
|
await wait(() => data.session.pending.list(sessionID).length === 0)
|
||||||
data: {},
|
|
||||||
})
|
|
||||||
await wait(() => data.session.compaction.list(sessionID).length === 0)
|
|
||||||
} finally {
|
} finally {
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
@@ -1689,7 +1705,7 @@ test("keeps shell state scoped to location", async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await wait(() => data.shell.list().some((shell) => shell.id === "sh_default"))
|
await wait(() => data.shell.list().some((shell) => shell.id === "sh_default"))
|
||||||
await data.shell.refresh({ directory: other })
|
await data.shell.sync({ directory: other })
|
||||||
|
|
||||||
expect(data.shell.list().map((shell) => shell.id)).toEqual(["sh_default"])
|
expect(data.shell.list().map((shell) => shell.id)).toEqual(["sh_default"])
|
||||||
expect(data.shell.list({ directory: other }).map((shell) => shell.id)).toEqual(["sh_other"])
|
expect(data.shell.list({ directory: other }).map((shell) => shell.id)).toEqual(["sh_other"])
|
||||||
@@ -1790,22 +1806,27 @@ test("adds and dismisses permission requests from live events", async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("reconciles all pending permission requests when the event stream reconnects", async () => {
|
test("reconciles active session permissions when the event stream reconnects", async () => {
|
||||||
const events = createEventStream()
|
const events = createEventStream()
|
||||||
let requests = [
|
let requests = [
|
||||||
{ id: "per_old", sessionID: "ses_old", action: "read", resources: ["old.txt"] },
|
{ id: "per_old", sessionID: "ses_active", action: "read", resources: ["old.txt"] },
|
||||||
{ id: "per_keep", sessionID: "ses_keep", action: "shell", resources: ["bun test"] },
|
{ id: "per_keep", sessionID: "ses_active", action: "shell", resources: ["bun test"] },
|
||||||
]
|
]
|
||||||
let calls = 0
|
let calls = 0
|
||||||
const fetch = createFetch((url) => {
|
const fetch = createFetch((url) => {
|
||||||
if (url.pathname !== "/api/permission/request") return
|
if (url.pathname !== "/api/session/ses_active/permission") return
|
||||||
calls++
|
calls++
|
||||||
return json({ location: { directory, project: { id: "proj_test", directory } }, data: requests })
|
return json({ data: requests })
|
||||||
}, events)
|
}, events)
|
||||||
let data!: ReturnType<typeof useData>
|
let data!: ReturnType<typeof useData>
|
||||||
|
|
||||||
function Probe() {
|
function Probe() {
|
||||||
data = useData()
|
data = useData()
|
||||||
|
const client = useClient()
|
||||||
|
createEffect(() => {
|
||||||
|
if (client.connection.status() !== "connected") return
|
||||||
|
void data.session.permission.sync("ses_active")
|
||||||
|
})
|
||||||
return <box />
|
return <box />
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1822,15 +1843,12 @@ test("reconciles all pending permission requests when the event stream reconnect
|
|||||||
))
|
))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await wait(() => data.session.permission.list("ses_old")?.[0]?.id === "per_old")
|
await wait(() => data.session.permission.list("ses_active")?.length === 2)
|
||||||
expect(data.session.permission.list("ses_keep")?.[0]?.id).toBe("per_keep")
|
|
||||||
|
|
||||||
requests = [{ id: "per_new", sessionID: "ses_new", action: "edit", resources: ["new.txt"] }]
|
requests = [{ id: "per_new", sessionID: "ses_active", action: "edit", resources: ["new.txt"] }]
|
||||||
events.disconnect()
|
events.disconnect()
|
||||||
|
|
||||||
await wait(() => calls === 2 && data.session.permission.list("ses_new")?.[0]?.id === "per_new")
|
await wait(() => calls === 2 && data.session.permission.list("ses_active")?.[0]?.id === "per_new")
|
||||||
expect(data.session.permission.list("ses_old")).toBeUndefined()
|
|
||||||
expect(data.session.permission.list("ses_keep")).toBeUndefined()
|
|
||||||
} finally {
|
} finally {
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
@@ -1903,7 +1921,7 @@ test("adds, dismisses, and refreshes form requests", async () => {
|
|||||||
})
|
})
|
||||||
await wait(() => data.session.form.list("ses_1")?.length === 0)
|
await wait(() => data.session.form.list("ses_1")?.length === 0)
|
||||||
|
|
||||||
await data.session.form.refresh("ses_1")
|
await data.session.form.sync("ses_1")
|
||||||
expect(data.session.form.list("ses_1")?.map((form) => form.id)).toEqual(["frm_remote"])
|
expect(data.session.form.list("ses_1")?.map((form) => form.id)).toEqual(["frm_remote"])
|
||||||
} finally {
|
} finally {
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
@@ -1975,7 +1993,7 @@ test("tracks global forms by location", async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("refreshes global forms for the requested location", async () => {
|
test("syncs global forms once for each requested location", async () => {
|
||||||
const events = createEventStream()
|
const events = createEventStream()
|
||||||
const requests: URL[] = []
|
const requests: URL[] = []
|
||||||
const other = { directory: "/tmp/opencode-other", workspaceID: "wrk_other" }
|
const other = { directory: "/tmp/opencode-other", workspaceID: "wrk_other" }
|
||||||
@@ -2025,20 +2043,24 @@ test("refreshes global forms for the requested location", async () => {
|
|||||||
await wait(() => client.connection.status() === "connected" && requests.length > 0)
|
await wait(() => client.connection.status() === "connected" && requests.length > 0)
|
||||||
requests.length = 0
|
requests.length = 0
|
||||||
|
|
||||||
await data.session.form.refresh("global", { directory })
|
await data.session.form.sync("global", { directory })
|
||||||
await data.session.form.refresh("global", other)
|
await data.session.form.sync("global", other)
|
||||||
|
|
||||||
expect(requests).toHaveLength(2)
|
expect(requests).toHaveLength(1)
|
||||||
expect(requests[1]?.searchParams.get("location[directory]")).toBe(other.directory)
|
expect(requests[0]?.searchParams.get("location[directory]")).toBe(other.directory)
|
||||||
expect(requests[1]?.searchParams.get("location[workspace]")).toBe(other.workspaceID)
|
expect(requests[0]?.searchParams.get("location[workspace]")).toBe(other.workspaceID)
|
||||||
expect(data.session.form.list("global", other)?.map((form) => form.id)).toEqual(["frm_other"])
|
expect(data.session.form.list("global", other)?.map((form) => form.id)).toEqual(["frm_other"])
|
||||||
expect(data.session.form.list("global", { directory })?.map((form) => form.id)).toEqual(["frm_default"])
|
expect(data.session.form.list("global", { directory })?.map((form) => form.id)).toEqual(["frm_default"])
|
||||||
|
|
||||||
|
data.session.form.invalidate("global", other)
|
||||||
|
await data.session.form.sync("global", other)
|
||||||
|
expect(requests).toHaveLength(2)
|
||||||
} finally {
|
} finally {
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("refreshes global forms once per loaded location after reconnect", async () => {
|
test("resyncs global forms only for the active location after reconnect", async () => {
|
||||||
const events = createEventStream()
|
const events = createEventStream()
|
||||||
const requests: URL[] = []
|
const requests: URL[] = []
|
||||||
const counts = new Map<string, number>()
|
const counts = new Map<string, number>()
|
||||||
@@ -2098,58 +2120,54 @@ test("refreshes global forms once per loaded location after reconnect", async ()
|
|||||||
))
|
))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await wait(
|
await wait(() => data.session.form.list("global", home)?.[0]?.id === "frm_default_1")
|
||||||
() =>
|
await data.session.form.sync("global", other)
|
||||||
data.session.form.list("global", home)?.[0]?.id === "frm_default_1" &&
|
expect(data.session.form.list("global", other)?.[0]?.id).toBe("frm_other_1")
|
||||||
data.session.form.list("global", other)?.[0]?.id === "frm_other_1",
|
|
||||||
)
|
|
||||||
expect(requests).toHaveLength(2)
|
expect(requests).toHaveLength(2)
|
||||||
requests.length = 0
|
requests.length = 0
|
||||||
|
|
||||||
events.disconnect()
|
events.disconnect()
|
||||||
|
|
||||||
await wait(
|
await wait(() => data.session.form.list("global", home)?.[0]?.id === "frm_default_2", 4000)
|
||||||
() =>
|
expect(data.session.form.list("global", other)?.[0]?.id).toBe("frm_other_1")
|
||||||
data.session.form.list("global", home)?.[0]?.id === "frm_default_2" &&
|
expect(requests).toHaveLength(1)
|
||||||
data.session.form.list("global", other)?.[0]?.id === "frm_other_2",
|
|
||||||
4000,
|
|
||||||
)
|
|
||||||
expect(requests).toHaveLength(2)
|
|
||||||
expect(
|
expect(
|
||||||
requests.map((url) => [
|
requests.map((url) => [
|
||||||
url.searchParams.get("location[directory]") ?? directory,
|
url.searchParams.get("location[directory]") ?? directory,
|
||||||
url.searchParams.get("location[workspace]") ?? undefined,
|
url.searchParams.get("location[workspace]") ?? undefined,
|
||||||
]),
|
]),
|
||||||
).toEqual([
|
).toEqual([[home.directory, undefined]])
|
||||||
[home.directory, undefined],
|
|
||||||
[other.directory, other.workspaceID],
|
|
||||||
])
|
|
||||||
} finally {
|
} finally {
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("reconciles all pending form requests when the event stream reconnects", async () => {
|
test("reconciles active session forms when the event stream reconnects", async () => {
|
||||||
const events = createEventStream()
|
const events = createEventStream()
|
||||||
let requests = [
|
let requests = [
|
||||||
{ id: "frm_old", sessionID: "ses_old", title: "Input requested", fields: formFields },
|
{ id: "frm_old", sessionID: "ses_active", title: "Input requested", fields: formFields },
|
||||||
{
|
{
|
||||||
id: "frm_keep",
|
id: "frm_keep",
|
||||||
sessionID: "ses_keep",
|
sessionID: "ses_active",
|
||||||
title: "Input requested",
|
title: "Input requested",
|
||||||
fields: [{ key: "authorization", type: "external" as const, url: "https://example.com" }],
|
fields: [{ key: "authorization", type: "external" as const, url: "https://example.com" }],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
let calls = 0
|
let calls = 0
|
||||||
const fetch = createFetch((url) => {
|
const fetch = createFetch((url) => {
|
||||||
if (url.pathname !== "/api/form/request") return
|
if (url.pathname !== "/api/session/ses_active/form") return
|
||||||
calls++
|
calls++
|
||||||
return json({ location: { directory, project: { id: "proj_test", directory } }, data: requests })
|
return json({ data: requests })
|
||||||
}, events)
|
}, events)
|
||||||
let data!: ReturnType<typeof useData>
|
let data!: ReturnType<typeof useData>
|
||||||
|
|
||||||
function Probe() {
|
function Probe() {
|
||||||
data = useData()
|
data = useData()
|
||||||
|
const client = useClient()
|
||||||
|
createEffect(() => {
|
||||||
|
if (client.connection.status() !== "connected") return
|
||||||
|
void data.session.form.sync("ses_active")
|
||||||
|
})
|
||||||
return <box />
|
return <box />
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2166,15 +2184,12 @@ test("reconciles all pending form requests when the event stream reconnects", as
|
|||||||
))
|
))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await wait(() => data.session.form.list("ses_old")?.[0]?.id === "frm_old")
|
await wait(() => data.session.form.list("ses_active")?.length === 2)
|
||||||
expect(data.session.form.list("ses_keep")?.[0]?.id).toBe("frm_keep")
|
|
||||||
|
|
||||||
requests = [{ id: "frm_new", sessionID: "ses_new", title: "Input requested", fields: formFields }]
|
requests = [{ id: "frm_new", sessionID: "ses_active", title: "Input requested", fields: formFields }]
|
||||||
events.disconnect()
|
events.disconnect()
|
||||||
|
|
||||||
await wait(() => calls === 2 && data.session.form.list("ses_new")?.[0]?.id === "frm_new")
|
await wait(() => calls === 2 && data.session.form.list("ses_active")?.[0]?.id === "frm_new")
|
||||||
expect(data.session.form.list("ses_old")).toBeUndefined()
|
|
||||||
expect(data.session.form.list("ses_keep")).toBeUndefined()
|
|
||||||
} finally {
|
} finally {
|
||||||
app.renderer.destroy()
|
app.renderer.destroy()
|
||||||
}
|
}
|
||||||
@@ -2421,7 +2436,7 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn
|
|||||||
])
|
])
|
||||||
expect(sync.session.input.list(sessionID)).toEqual([messageID])
|
expect(sync.session.input.list(sessionID)).toEqual([messageID])
|
||||||
|
|
||||||
await sync.session.message.refresh(sessionID)
|
await sync.session.message.sync(sessionID)
|
||||||
expect(sync.session.message.list(sessionID)?.[0]?.metadata).toBeUndefined()
|
expect(sync.session.message.list(sessionID)?.[0]?.metadata).toBeUndefined()
|
||||||
|
|
||||||
emitEvent(events, {
|
emitEvent(events, {
|
||||||
@@ -2538,8 +2553,7 @@ function sessionInfo(id: string, parentID: string | undefined, cost = 0) {
|
|||||||
async function mountData(parents: Record<string, string>, costs: Record<string, number> = {}) {
|
async function mountData(parents: Record<string, string>, costs: Record<string, number> = {}) {
|
||||||
const calls = createFetch((url) => {
|
const calls = createFetch((url) => {
|
||||||
const match = url.pathname.match(/^\/api\/session\/([^/]+)$/)
|
const match = url.pathname.match(/^\/api\/session\/([^/]+)$/)
|
||||||
if (match && match[1] !== "active")
|
if (match && match[1] !== "active") return json({ data: sessionInfo(match[1], parents[match[1]], costs[match[1]]) })
|
||||||
return json({ data: sessionInfo(match[1], parents[match[1]], costs[match[1]]) })
|
|
||||||
})
|
})
|
||||||
let data!: ReturnType<typeof useData>
|
let data!: ReturnType<typeof useData>
|
||||||
let ready!: () => void
|
let ready!: () => void
|
||||||
@@ -2569,13 +2583,13 @@ async function mountData(parents: Record<string, string>, costs: Record<string,
|
|||||||
test("groups an orphan child under its missing parent until the root arrives", async () => {
|
test("groups an orphan child under its missing parent until the root arrives", async () => {
|
||||||
const { data, app } = await mountData({ child: "root" })
|
const { data, app } = await mountData({ child: "root" })
|
||||||
try {
|
try {
|
||||||
await data.session.refresh("child")
|
await data.session.sync("child")
|
||||||
// Parent info is absent, so the missing parent is the furthest-known ancestor.
|
// Parent info is absent, so the missing parent is the furthest-known ancestor.
|
||||||
expect(data.session.root("child")).toBe("root")
|
expect(data.session.root("child")).toBe("root")
|
||||||
expect(data.session.family("child")).toEqual(["child"])
|
expect(data.session.family("child")).toEqual(["child"])
|
||||||
expect(data.session.family("root")).toEqual(["child"])
|
expect(data.session.family("root")).toEqual(["child"])
|
||||||
|
|
||||||
await data.session.refresh("root")
|
await data.session.sync("root")
|
||||||
expect(data.session.root("root")).toBe("root")
|
expect(data.session.root("root")).toBe("root")
|
||||||
// The tentative root entry folds into the now-known root's family.
|
// The tentative root entry folds into the now-known root's family.
|
||||||
expect(data.session.family("child")).toEqual(["child", "root"])
|
expect(data.session.family("child")).toEqual(["child", "root"])
|
||||||
@@ -2588,17 +2602,17 @@ test("groups an orphan child under its missing parent until the root arrives", a
|
|||||||
test("indexes arbitrarily deep nesting under a single root", async () => {
|
test("indexes arbitrarily deep nesting under a single root", async () => {
|
||||||
const { data, app } = await mountData({ grandchild: "child", child: "root" })
|
const { data, app } = await mountData({ grandchild: "child", child: "root" })
|
||||||
try {
|
try {
|
||||||
await data.session.refresh("grandchild")
|
await data.session.sync("grandchild")
|
||||||
expect(data.session.root("grandchild")).toBe("child")
|
expect(data.session.root("grandchild")).toBe("child")
|
||||||
expect(data.session.family("grandchild")).toEqual(["grandchild"])
|
expect(data.session.family("grandchild")).toEqual(["grandchild"])
|
||||||
|
|
||||||
await data.session.refresh("child")
|
await data.session.sync("child")
|
||||||
// grandchild's tentative family (keyed by the missing "child") merges up
|
// grandchild's tentative family (keyed by the missing "child") merges up
|
||||||
// toward the still-missing "root".
|
// toward the still-missing "root".
|
||||||
expect(data.session.root("child")).toBe("root")
|
expect(data.session.root("child")).toBe("root")
|
||||||
expect(data.session.family("grandchild")).toEqual(["grandchild", "child"])
|
expect(data.session.family("grandchild")).toEqual(["grandchild", "child"])
|
||||||
|
|
||||||
await data.session.refresh("root")
|
await data.session.sync("root")
|
||||||
expect(data.session.root("grandchild")).toBe("root")
|
expect(data.session.root("grandchild")).toBe("root")
|
||||||
expect(data.session.root("child")).toBe("root")
|
expect(data.session.root("child")).toBe("root")
|
||||||
expect(data.session.family("root")).toEqual(["grandchild", "child", "root"])
|
expect(data.session.family("root")).toEqual(["grandchild", "child", "root"])
|
||||||
@@ -2608,14 +2622,11 @@ test("indexes arbitrarily deep nesting under a single root", async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("totals family cost for roots and keeps subagent cost scoped", async () => {
|
test("totals family cost for roots and keeps subagent cost scoped", async () => {
|
||||||
const { data, app } = await mountData(
|
const { data, app } = await mountData({ grandchild: "child", child: "root" }, { root: 1, child: 2, grandchild: 3 })
|
||||||
{ grandchild: "child", child: "root" },
|
|
||||||
{ root: 1, child: 2, grandchild: 3 },
|
|
||||||
)
|
|
||||||
try {
|
try {
|
||||||
await data.session.refresh("grandchild")
|
await data.session.sync("grandchild")
|
||||||
await data.session.refresh("child")
|
await data.session.sync("child")
|
||||||
await data.session.refresh("root")
|
await data.session.sync("root")
|
||||||
|
|
||||||
expect(data.session.cost("root")).toBe(6)
|
expect(data.session.cost("root")).toBe(6)
|
||||||
expect(data.session.cost("child")).toBe(2)
|
expect(data.session.cost("child")).toBe(2)
|
||||||
@@ -2628,15 +2639,15 @@ test("totals family cost for roots and keeps subagent cost scoped", async () =>
|
|||||||
test("re-registering an existing session is idempotent", async () => {
|
test("re-registering an existing session is idempotent", async () => {
|
||||||
const { data, app } = await mountData({ grandchild: "child", child: "root" })
|
const { data, app } = await mountData({ grandchild: "child", child: "root" })
|
||||||
try {
|
try {
|
||||||
await data.session.refresh("grandchild")
|
await data.session.sync("grandchild")
|
||||||
await data.session.refresh("child")
|
await data.session.sync("child")
|
||||||
await data.session.refresh("root")
|
await data.session.sync("root")
|
||||||
const before = data.session.family("root")
|
const before = data.session.family("root")
|
||||||
expect(before).toEqual(["grandchild", "child", "root"])
|
expect(before).toEqual(["grandchild", "child", "root"])
|
||||||
|
|
||||||
await data.session.refresh("child")
|
await data.session.sync("child")
|
||||||
await data.session.refresh("root")
|
await data.session.sync("root")
|
||||||
await data.session.refresh("grandchild")
|
await data.session.sync("grandchild")
|
||||||
expect(data.session.family("root")).toEqual(before)
|
expect(data.session.family("root")).toEqual(before)
|
||||||
expect(data.session.family("root")).toHaveLength(3)
|
expect(data.session.family("root")).toHaveLength(3)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2647,8 +2658,8 @@ test("re-registering an existing session is idempotent", async () => {
|
|||||||
test("stops at the last non-repeating ancestor on a parent cycle", async () => {
|
test("stops at the last non-repeating ancestor on a parent cycle", async () => {
|
||||||
const { data, app } = await mountData({ x: "y", y: "x" })
|
const { data, app } = await mountData({ x: "y", y: "x" })
|
||||||
try {
|
try {
|
||||||
await data.session.refresh("x")
|
await data.session.sync("x")
|
||||||
await data.session.refresh("y")
|
await data.session.sync("y")
|
||||||
// Does not hang; walking up from "y" stops before re-entering "x".
|
// Does not hang; walking up from "y" stops before re-entering "x".
|
||||||
expect(data.session.root("y")).toBe("x")
|
expect(data.session.root("y")).toBe("x")
|
||||||
expect(data.session.family("y")).toEqual(["x", "y"])
|
expect(data.session.family("y")).toEqual(["x", "y"])
|
||||||
|
|||||||
Reference in New Issue
Block a user