1060 lines · 36.6 KB · TypeScript
RawDownload
import type {  OrchestrationEvent,  OrchestrationProject,  OrchestrationReadModel,  ThreadId,  ThreadLinkedPullRequest,  ThreadPullRequestKey,  ThreadPullRequestLink,} from "@t3tools/contracts";import {  isImportedAgentSessionMessageId,  OrchestrationCheckpointSummary,  OrchestrationMessage,  OrchestrationSession,  OrchestrationThread,} from "@t3tools/contracts";import {  legacyLinkedPullRequestOf,  legacyThreadPullRequestKey,  threadPullRequestKeysEqual,} from "@t3tools/shared/threadPullRequests";import { compareDateTimeStrings } from "@t3tools/shared/dateTime";import * as Effect from "effect/Effect";import * as Schema from "effect/Schema";import * as Predicate from "effect/Predicate"; import { toProjectorDecodeError, type OrchestrationProjectorDecodeError } from "./Errors.ts";import {  MessageSentPayloadSchema,  ProjectCreatedPayload,  ProjectDeletedPayload,  ProjectMetaUpdatedPayload,  ThreadActivityAppendedPayload,  ThreadArchivedPayload,  ThreadCreatedPayload,  ThreadDeletedPayload,  ThreadInteractionModeSetPayload,  ThreadMetaUpdatedPayload,  ThreadProposedPlanUpsertedPayload,  ThreadRuntimeModeSetPayload,  ThreadSettledPayload,  ThreadPinnedPayload,  ThreadPinReorderedPayload,  ThreadPullRequestLinkedPayload,  ThreadPullRequestSyncedPayload,  ThreadPullRequestUnlinkedPayload,  ThreadSnoozedPayload,  ThreadUnpinnedPayload,  ThreadUnarchivedPayload,  ThreadUnsettledPayload,  ThreadUnsnoozedPayload,  ThreadRevertedPayload,  ThreadSessionSetPayload,  ThreadTurnDiffCompletedPayload,} from "./Schemas.ts"; type ThreadPatch = Partial<Omit<OrchestrationThread, "id" | "projectId">>;const MAX_THREAD_MESSAGES = 2_000;const MAX_THREAD_CHECKPOINTS = 500; // Async questions can stay open while the agent produces more activity.// Match the database snapshot's pending-question retention.function retainThreadActivities(activities: OrchestrationThread["activities"]) {  const recentStart = activities.length - 500;  if (recentStart <= 0) return activities;  const pending = new Map<string, OrchestrationThread["activities"][number]>();  for (const activity of activities) {    if (!Predicate.isObject(activity.payload)) continue;    const requestId = activity.payload.requestId;    if (typeof requestId !== "string") continue;    if (activity.kind === "user-input.requested" && activity.payload.responseMode === "message") {      pending.set(requestId, activity);    } else if (activity.kind === "user-input.resolved") {      pending.delete(requestId);    }  }  const pendingActivities = new Set(pending.values());  return activities.filter(    (activity, index) => index >= recentStart || pendingActivities.has(activity),  );} function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error") {  if (status === "error") return "error" as const;  // Match SQL and client projections: a missing git ref is not an interruption.  return "completed" as const;} /** * Turn state to settle a still-running latest turn with when its session * leaves the "running" status, or null while the session is (re)starting or * running and the turn must stay unsettled. */function settledTurnStateForSessionStatus(  status: OrchestrationSession["status"],): "completed" | "interrupted" | "error" | null {  switch (status) {    case "idle":    case "ready":      return "completed";    case "error":      return "error";    case "interrupted":    case "stopped":      return "interrupted";    case "starting":    case "running":      return null;  }} function updateThread(  threads: ReadonlyArray<OrchestrationThread>,  threadId: ThreadId,  patch: ThreadPatch,): OrchestrationThread[] {  return threads.map((thread) => (thread.id === threadId ? { ...thread, ...patch } : thread));} /** Patch that swaps a thread's links and re-derives the legacy single-PR field from them. */function pullRequestsPatch(  thread: Pick<OrchestrationThread, "projectId">,  pullRequests: ReadonlyArray<ThreadPullRequestLink>,  projects: OrchestrationReadModel["projects"],): Pick<OrchestrationThread, "pullRequests" | "linkedPullRequest"> {  return {    pullRequests,    linkedPullRequest: legacyLinkedPullRequestOf(      pullRequests,      thread.projectId,      projects.find((project) => project.id === thread.projectId)?.repositoryIdentity,    ),  };} function upsertPullRequestLink(  pullRequests: ReadonlyArray<ThreadPullRequestLink>,  link: ThreadPullRequestLink,): ReadonlyArray<ThreadPullRequestLink> {  const index = pullRequests.findIndex((entry) => threadPullRequestKeysEqual(entry, link));  return index === -1    ? [...pullRequests, link]    : pullRequests.map((entry, entryIndex) => (entryIndex === index ? link : entry));} function removePullRequestLink(  pullRequests: ReadonlyArray<ThreadPullRequestLink>,  key: ThreadPullRequestKey,): ReadonlyArray<ThreadPullRequestLink> {  return pullRequests.filter((entry) => !threadPullRequestKeysEqual(entry, key));} /** * Host for a legacy `linkedPullRequest` being replayed into the link array. * Legacy links never carried one; the project's canonical key * (`<host>/<owner>/<name>`) is the best witness, then the link URL. */function legacyPullRequestHost(  project: OrchestrationProject | undefined,  linked: ThreadLinkedPullRequest,): string {  const canonicalHost = project?.repositoryIdentity?.canonicalKey.split("/")[0];  if (canonicalHost) return canonicalHost.toLowerCase();  try {    return new URL(linked.url).hostname.toLowerCase();  } catch {    return "unknown";  }} function legacyLinkToPullRequests(  thread: Pick<OrchestrationThread, "pullRequests">,  project: OrchestrationProject | undefined,  linked: ThreadLinkedPullRequest | null,  linkedAt: string,): ReadonlyArray<ThreadPullRequestLink> {  // The legacy field held one user-chosen link, so null clears exactly the  // manual ones and leaves created/agent/stack links alone.  const withoutManual = thread.pullRequests.filter((entry) => entry.source !== "manual");  if (linked === null) return withoutManual;  return upsertPullRequestLink(withoutManual, {    ...legacyThreadPullRequestKey(linked, legacyPullRequestHost(project, linked)),    url: linked.url,    source: "manual",    linkedAt,    snapshot: null,    stack: null,  });} function decodeForEvent<A>(  schema: Schema.Decoder<A, never>,  value: unknown,  eventType: OrchestrationEvent["type"],  field: string,): Effect.Effect<A, OrchestrationProjectorDecodeError> {  return Schema.decodeUnknownEffect(schema)(value).pipe(    Effect.mapError(toProjectorDecodeError(`${eventType}:${field}`)),  );} function retainThreadMessagesAfterRevert(  messages: ReadonlyArray<OrchestrationMessage>,  retainedTurnIds: ReadonlySet<string>,  turnCount: number,): ReadonlyArray<OrchestrationMessage> {  const retainedMessageIds = new Set<string>();  for (const message of messages) {    if (message.role === "system" || isImportedAgentSessionMessageId(message.id)) {      retainedMessageIds.add(message.id);      continue;    }    if (message.turnId !== null && retainedTurnIds.has(message.turnId)) {      retainedMessageIds.add(message.id);    }  }   const retainedUserCount = messages.filter(    (message) =>      message.role === "user" &&      !isImportedAgentSessionMessageId(message.id) &&      retainedMessageIds.has(message.id),  ).length;  const missingUserCount = Math.max(0, turnCount - retainedUserCount);  if (missingUserCount > 0) {    const fallbackUserMessages = messages      .filter(        (message) =>          message.role === "user" &&          !retainedMessageIds.has(message.id) &&          (message.turnId === null || retainedTurnIds.has(message.turnId)),      )      .toSorted(        (left, right) =>          compareDateTimeStrings(left.createdAt, right.createdAt) ||          left.id.localeCompare(right.id),      )      .slice(0, missingUserCount);    for (const message of fallbackUserMessages) {      retainedMessageIds.add(message.id);    }  }   const retainedAssistantCount = messages.filter(    (message) =>      message.role === "assistant" &&      !isImportedAgentSessionMessageId(message.id) &&      retainedMessageIds.has(message.id),  ).length;  const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount);  if (missingAssistantCount > 0) {    const fallbackAssistantMessages = messages      .filter(        (message) =>          message.role === "assistant" &&          !retainedMessageIds.has(message.id) &&          (message.turnId === null || retainedTurnIds.has(message.turnId)),      )      .toSorted(        (left, right) =>          compareDateTimeStrings(left.createdAt, right.createdAt) ||          left.id.localeCompare(right.id),      )      .slice(0, missingAssistantCount);    for (const message of fallbackAssistantMessages) {      retainedMessageIds.add(message.id);    }  }   return messages.filter((message) => retainedMessageIds.has(message.id));} function retainThreadActivitiesAfterRevert(  activities: ReadonlyArray<OrchestrationThread["activities"][number]>,  retainedTurnIds: ReadonlySet<string>,): ReadonlyArray<OrchestrationThread["activities"][number]> {  return activities.filter(    (activity) => activity.turnId === null || retainedTurnIds.has(activity.turnId),  );} function retainThreadProposedPlansAfterRevert(  proposedPlans: ReadonlyArray<OrchestrationThread["proposedPlans"][number]>,  retainedTurnIds: ReadonlySet<string>,): ReadonlyArray<OrchestrationThread["proposedPlans"][number]> {  return proposedPlans.filter(    (proposedPlan) => proposedPlan.turnId === null || retainedTurnIds.has(proposedPlan.turnId),  );} function compareThreadActivities(  left: OrchestrationThread["activities"][number],  right: OrchestrationThread["activities"][number],): number {  if (left.sequence !== undefined && right.sequence !== undefined) {    if (left.sequence !== right.sequence) {      return left.sequence - right.sequence;    }  } else if (left.sequence !== undefined) {    return 1;  } else if (right.sequence !== undefined) {    return -1;  }   return left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id);} export function createEmptyReadModel(nowIso: string): OrchestrationReadModel {  return {    snapshotSequence: 0,    projects: [],    threads: [],    updatedAt: nowIso,  };} export function projectEvent(  model: OrchestrationReadModel,  event: OrchestrationEvent,): Effect.Effect<OrchestrationReadModel, OrchestrationProjectorDecodeError> {  const nextBase: OrchestrationReadModel = {    ...model,    snapshotSequence: event.sequence,    updatedAt: event.occurredAt,  };   switch (event.type) {    case "project.created":      return decodeForEvent(ProjectCreatedPayload, event.payload, event.type, "payload").pipe(        Effect.map((payload) => {          const existing = nextBase.projects.find((entry) => entry.id === payload.projectId);          const nextProject = {            id: payload.projectId,            title: payload.title,            workspaceRoot: payload.workspaceRoot,            defaultModelSelection: payload.defaultModelSelection,            defaultThreadEnvMode: null,            autoPull: false,            faviconPath: payload.faviconPath ?? null,            projectIcon: payload.projectIcon ?? null,            scripts: payload.scripts,            createdAt: payload.createdAt,            updatedAt: payload.updatedAt,            deletedAt: null,          };           return {            ...nextBase,            projects: existing              ? nextBase.projects.map((entry) =>                  entry.id === payload.projectId ? nextProject : entry,                )              : [...nextBase.projects, nextProject],          };        }),      );     case "project.meta-updated":      return decodeForEvent(ProjectMetaUpdatedPayload, event.payload, event.type, "payload").pipe(        Effect.map((payload) => ({          ...nextBase,          projects: nextBase.projects.map((project) =>            project.id === payload.projectId              ? {                  ...project,                  ...(payload.title !== undefined ? { title: payload.title } : {}),                  ...(payload.workspaceRoot !== undefined                    ? { workspaceRoot: payload.workspaceRoot }                    : {}),                  ...(payload.defaultModelSelection !== undefined                    ? { defaultModelSelection: payload.defaultModelSelection }                    : {}),                  ...(payload.defaultThreadEnvMode !== undefined                    ? { defaultThreadEnvMode: payload.defaultThreadEnvMode }                    : {}),                  ...(payload.autoPull !== undefined ? { autoPull: payload.autoPull } : {}),                  ...(payload.faviconPath !== undefined                    ? { faviconPath: payload.faviconPath }                    : {}),                  ...(payload.projectIcon !== undefined                    ? { projectIcon: payload.projectIcon }                    : {}),                  ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}),                  updatedAt: payload.updatedAt,                }              : project,          ),        })),      );     case "project.deleted":      return decodeForEvent(ProjectDeletedPayload, event.payload, event.type, "payload").pipe(        Effect.map((payload) => ({          ...nextBase,          projects: nextBase.projects.map((project) =>            project.id === payload.projectId              ? {                  ...project,                  deletedAt: payload.deletedAt,                  updatedAt: payload.deletedAt,                }              : project,          ),        })),      );     case "thread.created":      return Effect.gen(function* () {        const payload = yield* decodeForEvent(          ThreadCreatedPayload,          event.payload,          event.type,          "payload",        );        const thread: OrchestrationThread = yield* decodeForEvent(          OrchestrationThread,          {            id: payload.threadId,            projectId: payload.projectId,            title: payload.title,            modelSelection: payload.modelSelection,            runtimeMode: payload.runtimeMode,            interactionMode: payload.interactionMode,            branch: payload.branch,            worktreePath: payload.worktreePath,            pullRequests: [],            branchPullRequest: null,            latestTurn: null,            createdAt: payload.createdAt,            updatedAt: payload.updatedAt,            archivedAt: null,            settledOverride: null,            settledAt: null,            unsettledAt: null,            activeOrderKey: null,            snoozedUntil: null,            snoozedAt: null,            deletedAt: null,            messages: [],            activities: [],            checkpoints: [],            session: null,          },          event.type,          "thread",        );        const existing = nextBase.threads.find((entry) => entry.id === thread.id);        return {          ...nextBase,          threads: existing            ? nextBase.threads.map((entry) => (entry.id === thread.id ? thread : entry))            : [...nextBase.threads, thread],        };      });     case "thread.deleted":      return decodeForEvent(ThreadDeletedPayload, event.payload, event.type, "payload").pipe(        Effect.map((payload) => ({          ...nextBase,          threads: updateThread(nextBase.threads, payload.threadId, {            deletedAt: payload.deletedAt,            updatedAt: payload.deletedAt,          }),        })),      );     case "thread.archived":      return decodeForEvent(ThreadArchivedPayload, event.payload, event.type, "payload").pipe(        Effect.map((payload) => ({          ...nextBase,          threads: updateThread(nextBase.threads, payload.threadId, {            archivedAt: payload.archivedAt,            titleRegeneration: null,            updatedAt: payload.updatedAt,          }),        })),      );     case "thread.unarchived":      return decodeForEvent(ThreadUnarchivedPayload, event.payload, event.type, "payload").pipe(        Effect.map((payload) => ({          ...nextBase,          threads: updateThread(nextBase.threads, payload.threadId, {            archivedAt: null,            updatedAt: payload.updatedAt,          }),        })),      );     case "thread.settled":      return decodeForEvent(ThreadSettledPayload, event.payload, event.type, "payload").pipe(        Effect.map((payload) => ({          ...nextBase,          threads: updateThread(nextBase.threads, payload.threadId, {            settledOverride: "settled",            settledAt: payload.settledAt,            unsettledAt: null,            activeOrderKey: null,            updatedAt: payload.updatedAt,          }),

Lines 1–500 of 1060.