Architecture Migration Plan

reaktorWeb → reaktor-graph

Decompose a 14K-line React workbench into a polyglot reaktor-graph application. Common core in Kotlin, dual renderers (engine + website), reactive in-memory store, and self-referential graph visualization.

1. Situation & Goals

Total Source
14,368
lines across 30+ files
Screens
10
mode screens + nested views
State Fields
40+
in ScenarioContext god object
Mock Data
80+
hardcoded entities

What exists today

reaktorWeb is a React 19 workbench deployed on Cloudflare Workers. It has 10 modes (graph, agent, database, auth, deploy, insights, etc.), complex state management via a single React context (ScenarioContext — 762 lines), hash-based routing, mock services, and @xyflow/react graph visualization. Everything runs on hardcoded mock data.

reaktor-graph is a Kotlin Multiplatform graph framework with a mature JS target:

Goals

G1 — Polyglot Graph Application

reaktorWeb becomes a reaktor-graph application. The graph model is usable from both Kotlin and TypeScript. React components consume the graph via typed port access.

G2 — Reactive In-Memory Store

Design a graph-native reactive store that holds shared state across components. Aligns with reaktor principles: ports for access, scoped lifetime, observable via StateFlow.

G3 — Common Core, Dual Renderers

The graph definition (nodes, routes, services, ports) lives in shared Kotlin. The engine module (Compose Desktop) and reaktorWeb (React) are two renderers of the same graph.

G4 — Self-Referential Visualization

The graph screen visualizes the app's own live graph via buildReaktorFlowGraph() — the ultimate stress test of the introspection API.

2. Polyglot Graph Design

reaktor-graph is Kotlin Multiplatform, but the website is React/TypeScript. The polyglot bridge must feel natural in both languages.

2.1 Current JS Target Surface

These are already @JsExported and proven:

Kotlin ClassJS UsageStatus
WebHostEntry point: new WebHost(graph)Ready
WebNavigationBridgebackStack ↔ browser history syncReady
StateFlow.toReactState()Bidirectional StateFlow → React useStateReady
ReactNode<State>Graph node wrapping a React componentReady
ReactContainerContainer rendering child graphsReady
GraphContentComponentRenders backStack top entryReady
WebBottomNavigationContainerTab bar containerReady
Graph.toJsonElement()JSON serialization of graph structureReady
ServiceNodeService with typed handlersReady
JsRequestHandlerPromise-based handler bridgeReady

2.2 TypeScript Interop Architecture

The polyglot boundary has three layers:

┌─────────────────────────────────────────────────────────────────────┐ │ TypeScript / React │ │ │ │ import { useGraph, usePort, useNavigation } from 'reaktor-web' │ │ import type { WorkbenchState, GraphPort } from 'reaktor-web' │ │ │ │ function GraphScreen() { │ │ const graph = useGraph() │ │ const { selectedId } = usePort<WorkbenchState>('workbench') │ │ const nav = useNavigation() │ │ ... │ │ } │ ├─────────────────────────────────────────────────────────────────────┤ │ Bridge Layer (reaktor-web/src/hooks/) │ │ │ │ useGraph() → React context holding WebHost instance │ │ usePort(key) → StateFlow.toReactState() for a named port │ │ useNavigation() → Push/Pop/Replace via graph.dispatch() │ │ useService(key) → Promise-based handler invocation │ │ useStore(key) → In-memory store slot access │ ├─────────────────────────────────────────────────────────────────────┤ │ Kotlin/JS (@JsExport) │ │ │ │ WebHost(graph) ← Graph built in commonMain or jsMain │ │ Graph, Node, Port, Edge, Service, StateFlow, NavCommand │ │ toReactFlowData() for self-visualization │ └─────────────────────────────────────────────────────────────────────┘

2.3 React Hook API Design

These hooks are the TypeScript-facing API. They wrap the Kotlin/JS exports into idiomatic React patterns:

// reaktor-web/src/hooks/use-graph.ts
// React context holding the WebHost + Graph instance
const GraphContext = createContext<WebHost | null>(null)

export function GraphProvider({ graph, children }) {
  const host = useMemo(() => {
    const h = new WebHost(graph)
    h.start()
    return h
  }, [graph])

  useEffect(() => () => host.destroy(), [host])

  return <GraphContext.Provider value={host}>{children}</GraphContext.Provider>
}

