packages/client-runtime/src/state/threadFamilies.ts

import {
  ORCHESTRATION_V2_WS_METHODS,
  type EnvironmentId,
  type OrchestrationV2ShellSnapshot,
  type OrchestrationV2ThreadFamily,
  type OrchestrationV2ThreadShell,
  type ThreadId,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as SubscriptionRef from "effect/SubscriptionRef";
import { AsyncResult, Atom, type AtomRegistry } from "effect/reactivity";

import * as EnvironmentSupervisor from "../connection/supervisor.ts";
import type * as EnvironmentRegistry from "../connection/registry.ts";
import { EnvironmentRpcUnavailableError, request } from "../rpc/client.ts";
import { arrayElementsEqual } from "./entities.ts";
import { createEnvironmentQueryAtomFamily, selectEnvironmentQueryData } from "./runtime.ts";
import type { EnvironmentShellState } from "./shell.ts";

const EMPTY_FAMILY: OrchestrationV2ThreadFamily = { snapshotSequence: 0, threads: [] };
const EMPTY_READS: ReadonlyArray<OrchestrationV2ThreadFamily> = [];

interface MountedRows {
  readonly rows: Atom.Atom<ReadonlyArray<OrchestrationV2ThreadFamily>>;
  readonly count: number;
}
const EMPTY_MOUNTS: ReadonlyMap<string, MountedRows> = new Map();
// Generation 0 is the cached shell from before this session's first authoritative snapshot. A read
// for it would be thrown away once that snapshot lands, so it waits instead.
const AWAITING_AUTHORITATIVE_SHELL = AsyncResult.initial<never, never>(true);

/** Whether a family read can decide anything yet. Only a settled read can prove a thread is gone. */
export type ThreadFamilyRead = "pending" | "failed" | "settled";

export function threadFamilyRead(
  result: AsyncResult.AsyncResult<OrchestrationV2ThreadFamily, unknown>,
): ThreadFamilyRead {
  // A failure whose retry is in flight is pending, so callers do not restart that retry.
  if (result.waiting) return "pending";
  if (AsyncResult.isFailure(result)) return "failed";
  return AsyncResult.isSuccess(result) ? "settled" : "pending";
}

/**
 * Family rows the shell does not have. A shell row is at least as new as any family copy, since
 * shell deltas carry every change; a family copy read before the shell removed that thread is
 * stale.
 */
export function threadFamilyRowsMissingFromShell(input: {
  readonly shellThreads: ReadonlyArray<OrchestrationV2ThreadShell>;
  readonly families: ReadonlyArray<OrchestrationV2ThreadFamily>;
  readonly removedAt: ReadonlyMap<ThreadId, number>;
}): ReadonlyArray<OrchestrationV2ThreadShell> {
  const present = new Set<ThreadId>(input.shellThreads.map((thread) => thread.id));
  const missing: OrchestrationV2ThreadShell[] = [];
  for (const family of input.families) {
    for (const thread of family.threads) {
      if (present.has(thread.id)) continue;
      if ((input.removedAt.get(thread.id) ?? -1) > family.snapshotSequence) continue;
      present.add(thread.id);
      missing.push(thread);
    }
  }
  return missing;
}

/**
 * Shells with `omitIdleSubagents` or `omitOlderSettled` leave rows to on-demand reads: a family is
 * every active thread sharing a `lineage.rootThreadId`, read once per shell generation; shelf pages
 * and search rows mount through `mountRows`. All merge behind shell rows, which win (deltas carry
 * every change), and are never persisted.
 */
export function createThreadFamilyAtoms<R, ER, E>(
  runtime: Atom.AtomRuntime<EnvironmentRegistry.EnvironmentRegistry | R, ER>,
  shellStateAtom: (
    environmentId: EnvironmentId,
  ) => Atom.Atom<AsyncResult.AsyncResult<EnvironmentShellState, E>>,
) {
  const shellStateValue = (state: AsyncResult.AsyncResult<EnvironmentShellState, E>) =>
    AsyncResult.getOrElse(state, () => null);
  const generationAtom = Atom.family((environmentId: EnvironmentId) =>
    Atom.make((get) => shellStateValue(get(shellStateAtom(environmentId)))?.generation ?? 0).pipe(
      Atom.withLabel(`environment-shell-generation:${environmentId}`),
    ),
  );

  // The generation is part of the key, so a read from before the latest authoritative snapshot is
  // never used again, even while a new read is pending or failed.
  const familyQueryAtom = createEnvironmentQueryAtomFamily(runtime, {
    label: "environment-data:orchestration:thread-family",
    staleTimeMs: Number.POSITIVE_INFINITY,
    idleTtlMs: 60_000,
    execute: Effect.fn("ThreadFamilies.read")(function* (input: {
      readonly threadId: ThreadId;
      readonly generation: number;
    }) {
      const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor;
      const session = yield* SubscriptionRef.get(supervisor.session);
      if (Option.isNone(session)) {
        return yield* new EnvironmentRpcUnavailableError({
          environmentId: supervisor.target.environmentId,
          message: "The environment has no session.",
        });
      }
      // A config failure fails the read: an empty family would read as "the thread is gone".
      const config = yield* session.value.initialConfig;
      // Servers without families send every child in the shell, so absent means gone.
      if (config.shellThreadFamilies !== true) return EMPTY_FAMILY;
      return yield* request(ORCHESTRATION_V2_WS_METHODS.getThreadFamily, {
        threadId: input.threadId,
      });
    }),
  });
  const queryFor = (environmentId: EnvironmentId, threadId: ThreadId, generation: number) =>
    familyQueryAtom({ environmentId, input: { threadId, generation } });

  const familyAtom = Atom.family((key: string) => {
    const [environmentId, threadId] = JSON.parse(key) as [EnvironmentId, ThreadId];
    return Atom.make((get) => {
      const generation = get(generationAtom(environmentId));
      return generation === 0
        ? AWAITING_AUTHORITATIVE_SHELL
        : get(queryFor(environmentId, threadId, generation));
    }).pipe(Atom.withLabel(`environment-thread-family:${key}`));
  });
  const familyKey = (environmentId: EnvironmentId, threadId: ThreadId) =>
    JSON.stringify([environmentId, threadId]);

  /** Reads the current generation's family again, so a failed read does not last the session. */
  const refresh = (
    registry: AtomRegistry.AtomRegistry,
    environmentId: EnvironmentId,
    threadId: ThreadId,
  ) => {
    const generation = registry.get(generationAtom(environmentId));
    if (generation !== 0) registry.refresh(queryFor(environmentId, threadId, generation));
  };

  const mountsAtom = Atom.family((environmentId: EnvironmentId) =>
    Atom.make(EMPTY_MOUNTS).pipe(
      Atom.keepAlive,
      Atom.withLabel(`environment-thread-family-mounts:${environmentId}`),
    ),
  );

  const familyRowsAtom = Atom.family((key: string) =>
    Atom.make((get): ReadonlyArray<OrchestrationV2ThreadFamily> => {
      const family = selectEnvironmentQueryData(get(familyAtom(key)));
      return family === null ? EMPTY_READS : [family];
    }).pipe(Atom.withLabel(`environment-thread-family-rows:${key}`)),
  );

  /**
   * Merges `rows` behind the shell rows until the returned release runs. Reads read under `key`
   * share one entry; each read's `snapshotSequence` decides which removals drop its rows.
   */
  const mountRows = (
    registry: AtomRegistry.AtomRegistry,
    environmentId: EnvironmentId,
    key: string,
    rows: Atom.Atom<ReadonlyArray<OrchestrationV2ThreadFamily>>,
  ): (() => void) => {
    const atom = mountsAtom(environmentId);
    const mounts = registry.get(atom);
    const entry = mounts.get(key);
    registry.set(
      atom,
      new Map(mounts).set(key, { rows: entry?.rows ?? rows, count: (entry?.count ?? 0) + 1 }),
    );
    let released = false;
    return () => {
      if (released) return;
      released = true;
      const next = new Map(registry.get(atom));
      const entry = next.get(key);
      if (entry !== undefined && entry.count > 1)
        next.set(key, { ...entry, count: entry.count - 1 });
      else next.delete(key);
      registry.set(atom, next);
    };
  };

  /** Keeps a thread's family in the merged rows until the returned release runs. */
  const mount = (
    registry: AtomRegistry.AtomRegistry,
    environmentId: EnvironmentId,
    threadId: ThreadId,
  ): (() => void) => {
    const key = familyKey(environmentId, threadId);
    const release = mountRows(registry, environmentId, `family:${key}`, familyRowsAtom(key));
    // Mounting again after a failure retries instead of keeping the failure until reconnect.
    if (threadFamilyRead(registry.get(familyAtom(key))) === "failed") {
      refresh(registry, environmentId, threadId);
    }
    return release;
  };

  const snapshotAtom = Atom.family((environmentId: EnvironmentId) => {
    let previous: {
      readonly snapshot: OrchestrationV2ShellSnapshot;
      readonly removedAt: ReadonlyMap<ThreadId, number>;
      readonly families: ReadonlyArray<OrchestrationV2ThreadFamily>;
      readonly merged: OrchestrationV2ShellSnapshot;
    } | null = null;
    return Atom.make((get): OrchestrationV2ShellSnapshot | null => {
      const state = shellStateValue(get(shellStateAtom(environmentId)));
      const snapshot = state === null ? null : Option.getOrNull(state.snapshot);
      const mounts = get(mountsAtom(environmentId));
      if (snapshot === null || state === null || mounts.size === 0) return snapshot;
      // A read from before the latest authoritative snapshot can hold threads removed before it,
      // and that snapshot cleared those removals; such reads (a cached search) wait for new ones.
      const cut = snapshot.olderSettled?.asOfSequence ?? 0;
      const families = [...mounts.values()].flatMap(({ rows }) =>
        get(rows).filter((read) => read.snapshotSequence >= cut),
      );
      // Most shell updates move only the cursor. With the same rows, removals and families, keep
      // the merged rows so thread lists skip their rebuild.
      const unchanged =
        previous !== null &&
        previous.snapshot.threads === snapshot.threads &&
        previous.removedAt === state.removedAt &&
        arrayElementsEqual(previous.families, families);
      if (unchanged && previous !== null && previous.snapshot === snapshot) return previous.merged;
      let merged: OrchestrationV2ShellSnapshot;
      if (unchanged && previous !== null) {
        merged =
          previous.merged === previous.snapshot
            ? snapshot
            : { ...snapshot, threads: previous.merged.threads };
      } else {
        const extras = threadFamilyRowsMissingFromShell({
          shellThreads: snapshot.threads,
          removedAt: state.removedAt,
          families,
        });
        merged =
          extras.length === 0
            ? snapshot
            : { ...snapshot, threads: [...snapshot.threads, ...extras] };
      }
      previous = { snapshot, removedAt: state.removedAt, families, merged };
      return merged;
    }).pipe(Atom.withLabel(`environment-snapshot-with-families:${environmentId}`));
  });

  return {
    /** The shell snapshot plus the rows of every mounted family. */
    snapshotAtom,
    /** The current generation's family read, for decisions that need a settled answer. */
    familyAtom: (environmentId: EnvironmentId, threadId: ThreadId) =>
      familyAtom(familyKey(environmentId, threadId)),
    mount,
    mountRows,
    refresh,
  };
}