1. Situation & Goals
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:
WebHost— entry point wrapping a Graph withWebNavigationBridgeStateFlow.toReactState()— bidirectional Kotlin StateFlow ↔ React useState bridgeReactNode<State>— Kotlin wrapper making React components act as graph nodesReactContainer/ReactContent— interfaces for graph-driven renderingWebBottomNavigationContainer/WebTabbedContainer— container implementationsGraphContentComponent— FC that renders based on backStack top entry@JsExportthroughout, karakum TypeScript type generation
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 Class | JS Usage | Status |
|---|---|---|
WebHost | Entry point: new WebHost(graph) | Ready |
WebNavigationBridge | backStack ↔ browser history sync | Ready |
StateFlow.toReactState() | Bidirectional StateFlow → React useState | Ready |
ReactNode<State> | Graph node wrapping a React component | Ready |
ReactContainer | Container rendering child graphs | Ready |
GraphContentComponent | Renders backStack top entry | Ready |
WebBottomNavigationContainer | Tab bar container | Ready |
Graph.toJsonElement() | JSON serialization of graph structure | Ready |
ServiceNode | Service with typed handlers | Ready |
JsRequestHandler | Promise-based handler bridge | Ready |
2.2 TypeScript Interop Architecture
The polyglot boundary has three layers:
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:
| Export | Purpose | Location |
|---|---|---|
Graph.findNode(label) | Lookup node by label for port access | Graph.kt |
Node.findProvider(key) | Lookup provider port by key string | Node.kt |
Node.findConsumer(key) | Lookup consumer port by key string | Node.kt |
WebHost.navigateToPattern(pattern, params) | URL-based navigation from TS | WebHost.kt |
MemoryStore (new) | In-memory reactive store | New file |
Graph.toJsonElement() | Already exists, verify @JsExport | GraphJson.kt |
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
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
| Layer | Purpose | Lifetime | Access 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
4.3 URL Mapping
| Old (hash) | New (path) | Keyboard |
|---|---|---|
#graph (or just mode state) | /graph | Cmd+1 |
#devtools | /devtools | Cmd+2 |
#agent | /agent/chat | Cmd+3 |
#agent/conversation/abc | /agent/chat/abc | — |
#agent/scout/xyz | /agent/scout/xyz | — |
#ui | /ui | Cmd+4 |
#database | /database | Cmd+5 |
#auth | /auth | Cmd+6 |
#ai | /ai | Cmd+7 |
#testing | /testing | Cmd+8 |
#deploy | /deploy | Cmd+9 |
#insights | /insights | Cmd+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 })
}
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
7.2 What Gets Visualized
| Graph Element | Visual Representation | Kind |
|---|---|---|
| RouteNode("/graph") | Blue "Route" card | Route |
| ReactNode(GraphScreen) | Green "Screen" card | Screen |
| AgentContainer | Orange "Container" card with child graph | Container |
| WorkbenchService | Orange "Service" card with handler ports | Service |
| MemoryStore | Yellow "Node" card with slot ports | Node |
| Port edges (consumer→provider) | Data edges with labels | Data |
| Navigation edges (route→route) | Blue navigation arrows | Navigation |
| Child graph regions | Colored background overlays | Region |
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}
/>
}
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
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)
}
}
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)
| File | Purpose | Lines (est.) |
|---|---|---|
graph/store/MemoryStore.kt | In-memory reactive store node | ~80 |
graph/store/StoreTypes.kt | SelectionState, LayoutState, EnvironmentState, SystemState | ~100 |
workbench/WorkbenchGraph.kt | buildWorkbenchGraph() — shared graph definition | ~200 |
workbench/WorkbenchService.kt | Typed service handlers (hot-reload, deploy, etc.) | ~150 |
workbench/AgentService.kt | Agent-specific service handlers | ~100 |
workbench/WorkbenchStates.kt | AgentState, DatabaseState, AuthState, etc. | ~120 |
9.2 New Kotlin Files (jsMain — web bridge)
| File | Purpose | Lines (est.) |
|---|---|---|
graph/store/MemoryStoreJs.kt | JS-specific store helpers, @JsExport | ~40 |
graph/core/GraphLookupJs.kt | findNode(), findProvider() @JsExport wrappers | ~30 |
ui/WebHost.kt (modify) | Add navigateToPattern(), store accessor | ~20 added |
9.3 New TypeScript/React Files
| File | Purpose | Lines (est.) |
|---|---|---|
hooks/use-graph.ts | GraphProvider + useGraph() | ~40 |
hooks/use-store.ts | useStore<T>(key) — typed store access | ~30 |
hooks/use-navigation.ts | useNavigation() — push/pop/replace | ~35 |
hooks/use-service.ts | useService(node, handler) — typed service calls | ~25 |
hooks/index.ts | Barrel export | ~10 |
9.4 Modified React Files
| File | Change | Effort |
|---|---|---|
main.jsx | Import Kotlin/JS graph, wrap in GraphProvider | Small |
app.jsx | Replace mode state with useNavigation(), keyboard shortcuts dispatch NavCommand | Small |
scenario.jsx | Gut ScenarioContext — thin adapter calling useStore/useNavigation | Large |
scenario-v3.jsx | Move static data (AGENTS, DB_CATALOG, etc.) to Kotlin state types | Large |
workbench-domain.jsx | Domain helpers now read from graph ports | Medium |
agent-domain.jsx | Agent navigation via graph ContainerNode | Medium |
screen-graph.jsx | Replace hardcoded data with buildReaktorFlowGraph() | Medium |
shell.jsx | Sidebar reads from store, mode from navigation | Medium |
shell-v3.jsx | Same as shell.jsx | Medium |
bottom.jsx | Drawer state from store | Small |
screen-agent.jsx | Agent state from AgentNode, navigation from graph | Large |
screen-database.jsx | DB state from DatabaseNode | Medium |
screen-auth.jsx | Auth state from AuthNode | Small |
screen-deploy.jsx | Deploy state from DeployNode | Small |
screen-insights.jsx | Insights state from store | Small |
inspector-bodies.jsx | Read entity data from graph introspection | Medium |
mock-services.js | Delete — replaced by WorkbenchService | Delete |
10. Sprint Plan
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)MemoryStoreJS 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.
2.1 Graph topology
- New file:
WorkbenchGraph.kt—buildWorkbenchGraph() - 10 RouteNodes, AgentContainer with child graph, sentinel
- Default route:
/graph
2.2 Mode switching via navigation
- Modify
app.jsx: keyboard shortcuts dispatchPush(route, Payload()) - Mode toolbar reads
host.topPattern()for active indicator - Remove
modestate 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.
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)
- GraphScreen → reads
SelectionStatefrom store - Shell/TopBar → reads
EnvironmentStatefrom store - Bottom drawer → reads
LayoutStatefrom store - Inspector → reads
SelectionStatefrom store - AgentScreen → reads from
AgentNodestate - DatabaseScreen → reads from
DatabaseNodestate - AuthScreen → reads from
AuthNodestate - DeployScreen → reads from
DeployNodestate - InsightsScreen → reads from store
InsightsState - 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.
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.
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
| Risk | Severity | Likelihood | Mitigation |
|---|---|---|---|
| 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. |
Summary
The migration transforms reaktorWeb from a standalone React app with hardcoded mock data into a polyglot reaktor-graph application with:
- Typed graph model shared between desktop and web
- Reactive in-memory store built on ports and StateFlow
- URL-based navigation synced with browser history
- Typed services replacing mock timeouts
- Self-referential visualization — the app is its own graph
- Better performance — per-slot subscriptions replace god-object context
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.