export function useGraph(): WebHost {
  return useContext(GraphContext)!
}
// reaktor-web/src/hooks/use-port.ts
// Subscribe to a ProviderPort's StateFlow value by port key
export function usePort<T>(nodeLabel: string, portKey: string): T {
  const host = useGraph()
  const node = host.graph.findNode(nodeLabel)
  const port = node.findProvider(portKey)
  // port.impl is a StateFlow — toReactState() bridges it
  const [value] = port.impl.toReactState()
  return value as T
}
// reaktor-web/src/hooks/use-navigation.ts
export function useNavigation() {
  const host = useGraph()
  return {
    push: (pattern: string, params?: Record<string, string>) =>
      host.navigateToPattern(pattern, params),
    pop: () => host.goBack(),
    topPattern: () => host.topPattern(),
    stackSize: () => host.stackSize(),
  }
}

2.4 Kotlin Exports Needed

New @JsExport additions required on reaktor-graph:

ExportPurposeLocation
Graph.findNode(label)Lookup node by label for port accessGraph.kt
Node.findProvider(key)Lookup provider port by key stringNode.kt
Node.findConsumer(key)Lookup consumer port by key stringNode.kt
WebHost.navigateToPattern(pattern, params)URL-based navigation from TSWebHost.kt
MemoryStore (new)In-memory reactive storeNew file
Graph.toJsonElement()Already exists, verify @JsExportGraphJson.kt
Key principle: Kotlin owns the graph model, navigation, state, and services. TypeScript owns the rendering. The bridge layer (hooks) translates between the two worlds. React components never directly create or mutate graph nodes — they read port values and dispatch commands.

3. In-Memory Reactive Store

reaktorWeb needs cross-cutting state: selectedId, graphFocus, edgeFilters, theme, notifications, etc. Currently this is a React context god object. We need a graph-native replacement.

3.1 Design Principles

Port-Aligned

Every store slot is a ProviderPort on a StoreNode. Consumers wire to it via edges, just like any other port connection.

Scoped Lifetime

Store slots die when their owning node is detached. No zombie state. The graph lifecycle manages cleanup.

Observable

Every slot is a MutableStateFlow<T>. Kotlin collects it, React bridges via toReactState(). One reactive primitive everywhere.

3.2 Architecture

┌──────────────────────────────────────────────────────────────┐ │ MemoryStore │ │ (BasicNode on the Graph — sibling to route/service nodes) │ │ │ │ ┌─────────────────────┐ ┌──────────────────────────────┐ │ │ │ Slot: "selection" │ │ Slot: "layout" │ │ │ │ MutableStateFlow< │ │ MutableStateFlow< │ │ │ │ SelectionState │ │ LayoutState │ │ │ │ > │ │ > │ │ │ │ │ │ │ │ │ │ ProviderPort │ │ ProviderPort │ │ │ │ key="selection" │ │ key="layout" │ │ │ └────────┬────────────┘ └────────┬─────────────────────┘ │ │ │ │ │ │ ┌─────────────────────┐ ┌──────────────────────────────┐ │ │ │ Slot: "environment" │ │ Slot: "notifications" │ │ │ │ MutableStateFlow< │ │ MutableStateFlow< │ │ │ │ EnvironmentState │ │ List<Notification> │ │ │ │ > │ │ > │ │ │ │ ProviderPort │ │ ProviderPort │ │ │ │ key="environment" │ │ key="notifications" │ │ │ └─────────────────────┘ └──────────────────────────────┘ │ └──────────────────────────────────────────────────────────────┘ │ │ │ │ ┌────┘ ┌─────────┘ ┌─────────┘ ┌────────┘ ▼ ▼ ▼ ▼ GraphScreen AgentScreen Shell Inspector (consumes (consumes (consumes (consumes selection) selection) layout) selection)

3.3 Store Node Implementation

// reaktor-graph commonMain — new file
// dev/shibasis/reaktor/graph/store/MemoryStore.kt

@JsExport
class MemoryStore(graph: Graph) : BasicNode(graph) {
    private val slots = mutableMapOf<String, MutableStateFlow<Any?>>()

    // Create a typed slot and register it as a ProviderPort
    fun <T> slot(key: String, initial: T): MutableStateFlow<T> {
        val flow = MutableStateFlow<Any?>(initial)
        slots[key] = flow
        registerProvider(
            Port.KeyType(Port.Key(key), Port.Type(key)),
            flow
        )
        @Suppress("UNCHECKED_CAST")
        return flow as MutableStateFlow<T>
    }

