1import {
2 isOrchestrationV2WorkActive,
3 type OrchestrationV2ThreadProjection,
4 type OrchestrationV2ThreadShell,
5 type ThreadId,
6} from "@t3tools/contracts";
7import * as DateTime from "effect/DateTime";
8
9export type ThreadRelationshipKind = "parent" | "fork" | "subagent" | "transfer";
10
11export interface ThreadRelationshipNode {
12 readonly threadId: ThreadId;
13 readonly thread: OrchestrationV2ThreadShell | null;
14 readonly missing: boolean;
15}
16
17export interface ThreadRelationshipEdge {
18 readonly sourceThreadId: ThreadId;
19 readonly targetThreadId: ThreadId;
20 readonly kind: ThreadRelationshipKind;
21 readonly status: string | null;
22}
23
24export interface ThreadRelationshipGraph {
25 readonly nodes: ReadonlyMap<ThreadId, ThreadRelationshipNode>;
26 readonly edges: ReadonlyArray<ThreadRelationshipEdge>;
27}
28
29export interface ThreadRelationshipWalkRow {
30 readonly threadId: ThreadId;
31 readonly fromThreadId: ThreadId;
32 readonly depth: number;
33 readonly edge: ThreadRelationshipEdge;
34}
35
36export function resolveMergeBackTargetThreadId(
37 projection: Pick<OrchestrationV2ThreadProjection, "thread"> | null,
38): ThreadId | null {
39 if (projection?.thread.lineage.relationshipToParent !== "fork") return null;
40 return projection.thread.forkedFrom?.type === "run"
41 ? projection.thread.forkedFrom.threadId
42 : projection.thread.lineage.parentThreadId;
43}
44
45function edgeKey(edge: ThreadRelationshipEdge): string {
46 return `${edge.sourceThreadId}\u001f${edge.targetThreadId}\u001f${edge.kind}`;
47}
48
49export function deriveThreadRelationshipGraph(input: {
50 readonly threads: ReadonlyArray<OrchestrationV2ThreadShell>;
51 readonly projection: OrchestrationV2ThreadProjection | null;
52}): ThreadRelationshipGraph {
53 const threadsById = new Map<ThreadId, OrchestrationV2ThreadShell>();
54 for (const thread of input.threads) {
55 // Callers order shells from most to least authoritative. In particular,
56 // live shells precede archived snapshots, which may still contain a stale
57 // copy during archive refresh.
58 if (!threadsById.has(thread.id)) {
59 threadsById.set(thread.id, thread);
60 }
61 }
62 const threads = [...threadsById.values()];
63 const nodes = new Map<ThreadId, ThreadRelationshipNode>(
64 threads.map((thread) => [thread.id, { threadId: thread.id, thread, missing: false }]),
65 );
66 const edgesByKey = new Map<string, ThreadRelationshipEdge>();
67 const ensureNode = (threadId: ThreadId) => {
68 if (!nodes.has(threadId)) {
69 nodes.set(threadId, { threadId, thread: null, missing: true });
70 }
71 };
72 const addEdge = (edge: ThreadRelationshipEdge) => {
73 ensureNode(edge.sourceThreadId);
74 ensureNode(edge.targetThreadId);
75 edgesByKey.set(
76 edgeKey(edge),
77 edge.kind === "subagent" && edge.status === "rolled_back"
78 ? { ...edge, status: "interrupted" }
79 : edge,
80 );
81 };
82
83 const selectedThread = input.projection?.thread;
84 const lineageThreads =
85 selectedThread !== undefined && !threadsById.has(selectedThread.id)
86 ? [...threads, { ...selectedThread, activityRunStatus: null, status: null }]
87 : threads;
88 for (const thread of lineageThreads) {
89 const parentThreadId =
90 thread.forkedFrom?.type === "run"
91 ? thread.forkedFrom.threadId
92 : thread.lineage.parentThreadId;
93 if (parentThreadId === null) continue;
94 addEdge({
95 sourceThreadId: parentThreadId,
96 targetThreadId: thread.id,
97 kind: thread.lineage.relationshipToParent === "subagent" ? "subagent" : "fork",
98 status: thread.activityRunStatus ?? thread.status,
99 });
100 }
101
102 if (input.projection !== null) {
103 const ownerThreadId = input.projection.thread.id;
104 for (const subagent of input.projection.subagents) {
105 if (subagent.childThreadId === null) continue;
106 const child = threadsById.get(subagent.childThreadId);
107 addEdge({
108 sourceThreadId: ownerThreadId,
109 targetThreadId: subagent.childThreadId,
110 kind: "subagent",
111 status:
112 child?.activityRunStatus ??
113 (child?.latestRunId != null && !isOrchestrationV2WorkActive(subagent.status)
114 ? child.status
115 : subagent.status),
116 });
117 }
118 for (const transfer of input.projection.contextTransfers) {
119 if (transfer.sourceThreadId === transfer.targetThreadId) continue;
120 addEdge({
121 sourceThreadId: transfer.sourceThreadId,
122 targetThreadId: transfer.targetThreadId,
123 kind: "transfer",
124 status: transfer.status,
125 });
126 }
127 }
128
129 return { nodes, edges: [...edgesByKey.values()] };
130}
131
132export function immediateThreadRelationships(
133 graph: ThreadRelationshipGraph,
134 threadId: ThreadId,
135): ReadonlyArray<ThreadRelationshipWalkRow> {
136 const visited = new Set<ThreadId>();
137 const rows: ThreadRelationshipWalkRow[] = [];
138
139 for (const edge of graph.edges) {
140 const relatedId =
141 edge.sourceThreadId === threadId
142 ? edge.targetThreadId
143 : edge.targetThreadId === threadId
144 ? edge.sourceThreadId
145 : null;
146 if (relatedId === null || visited.has(relatedId)) continue;
147 visited.add(relatedId);
148 rows.push({ threadId: relatedId, fromThreadId: threadId, depth: 1, edge });
149 }
150
151 return rows;
152}
153
154export function isParentThreadRelationship(
155 edge: ThreadRelationshipEdge,
156 currentThreadId: ThreadId,
157): boolean {
158 return edge.kind !== "transfer" && edge.targetThreadId === currentThreadId;
159}
160
161export function threadRelationshipRowStatus(
162 graph: ThreadRelationshipGraph,
163 row: Pick<ThreadRelationshipWalkRow, "threadId" | "edge">,
164): string | null {
165 if (row.edge.kind === "transfer" || row.threadId === row.edge.targetThreadId) {
166 return row.edge.status;
167 }
168 const thread = graph.nodes.get(row.threadId)?.thread;
169 return thread?.activityRunStatus ?? thread?.status ?? null;
170}
171
172function threadCreatedAtMillis(node: ThreadRelationshipNode | undefined): number | null {
173 const createdAt: unknown = node?.thread?.createdAt;
174 if (!DateTime.isDateTime(createdAt)) return null;
175 const millis = DateTime.toEpochMillis(createdAt);
176 return Number.isFinite(millis) ? millis : null;
177}
178
179/**
180 * Web pins the parent and distinct merge-back target before newest-created-first ordering.
181 * Mobile does not share this exception; do not reuse it as the canonical relationship order.
182 */
183export function orderWebThreadLineageRows(input: {
184 readonly graph: ThreadRelationshipGraph;
185 readonly rows: ReadonlyArray<ThreadRelationshipWalkRow>;
186 readonly currentThreadId: ThreadId;
187 readonly mergeTargetThreadId: ThreadId | null;
188}): ReadonlyArray<ThreadRelationshipWalkRow> {
189 const pinRank = (row: ThreadRelationshipWalkRow): number => {
190 if (isParentThreadRelationship(row.edge, input.currentThreadId)) return 0;
191 if (row.threadId === input.mergeTargetThreadId) return 1;
192 return 2;
193 };
194
195 return [...input.rows].sort((left, right) => {
196 const rankDelta = pinRank(left) - pinRank(right);
197 if (rankDelta !== 0) return rankDelta;
198 const leftCreatedAt = threadCreatedAtMillis(input.graph.nodes.get(left.threadId));
199 const rightCreatedAt = threadCreatedAtMillis(input.graph.nodes.get(right.threadId));
200 if (leftCreatedAt !== rightCreatedAt) {
201 if (leftCreatedAt === null) return 1;
202 if (rightCreatedAt === null) return -1;
203 return rightCreatedAt - leftCreatedAt;
204 }
205 return left.threadId < right.threadId ? -1 : left.threadId > right.threadId ? 1 : 0;
206 });
207}