From 2e0350e87c3a84f14c74e81a0e081a0e1438386e Mon Sep 17 00:00:00 2001 From: "glm-5.1" Date: Thu, 21 May 2026 22:04:28 +0000 Subject: [PATCH] feat: implement call graph construction methods (fromCallEvents, updateFromEvent, addCall, addDependency, updateStatus, updateCall, removeCall) --- src/graph/construction.ts | 273 ++++++++++++++++- src/graph/index.ts | 2 +- src/index.ts | 2 +- test/graph/construction.test.ts | 528 +++++++++++++++++++++++++++++++- 4 files changed, 791 insertions(+), 14 deletions(-) diff --git a/src/graph/construction.ts b/src/graph/construction.ts index d5c37ee..1f48c22 100644 --- a/src/graph/construction.ts +++ b/src/graph/construction.ts @@ -1,13 +1,16 @@ import { DirectedGraph } from "graphology"; import type { TSchema, Static } from "@alkdev/typebox"; +import { Value } from "@alkdev/typebox/value"; import { willCreateCycle, topologicalSort, hasCycle } from "graphology-dag"; import { DuplicateNodeError, DuplicateEdgeError, NodeNotFoundError, CycleError, + InvalidInputError, + InvalidTransitionError, } from "../error/index.js"; -import type { CallStatus, AnyValidationError } from "../error/index.js"; +import type { CallStatus, AnyValidationError, ValidationError } from "../error/index.js"; import { findCycles, reachableFrom as reachableFromFn, @@ -16,8 +19,12 @@ import { validate as _validate } from "./validation.js"; import { OperationNodeAttrs as OperationNodeAttrsSchema, OperationEdgeAttrs as OperationEdgeAttrsSchema, + OperationGraphSerialized, + CallGraphSerialized, + CallNodeAttrs as CallNodeAttrsSchema, + CallEdgeAttrs as CallEdgeAttrsSchema, } from "../schema/index.js"; -import type { OperationNodeAttrs } from "../schema/index.js"; +import type { OperationNodeAttrs, FlowGraphSerialized, CallNodeAttrs } from "../schema/index.js"; import { typeCompat, type TypeCompatResult } from "../analysis/type-compat.js"; export interface FlowGraphOptions { @@ -37,8 +44,55 @@ export interface OperationSpec { tags?: string[]; } +export interface CallRequestedEvent { + type: "call.requested"; + requestId: string; + operationId: string; + input: unknown; + timestamp: string; + parentRequestId?: string; + identity?: { id: string; scopes: string[]; resources?: Record }; + startedAt?: string; +} + +export interface CallRespondedEvent { + type: "call.responded"; + requestId: string; + output: unknown; + timestamp: string; +} + +export interface CallErrorEvent { + type: "call.error"; + requestId: string; + error: { code: string; message: string; details?: unknown }; + timestamp: string; +} + +export interface CallAbortedEvent { + type: "call.aborted"; + requestId: string; + timestamp: string; +} + +export interface CallCompletedEvent { + type: "call.completed"; + requestId: string; + output?: unknown; + timestamp: string; +} + +export type CallEventMapValue = + | CallRequestedEvent + | CallRespondedEvent + | CallErrorEvent + | CallAbortedEvent + | CallCompletedEvent; + type OperationGraph = FlowGraph; +type CallGraph = FlowGraph; + type TypedEdgeAttrs = { edgeType: "typed"; compatible: boolean; @@ -48,6 +102,14 @@ type TypedEdgeAttrs = { type Attrs = Record; +const VALID_TRANSITIONS: Record = { + pending: ["running", "aborted"], + running: ["completed", "failed", "aborted"], + completed: [], + failed: [], + aborted: [], +}; + export class FlowGraph< NodeAttrs extends TSchema = TSchema, EdgeAttrs extends TSchema = TSchema, @@ -314,6 +376,145 @@ export class FlowGraph< return chain; } + updateFromEvent(event: CallEventMapValue): void { + switch (event.type) { + case "call.requested": { + const attrs: CallNodeAttrs = { + requestId: event.requestId, + operationId: event.operationId, + status: "pending", + input: event.input, + ...(event.parentRequestId !== undefined ? { parentRequestId: event.parentRequestId } : {}), + ...(event.identity !== undefined ? { identity: event.identity } : {}), + ...(event.startedAt !== undefined ? { startedAt: event.startedAt } : {}), + }; + this.addCall(attrs); + break; + } + case "call.responded": { + if (!this._graph.hasNode(event.requestId)) return; + const current = this._graph.getNodeAttributes(event.requestId) as Record; + const currentStatus = current.status as CallStatus; + if (currentStatus === "completed" || currentStatus === "failed" || currentStatus === "aborted") return; + this._graph.mergeNodeAttributes(event.requestId, { + status: "completed", + output: event.output, + completedAt: event.timestamp, + } as Attrs); + break; + } + case "call.error": { + if (!this._graph.hasNode(event.requestId)) return; + const current = this._graph.getNodeAttributes(event.requestId) as Record; + const currentStatus = current.status as CallStatus; + if (currentStatus === "completed" || currentStatus === "failed" || currentStatus === "aborted") return; + this._graph.mergeNodeAttributes(event.requestId, { + status: "failed", + error: event.error, + completedAt: event.timestamp, + } as Attrs); + break; + } + case "call.aborted": { + if (!this._graph.hasNode(event.requestId)) return; + const current = this._graph.getNodeAttributes(event.requestId) as Record; + const currentStatus = current.status as CallStatus; + if (currentStatus === "completed" || currentStatus === "failed" || currentStatus === "aborted") return; + this._graph.mergeNodeAttributes(event.requestId, { + status: "aborted", + completedAt: event.timestamp, + } as Attrs); + break; + } + case "call.completed": { + if (!this._graph.hasNode(event.requestId)) return; + const current = this._graph.getNodeAttributes(event.requestId) as Record; + const currentStatus = current.status as CallStatus; + if (currentStatus === "completed") { + if (!current.completedAt) { + this._graph.mergeNodeAttributes(event.requestId, { completedAt: event.timestamp } as Attrs); + } + return; + } + if (currentStatus === "failed" || currentStatus === "aborted") return; + this._graph.mergeNodeAttributes(event.requestId, { + status: "completed", + ...(event.output !== undefined ? { output: event.output } : {}), + completedAt: event.timestamp, + } as Attrs); + break; + } + } + } + + addCall(attrs: CallNodeAttrs): void { + if (this._graph.hasNode(attrs.requestId)) return; + this._graph.addNode(attrs.requestId, attrs as Attrs); + if (attrs.parentRequestId !== undefined) { + if (this._graph.hasNode(attrs.parentRequestId)) { + if (willCreateCycle(this._graph, attrs.parentRequestId, attrs.requestId)) { + this._graph.dropNode(attrs.requestId); + const path = this._findPath(attrs.requestId, attrs.parentRequestId); + const cycle = [attrs.parentRequestId, ...path, attrs.parentRequestId]; + throw new CycleError([cycle]); + } + const edgeKey = this._edgeKey(attrs.parentRequestId, attrs.requestId); + this._graph.addEdgeWithKey(edgeKey, attrs.parentRequestId, attrs.requestId, { edgeType: "triggered" } as Attrs); + } + } + } + + addDependency(source: string, target: string): void { + if (!this._graph.hasNode(source)) { + throw new NodeNotFoundError(source); + } + if (!this._graph.hasNode(target)) { + throw new NodeNotFoundError(target); + } + const edgeKey = `${source}->${target}:depends_on`; + if (this._graph.hasEdge(edgeKey)) return; + if (willCreateCycle(this._graph, source, target)) { + const path = this._findPath(target, source); + const cycle = [source, ...path, source]; + throw new CycleError([cycle]); + } + this._graph.addEdgeWithKey(edgeKey, source, target, { edgeType: "depends_on" } as Attrs); + } + + updateStatus(requestId: string, status: CallStatus, extra?: Partial): void { + if (!this._graph.hasNode(requestId)) { + throw new NodeNotFoundError(requestId); + } + const current = this._graph.getNodeAttributes(requestId) as Record; + const currentStatus = current.status as CallStatus; + if (currentStatus === status) return; + const allowed = VALID_TRANSITIONS[currentStatus]; + if (!allowed || !allowed.includes(status)) { + throw new InvalidTransitionError(requestId, currentStatus, status); + } + const update: Record = { status }; + if (extra) { + for (const [key, value] of Object.entries(extra)) { + if (value !== undefined) { + update[key] = value; + } + } + } + this._graph.mergeNodeAttributes(requestId, update as Attrs); + } + + updateCall(requestId: string, attrs: Partial): void { + if (!this._graph.hasNode(requestId)) { + throw new NodeNotFoundError(requestId); + } + this._graph.mergeNodeAttributes(requestId, attrs as Attrs); + } + + removeCall(requestId: string): void { + if (!this._graph.hasNode(requestId)) return; + this._graph.dropNode(requestId); + } + validate(schema: TSchema): AnyValidationError[] { return _validate(this, schema as NodeAttrs); } @@ -361,16 +562,72 @@ export class FlowGraph< return graph; } - static fromCallEvents( - _events: unknown[], - ): FlowGraph { - throw new Error("not implemented"); + static fromCallEvents(events: CallEventMapValue[]): CallGraph { + const graph = new FlowGraph(); + for (const event of events) { + graph.updateFromEvent(event); + } + return graph; + } + + export(): FlowGraphSerialized { + return this._graph.export() as unknown as FlowGraphSerialized; + } + + toJSON(): FlowGraphSerialized { + return this.export(); + } + + toString(): string { + return JSON.stringify(this.export()); } static fromJSON( - _data: unknown, + data: FlowGraphSerialized, ): FlowGraph { - throw new Error("not implemented"); + const opCheck = Value.Check(OperationGraphSerialized, data); + const callCheck = Value.Check(CallGraphSerialized, data); + if (!opCheck && !callCheck) { + const errors: ValidationError[] = []; + const opIter = Value.Errors(OperationGraphSerialized, data as Record); + for (const err of opIter) { + errors.push({ + type: "schema", + nodeKey: "", + field: err.path.replace(/^\//, "") || err.path, + message: err.message, + value: err.value, + }); + } + if (errors.length === 0) { + const callIter = Value.Errors(CallGraphSerialized, data as Record); + for (const err of callIter) { + errors.push({ + type: "schema", + nodeKey: "", + field: err.path.replace(/^\//, "") || err.path, + message: err.message, + value: err.value, + }); + } + } + throw new InvalidInputError(errors); + } + + const fg = new FlowGraph(); + for (const node of data.nodes) { + fg._graph.addNode(node.key, node.attributes as Attrs); + } + for (const edge of data.edges) { + fg._graph.addEdgeWithKey(edge.key, edge.source, edge.target, edge.attributes as Attrs); + } + + if (hasCycle(fg._graph)) { + const cycles = findCycles(fg._graph); + throw new CycleError(cycles); + } + + return fg; } private _findPath(from: string, to: string): string[] { diff --git a/src/graph/index.ts b/src/graph/index.ts index 9a2f18e..6d249ff 100644 --- a/src/graph/index.ts +++ b/src/graph/index.ts @@ -1,4 +1,4 @@ -export { FlowGraph, buildTypeEdges, type FlowGraphOptions, type OperationSpec } from "./construction.js"; +export { FlowGraph, buildTypeEdges, type FlowGraphOptions, type OperationSpec, type CallEventMapValue, type CallRequestedEvent, type CallRespondedEvent, type CallErrorEvent, type CallAbortedEvent, type CallCompletedEvent } from "./construction.js"; export { topologicalOrder, hasCycles, diff --git a/src/index.ts b/src/index.ts index 37319a2..978b0ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ export * from "./error/index.js"; -export { FlowGraph, buildTypeEdges, type FlowGraphOptions, type OperationSpec } from "./graph/index.js"; +export { FlowGraph, buildTypeEdges, type FlowGraphOptions, type OperationSpec, type CallEventMapValue, type CallRequestedEvent, type CallRespondedEvent, type CallErrorEvent, type CallAbortedEvent, type CallCompletedEvent } from "./graph/index.js"; export { validateSchema, validateGraph, diff --git a/test/graph/construction.test.ts b/test/graph/construction.test.ts index 68d3f8c..e7c2006 100644 --- a/test/graph/construction.test.ts +++ b/test/graph/construction.test.ts @@ -1,13 +1,15 @@ import { describe, it, expect } from "vitest"; import { Type } from "@alkdev/typebox"; import { FlowGraph, buildTypeEdges } from "../../src/graph/construction.js"; -import type { OperationSpec } from "../../src/graph/construction.js"; +import type { OperationSpec, CallEventMapValue } from "../../src/graph/construction.js"; import { DuplicateNodeError, DuplicateEdgeError, NodeNotFoundError, CycleError, + InvalidTransitionError, } from "../../src/error/index.js"; +import type { CallStatus } from "../../src/error/index.js"; describe("FlowGraph constructor", () => { it("creates an empty graph", () => { @@ -300,12 +302,14 @@ describe("FlowGraph query methods", () => { }); describe("FlowGraph static stubs", () => { - it("fromCallEvents throws not implemented", () => { - expect(() => FlowGraph.fromCallEvents([])).toThrow("not implemented"); + it("fromCallEvents returns empty graph for empty events", () => { + const graph = FlowGraph.fromCallEvents([]); + expect(graph.order).toBe(0); + expect(graph.size).toBe(0); }); it("fromJSON throws not implemented", () => { - expect(() => FlowGraph.fromJSON({})).toThrow("not implemented"); + expect(() => FlowGraph.fromJSON({} as never)).toThrow(); }); }); @@ -638,4 +642,520 @@ describe("FlowGraph cycle detection", () => { fg.addEdge("a", "c"); expect(() => fg.addEdge("b", "c")).not.toThrow(); }); +}); + +describe("FlowGraph.fromCallEvents", () => { + const requestedEvent: CallEventMapValue = { + type: "call.requested", + requestId: "req-1", + operationId: "task.classify", + input: { text: "hello" }, + timestamp: "2026-01-01T00:00:00Z", + }; + + const requestedWithParent: CallEventMapValue = { + type: "call.requested", + requestId: "req-2", + operationId: "task.enrich", + input: { label: "greeting" }, + timestamp: "2026-01-01T00:00:01Z", + parentRequestId: "req-1", + }; + + const respondedEvent: CallEventMapValue = { + type: "call.responded", + requestId: "req-1", + output: { label: "greeting" }, + timestamp: "2026-01-01T00:00:02Z", + }; + + const errorEvent: CallEventMapValue = { + type: "call.error", + requestId: "req-1", + error: { code: "INTERNAL", message: "Something went wrong" }, + timestamp: "2026-01-01T00:00:03Z", + }; + + const abortedEvent: CallEventMapValue = { + type: "call.aborted", + requestId: "req-1", + timestamp: "2026-01-01T00:00:04Z", + }; + + const completedEvent: CallEventMapValue = { + type: "call.completed", + requestId: "req-1", + output: { label: "greeting" }, + timestamp: "2026-01-01T00:00:05Z", + }; + + it("adds node from call.requested event", () => { + const graph = FlowGraph.fromCallEvents([requestedEvent]); + expect(graph.order).toBe(1); + expect(graph.hasNode("req-1")).toBe(true); + const attrs = graph.getNodeAttributes("req-1") as Record; + expect(attrs.status).toBe("pending"); + expect(attrs.operationId).toBe("task.classify"); + }); + + it("creates triggered edge from parentRequestId", () => { + const graph = FlowGraph.fromCallEvents([requestedEvent, requestedWithParent]); + expect(graph.order).toBe(2); + expect(graph.hasNode("req-2")).toBe(true); + expect(graph.hasEdge("req-1", "req-2")).toBe(true); + const edgeAttrs = graph.getEdgeAttributes("req-1", "req-2") as Record; + expect(edgeAttrs.edgeType).toBe("triggered"); + }); + + it("updates status to completed on call.responded", () => { + const graph = FlowGraph.fromCallEvents([requestedEvent, respondedEvent]); + const attrs = graph.getNodeAttributes("req-1") as Record; + expect(attrs.status).toBe("completed"); + expect(attrs.output).toEqual({ label: "greeting" }); + expect(attrs.completedAt).toBe("2026-01-01T00:00:02Z"); + }); + + it("updates status to failed on call.error", () => { + const graph = FlowGraph.fromCallEvents([requestedEvent, errorEvent]); + const attrs = graph.getNodeAttributes("req-1") as Record; + expect(attrs.status).toBe("failed"); + expect(attrs.error).toEqual({ code: "INTERNAL", message: "Something went wrong" }); + expect(attrs.completedAt).toBe("2026-01-01T00:00:03Z"); + }); + + it("updates status to aborted on call.aborted", () => { + const graph = FlowGraph.fromCallEvents([requestedEvent, abortedEvent]); + const attrs = graph.getNodeAttributes("req-1") as Record; + expect(attrs.status).toBe("aborted"); + expect(attrs.completedAt).toBe("2026-01-01T00:00:04Z"); + }); + + it("updates status to completed on call.completed", () => { + const graph = FlowGraph.fromCallEvents([requestedEvent, completedEvent]); + const attrs = graph.getNodeAttributes("req-1") as Record; + expect(attrs.status).toBe("completed"); + expect(attrs.completedAt).toBe("2026-01-01T00:00:05Z"); + }); + + it("is idempotent — duplicate events have no effect", () => { + const graph = FlowGraph.fromCallEvents([requestedEvent, requestedEvent, respondedEvent, respondedEvent]); + expect(graph.order).toBe(1); + const attrs = graph.getNodeAttributes("req-1") as Record; + expect(attrs.status).toBe("completed"); + }); + + it("ignores responded/error/aborted for unknown requestId", () => { + const graph = FlowGraph.fromCallEvents([respondedEvent, errorEvent, abortedEvent]); + expect(graph.order).toBe(0); + }); + + it("creates node for unknown operationId", () => { + const unknownOpEvent: CallEventMapValue = { + type: "call.requested", + requestId: "req-unknown", + operationId: "unknown.op", + input: {}, + timestamp: "2026-01-01T00:00:00Z", + }; + const graph = FlowGraph.fromCallEvents([unknownOpEvent]); + expect(graph.order).toBe(1); + const attrs = graph.getNodeAttributes("req-unknown") as Record; + expect(attrs.status).toBe("pending"); + expect(attrs.operationId).toBe("unknown.op"); + }); + + it("processes full event sequence", () => { + const req1: CallEventMapValue = { + type: "call.requested", + requestId: "req-parent", + operationId: "task.parent", + input: {}, + timestamp: "2026-01-01T00:00:00Z", + }; + const req2: CallEventMapValue = { + type: "call.requested", + requestId: "req-child", + operationId: "task.child", + input: {}, + timestamp: "2026-01-01T00:00:01Z", + parentRequestId: "req-parent", + }; + const resp: CallEventMapValue = { + type: "call.responded", + requestId: "req-parent", + output: "done", + timestamp: "2026-01-01T00:00:02Z", + }; + const graph = FlowGraph.fromCallEvents([req1, req2, resp]); + expect(graph.order).toBe(2); + expect(graph.hasEdge("req-parent", "req-child")).toBe(true); + const parentAttrs = graph.getNodeAttributes("req-parent") as Record; + expect(parentAttrs.status).toBe("completed"); + const childAttrs = graph.getNodeAttributes("req-child") as Record; + expect(childAttrs.status).toBe("pending"); + }); + + it("stores identity and startedAt from call.requested", () => { + const event: CallEventMapValue = { + type: "call.requested", + requestId: "req-id", + operationId: "task.op", + input: {}, + timestamp: "2026-01-01T00:00:00Z", + identity: { id: "user-1", scopes: ["read"] }, + startedAt: "2026-01-01T00:00:01Z", + }; + const graph = FlowGraph.fromCallEvents([event]); + const attrs = graph.getNodeAttributes("req-id") as Record; + expect(attrs.identity).toEqual({ id: "user-1", scopes: ["read"] }); + expect(attrs.startedAt).toBe("2026-01-01T00:00:01Z"); + }); + + it("skips triggered edge if parent node does not exist", () => { + const orphanEvent: CallEventMapValue = { + type: "call.requested", + requestId: "req-orphan", + operationId: "task.child", + input: {}, + timestamp: "2026-01-01T00:00:00Z", + parentRequestId: "req-nonexistent", + }; + const graph = FlowGraph.fromCallEvents([orphanEvent]); + expect(graph.order).toBe(1); + expect(graph.hasNode("req-orphan")).toBe(true); + expect(graph.size).toBe(0); + }); +}); + +describe("FlowGraph.updateFromEvent", () => { + it("processes single event for real-time pattern", () => { + const graph = new FlowGraph(); + graph.updateFromEvent({ + type: "call.requested", + requestId: "req-1", + operationId: "task.classify", + input: { text: "hello" }, + timestamp: "2026-01-01T00:00:00Z", + }); + expect(graph.hasNode("req-1")).toBe(true); + + graph.updateFromEvent({ + type: "call.responded", + requestId: "req-1", + output: { label: "hi" }, + timestamp: "2026-01-01T00:00:02Z", + }); + const attrs = graph.getNodeAttributes("req-1") as Record; + expect(attrs.status).toBe("completed"); + expect(attrs.output).toEqual({ label: "hi" }); + }); + + it("ignores events for unknown requestId", () => { + const graph = new FlowGraph(); + graph.updateFromEvent({ + type: "call.responded", + requestId: "unknown", + output: "x", + timestamp: "2026-01-01T00:00:00Z", + }); + expect(graph.order).toBe(0); + }); + + it("ignores terminal event re-processing", () => { + const graph = new FlowGraph(); + graph.updateFromEvent({ + type: "call.requested", + requestId: "req-1", + operationId: "task.op", + input: {}, + timestamp: "2026-01-01T00:00:00Z", + }); + graph.updateFromEvent({ + type: "call.responded", + requestId: "req-1", + output: "done", + timestamp: "2026-01-01T00:00:01Z", + }); + graph.updateFromEvent({ + type: "call.error", + requestId: "req-1", + error: { code: "X", message: "Y" }, + timestamp: "2026-01-01T00:00:02Z", + }); + const attrs = graph.getNodeAttributes("req-1") as Record; + expect(attrs.status).toBe("completed"); + }); +}); + +describe("FlowGraph.addCall", () => { + it("adds a call node", () => { + const graph = new FlowGraph(); + graph.addCall({ + requestId: "req-1", + operationId: "task.classify", + status: "pending", + input: { text: "hello" }, + }); + expect(graph.hasNode("req-1")).toBe(true); + const attrs = graph.getNodeAttributes("req-1") as Record; + expect(attrs.status).toBe("pending"); + }); + + it("adds triggered edge when parentRequestId is present", () => { + const graph = new FlowGraph(); + graph.addCall({ + requestId: "req-parent", + operationId: "task.parent", + status: "pending", + input: {}, + }); + graph.addCall({ + requestId: "req-child", + operationId: "task.child", + status: "pending", + input: {}, + parentRequestId: "req-parent", + }); + expect(graph.hasEdge("req-parent", "req-child")).toBe(true); + const edgeAttrs = graph.getEdgeAttributes("req-parent", "req-child") as Record; + expect(edgeAttrs.edgeType).toBe("triggered"); + }); + + it("is idempotent — duplicate addCall is ignored", () => { + const graph = new FlowGraph(); + graph.addCall({ + requestId: "req-1", + operationId: "task.op", + status: "pending", + input: {}, + }); + graph.addCall({ + requestId: "req-1", + operationId: "task.op", + status: "pending", + input: {}, + }); + expect(graph.order).toBe(1); + }); + + it("does not throw if parentRequestId node does not exist", () => { + const graph = new FlowGraph(); + graph.addCall({ + requestId: "req-child", + operationId: "task.child", + status: "pending", + input: {}, + parentRequestId: "nonexistent", + }); + expect(graph.hasNode("req-child")).toBe(true); + expect(graph.size).toBe(0); + }); +}); + +describe("FlowGraph.addDependency", () => { + it("creates depends_on edge", () => { + const graph = new FlowGraph(); + graph.addCall({ requestId: "req-1", operationId: "task.a", status: "pending", input: {} }); + graph.addCall({ requestId: "req-2", operationId: "task.b", status: "pending", input: {} }); + graph.addDependency("req-1", "req-2"); + const edgeKey = "req-1->req-2:depends_on"; + expect(graph.graph.hasEdge(edgeKey)).toBe(true); + const attrs = graph.graph.getEdgeAttributes(edgeKey) as Record; + expect(attrs.edgeType).toBe("depends_on"); + }); + + it("is idempotent — duplicate addDependency is ignored", () => { + const graph = new FlowGraph(); + graph.addCall({ requestId: "req-1", operationId: "task.a", status: "pending", input: {} }); + graph.addCall({ requestId: "req-2", operationId: "task.b", status: "pending", input: {} }); + graph.addDependency("req-1", "req-2"); + graph.addDependency("req-1", "req-2"); + expect(graph.graph.hasEdge("req-1->req-2:depends_on")).toBe(true); + }); + + it("throws NodeNotFoundError if source doesn't exist", () => { + const graph = new FlowGraph(); + graph.addCall({ requestId: "req-2", operationId: "task.b", status: "pending", input: {} }); + expect(() => graph.addDependency("missing", "req-2")).toThrow(NodeNotFoundError); + }); + + it("throws NodeNotFoundError if target doesn't exist", () => { + const graph = new FlowGraph(); + graph.addCall({ requestId: "req-1", operationId: "task.a", status: "pending", input: {} }); + expect(() => graph.addDependency("req-1", "missing")).toThrow(NodeNotFoundError); + }); + + it("throws CycleError if adding would create cycle", () => { + const graph = new FlowGraph(); + graph.addCall({ requestId: "req-1", operationId: "task.a", status: "pending", input: {} }); + graph.addCall({ requestId: "req-2", operationId: "task.b", status: "pending", input: {} }); + graph.addCall({ requestId: "req-3", operationId: "task.c", status: "pending", input: {} }); + graph.addEdge("req-1", "req-2"); + graph.addEdge("req-2", "req-3"); + expect(() => graph.addDependency("req-3", "req-1")).toThrow(CycleError); + }); +}); + +describe("FlowGraph.updateStatus", () => { + it("transitions pending to running", () => { + const graph = new FlowGraph(); + graph.addCall({ requestId: "req-1", operationId: "task.op", status: "pending", input: {} }); + graph.updateStatus("req-1", "running"); + const attrs = graph.getNodeAttributes("req-1") as Record; + expect(attrs.status).toBe("running"); + }); + + it("transitions running to completed", () => { + const graph = new FlowGraph(); + graph.addCall({ requestId: "req-1", operationId: "task.op", status: "pending", input: {} }); + graph.updateStatus("req-1", "running"); + graph.updateStatus("req-1", "completed", { completedAt: "2026-01-01T00:00:01Z" }); + const attrs = graph.getNodeAttributes("req-1") as Record; + expect(attrs.status).toBe("completed"); + expect(attrs.completedAt).toBe("2026-01-01T00:00:01Z"); + }); + + it("transitions running to failed", () => { + const graph = new FlowGraph(); + graph.addCall({ requestId: "req-1", operationId: "task.op", status: "pending", input: {} }); + graph.updateStatus("req-1", "running"); + graph.updateStatus("req-1", "failed"); + const attrs = graph.getNodeAttributes("req-1") as Record; + expect(attrs.status).toBe("failed"); + }); + + it("transitions pending to aborted", () => { + const graph = new FlowGraph(); + graph.addCall({ requestId: "req-1", operationId: "task.op", status: "pending", input: {} }); + graph.updateStatus("req-1", "aborted"); + const attrs = graph.getNodeAttributes("req-1") as Record; + expect(attrs.status).toBe("aborted"); + }); + + it("transitions running to aborted", () => { + const graph = new FlowGraph(); + graph.addCall({ requestId: "req-1", operationId: "task.op", status: "pending", input: {} }); + graph.updateStatus("req-1", "running"); + graph.updateStatus("req-1", "aborted"); + const attrs = graph.getNodeAttributes("req-1") as Record; + expect(attrs.status).toBe("aborted"); + }); + + it("is no-op if status is already the target", () => { + const graph = new FlowGraph(); + graph.addCall({ requestId: "req-1", operationId: "task.op", status: "pending", input: {} }); + graph.updateStatus("req-1", "pending"); + const attrs = graph.getNodeAttributes("req-1") as Record; + expect(attrs.status).toBe("pending"); + }); + + it("throws InvalidTransitionError for completed to running", () => { + const graph = new FlowGraph(); + graph.addCall({ requestId: "req-1", operationId: "task.op", status: "pending", input: {} }); + graph.updateStatus("req-1", "running"); + graph.updateStatus("req-1", "completed", { completedAt: "2026-01-01T00:00:01Z" }); + expect(() => graph.updateStatus("req-1", "running")).toThrow(InvalidTransitionError); + }); + + it("throws InvalidTransitionError for failed to running", () => { + const graph = new FlowGraph(); + graph.addCall({ requestId: "req-1", operationId: "task.op", status: "pending", input: {} }); + graph.updateStatus("req-1", "running"); + graph.updateStatus("req-1", "failed"); + expect(() => graph.updateStatus("req-1", "running")).toThrow(InvalidTransitionError); + }); + + it("throws InvalidTransitionError for aborted to running", () => { + const graph = new FlowGraph(); + graph.addCall({ requestId: "req-1", operationId: "task.op", status: "pending", input: {} }); + graph.updateStatus("req-1", "aborted"); + expect(() => graph.updateStatus("req-1", "running")).toThrow(InvalidTransitionError); + }); + + it("throws InvalidTransitionError for pending to completed", () => { + const graph = new FlowGraph(); + graph.addCall({ requestId: "req-1", operationId: "task.op", status: "pending", input: {} }); + expect(() => graph.updateStatus("req-1", "completed")).toThrow(InvalidTransitionError); + }); + + it("throws InvalidTransitionError for pending to failed", () => { + const graph = new FlowGraph(); + graph.addCall({ requestId: "req-1", operationId: "task.op", status: "pending", input: {} }); + expect(() => graph.updateStatus("req-1", "failed")).toThrow(InvalidTransitionError); + }); + + it("throws NodeNotFoundError for unknown requestId", () => { + const graph = new FlowGraph(); + expect(() => graph.updateStatus("missing", "running")).toThrow(NodeNotFoundError); + }); + + it("InvalidTransitionError contains from/to info", () => { + const graph = new FlowGraph(); + graph.addCall({ requestId: "req-1", operationId: "task.op", status: "pending", input: {} }); + graph.updateStatus("req-1", "running"); + graph.updateStatus("req-1", "completed", { completedAt: "2026-01-01T00:00:01Z" }); + try { + graph.updateStatus("req-1", "running"); + expect.unreachable("should throw"); + } catch (e) { + expect(e).toBeInstanceOf(InvalidTransitionError); + const ite = e as InvalidTransitionError; + expect(ite.requestId).toBe("req-1"); + expect(ite.from).toBe("completed" as CallStatus); + expect(ite.to).toBe("running" as CallStatus); + } + }); + + it("merges extra attributes on transition", () => { + const graph = new FlowGraph(); + graph.addCall({ requestId: "req-1", operationId: "task.op", status: "pending", input: {} }); + graph.updateStatus("req-1", "running"); + graph.updateStatus("req-1", "completed", { + output: { result: 42 }, + completedAt: "2026-01-01T00:00:01Z", + }); + const attrs = graph.getNodeAttributes("req-1") as Record; + expect(attrs.status).toBe("completed"); + expect(attrs.output).toEqual({ result: 42 }); + expect(attrs.completedAt).toBe("2026-01-01T00:00:01Z"); + }); +}); + +describe("FlowGraph.updateCall", () => { + it("partially merges call attributes", () => { + const graph = new FlowGraph(); + graph.addCall({ + requestId: "req-1", + operationId: "task.op", + status: "pending", + input: {}, + }); + graph.updateCall("req-1", { output: "some result" }); + const attrs = graph.getNodeAttributes("req-1") as Record; + expect(attrs.output).toBe("some result"); + expect(attrs.status).toBe("pending"); + }); + + it("throws NodeNotFoundError for unknown requestId", () => { + const graph = new FlowGraph(); + expect(() => graph.updateCall("missing", { output: "x" })).toThrow(NodeNotFoundError); + }); +}); + +describe("FlowGraph.removeCall", () => { + it("removes node and attached edges", () => { + const graph = new FlowGraph(); + graph.addCall({ requestId: "req-1", operationId: "task.parent", status: "pending", input: {} }); + graph.addCall({ requestId: "req-2", operationId: "task.child", status: "pending", input: {}, parentRequestId: "req-1" }); + expect(graph.size).toBe(1); + graph.removeCall("req-2"); + expect(graph.hasNode("req-2")).toBe(false); + expect(graph.size).toBe(0); + expect(graph.hasNode("req-1")).toBe(true); + }); + + it("is a no-op if requestId doesn't exist", () => { + const graph = new FlowGraph(); + expect(() => graph.removeCall("missing")).not.toThrow(); + }); }); \ No newline at end of file