    // Read-only access for JS interop
    fun get(key: String): Any? = slots[key]?.value
    fun set(key: String, value: Any?) { slots[key]?.value = value }
    fun flow(key: String): StateFlow<Any?>? = slots[key]

    // Batch update — mutate multiple slots atomically
    fun update(block: (slots: MutableMap<String, Any?>) -> Unit) {
        val snapshot = slots.mapValues { it.value.value }.toMutableMap()
        block(snapshot)
        snapshot.forEach { (key, value) -> slots[key]?.value = value }
    }
}

3.4 Store Slot Types (Kotlin — shared by both renderers)

// Common state types — used by engine and website
@Serializable
data class SelectionState(
    val selectedId: String? = null,
    val graphFocus: GraphFocus = GraphFocus.NONE,
    val edgeFilters: Set<String> = emptySet(),
)

@Serializable
data class LayoutState(
    val drawerOpen: Boolean = true,
    val drawerHeight: Int = 260,
    val drawerTab: String = "cmds",
    val leftWidth: Int = 240,
    val rightWidth: Int = 300,
    val leftCollapsed: Boolean = false,
    val rightCollapsed: Boolean = false,
)

@Serializable
data class EnvironmentState(
    val project: String = "bestbuds",
    val environment: String = "staging",
    val deviceTarget: String = "iPhone 16 Pro",
    val locale: String = "en",
    val themeMode: String = "dark",
    val networkMode: String = "online",
)

@Serializable
enum class GraphFocus { NONE, SELECTED, TRACE, BLAST, TESTS }

3.5 TypeScript Access via React Hooks

// useStore hook — typed access to MemoryStore slots
export function useStore<T>(key: string): [T, (value: T) => void] {
  const host = useGraph()
  const store = host.graph.findNode('store') as MemoryStore
  const flow = store.flow(key)
  const [value] = flow.toReactState()
  const setter = useCallback((v: T) => store.set(key, v), [store, key])
  return [value as T, setter]
}

// Usage in a React component
function GraphScreen() {
  const [selection, setSelection] = useStore<SelectionState>('selection')
  const [layout] = useStore<LayoutState>('layout')

  const onNodeClick = (nodeId: string) => {
    setSelection({ ...selection, selectedId: nodeId })
  }
  // ...
}

3.6 How This Relates to the Object Database

LayerPurposeLifetimeAccess Pattern
MemoryStore UI state, cross-component reactivity Graph lifetime (session) StateFlow → React hooks, port wiring
Object Database Persistent cache, offline data Across sessions (disk) Queries, write-through, migrations
Remote API Server of record Permanent ServiceNode handlers, HTTP

The MemoryStore sits between the Object Database and React components. On startup, it hydrates from the Object Database. During the session, it holds the working set. On meaningful changes, it writes through to the Object Database. This is exactly the RepositoryNode pattern already in reaktor-graph — writeThrough<T> with CacheResult.

4. Navigation Topology

4.1 Current: Hash-Based Mode Switching

// scenario.jsx — mode is just a React state string
const [mode, setMode] = useState('graph')
// Cmd+1 → setMode('graph'), Cmd+2 → setMode('devtools'), etc.
// Agent sub-routes: #agent/conversation/{id}, #agent/scout/{id}, etc.

4.2 Target: Graph Navigation with URL Sync

RootGraph (label: "workbench") │ ├── sentinel: RouteNode("/") │ ↓ (default redirect to /graph) │ ├── RouteNode("/graph") → attached: ReactNode<GraphScreen> ├── RouteNode("/devtools") → attached: ReactNode<DevToolsScreen> ├── RouteNode("/ui") → attached: ReactNode<UiScreen> ├── RouteNode("/database") → attached: ReactNode<DatabaseScreen> ├── RouteNode("/auth") → attached: ReactNode<AuthScreen> ├── RouteNode("/ai") → attached: ReactNode<AiScreen> ├── RouteNode("/testing") → attached: ReactNode<TestingScreen> ├── RouteNode("/deploy") → attached: ReactNode<DeployScreen> ├── RouteNode("/insights") → attached: ReactNode<InsightsScreen> │ ├── RouteNode("/agent") → attached: AgentContainer (ContainerNode) │ └── ChildGraph (label: "agent") │ ├── RouteNode("/agent/chat") │ ├── RouteNode("/agent/chat/{id}") │ ├── RouteNode("/agent/scout/{id}") │ ├── RouteNode("/agent/queue/{id}") │ ├── RouteNode("/agent/manage/{id}") │ └── RouteNode("/agent/pipeline") │ ├── BasicNode (label: "store") → MemoryStore ├── ServiceNode (label: "workbench-svc") → WorkbenchService └── ServiceNode (label: "agent-svc") → AgentService

