import { EnvironmentId, ORCHESTRATION_WS_METHODS, ThreadId, type EnvironmentId as EnvironmentIdType, type OrchestrationThread, type OrchestrationThreadStreamItem, type ThreadId as ThreadIdType, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { Atom } from "effect/unstable/reactivity"; import { EnvironmentRegistry } from "../connection/registry.ts"; import { connectionProjectionPhase } from "../connection/model.ts"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; import { EnvironmentCacheStore } from "../platform/persistence.ts"; import { subscribe } from "../rpc/client.ts"; import { applyThreadDetailEvent } from "./threadReducer.ts"; import { followStreamInEnvironment } from "./runtime.ts"; export type EnvironmentThreadStatus = "empty" | "cached" | "synchronizing" | "live" | "deleted"; export interface EnvironmentThreadState { readonly data: Option.Option; readonly status: EnvironmentThreadStatus; readonly error: Option.Option; } export const EMPTY_ENVIRONMENT_THREAD_STATE: EnvironmentThreadState = { data: Option.none(), status: "empty", error: Option.none(), }; function statusWithoutLiveData(data: Option.Option): EnvironmentThreadStatus { return Option.isSome(data) ? "cached" : "empty"; } function formatThreadError(cause: Cause.Cause): string { const error = Cause.squash(cause); return error instanceof Error && error.message.trim().length > 0 ? error.message : "Could not synchronize the thread."; } export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make")(function* ( threadId: ThreadIdType, ) { const supervisor = yield* EnvironmentSupervisor; const cache = yield* EnvironmentCacheStore; const environmentId = supervisor.target.environmentId; const cached = yield* cache.loadThread(environmentId, threadId).pipe( Effect.catch((error) => Effect.logWarning("Could not load cached thread.").pipe( Effect.annotateLogs({ environmentId, threadId, error: error.message, }), Effect.as(Option.none()), ), ), ); const state = yield* SubscriptionRef.make({ data: cached, status: statusWithoutLiveData(cached), error: Option.none(), }); const lastSequence = yield* SubscriptionRef.make(0); const persistence = yield* Queue.sliding(1); const persist = Effect.fn("EnvironmentThreadState.persist")(function* ( thread: OrchestrationThread, ) { yield* cache.saveThread(environmentId, thread).pipe( Effect.catch((error) => Effect.logWarning("Could not persist the thread cache.").pipe( Effect.annotateLogs({ environmentId, threadId, error: error.message, }), ), ), ); }); yield* Stream.fromQueue(persistence).pipe( Stream.debounce("500 millis"), Stream.runForEach(persist), Effect.forkScoped, ); const setSynchronizing = SubscriptionRef.update(state, (current) => ({ ...current, status: "synchronizing" as const, error: Option.none(), })); const setReady = SubscriptionRef.update(state, (current) => current.status === "live" || current.status === "deleted" ? current : { ...current, status: "synchronizing" as const, error: Option.none(), }, ); const setDisconnected = SubscriptionRef.update(state, (current) => ({ ...current, status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data), })); const setStreamError = (cause: Cause.Cause) => SubscriptionRef.update(state, (current) => ({ ...current, status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data), error: Option.some(formatThreadError(cause)), })); const setThread = Effect.fn("EnvironmentThreadState.setThread")(function* ( thread: OrchestrationThread, ) { yield* SubscriptionRef.set(state, { data: Option.some(thread), status: "live", error: Option.none(), }); yield* Queue.offer(persistence, thread); }); const setDeleted = Effect.fn("EnvironmentThreadState.setDeleted")(function* () { yield* SubscriptionRef.set(state, { data: Option.none(), status: "deleted", error: Option.none(), }); yield* cache.removeThread(environmentId, threadId).pipe( Effect.catch((error) => Effect.logWarning("Could not remove the cached thread.").pipe( Effect.annotateLogs({ environmentId, threadId, error: error.message, }), ), ), ); }); const applyItem = Effect.fn("EnvironmentThreadState.applyItem")(function* ( item: OrchestrationThreadStreamItem, ) { if (item.kind === "snapshot") { yield* SubscriptionRef.set(lastSequence, item.snapshot.snapshotSequence); yield* setThread(item.snapshot.thread); return; } const sequence = yield* SubscriptionRef.get(lastSequence); if (item.event.sequence <= sequence) { return; } yield* SubscriptionRef.set(lastSequence, item.event.sequence); const current = yield* SubscriptionRef.get(state); if (Option.isNone(current.data)) { if (item.event.type === "thread.deleted") { yield* setDeleted(); } return; } const result = applyThreadDetailEvent(current.data.value, item.event); if (result.kind === "updated") { yield* setThread(result.thread); } else if (result.kind === "deleted") { yield* setDeleted(); } }); yield* SubscriptionRef.changes(supervisor.state).pipe( Stream.runForEach((connectionState) => { switch (connectionProjectionPhase(connectionState)) { case "synchronizing": return setSynchronizing; case "disconnected": return setDisconnected; case "ready": return setReady; } }), Effect.forkScoped, ); yield* setSynchronizing; yield* subscribe( ORCHESTRATION_WS_METHODS.subscribeThread, { threadId }, { onExpectedFailure: setStreamError, retryExpectedFailureAfter: "250 millis", }, ).pipe(Stream.runForEach(applyItem), Effect.forkScoped); yield* Effect.addFinalizer(() => SubscriptionRef.get(state).pipe( Effect.flatMap((current) => Option.match(current.data, { onNone: () => Effect.void, onSome: persist, }), ), ), ); return state; }); export function threadStateChanges(environmentId: EnvironmentIdType, threadId: ThreadIdType) { return followStreamInEnvironment( environmentId, Stream.unwrap(makeEnvironmentThreadState(threadId).pipe(Effect.map(SubscriptionRef.changes))), ); } function threadAtomKey(environmentId: EnvironmentIdType, threadId: ThreadIdType): string { return `${environmentId}\u0000${threadId}`; } function parseThreadAtomKey(key: string): { readonly environmentId: EnvironmentIdType; readonly threadId: ThreadIdType; } { const separator = key.indexOf("\u0000"); if (separator < 0) { throw new Error("Invalid environment thread atom key."); } return { environmentId: EnvironmentId.make(key.slice(0, separator)), threadId: ThreadId.make(key.slice(separator + 1)), }; } export function createEnvironmentThreadStateAtoms( runtime: Atom.AtomRuntime, ) { const family = Atom.family((key: string) => { const { environmentId, threadId } = parseThreadAtomKey(key); return runtime.atom(threadStateChanges(environmentId, threadId), { initialValue: EMPTY_ENVIRONMENT_THREAD_STATE, }); }); return { stateAtom: (environmentId: EnvironmentIdType, threadId: ThreadIdType) => family(threadAtomKey(environmentId, threadId)), }; } export * from "./archivedThreads.ts"; export * from "./checkpointDiff.ts"; export * from "./composerPathSearch.ts"; export * from "./threadCommands.ts"; export * from "./threadDetail.ts"; export * from "./threadReducer.ts"; export * from "./threadShell.ts";