diff --git a/src/graph/construction.ts b/src/graph/construction.ts index 3f8b6dd..1f48c22 100644 --- a/src/graph/construction.ts +++ b/src/graph/construction.ts @@ -8,6 +8,7 @@ import { NodeNotFoundError, CycleError, InvalidInputError, + InvalidTransitionError, } from "../error/index.js"; import type { CallStatus, AnyValidationError, ValidationError } from "../error/index.js"; import { @@ -20,8 +21,10 @@ import { OperationEdgeAttrs as OperationEdgeAttrsSchema, OperationGraphSerialized, CallGraphSerialized, + CallNodeAttrs as CallNodeAttrsSchema, + CallEdgeAttrs as CallEdgeAttrsSchema, } from "../schema/index.js"; -import type { OperationNodeAttrs, FlowGraphSerialized } 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 { @@ -41,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; @@ -52,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, @@ -318,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); } @@ -365,10 +562,12 @@ 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 { 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/src/reactive/workflow.ts b/src/reactive/workflow.ts index dfd0ad3..d10a5a2 100644 --- a/src/reactive/workflow.ts +++ b/src/reactive/workflow.ts @@ -88,6 +88,7 @@ export class WorkflowReactiveRoot implements EventLogProjection { blockedByFailure: Map>; resultMap: Map>; nodeKeyToRequestId: Map; + requestIdToNodeKey: Map; private graph: DirectedGraph; private effectDisposers: (() => void)[]; @@ -106,10 +107,16 @@ export class WorkflowReactiveRoot implements EventLogProjection { this.effectDisposers = []; this.eventLog = []; this.nodeKeyToRequestId = new Map(); + this.requestIdToNodeKey = new Map(); this._failurePolicy = options?.failurePolicy ?? "continue-running"; this.initializeSignals(); } + setRequestId(nodeKey: string, requestId: string): void { + this.nodeKeyToRequestId.set(nodeKey, requestId); + this.requestIdToNodeKey.set(requestId, nodeKey); + } + private initializeSignals(): void { for (const node of this.graph.nodes()) { const predecessors: string[] = this.graph.inNeighbors(node) ?? []; @@ -213,15 +220,29 @@ export class WorkflowReactiveRoot implements EventLogProjection { if (!("requestId" in event)) return; - const nodeId = this.findNodeByRequestId(event.requestId); + let nodeId = this.requestIdToNodeKey.get(event.requestId); + + if (nodeId === undefined) { + for (const [nId, rid] of this.nodeKeyToRequestId) { + if (rid === event.requestId) { + nodeId = nId; + this.requestIdToNodeKey.set(event.requestId, nId); + break; + } + } + } + if (nodeId === undefined) return; - const statusSignal = this.statusMap.get(nodeId); - if (!statusSignal) return; + const currentRequestId = this.nodeKeyToRequestId.get(nodeId); + if (currentRequestId === event.requestId) { + const statusSignal = this.statusMap.get(nodeId); + if (!statusSignal) return; - const derived = EVENT_TO_STATUS[event.type]; - if (derived !== undefined) { - statusSignal.value = derived; + const derived = EVENT_TO_STATUS[event.type]; + if (derived !== undefined) { + statusSignal.value = derived; + } } } @@ -254,12 +275,17 @@ export class WorkflowReactiveRoot implements EventLogProjection { } getEvents(nodeId: string): CallEventMapValue[] { - const requestId = this.nodeKeyToRequestId.get(nodeId); - if (!requestId) return []; + const requestIds = new Set(); + for (const [rid, nId] of this.requestIdToNodeKey) { + if (nId === nodeId) { + requestIds.add(rid); + } + } + if (requestIds.size === 0) return []; const events: CallEventMapValue[] = []; for (const e of this.eventLog) { - if ("requestId" in e && e.requestId === requestId) { + if ("requestId" in e && requestIds.has(e.requestId)) { events.push(e); } } @@ -325,13 +351,7 @@ export class WorkflowReactiveRoot implements EventLogProjection { this.blockedByFailure.clear(); this.resultMap.clear(); this.nodeKeyToRequestId.clear(); + this.requestIdToNodeKey.clear(); this.eventLog = []; } - - private findNodeByRequestId(requestId: string): string | undefined { - for (const [nodeId, rid] of this.nodeKeyToRequestId) { - if (rid === requestId) return nodeId; - } - return undefined; - } } diff --git a/test/graph/construction.test.ts b/test/graph/construction.test.ts index 19c99ac..76e049d 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,8 +302,13 @@ 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({} as never)).toThrow(); }); }); @@ -634,4 +641,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 diff --git a/test/reactive/workflow.test.ts b/test/reactive/workflow.test.ts index 1975831..f02ab08 100644 --- a/test/reactive/workflow.test.ts +++ b/test/reactive/workflow.test.ts @@ -159,7 +159,7 @@ describe("WorkflowReactiveRoot", () => { const graph = makeSimpleGraph(); const root = new WorkflowReactiveRoot(graph); - root.nodeKeyToRequestId.set("a", "req-1"); + root.setRequestId("a", "req-1"); root.append({ type: "call.requested", requestId: "req-1", @@ -177,7 +177,7 @@ describe("WorkflowReactiveRoot", () => { const graph = makeSimpleGraph(); const root = new WorkflowReactiveRoot(graph); - root.nodeKeyToRequestId.set("a", "req-1"); + root.setRequestId("a", "req-1"); root.append({ type: "call.requested", requestId: "req-1", @@ -201,7 +201,7 @@ describe("WorkflowReactiveRoot", () => { const graph = makeSimpleGraph(); const root = new WorkflowReactiveRoot(graph); - root.nodeKeyToRequestId.set("a", "req-1"); + root.setRequestId("a", "req-1"); root.append({ type: "call.requested", requestId: "req-1", @@ -225,7 +225,7 @@ describe("WorkflowReactiveRoot", () => { const graph = makeSimpleGraph(); const root = new WorkflowReactiveRoot(graph); - root.nodeKeyToRequestId.set("a", "req-1"); + root.setRequestId("a", "req-1"); root.append({ type: "call.requested", requestId: "req-1", @@ -265,7 +265,7 @@ describe("WorkflowReactiveRoot", () => { const graph = makeSimpleGraph(); const root = new WorkflowReactiveRoot(graph); - root.nodeKeyToRequestId.set("a", "req-1"); + root.setRequestId("a", "req-1"); const respondedEvent: CallEventMapValue = { type: "call.responded", @@ -304,7 +304,7 @@ describe("WorkflowReactiveRoot", () => { const graph = makeSimpleGraph(); const root = new WorkflowReactiveRoot(graph); - root.nodeKeyToRequestId.set("a", "req-1"); + root.setRequestId("a", "req-1"); root.append({ type: "call.requested", requestId: "req-1", @@ -353,7 +353,7 @@ describe("WorkflowReactiveRoot", () => { const graph = makeSimpleGraph(); const root = new WorkflowReactiveRoot(graph); - root.nodeKeyToRequestId.set("a", "req-1"); + root.setRequestId("a", "req-1"); root.append({ type: "call.requested", requestId: "req-1", @@ -380,7 +380,7 @@ describe("WorkflowReactiveRoot", () => { const graph = makeSimpleGraph(); const root = new WorkflowReactiveRoot(graph); - root.nodeKeyToRequestId.set("a", "req-1"); + root.setRequestId("a", "req-1"); root.append({ type: "call.requested", requestId: "req-1", @@ -408,7 +408,7 @@ describe("WorkflowReactiveRoot", () => { const graph = makeSimpleGraph(); const root = new WorkflowReactiveRoot(graph); - root.nodeKeyToRequestId.set("a", "req-1"); + root.setRequestId("a", "req-1"); root.append({ type: "call.requested", requestId: "req-1", @@ -434,7 +434,7 @@ describe("WorkflowReactiveRoot", () => { const graph = makeSimpleGraph(); const root = new WorkflowReactiveRoot(graph); - root.nodeKeyToRequestId.set("a", "req-1"); + root.setRequestId("a", "req-1"); root.append({ type: "call.requested", requestId: "req-1", @@ -452,7 +452,7 @@ describe("WorkflowReactiveRoot", () => { const graph = makeSimpleGraph(); const root = new WorkflowReactiveRoot(graph); - root.nodeKeyToRequestId.set("a", "req-2"); + root.setRequestId("a", "req-1"); root.append({ type: "call.requested", requestId: "req-1", @@ -466,6 +466,7 @@ describe("WorkflowReactiveRoot", () => { error: { code: "ERR", message: "first attempt failed" }, timestamp: "2026-01-01T00:00:01Z", }); + root.setRequestId("a", "req-2"); root.append({ type: "call.requested", requestId: "req-2", @@ -503,7 +504,7 @@ describe("WorkflowReactiveRoot", () => { const graph = makeSimpleGraph(); const root = new WorkflowReactiveRoot(graph); - root.nodeKeyToRequestId.set("a", "req-1"); + root.setRequestId("a", "req-1"); root.append({ type: "call.requested", requestId: "req-1", @@ -840,9 +841,9 @@ describe("WorkflowReactiveRoot", () => { const graph = makeSimpleGraph(); const root = new WorkflowReactiveRoot(graph); - root.nodeKeyToRequestId.set("a", "req-a"); - root.nodeKeyToRequestId.set("b", "req-b"); - root.nodeKeyToRequestId.set("c", "req-c"); + root.setRequestId("a", "req-a"); + root.setRequestId("b", "req-b"); + root.setRequestId("c", "req-c"); expect(root.getStatus("a")).toBe("idle"); expect(root.getStatus("b")).toBe("idle");