import {
ApprovalRequestId,
type ChatAttachment,
type OrchestrationEvent,
type OrchestrationSessionStatus,
ThreadId,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Stream from "effect/Stream";
import * as SqlClient from "effect/unstable/sql/SqlClient";
import { toPersistenceSqlError, type ProjectionRepositoryError } from "../../persistence/Errors.ts";
import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts";
import { ProjectionPendingApprovalRepository } from "../../persistence/Services/ProjectionPendingApprovals.ts";
import { ProjectionProjectRepository } from "../../persistence/Services/ProjectionProjects.ts";
import { ProjectionStateRepository } from "../../persistence/Services/ProjectionState.ts";
import { ProjectionThreadActivityRepository } from "../../persistence/Services/ProjectionThreadActivities.ts";
import { type ProjectionThreadActivity } from "../../persistence/Services/ProjectionThreadActivities.ts";
import {
type ProjectionThreadMessage,
ProjectionThreadMessageRepository,
} from "../../persistence/Services/ProjectionThreadMessages.ts";
import {
type ProjectionThreadProposedPlan,
ProjectionThreadProposedPlanRepository,
} from "../../persistence/Services/ProjectionThreadProposedPlans.ts";
import { ProjectionThreadSessionRepository } from "../../persistence/Services/ProjectionThreadSessions.ts";
import {
type ProjectionTurn,
ProjectionTurnRepository,
} from "../../persistence/Services/ProjectionTurns.ts";
import { ProjectionThreadRepository } from "../../persistence/Services/ProjectionThreads.ts";
import { ProjectionPendingApprovalRepositoryLive } from "../../persistence/Layers/ProjectionPendingApprovals.ts";
import { ProjectionProjectRepositoryLive } from "../../persistence/Layers/ProjectionProjects.ts";
import { ProjectionStateRepositoryLive } from "../../persistence/Layers/ProjectionState.ts";
import { ProjectionThreadActivityRepositoryLive } from "../../persistence/Layers/ProjectionThreadActivities.ts";
import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ProjectionThreadMessages.ts";
import { ProjectionThreadProposedPlanRepositoryLive } from "../../persistence/Layers/ProjectionThreadProposedPlans.ts";
import { ProjectionThreadSessionRepositoryLive } from "../../persistence/Layers/ProjectionThreadSessions.ts";
import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts";
import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts";
import { ServerConfig } from "../../config.ts";
import {
OrchestrationProjectionPipeline,
type OrchestrationProjectionPipelineShape,
} from "../Services/ProjectionPipeline.ts";
import {
attachmentRelativePath,
parseAttachmentIdFromRelativePath,
parseThreadSegmentFromAttachmentId,
toSafeThreadAttachmentSegment,
} from "../../attachmentStore.ts";
export const ORCHESTRATION_PROJECTOR_NAMES = {
projects: "projection.projects",
threads: "projection.threads",
threadMessages: "projection.thread-messages",
threadProposedPlans: "projection.thread-proposed-plans",
threadActivities: "projection.thread-activities",
threadSessions: "projection.thread-sessions",
threadTurns: "projection.thread-turns",
checkpoints: "projection.checkpoints",
pendingApprovals: "projection.pending-approvals",
} as const;
type ProjectorName =
(typeof ORCHESTRATION_PROJECTOR_NAMES)[keyof typeof ORCHESTRATION_PROJECTOR_NAMES];
/**
* Turn state to settle still-running turns with when their session leaves the
* "running" status, or null while the session is (re)starting or running and
* turns must stay unsettled.
*/
function settledTurnStateForSessionStatus(
status: OrchestrationSessionStatus,
): "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;
}
}
interface ProjectorDefinition {
readonly name: ProjectorName;
readonly apply: (
event: OrchestrationEvent,
attachmentSideEffects: AttachmentSideEffects,
) => Effect.Effect<void, ProjectionRepositoryError>;
}
interface AttachmentSideEffects {
readonly deletedThreadIds: Set<string>;
readonly prunedThreadRelativePaths: Map<string, Set<string>>;
}
const materializeAttachmentsForProjection = Effect.fn("materializeAttachmentsForProjection")(
(input: { readonly attachments: ReadonlyArray<ChatAttachment> }) =>
Effect.succeed(input.attachments.length === 0 ? [] : input.attachments),
);
function extractActivityRequestId(payload: unknown): ApprovalRequestId | null {
if (typeof payload !== "object" || payload === null) {
return null;
}
const requestId = (payload as Record<string, unknown>).requestId;
return typeof requestId === "string" ? ApprovalRequestId.make(requestId) : null;
}
function isStalePendingApprovalFailureDetail(detail: string | null): boolean {
if (detail === null) {
return false;
}
return (
detail.includes("stale pending approval request") ||
detail.includes("unknown pending approval request") ||
detail.includes("unknown pending permission request")
);
}
// A refresh reads each persisted summary source, so skip activities that cannot change the result.
function shouldRefreshThreadShellSummary(event: OrchestrationEvent): boolean {
if (event.type !== "thread.activity-appended") {
return true;
}
switch (event.payload.activity.kind) {
case "approval.requested":
case "approval.resolved":
case "provider.approval.respond.failed":
case "user-input.requested":
case "user-input.resolved":
case "provider.user-input.respond.failed":
return true;
default:
return false;
}
}
function derivePendingUserInputCountFromActivities(
activities: ReadonlyArray<ProjectionThreadActivity>,
): number {
const openRequestIds = new Set<string>();
const ordered = [...activities].toSorted(
(left, right) =>
left.createdAt.localeCompare(right.createdAt) ||
left.activityId.localeCompare(right.activityId),
);
for (const activity of ordered) {
const requestId = extractActivityRequestId(activity.payload);
if (requestId === null) {
continue;
}
const payload =
typeof activity.payload === "object" && activity.payload !== null
? (activity.payload as Record<string, unknown>)
: null;
const detail = typeof payload?.detail === "string" ? payload.detail.toLowerCase() : null;
if (activity.kind === "user-input.requested") {
openRequestIds.add(requestId);
continue;
}
if (activity.kind === "user-input.resolved") {
openRequestIds.delete(requestId);
continue;
}
if (
activity.kind === "provider.user-input.respond.failed" &&
detail !== null &&
(detail.includes("stale pending user-input request") ||
detail.includes("unknown pending user-input request") ||
detail.includes("unknown pending user input request") ||
detail.includes("unknown pending codex user input request"))
) {
openRequestIds.delete(requestId);
}
}
return openRequestIds.size;
}
function deriveHasActionableProposedPlan(input: {
readonly latestTurnId: string | null;
readonly proposedPlans: ReadonlyArray<ProjectionThreadProposedPlan>;
}): boolean {
const sorted = [...input.proposedPlans].toSorted(
(left, right) =>
left.updatedAt.localeCompare(right.updatedAt) || left.planId.localeCompare(right.planId),
);
let latestForTurn: ProjectionThreadProposedPlan | null = null;
if (input.latestTurnId !== null) {
for (let index = sorted.length - 1; index >= 0; index -= 1) {
const plan = sorted[index];
if (plan?.turnId === input.latestTurnId) {
latestForTurn = plan;
break;
}
}
}
if (latestForTurn !== null) {
return latestForTurn.implementedAt === null;
}
const latestPlan = sorted.at(-1) ?? null;
return latestPlan !== null && latestPlan.implementedAt === null;
}
function retainProjectionMessagesAfterRevert(
messages: ReadonlyArray<ProjectionThreadMessage>,
turns: ReadonlyArray<ProjectionTurn>,
turnCount: number,
): ReadonlyArray<ProjectionThreadMessage> {
const retainedMessageIds = new Set<string>();
const retainedTurnIds = new Set<string>();
const keptTurns = turns.filter(
(turn) =>
turn.turnId !== null &&
turn.checkpointTurnCount !== null &&
turn.checkpointTurnCount <= turnCount,
);
for (const turn of keptTurns) {
if (turn.turnId !== null) {
retainedTurnIds.add(turn.turnId);
}
if (turn.pendingMessageId !== null) {
retainedMessageIds.add(turn.pendingMessageId);
}
if (turn.assistantMessageId !== null) {
retainedMessageIds.add(turn.assistantMessageId);
}
}
for (const message of messages) {
if (message.role === "system") {
retainedMessageIds.add(message.messageId);
continue;
}
if (message.turnId !== null && retainedTurnIds.has(message.turnId)) {
retainedMessageIds.add(message.messageId);
}
}
const retainedUserCount = messages.filter(
(message) => message.role === "user" && retainedMessageIds.has(message.messageId),
).length;
const missingUserCount = Math.max(0, turnCount - retainedUserCount);
if (missingUserCount > 0) {
const fallbackUserMessages = messages
.filter(
(message) =>
message.role === "user" &&
!retainedMessageIds.has(message.messageId) &&
(message.turnId === null || retainedTurnIds.has(message.turnId)),
)
.toSorted(
(left, right) =>
left.createdAt.localeCompare(right.createdAt) ||
left.messageId.localeCompare(right.messageId),
)
.slice(0, missingUserCount);
for (const message of fallbackUserMessages) {
retainedMessageIds.add(message.messageId);
}
}
const retainedAssistantCount = messages.filter(
(message) => message.role === "assistant" && retainedMessageIds.has(message.messageId),
).length;
const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount);
if (missingAssistantCount > 0) {
const fallbackAssistantMessages = messages
.filter(
(message) =>
message.role === "assistant" &&
!retainedMessageIds.has(message.messageId) &&
(message.turnId === null || retainedTurnIds.has(message.turnId)),
)
.toSorted(
(left, right) =>
left.createdAt.localeCompare(right.createdAt) ||
left.messageId.localeCompare(right.messageId),
)
.slice(0, missingAssistantCount);
for (const message of fallbackAssistantMessages) {
retainedMessageIds.add(message.messageId);
}
}
return messages.filter((message) => retainedMessageIds.has(message.messageId));
}
function retainProjectionActivitiesAfterRevert(
activities: ReadonlyArray<ProjectionThreadActivity>,
turns: ReadonlyArray<ProjectionTurn>,
turnCount: number,
): ReadonlyArray<ProjectionThreadActivity> {
const retainedTurnIds = new Set<string>(
turns
.filter(
(turn) =>
turn.turnId !== null &&
turn.checkpointTurnCount !== null &&
turn.checkpointTurnCount <= turnCount,
)
.flatMap((turn) => (turn.turnId === null ? [] : [turn.turnId])),
);
return activities.filter(
(activity) => activity.turnId === null || retainedTurnIds.has(activity.turnId),
);
}
function retainProjectionProposedPlansAfterRevert(
proposedPlans: ReadonlyArray<ProjectionThreadProposedPlan>,
turns: ReadonlyArray<ProjectionTurn>,
turnCount: number,
): ReadonlyArray<ProjectionThreadProposedPlan> {
const retainedTurnIds = new Set<string>(
turns
.filter(
(turn) =>
turn.turnId !== null &&
turn.checkpointTurnCount !== null &&
turn.checkpointTurnCount <= turnCount,
)
.flatMap((turn) => (turn.turnId === null ? [] : [turn.turnId])),
);
return proposedPlans.filter(
(proposedPlan) => proposedPlan.turnId === null || retainedTurnIds.has(proposedPlan.turnId),
);
}
function collectThreadAttachmentRelativePaths(
threadId: string,
messages: ReadonlyArray<ProjectionThreadMessage>,
): Set<string> {
const threadSegment = toSafeThreadAttachmentSegment(threadId);
if (!threadSegment) {
return new Set();
}
const relativePaths = new Set<string>();
for (const message of messages) {
for (const attachment of message.attachments ?? []) {
const attachmentThreadSegment = parseThreadSegmentFromAttachmentId(attachment.id);
if (!attachmentThreadSegment || attachmentThreadSegment !== threadSegment) {
continue;
}
const relativePath = attachmentRelativePath(attachment);
if (relativePath) {
relativePaths.add(relativePath);
}
}
}
return relativePaths;
}
const runAttachmentSideEffects = Effect.fn("runAttachmentSideEffects")(function* (
sideEffects: AttachmentSideEffects,
) {
const serverConfig = yield* Effect.service(ServerConfig);
const fileSystem = yield* Effect.service(FileSystem.FileSystem);
const path = yield* Effect.service(Path.Path);
const attachmentsRootDir = serverConfig.attachmentsDir;
const readAttachmentRootEntries = fileSystem
.readDirectory(attachmentsRootDir, { recursive: false })
.pipe(Effect.orElseSucceed(() => [] as Array<string>));
const removeDeletedThreadAttachmentEntry = Effect.fn("removeDeletedThreadAttachmentEntry")(
function* (threadSegment: string, entry: string) {
const normalizedEntry = entry.replace(/^[/\\]+/, "").replace(/\\/g, "/");
if (normalizedEntry.length === 0 || normalizedEntry.includes("/")) {
return;
}
const attachmentId = parseAttachmentIdFromRelativePath(normalizedEntry);
if (!attachmentId) {
return;
}
const attachmentThreadSegment = parseThreadSegmentFromAttachmentId(attachmentId);
if (!attachmentThreadSegment || attachmentThreadSegment !== threadSegment) {
return;
}
yield* fileSystem.remove(path.join(attachmentsRootDir, normalizedEntry), {
force: true,
});
},
);
const deleteThreadAttachments = Effect.fn("deleteThreadAttachments")(function* (
threadId: string,
) {
const threadSegment = toSafeThreadAttachmentSegment(threadId);
if (!threadSegment) {
yield* Effect.logWarning("skipping attachment cleanup for unsafe thread id", {
threadId,
});
return;
}
const entries = yield* readAttachmentRootEntries;
yield* Effect.forEach(
entries,
(entry) => removeDeletedThreadAttachmentEntry(threadSegment, entry),
{
concurrency: 1,
},
);
});
const pruneThreadAttachmentEntry = Effect.fn("pruneThreadAttachmentEntry")(function* (
threadSegment: string,
keptThreadRelativePaths: Set<string>,
entry: string,
) {
const relativePath = entry.replace(/^[/\\]+/, "").replace(/\\/g, "/");
if (relativePath.length === 0 || relativePath.includes("/")) {
return;
}
const attachmentId = parseAttachmentIdFromRelativePath(relativePath);
if (!attachmentId) {
return;
}
const attachmentThreadSegment = parseThreadSegmentFromAttachmentId(attachmentId);
if (!attachmentThreadSegment || attachmentThreadSegment !== threadSegment) {
return;
}
const absolutePath = path.join(attachmentsRootDir, relativePath);
const fileInfo = yield* fileSystem.stat(absolutePath).pipe(Effect.orElseSucceed(() => null));
if (!fileInfo || fileInfo.type !== "File") {
return;
}
if (!keptThreadRelativePaths.has(relativePath)) {
yield* fileSystem.remove(absolutePath, { force: true });
}
});
const pruneThreadAttachments = Effect.fn("pruneThreadAttachments")(function* (
threadId: string,
keptThreadRelativePaths: Set<string>,
) {
if (sideEffects.deletedThreadIds.has(threadId)) {
return;
}
const threadSegment = toSafeThreadAttachmentSegment(threadId);
if (!threadSegment) {
yield* Effect.logWarning("skipping attachment prune for unsafe thread id", { threadId });
return;
}
const entries = yield* readAttachmentRootEntries;
yield* Effect.forEach(
entries,
(entry) => pruneThreadAttachmentEntry(threadSegment, keptThreadRelativePaths, entry),
{ concurrency: 1 },
);
});
yield* Effect.forEach(sideEffects.deletedThreadIds, deleteThreadAttachments, {
concurrency: 1,
});
yield* Effect.forEach(
sideEffects.prunedThreadRelativePaths.entries(),
([threadId, keptThreadRelativePaths]) =>
pruneThreadAttachments(threadId, keptThreadRelativePaths),
{ concurrency: 1 },
);
});
const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjectionPipeline")(
function* () {
const sql = yield* SqlClient.SqlClient;
const eventStore = yield* OrchestrationEventStore;
const projectionStateRepository = yield* ProjectionStateRepository;
const projectionProjectRepository = yield* ProjectionProjectRepository;
const projectionThreadRepository = yield* ProjectionThreadRepository;
const projectionThreadMessageRepository = yield* ProjectionThreadMessageRepository;
const projectionThreadProposedPlanRepository = yield* ProjectionThreadProposedPlanRepository;
const projectionThreadActivityRepository = yield* ProjectionThreadActivityRepository;
const projectionThreadSessionRepository = yield* ProjectionThreadSessionRepository;
const projectionTurnRepository = yield* ProjectionTurnRepository;
const projectionPendingApprovalRepository = yield* ProjectionPendingApprovalRepository;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const serverConfig = yield* ServerConfig;
const applyProjectsProjection: ProjectorDefinition["apply"] = Effect.fn(
"applyProjectsProjection",
)(function* (event, _attachmentSideEffects) {
switch (event.type) {
case "project.created":
yield* projectionProjectRepository.upsert({
projectId: event.payload.projectId,
title: event.payload.title,
workspaceRoot: event.payload.workspaceRoot,
defaultModelSelection: event.payload.defaultModelSelection,
defaultThreadEnvMode: null,
autoPull: false,
faviconPath: event.payload.faviconPath ?? null,
projectIcon: event.payload.projectIcon ?? null,
scripts: event.payload.scripts,
createdAt: event.payload.createdAt,
updatedAt: event.payload.updatedAt,
deletedAt: null,
});
return;
case "project.meta-updated": {
const existingRow = yield* projectionProjectRepository.getById({
projectId: event.payload.projectId,
});
if (Option.isNone(existingRow)) {
return;
}
yield* projectionProjectRepository.upsert({
...existingRow.value,
...(event.payload.title !== undefined ? { title: event.payload.title } : {}),
...(event.payload.workspaceRoot !== undefined
? { workspaceRoot: event.payload.workspaceRoot }
: {}),
...(event.payload.defaultModelSelection !== undefined
? { defaultModelSelection: event.payload.defaultModelSelection }
: {}),
...(event.payload.defaultThreadEnvMode !== undefined
? { defaultThreadEnvMode: event.payload.defaultThreadEnvMode }
: {}),
...(event.payload.autoPull !== undefined ? { autoPull: event.payload.autoPull } : {}),
...(event.payload.faviconPath !== undefined
? { faviconPath: event.payload.faviconPath }
: {}),
...(event.payload.projectIcon !== undefined
? { projectIcon: event.payload.projectIcon }
: {}),
...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}),
updatedAt: event.payload.updatedAt,
});
return;
}
case "project.deleted": {
const existingRow = yield* projectionProjectRepository.getById({
projectId: event.payload.projectId,
});
if (Option.isNone(existingRow)) {
return;
}
yield* projectionProjectRepository.upsert({
...existingRow.value,
deletedAt: event.payload.deletedAt,
updatedAt: event.payload.deletedAt,
});
return;
}
default:
return;
}
});
const refreshThreadShellSummary = Effect.fn("refreshThreadShellSummary")(function* (
threadId: ThreadId,
) {
const existingRow = yield* projectionThreadRepository.getById({
threadId,
});
if (Option.isNone(existingRow)) {
return;
}
const [latestUserMessageAt, proposedPlans, activities, pendingApprovalCount] =
yield* Effect.all([
projectionThreadMessageRepository.getLatestUserMessageAt({ threadId }),
projectionThreadProposedPlanRepository.listByThreadId({ threadId }),
projectionThreadActivityRepository.listUserInputLifecycleByThreadId({ threadId }),
projectionPendingApprovalRepository.countPendingByThreadId({ threadId }),
]);
const pendingUserInputCount = derivePendingUserInputCountFromActivities(activities);
const hasActionableProposedPlan = deriveHasActionableProposedPlan({
latestTurnId: existingRow.value.latestTurnId,
proposedPlans,
});
yield* projectionThreadRepository.upsert({
...existingRow.value,
latestUserMessageAt,
pendingApprovalCount,
pendingUserInputCount,
hasActionableProposedPlan: hasActionableProposedPlan ? 1 : 0,
});
});
const applyThreadsProjection: ProjectorDefinition["apply"] = Effect.fn(
"applyThreadsProjection",
)(function* (event, attachmentSideEffects) {
switch (event.type) {
case "thread.created":
yield* projectionThreadRepository.upsert({
threadId: event.payload.threadId,
projectId: event.payload.projectId,
title: event.payload.title,
modelSelection: event.payload.modelSelection,
runtimeMode: event.payload.runtimeMode,
interactionMode: event.payload.interactionMode,
branch: event.payload.branch,
worktreePath: event.payload.worktreePath,
linkedPullRequest: null,
latestTurnId: null,
createdAt: event.payload.createdAt,
updatedAt: event.payload.updatedAt,
archivedAt: null,
settledOverride: null,
settledAt: null,
unsettledAt: null,
snoozedUntil: null,
snoozedAt: null,
pinnedAt: null,
pinOrderKey: null,
titleRegenerationRequestId: null,
titleRegenerationStartedAt: null,
latestUserMessageAt: null,
pendingApprovalCount: 0,
pendingUserInputCount: 0,
hasActionableProposedPlan: 0,
deletedAt: null,
});
return;
case "thread.archived": {
const existingRow = yield* projectionThreadRepository.getById({
threadId: event.payload.threadId,
});
if (Option.isNone(existingRow)) {
return;
}
yield* projectionThreadRepository.upsert({
...existingRow.value,
archivedAt: event.payload.archivedAt,
titleRegenerationRequestId: null,
titleRegenerationStartedAt: null,
updatedAt: event.payload.updatedAt,
});
return;
}
case "thread.unarchived": {
const existingRow = yield* projectionThreadRepository.getById({
threadId: event.payload.threadId,
});
if (Option.isNone(existingRow)) {
return;
}
yield* projectionThreadRepository.upsert({
...existingRow.value,
archivedAt: null,
updatedAt: event.payload.updatedAt,
});
return;
}
case "thread.settled": {
const existingRow = yield* projectionThreadRepository.getById({
threadId: event.payload.threadId,
});
if (Option.isNone(existingRow)) {
return;
}
yield* projectionThreadRepository.upsert({
...existingRow.value,
settledOverride: "settled",
settledAt: event.payload.settledAt,
unsettledAt: null,
updatedAt: event.payload.updatedAt,
});
return;
}
case "thread.unsettled": {
const existingRow = yield* projectionThreadRepository.getById({
threadId: event.payload.threadId,
});
if (Option.isNone(existingRow)) {
return;
}
yield* projectionThreadRepository.upsert({
...existingRow.value,
settledOverride: event.payload.reason === "user" ? "active" : null,
settledAt: null,
// Re-entry stamp for active-list ordering. A thread already pinned
// active keeps its stamp: the activity reset that clears the pin
// is not a re-entry and must not reorder the list.
unsettledAt:
existingRow.value.settledOverride === "active"
? existingRow.value.unsettledAt
: event.payload.updatedAt,
updatedAt: event.payload.updatedAt,
});
return;
}
case "thread.snoozed": {
const existingRow = yield* projectionThreadRepository.getById({
threadId: event.payload.threadId,
});
if (Option.isNone(existingRow)) {
return;
}
yield* projectionThreadRepository.upsert({
...existingRow.value,
snoozedUntil: event.payload.snoozedUntil,
snoozedAt: event.payload.snoozedAt,
updatedAt: event.payload.updatedAt,
});
return;
}
case "thread.unsnoozed": {
const existingRow = yield* projectionThreadRepository.getById({
threadId: event.payload.threadId,
});
if (Option.isNone(existingRow)) {
return;
}
yield* projectionThreadRepository.upsert({
...existingRow.value,
snoozedUntil: null,
snoozedAt: null,
updatedAt: event.payload.updatedAt,
});
return;
}
case "thread.pinned": {
const existingRow = yield* projectionThreadRepository.getById({
threadId: event.payload.threadId,
});
if (Option.isNone(existingRow)) {
return;
}
yield* projectionThreadRepository.upsert({
...existingRow.value,
pinnedAt: event.payload.pinnedAt,
...(event.payload.pinOrderKey !== undefined
? { pinOrderKey: event.payload.pinOrderKey }
: {}),
updatedAt: event.payload.updatedAt,
});
return;
}
case "thread.unpinned": {
const existingRow = yield* projectionThreadRepository.getById({
threadId: event.payload.threadId,
});
if (Option.isNone(existingRow)) {
return;
}
yield* projectionThreadRepository.upsert({
...existingRow.value,
pinnedAt: null,
pinOrderKey: null,
updatedAt: event.payload.updatedAt,
});
return;
}
case "thread.pin-reordered": {
const existingRow = yield* projectionThreadRepository.getById({
threadId: event.payload.threadId,
});
if (Option.isNone(existingRow)) {
return;
}
yield* projectionThreadRepository.upsert({
...existingRow.value,
pinOrderKey: event.payload.orderKey,
updatedAt: event.payload.updatedAt,
});
return;
}
case "thread.meta-updated": {
const existingRow = yield* projectionThreadRepository.getById({
threadId: event.payload.threadId,
});
if (Option.isNone(existingRow)) {
return;
}
yield* projectionThreadRepository.upsert({
...existingRow.value,
...(event.payload.title !== undefined ? { title: event.payload.title } : {}),
...(event.payload.titleRegeneration !== undefined
? {
titleRegenerationRequestId: event.payload.titleRegeneration?.requestId ?? null,
titleRegenerationStartedAt: event.payload.titleRegeneration?.startedAt ?? null,
}
: {}),
...(event.payload.modelSelection !== undefined
? { modelSelection: event.payload.modelSelection }
: {}),
...(event.payload.branch !== undefined ? { branch: event.payload.branch } : {}),
...(event.payload.worktreePath !== undefined
? { worktreePath: event.payload.worktreePath }
: {}),
...(event.payload.linkedPullRequest !== undefined
? { linkedPullRequest: event.payload.linkedPullRequest }
: {}),
updatedAt: event.payload.updatedAt,
});
return;
}
case "thread.runtime-mode-set": {
const existingRow = yield* projectionThreadRepository.getById({
threadId: event.payload.threadId,
});
if (Option.isNone(existingRow)) {
return;
}
yield* projectionThreadRepository.upsert({
...existingRow.value,
runtimeMode: event.payload.runtimeMode,
updatedAt: event.payload.updatedAt,
});
return;
}
case "thread.interaction-mode-set": {
const existingRow = yield* projectionThreadRepository.getById({
threadId: event.payload.threadId,
});
if (Option.isNone(existingRow)) {
return;
}
yield* projectionThreadRepository.upsert({
...existingRow.value,
interactionMode: event.payload.interactionMode,
updatedAt: event.payload.updatedAt,
});
return;
}
case "thread.deleted": {
// A draft retry can re-create this id later in the log. During
// replay the attachment files on disk already belong to that later
// incarnation, so only an unsuperseded deletion removes them.
const recreatedLater = yield* eventStore.hasEventAfter({
aggregateKind: "thread",
aggregateId: event.payload.threadId,
type: "thread.created",
sequenceExclusive: event.sequence,
});
if (!recreatedLater) {
attachmentSideEffects.deletedThreadIds.add(event.payload.threadId);
}
const existingRow = yield* projectionThreadRepository.getById({
threadId: event.payload.threadId,
});
if (Option.isNone(existingRow)) {
return;
}
yield* projectionThreadRepository.upsert({
...existingRow.value,
deletedAt: event.payload.deletedAt,
updatedAt: event.payload.deletedAt,
});
return;
}
// A message cannot change any summary field except latestUserMessageAt,
// which is a monotonic maximum that folds in directly. The full refresh
// would re-read every message body in the thread per user message.
case "thread.message-sent": {
const existingRow = yield* projectionThreadRepository.getById({
threadId: event.payload.threadId,
});
if (Option.isNone(existingRow)) {
return;
}
const previousLatest = existingRow.value.latestUserMessageAt;
yield* projectionThreadRepository.upsert({
...existingRow.value,
updatedAt: event.occurredAt,
latestUserMessageAt:
event.payload.role === "user" &&
(previousLatest === null || event.payload.createdAt > previousLatest)
? event.payload.createdAt
: previousLatest,
});
return;
}
case "thread.proposed-plan-upserted":
case "thread.activity-appended":
case "thread.approval-response-requested":
case "thread.user-input-response-requested": {
const existingRow = yield* projectionThreadRepository.getById({
threadId: event.payload.threadId,
});
if (Option.isNone(existingRow)) {
return;
}
yield* projectionThreadRepository.upsert({
...existingRow.value,
updatedAt: event.occurredAt,
});
if (shouldRefreshThreadShellSummary(event)) {
yield* refreshThreadShellSummary(event.payload.threadId);
}
return;
}
case "thread.session-set": {
const existingRow = yield* projectionThreadRepository.getById({
threadId: event.payload.threadId,
});
if (Option.isNone(existingRow)) {
return;
}
yield* projectionThreadRepository.upsert({
...existingRow.value,
// activeTurnId describes current work; a terminal session must not erase history.
latestTurnId: event.payload.session.activeTurnId ?? existingRow.value.latestTurnId,
updatedAt: event.occurredAt,
});
yield* refreshThreadShellSummary(event.payload.threadId);
return;
}
case "thread.turn-diff-completed": {
const existingRow = yield* projectionThreadRepository.getById({
threadId: event.payload.threadId,
});
if (Option.isNone(existingRow)) {
return;
}
yield* projectionThreadRepository.upsert({
...existingRow.value,
latestTurnId: event.payload.turnId,
updatedAt: event.occurredAt,
});
yield* refreshThreadShellSummary(event.payload.threadId);
return;
}
case "thread.reverted": {
const existingRow = yield* projectionThreadRepository.getById({
threadId: event.payload.threadId,
});
if (Option.isNone(existingRow)) {
return;
}
const retainedTurns = yield* projectionTurnRepository.listByThreadId({
threadId: event.payload.threadId,
});
let latestTurnId: ProjectionTurn["turnId"] = null;
let latestCheckpointTurnCount = -1;
for (let index = 0; index < retainedTurns.length; index += 1) {
const turn = retainedTurns[index];
if (
!turn ||
turn.turnId === null ||
turn.checkpointTurnCount === null ||
turn.checkpointTurnCount > event.payload.turnCount
) {
continue;
}
if (turn.checkpointTurnCount > latestCheckpointTurnCount) {
latestCheckpointTurnCount = turn.checkpointTurnCount;
latestTurnId = turn.turnId;
}
}
yield* projectionThreadRepository.upsert({
...existingRow.value,
latestTurnId,
updatedAt: event.occurredAt,
});
yield* refreshThreadShellSummary(event.payload.threadId);
return;
}
default:
return;
}
});
const applyThreadMessagesProjection: ProjectorDefinition["apply"] = Effect.fn(
"applyThreadMessagesProjection",
)(function* (event, attachmentSideEffects) {
switch (event.type) {
// A draft retry re-creates a soft-deleted thread id. Every projector
// drops its own rows for the old incarnation here so replay from any
// per-projector cursor rebuilds the new thread without stale history.
case "thread.created":
yield* projectionThreadMessageRepository.deleteByThreadId({
threadId: event.payload.threadId,
});
return;
case "thread.message-sent": {
if (event.payload.streaming) {
const attachments =
event.payload.attachments !== undefined
? yield* materializeAttachmentsForProjection({
attachments: event.payload.attachments,
})
: undefined;
yield* projectionThreadMessageRepository.appendStreaming({
messageId: event.payload.messageId,
threadId: event.payload.threadId,
turnId: event.payload.turnId,
role: event.payload.role,
text: event.payload.text,
...(attachments !== undefined ? { attachments: [...attachments] } : {}),
createdAt: event.payload.createdAt,
updatedAt: event.payload.updatedAt,
});
return;
}
const existingMessage = yield* projectionThreadMessageRepository.getByMessageId({
messageId: event.payload.messageId,
});
const previousMessage = Option.getOrUndefined(existingMessage);
const nextText = Option.match(existingMessage, {
onNone: () => event.payload.text,
onSome: (message) =>
event.payload.text.length === 0 ? message.text : event.payload.text,
});
const nextAttachments =
event.payload.attachments !== undefined
? yield* materializeAttachmentsForProjection({
attachments: event.payload.attachments,
})
: previousMessage?.attachments;
yield* projectionThreadMessageRepository.upsert({
messageId: event.payload.messageId,
threadId: event.payload.threadId,
turnId: event.payload.turnId,
role: event.payload.role,
text: nextText,
...(nextAttachments !== undefined ? { attachments: [...nextAttachments] } : {}),
isStreaming: false,
createdAt: previousMessage?.createdAt ?? event.payload.createdAt,
updatedAt: event.payload.updatedAt,
});
return;
}
case "thread.reverted": {
const existingRows = yield* projectionThreadMessageRepository.listByThreadId({
threadId: event.payload.threadId,
});
if (existingRows.length === 0) {
return;
}
const existingTurns = yield* projectionTurnRepository.listByThreadId({
threadId: event.payload.threadId,
});
const keptRows = retainProjectionMessagesAfterRevert(
existingRows,
existingTurns,
event.payload.turnCount,
);
if (keptRows.length === existingRows.length) {
return;
}
yield* projectionThreadMessageRepository.deleteByThreadId({
threadId: event.payload.threadId,
});
yield* Effect.forEach(keptRows, projectionThreadMessageRepository.upsert, {
concurrency: 1,
}).pipe(Effect.asVoid);
attachmentSideEffects.prunedThreadRelativePaths.set(
event.payload.threadId,
collectThreadAttachmentRelativePaths(event.payload.threadId, keptRows),
);
return;
}
default:
return;
}
});
const applyThreadProposedPlansProjection: ProjectorDefinition["apply"] = Effect.fn(
"applyThreadProposedPlansProjection",
)(function* (event, _attachmentSideEffects) {
switch (event.type) {
case "thread.created":
yield* projectionThreadProposedPlanRepository.deleteByThreadId({
threadId: event.payload.threadId,
});
return;
case "thread.proposed-plan-upserted":
yield* projectionThreadProposedPlanRepository.upsert({
planId: event.payload.proposedPlan.id,
threadId: event.payload.threadId,
turnId: event.payload.proposedPlan.turnId,
planMarkdown: event.payload.proposedPlan.planMarkdown,
implementedAt: event.payload.proposedPlan.implementedAt,
implementationThreadId: event.payload.proposedPlan.implementationThreadId,
createdAt: event.payload.proposedPlan.createdAt,
updatedAt: event.payload.proposedPlan.updatedAt,
});
return;
case "thread.reverted": {
const existingRows = yield* projectionThreadProposedPlanRepository.listByThreadId({
threadId: event.payload.threadId,
});
if (existingRows.length === 0) {
return;
}
const existingTurns = yield* projectionTurnRepository.listByThreadId({
threadId: event.payload.threadId,
});
const keptRows = retainProjectionProposedPlansAfterRevert(
existingRows,
existingTurns,
event.payload.turnCount,
);
if (keptRows.length === existingRows.length) {
return;
}
yield* projectionThreadProposedPlanRepository.deleteByThreadId({
threadId: event.payload.threadId,
});
yield* Effect.forEach(keptRows, projectionThreadProposedPlanRepository.upsert, {
concurrency: 1,
}).pipe(Effect.asVoid);
return;
}
default:
return;
}
});
const applyThreadActivitiesProjection: ProjectorDefinition["apply"] = Effect.fn(
"applyThreadActivitiesProjection",
)(function* (event, _attachmentSideEffects) {
switch (event.type) {
case "thread.created":
yield* projectionThreadActivityRepository.deleteByThreadId({
threadId: event.payload.threadId,
});
return;
case "thread.activity-appended":
yield* projectionThreadActivityRepository.upsert({
activityId: event.payload.activity.id,
threadId: event.payload.threadId,
turnId: event.payload.activity.turnId,
tone: event.payload.activity.tone,
kind: event.payload.activity.kind,
summary: event.payload.activity.summary,
payload: event.payload.activity.payload,
...(event.payload.activity.sequence !== undefined
? { sequence: event.payload.activity.sequence }
: {}),
createdAt: event.payload.activity.createdAt,
});
return;
case "thread.reverted": {
const existingRows = yield* projectionThreadActivityRepository.listByThreadId({
threadId: event.payload.threadId,
});
if (existingRows.length === 0) {
return;
}
const existingTurns = yield* projectionTurnRepository.listByThreadId({
threadId: event.payload.threadId,
});
const keptRows = retainProjectionActivitiesAfterRevert(
existingRows,
existingTurns,
event.payload.turnCount,
);
if (keptRows.length === existingRows.length) {
return;
}
yield* projectionThreadActivityRepository.deleteByThreadId({
threadId: event.payload.threadId,
});
yield* Effect.forEach(keptRows, projectionThreadActivityRepository.upsert, {
concurrency: 1,
}).pipe(Effect.asVoid);
return;
}
default:
return;
}
});
const applyThreadSessionsProjection: ProjectorDefinition["apply"] = Effect.fn(
"applyThreadSessionsProjection",
)(function* (event, _attachmentSideEffects) {
if (event.type === "thread.created") {
yield* projectionThreadSessionRepository.deleteByThreadId({
threadId: event.payload.threadId,
});
return;
}
if (event.type !== "thread.session-set") {
return;
}
yield* projectionThreadSessionRepository.upsert({
threadId: event.payload.threadId,
status: event.payload.session.status,
providerName: event.payload.session.providerName,
providerInstanceId: event.payload.session.providerInstanceId ?? null,
runtimeMode: event.payload.session.runtimeMode,
activeTurnId: event.payload.session.activeTurnId,
lastError: event.payload.session.lastError,
updatedAt: event.payload.session.updatedAt,
});
});
const applyThreadTurnsProjection: ProjectorDefinition["apply"] = Effect.fn(
"applyThreadTurnsProjection",
)(function* (event, _attachmentSideEffects) {
switch (event.type) {
case "thread.created":
yield* projectionTurnRepository.deleteByThreadId({
threadId: event.payload.threadId,
});
return;
case "thread.turn-start-requested": {
const pendingTurnStart = yield* projectionTurnRepository.getPendingTurnStartByThreadId({
threadId: event.payload.threadId,
});
if (Option.isSome(pendingTurnStart)) {
const pendingMessage = yield* projectionThreadMessageRepository.getByMessageId({
messageId: pendingTurnStart.value.messageId,
});
if (
Option.isSome(pendingMessage) &&
pendingMessage.value.role === "user" &&
(pendingMessage.value.attachments?.length ?? 0) === 0 &&
pendingMessage.value.text.trim().toLowerCase() === "/compact"
) {
return;
}
}
yield* projectionTurnRepository.replacePendingTurnStart({
threadId: event.payload.threadId,
messageId: event.payload.messageId,
sourceProposedPlanThreadId: event.payload.sourceProposedPlan?.threadId ?? null,
sourceProposedPlanId: event.payload.sourceProposedPlan?.planId ?? null,
requestedAt: event.payload.createdAt,
});
return;
}
case "thread.activity-appended": {
if (event.payload.activity.kind === "context-compaction") {
const pendingTurnStart = yield* projectionTurnRepository.getPendingTurnStartByThreadId(
event.payload,
);
if (
Option.isNone(pendingTurnStart) ||
String(pendingTurnStart.value.messageId) !==
extractActivityRequestId(event.payload.activity.payload)
) {
return;
}
yield* projectionTurnRepository.deletePendingTurnStartByThreadId(event.payload);
return;
}
if (event.payload.activity.kind !== "provider.turn.start.failed") return;
const pendingTurnStart = yield* projectionTurnRepository.getPendingTurnStartByThreadId(
event.payload,
);
if (
Option.isNone(pendingTurnStart) ||
String(pendingTurnStart.value.messageId) !==
extractActivityRequestId(event.payload.activity.payload)
) {
return;
}
yield* projectionTurnRepository.deletePendingTurnStartByThreadId(event.payload);
return;
}
case "thread.session-set": {
const turnId = event.payload.session.activeTurnId;
if (turnId === null || event.payload.session.status !== "running") {
if (
(event.payload.session.status === "ready" &&
event.commandId?.startsWith("server:provider-session-set:") === true) ||
event.payload.session.status === "error" ||
event.payload.session.status === "stopped" ||
event.payload.session.status === "interrupted"
) {
yield* projectionTurnRepository.deletePendingTurnStartByThreadId({
threadId: event.payload.threadId,
});
}
// Leaving the "running" session status is the turn-end signal:
// settle still-running turns so their duration reflects the whole
// turn rather than the last assistant message.
const settledTurnState = settledTurnStateForSessionStatus(event.payload.session.status);
if (settledTurnState === null) {
return;
}
const existingTurns = yield* projectionTurnRepository.listByThreadId({
threadId: event.payload.threadId,
});
yield* Effect.forEach(
existingTurns.filter((turn) => turn.turnId !== null && turn.state === "running"),
(turn) =>
turn.turnId === null
? Effect.void
: projectionTurnRepository.upsertByTurnId({
...turn,
turnId: turn.turnId,
state: settledTurnState,
// A running turn's completedAt can only hold a mid-turn
// placeholder checkpoint timestamp — the session leaving
// "running" is the authoritative turn end.
completedAt: event.payload.session.updatedAt,
}),
{ concurrency: 1 },
);
return;
}
// A new active turn supersedes any still-running turn on the same
// thread — steering can open a new turn without the provider ever
// completing the previous one.
const otherRunningTurns = yield* projectionTurnRepository.listByThreadId({
threadId: event.payload.threadId,
});
yield* Effect.forEach(
otherRunningTurns.filter(
(turn) => turn.turnId !== null && turn.turnId !== turnId && turn.state === "running",
),
(turn) =>
turn.turnId === null
? Effect.void
: projectionTurnRepository.upsertByTurnId({
...turn,
turnId: turn.turnId,
state: "completed",
completedAt: event.payload.session.updatedAt,
}),
{ concurrency: 1 },
);
const existingTurn = yield* projectionTurnRepository.getByTurnId({
threadId: event.payload.threadId,
turnId,
});
const pendingTurnStart = yield* projectionTurnRepository.getPendingTurnStartByThreadId({
threadId: event.payload.threadId,
});
if (Option.isSome(existingTurn)) {
const nextState =
existingTurn.value.state === "completed" || existingTurn.value.state === "error"
? existingTurn.value.state
: "running";
yield* projectionTurnRepository.upsertByTurnId({
...existingTurn.value,
state: nextState,
pendingMessageId:
existingTurn.value.pendingMessageId ??
(Option.isSome(pendingTurnStart) ? pendingTurnStart.value.messageId : null),
sourceProposedPlanThreadId:
existingTurn.value.sourceProposedPlanThreadId ??
(Option.isSome(pendingTurnStart)
? pendingTurnStart.value.sourceProposedPlanThreadId
: null),
sourceProposedPlanId:
existingTurn.value.sourceProposedPlanId ??
(Option.isSome(pendingTurnStart)
? pendingTurnStart.value.sourceProposedPlanId
: null),
startedAt:
existingTurn.value.startedAt ??
(Option.isSome(pendingTurnStart)
? pendingTurnStart.value.requestedAt
: event.occurredAt),
requestedAt:
existingTurn.value.requestedAt ??
(Option.isSome(pendingTurnStart)
? pendingTurnStart.value.requestedAt
: event.occurredAt),
});
} else {
yield* projectionTurnRepository.upsertByTurnId({
turnId,
threadId: event.payload.threadId,
pendingMessageId: Option.isSome(pendingTurnStart)
? pendingTurnStart.value.messageId
: null,
sourceProposedPlanThreadId: Option.isSome(pendingTurnStart)
? pendingTurnStart.value.sourceProposedPlanThreadId
: null,
sourceProposedPlanId: Option.isSome(pendingTurnStart)
? pendingTurnStart.value.sourceProposedPlanId
: null,
assistantMessageId: null,
state: "running",
requestedAt: Option.isSome(pendingTurnStart)
? pendingTurnStart.value.requestedAt
: event.occurredAt,
startedAt: Option.isSome(pendingTurnStart)
? pendingTurnStart.value.requestedAt
: event.occurredAt,
completedAt: null,
checkpointTurnCount: null,
checkpointRef: null,
checkpointStatus: null,
checkpointFiles: [],
});
}
yield* projectionTurnRepository.deletePendingTurnStartByThreadId({
threadId: event.payload.threadId,
});
return;
}
case "thread.message-sent": {
if (event.payload.turnId === null || event.payload.role !== "assistant") {
return;
}
// A completed assistant message only settles the turn once the
// session is no longer running it — providers may emit several
// assistant messages per turn (commentary between tool calls), and
// the turn must stay unsettled until the provider reports turn end
// (projected as thread.session-set leaving the "running" status).
const session = yield* projectionThreadSessionRepository.getByThreadId({
threadId: event.payload.threadId,
});
const turnStillRunning =
Option.isSome(session) &&
session.value.status === "running" &&
session.value.activeTurnId === event.payload.turnId;
const settlesTurn = !event.payload.streaming && !turnStillRunning;
const existingTurn = yield* projectionTurnRepository.getByTurnId({
threadId: event.payload.threadId,
turnId: event.payload.turnId,
});
if (Option.isSome(existingTurn)) {
yield* projectionTurnRepository.upsertByTurnId({
...existingTurn.value,
assistantMessageId: event.payload.messageId,
state: settlesTurn
? existingTurn.value.state === "interrupted"
? "interrupted"
: existingTurn.value.state === "error"
? "error"
: "completed"
: existingTurn.value.state,
completedAt: settlesTurn
? (existingTurn.value.completedAt ?? event.payload.updatedAt)
: existingTurn.value.completedAt,
startedAt: existingTurn.value.startedAt ?? event.payload.createdAt,
requestedAt: existingTurn.value.requestedAt ?? event.payload.createdAt,
});
return;
}
yield* projectionTurnRepository.upsertByTurnId({
turnId: event.payload.turnId,
threadId: event.payload.threadId,
pendingMessageId: null,
sourceProposedPlanThreadId: null,
sourceProposedPlanId: null,
assistantMessageId: event.payload.messageId,
state: settlesTurn ? "completed" : "running",
requestedAt: event.payload.createdAt,
startedAt: event.payload.createdAt,
completedAt: settlesTurn ? event.payload.updatedAt : null,
checkpointTurnCount: null,
checkpointRef: null,
checkpointStatus: null,
checkpointFiles: [],
});
return;
}
case "thread.turn-interrupt-requested": {
if (event.payload.turnId === undefined) {
return;
}
const existingTurn = yield* projectionTurnRepository.getByTurnId({
threadId: event.payload.threadId,
turnId: event.payload.turnId,
});
if (Option.isSome(existingTurn)) {
yield* projectionTurnRepository.upsertByTurnId({
...existingTurn.value,
state: "interrupted",
completedAt: existingTurn.value.completedAt ?? event.payload.createdAt,
startedAt: existingTurn.value.startedAt ?? event.payload.createdAt,
requestedAt: existingTurn.value.requestedAt ?? event.payload.createdAt,
});
return;
}
yield* projectionTurnRepository.upsertByTurnId({
turnId: event.payload.turnId,
threadId: event.payload.threadId,
pendingMessageId: null,
sourceProposedPlanThreadId: null,
sourceProposedPlanId: null,
assistantMessageId: null,
state: "interrupted",
requestedAt: event.payload.createdAt,
startedAt: event.payload.createdAt,
completedAt: event.payload.createdAt,
checkpointTurnCount: null,
checkpointRef: null,
checkpointStatus: null,
checkpointFiles: [],
});
return;
}
case "thread.turn-diff-completed": {
// Mid-turn diff updates produce placeholder checkpoints; record the
// checkpoint, but don't settle a turn its session is still running.
const session = yield* projectionThreadSessionRepository.getByThreadId({
threadId: event.payload.threadId,
});
const turnStillRunning =
Option.isSome(session) &&
session.value.status === "running" &&
session.value.activeTurnId === event.payload.turnId;
const existingTurn = yield* projectionTurnRepository.getByTurnId({
threadId: event.payload.threadId,
turnId: event.payload.turnId,
});
const nextState = event.payload.status === "error" ? "error" : "completed";
yield* projectionTurnRepository.clearCheckpointTurnConflict({
threadId: event.payload.threadId,
turnId: event.payload.turnId,
checkpointTurnCount: event.payload.checkpointTurnCount,
});
if (Option.isSome(existingTurn)) {
yield* projectionTurnRepository.upsertByTurnId({
...existingTurn.value,
assistantMessageId: event.payload.assistantMessageId,
state: turnStillRunning ? existingTurn.value.state : nextState,
checkpointTurnCount: event.payload.checkpointTurnCount,
checkpointRef: event.payload.checkpointRef,
checkpointStatus: event.payload.status,
checkpointFiles: event.payload.files,
startedAt: existingTurn.value.startedAt ?? event.payload.completedAt,
requestedAt: existingTurn.value.requestedAt ?? event.payload.completedAt,
completedAt: event.payload.completedAt,
});
return;
}
yield* projectionTurnRepository.upsertByTurnId({
turnId: event.payload.turnId,
threadId: event.payload.threadId,
pendingMessageId: null,
sourceProposedPlanThreadId: null,
sourceProposedPlanId: null,
assistantMessageId: event.payload.assistantMessageId,
state: turnStillRunning ? "running" : nextState,
requestedAt: event.payload.completedAt,
startedAt: event.payload.completedAt,
completedAt: event.payload.completedAt,
checkpointTurnCount: event.payload.checkpointTurnCount,
checkpointRef: event.payload.checkpointRef,
checkpointStatus: event.payload.status,
checkpointFiles: event.payload.files,
});
return;
}
case "thread.reverted": {
const existingTurns = yield* projectionTurnRepository.listByThreadId({
threadId: event.payload.threadId,
});
const keptTurns = existingTurns.filter(
(turn) =>
turn.turnId !== null &&
turn.checkpointTurnCount !== null &&
turn.checkpointTurnCount <= event.payload.turnCount,
);
yield* projectionTurnRepository.deleteByThreadId({
threadId: event.payload.threadId,
});
yield* Effect.forEach(
keptTurns,
(turn) =>
turn.turnId === null
? Effect.void
: projectionTurnRepository.upsertByTurnId({
...turn,
turnId: turn.turnId,
}),
{ concurrency: 1 },
).pipe(Effect.asVoid);
return;
}
default:
return;
}
});
const applyCheckpointsProjection: ProjectorDefinition["apply"] = () => Effect.void;
const applyPendingApprovalsProjection: ProjectorDefinition["apply"] = Effect.fn(
"applyPendingApprovalsProjection",
)(function* (event, _attachmentSideEffects) {
switch (event.type) {
case "thread.created":
yield* projectionPendingApprovalRepository.deleteByThreadId({
threadId: event.payload.threadId,
});
return;
case "thread.activity-appended": {
const requestId =
extractActivityRequestId(event.payload.activity.payload) ??
event.metadata.requestId ??
null;
if (requestId === null) {
return;
}
const existingRow = yield* projectionPendingApprovalRepository.getByRequestId({
requestId,
});
if (event.payload.activity.kind === "approval.resolved") {
const resolvedDecisionRaw =
typeof event.payload.activity.payload === "object" &&
event.payload.activity.payload !== null &&
"decision" in event.payload.activity.payload
? (event.payload.activity.payload as { decision?: unknown }).decision
: null;
const resolvedDecision =
resolvedDecisionRaw === "accept" ||
resolvedDecisionRaw === "acceptForSession" ||
resolvedDecisionRaw === "acceptAlways" ||
resolvedDecisionRaw === "decline" ||
resolvedDecisionRaw === "cancel"
? resolvedDecisionRaw
: null;
yield* projectionPendingApprovalRepository.upsert({
requestId,
threadId: Option.isSome(existingRow)
? existingRow.value.threadId
: event.payload.threadId,
turnId: Option.isSome(existingRow)
? existingRow.value.turnId
: event.payload.activity.turnId,
status: "resolved",
decision: resolvedDecision,
createdAt: Option.isSome(existingRow)
? existingRow.value.createdAt
: event.payload.activity.createdAt,
resolvedAt: event.payload.activity.createdAt,
});
return;
}
if (event.payload.activity.kind === "provider.approval.respond.failed") {
const payload =
typeof event.payload.activity.payload === "object" &&
event.payload.activity.payload !== null
? (event.payload.activity.payload as Record<string, unknown>)
: null;
const detail =
typeof payload?.detail === "string" ? payload.detail.toLowerCase() : null;
if (isStalePendingApprovalFailureDetail(detail)) {
if (Option.isNone(existingRow)) {
return;
}
if (existingRow.value.status === "resolved") {
return;
}
yield* projectionPendingApprovalRepository.upsert({
requestId,
threadId: existingRow.value.threadId,
turnId: existingRow.value.turnId,
status: "resolved",
decision: null,
createdAt: existingRow.value.createdAt,
resolvedAt: event.payload.activity.createdAt,
});
return;
}
if (Option.isNone(existingRow) || existingRow.value.status !== "resolved") {
return;
}
// Sending a reply clears the badge before the provider accepts it.
// A failed reply must restore the request unless a terminal event
// already closed it, including a reply from another client.
const requestActivities = (yield* projectionThreadActivityRepository.listByThreadId({
threadId: existingRow.value.threadId,
})).filter((activity) => extractActivityRequestId(activity.payload) === requestId);
const wasRequested = requestActivities.some(
(activity) => activity.kind === "approval.requested",
);
const wasResolved = requestActivities.some((activity) => {
if (activity.kind === "approval.resolved") {
return true;
}
if (activity.kind !== "provider.approval.respond.failed") {
return false;
}
const activityPayload =
typeof activity.payload === "object" && activity.payload !== null
? (activity.payload as Record<string, unknown>)
: null;
return isStalePendingApprovalFailureDetail(
typeof activityPayload?.detail === "string"
? activityPayload.detail.toLowerCase()
: null,
);
});
if (wasRequested && !wasResolved) {
yield* projectionPendingApprovalRepository.upsert({
...existingRow.value,
status: "pending",
decision: null,
resolvedAt: null,
});
}
return;
}
// Only approval-requested activities should create pending-approval
// rows. Other activity kinds that happen to carry a requestId
// (e.g. user-input.requested / user-input.resolved) must not
// pollute this projection — they have their own accounting via
// derivePendingUserInputCountFromActivities.
if (event.payload.activity.kind !== "approval.requested") {
return;
}
if (Option.isSome(existingRow) && existingRow.value.status === "resolved") {
return;
}
yield* projectionPendingApprovalRepository.upsert({
requestId,
threadId: event.payload.threadId,
turnId: event.payload.activity.turnId,
status: "pending",
decision: null,
createdAt: Option.isSome(existingRow)
? existingRow.value.createdAt
: event.payload.activity.createdAt,
resolvedAt: null,
});
return;
}
case "thread.approval-response-requested": {
const existingRow = yield* projectionPendingApprovalRepository.getByRequestId({
requestId: event.payload.requestId,
});
yield* projectionPendingApprovalRepository.upsert({
requestId: event.payload.requestId,
threadId: Option.isSome(existingRow)
? existingRow.value.threadId
: event.payload.threadId,
turnId: Option.isSome(existingRow) ? existingRow.value.turnId : null,
status: "resolved",
decision: event.payload.decision,
createdAt: Option.isSome(existingRow)
? existingRow.value.createdAt
: event.payload.createdAt,
resolvedAt: event.payload.createdAt,
});
return;
}
default:
return;
}
});
const projectors: ReadonlyArray<ProjectorDefinition> = [
{
name: ORCHESTRATION_PROJECTOR_NAMES.projects,
apply: applyProjectsProjection,
},
{
name: ORCHESTRATION_PROJECTOR_NAMES.threadMessages,
apply: applyThreadMessagesProjection,
},
{
name: ORCHESTRATION_PROJECTOR_NAMES.threadProposedPlans,
apply: applyThreadProposedPlansProjection,
},
{
name: ORCHESTRATION_PROJECTOR_NAMES.threadActivities,
apply: applyThreadActivitiesProjection,
},
{
name: ORCHESTRATION_PROJECTOR_NAMES.threadSessions,
apply: applyThreadSessionsProjection,
},
{
name: ORCHESTRATION_PROJECTOR_NAMES.threadTurns,
apply: applyThreadTurnsProjection,
},
{
name: ORCHESTRATION_PROJECTOR_NAMES.checkpoints,
apply: applyCheckpointsProjection,
},
{
name: ORCHESTRATION_PROJECTOR_NAMES.pendingApprovals,
apply: applyPendingApprovalsProjection,
},
{
name: ORCHESTRATION_PROJECTOR_NAMES.threads,
apply: applyThreadsProjection,
},
];
const applyAttachmentSideEffects = Effect.fn("applyAttachmentSideEffects")(
function* (event: OrchestrationEvent, sideEffects: AttachmentSideEffects) {
if (
sideEffects.deletedThreadIds.size === 0 &&
sideEffects.prunedThreadRelativePaths.size === 0
) {
return;
}
const deletedThreadIds = new Set<string>();
for (const threadId of sideEffects.deletedThreadIds) {
const recreatedLater = yield* eventStore.hasEventAfter({
aggregateKind: "thread",
aggregateId: ThreadId.make(threadId),
type: "thread.created",
sequenceExclusive: event.sequence,
});
if (!recreatedLater) {
deletedThreadIds.add(threadId);
}
}
// Later events in the same transaction can add attachment references.
const prunedThreadRelativePaths = new Map<string, Set<string>>();
for (const threadId of sideEffects.prunedThreadRelativePaths.keys()) {
const messages = yield* projectionThreadMessageRepository.listByThreadId({
threadId: ThreadId.make(threadId),
});
prunedThreadRelativePaths.set(
threadId,
collectThreadAttachmentRelativePaths(threadId, messages),
);
}
yield* runAttachmentSideEffects({ deletedThreadIds, prunedThreadRelativePaths });
},
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(Path.Path, path),
Effect.provideService(ServerConfig, serverConfig),
(effect, event) =>
effect.pipe(
Effect.catch((cause) =>
Effect.logWarning("failed to apply projected attachment side-effects", {
sequence: event.sequence,
eventType: event.type,
cause,
}),
),
),
);
const applyProjectorForEvent = Effect.fn("applyProjectorForEvent")(function* (
projector: ProjectorDefinition,
event: OrchestrationEvent,
attachmentSideEffects: AttachmentSideEffects,
) {
yield* projector.apply(event, attachmentSideEffects);
yield* projectionStateRepository.upsert({
projector: projector.name,
lastAppliedSequence: event.sequence,
updatedAt: event.occurredAt,
});
});
const runProjectorForEvent = Effect.fn("runProjectorForEvent")(function* (
projector: ProjectorDefinition,
event: OrchestrationEvent,
) {
const attachmentSideEffects: AttachmentSideEffects = {
deletedThreadIds: new Set<string>(),
prunedThreadRelativePaths: new Map<string, Set<string>>(),
};
yield* sql.withTransaction(applyProjectorForEvent(projector, event, attachmentSideEffects));
yield* applyAttachmentSideEffects(event, attachmentSideEffects);
});
const bootstrapProjector = (projector: ProjectorDefinition) =>
projectionStateRepository
.getByProjector({
projector: projector.name,
})
.pipe(
Effect.flatMap((stateRow) =>
Stream.runForEach(
eventStore.readFromSequence(
Option.isSome(stateRow) ? stateRow.value.lastAppliedSequence : 0,
Number.MAX_SAFE_INTEGER,
),
(event) => runProjectorForEvent(projector, event),
),
),
);
const projectEventDeferred: OrchestrationProjectionPipelineShape["projectEventDeferred"] =
Effect.fn("projectEventDeferred")(
function* (event) {
const attachmentSideEffects: AttachmentSideEffects = {
deletedThreadIds: new Set<string>(),
prunedThreadRelativePaths: new Map<string, Set<string>>(),
};
yield* sql.withTransaction(
Effect.gen(function* () {
yield* Effect.forEach(
projectors,
(projector) => projector.apply(event, attachmentSideEffects),
{ concurrency: 1, discard: true },
);
// Runtime projectors commit together. Bootstrap still advances each cursor separately.
yield* projectionStateRepository.upsertMany(
projectors.map((projector) => ({
projector: projector.name,
lastAppliedSequence: event.sequence,
updatedAt: event.occurredAt,
})),
);
}),
);
// Return the cleanup effect so the caller runs it after the outer transaction commits.
// @effect-diagnostics-next-line returnEffectInGen:off
return applyAttachmentSideEffects(event, attachmentSideEffects);
},
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(Path.Path, path),
Effect.provideService(ServerConfig, serverConfig),
Effect.catchTag("SqlError", (sqlError) =>
Effect.fail(toPersistenceSqlError("ProjectionPipeline.projectEvent:query")(sqlError)),
),
);
const projectEvent: OrchestrationProjectionPipelineShape["projectEvent"] = Effect.fn(
"projectEvent",
)(function* (event) {
const cleanup = yield* projectEventDeferred(event);
yield* cleanup;
});
const bootstrap: OrchestrationProjectionPipelineShape["bootstrap"] = Effect.forEach(
projectors,
bootstrapProjector,
{ concurrency: 1 },
).pipe(
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(Path.Path, path),
Effect.provideService(ServerConfig, serverConfig),
Effect.asVoid,
Effect.tap(() =>
Effect.logDebug("orchestration projection pipeline bootstrapped").pipe(
Effect.annotateLogs({ projectors: projectors.length }),
),
),
Effect.catchTag("SqlError", (sqlError) =>
Effect.fail(toPersistenceSqlError("ProjectionPipeline.bootstrap:query")(sqlError)),
),
);
return {
bootstrap,
projectEvent,
projectEventDeferred,
} satisfies OrchestrationProjectionPipelineShape;
},
);
export const OrchestrationProjectionPipelineLive = Layer.effect(
OrchestrationProjectionPipeline,
makeOrchestrationProjectionPipeline(),
).pipe(
Layer.provideMerge(ProjectionProjectRepositoryLive),
Layer.provideMerge(ProjectionThreadRepositoryLive),
Layer.provideMerge(ProjectionThreadMessageRepositoryLive),
Layer.provideMerge(ProjectionThreadProposedPlanRepositoryLive),
Layer.provideMerge(ProjectionThreadActivityRepositoryLive),
Layer.provideMerge(ProjectionThreadSessionRepositoryLive),
Layer.provideMerge(ProjectionTurnRepositoryLive),
Layer.provideMerge(ProjectionPendingApprovalRepositoryLive),
Layer.provideMerge(ProjectionStateRepositoryLive),
);