ROADMAP.md

This commit is contained in:
2026-01-10 01:43:22 +00:00
parent 606f2b40e4
commit ae11af4267
29 changed files with 820 additions and 195 deletions

View File

@@ -302,6 +302,7 @@ set(WORKFLOW_SOURCES
set(FRAME_WORKFLOW_SOURCES
src/services/impl/workflow_frame_begin_step.cpp
src/services/impl/workflow_frame_bullet_physics_step.cpp
src/services/impl/workflow_frame_physics_step.cpp
src/services/impl/workflow_frame_scene_step.cpp
src/services/impl/workflow_frame_render_step.cpp

View File

@@ -2,44 +2,192 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "SDL3CPP Workflow v1",
"type": "object",
"properties": {
"template": {
"type": "string"
"oneOf": [
{
"$ref": "#/definitions/legacyWorkflow"
},
"steps": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"plugin"
],
"properties": {
"id": {
{
"$ref": "#/definitions/n8nWorkflow"
}
],
"definitions": {
"legacyWorkflow": {
"type": "object",
"properties": {
"template": {
"type": "string"
},
"steps": {
"type": "array",
"items": {
"$ref": "#/definitions/legacyStep"
}
}
},
"required": [
"steps"
],
"additionalProperties": false
},
"legacyStep": {
"type": "object",
"required": [
"id",
"plugin"
],
"properties": {
"id": {
"type": "string"
},
"plugin": {
"type": "string"
},
"inputs": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"plugin": {
}
},
"outputs": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"additionalProperties": false
},
"n8nWorkflow": {
"type": "object",
"properties": {
"template": {
"type": "string"
},
"nodes": {
"type": "array",
"items": {
"$ref": "#/definitions/n8nNode"
}
},
"connections": {
"$ref": "#/definitions/n8nConnections"
}
},
"required": [
"nodes"
],
"additionalProperties": false
},
"n8nNode": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"plugin": {
"type": "string"
},
"type": {
"type": "string"
},
"typeVersion": {
"type": "integer"
},
"position": {
"type": "array",
"items": {
"type": "number"
},
"inputs": {
"type": "object",
"additionalProperties": {
"type": "string"
"minItems": 2,
"maxItems": 2
},
"parameters": {
"type": "object",
"additionalProperties": true
},
"inputs": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"outputs": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"allOf": [
{
"anyOf": [
{
"required": [
"id"
]
},
{
"required": [
"name"
]
}
},
"outputs": {
"type": "object",
"additionalProperties": {
"type": "string"
]
},
{
"anyOf": [
{
"required": [
"plugin"
]
},
{
"required": [
"type"
]
}
]
}
],
"additionalProperties": true
},
"n8nConnections": {
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"main": {
"type": "array",
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/n8nConnectionEntry"
}
}
}
},
"additionalProperties": false
"additionalProperties": true
}
},
"n8nConnectionEntry": {
"type": "object",
"properties": {
"node": {
"type": "string"
},
"type": {
"type": "string"
},
"index": {
"type": "integer"
}
},
"required": [
"node"
],
"additionalProperties": true
}
},
"required": [
"steps"
],
"additionalProperties": false
}
}

View File

@@ -1,9 +1,10 @@
{
"template": "boot.default",
"steps": [
"nodes": [
{
"id": "load_config",
"plugin": "config.load",
"position": [0, 0],
"inputs": {
"path": "config.path"
},
@@ -14,6 +15,7 @@
{
"id": "validate_version",
"plugin": "config.version.validate",
"position": [260, 0],
"inputs": {
"document": "config.document",
"path": "config.path"
@@ -25,6 +27,7 @@
{
"id": "migrate_version",
"plugin": "config.migrate",
"position": [520, 0],
"inputs": {
"document": "config.document",
"path": "config.path",
@@ -38,6 +41,7 @@
{
"id": "validate_schema",
"plugin": "config.schema.validate",
"position": [780, 0],
"inputs": {
"document": "config.document",
"path": "config.path"
@@ -46,6 +50,7 @@
{
"id": "build_runtime_config",
"plugin": "runtime.config.build",
"position": [1040, 0],
"inputs": {
"document": "config.document",
"path": "config.path"
@@ -54,5 +59,35 @@
"runtime": "config.runtime"
}
}
]
],
"connections": {
"load_config": {
"main": [
[
{ "node": "validate_version", "type": "main", "index": 0 }
]
]
},
"validate_version": {
"main": [
[
{ "node": "migrate_version", "type": "main", "index": 0 }
]
]
},
"migrate_version": {
"main": [
[
{ "node": "validate_schema", "type": "main", "index": 0 }
]
]
},
"validate_schema": {
"main": [
[
{ "node": "build_runtime_config", "type": "main", "index": 0 }
]
]
}
}
}

View File

@@ -1,9 +1,10 @@
{
"template": "frame.default",
"steps": [
"nodes": [
{
"id": "begin_frame",
"plugin": "frame.begin",
"position": [0, 0],
"inputs": {
"delta": "frame.delta",
"elapsed": "frame.elapsed"
@@ -12,6 +13,7 @@
{
"id": "step_physics",
"plugin": "frame.physics",
"position": [260, 0],
"inputs": {
"delta": "frame.delta"
}
@@ -19,6 +21,7 @@
{
"id": "update_scene",
"plugin": "frame.scene",
"position": [520, 0],
"inputs": {
"delta": "frame.delta"
}
@@ -26,17 +29,51 @@
{
"id": "render_frame",
"plugin": "frame.render",
"position": [780, 0],
"inputs": {
"elapsed": "frame.elapsed"
}
},
{
"id": "update_audio",
"plugin": "frame.audio"
"plugin": "frame.audio",
"position": [1040, -120]
},
{
"id": "dispatch_gui",
"plugin": "frame.gui"
"plugin": "frame.gui",
"position": [1040, 120]
}
]
],
"connections": {
"begin_frame": {
"main": [
[
{ "node": "step_physics", "type": "main", "index": 0 }
]
]
},
"step_physics": {
"main": [
[
{ "node": "update_scene", "type": "main", "index": 0 }
]
]
},
"update_scene": {
"main": [
[
{ "node": "render_frame", "type": "main", "index": 0 }
]
]
},
"render_frame": {
"main": [
[
{ "node": "update_audio", "type": "main", "index": 0 },
{ "node": "dispatch_gui", "type": "main", "index": 0 }
]
]
}
}
}

View File

@@ -0,0 +1,34 @@
{
"template": "n8n.skeleton",
"nodes": [
{
"id": "load_config",
"plugin": "config.load",
"position": [0, 0],
"inputs": {
"path": "config.path"
},
"outputs": {
"document": "config.document"
}
},
{
"id": "validate_schema",
"plugin": "config.schema.validate",
"position": [260, 0],
"inputs": {
"document": "config.document",
"path": "config.path"
}
}
],
"connections": {
"load_config": {
"main": [
[
{ "node": "validate_schema", "type": "main", "index": 0 }
]
]
}
}
}

31
docs/N8N_WORKFLOWS.md Normal file
View File

@@ -0,0 +1,31 @@
N8N-Style Workflows
Overview
- Workflows can be declared with `nodes` and `connections`, modeled after n8n.
- Each node maps to a C++ workflow plugin (via `plugin` or `type`).
- Connections define execution order. When connections are omitted, the node array order is used.
Node Shape
- `id` or `name`: required. Used to identify the node in `connections`.
- `plugin` or `type`: required. The plugin string must match a registered step.
- `inputs` / `outputs`: optional string maps for workflow context keys.
- `position`: optional `[x, y]` array for layout parity with n8n.
Connections Shape
```json
{
"connections": {
"load_config": {
"main": [
[
{ "node": "validate_schema", "type": "main", "index": 0 }
]
]
}
}
}
```
Templates
- `config/workflows/templates/boot_default.json` and `config/workflows/templates/frame_default.json` are n8n-style now.
- `config/workflows/templates/n8n_skeleton.json` is a minimal starting point.

View File

@@ -4,8 +4,7 @@
"description": "Validation tour package with teleport checkpoints, captures, and diagnostics.",
"defaultWorkflow": "workflows/validation_tour.json",
"workflows": [
"workflows/validation_tour.json",
"workflows/stubs/validation_probe_placeholder.json"
"workflows/validation_tour.json"
],
"assets": [
"assets/validation_checks.json"

View File

@@ -1,9 +0,0 @@
{
"id": "validation.tour.checkpoint",
"summary": "Validation probe step stub used to reserve workflow hooks before full diagnostics land.",
"stage": "planned",
"notes": [
"Should sample engine framebuffers, analyze capture stats, and report mismatches.",
"Currently registered as part of packages so workflows referencing checkpoints keep parsing."
]
}

View File

@@ -1,9 +1,10 @@
{
"template": "boot.default",
"steps": [
"nodes": [
{
"id": "load_config",
"plugin": "config.load",
"position": [0, 0],
"inputs": {
"path": "config.path"
},
@@ -14,6 +15,7 @@
{
"id": "validate_schema",
"plugin": "config.schema.validate",
"position": [260, 0],
"inputs": {
"document": "config.document",
"path": "config.path"
@@ -22,6 +24,7 @@
{
"id": "build_runtime",
"plugin": "runtime.config.build",
"position": [520, 0],
"inputs": {
"document": "config.document",
"path": "config.path"
@@ -33,9 +36,33 @@
{
"id": "validation_probe",
"plugin": "validation.tour.checkpoint",
"position": [780, 0],
"inputs": {
"checkpoint": "packages.engine_tester"
}
}
]
],
"connections": {
"load_config": {
"main": [
[
{ "node": "validate_schema", "type": "main", "index": 0 }
]
]
},
"validate_schema": {
"main": [
[
{ "node": "build_runtime", "type": "main", "index": 0 }
]
]
},
"build_runtime": {
"main": [
[
{ "node": "validation_probe", "type": "main", "index": 0 }
]
]
}
}
}

View File

@@ -4,8 +4,7 @@
"description": "Workflow package describing the GUI demo, focused on UI updates + frame capture validation.",
"defaultWorkflow": "workflows/gui_frame.json",
"workflows": [
"workflows/gui_frame.json",
"workflows/stubs/gui_layout_placeholder.json"
"workflows/gui_frame.json"
],
"assets": [
"assets/gui_widgets.json",

View File

@@ -1,9 +1,10 @@
{
"template": "frame.default",
"steps": [
"nodes": [
{
"id": "gui_begin",
"plugin": "frame.begin",
"position": [0, 0],
"inputs": {
"delta": "frame.delta",
"elapsed": "frame.elapsed"
@@ -12,6 +13,7 @@
{
"id": "gui_layout",
"plugin": "frame.gui",
"position": [260, 0],
"inputs": {
"elapsed": "frame.elapsed"
}
@@ -19,6 +21,7 @@
{
"id": "render_ui",
"plugin": "frame.render",
"position": [520, 0],
"inputs": {
"elapsed": "frame.elapsed"
}
@@ -26,9 +29,33 @@
{
"id": "capture_ui",
"plugin": "validation.tour.checkpoint",
"position": [780, 0],
"inputs": {
"checkpoint": "packages.gui_demo"
}
}
]
],
"connections": {
"gui_begin": {
"main": [
[
{ "node": "gui_layout", "type": "main", "index": 0 }
]
]
},
"gui_layout": {
"main": [
[
{ "node": "render_ui", "type": "main", "index": 0 }
]
]
},
"render_ui": {
"main": [
[
{ "node": "capture_ui", "type": "main", "index": 0 }
]
]
}
}
}

View File

@@ -1,9 +0,0 @@
{
"id": "frame.gui",
"summary": "Placeholder for GUI layout + input processing steps that are still being refactored.",
"stage": "planned",
"notes": [
"Should resolve UI command bundles and update ImGui state.",
"Currently registered so workflows can declare GUI dependencies without crashing."
]
}

View File

@@ -4,8 +4,7 @@
"description": "Quake3-style example package bundling physics, scene, and map metadata.",
"defaultWorkflow": "workflows/quake3_frame.json",
"workflows": [
"workflows/quake3_frame.json",
"workflows/stubs/quake3_physics_placeholder.json"
"workflows/quake3_frame.json"
],
"assets": [
"assets/quake3_materials.json"

View File

@@ -1,9 +1,10 @@
{
"template": "frame.default",
"steps": [
"nodes": [
{
"id": "quake_begin",
"plugin": "frame.begin",
"position": [0, 0],
"inputs": {
"delta": "frame.delta"
}
@@ -11,6 +12,7 @@
{
"id": "quake_physics",
"plugin": "frame.bullet_physics",
"position": [260, 0],
"inputs": {
"delta": "frame.delta"
}
@@ -18,6 +20,7 @@
{
"id": "quake_scene",
"plugin": "frame.scene",
"position": [520, 0],
"inputs": {
"delta": "frame.delta"
}
@@ -25,6 +28,7 @@
{
"id": "quake_render",
"plugin": "frame.render",
"position": [780, 0],
"inputs": {
"elapsed": "frame.elapsed"
}
@@ -32,9 +36,40 @@
{
"id": "quake_validation",
"plugin": "validation.tour.checkpoint",
"position": [1040, 0],
"inputs": {
"checkpoint": "packages.quake3_map"
}
}
]
],
"connections": {
"quake_begin": {
"main": [
[
{ "node": "quake_physics", "type": "main", "index": 0 }
]
]
},
"quake_physics": {
"main": [
[
{ "node": "quake_scene", "type": "main", "index": 0 }
]
]
},
"quake_scene": {
"main": [
[
{ "node": "quake_render", "type": "main", "index": 0 }
]
]
},
"quake_render": {
"main": [
[
{ "node": "quake_validation", "type": "main", "index": 0 }
]
]
}
}
}

View File

@@ -1,9 +0,0 @@
{
"id": "frame.bullet_physics",
"summary": "Quake3 physics stub to ensure workflows can declare bullet-dependent steps.",
"stage": "planned",
"notes": [
"Should load BSP geometry, apply gravity, and resolve collisions.",
"Widgets will be active once the physics service is refactored accordingly."
]
}

View File

@@ -4,8 +4,7 @@
"description": "Template package describing a boot-to-frame workflow, assets, and validation presets for the demo cube.",
"defaultWorkflow": "workflows/demo_gameplay.json",
"workflows": [
"workflows/demo_gameplay.json",
"workflows/stubs/bullet_physics_placeholder.json"
"workflows/demo_gameplay.json"
],
"assets": [
"assets/cube_materials.json"

View File

@@ -1,9 +1,10 @@
{
"template": "frame.default",
"steps": [
"nodes": [
{
"id": "begin_frame",
"plugin": "frame.begin",
"position": [0, 0],
"inputs": {
"delta": "frame.delta",
"elapsed": "frame.elapsed"
@@ -12,6 +13,7 @@
{
"id": "camera_control",
"plugin": "frame.camera",
"position": [260, 0],
"inputs": {
"delta": "frame.delta"
}
@@ -19,6 +21,7 @@
{
"id": "bullet_physics",
"plugin": "frame.bullet_physics",
"position": [520, 0],
"inputs": {
"delta": "frame.delta"
}
@@ -26,6 +29,7 @@
{
"id": "scene",
"plugin": "frame.scene",
"position": [780, 0],
"inputs": {
"delta": "frame.delta"
}
@@ -33,6 +37,7 @@
{
"id": "render",
"plugin": "frame.render",
"position": [1040, 0],
"inputs": {
"elapsed": "frame.elapsed"
}
@@ -40,9 +45,47 @@
{
"id": "validate_capture",
"plugin": "validation.tour.checkpoint",
"position": [1300, 0],
"inputs": {
"checkpoint": "gameplay.startup_camera"
}
}
]
],
"connections": {
"begin_frame": {
"main": [
[
{ "node": "camera_control", "type": "main", "index": 0 }
]
]
},
"camera_control": {
"main": [
[
{ "node": "bullet_physics", "type": "main", "index": 0 }
]
]
},
"bullet_physics": {
"main": [
[
{ "node": "scene", "type": "main", "index": 0 }
]
]
},
"scene": {
"main": [
[
{ "node": "render", "type": "main", "index": 0 }
]
]
},
"render": {
"main": [
[
{ "node": "validate_capture", "type": "main", "index": 0 }
]
]
}
}
}

View File

@@ -1,10 +0,0 @@
{
"id": "frame.bullet_physics",
"summary": "Stub for bullet physics integration; wiring exists but needs a concrete implementation before it becomes active.",
"stage": "planned",
"notes": [
"Dependencies: physics service + rigid body registry",
"Placeholder step ensures the workflow registry can parse missing plugins without crashing.",
"Once implemented, this step should apply gravity + constraints before the scene update step."
]
}

View File

@@ -4,11 +4,7 @@
"description": "Workflow template for the soundboard experience (audio cues + GUI controls).",
"defaultWorkflow": "workflows/soundboard_flow.json",
"workflows": [
"workflows/soundboard_flow.json",
"workflows/stubs/soundboard_catalog_scan_stub.json",
"workflows/stubs/soundboard_gui_stub.json",
"workflows/stubs/soundboard_audio_stub.json",
"workflows/stubs/audio_visualizer_placeholder.json"
"workflows/soundboard_flow.json"
],
"assets": [
"assets/sound/sound_samples.json",

View File

@@ -1,9 +1,10 @@
{
"template": "frame.default",
"steps": [
"nodes": [
{
"id": "begin_frame",
"plugin": "frame.begin",
"position": [0, 0],
"inputs": {
"delta": "frame.delta",
"elapsed": "frame.elapsed"
@@ -12,6 +13,7 @@
{
"id": "catalog_scan",
"plugin": "soundboard.catalog.scan",
"position": [260, -120],
"outputs": {
"catalog": "soundboard.catalog"
}
@@ -19,6 +21,7 @@
{
"id": "gui_render",
"plugin": "soundboard.gui",
"position": [520, -120],
"inputs": {
"catalog": "soundboard.catalog",
"layout": "soundboard.layout"
@@ -30,6 +33,7 @@
{
"id": "audio_dispatch",
"plugin": "soundboard.audio",
"position": [780, -120],
"inputs": {
"catalog": "soundboard.catalog",
"selection": "soundboard.selection"
@@ -41,6 +45,7 @@
{
"id": "render_frame",
"plugin": "frame.render",
"position": [520, 120],
"inputs": {
"elapsed": "frame.elapsed"
}
@@ -48,9 +53,48 @@
{
"id": "validation_capture",
"plugin": "validation.tour.checkpoint",
"position": [780, 120],
"inputs": {
"checkpoint": "packages.soundboard"
}
}
]
],
"connections": {
"begin_frame": {
"main": [
[
{ "node": "catalog_scan", "type": "main", "index": 0 },
{ "node": "render_frame", "type": "main", "index": 0 }
]
]
},
"catalog_scan": {
"main": [
[
{ "node": "gui_render", "type": "main", "index": 0 }
]
]
},
"gui_render": {
"main": [
[
{ "node": "audio_dispatch", "type": "main", "index": 0 }
]
]
},
"audio_dispatch": {
"main": [
[
{ "node": "validation_capture", "type": "main", "index": 0 }
]
]
},
"render_frame": {
"main": [
[
{ "node": "validation_capture", "type": "main", "index": 0 }
]
]
}
}
}

View File

@@ -1,9 +0,0 @@
{
"id": "frame.audio",
"summary": "Audio dispatch stub for the soundboard; actual cue scheduling + mixing service is pending.",
"stage": "planned",
"notes": [
"Should queue audio commands and report current sample index to UI.",
"Registered so workflows can mention the audio step without requiring implementation."
]
}

View File

@@ -1,28 +0,0 @@
{
"id": "soundboard.audio",
"summary": "Plays clips requested by the GUI and reports playback status.",
"stage": "planned",
"inputs": [
{
"name": "catalog",
"type": "soundboard.catalog",
"description": "Catalog data to resolve clip paths."
},
{
"name": "selection",
"type": "soundboard.selection",
"description": "Selected clip identifier emitted by the GUI step."
}
],
"outputs": [
{
"name": "status",
"type": "soundboard.status",
"description": "Playback status message (playing/failed/missing)."
}
],
"notes": [
"Should interact with the runtime audio command pipeline (`audio_play_sound` equivalent) to queue clips.",
"Status outputs let the GUI display feedback (success, failure, no clip available)."
]
}

View File

@@ -1,23 +0,0 @@
{
"id": "soundboard.catalog.scan",
"summary": "Scans `assets/audio_catalog.json` plus audio folders to publish clip metadata.",
"stage": "planned",
"inputs": [
{
"name": "catalog_descriptor",
"type": "json_path",
"description": "Path to `assets/audio_catalog.json` describing categories + relative folders."
}
],
"outputs": [
{
"name": "catalog",
"type": "soundboard.catalog",
"description": "Structured catalog with categories, clip names, absolute/audio-relative paths."
}
],
"notes": [
"This step should read the JSON descriptor, scan the referenced folders (`assets/audio/sfx`, `assets/audio/tts`), and sort clip names alphabetically per category.",
"The output catalog should include the category ID, display name, and clip list (filename + label + path)."
]
}

View File

@@ -1,28 +0,0 @@
{
"id": "soundboard.gui",
"summary": "Renders buttons for every clip in the catalog and emits selection events.",
"stage": "planned",
"inputs": [
{
"name": "catalog",
"type": "soundboard.catalog",
"description": "Catalog produced by `soundboard.catalog.scan` (categories + clip names/paths)."
},
{
"name": "layout",
"type": "json_path",
"description": "GUI layout definition from `assets/soundboard_gui.json`."
}
],
"outputs": [
{
"name": "selection",
"type": "soundboard.selection",
"description": "Clip selection event including category, clip id, and display label."
}
],
"notes": [
"Should render a panel with column tabs per category, draw buttons for each clip, and feed button presses into the audio dispatcher.",
"Should also publish status messages (e.g., playback started/failed) for preview."
]
}

View File

@@ -2,6 +2,7 @@
#include "workflow_frame_audio_step.hpp"
#include "workflow_frame_begin_step.hpp"
#include "workflow_frame_bullet_physics_step.hpp"
#include "workflow_frame_gui_step.hpp"
#include "workflow_frame_physics_step.hpp"
#include "workflow_frame_render_step.hpp"
@@ -43,6 +44,9 @@ void FrameWorkflowStepRegistrar::RegisterUsedSteps(
if (plugins.contains("frame.physics")) {
registry->RegisterStep(std::make_shared<WorkflowFramePhysicsStep>(physicsService_, logger_));
}
if (plugins.contains("frame.bullet_physics")) {
registry->RegisterStep(std::make_shared<WorkflowFrameBulletPhysicsStep>(physicsService_, logger_));
}
if (plugins.contains("frame.scene")) {
registry->RegisterStep(std::make_shared<WorkflowFrameSceneStep>(sceneService_, logger_));
}

View File

@@ -4,6 +4,7 @@
#include <rapidjson/document.h>
#include <stdexcept>
#include <set>
#include <string>
#include <unordered_map>
#include <utility>
@@ -36,34 +37,197 @@ std::unordered_map<std::string, std::string> ReadStringMap(const rapidjson::Valu
}
return result;
}
std::string ReadNodeId(const rapidjson::Value& node, size_t index) {
if (node.HasMember("id") && node["id"].IsString()) {
return node["id"].GetString();
}
if (node.HasMember("name") && node["name"].IsString()) {
return node["name"].GetString();
}
throw std::runtime_error("Workflow node[" + std::to_string(index) + "] requires string id or name");
}
std::string ReadNodePlugin(const rapidjson::Value& node, const std::string& nodeId) {
if (node.HasMember("plugin") && node["plugin"].IsString()) {
return node["plugin"].GetString();
}
if (node.HasMember("type") && node["type"].IsString()) {
return node["type"].GetString();
}
throw std::runtime_error("Workflow node '" + nodeId + "' requires string plugin or type");
}
std::vector<std::pair<std::string, std::string>> ReadConnections(const rapidjson::Value& document) {
if (!document.HasMember("connections")) {
return {};
}
const auto& connectionsValue = document["connections"];
if (!connectionsValue.IsObject()) {
throw std::runtime_error("Workflow member 'connections' must be an object");
}
std::vector<std::pair<std::string, std::string>> edges;
for (auto it = connectionsValue.MemberBegin(); it != connectionsValue.MemberEnd(); ++it) {
const std::string fromNode = it->name.GetString();
if (!it->value.IsObject()) {
throw std::runtime_error("Workflow connections for '" + fromNode + "' must be an object");
}
if (!it->value.HasMember("main")) {
continue;
}
const auto& mainValue = it->value["main"];
if (!mainValue.IsArray()) {
throw std::runtime_error("Workflow connections.main for '" + fromNode + "' must be an array");
}
for (const auto& branch : mainValue.GetArray()) {
if (!branch.IsArray()) {
throw std::runtime_error("Workflow connections.main entries for '" + fromNode + "' must be arrays");
}
for (const auto& connection : branch.GetArray()) {
if (!connection.IsObject() || !connection.HasMember("node") || !connection["node"].IsString()) {
throw std::runtime_error("Workflow connection entries for '" + fromNode + "' require a node string");
}
edges.emplace_back(fromNode, connection["node"].GetString());
}
}
}
return edges;
}
std::vector<std::string> SortNodesByConnections(
const std::vector<std::string>& nodeOrder,
const std::vector<std::pair<std::string, std::string>>& edges) {
std::unordered_map<std::string, size_t> indexById;
std::unordered_map<std::string, size_t> indegree;
std::unordered_map<std::string, std::vector<std::string>> adjacency;
indexById.reserve(nodeOrder.size());
indegree.reserve(nodeOrder.size());
adjacency.reserve(nodeOrder.size());
for (size_t i = 0; i < nodeOrder.size(); ++i) {
indexById.emplace(nodeOrder[i], i);
indegree.emplace(nodeOrder[i], 0);
adjacency.emplace(nodeOrder[i], std::vector<std::string>{});
}
for (const auto& edge : edges) {
const auto fromIt = indexById.find(edge.first);
if (fromIt == indexById.end()) {
throw std::runtime_error("Workflow connection references unknown node '" + edge.first + "'");
}
const auto toIt = indexById.find(edge.second);
if (toIt == indexById.end()) {
throw std::runtime_error("Workflow connection references unknown node '" + edge.second + "'");
}
adjacency[edge.first].push_back(edge.second);
++indegree[edge.second];
}
std::set<std::pair<size_t, std::string>> ready;
for (const auto& nodeId : nodeOrder) {
if (indegree[nodeId] == 0u) {
ready.emplace(indexById[nodeId], nodeId);
}
}
std::vector<std::string> ordered;
ordered.reserve(nodeOrder.size());
while (!ready.empty()) {
auto it = ready.begin();
const std::string nodeId = it->second;
ready.erase(it);
ordered.push_back(nodeId);
for (const auto& next : adjacency[nodeId]) {
auto indegreeIt = indegree.find(next);
if (indegreeIt == indegree.end()) {
continue;
}
if (--indegreeIt->second == 0u) {
ready.emplace(indexById[next], next);
}
}
}
if (ordered.size() != nodeOrder.size()) {
throw std::runtime_error("Workflow connections contain a cycle");
}
return ordered;
}
} // namespace
WorkflowDefinition WorkflowDefinitionParser::ParseFile(const std::filesystem::path& path) const {
json_config::JsonConfigDocumentParser parser;
rapidjson::Document document = parser.Parse(path, "workflow file");
if (!document.HasMember("steps") || !document["steps"].IsArray()) {
throw std::runtime_error("Workflow must contain a 'steps' array");
const bool hasSteps = document.HasMember("steps");
const bool hasNodes = document.HasMember("nodes");
if (hasSteps && hasNodes) {
throw std::runtime_error("Workflow cannot define both 'steps' and 'nodes'");
}
if (!hasSteps && !hasNodes) {
throw std::runtime_error("Workflow must contain a 'steps' array or 'nodes' array");
}
WorkflowDefinition workflow;
if (document.HasMember("template")) {
if (!document["template"].IsString()) {
throw std::runtime_error("Workflow member 'template' must be a string");
}
workflow.templateName = document["template"].GetString();
workflow.templateName = ReadRequiredString(document, "template");
}
for (const auto& entry : document["steps"].GetArray()) {
if (hasSteps) {
if (!document["steps"].IsArray()) {
throw std::runtime_error("Workflow must contain a 'steps' array");
}
for (const auto& entry : document["steps"].GetArray()) {
if (!entry.IsObject()) {
throw std::runtime_error("Workflow steps must be objects");
}
WorkflowStepDefinition step;
step.id = ReadRequiredString(entry, "id");
step.plugin = ReadRequiredString(entry, "plugin");
step.inputs = ReadStringMap(entry, "inputs");
step.outputs = ReadStringMap(entry, "outputs");
workflow.steps.push_back(std::move(step));
}
return workflow;
}
if (!document["nodes"].IsArray()) {
throw std::runtime_error("Workflow must contain a 'nodes' array");
}
std::vector<WorkflowStepDefinition> nodes;
std::vector<std::string> nodeOrder;
for (rapidjson::SizeType i = 0; i < document["nodes"].Size(); ++i) {
const auto& entry = document["nodes"][i];
if (!entry.IsObject()) {
throw std::runtime_error("Workflow steps must be objects");
throw std::runtime_error("Workflow nodes must be objects");
}
WorkflowStepDefinition step;
step.id = ReadRequiredString(entry, "id");
step.plugin = ReadRequiredString(entry, "plugin");
step.id = ReadNodeId(entry, i);
step.plugin = ReadNodePlugin(entry, step.id);
step.inputs = ReadStringMap(entry, "inputs");
step.outputs = ReadStringMap(entry, "outputs");
workflow.steps.push_back(std::move(step));
nodes.push_back(step);
nodeOrder.push_back(step.id);
}
const auto edges = ReadConnections(document);
std::vector<std::string> orderedIds = edges.empty()
? nodeOrder
: SortNodesByConnections(nodeOrder, edges);
std::unordered_map<std::string, WorkflowStepDefinition> nodeMap;
nodeMap.reserve(nodes.size());
for (const auto& node : nodes) {
nodeMap.emplace(node.id, node);
}
workflow.steps.reserve(nodes.size());
for (const auto& nodeId : orderedIds) {
auto it = nodeMap.find(nodeId);
if (it == nodeMap.end()) {
throw std::runtime_error("Workflow nodes missing entry for '" + nodeId + "'");
}
workflow.steps.push_back(it->second);
}
return workflow;

View File

@@ -0,0 +1,35 @@
#include "workflow_frame_bullet_physics_step.hpp"
#include "workflow_step_io_resolver.hpp"
#include <stdexcept>
namespace sdl3cpp::services::impl {
WorkflowFrameBulletPhysicsStep::WorkflowFrameBulletPhysicsStep(std::shared_ptr<IPhysicsService> physicsService,
std::shared_ptr<ILogger> logger)
: physicsService_(std::move(physicsService)),
logger_(std::move(logger)) {}
std::string WorkflowFrameBulletPhysicsStep::GetPluginId() const {
return "frame.bullet_physics";
}
void WorkflowFrameBulletPhysicsStep::Execute(const WorkflowStepDefinition& step, WorkflowContext& context) {
if (!physicsService_) {
throw std::runtime_error("frame.bullet_physics requires an IPhysicsService");
}
WorkflowStepIoResolver resolver;
const std::string deltaKey = resolver.GetRequiredInputKey(step, "delta");
const auto* delta = context.TryGet<double>(deltaKey);
if (!delta) {
throw std::runtime_error("frame.bullet_physics missing delta input");
}
physicsService_->StepSimulation(static_cast<float>(*delta));
if (logger_) {
logger_->Trace("WorkflowFrameBulletPhysicsStep", "Execute",
"delta=" + std::to_string(*delta),
"Bullet physics step completed");
}
}
} // namespace sdl3cpp::services::impl

View File

@@ -0,0 +1,25 @@
#pragma once
#include "../interfaces/i_logger.hpp"
#include "../interfaces/i_physics_service.hpp"
#include "../interfaces/i_workflow_step.hpp"
#include <memory>
#include <string>
namespace sdl3cpp::services::impl {
class WorkflowFrameBulletPhysicsStep final : public IWorkflowStep {
public:
WorkflowFrameBulletPhysicsStep(std::shared_ptr<IPhysicsService> physicsService,
std::shared_ptr<ILogger> logger);
std::string GetPluginId() const override;
void Execute(const WorkflowStepDefinition& step, WorkflowContext& context) override;
private:
std::shared_ptr<IPhysicsService> physicsService_;
std::shared_ptr<ILogger> logger_;
};
} // namespace sdl3cpp::services::impl

View File

@@ -80,3 +80,71 @@ TEST(WorkflowDefinitionParserTest, FailsWhenPluginIsMissing) {
EXPECT_NE(message.find("plugin"), std::string::npos);
}
}
TEST(WorkflowDefinitionParserTest, ParsesN8nNodesWithConnections) {
const std::string json = R"json({
"template": "boot.default",
"nodes": [
{
"id": "load_config",
"plugin": "config.load",
"inputs": { "path": "config.path" },
"outputs": { "document": "config.document" }
},
{
"name": "validate_schema",
"type": "config.schema.validate",
"inputs": { "document": "config.document", "path": "config.path" }
}
],
"connections": {
"load_config": {
"main": [
[
{ "node": "validate_schema", "type": "main", "index": 0 }
]
]
}
}
})json";
TempFile file(json);
sdl3cpp::services::impl::WorkflowDefinitionParser parser;
const auto workflow = parser.ParseFile(file.path);
ASSERT_EQ(workflow.steps.size(), 2u);
EXPECT_EQ(workflow.steps[0].id, "load_config");
EXPECT_EQ(workflow.steps[0].plugin, "config.load");
EXPECT_EQ(workflow.steps[1].id, "validate_schema");
EXPECT_EQ(workflow.steps[1].plugin, "config.schema.validate");
}
TEST(WorkflowDefinitionParserTest, RejectsUnknownNodeInConnections) {
const std::string json = R"json({
"nodes": [
{
"id": "start",
"plugin": "config.load"
}
],
"connections": {
"start": {
"main": [
[
{ "node": "missing", "type": "main", "index": 0 }
]
]
}
}
})json";
TempFile file(json);
sdl3cpp::services::impl::WorkflowDefinitionParser parser;
try {
parser.ParseFile(file.path);
FAIL() << "Expected workflow parser to reject unknown node connection";
} catch (const std::runtime_error& error) {
const std::string message = error.what();
EXPECT_NE(message.find("unknown node"), std::string::npos);
}
}