4.3 URL Mapping

Old (hash)New (path)Keyboard
#graph (or just mode state)/graphCmd+1
#devtools/devtoolsCmd+2
#agent/agent/chatCmd+3
#agent/conversation/abc/agent/chat/abc
#agent/scout/xyz/agent/scout/xyz
#ui/uiCmd+4
#database/databaseCmd+5
#auth/authCmd+6
#ai/aiCmd+7
#testing/testingCmd+8
#deploy/deployCmd+9
#insights/insightsCmd+0

WebNavigationBridge handles all browser history sync — pushState on forward navigation, popstate listener for back button. The Cloudflare Worker's SPA fallback (not_found_handling: "single-page-application") already handles server-side routing.

5. State Decomposition

The ScenarioContext god object (40+ fields, 762 lines) must decompose into graph-native state. Each domain gets its own node with typed state exposed via ports.

5.1 Decomposition Map

ScenarioContext Fields Target Node Node Type Port Key
selectedId, graphFocus, edgeFilters MemoryStore slot: "selection" SelectionState
drawerOpen/Height/Tab, leftWidth, rightWidth, collapsed MemoryStore slot: "layout" LayoutState
project, environment, device, locale, theme, network MemoryStore slot: "environment" EnvironmentState
mode Graph backStack Navigation host.topPattern()
agentView, agentId, agentConversationId, agents[] AgentNode (ControllerNode) Node state AgentState
dbTier, dbTable, dbTab DatabaseNode (ControllerNode) Node state DatabaseState
authView, authRole AuthNode (ControllerNode) Node state AuthState
deployEnv DeployNode (ControllerNode) Node state DeployState
insightsRange, insightsTab, insightsSeries InsightsNode (ControllerNode) Node state InsightsState
busy, notifications[], activityLog[], revision MemoryStore slot: "system" SystemState
actions: { hotReload, deploy, ... } WorkbenchService (ServiceNode) Handlers Port per handler

5.2 Before/After Comparison

Before: God Object

// scenario.jsx
function GraphScreen() {
  const sc = useScenario()
  // sc has 40+ fields
  // Re-renders on ANY change
  const { selectedId, graphFocus,
    edgeFilters, mode, drawerOpen,
    agents, dbTier, authView,
    ... } = sc
}

After: Targeted Subscriptions

// screen-graph.tsx
function GraphScreen() {
  const [sel] = useStore<SelectionState>('selection')
  const nav = useNavigation()
  // Only re-renders when selection changes
  // No dependency on agent/db/auth state

  const onNodeClick = (id: string) =>
    setSelection({ ...sel, selectedId: id })
}
Performance win: The current ScenarioContext triggers re-renders in ALL children when ANY field changes. With per-slot StateFlow subscriptions, each component only re-renders when its specific dependencies change. A theme toggle no longer re-renders the graph canvas.

6. Service Modeling

6.1 Current Mock Services

// mock-services.js — 82 lines of setTimeout-based mocks
createMockWorkbenchServices() {
  hotReload({ device })              → wait(420ms)
  commitBundle({ bundleId, branch }) → wait(520ms)
  deploy({ env, bundleId })          → wait(650-900ms)
  rollback({ version, env })         → wait(600ms)
  runTests({ count })                → wait(760ms)
  sendToAgent({ bundleId })          → wait(450ms)
}

6.2 Target: ServiceNode with Typed Handlers

// commonMain — shared between engine and web
class WorkbenchService : Service() {
    val hotReload = PostHandler.create<HotReloadRequest, StatusResponse>(
        "/workbench/hot-reload", "HotReload"
    ) { request ->
        delay(420) // mock for now, real impl later
        StatusResponse(statusCode = StatusCode.OK)
    }

    val commitBundle = PostHandler.create<CommitRequest, StatusResponse>(
        "/workbench/commit", "CommitBundle"
    ) { request ->
        delay(520)
        StatusResponse(statusCode = StatusCode.OK)
    }

