diff --git a/src/host/index.ts b/src/host/index.ts index 5be592c..2786c98 100644 --- a/src/host/index.ts +++ b/src/host/index.ts @@ -1,2 +1,4 @@ export { GraphologyHostConfig } from "./graphology.js"; -export type { WorkflowTag, GraphNode, GraphContext, OperationRegistry } from "./graphology.js"; \ No newline at end of file +export type { WorkflowTag, GraphNode, GraphContext, OperationRegistry as GraphOperationRegistry } from "./graphology.js"; +export { ReactiveHostConfig } from "./reactive.js"; +export type { WorkflowNode, ReactiveContext, OperationRegistry } from "./reactive.js"; \ No newline at end of file diff --git a/src/host/reactive.ts b/src/host/reactive.ts index 8cec2e9..dab5d8e 100644 --- a/src/host/reactive.ts +++ b/src/host/reactive.ts @@ -1 +1,250 @@ -export {}; \ No newline at end of file +import { signal, computed } from "@preact/signals-core"; +import type { Signal, ReadonlySignal } from "@preact/signals-core"; +import type { HostConfig } from "@alkdev/ujsx"; +import type { NodeStatus } from "../schema/enums.js"; +import type { CallResult } from "../schema/edge.js"; +import type { EventLogProjection } from "../reactive/workflow.js"; +import type { WorkflowTag } from "./graphology.js"; + +export interface OperationRegistry { + resolve(name: string): unknown; +} + +export interface WorkflowNode { + key: string; + type: WorkflowTag; + status: Signal; + preconditions: ReadonlySignal; + blockedByFailure: ReadonlySignal; + operationId?: string; + output?: Signal; + children: WorkflowNode[]; +} + +export interface ReactiveContext { + operationRegistry: OperationRegistry; + nodes: Map; + statusSignals: Map>; + preconditions: Map>; + blockedByFailure: Map>; + resultProjection: EventLogProjection; + parentMap: Map; + siblingMap: Map; + results: Map>; + _containerCounter: number; +} + +function collectLeafPredecessors( + nodeKey: string, + ctx: ReactiveContext, +): string[] { + const parentKey = ctx.parentMap.get(nodeKey); + if (!parentKey) return []; + + const parentNode = ctx.nodes.get(parentKey); + if (!parentNode) return []; + + const siblings = ctx.siblingMap.get(parentKey); + if (!siblings) return []; + + const idx = siblings.indexOf(nodeKey); + + switch (parentNode.type) { + case "sequential": { + if (idx > 0) { + const prevKey = siblings[idx - 1]!; + const prevNode = ctx.nodes.get(prevKey); + if (prevNode && prevNode.type === "operation") { + return [prevKey]; + } + return collectLeafPredecessors(prevKey, ctx); + } + return collectLeafPredecessors(parentKey, ctx); + } + case "parallel": + case "map": + case "conditional": { + return collectLeafPredecessors(parentKey, ctx); + } + default: + return []; + } +} + +function computePreconditions( + node: WorkflowNode, + ctx: ReactiveContext, +): boolean { + const predecessors = collectLeafPredecessors(node.key, ctx); + if (predecessors.length === 0) return true; + return predecessors.every((predKey) => { + const predStatus = ctx.statusSignals.get(predKey); + if (!predStatus) return true; + return predStatus.value === "completed" || predStatus.value === "skipped"; + }); +} + +function computeBlockedByFailure( + node: WorkflowNode, + ctx: ReactiveContext, +): boolean { + const predecessors = collectLeafPredecessors(node.key, ctx); + return predecessors.some((predKey) => { + const predStatus = ctx.statusSignals.get(predKey); + if (!predStatus) return false; + return predStatus.value === "failed" || predStatus.value === "aborted"; + }); +} + +export const ReactiveHostConfig: HostConfig = { + name: "reactive", + + createRootContext(_container, options) { + const ctx: ReactiveContext = { + operationRegistry: options?.registry as OperationRegistry ?? { resolve: () => undefined }, + nodes: new Map(), + statusSignals: new Map(), + preconditions: new Map(), + blockedByFailure: new Map(), + resultProjection: options?.resultProjection as EventLogProjection ?? { + append() {}, + getStatus: () => "idle" as NodeStatus, + getResult: () => undefined, + getEvents: () => [], + }, + parentMap: new Map(), + siblingMap: new Map(), + results: new Map(), + _containerCounter: 0, + }; + return ctx; + }, + + createInstance(tag, props, ctx, parent) { + if (tag === "operation") { + const key = props.name as string; + const status = signal("idle"); + + const node: WorkflowNode = { + key, + type: "operation", + status, + preconditions: computed(() => computePreconditions(node, ctx)), + blockedByFailure: computed(() => computeBlockedByFailure(node, ctx)), + operationId: key, + output: signal(undefined), + children: [], + }; + + ctx.nodes.set(key, node); + ctx.statusSignals.set(key, status); + ctx.preconditions.set(key, node.preconditions); + ctx.blockedByFailure.set(key, node.blockedByFailure); + + if (parent) { + ctx.parentMap.set(key, parent.key); + const siblings = ctx.siblingMap.get(parent.key); + if (siblings) { + siblings.push(key); + } else { + ctx.siblingMap.set(parent.key, [key]); + } + } + + return node; + } + + const counter = ctx._containerCounter++; + const key = `__${tag}_${counter}`; + const status = signal("idle"); + + const node: WorkflowNode = { + key, + type: tag, + status, + preconditions: computed(() => computePreconditions(node, ctx)), + blockedByFailure: computed(() => computeBlockedByFailure(node, ctx)), + children: [], + }; + + ctx.nodes.set(key, node); + ctx.statusSignals.set(key, status); + ctx.preconditions.set(key, node.preconditions); + ctx.blockedByFailure.set(key, node.blockedByFailure); + + if (parent) { + ctx.parentMap.set(key, parent.key); + const siblings = ctx.siblingMap.get(parent.key); + if (siblings) { + siblings.push(key); + } else { + ctx.siblingMap.set(parent.key, [key]); + } + } + + if (!ctx.siblingMap.has(key)) { + ctx.siblingMap.set(key, []); + } + + return node; + }, + + createTextInstance(_text, ctx, _parent) { + const counter = ctx._containerCounter++; + const key = `__text_${counter}`; + const status = signal("idle"); + const node: WorkflowNode = { + key, + type: "sequential" as WorkflowTag, + status, + preconditions: computed(() => true), + blockedByFailure: computed(() => false), + children: [], + }; + ctx.nodes.set(key, node); + ctx.statusSignals.set(key, status); + return node; + }, + + appendChild(parent, child, _ctx) { + if (!parent.children.includes(child)) { + parent.children.push(child); + } + }, + + insertBefore(parent, child, before, _ctx) { + const idx = parent.children.indexOf(before); + if (idx === -1) { + parent.children.push(child); + } else { + if (!parent.children.includes(child)) { + parent.children.splice(idx, 0, child); + } + } + }, + + removeChild(parent, child, ctx) { + parent.children = parent.children.filter((c) => c.key !== child.key); + + const siblings = ctx.siblingMap.get(parent.key); + if (siblings) { + const idx = siblings.indexOf(child.key); + if (idx !== -1) { + siblings.splice(idx, 1); + } + } + }, + + prepareUpdate() { + return null; + }, + + commitUpdate() { + }, + + emit() { + }, + + finalizeInstance(_instance, _ctx) { + }, +}; \ No newline at end of file diff --git a/test/host/reactive.test.ts b/test/host/reactive.test.ts index 37f88d2..2a5040b 100644 --- a/test/host/reactive.test.ts +++ b/test/host/reactive.test.ts @@ -1,7 +1,507 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect } from "vitest"; +import { h, createHostRoot } from "@alkdev/ujsx"; +import { Operation, Sequential, Parallel, Conditional, Map } from "../../src/component/index.js"; +import { ReactiveHostConfig } from "../../src/host/reactive.js"; +import type { WorkflowNode, ReactiveContext } from "../../src/host/reactive.js"; -describe('reactive host', () => { - it('placeholder', () => { - expect(true).toBe(true); +function renderTemplate(template: ReturnType): ReactiveContext { + const root = createHostRoot(ReactiveHostConfig, null); + root.render(template); + return root.ctx; +} + +describe("ReactiveHostConfig", () => { + describe("createRootContext", () => { + it("creates fresh ReactiveContext with empty maps", () => { + const ctx = ReactiveHostConfig.createRootContext(null); + expect(ctx.nodes.size).toBe(0); + expect(ctx.statusSignals.size).toBe(0); + expect(ctx.parentMap.size).toBe(0); + expect(ctx.siblingMap.size).toBe(0); + }); + + it("accepts options with registry", () => { + const registry = { resolve: (name: string) => name }; + const ctx = ReactiveHostConfig.createRootContext(null, { registry }); + expect(ctx.operationRegistry).toBe(registry); + }); + }); + + describe("createInstance for operation", () => { + it("creates WorkflowNode with correct key and type", () => { + const template = h(Sequential, {}, + h(Operation, { name: "classify" }), + ); + + const ctx = renderTemplate(template); + const node = ctx.nodes.get("classify"); + expect(node).toBeDefined(); + expect(node!.key).toBe("classify"); + expect(node!.type).toBe("operation"); + expect(node!.operationId).toBe("classify"); + }); + + it("creates WorkflowNode with idle status signal", () => { + const template = h(Sequential, {}, + h(Operation, { name: "classify" }), + ); + + const ctx = renderTemplate(template); + const node = ctx.nodes.get("classify")!; + expect(node.status.value).toBe("idle"); + }); + + it("registers node in context maps", () => { + const template = h(Sequential, {}, + h(Operation, { name: "classify" }), + ); + + const ctx = renderTemplate(template); + expect(ctx.statusSignals.has("classify")).toBe(true); + expect(ctx.preconditions.has("classify")).toBe(true); + expect(ctx.blockedByFailure.has("classify")).toBe(true); + }); + + it("creates output signal for operation nodes", () => { + const template = h(Sequential, {}, + h(Operation, { name: "classify" }), + ); + + const ctx = renderTemplate(template); + const node = ctx.nodes.get("classify")!; + expect(node.output).toBeDefined(); + expect(node.output!.value).toBeUndefined(); + }); + }); + + describe("createInstance for structural containers", () => { + it("creates WorkflowNode tracking children for Sequential", () => { + const template = h(Sequential, {}, + h(Operation, { name: "A" }), + h(Operation, { name: "B" }), + ); + + const ctx = renderTemplate(template); + const seqNode = ctx.nodes.get("__sequential_0"); + expect(seqNode).toBeDefined(); + expect(seqNode!.type).toBe("sequential"); + expect(seqNode!.children.length).toBe(2); + }); + + it("creates WorkflowNode for Parallel with children", () => { + const template = h(Parallel, {}, + h(Operation, { name: "A" }), + h(Operation, { name: "B" }), + ); + + const ctx = renderTemplate(template); + const parNode = ctx.nodes.get("__parallel_0"); + expect(parNode).toBeDefined(); + expect(parNode!.type).toBe("parallel"); + expect(parNode!.children.length).toBe(2); + }); + + it("creates WorkflowNode for Conditional with children", () => { + const template = h(Sequential, {}, + h(Operation, { name: "A" }), + h(Conditional, { test: "A" }, + h(Operation, { name: "B" }), + ), + ); + + const ctx = renderTemplate(template); + const condNode = ctx.nodes.get("__conditional_1"); + expect(condNode).toBeDefined(); + expect(condNode!.type).toBe("conditional"); + }); + + it("structural containers do not have operationId", () => { + const template = h(Sequential, {}, + h(Operation, { name: "A" }), + ); + + const ctx = renderTemplate(template); + const seqNode = ctx.nodes.get("__sequential_0")!; + expect(seqNode.operationId).toBeUndefined(); + }); + }); + + describe("signal initial states", () => { + it("root operation has preconditions met (no predecessors)", () => { + const template = h(Sequential, {}, + h(Operation, { name: "A" }), + h(Operation, { name: "B" }), + ); + + const ctx = renderTemplate(template); + const nodeA = ctx.nodes.get("A")!; + expect(nodeA.preconditions.value).toBe(true); + }); + + it("second operation in sequential has unmet preconditions", () => { + const template = h(Sequential, {}, + h(Operation, { name: "A" }), + h(Operation, { name: "B" }), + ); + + const ctx = renderTemplate(template); + const nodeB = ctx.nodes.get("B")!; + expect(nodeB.preconditions.value).toBe(false); + }); + + it("parallel children have preconditions met (no inter-child deps)", () => { + const template = h(Parallel, {}, + h(Operation, { name: "A" }), + h(Operation, { name: "B" }), + ); + + const ctx = renderTemplate(template); + expect(ctx.nodes.get("A")!.preconditions.value).toBe(true); + expect(ctx.nodes.get("B")!.preconditions.value).toBe(true); + }); + + it("all initial blockedByFailure are false", () => { + const template = h(Sequential, {}, + h(Operation, { name: "A" }), + h(Operation, { name: "B" }), + ); + + const ctx = renderTemplate(template); + expect(ctx.nodes.get("A")!.blockedByFailure.value).toBe(false); + expect(ctx.nodes.get("B")!.blockedByFailure.value).toBe(false); + }); + }); + + describe("precondition transition on predecessor completion", () => { + it("sequential: completing predecessor updates dependent preconditions", () => { + const template = h(Sequential, {}, + h(Operation, { name: "A" }), + h(Operation, { name: "B" }), + ); + + const ctx = renderTemplate(template); + const nodeA = ctx.nodes.get("A")!; + const nodeB = ctx.nodes.get("B")!; + + expect(nodeB.preconditions.value).toBe(false); + + nodeA.status.value = "completed"; + + expect(nodeB.preconditions.value).toBe(true); + }); + + it("sequential: skipped predecessor satisfies preconditions", () => { + const template = h(Sequential, {}, + h(Operation, { name: "A" }), + h(Operation, { name: "B" }), + ); + + const ctx = renderTemplate(template); + const nodeA = ctx.nodes.get("A")!; + + nodeA.status.value = "skipped"; + + expect(ctx.nodes.get("B")!.preconditions.value).toBe(true); + }); + + it("sequential chain: A→B→C, completing A then B", () => { + const template = h(Sequential, {}, + h(Operation, { name: "A" }), + h(Operation, { name: "B" }), + h(Operation, { name: "C" }), + ); + + const ctx = renderTemplate(template); + + expect(ctx.nodes.get("B")!.preconditions.value).toBe(false); + expect(ctx.nodes.get("C")!.preconditions.value).toBe(false); + + ctx.nodes.get("A")!.status.value = "completed"; + expect(ctx.nodes.get("B")!.preconditions.value).toBe(true); + expect(ctx.nodes.get("C")!.preconditions.value).toBe(false); + + ctx.nodes.get("B")!.status.value = "completed"; + expect(ctx.nodes.get("C")!.preconditions.value).toBe(true); + }); + + it("parallel children preconditions independent of each other", () => { + const template = h(Sequential, {}, + h(Operation, { name: "pre" }), + h(Parallel, {}, + h(Operation, { name: "A" }), + h(Operation, { name: "B" }), + ), + ); + + const ctx = renderTemplate(template); + + expect(ctx.nodes.get("A")!.preconditions.value).toBe(false); + expect(ctx.nodes.get("B")!.preconditions.value).toBe(false); + + ctx.nodes.get("pre")!.status.value = "completed"; + + expect(ctx.nodes.get("A")!.preconditions.value).toBe(true); + expect(ctx.nodes.get("B")!.preconditions.value).toBe(true); + }); + }); + + describe("failure propagation", () => { + it("sequential: failed predecessor causes blockedByFailure to be true", () => { + const template = h(Sequential, {}, + h(Operation, { name: "A" }), + h(Operation, { name: "B" }), + ); + + const ctx = renderTemplate(template); + + ctx.nodes.get("A")!.status.value = "failed"; + + expect(ctx.nodes.get("B")!.blockedByFailure.value).toBe(true); + }); + + it("sequential: aborted predecessor causes blockedByFailure to be true", () => { + const template = h(Sequential, {}, + h(Operation, { name: "A" }), + h(Operation, { name: "B" }), + ); + + const ctx = renderTemplate(template); + + ctx.nodes.get("A")!.status.value = "aborted"; + + expect(ctx.nodes.get("B")!.blockedByFailure.value).toBe(true); + }); + + it("parallel siblings are independent for failure propagation", () => { + const template = h(Parallel, {}, + h(Operation, { name: "A" }), + h(Operation, { name: "B" }), + ); + + const ctx = renderTemplate(template); + + ctx.nodes.get("A")!.status.value = "failed"; + + expect(ctx.nodes.get("B")!.blockedByFailure.value).toBe(false); + }); + + it("failed predecessor does not satisfy preconditions", () => { + const template = h(Sequential, {}, + h(Operation, { name: "A" }), + h(Operation, { name: "B" }), + ); + + const ctx = renderTemplate(template); + + ctx.nodes.get("A")!.status.value = "failed"; + + expect(ctx.nodes.get("B")!.preconditions.value).toBe(false); + }); + }); + + describe("appendChild", () => { + it("appends children to parent's children array", () => { + const template = h(Sequential, {}, + h(Operation, { name: "A" }), + h(Operation, { name: "B" }), + ); + + const ctx = renderTemplate(template); + const seqNode = ctx.nodes.get("__sequential_0")!; + expect(seqNode.children.length).toBe(2); + expect(seqNode.children[0]!.key).toBe("A"); + expect(seqNode.children[1]!.key).toBe("B"); + }); + + it("does not duplicate children", () => { + const template = h(Sequential, {}, + h(Operation, { name: "A" }), + ); + + const ctx = renderTemplate(template); + const seqNode = ctx.nodes.get("__sequential_0")!; + const childNode = ctx.nodes.get("A")!; + ReactiveHostConfig.appendChild(seqNode, childNode, ctx); + expect(seqNode.children.length).toBe(1); + }); + }); + + describe("removeChild", () => { + it("removes child from parent's children array", () => { + const template = h(Sequential, {}, + h(Operation, { name: "A" }), + h(Operation, { name: "B" }), + ); + + const ctx = renderTemplate(template); + const seqNode = ctx.nodes.get("__sequential_0")!; + const childB = ctx.nodes.get("B")!; + + ReactiveHostConfig.removeChild(seqNode, childB, ctx); + expect(seqNode.children.length).toBe(1); + expect(seqNode.children[0]!.key).toBe("A"); + }); + + it("preconditions auto-reevaluate after removeChild", () => { + const template = h(Sequential, {}, + h(Operation, { name: "A" }), + h(Operation, { name: "B" }), + h(Operation, { name: "C" }), + ); + + const ctx = renderTemplate(template); + const seqNode = ctx.nodes.get("__sequential_0")!; + const childB = ctx.nodes.get("B")!; + + ctx.nodes.get("A")!.status.value = "completed"; + + expect(childB.preconditions.value).toBe(true); + + ReactiveHostConfig.removeChild(seqNode, childB, ctx); + + const childC = ctx.nodes.get("C")!; + expect(childC.preconditions.value).toBe(true); + }); + }); + + describe("parentMap and siblingMap", () => { + it("registers parent-child relationships in parentMap", () => { + const template = h(Sequential, {}, + h(Operation, { name: "A" }), + h(Operation, { name: "B" }), + ); + + const ctx = renderTemplate(template); + + expect(ctx.parentMap.get("A")).toBe("__sequential_0"); + expect(ctx.parentMap.get("B")).toBe("__sequential_0"); + }); + + it("registers siblings in siblingMap", () => { + const template = h(Sequential, {}, + h(Operation, { name: "A" }), + h(Operation, { name: "B" }), + ); + + const ctx = renderTemplate(template); + + const siblings = ctx.siblingMap.get("__sequential_0"); + expect(siblings).toEqual(["A", "B"]); + }); + + it("nested structures register correct parent", () => { + const template = h(Sequential, {}, + h(Operation, { name: "pre" }), + h(Parallel, {}, + h(Operation, { name: "A" }), + h(Operation, { name: "B" }), + ), + ); + + const ctx = renderTemplate(template); + + expect(ctx.parentMap.get("pre")).toBe("__sequential_0"); + expect(ctx.parentMap.get("A")).toBe("__parallel_1"); + expect(ctx.parentMap.get("B")).toBe("__parallel_1"); + + const seqSiblings = ctx.siblingMap.get("__sequential_0"); + expect(seqSiblings).toEqual(["pre", "__parallel_1"]); + + const parSiblings = ctx.siblingMap.get("__parallel_1"); + expect(parSiblings).toEqual(["A", "B"]); + }); + }); + + describe("Conditional as error boundary", () => { + it("conditional node is created with correct type", () => { + const template = h(Sequential, {}, + h(Operation, { name: "A" }), + h(Conditional, { test: "A" }, + h(Operation, { name: "B" }), + ), + ); + + const ctx = renderTemplate(template); + + const condNode = ctx.nodes.get("__conditional_1"); + expect(condNode).toBeDefined(); + expect(condNode!.type).toBe("conditional"); + }); + + it("conditional child has preconditions met (no inter-child ordering)", () => { + const template = h(Conditional, { test: "A" }, + h(Operation, { name: "B" }), + ); + + const ctx = renderTemplate(template); + + expect(ctx.nodes.get("B")!.preconditions.value).toBe(true); + }); + }); + + describe("full template rendering", () => { + it("renders a complete pipeline template", () => { + const template = h(Sequential, {}, + h(Operation, { name: "architect" }), + h(Operation, { name: "reviewer" }), + h(Parallel, {}, + h(Operation, { name: "decomposer" }), + h(Operation, { name: "specialist" }), + ), + h(Operation, { name: "synthesizer" }), + ); + + const ctx = renderTemplate(template); + + expect(ctx.nodes.get("architect")).toBeDefined(); + expect(ctx.nodes.get("reviewer")).toBeDefined(); + expect(ctx.nodes.get("decomposer")).toBeDefined(); + expect(ctx.nodes.get("specialist")).toBeDefined(); + expect(ctx.nodes.get("synthesizer")).toBeDefined(); + + expect(ctx.nodes.get("architect")!.preconditions.value).toBe(true); + expect(ctx.nodes.get("reviewer")!.preconditions.value).toBe(false); + + ctx.nodes.get("architect")!.status.value = "completed"; + expect(ctx.nodes.get("reviewer")!.preconditions.value).toBe(true); + }); + + it("deeply nested template registers all nodes", () => { + const template = h(Sequential, {}, + h(Parallel, {}, + h(Sequential, {}, + h(Operation, { name: "A" }), + h(Operation, { name: "B" }), + ), + h(Operation, { name: "C" }), + ), + ); + + const ctx = renderTemplate(template); + + expect(ctx.nodes.has("A")).toBe(true); + expect(ctx.nodes.has("B")).toBe(true); + expect(ctx.nodes.has("C")).toBe(true); + }); + }); + + describe("shared signal references", () => { + it("WorkflowNode.status and statusSignals point to same signal", () => { + const template = h(Sequential, {}, + h(Operation, { name: "A" }), + ); + + const ctx = renderTemplate(template); + const node = ctx.nodes.get("A")!; + const statusFromMap = ctx.statusSignals.get("A")!; + + expect(node.status).toBe(statusFromMap); + + statusFromMap.value = "running"; + expect(node.status.value).toBe("running"); + + node.status.value = "completed"; + expect(statusFromMap.value).toBe("completed"); + }); }); }); \ No newline at end of file