    val deploy = PostHandler.create<DeployRequest, DeployResponse>(
        "/workbench/deploy", "Deploy"
    ) { request ->
        delay(if (request.env == "production") 900 else 650)
        DeployResponse(statusCode = StatusCode.OK, version = "1.0.${revision++}")
    }
    // ... rollback, runTests, sendToAgent
}

Each handler becomes a ProviderPort on the ServiceNode. React components consume them via the useService hook:

// TypeScript usage
function DeployPanel() {
  const deploy = useService('workbench-svc', 'Deploy')
  const [env] = useStore<EnvironmentState>('environment')

  const handleDeploy = async () => {
    const response = await deploy({ env: env.environment, bundleId: currentBundle })
    // response is typed DeployResponse
  }
}

7. Self-Referential Visualization

The most powerful outcome: the graph screen shows the app's own live graph.

7.1 Data Pipeline

WebHost.graph (live reaktor-graph instance) │ ▼ buildReaktorFlowGraph(graph, style) ← reaktor-flow commonMain │ ▼ ReaktorFlowGraph ← nodes, edges, regions, handles │ ▼ flowGraph.toReactFlowData() ← reaktor-flow jsMain │ ▼ JsReaktorFlowData ← React Flow-compatible arrays │ ▼ <ReactFlow nodes={data.nodes} ← @xyflow/react edges={data.edges} />

7.2 What Gets Visualized

Graph ElementVisual RepresentationKind
RouteNode("/graph")Blue "Route" cardRoute
ReactNode(GraphScreen)Green "Screen" cardScreen
AgentContainerOrange "Container" card with child graphContainer
WorkbenchServiceOrange "Service" card with handler portsService
MemoryStoreYellow "Node" card with slot portsNode
Port edges (consumer→provider)Data edges with labelsData
Navigation edges (route→route)Blue navigation arrowsNavigation
Child graph regionsColored background overlaysRegion

7.3 Replacing Hardcoded Data

Before: Hardcoded Mock

// scenario.jsx — 80+ entities
const ENTITIES = {
  'route:/onboarding': {
    type: 'route', title: '/onboarding',
    pos: { x: COL_0, y: ROW_0, w: 420, h: 140 },
    ports: { in: ['NavBinding'], out: ['routeBinding'] },
    ...
  },
  // 79 more...
}
const EDGES = [ /* 40+ edges */ ]
const REGIONS = [ /* 6 regions */ ]

// screen-graph.jsx
const flowData = scenarioToFlowData(ENTITIES, EDGES, REGIONS)

After: Live Graph

// screen-graph.tsx
function GraphScreen() {
  const host = useGraph()
  const style = defaultReaktorGraphStyle()

  const flowData = useMemo(() => {
    const flowGraph = buildReaktorFlowGraph(
      host.graph, style
    )
    return flowGraph.toReactFlowData()
  }, [host.graph, /* revision trigger */])

  return <ReactFlow
    nodes={flowData.nodes}
    edges={flowData.edges}
    nodeTypes={nodeTypes}
  />
}
Infinite recursion guard: The graph visualization node is itself part of the graph. The Visitor used by buildReaktorFlowGraph must skip nodes marked as introspectionExcluded, or use a depth limit. The StructuralSelector already supports this via selective traversal.

8. Dual Renderer Architecture

The graph definition is shared. Two renderers consume it independently.

8.1 Module Layout

┌─────────────────────────────────────────────────────────────────────┐ │ reaktor-graph (KMP — commonMain) │ │ Graph, Node, Port, Edge, Service, Navigation, Lifecycle, DI │ │ MemoryStore, State types, Service definitions │ └───────────────────────────┬─────────────────────────────────────────┘ │ ┌───────────────┼───────────────┐ ▼ ▼ ▼ ┌───────────────┐ ┌──────────────┐ ┌──────────────────┐ │ jsMain │ │ jvmMain │ │ iosMain/ │ │ WebHost │ │ Spring │ │ androidMain │ │ ReactNode │ │ Adapters │ │ Native adapters │ │ toReactState │ │ │ │ │ └───────┬───────┘ └──────┬───────┘ └──────────────────┘ │ │ ▼ ▼ ┌───────────────────┐ ┌───────────────────────────────────────────┐ │ reaktorWeb │ │ engine module (Compose Desktop) │ │ (React/TS) │ │ │ │ │ │ reaktor-flow ──▶ compose-flow ──▶ Compose │ │ React hooks │ │ buildReaktorFlowGraph() │ │ @xyflow/react │ │ ReaktorGraphEditor │ │ Vite + CF Workers│ │ ReaktorGraphNodeCard │ └───────────────────┘ └───────────────────────────────────────────┘

8.2 Shared Graph Definition

The workbench graph definition lives in a shared module (or in the engine module's commonMain) so both renderers consume the same topology:

// commonMain — shared graph builder
@JsExport
fun buildWorkbenchGraph(
    dependencyAdapter: DependencyAdapter? = null
): Graph {
    val graph = Graph(
        label = "workbench",
        dependencyAdapter = dependencyAdapter
    )

    // Store
    val store = MemoryStore(graph).apply {
        slot("selection", SelectionState())
        slot("layout", LayoutState())
        slot("environment", EnvironmentState())
        slot("system", SystemState())
    }
    graph.attach(store)

    // Routes
    val graphRoute = graph.Route("/graph")
    val devtoolsRoute = graph.Route("/devtools")
    val agentRoute = graph.Route("/agent")
    val uiRoute = graph.Route("/ui")
    val databaseRoute = graph.Route("/database")
    val authRoute = graph.Route("/auth")
    val aiRoute = graph.Route("/ai")
    val testingRoute = graph.Route("/testing")
    val deployRoute = graph.Route("/deploy")
    val insightsRoute = graph.Route("/insights")

    // Agent container with child graph
    val agentContainer = AgentContainer(graph, "/agent", mapOf(
        "chat" to ChildGraph(/* ... */),
        "scout" to ChildGraph(/* ... */),
        "queue" to ChildGraph(/* ... */),
        "manage" to ChildGraph(/* ... */),
        "pipeline" to ChildGraph(/* ... */),
    ))

    // Services
    val workbenchSvc = ServiceNode(graph, WorkbenchService())
    val agentSvc = ServiceNode(graph, AgentService())
    graph.attach(workbenchSvc)
    graph.attach(agentSvc)

    // Wiring
    graph.autoWire()

    // Default route
    graph.addRoot(graphRoute, Payload())

    return graph
}

8.3 Renderer-Specific Wiring

Web Renderer (React)

// main.jsx
import { buildWorkbenchGraph } from 'reaktor-kt'

const graph = buildWorkbenchGraph()

// Attach React nodes (web-specific)
attachReactScreens(graph)

// Start
const host = new WebHost(graph)
host.start()

ReactDOM.createRoot(root).render(
  <GraphProvider graph={graph}>
    <AppShell />
  </GraphProvider>
)

Desktop Renderer (Compose)

// Desktop main.kt
fun main() = application {
  val graph = buildWorkbenchGraph(
    KoinDependencyAdapter()
  )

  // Attach Compose nodes (desktop-specific)
  attachComposeScreens(graph)

  // Render
  Window(title = "Reaktor") {
    GraphApplication(graph)
  }
}
Platform split: The graph topology, state types, and service definitions are in commonMain. Only the screen rendering is platform-specific. A RouteNode("/graph") exists in both renderers — on web it's attached to a ReactNode<GraphScreen>, on desktop it's attached to a ComposeNode<GraphScreenState>. Same route, same ports, different paint.

9. Migration File Map

9.1 New Kotlin Files (commonMain — shared)

FilePurposeLines (est.)
graph/store/MemoryStore.ktIn-memory reactive store node~80
graph/store/StoreTypes.ktSelectionState, LayoutState, EnvironmentState, SystemState~100
workbench/WorkbenchGraph.ktbuildWorkbenchGraph() — shared graph definition~200
workbench/WorkbenchService.ktTyped service handlers (hot-reload, deploy, etc.)~150
workbench/AgentService.ktAgent-specific service handlers~100
workbench/WorkbenchStates.ktAgentState, DatabaseState, AuthState, etc.~120

9.2 New Kotlin Files (jsMain — web bridge)

FilePurposeLines (est.)
graph/store/MemoryStoreJs.ktJS-specific store helpers, @JsExport~40
graph/core/GraphLookupJs.ktfindNode(), findProvider() @JsExport wrappers~30
ui/WebHost.kt (modify)Add navigateToPattern(), store accessor~20 added

9.3 New TypeScript/React Files

FilePurposeLines (est.)
hooks/use-graph.tsGraphProvider + useGraph()~40
hooks/use-store.tsuseStore<T>(key) — typed store access~30
hooks/use-navigation.tsuseNavigation() — push/pop/replace~35
hooks/use-service.tsuseService(node, handler) — typed service calls~25
hooks/index.tsBarrel export~10

9.4 Modified React Files

FileChangeEffort
main.jsxImport Kotlin/JS graph, wrap in GraphProviderSmall
app.jsxReplace mode state with useNavigation(), keyboard shortcuts dispatch NavCommandSmall
scenario.jsxGut ScenarioContext — thin adapter calling useStore/useNavigationLarge
scenario-v3.jsxMove static data (AGENTS, DB_CATALOG, etc.) to Kotlin state typesLarge
workbench-domain.jsxDomain helpers now read from graph portsMedium
agent-domain.jsxAgent navigation via graph ContainerNodeMedium
screen-graph.jsxReplace hardcoded data with buildReaktorFlowGraph()Medium
shell.jsxSidebar reads from store, mode from navigationMedium
shell-v3.jsxSame as shell.jsxMedium
bottom.jsxDrawer state from storeSmall
screen-agent.jsxAgent state from AgentNode, navigation from graphLarge
screen-database.jsxDB state from DatabaseNodeMedium
screen-auth.jsxAuth state from AuthNodeSmall
screen-deploy.jsxDeploy state from DeployNodeSmall
screen-insights.jsxInsights state from storeSmall
inspector-bodies.jsxRead entity data from graph introspectionMedium
mock-services.jsDelete — replaced by WorkbenchServiceDelete

10. Sprint Plan

Sprint 1 — Foundation Core
~1 week · Kotlin + bridge layer

1.1 MemoryStore implementation

  • New file: reaktor-graph/store/MemoryStore.kt
  • Slot creation, typed get/set, StateFlow exposure
  • Batch update API
  • Unit tests: slot lifecycle, reactivity, cleanup on detach

1.2 Shared state types

  • New file: reaktor-graph/store/StoreTypes.kt (or in workbench module)
  • SelectionState, LayoutState, EnvironmentState, SystemState
  • All @Serializable, all @JsExport

1.3 JS export additions

  • Graph.findNode(label), Node.findProvider(key)
  • WebHost.navigateToPattern(pattern, params)
  • MemoryStore JS interop methods

1.4 React hooks

  • use-graph.ts — GraphProvider + useGraph()
  • use-store.ts — useStore<T>(key)
  • use-navigation.ts — useNavigation()
  • use-service.ts — useService()

Deliverable:

Graph boots in browser, WebNavigationBridge syncs URLs, MemoryStore holds state, React hooks work. No visual changes yet — the old UI still renders.

Sprint 2 — Navigation Migration Navigation
~4 days · Replace mode switching

2.1 Graph topology

  • New file: WorkbenchGraph.ktbuildWorkbenchGraph()
  • 10 RouteNodes, AgentContainer with child graph, sentinel
  • Default route: /graph

2.2 Mode switching via navigation

  • Modify app.jsx: keyboard shortcuts dispatch Push(route, Payload())
  • Mode toolbar reads host.topPattern() for active indicator
  • Remove mode state from ScenarioContext

2.3 Agent sub-navigation

  • Replace hash-based #agent/conversation/{id} with path-based /agent/chat/{id}
  • AgentDomain reads from graph backStack instead of custom hash parser

Deliverable:

All mode switching works via URL navigation. Browser back/forward works. Deep links work. Agent sub-views navigate correctly.

Sprint 3 — State Migration High Risk
~2 weeks · Strangler fig decomposition

3.1 ScenarioContext → Store adapter

  • ScenarioContext becomes a thin layer that reads from MemoryStore
  • Existing useScenario() calls continue to work during migration
  • New components use useStore() directly

3.2 Per-screen state migration (one screen at a time)

  1. GraphScreen → reads SelectionState from store
  2. Shell/TopBar → reads EnvironmentState from store
  3. Bottom drawer → reads LayoutState from store
  4. Inspector → reads SelectionState from store
  5. AgentScreen → reads from AgentNode state
  6. DatabaseScreen → reads from DatabaseNode state
  7. AuthScreen → reads from AuthNode state
  8. DeployScreen → reads from DeployNode state
  9. InsightsScreen → reads from store InsightsState
  10. All remaining screens

3.3 ScenarioContext removal

  • Once all consumers migrated, delete ScenarioContext
  • Delete scenario-v3.jsx — static data moves to Kotlin
  • Domain helpers (workbench-domain.jsx, agent-domain.jsx) read from graph

Deliverable:

No more god object. Each component subscribes only to its dependencies. ScenarioContext deleted.

Sprint 4 — Services & Self-Viz Integration
~1 week · Services + graph introspection

4.1 Service migration

  • New file: WorkbenchService.kt — typed handlers
  • New file: AgentService.kt — agent-specific handlers
  • Delete mock-services.js
  • Action dispatchers in bottom drawer use useService()

4.2 Self-referential graph visualization

  • GraphScreen calls buildReaktorFlowGraph(host.graph, style)
  • toReactFlowData() feeds @xyflow/react
  • Remove hardcoded ENTITIES/EDGES/REGIONS from scenario.jsx
  • Add introspection exclusion for the graph screen node itself

4.3 Inspector from live graph

  • Inspector reads node details from graph introspection (ports, edges, lifecycle state)
  • Service metrics from ServiceNode handler invocation counts

Deliverable:

The app visualizes itself. Services are typed and wired. Inspector shows live graph data.

Sprint 5 — Dual Renderer & Polish Ship
~1 week · Desktop parity + deploy

5.1 Engine module alignment

  • Engine module consumes buildWorkbenchGraph()
  • Compose screens attach to same RouteNodes
  • Verify both renderers show equivalent graph topology

5.2 Kotlin/JS bundle optimization

  • Measure bundle size impact
  • Tree-shaking: ensure unused Kotlin code is eliminated
  • Lazy loading: split graph construction from UI code

5.3 Cloudflare Workers deployment

  • Verify SPA fallback works with new URL patterns
  • Test deep links: reaktor.build/agent/chat/abc
  • Deploy to staging, smoke test all 10 modes

5.4 Cleanup

  • Remove all vestiges of old state management
  • Update documentation
  • Run full test suite (desktop Maestro tests + web)

Deliverable:

Production deployment. Both renderers work. The app is a reaktor-graph application.

11. Risk Matrix

RiskSeverityLikelihoodMitigation
StateFlow→React performance
40+ flows triggering React re-renders
High Medium Per-slot subscriptions (not one big flow). Profile early. useMemo on derived values. The decomposition itself is the fix — 40 small flows > 1 big context.
Kotlin/JS bundle size
Adding reaktor-graph to the web bundle
Medium Medium Measure after Sprint 1. Kotlin/JS tree-shaking is decent. Worst case: lazy-load the Kotlin bundle. Current bundle is ~200KB gzipped for bestbuds-kt.
Strangler fig regression
Half-migrated state causes bugs during Sprint 3
Medium High ScenarioContext adapter reads from store — both systems stay in sync during migration. Migrate one screen at a time. Visual regression testing with screenshots.
Self-referential infinite loop
Graph viz node triggers graph change → re-viz
Medium Low Exclude introspection nodes from Visitor. Memoize buildReaktorFlowGraph() result. Only rebuild on structural graph changes (node attach/detach), not state changes.
TypeScript type drift
Kotlin types and TS types diverge
Low Medium Karakum generates TS types from Kotlin. CI check: rebuild types on every Kotlin change. The hook layer provides type safety at the boundary.
WebNavigationBridge edge cases
10+ routes with params, deep links, back/forward
Low Low Bridge is already proven. Agent sub-routes are the only complex case — test with Playwright after Sprint 2.
Cloudflare Worker compatibility
Kotlin/JS runtime in Worker context
Low Low Kotlin/JS is client-side only. Worker just serves static assets. No Kotlin runs in the Worker.
Critical path: Sprint 3 (State Migration) is the highest-risk phase. The strangler fig pattern — ScenarioContext reads from MemoryStore while components gradually switch to direct store access — must be carefully orchestrated. Migrate one screen at a time and verify after each. The temptation to do a big-bang migration must be resisted.

Summary

New Kotlin
~750
lines (commonMain + jsMain)
New TypeScript
~140
lines (React hooks)
Modified JSX
~2,500
lines across 15+ files
Deleted
~1,500
lines (scenario, mock-services)
Duration
~5 wk
5 sprints

The migration transforms reaktorWeb from a standalone React app with hardcoded mock data into a polyglot reaktor-graph application with:

This is the highest-value stress test for reaktor-graph. If the framework can power a 14K-line workbench with 10 modes, nested navigation, cross-cutting state, services, and self-visualization — it can power anything.