Эх сурвалжийг харах

Update platform model workflow

Jax Docker 1 сар өмнө
parent
commit
1c0d2a786e
100 өөрчлөгдсөн 357 нэмэгдсэн , 7782 устгасан
  1. 2 1
      .claude/settings.local.json
  2. 0 3
      .gitlab-ci.yml
  3. 11 509
      README.md
  4. 0 2
      deployments/docker/.env.example
  5. 1 133
      deployments/docker/docker-compose.yml
  6. 0 2
      deployments/docker/prometheus.yml
  7. 0 22
      libs/core-domain/src/core_domain/__init__.py
  8. 3 1
      libs/core-domain/src/core_domain/execution_contracts.py
  9. 0 81
      libs/core-domain/src/core_domain/runtime_contracts.py
  10. 0 14
      libs/core-domain/src/core_domain/workflow_contracts.py
  11. 0 3
      pyproject.toml
  12. 0 2
      scripts/migrate_all.py
  13. 0 370
      scripts/smoke_runtime_no_key.py
  14. 0 44
      services/api-gateway/app/api/routes.py
  15. 0 2
      services/api-gateway/app/bootstrap/settings.py
  16. 0 2
      services/api-gateway/app/infrastructure/proxy.py
  17. 8 2
      services/model-gateway-service/app/api/routes.py
  18. 162 7
      services/model-gateway-service/app/application/services.py
  19. 13 0
      services/model-gateway-service/app/domain/repositories.py
  20. 133 0
      services/model-gateway-service/app/infrastructure/provider.py
  21. 0 36
      services/runtime-service/alembic.ini
  22. 0 53
      services/runtime-service/alembic/env.py
  23. 0 1
      services/runtime-service/alembic/versions/.gitkeep
  24. 0 88
      services/runtime-service/alembic/versions/20260422_0001_init_runtime_models.py
  25. 0 26
      services/runtime-service/alembic/versions/20260423_0002_add_node_run_outputs.py
  26. 0 45
      services/runtime-service/alembic/versions/20260423_0003_add_execution_logs.py
  27. 0 51
      services/runtime-service/alembic/versions/20260423_0004_add_node_artifacts.py
  28. 0 55
      services/runtime-service/alembic/versions/20260423_0005_add_trace_spans.py
  29. 0 27
      services/runtime-service/alembic/versions/20260423_0006_add_runtime_worker_indexes.py
  30. 0 30
      services/runtime-service/alembic/versions/20260425_0007_add_node_run_scheduling.py
  31. 0 22
      services/runtime-service/alembic/versions/20260429_9001_remove_runtime_versioning.py
  32. 0 1
      services/runtime-service/app/__init__.py
  33. 0 1
      services/runtime-service/app/api/__init__.py
  34. 0 465
      services/runtime-service/app/api/routes.py
  35. 0 1
      services/runtime-service/app/application/__init__.py
  36. 0 1260
      services/runtime-service/app/application/services.py
  37. 0 1
      services/runtime-service/app/bootstrap/__init__.py
  38. 0 20
      services/runtime-service/app/bootstrap/app.py
  39. 0 19
      services/runtime-service/app/bootstrap/settings.py
  40. 0 1
      services/runtime-service/app/db/__init__.py
  41. 0 9
      services/runtime-service/app/db/models/__init__.py
  42. 0 16
      services/runtime-service/app/db/models/execution_log.py
  43. 0 20
      services/runtime-service/app/db/models/node_artifact.py
  44. 0 29
      services/runtime-service/app/db/models/node_run.py
  45. 0 24
      services/runtime-service/app/db/models/trace_span.py
  46. 0 27
      services/runtime-service/app/db/models/workflow_run.py
  47. 0 27
      services/runtime-service/app/db/session.py
  48. 0 1
      services/runtime-service/app/domain/__init__.py
  49. 0 445
      services/runtime-service/app/domain/repositories.py
  50. 0 20
      services/runtime-service/app/infrastructure/__init__.py
  51. 0 25
      services/runtime-service/app/infrastructure/code_runner_client.py
  52. 0 190
      services/runtime-service/app/infrastructure/context.py
  53. 0 1230
      services/runtime-service/app/infrastructure/executors.py
  54. 0 34
      services/runtime-service/app/infrastructure/human_client.py
  55. 0 30
      services/runtime-service/app/infrastructure/knowledge_client.py
  56. 0 25
      services/runtime-service/app/infrastructure/model_gateway_client.py
  57. 0 122
      services/runtime-service/app/infrastructure/planner.py
  58. 0 24
      services/runtime-service/app/infrastructure/tool_client.py
  59. 0 24
      services/runtime-service/app/infrastructure/workflow_client.py
  60. 0 4
      services/runtime-service/app/main.py
  61. 0 1
      services/runtime-service/app/schemas/__init__.py
  62. 0 205
      services/runtime-service/app/schemas/run.py
  63. 0 120
      services/runtime-service/app/worker.py
  64. 0 28
      services/runtime-service/pyproject.toml
  65. 2 16
      services/session-service/app/api/routes.py
  66. 2 42
      services/session-service/app/application/services.py
  67. 0 1
      services/session-service/app/bootstrap/settings.py
  68. 0 23
      services/session-service/app/infrastructure/runtime_client.py
  69. 0 32
      services/session-service/app/schemas/run_request.py
  70. 0 36
      services/workflow-service/alembic.ini
  71. 0 53
      services/workflow-service/alembic/env.py
  72. 0 1
      services/workflow-service/alembic/versions/.gitkeep
  73. 0 113
      services/workflow-service/alembic/versions/20260422_0001_init_workflow_models.py
  74. 0 22
      services/workflow-service/alembic/versions/20260429_9001_remove_workflow_versioning.py
  75. 0 1
      services/workflow-service/app/__init__.py
  76. 0 1
      services/workflow-service/app/api/__init__.py
  77. 0 228
      services/workflow-service/app/api/routes.py
  78. 0 1
      services/workflow-service/app/application/__init__.py
  79. 0 338
      services/workflow-service/app/application/designer.py
  80. 0 153
      services/workflow-service/app/application/services.py
  81. 0 1
      services/workflow-service/app/bootstrap/__init__.py
  82. 0 20
      services/workflow-service/app/bootstrap/app.py
  83. 0 6
      services/workflow-service/app/bootstrap/settings.py
  84. 0 1
      services/workflow-service/app/db/__init__.py
  85. 0 15
      services/workflow-service/app/db/models/__init__.py
  86. 0 11
      services/workflow-service/app/db/models/app_config.py
  87. 0 17
      services/workflow-service/app/db/models/app_definition.py
  88. 0 14
      services/workflow-service/app/db/models/workflow_config.py
  89. 0 13
      services/workflow-service/app/db/models/workflow_definition.py
  90. 0 27
      services/workflow-service/app/db/session.py
  91. 0 1
      services/workflow-service/app/domain/__init__.py
  92. 0 124
      services/workflow-service/app/domain/repositories.py
  93. 0 4
      services/workflow-service/app/main.py
  94. 0 1
      services/workflow-service/app/schemas/__init__.py
  95. 0 54
      services/workflow-service/app/schemas/app.py
  96. 0 199
      services/workflow-service/app/schemas/workflow.py
  97. 0 26
      services/workflow-service/pyproject.toml
  98. 0 6
      tests/conftest.py
  99. 20 0
      tests/test_model_service.py
  100. 0 98
      tests/test_post_contract_aliases.py

+ 2 - 1
.claude/settings.local.json

@@ -10,7 +10,8 @@
       "Bash(python scripts/search.py \"dashboard data analytics\" --domain typography)",
       "Bash(python scripts/search.py \"workflow flowchart status pipeline\" --domain chart)",
       "Bash(python scripts/search.py \"sidebar navigation dashboard admin\" --domain ux)",
-      "Bash(npm install:*)"
+      "Bash(npm install:*)",
+      "Bash(npx tsc:*)"
     ]
   }
 }

+ 0 - 3
.gitlab-ci.yml

@@ -10,12 +10,9 @@ python-test:
     - pip install -e libs/core-shared
     - pip install -e libs/core-domain
     - pip install -e libs/core-db
-    - pip install -e libs/core-dsl
     - pip install -e libs/core-events
     - pip install -e services/agent-service
     - pip install -e services/knowledge-service
-    - pip install -e services/workflow-service
-    - pip install -e services/runtime-service
   script:
     - python -m compileall libs services scripts tests
     - pytest -q

+ 11 - 509
README.md

@@ -14,8 +14,6 @@
 - `api-gateway`
 - `model-gateway-service`
 - `session-service`
-- `workflow-service`
-- `runtime-service`
 - `agent-service`
 - `memory-service`
 - `team-service`
@@ -52,8 +50,6 @@ pip install -e .\libs\core-events
 pip install -e .\libs\core-db
 pip install -e .\services\api-gateway
 pip install -e .\services\session-service
-pip install -e .\services\workflow-service
-pip install -e .\services\runtime-service
 pip install -e .\services\agent-service
 pip install -e .\services\memory-service
 pip install -e .\services\team-service
@@ -76,7 +72,7 @@ uvicorn app.main:app --reload --port 8000
 数据库连接默认使用 PostgreSQL,可以通过环境变量覆盖:
 
 ```powershell
-$env:AGENT_PLATFORM_DATABASE_URL="postgresql+psycopg://user:password@localhost:5432/workflow_db"
+$env:AGENT_PLATFORM_DATABASE_URL="postgresql+psycopg://user:password@localhost:5432/agent_db"
 ```
 
 ## 数据层脚手架
@@ -84,36 +80,24 @@ $env:AGENT_PLATFORM_DATABASE_URL="postgresql+psycopg://user:password@localhost:5
 本轮已经加入:
 
 - `libs/core-db`:统一 `SQLAlchemy` Base、通用 mixin、命名约定
-- `workflow-service`:应用与流程定义模型
 - `session-service`:会话与消息模型
-- `runtime-service`:运行与节点执行模型
 - `tool-service`:工具定义与绑定模型
 - 每个服务独立的 `alembic.ini`、`env.py`、`versions/`
-- `workflow-service`:已接入 repository / application service / CRUD API
 - `session-service`:已接入 repository / application service / CRUD API
 
 迁移执行示例:
 
 ```powershell
-cd D:\workspace\auto-platform\services\workflow-service
+cd D:\workspace\auto-platform\services\session-service
 alembic upgrade head
 ```
 
 其他服务同理:
 
-- `services/session-service`
-- `services/runtime-service`
 - `services/tool-service`
 
 接口示例:
 
-```powershell
-Invoke-RestMethod -Method Post `
-  -Uri http://127.0.0.1:8002/workflows/apps `
-  -ContentType "application/json" `
-  -Body '{"code":"sales_assistant","name":"Sales Assistant"}'
-```
-
 ```powershell
 Invoke-RestMethod -Method Post `
   -Uri http://127.0.0.1:8001/sessions `
@@ -121,45 +105,6 @@ Invoke-RestMethod -Method Post `
   -Body '{"app_id":"app-1","user_id":"user-1","channel_type":"web"}'
 ```
 
-```powershell
-Invoke-RestMethod -Method Post `
-  -Uri http://127.0.0.1:8002/workflows/versions `
-  -ContentType "application/json" `
-  -Body '{"workflow_id":"wf-1","dsl_json":{"nodes":[],"edges":[]}}'
-```
-
-```powershell
-Invoke-RestMethod -Method Post `
-  -Uri http://127.0.0.1:8001/sessions/run-requests `
-  -ContentType "application/json" `
-  -Body '{"session_id":"sess-1","app_version_id":"appv-1","workflow_version_id":"wfv-1"}'
-```
-
-```powershell
-Invoke-RestMethod -Method Post `
-  -Uri http://127.0.0.1:8003/runtime/runs `
-  -ContentType "application/json" `
-  -Body '{"app_id":"app-1","app_version_id":"appv-1","workflow_id":"wf-1","workflow_version_id":"wfv-1","session_id":"sess-1","initial_node":{"node_id":"start","node_type":"llm"}}'
-```
-
-如果不传 `initial_node`,`runtime-service` 会调用 `workflow-service` 读取对应的 `workflow version`,并从 DSL 中自动推导首节点:
-
-```powershell
-Invoke-RestMethod -Method Post `
-  -Uri http://127.0.0.1:8003/runtime/runs `
-  -ContentType "application/json" `
-  -Body '{"app_id":"app-1","app_version_id":"appv-1","workflow_id":"wf-1","workflow_version_id":"wfv-1","session_id":"sess-1"}'
-```
-
-一条链直接派发到 runtime:
-
-```powershell
-Invoke-RestMethod -Method Post `
-  -Uri http://127.0.0.1:8001/sessions/run-requests/dispatch `
-  -ContentType "application/json" `
-  -Body '{"session_id":"sess-1","app_id":"app-1","app_version_id":"appv-1","workflow_id":"wf-1","workflow_version_id":"wfv-1","initial_node":{"node_id":"start","node_type":"llm"}}'
-```
-
 工具定义示例:
 
 ```powershell
@@ -176,36 +121,12 @@ Invoke-RestMethod -Method Post `
   -Body '{"tool_id":"tool-1","input_schema_json":{"query":{"type":"string"}},"invoke_config_json":{"method":"GET","path":"/products/search"}}'
 ```
 
-运行状态推进示例:
-
-```powershell
-Invoke-RestMethod -Method Post `
-  -Uri http://127.0.0.1:8003/runtime/node-runs/node-run-id/status `
-  -ContentType "application/json" `
-  -Body '{"status":"running","worker_key":"runtime-worker-1"}'
-```
-
-```powershell
-Invoke-RestMethod -Method Post `
-  -Uri http://127.0.0.1:8003/runtime/runs/run-id/status `
-  -ContentType "application/json" `
-  -Body '{"status":"completed"}'
-```
-
-说明:
-
-- 当你调用 `node-runs/{node_run_id}/status` 更新节点状态时,`runtime-service` 会自动聚合当前运行下所有 `node_run` 的状态,并同步刷新 `workflow_run.status`
-- 当前规则是:任一节点 `failed` 则运行 `failed`;有节点 `running` 则运行 `running`;全部节点都为 `completed/skipped` 则运行 `completed`
-- 当某个 `node_run` 被更新为 `completed` 时,`runtime-service` 还会基于 `workflow version` 的 DSL 自动查找后继节点,并创建新的 `queued` 状态 `node_run`
-
 ## 目录结构
 
 ```text
 services/
   api-gateway/
   session-service/
-  workflow-service/
-  runtime-service/
   skill-service/
   human-service/
   knowledge-service/
@@ -232,9 +153,7 @@ tests/
 2. 写第一版 Alembic 初始迁移
 3. 接入 PostgreSQL / Redis
 4. 增加 Docker Compose
-5. 开始实现应用、流程、运行三条主链路
-
-## Runtime Execute APIs
+5. 开始实现会话、代理、团队主链路
 
 ## Agent Service APIs
 
@@ -469,23 +388,6 @@ Invoke-RestMethod -Method Post `
 
 Through `api-gateway`, use `/gateway/human/**`.
 
-Runtime human-in-the-loop nodes now create `human-service` tasks and pause the
-node in `pending` status until the task is completed. Supported node types:
-
-- `human`
-- `approval`
-- `human-input`
-- `human-takeover`
-
-After completing the human task, resume the blocked node:
-
-```powershell
-Invoke-RestMethod -Method Post `
-  -Uri http://127.0.0.1:8003/runtime/node-runs/node-run-id/resume-human `
-  -ContentType "application/json" `
-  -Body '{"human_task_id":"human-task-id","worker_key":"runtime-worker-1"}'
-```
-
 ## Knowledge Service APIs
 
 `knowledge-service` stores independent knowledge bases, documents, chunks, and
@@ -541,7 +443,7 @@ Publish an event:
 Invoke-RestMethod -Method Post `
   -Uri http://127.0.0.1:8013/events `
   -ContentType "application/json" `
-  -Body '{"event_type":"run.created","source_service":"runtime-service","aggregate_type":"workflow_run","aggregate_id":"run-id","payload_json":{"run_id":"run-id"}}'
+  -Body '{"event_type":"run.created","source_service":"agent-service","aggregate_type":"agent_run","aggregate_id":"run-id","payload_json":{"run_id":"run-id"}}'
 ```
 
 Claim pending events for a delivery worker:
@@ -579,7 +481,7 @@ Invoke-RestMethod -Method Post `
 Invoke-RestMethod -Method Post `
   -Uri http://127.0.0.1:8014/auth/permissions/check `
   -ContentType "application/json" `
-  -Body "{`"user_id`":`"$($user.id)`",`"permission`":`"workflow:write`"}"
+  -Body "{`"user_id`":`"$($user.id)`",`"permission`":`"agent:write`"}"
 ```
 
 Through `api-gateway`, use `/gateway/auth/**`.
@@ -588,7 +490,7 @@ Through `api-gateway`, use `/gateway/auth/**`.
 
 `scheduler-service` stores delayed jobs and due-job leases for time-based
 automation. It is intentionally service-neutral: jobs can target HTTP,
-event, runtime, agent, or team execution.
+event, agent, or team execution.
 
 Create a scheduled job:
 
@@ -596,7 +498,7 @@ Create a scheduled job:
 Invoke-RestMethod -Method Post `
   -Uri http://127.0.0.1:8015/scheduler/jobs `
   -ContentType "application/json" `
-  -Body '{"job_type":"runtime","name":"Run workflow later","schedule_time":"2026-04-26T12:00:00Z","payload_json":{"workflow_run_id":"run-id"}}'
+  -Body '{"job_type":"agent","name":"Run agent later","schedule_time":"2026-04-26T12:00:00Z","payload_json":{"agent_run_id":"run-id"}}'
 ```
 
 Claim due jobs for a worker:
@@ -691,312 +593,12 @@ $env:AGENT_PLATFORM_WORKER_DRY_RUN="true"
 Pop-Location
 ```
 
-`runtime-service` now includes a typed executor skeleton for these node types:
-
-- `llm`
-- `tool`
-- `code`
-- `human`
-- `approval`
-- `human-input`
-- `human-takeover`
-- `answer`
-- `if-else`
-- `assigner`
-- `knowledge-retrieval`
-- `template-transform`
-
-Execute a specific queued node:
-
-```powershell
-Invoke-RestMethod -Method Post `
-  -Uri http://127.0.0.1:8003/runtime/node-runs/node-run-id/execute `
-  -ContentType "application/json" `
-  -Body '{"worker_key":"runtime-worker-1"}'
-```
-
-Execute the next queued node in a run:
-
-```powershell
-Invoke-RestMethod -Method Post `
-  -Uri "http://127.0.0.1:8003/runtime/runs/run-id/execute-next" `
-  -ContentType "application/json" `
-  -Body '{"worker_key":"runtime-worker-1"}'
-```
-
-Execute queued nodes in sequence until the run is finished, blocked, or reaches `max_steps`:
-
-```powershell
-Invoke-RestMethod -Method Post `
-  -Uri "http://127.0.0.1:8003/runtime/runs/run-id/execute" `
-  -ContentType "application/json" `
-  -Body '{"worker_key":"runtime-worker-1","max_steps":16}'
-```
-
-Execute one queued node through the worker claim API:
-
-```powershell
-Invoke-RestMethod -Method Post `
-  -Uri "http://127.0.0.1:8003/runtime/workers/execute-next" `
-  -ContentType "application/json" `
-  -Body '{"worker_key":"runtime-worker-1","lease_seconds":300}'
-```
-
-Run a standalone runtime worker process:
-
-```powershell
-Push-Location .\services\runtime-service
-$env:AGENT_PLATFORM_DATABASE_URL="postgresql+psycopg://admin:password@git.newpoint.work:5432/vectordb"
-..\..\.venv\Scripts\python -m app.worker
-Pop-Location
-```
-
-The worker uses `node_run.status` plus `lease_expire_time` as a DB-backed queue, with PostgreSQL and Redis as the supported scaling baseline.
-
-Node execution results are now persisted on `node_run`:
-
-- `output_text`
-- `output_json`
-
-Node execution artifacts are also persisted on `node_artifact`:
-
-- `artifact_type`
-- `content_text`
-- `content_json`
-- `storage_uri`
-- `size_bytes`
-
-Query artifacts:
-
-```powershell
-Invoke-RestMethod `
-  -Uri "http://127.0.0.1:8003/runtime/node-artifacts?run_id=run-id"
-```
-
-Trace spans are persisted on `trace_span` for timeline and latency analysis:
-
-- `span_type`
-- `name`
-- `status`
-- `started_time`
-- `ended_time`
-- `duration_ms`
-- `attributes_json`
-- `error_code`
-- `error_message`
-
-Query trace spans:
-
-```powershell
-Invoke-RestMethod `
-  -Uri "http://127.0.0.1:8003/runtime/trace-spans?run_id=run-id"
-```
-
-Current behavior:
-
-- `answer` nodes persist rendered text to `output_text`
-- `assigner` nodes write `state_updates` to `output_json`
-- `condition` / `if-else` nodes write `condition_result` and `route` to `output_json`
-- `template-transform` nodes render text or JSON using previous node outputs and run state
-- `knowledge-retrieval` / `retriever` nodes run keyword retrieval over inline or HTTP JSON documents
-- `tool` nodes persist resolved binding/tool metadata to `output_json`
-- default executors persist basic executor metadata to `output_json`
-- parallel fan-out is supported by defining multiple outgoing edges from one node
-- join nodes wait for predecessor completion with `config.join_policy`
-- loop/re-entry is supported with `config.allow_loop=true` and `config.max_iterations`
-- retry is supported with `config.retry_policy.max_attempts` and `retry_delay_seconds`
-- delayed scheduling and node timeout use `config.delay_seconds` and `config.timeout_seconds`
-- compensation nodes can be queued on failure with `config.compensation_node_id`
-
-Runtime template context:
-
-- `state.xxx`: values written by previous `assigner` nodes
-- `nodes.node_id.output.xxx`: structured output from a previous node
-- `nodes.node_id.text`: text output from a previous node
-- `current.node_id`: current node id
-
-Assigner node config example:
-
-```json
-{
-  "id": "seed-state",
-  "type": "assigner",
-  "config": {
-    "assignments": {
-      "score": 7,
-      "user_name": "Alice"
-    }
-  }
-}
-```
-
-Condition node config example:
-
-```json
-{
-  "id": "check-score",
-  "type": "if-else",
-  "config": {
-    "expression": "state.score >= 5"
-  }
-}
-```
-
-Conditional edge example:
-
-```json
-[
-  {"source": "check-score", "target": "high-path", "condition": "true"},
-  {"source": "check-score", "target": "low-path", "condition": "false"}
-]
-```
-
-Join node config example:
-
-```json
-{
-  "id": "join-results",
-  "type": "join",
-  "config": {
-    "join_policy": "all_completed"
-  }
-}
-```
-
-Loop and retry config example:
-
-```json
-{
-  "id": "poll-status",
-  "type": "tool",
-  "config": {
-    "allow_loop": true,
-    "max_iterations": 5,
-    "timeout_seconds": 30,
-    "retry_policy": {
-      "max_attempts": 3,
-      "retry_delay_seconds": 2
-    }
-  }
-}
-```
-
-Compensation config example:
-
-```json
-{
-  "id": "charge-card",
-  "type": "tool",
-  "config": {
-    "compensation_node_id": "refund-card"
-  }
-}
-```
-
-Template node config example:
-
-```json
-{
-  "id": "high-path",
-  "type": "template-transform",
-  "config": {
-    "template": "{{state.user_name}} passed with score {{state.score}}"
-  }
-}
-```
-
-Retriever node config example:
-
-```json
-{
-  "id": "retrieve-docs",
-  "type": "knowledge-retrieval",
-  "config": {
-    "query_template": "{{state.query}}",
-    "top_k": 2,
-    "documents": [
-      {
-        "id": "refund",
-        "title": "Refund Policy",
-        "text": "Refund policy allows returns within seven days."
-      },
-      {
-        "id": "shipping",
-        "title": "Shipping Policy",
-        "text": "Shipping usually takes three to five business days."
-      }
-    ]
-  }
-}
-```
-
-Retriever nodes can call `knowledge-service` directly:
-
-```json
-{
-  "id": "retrieve-kb",
-  "type": "knowledge-retrieval",
-  "config": {
-    "knowledge_base_id": "kb-id",
-    "query_template": "{{state.query}}",
-    "top_k": 3,
-    "filters_json": {
-      "source_type": "text"
-    }
-  }
-}
-```
-
-Retriever output is persisted to `node_run.output_json.retrieved_documents`. Template nodes can consume it:
-
-```json
-{
-  "id": "render-answer",
-  "type": "template-transform",
-  "config": {
-    "template": "Top doc: {{nodes.retrieve-docs.output.retrieved_documents.0.title}}"
-  }
-}
-```
-
-Retriever nodes can also load documents from an HTTP JSON source:
-
-```json
-{
-  "id": "retrieve-remote-docs",
-  "type": "retriever",
-  "config": {
-    "query": "refund policy",
-    "source_url": "http://127.0.0.1:9000/documents",
-    "top_k": 3
-  }
-}
-```
-
-The HTTP source should return either a document list or an object with a `documents` list.
-
-Run the no-key runtime smoke test after local services are running:
-
-```powershell
-.\.venv\Scripts\python scripts\smoke_runtime_no_key.py
-```
-
-Run the same smoke test through `api-gateway`:
-
-```powershell
-$env:AGENT_PLATFORM_SMOKE_WORKFLOW_URL="http://127.0.0.1:8000/gateway/workflows"
-$env:AGENT_PLATFORM_SMOKE_RUNTIME_URL="http://127.0.0.1:8000/gateway/runtime"
-.\.venv\Scripts\python scripts\smoke_runtime_no_key.py
-```
-
 ## API Gateway
 
 `api-gateway` provides a unified entrypoint:
 
 - `GET /gateway/services/health`
-- `/gateway/workflows/**` -> `workflow-service /workflows/**`
 - `/gateway/sessions/**` -> `session-service /sessions/**`
-- `/gateway/runtime/**` -> `runtime-service /runtime/**`
 - `/gateway/agents/**` -> `agent-service /agents/**`
 - `/gateway/memories/**` -> `memory-service /memories/**`
 - `/gateway/teams/**` -> `team-service /teams/**`
@@ -1060,7 +662,7 @@ Create an API key:
 ```powershell
 $body = @{
     name = "local-dev"
-  scopes = "gateway:agents:* gateway:runtime:read"
+  scopes = "gateway:agents:*"
 } | ConvertTo-Json
 
 $created = Invoke-RestMethod `
@@ -1095,74 +697,6 @@ Invoke-RestMethod `
   -Body $body
 ```
 
-Run smoke test through an authenticated gateway:
-
-```powershell
-$env:AGENT_PLATFORM_SMOKE_WORKFLOW_URL="http://127.0.0.1:8000/gateway/workflows"
-$env:AGENT_PLATFORM_SMOKE_RUNTIME_URL="http://127.0.0.1:8000/gateway/runtime"
-$env:AGENT_PLATFORM_SMOKE_API_KEY=$created.api_key
-.\.venv\Scripts\python scripts\smoke_runtime_no_key.py
-```
-
-HTTP tool node config example:
-
-```json
-{
-  "id": "search-products",
-  "type": "tool",
-  "config": {
-    "tool_binding_id": "binding-1",
-    "query": {
-      "keyword": "milk"
-    }
-  }
-}
-```
-
-Supported HTTP tool config resolution order:
-
-- URL: `config.url` or `invoke_config_json.url`
-- Base URL: `config.base_url` or `binding.config_json.base_url` or `invoke_config_json.base_url`
-- Path: `config.path` or `invoke_config_json.path`
-- Method: `invoke_config_json.method`, default `GET`
-- Query params: merge `invoke_config_json.query` + `config.query`
-- Body JSON: merge `invoke_config_json.body` + `config.body`
-- Headers: merge `invoke_config_json.headers` + `binding.config_json.headers` + `config.headers`
-
-LLM node config example:
-
-```json
-{
-  "id": "draft-answer",
-  "type": "llm",
-  "config": {
-    "model": "gpt-4o-mini",
-    "system_prompt": "You are a customer support assistant.",
-    "prompt": "Summarize the user intent in Chinese.",
-    "temperature": 0.2,
-    "max_tokens": 400
-  }
-}
-```
-
-`llm` nodes also support explicit `messages`:
-
-```json
-{
-  "id": "rewrite-message",
-  "type": "llm",
-  "config": {
-    "model": "gpt-4o-mini",
-    "messages": [
-      {"role": "system", "content": "You are a concise editor."},
-      {"role": "user", "content": "Rewrite this sentence in a warmer tone."}
-    ]
-  }
-}
-```
-
-`runtime-service` sends `llm` execution requests to `model-gateway-service`, and the gateway forwards them to an OpenAI-compatible `/chat/completions` provider.
-
 Recommended environment variables for `model-gateway-service`:
 
 ```powershell
@@ -1171,30 +705,6 @@ $env:AGENT_PLATFORM_PROVIDER_API_KEY="your-api-key"
 $env:AGENT_PLATFORM_DEFAULT_MODEL="gpt-4o-mini"
 ```
 
-Code node config example:
-
-```json
-{
-  "id": "compute-summary",
-  "type": "code",
-  "config": {
-    "language": "python",
-    "timeout_seconds": 5,
-    "input_json": {
-      "numbers": [1, 2, 3, 4]
-    },
-    "code": "total = sum(payload['numbers'])\nresult = {'total': total, 'count': len(payload['numbers'])}\nprint(f'total={total}')"
-  }
-}
-```
-
-`runtime-service` sends `code` execution requests to `code-runner-service`. Current `python` execution contract:
-
-- input payload is available as `payload`
-- execution result should be assigned to `result`
-- `print(...)` output is captured into `node_run.output_text`
-- structured `result` is captured into `node_run.output_json.result_json`
-
 Recommended environment variables for `code-runner-service`:
 
 ```powershell
@@ -1228,7 +738,7 @@ Production-like infrastructure:
 
 - Compose now starts `postgres` with the `pgvector` image and runs `CREATE EXTENSION IF NOT EXISTS vector`.
 - Compose now starts durable `redis` with append-only persistence.
-- Copy `deployments/docker/.env.example` to `.env` to use per-service PostgreSQL databases such as `workflow_service`, `agent_service`, and `knowledge_service`.
+- Copy `deployments/docker/.env.example` to `.env` to use per-service PostgreSQL databases such as `agent_service` and `knowledge_service`.
 - Set `AGENT_PLATFORM_REDIS_URL=redis://redis:6379/0` to enable shared Redis-backed locks, idempotency keys, and queues.
 
 Run all service migrations:
@@ -1240,7 +750,7 @@ python .\scripts\migrate_all.py
 Run only selected migrations:
 
 ```powershell
-python .\scripts\migrate_all.py --only agent-service --only runtime-service
+python .\scripts\migrate_all.py --only agent-service --only knowledge-service
 ```
 
 Run the automated smoke tests:
@@ -1254,12 +764,6 @@ The repository includes `.gitlab-ci.yml` with a Python 3.11 test job that
 installs the core libraries plus Agent/Knowledge services, runs `compileall`,
 and executes the pytest smoke suite.
 
-Scale runtime workers:
-
-```powershell
-docker compose -f .\deployments\docker\docker-compose.yml up --build -d --scale runtime-worker=3
-```
-
 Scale agent workers:
 
 ```powershell
@@ -1289,7 +793,7 @@ Important notes:
 - Services default to PostgreSQL; set `AGENT_PLATFORM_DATABASE_URL` explicitly for each environment.
 - Scaled workers should use PostgreSQL plus Redis for locks, queues, idempotency, and leases.
 - `core-shared.redis_primitives` provides `DistributedLock`, `IdempotencyStore`, and `RedisQueue` for services that need cross-process coordination.
-- `agent-worker`, `runtime-worker`, and `scheduler-worker` use Redis locks/idempotency when Redis is available, and fall back to DB leases when Redis is not available.
+- `agent-worker` and `scheduler-worker` use Redis locks/idempotency when Redis is available, and fall back to DB leases when Redis is not available.
 - `agent-service` stores agent definitions, prompt/config versions, and agent run records under `/data`
 - `memory-service` stores scoped memories under `/data`; move it to PostgreSQL before enabling high-volume memory writes
 - `team-service` stores multi-agent team definitions, team versions, and team run records under `/data`
@@ -1302,6 +806,4 @@ Important notes:
 - `scheduler-service` stores delayed jobs, due-job leases, and retry status under `/data`
 - `agent-worker` has no exposed port and can be scaled independently; set `AGENT_PLATFORM_AGENT_WORKER_DRY_RUN=true` for no-key local smoke runs
 - `scheduler-worker` has no exposed port and can be scaled independently; prefer PostgreSQL for real multi-worker write concurrency
-- `runtime-worker` has no exposed port and can be scaled independently; prefer PostgreSQL for real multi-worker write concurrency
-- `runtime-service` automatically resolves internal URLs to `workflow-service`, `tool-service`, `model-gateway-service`, and `code-runner-service`
 - `model-gateway-service` defaults to `http://host.docker.internal:11434/v1`; replace it in `.env` if you want OpenAI or another OpenAI-compatible provider

+ 0 - 2
deployments/docker/.env.example

@@ -4,9 +4,7 @@ AGENT_PLATFORM_DEFAULT_MODEL=gpt-4o-mini
 AGENT_PLATFORM_POSTGRES_USER=admin
 AGENT_PLATFORM_POSTGRES_PASSWORD=hFOvG5UBeK5KIGhz5cQH
 AGENT_PLATFORM_POSTGRES_DB=vectordb
-AGENT_PLATFORM_WORKFLOW_DATABASE_URL=postgresql+psycopg://admin:hFOvG5UBeK5KIGhz5cQH@git.newpoint.work:5432/vectordb
 AGENT_PLATFORM_SESSION_DATABASE_URL=postgresql+psycopg://admin:hFOvG5UBeK5KIGhz5cQH@git.newpoint.work:5432/vectordb
-AGENT_PLATFORM_RUNTIME_DATABASE_URL=postgresql+psycopg://admin:hFOvG5UBeK5KIGhz5cQH@git.newpoint.work:5432/vectordb
 AGENT_PLATFORM_TOOL_DATABASE_URL=postgresql+psycopg://admin:hFOvG5UBeK5KIGhz5cQH@git.newpoint.work:5432/vectordb
 AGENT_PLATFORM_MODEL_GATEWAY_DATABASE_URL=postgresql+psycopg://admin:hFOvG5UBeK5KIGhz5cQH@git.newpoint.work:5432/vectordb
 AGENT_PLATFORM_AGENT_DATABASE_URL=postgresql+psycopg://admin:hFOvG5UBeK5KIGhz5cQH@git.newpoint.work:5432/vectordb

+ 1 - 133
deployments/docker/docker-compose.yml

@@ -88,10 +88,6 @@ services:
         condition: service_started
       session-service:
         condition: service_started
-      workflow-service:
-        condition: service_started
-      runtime-service:
-        condition: service_started
       tool-service:
         condition: service_started
       model-gateway-service:
@@ -117,27 +113,6 @@ services:
       scheduler-service:
         condition: service_started
 
-  workflow-service:
-    build:
-      context: ../..
-      dockerfile: deployments/docker/python-service.Dockerfile
-      args:
-        SERVICE_PATH: services/workflow-service
-    container_name: agent-platform-workflow-service
-    command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8002"]
-    environment:
-      <<: *agent-platform-common-env
-      AGENT_PLATFORM_DATABASE_URL: ${AGENT_PLATFORM_WORKFLOW_DATABASE_URL:-postgresql+psycopg://admin:hFOvG5UBeK5KIGhz5cQH@git.newpoint.work:5432/vectordb}
-    ports:
-      - "8002:8002"
-    volumes:
-      - workflow_service_data:/data
-    healthcheck:
-      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8002/workflows/health').read()"]
-      interval: 15s
-      timeout: 5s
-      retries: 5
-
   session-service:
     build:
       context: ../..
@@ -149,14 +124,10 @@ services:
     environment:
       <<: *agent-platform-common-env
       AGENT_PLATFORM_DATABASE_URL: ${AGENT_PLATFORM_SESSION_DATABASE_URL:-postgresql+psycopg://admin:hFOvG5UBeK5KIGhz5cQH@git.newpoint.work:5432/vectordb}
-      AGENT_PLATFORM_RUNTIME_SERVICE_URL: http://runtime-service:8003
     ports:
       - "8001:8001"
     volumes:
       - session_service_data:/data
-    depends_on:
-      runtime-service:
-        condition: service_started
     healthcheck:
       test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8001/sessions/health').read()"]
       interval: 15s
@@ -557,7 +528,7 @@ services:
     volumes:
       - auth_service_data:/data
     healthcheck:
-      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8014/auth/health').read()"]
+      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8014/identity/health').read()"]
       interval: 15s
       timeout: 5s
       retries: 5
@@ -605,101 +576,6 @@ services:
       event-service:
         condition: service_started
 
-  runtime-service:
-    build:
-      context: ../..
-      dockerfile: deployments/docker/python-service.Dockerfile
-      args:
-        SERVICE_PATH: services/runtime-service
-    container_name: agent-platform-runtime-service
-    command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8003"]
-    environment:
-      <<: *agent-platform-common-env
-      AGENT_PLATFORM_DATABASE_URL: ${AGENT_PLATFORM_RUNTIME_DATABASE_URL:-postgresql+psycopg://admin:hFOvG5UBeK5KIGhz5cQH@git.newpoint.work:5432/vectordb}
-      AGENT_PLATFORM_WORKFLOW_SERVICE_URL: http://workflow-service:8002
-      AGENT_PLATFORM_TOOL_SERVICE_URL: http://tool-service:8004
-      AGENT_PLATFORM_MODEL_GATEWAY_SERVICE_URL: http://model-gateway-service:8005
-      AGENT_PLATFORM_CODE_RUNNER_SERVICE_URL: http://code-runner-service:8006
-      AGENT_PLATFORM_AGENT_SERVICE_URL: http://agent-service:8007
-      AGENT_PLATFORM_MEMORY_SERVICE_URL: http://memory-service:8008
-      AGENT_PLATFORM_TEAM_SERVICE_URL: http://team-service:8009
-      AGENT_PLATFORM_SKILL_SERVICE_URL: http://skill-service:8010
-      AGENT_PLATFORM_HUMAN_SERVICE_URL: http://human-service:8011
-      AGENT_PLATFORM_KNOWLEDGE_SERVICE_URL: http://knowledge-service:8012
-      AGENT_PLATFORM_EVENT_SERVICE_URL: http://event-service:8013
-      AGENT_PLATFORM_SCHEDULER_SERVICE_URL: http://scheduler-service:8015
-    ports:
-      - "8003:8003"
-    volumes:
-      - runtime_service_data:/data
-    depends_on:
-      workflow-service:
-        condition: service_started
-      tool-service:
-        condition: service_started
-      model-gateway-service:
-        condition: service_started
-      code-runner-service:
-        condition: service_started
-      agent-service:
-        condition: service_started
-      memory-service:
-        condition: service_started
-      team-service:
-        condition: service_started
-      skill-service:
-        condition: service_started
-      human-service:
-        condition: service_started
-      knowledge-service:
-        condition: service_started
-      event-service:
-        condition: service_started
-      scheduler-service:
-        condition: service_started
-    healthcheck:
-      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8003/runtime/health').read()"]
-      interval: 15s
-      timeout: 5s
-      retries: 5
-
-  runtime-worker:
-    build:
-      context: ../..
-      dockerfile: deployments/docker/python-service.Dockerfile
-      args:
-        SERVICE_PATH: services/runtime-service
-    command: ["python", "-m", "app.worker"]
-    environment:
-      <<: *agent-platform-common-env
-      AGENT_PLATFORM_DATABASE_URL: ${AGENT_PLATFORM_RUNTIME_DATABASE_URL:-postgresql+psycopg://admin:hFOvG5UBeK5KIGhz5cQH@git.newpoint.work:5432/vectordb}
-      AGENT_PLATFORM_WORKFLOW_SERVICE_URL: http://workflow-service:8002
-      AGENT_PLATFORM_TOOL_SERVICE_URL: http://tool-service:8004
-      AGENT_PLATFORM_MODEL_GATEWAY_SERVICE_URL: http://model-gateway-service:8005
-      AGENT_PLATFORM_CODE_RUNNER_SERVICE_URL: http://code-runner-service:8006
-      AGENT_PLATFORM_KNOWLEDGE_SERVICE_URL: http://knowledge-service:8012
-      AGENT_PLATFORM_EVENT_SERVICE_URL: http://event-service:8013
-      AGENT_PLATFORM_SCHEDULER_SERVICE_URL: http://scheduler-service:8015
-      AGENT_PLATFORM_WORKER_POLL_INTERVAL_SECONDS: ${AGENT_PLATFORM_WORKER_POLL_INTERVAL_SECONDS:-1}
-      AGENT_PLATFORM_WORKER_LEASE_SECONDS: ${AGENT_PLATFORM_WORKER_LEASE_SECONDS:-300}
-    volumes:
-      - runtime_service_data:/data
-    depends_on:
-      workflow-service:
-        condition: service_started
-      tool-service:
-        condition: service_started
-      model-gateway-service:
-        condition: service_started
-      code-runner-service:
-        condition: service_started
-      knowledge-service:
-        condition: service_started
-      event-service:
-        condition: service_started
-      scheduler-service:
-        condition: service_started
-
   api-gateway:
     build:
       context: ../..
@@ -711,9 +587,7 @@ services:
     environment:
       <<: *agent-platform-common-env
       AGENT_PLATFORM_DATABASE_URL: ${AGENT_PLATFORM_API_GATEWAY_DATABASE_URL:-postgresql+psycopg://admin:hFOvG5UBeK5KIGhz5cQH@git.newpoint.work:5432/vectordb}
-      AGENT_PLATFORM_WORKFLOW_SERVICE_URL: http://workflow-service:8002
       AGENT_PLATFORM_SESSION_SERVICE_URL: http://session-service:8001
-      AGENT_PLATFORM_RUNTIME_SERVICE_URL: http://runtime-service:8003
       AGENT_PLATFORM_TOOL_SERVICE_URL: http://tool-service:8004
       AGENT_PLATFORM_MODEL_GATEWAY_SERVICE_URL: http://model-gateway-service:8005
       AGENT_PLATFORM_CODE_RUNNER_SERVICE_URL: http://code-runner-service:8006
@@ -736,12 +610,8 @@ services:
     volumes:
       - api_gateway_data:/data
     depends_on:
-      workflow-service:
-        condition: service_started
       session-service:
         condition: service_started
-      runtime-service:
-        condition: service_started
       tool-service:
         condition: service_started
       model-gateway-service:
@@ -787,8 +657,6 @@ volumes:
   event_service_data:
   auth_service_data:
   scheduler_service_data:
-  workflow_service_data:
   session_service_data:
-  runtime_service_data:
   tool_service_data:
   model_gateway_service_data:

+ 0 - 2
deployments/docker/prometheus.yml

@@ -9,8 +9,6 @@ scrape_configs:
       - targets:
           - api-gateway:8000
           - session-service:8001
-          - workflow-service:8002
-          - runtime-service:8003
           - tool-service:8004
           - model-gateway-service:8005
           - code-runner-service:8006

+ 0 - 22
libs/core-domain/src/core_domain/__init__.py

@@ -55,17 +55,6 @@ from .model_contracts import (
     ChatCompletionResponseContract,
     ChatMessageContract,
 )
-from .runtime_contracts import (
-    InitialNodeContract,
-    NodeRunContract,
-    NodeRunStatus,
-    NodeRunStatusUpdateContract,
-    RunBootstrapContract,
-    RunCreateContract,
-    WorkflowRunContract,
-    WorkflowRunStatus,
-    WorkflowRunStatusUpdateContract,
-)
 from .scheduler_contracts import ScheduledJobContract, ScheduledJobStatus, ScheduledJobType
 from .service import ServiceDescriptor, ServiceHealth
 from .skill_contracts import (
@@ -93,7 +82,6 @@ from .tool_contracts import (
     ToolDefinitionContract,
     ToolConnectionContract,
 )
-from .workflow_contracts import WorkflowConfigContract
 
 __all__ = [
     "AgentDefinitionContract",
@@ -124,7 +112,6 @@ __all__ = [
     "HumanTaskCreateContract",
     "HumanTaskStatus",
     "HumanTaskType",
-    "InitialNodeContract",
     "KnowledgeBaseContract",
     "KnowledgeBaseStatus",
     "KnowledgeChunkContract",
@@ -142,11 +129,6 @@ __all__ = [
     "NodeExecutionRequestContract",
     "NodeExecutionResultContract",
     "RunExecutionRequestContract",
-    "NodeRunContract",
-    "NodeRunStatus",
-    "NodeRunStatusUpdateContract",
-    "RunBootstrapContract",
-    "RunCreateContract",
     "ScheduledJobContract",
     "ScheduledJobStatus",
     "ScheduledJobType",
@@ -171,8 +153,4 @@ __all__ = [
     "ToolCredentialRevealContract",
     "ToolDefinitionContract",
     "ToolConnectionContract",
-    "WorkflowRunStatus",
-    "WorkflowRunStatusUpdateContract",
-    "WorkflowRunContract",
-    "WorkflowConfigContract",
 ]

+ 3 - 1
libs/core-domain/src/core_domain/execution_contracts.py

@@ -1,7 +1,9 @@
+from typing import Literal
+
 from core_shared import JSONValue
 from pydantic import BaseModel, Field
 
-from .runtime_contracts import NodeRunStatus
+NodeRunStatus = Literal["pending", "queued", "running", "completed", "failed", "skipped"]
 
 
 class NodeExecutionRequestContract(BaseModel):

+ 0 - 81
libs/core-domain/src/core_domain/runtime_contracts.py

@@ -1,81 +0,0 @@
-from datetime import datetime
-from typing import Literal
-
-from core_shared import JSONValue
-from pydantic import BaseModel
-
-NodeRunStatus = Literal["pending", "queued", "running", "completed", "failed", "skipped"]
-WorkflowRunStatus = Literal["pending", "running", "completed", "failed", "cancelled", "paused"]
-
-
-class InitialNodeContract(BaseModel):
-    node_id: str
-    node_type: str
-    status: NodeRunStatus = "queued"
-
-
-class RunCreateContract(BaseModel):
-    app_id: str
-    app_config_id: str
-    workflow_id: str
-    workflow_config_id: str
-    session_id: str | None = None
-    parent_run_id: str | None = None
-    root_run_id: str | None = None
-    run_type: str = "main"
-    trigger_type: str = "user"
-    priority: int = 0
-    initial_node: InitialNodeContract | None = None
-
-
-class WorkflowRunContract(BaseModel):
-    id: str
-    app_id: str
-    app_config_id: str
-    workflow_id: str
-    workflow_config_id: str
-    session_id: str | None = None
-    parent_run_id: str | None = None
-    root_run_id: str | None = None
-    run_type: str
-    status: WorkflowRunStatus
-    trigger_type: str
-    priority: int
-    current_node_count: int
-    started_time: datetime | None = None
-    created_time: datetime
-
-
-class NodeRunContract(BaseModel):
-    id: str
-    run_id: str
-    node_id: str
-    node_type: str
-    attempt_no: int
-    status: NodeRunStatus
-    output_text: str | None = None
-    output_json: dict[str, JSONValue] | None = None
-    scheduled_time: datetime | None = None
-    timeout_time: datetime | None = None
-    queued_time: datetime | None = None
-    created_time: datetime
-
-
-class RunBootstrapContract(BaseModel):
-    run: WorkflowRunContract
-    initial_node: NodeRunContract | None = None
-
-
-class WorkflowRunStatusUpdateContract(BaseModel):
-    status: WorkflowRunStatus
-    error_code: str | None = None
-    error_message: str | None = None
-
-
-class NodeRunStatusUpdateContract(BaseModel):
-    status: NodeRunStatus
-    worker_key: str | None = None
-    error_code: str | None = None
-    error_message: str | None = None
-    output_text: str | None = None
-    output_json: dict[str, JSONValue] | None = None

+ 0 - 14
libs/core-domain/src/core_domain/workflow_contracts.py

@@ -1,14 +0,0 @@
-from datetime import datetime
-
-from core_shared import JSONValue
-from pydantic import BaseModel
-
-
-class WorkflowConfigContract(BaseModel):
-    id: str
-    workflow_id: str
-    dsl_json: dict[str, JSONValue] | None = None
-    compiled_plan_json: dict[str, JSONValue] | None = None
-    checksum: str | None = None
-    created_time: datetime
-

+ 0 - 3
pyproject.toml

@@ -2,7 +2,6 @@
 members = [
   "libs/core-db",
   "libs/core-domain",
-  "libs/core-dsl",
   "libs/core-events",
   "libs/core-shared",
   "services/api-gateway",
@@ -18,8 +17,6 @@ members = [
   "services/session-service",
   "services/skill-service",
   "services/team-service",
-  "services/workflow-service",
-  "services/runtime-service",
   "services/tool-service",
 ]
 

+ 0 - 2
scripts/migrate_all.py

@@ -9,10 +9,8 @@ from dataclasses import dataclass
 from pathlib import Path
 
 DEFAULT_SERVICE_ORDER = [
-    "workflow-service",
     "session-service",
     "tool-service",
-    "runtime-service",
     "model-gateway-service",
     "memory-service",
     "skill-service",

+ 0 - 370
scripts/smoke_runtime_no_key.py

@@ -1,370 +0,0 @@
-from __future__ import annotations
-
-import json
-import os
-import sys
-import uuid
-from dataclasses import dataclass
-
-import httpx
-
-WORKFLOW_SERVICE_URL = os.getenv(
-    "AGENT_PLATFORM_SMOKE_WORKFLOW_URL",
-    "http://127.0.0.1:8002/workflows")
-RUNTIME_SERVICE_URL = os.getenv(
-    "AGENT_PLATFORM_SMOKE_RUNTIME_URL",
-    "http://127.0.0.1:8003/runtime")
-SMOKE_API_KEY = os.getenv("AGENT_PLATFORM_SMOKE_API_KEY")
-
-
-@dataclass(frozen=True)
-class SmokeScenario:
-    score: int
-    expected_branch_node_id: str
-    expected_output_text: str
-
-
-SCENARIOS = (
-    SmokeScenario(
-        score=7,
-        expected_branch_node_id="high_path",
-        expected_output_text="Alice passed with score 7"),
-    SmokeScenario(
-        score=3,
-        expected_branch_node_id="low_path",
-        expected_output_text="Alice did not pass; score 3"))
-
-
-def main() -> int:
-    unique_suffix = uuid.uuid4().hex[:8]
-    headers = {}
-    if SMOKE_API_KEY:
-        headers["x-api-key"] = SMOKE_API_KEY
-
-    with httpx.Client(timeout=20.0, headers=headers) as client:
-        app_id = create_app(client, unique_suffix)
-        workflow_id = create_workflow(client, app_id, unique_suffix)
-
-        results: list[dict[str, object]] = []
-        for scenario in SCENARIOS:
-            results.append(run_scenario(client, app_id, workflow_id, unique_suffix, scenario))
-        results.append(run_retriever_scenario(client, app_id, workflow_id, unique_suffix))
-
-    print(json.dumps(results, ensure_ascii=False, indent=2))
-    return 0
-
-
-def create_app(client: httpx.Client, unique_suffix: str) -> str:
-    response = client.post(
-        f"{WORKFLOW_SERVICE_URL}/apps",
-        json={
-            "code": f"smoke-app-{unique_suffix}",
-            "name": f"Smoke App {unique_suffix}",
-        })
-    response.raise_for_status()
-    payload = response.json()
-    return str(payload["id"])
-
-
-def create_workflow(client: httpx.Client, app_id: str, unique_suffix: str) -> str:
-    response = client.post(
-        WORKFLOW_SERVICE_URL,
-        json={
-            "app_id": app_id,
-            "code": f"smoke-flow-{unique_suffix}",
-            "name": f"Smoke Flow {unique_suffix}",
-        })
-    response.raise_for_status()
-    payload = response.json()
-    return str(payload["id"])
-
-
-def run_scenario(
-    client: httpx.Client,
-    app_id: str,
-    workflow_id: str,
-    unique_suffix: str,
-    scenario: SmokeScenario) -> dict[str, object]:
-    workflow_version_id = create_workflow_version(client, workflow_id, unique_suffix, scenario.score)
-    app_version_id = create_app_version(client, app_id, workflow_version_id)
-    run_id = create_run(client, app_id, app_version_id, workflow_id, workflow_version_id)
-    execute_run(client, run_id)
-    node_runs = list_node_runs(client, run_id)
-    artifacts = list_node_artifacts(client, run_id)
-    if len(artifacts) < 3:
-        raise AssertionError(f"expected at least 3 artifacts, got {len(artifacts)}")
-    trace_spans = list_trace_spans(client, run_id)
-    if len(trace_spans) < 3:
-        raise AssertionError(f"expected at least 3 trace spans, got {len(trace_spans)}")
-
-    node_map = {str(item["node_id"]): item for item in node_runs}
-    assert scenario.expected_branch_node_id in node_map, (
-        f"expected branch node not found: {scenario.expected_branch_node_id}"
-    )
-    expected_node = node_map[scenario.expected_branch_node_id]
-    actual_output_text = expected_node.get("output_text")
-    if actual_output_text != scenario.expected_output_text:
-        raise AssertionError(
-            f"unexpected output_text for {scenario.expected_branch_node_id}: {actual_output_text!r}"
-        )
-
-    other_branch_node_id = "low_path" if scenario.expected_branch_node_id == "high_path" else "high_path"
-    if other_branch_node_id in node_map:
-        raise AssertionError(f"unexpected branch node executed: {other_branch_node_id}")
-
-    return {
-        "score": scenario.score,
-        "executed_node_ids": [str(item["node_id"]) for item in node_runs],
-        "branch_output_text": actual_output_text,
-        "artifact_count": len(artifacts),
-        "trace_span_count": len(trace_spans),
-    }
-
-
-def run_retriever_scenario(
-    client: httpx.Client,
-    app_id: str,
-    workflow_id: str,
-    unique_suffix: str) -> dict[str, object]:
-    workflow_version_id = create_retriever_workflow_version(client, workflow_id, unique_suffix)
-    app_version_id = create_app_version(client, app_id, workflow_version_id)
-    run_id = create_run(client, app_id, app_version_id, workflow_id, workflow_version_id)
-    execute_run(client, run_id)
-    node_runs = list_node_runs(client, run_id)
-    artifacts = list_node_artifacts(client, run_id)
-    if len(artifacts) < 3:
-        raise AssertionError(f"expected at least 3 retriever artifacts, got {len(artifacts)}")
-    trace_spans = list_trace_spans(client, run_id)
-    if len(trace_spans) < 3:
-        raise AssertionError(f"expected at least 3 retriever trace spans, got {len(trace_spans)}")
-
-    node_map = {str(item["node_id"]): item for item in node_runs}
-    answer_node = node_map.get("render_answer")
-    if answer_node is None:
-        raise AssertionError("retriever answer node was not executed")
-    answer_text = answer_node.get("output_text")
-    expected_answer_text = "Top doc: Refund Policy"
-    if answer_text != expected_answer_text:
-        raise AssertionError(f"unexpected retriever answer text: {answer_text!r}")
-
-    retrieve_node = node_map.get("retrieve_docs")
-    if retrieve_node is None:
-        raise AssertionError("retriever node was not executed")
-    retrieve_output = retrieve_node.get("output_json")
-    if not isinstance(retrieve_output, dict):
-        raise AssertionError("retriever output_json must be an object")
-
-    return {
-        "scenario": "retriever",
-        "executed_node_ids": [str(item["node_id"]) for item in node_runs],
-        "answer_text": answer_text,
-        "artifact_count": len(artifacts),
-        "trace_span_count": len(trace_spans),
-    }
-
-
-def create_workflow_version(
-    client: httpx.Client,
-    workflow_id: str,
-    unique_suffix: str,
-    score: int) -> str:
-    response = client.post(
-        f"{WORKFLOW_SERVICE_URL}/versions",
-        json={
-            "workflow_id": workflow_id,
-            "status": "active",
-            "dsl_json": build_workflow_dsl(unique_suffix, score),
-        })
-    response.raise_for_status()
-    payload = response.json()
-    return str(payload["id"])
-
-
-def create_retriever_workflow_version(
-    client: httpx.Client,
-    workflow_id: str,
-    unique_suffix: str) -> str:
-    response = client.post(
-        f"{WORKFLOW_SERVICE_URL}/versions",
-        json={
-            "workflow_id": workflow_id,
-            "status": "active",
-            "dsl_json": build_retriever_workflow_dsl(unique_suffix),
-        })
-    response.raise_for_status()
-    payload = response.json()
-    return str(payload["id"])
-
-
-def create_app_version(client: httpx.Client, app_id: str, workflow_version_id: str) -> str:
-    response = client.post(
-        f"{WORKFLOW_SERVICE_URL}/apps/versions",
-        json={
-            "app_id": app_id,
-            "workflow_version_id": workflow_version_id,
-            "status": "active",
-        })
-    response.raise_for_status()
-    payload = response.json()
-    return str(payload["id"])
-
-
-def create_run(
-    client: httpx.Client,
-    app_id: str,
-    app_version_id: str,
-    workflow_id: str,
-    workflow_version_id: str) -> str:
-    response = client.post(
-        f"{RUNTIME_SERVICE_URL}/runs",
-        json={
-            "app_id": app_id,
-            "app_version_id": app_version_id,
-            "workflow_id": workflow_id,
-            "workflow_version_id": workflow_version_id,
-        })
-    response.raise_for_status()
-    payload = response.json()
-    return str(payload["run"]["id"])
-
-
-def execute_run(client: httpx.Client, run_id: str) -> None:
-    response = client.post(
-        f"{RUNTIME_SERVICE_URL}/runs/{run_id}/execute",
-        json={"max_steps": 8})
-    response.raise_for_status()
-
-
-def list_node_runs(client: httpx.Client, run_id: str) -> list[dict[str, object]]:
-    response = client.get(
-        f"{RUNTIME_SERVICE_URL}/node-runs",
-        params={"run_id": run_id})
-    response.raise_for_status()
-    payload = response.json()
-    if not isinstance(payload, list):
-        raise AssertionError("node-runs response must be a list")
-    return [item for item in payload if isinstance(item, dict)]
-
-
-def list_node_artifacts(client: httpx.Client, run_id: str) -> list[dict[str, object]]:
-    response = client.get(
-        f"{RUNTIME_SERVICE_URL}/node-artifacts",
-        params={"run_id": run_id})
-    response.raise_for_status()
-    payload = response.json()
-    if not isinstance(payload, list):
-        raise AssertionError("node-artifacts response must be a list")
-    return [item for item in payload if isinstance(item, dict)]
-
-
-def list_trace_spans(client: httpx.Client, run_id: str) -> list[dict[str, object]]:
-    response = client.get(
-        f"{RUNTIME_SERVICE_URL}/trace-spans",
-        params={"run_id": run_id})
-    response.raise_for_status()
-    payload = response.json()
-    if not isinstance(payload, list):
-        raise AssertionError("trace-spans response must be a list")
-    return [item for item in payload if isinstance(item, dict)]
-
-
-def build_workflow_dsl(unique_suffix: str, score: int) -> dict[str, object]:
-    return {
-        "code": f"smoke-flow-{unique_suffix}-{score}",
-        "name": f"Smoke Flow {score}",
-        "nodes": [
-            {
-                "id": "seed_state",
-                "type": "assigner",
-                "config": {
-                    "assignments": {
-                        "score": score,
-                        "user_name": "Alice",
-                    },
-                },
-            },
-            {
-                "id": "check_score",
-                "type": "if-else",
-                "config": {
-                    "expression": "state.score >= 5",
-                },
-            },
-            {
-                "id": "high_path",
-                "type": "template-transform",
-                "config": {
-                    "template": "{{state.user_name}} passed with score {{state.score}}",
-                },
-            },
-            {
-                "id": "low_path",
-                "type": "template-transform",
-                "config": {
-                    "template": "{{state.user_name}} did not pass; score {{state.score}}",
-                },
-            },
-        ],
-        "edges": [
-            {"source": "seed_state", "target": "check_score"},
-            {"source": "check_score", "target": "high_path", "condition": "true"},
-            {"source": "check_score", "target": "low_path", "condition": "false"},
-        ],
-    }
-
-
-def build_retriever_workflow_dsl(unique_suffix: str) -> dict[str, object]:
-    return {
-        "code": f"smoke-retriever-{unique_suffix}",
-        "name": "Smoke Retriever Flow",
-        "nodes": [
-            {
-                "id": "seed_query",
-                "type": "assigner",
-                "config": {
-                    "assignments": {
-                        "query": "refund policy",
-                    },
-                },
-            },
-            {
-                "id": "retrieve_docs",
-                "type": "knowledge-retrieval",
-                "config": {
-                    "query_template": "{{state.query}}",
-                    "top_k": 1,
-                    "documents": [
-                        {
-                            "id": "shipping",
-                            "title": "Shipping Policy",
-                            "text": "Shipping usually takes three to five business days.",
-                        },
-                        {
-                            "id": "refund",
-                            "title": "Refund Policy",
-                            "text": "Refund policy allows returns within seven days after delivery.",
-                        },
-                    ],
-                },
-            },
-            {
-                "id": "render_answer",
-                "type": "template-transform",
-                "config": {
-                    "template": "Top doc: {{nodes.retrieve_docs.output.retrieved_documents.0.title}}",
-                },
-            },
-        ],
-        "edges": [
-            {"source": "seed_query", "target": "retrieve_docs"},
-            {"source": "retrieve_docs", "target": "render_answer"},
-        ],
-    }
-
-
-if __name__ == "__main__":
-    try:
-        raise SystemExit(main())
-    except Exception as exc:
-        print(f"smoke test failed: {exc}", file=sys.stderr)
-        raise

+ 0 - 44
services/api-gateway/app/api/routes.py

@@ -152,21 +152,11 @@ ServiceProxyDep = Annotated[ServiceProxy, Depends(get_service_proxy)]
 
 def build_proxy_targets(settings: ApiGatewaySettings) -> dict[ProxyServiceName, ProxyTarget]:
     return {
-        "workflow-service": ProxyTarget(
-            service_name="workflow-service",
-            base_url=settings.workflow_service_url,
-            path_prefix="/workflows",
-            health_path="/workflows/health"),
         "session-service": ProxyTarget(
             service_name="session-service",
             base_url=settings.session_service_url,
             path_prefix="/sessions",
             health_path="/sessions/health"),
-        "runtime-service": ProxyTarget(
-            service_name="runtime-service",
-            base_url=settings.runtime_service_url,
-            path_prefix="/runtime",
-            health_path="/runtime/health"),
         "tool-service": ProxyTarget(
             service_name="tool-service",
             base_url=settings.tool_service_url,
@@ -251,23 +241,6 @@ async def downstream_health_check(
         downstream_services=downstream_services)
 
 
-@router.api_route(
-    "/gateway/workflows",
-    methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
-@router.api_route(
-    "/gateway/workflows/{path:path}",
-    methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
-async def proxy_workflow_service(
-    request: Request,
-    settings: GatewaySettingsDep,
-    proxy: ServiceProxyDep,
-    path: str = "") -> Response:
-    return await proxy.forward(
-        request=request,
-        target=build_proxy_targets(settings)["workflow-service"],
-        path=path)
-
-
 @router.api_route(
     "/gateway/sessions",
     methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
@@ -285,23 +258,6 @@ async def proxy_session_service(
         path=path)
 
 
-@router.api_route(
-    "/gateway/runtime",
-    methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
-@router.api_route(
-    "/gateway/runtime/{path:path}",
-    methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
-async def proxy_runtime_service(
-    request: Request,
-    settings: GatewaySettingsDep,
-    proxy: ServiceProxyDep,
-    path: str = "") -> Response:
-    return await proxy.forward(
-        request=request,
-        target=build_proxy_targets(settings)["runtime-service"],
-        path=path)
-
-
 @router.api_route(
     "/gateway/agents",
     methods=["GET", "POST", "PUT", "PATCH", "DELETE"])

+ 0 - 2
services/api-gateway/app/bootstrap/settings.py

@@ -4,9 +4,7 @@ from core_shared import ServiceSettings
 class ApiGatewaySettings(ServiceSettings):
     service_name: str = "api-gateway"
     service_port: int = 8000
-    workflow_service_url: str = "http://127.0.0.1:8002"
     session_service_url: str = "http://127.0.0.1:8001"
-    runtime_service_url: str = "http://127.0.0.1:8003"
     tool_service_url: str = "http://127.0.0.1:8004"
     model_gateway_service_url: str = "http://127.0.0.1:8005"
     code_runner_service_url: str = "http://127.0.0.1:8006"

+ 0 - 2
services/api-gateway/app/infrastructure/proxy.py

@@ -12,9 +12,7 @@ from app.infrastructure.request_context import REQUEST_ID_HEADER, get_gateway_re
 from app.schemas.gateway import DownstreamServiceHealth
 
 ProxyServiceName = Literal[
-    "workflow-service",
     "session-service",
-    "runtime-service",
     "tool-service",
     "model-gateway-service",
     "model-provider-service",

+ 8 - 2
services/model-gateway-service/app/api/routes.py

@@ -288,7 +288,10 @@ def delete_model_provider_contract(
 def test_model_provider_contract(
     payload: ModelProviderTestRequestDto,
     service: ModelServiceDep) -> ApiResponse[ModelProviderTestData]:
-    result = service.test_provider(payload)
+    try:
+        result = service.test_provider(payload)
+    except ModelProviderClientError as exc:
+        raise HTTPException(status_code=502, detail=str(exc)) from exc
     if result is None:
         raise HTTPException(status_code=404, detail=f"provider not found: {payload.providerId}")
     return ok(result)
@@ -298,4 +301,7 @@ def test_model_provider_contract(
 def discover_models_contract(
     payload: DiscoverModelsRequestDto,
     service: ModelServiceDep) -> ApiResponse[DiscoverModelsData]:
-    return ok(service.discover_models(payload))
+    try:
+        return ok(service.discover_models(payload))
+    except ModelProviderClientError as exc:
+        raise HTTPException(status_code=502, detail=str(exc)) from exc

+ 162 - 7
services/model-gateway-service/app/application/services.py

@@ -3,7 +3,7 @@ from core_domain import ChatCompletionRequestContract, ChatCompletionResponseCon
 from app.bootstrap.settings import ModelGatewayServiceSettings
 from app.db.models import ModelDefinition, ModelProviderDefinition
 from app.domain.repositories import ModelDefinitionRepository, ModelProviderDefinitionRepository
-from app.infrastructure.provider import ModelProviderClient
+from app.infrastructure.provider import ModelProviderClient, ModelProviderClientError
 from app.schemas.model import (
     DiscoverModelsData,
     DiscoverModelsRequestDto,
@@ -44,6 +44,26 @@ class ModelGatewayApplicationService:
 
     def create_model(self, payload: ModelCreateRequest) -> ModelDefinition:
         provider = self._get_provider_or_raise(payload.provider_id)
+        if provider is not None:
+            existing = self.model_repository.get_by_provider_model(
+                provider_id=provider.id,
+                model_name=payload.model_name)
+            if existing is not None:
+                existing.name = payload.name
+                existing.provider_type = provider.provider_type
+                existing.provider_base_url = provider.base_url
+                existing.provider_api_key = provider.api_key
+                existing.description = payload.description
+                existing.capabilities_json = payload.capabilities_json
+                existing.context_window = payload.context_window or existing.context_window
+                existing.max_output_tokens = payload.max_output_tokens
+                existing.default_temperature = payload.default_temperature
+                existing.timeout_seconds = payload.timeout_seconds
+                existing.metadata_json = {
+                    **(existing.metadata_json or {}),
+                    **payload.metadata_json,
+                }
+                return self.model_repository.update(existing)
         code = payload.code or self._build_model_code(payload.name, payload.model_name)
         if self.model_repository.get_by_code(code) is not None:
             raise ValueError(f"model code already exists: {code}")
@@ -236,7 +256,7 @@ class ModelGatewayApplicationService:
         return self.provider_repository.list_all()
 
     def create_provider(self, payload: ModelProviderCreateRequestDto) -> ModelProviderDefinition:
-        return self.provider_repository.create(
+        provider = self.provider_repository.create(
             ModelProviderDefinition(
                 name=payload.name,
                 provider_type=payload.providerType,
@@ -245,6 +265,8 @@ class ModelGatewayApplicationService:
                 models_json=[_to_snake_model_item(item) for item in payload.models],
                 default_model=payload.defaultModel,
                 extra_config_json=payload.extraConfig))
+        self._refresh_and_sync_provider_models(provider, raise_on_empty=False)
+        return provider
 
     def update_provider(
         self,
@@ -272,7 +294,9 @@ class ModelGatewayApplicationService:
                 ]
             elif key == "name":
                 entity.name = value
-        return self.provider_repository.update(entity)
+        updated = self.provider_repository.update(entity)
+        self._refresh_and_sync_provider_models(updated, raise_on_empty=False)
+        return updated
 
     def delete_provider(self, payload: ModelProviderDeleteRequestDto) -> bool:
         entity = self.provider_repository.get_by_id(payload.providerId)
@@ -285,14 +309,23 @@ class ModelGatewayApplicationService:
         entity = self.provider_repository.get_by_id(payload.providerId)
         if entity is None:
             return None
+        try:
+            models = self.provider_client.list_models(
+                provider_type=entity.provider_type,
+                provider_base_url=entity.base_url,
+                provider_api_key=entity.api_key)
+        except ModelProviderClientError:
+            models = list(entity.models_json or [])
+            if not models:
+                raise
         return ModelProviderTestData(
             success=True,
             message="Connection configuration is available.",
             latencyMs=0,
             modelList=[
-                str(item.get("model_id") or item.get("modelId"))
-                for item in entity.models_json or []
-                if item.get("model_id") or item.get("modelId")
+                str(item.get("modelId") or item.get("model_id"))
+                for item in models
+                if item.get("modelId") or item.get("model_id")
             ])
 
     def discover_models(self, payload: DiscoverModelsRequestDto) -> DiscoverModelsData:
@@ -300,9 +333,23 @@ class ModelGatewayApplicationService:
         if payload.providerId:
             provider = self.provider_repository.get_by_id(payload.providerId)
             if provider is not None:
+                discovered = self._refresh_and_sync_provider_models(
+                    provider,
+                    raise_on_empty=True)
                 return DiscoverModelsData(
                     providerType=provider.provider_type,
-                    models=ModelProviderDto.from_entity(provider).models)
+                    models=discovered)
+        if payload.baseUrl:
+            discovered = [
+                ModelItemDto(**item)
+                for item in self.provider_client.list_models(
+                    provider_type=provider_type,
+                    provider_base_url=str(payload.baseUrl),
+                    provider_api_key=payload.apiKey)
+            ]
+            return DiscoverModelsData(
+                providerType=provider_type or self.settings.provider_type,
+                models=discovered)
         return DiscoverModelsData(
             providerType=provider_type or self.settings.provider_type,
             models=self._default_model_catalog(provider_type or self.settings.provider_type))
@@ -346,6 +393,20 @@ class ModelGatewayApplicationService:
                     modelType="embedding",
                     ownedBy="nomic"),
             ],
+            "deepseek": [
+                ModelItemDto(
+                    modelId="deepseek-chat",
+                    displayName="DeepSeek Chat",
+                    modelType="chat",
+                    ownedBy="deepseek",
+                    contextWindow=64000),
+                ModelItemDto(
+                    modelId="deepseek-reasoner",
+                    displayName="DeepSeek Reasoner",
+                    modelType="reasoning",
+                    ownedBy="deepseek",
+                    contextWindow=64000),
+            ],
         }
         return catalogs.get(provider_type, [])
 
@@ -423,6 +484,100 @@ class ModelGatewayApplicationService:
             })
         provider.models_json = existing_items
 
+    def _sync_provider_models(
+        self,
+        *,
+        provider: ModelProviderDefinition,
+        models: list[ModelItemDto]) -> None:
+        for item in models:
+            model_name = item.modelId.strip()
+            if not model_name:
+                continue
+            existing = self.model_repository.get_by_provider_model(
+                provider_id=provider.id,
+                model_name=model_name)
+            capabilities = self._capabilities_for_model_item(item, provider.provider_type)
+            if existing is None:
+                self.model_repository.create(
+                    ModelDefinition(
+                        code=self._build_model_code(item.displayName or model_name, model_name),
+                        name=item.displayName or model_name,
+                        provider_id=provider.id,
+                        provider_type=provider.provider_type,
+                        provider_base_url=provider.base_url,
+                        provider_api_key=provider.api_key,
+                        model_name=model_name,
+                        status="active",
+                        description=None,
+                        capabilities_json=capabilities,
+                        context_window=item.contextWindow,
+                        max_output_tokens=None,
+                        default_temperature=None,
+                        timeout_seconds=60.0,
+                        metadata_json={"source": "provider_discovery"},
+                    )
+                )
+                continue
+
+            existing.name = item.displayName or existing.name
+            existing.provider_type = provider.provider_type
+            existing.provider_base_url = provider.base_url
+            existing.provider_api_key = provider.api_key
+            existing.capabilities_json = capabilities
+            existing.context_window = item.contextWindow or existing.context_window
+            existing.metadata_json = {
+                **(existing.metadata_json or {}),
+                "source": "provider_discovery",
+            }
+            self.model_repository.update(existing)
+
+    def _refresh_and_sync_provider_models(
+        self,
+        provider: ModelProviderDefinition,
+        *,
+        raise_on_empty: bool) -> list[ModelItemDto]:
+        try:
+            discovered = [
+                ModelItemDto(**item)
+                for item in self.provider_client.list_models(
+                    provider_type=provider.provider_type,
+                    provider_base_url=provider.base_url,
+                    provider_api_key=provider.api_key)
+            ]
+        except ModelProviderClientError:
+            discovered = ModelProviderDto.from_entity(provider).models
+            if not discovered and provider.provider_type == "deepseek":
+                discovered = self._default_model_catalog("deepseek")
+            if not discovered and raise_on_empty:
+                raise
+
+        if not discovered:
+            return []
+
+        provider.models_json = [_to_snake_model_item(item) for item in discovered]
+        if provider.default_model is None:
+            provider.default_model = discovered[0].modelId
+        self.provider_repository.update(provider)
+        self._sync_provider_models(provider=provider, models=discovered)
+        return discovered
+
+    def _capabilities_for_model_item(
+        self,
+        item: ModelItemDto,
+        provider_type: str) -> list[str]:
+        model_type = item.modelType
+        capabilities: set[str] = set()
+        if model_type == "reasoning":
+            capabilities.update(["chat", "reasoning"])
+        elif model_type in {"embedding", "image", "audio", "video", "rerank", "moderation"}:
+            capabilities.add(model_type)
+        else:
+            capabilities.add("chat")
+
+        if provider_type in {"openai", "anthropic", "deepseek", "openai_compatible"} and "chat" in capabilities:
+            capabilities.add("tools")
+        return sorted(capabilities)
+
     def _build_provider_name(self, provider_type: str, base_url: str) -> str:
         label = provider_type.replace("_", " ").title()
         host = base_url.split("//")[-1].split("/")[0]

+ 13 - 0
services/model-gateway-service/app/domain/repositories.py

@@ -30,6 +30,19 @@ class ModelDefinitionRepository:
         stmt = select(ModelDefinition).where(ModelDefinition.code == code).limit(1)
         return self.db.scalar(stmt)
 
+    def get_by_provider_model(
+        self,
+        *,
+        provider_id: str,
+        model_name: str) -> ModelDefinition | None:
+        stmt = (
+            select(ModelDefinition)
+            .where(ModelDefinition.provider_id == provider_id)
+            .where(ModelDefinition.model_name == model_name)
+            .limit(1)
+        )
+        return self.db.scalar(stmt)
+
     def get_active_for_request(self, model: str) -> ModelDefinition | None:
         stmt = (
             select(ModelDefinition)

+ 133 - 0
services/model-gateway-service/app/infrastructure/provider.py

@@ -39,6 +39,89 @@ class ModelProviderClient:
             provider_api_key=provider_api_key,
             timeout_seconds=timeout_seconds)
 
+    def list_models(
+        self,
+        *,
+        provider_type: str | None = None,
+        provider_base_url: str | None = None,
+        provider_api_key: str | None = None,
+        timeout_seconds: float = 30.0,
+    ) -> list[dict[str, JSONValue]]:
+        resolved_provider_type = provider_type or self.settings.provider_type
+        if resolved_provider_type == "anthropic":
+            return self._list_anthropic_models(
+                provider_base_url=provider_base_url,
+                provider_api_key=provider_api_key,
+                timeout_seconds=timeout_seconds)
+
+        return self._list_openai_compatible_models(
+            provider_base_url=provider_base_url,
+            provider_api_key=provider_api_key,
+            timeout_seconds=timeout_seconds)
+
+    def _list_openai_compatible_models(
+        self,
+        *,
+        provider_base_url: str | None,
+        provider_api_key: str | None,
+        timeout_seconds: float) -> list[dict[str, JSONValue]]:
+        request_headers: dict[str, str] = {"content-type": "application/json"}
+        api_key = (
+            provider_api_key
+            if provider_api_key is not None
+            else self.settings.provider_api_key
+        )
+        if api_key:
+            request_headers["authorization"] = f"Bearer {api_key}"
+
+        try:
+            base_url = provider_base_url or self.settings.provider_base_url
+            with httpx.Client(timeout=timeout_seconds) as client:
+                response = client.get(_join_url(base_url, "models"), headers=request_headers)
+                response.raise_for_status()
+        except httpx.HTTPStatusError as exc:
+            detail = exc.response.text[:1000]
+            raise ModelProviderClientError(
+                f"model provider list models failed: {exc.response.status_code} {detail}") from exc
+        except httpx.HTTPError as exc:
+            raise ModelProviderClientError(f"model provider list models failed: {exc}") from exc
+
+        return _extract_model_items(_coerce_json_dict(response.json()))
+
+    def _list_anthropic_models(
+        self,
+        *,
+        provider_base_url: str | None,
+        provider_api_key: str | None,
+        timeout_seconds: float) -> list[dict[str, JSONValue]]:
+        api_key = (
+            provider_api_key
+            if provider_api_key is not None
+            else self.settings.provider_api_key
+        )
+        if not api_key:
+            raise ModelProviderClientError("anthropic api key is required")
+
+        request_headers = {
+            "content-type": "application/json",
+            "x-api-key": api_key,
+            "anthropic-version": "2023-06-01",
+        }
+
+        try:
+            base_url = provider_base_url or self.settings.provider_base_url
+            with httpx.Client(timeout=timeout_seconds) as client:
+                response = client.get(_join_url(base_url, "v1/models"), headers=request_headers)
+                response.raise_for_status()
+        except httpx.HTTPStatusError as exc:
+            detail = exc.response.text[:1000]
+            raise ModelProviderClientError(
+                f"anthropic list models failed: {exc.response.status_code} {detail}") from exc
+        except httpx.HTTPError as exc:
+            raise ModelProviderClientError(f"anthropic list models failed: {exc}") from exc
+
+        return _extract_model_items(_coerce_json_dict(response.json()))
+
     def _create_openai_compatible_chat_completion(
         self,
         payload: ChatCompletionRequestContract,
@@ -207,6 +290,56 @@ def _read_string(payload: dict[str, JSONValue], key: str) -> str | None:
     return value if isinstance(value, str) else None
 
 
+def _extract_model_items(payload: dict[str, JSONValue]) -> list[dict[str, JSONValue]]:
+    data = payload.get("data")
+    if not isinstance(data, list):
+        data = payload.get("models")
+    if not isinstance(data, list):
+        return []
+
+    items: list[dict[str, JSONValue]] = []
+    for item in data:
+        if isinstance(item, str):
+            model_id = item
+            display_name = item
+            owned_by = None
+        elif isinstance(item, dict):
+            model_id = _read_string(item, "id") or _read_string(item, "model") or _read_string(item, "name")
+            if model_id is None:
+                continue
+            display_name = _read_string(item, "display_name") or _read_string(item, "displayName") or model_id
+            owned_by = _read_string(item, "owned_by") or _read_string(item, "ownedBy")
+        else:
+            continue
+
+        model_item: dict[str, JSONValue] = {
+            "modelId": model_id,
+            "displayName": display_name,
+            "modelType": _infer_model_type(model_id),
+        }
+        if owned_by:
+            model_item["ownedBy"] = owned_by
+        items.append(model_item)
+    return items
+
+
+def _infer_model_type(model_id: str) -> str:
+    normalized = model_id.lower()
+    if "embed" in normalized or "embedding" in normalized:
+        return "embedding"
+    if "rerank" in normalized or "ranker" in normalized:
+        return "rerank"
+    if "moderation" in normalized:
+        return "moderation"
+    if "image" in normalized or "vision" in normalized:
+        return "image"
+    if "audio" in normalized or "whisper" in normalized or "tts" in normalized:
+        return "audio"
+    if "reason" in normalized or "thinking" in normalized or normalized.endswith("-r1") or "-r1-" in normalized:
+        return "reasoning"
+    return "chat"
+
+
 def _extract_response_content(payload: dict[str, JSONValue]) -> str:
     choices = payload.get("choices")
     if isinstance(choices, list) and choices:

+ 0 - 36
services/runtime-service/alembic.ini

@@ -1,36 +0,0 @@
-[alembic]
-script_location = alembic
-prepend_sys_path = .
-sqlalchemy.url = postgresql+psycopg://admin:hFOvG5UBeK5KIGhz5cQH@git.newpoint.work:5432/vectordb
-
-[loggers]
-keys = root,sqlalchemy,alembic
-
-[handlers]
-keys = console
-
-[formatters]
-keys = generic
-
-[logger_root]
-level = WARN
-handlers = console
-
-[logger_sqlalchemy]
-level = WARN
-handlers =
-qualname = sqlalchemy.engine
-
-[logger_alembic]
-level = INFO
-handlers = console
-qualname = alembic
-
-[handler_console]
-class = StreamHandler
-args = (sys.stderr,)
-level = NOTSET
-formatter = generic
-
-[formatter_generic]
-format = %(levelname)-5.5s [%(name)s] %(message)s

+ 0 - 53
services/runtime-service/alembic/env.py

@@ -1,53 +0,0 @@
-import os
-from logging.config import fileConfig
-
-from alembic import context
-from app.db.models import Base
-from sqlalchemy import engine_from_config, pool
-
-SERVICE_VERSION_TABLE = "runtime_alembic_version"
-
-config = context.config
-database_url = os.getenv("AGENT_PLATFORM_DATABASE_URL")
-if database_url:
-    config.set_main_option("sqlalchemy.url", database_url.replace("%", "%%"))
-
-if config.config_file_name is not None:
-    fileConfig(config.config_file_name)
-
-target_metadata = Base.metadata
-
-
-def run_migrations_offline() -> None:
-    url = config.get_main_option("sqlalchemy.url")
-    context.configure(
-        url=url,
-        target_metadata=target_metadata,
-        literal_binds=True,
-        version_table=SERVICE_VERSION_TABLE)
-
-    with context.begin_transaction():
-        context.run_migrations()
-
-
-def run_migrations_online() -> None:
-    connectable = engine_from_config(
-        config.get_section(config.config_ini_section, {}),
-        prefix="sqlalchemy.",
-        poolclass=pool.NullPool)
-
-    with connectable.connect() as connection:
-        context.configure(
-            connection=connection,
-            target_metadata=target_metadata,
-            version_table=SERVICE_VERSION_TABLE)
-
-        with context.begin_transaction():
-            context.run_migrations()
-
-
-if context.is_offline_mode():
-    run_migrations_offline()
-else:
-    run_migrations_online()
-

+ 0 - 1
services/runtime-service/alembic/versions/.gitkeep

@@ -1 +0,0 @@
-

+ 0 - 88
services/runtime-service/alembic/versions/20260422_0001_init_runtime_models.py

@@ -1,88 +0,0 @@
-"""init runtime models
-
-Revision ID: 20260422_0001
-Revises:
-Create Date: 2026-04-22 17:20:00
-"""
-
-from collections.abc import Sequence
-
-import sqlalchemy as sa
-from alembic import op
-
-revision: str = "20260422_0001"
-down_revision: str | None = None
-branch_labels: Sequence[str] | None = None
-depends_on: Sequence[str] | None = None
-
-
-def upgrade() -> None:
-    op.create_table(
-        "workflow_run",
-        sa.Column("app_id", sa.String(length=36), nullable=False),
-        sa.Column("app_version_id", sa.String(length=36), nullable=False),
-        sa.Column("workflow_id", sa.String(length=36), nullable=False),
-        sa.Column("workflow_version_id", sa.String(length=36), nullable=False),
-        sa.Column("session_id", sa.String(length=36), nullable=True),
-        sa.Column("parent_run_id", sa.String(length=36), nullable=True),
-        sa.Column("root_run_id", sa.String(length=36), nullable=True),
-        sa.Column("run_type", sa.String(length=32), nullable=False),
-        sa.Column("status", sa.String(length=32), nullable=False),
-        sa.Column("trigger_type", sa.String(length=32), nullable=False),
-        sa.Column("priority", sa.Integer(), nullable=False),
-        sa.Column("current_node_count", sa.Integer(), nullable=False),
-        sa.Column("started_time", sa.DateTime(), nullable=True),
-        sa.Column("finished_time", sa.DateTime(), nullable=True),
-        sa.Column("error_code", sa.String(length=64), nullable=True),
-        sa.Column("error_message", sa.Text(), nullable=True),
-        sa.Column("id", sa.String(length=36), nullable=False),
-        sa.Column("created_by", sa.String(length=36), nullable=True),
-        sa.Column("updated_by", sa.String(length=36), nullable=True),
-        sa.Column("created_time", sa.DateTime(), nullable=False),
-        sa.Column("updated_time", sa.DateTime(), nullable=False),
-        sa.Column("deleted_time", sa.DateTime(), nullable=True),
-        sa.Column("version", sa.Integer(), nullable=False),
-        sa.PrimaryKeyConstraint("id"))
-    op.create_index("ix_workflow_run_app_id", "workflow_run", ["app_id"], unique=False)
-    op.create_index("ix_workflow_run_root_run_id", "workflow_run", ["root_run_id"], unique=False)
-    op.create_index("ix_workflow_run_session_id", "workflow_run", ["session_id"], unique=False)
-    op.create_index("ix_workflow_run_status", "workflow_run", ["status"], unique=False)
-
-    op.create_table(
-        "node_run",
-        sa.Column("run_id", sa.String(length=36), nullable=False),
-        sa.Column("parent_node_run_id", sa.String(length=36), nullable=True),
-        sa.Column("node_id", sa.String(length=128), nullable=False),
-        sa.Column("node_type", sa.String(length=32), nullable=False),
-        sa.Column("attempt_no", sa.Integer(), nullable=False),
-        sa.Column("status", sa.String(length=32), nullable=False),
-        sa.Column("worker_key", sa.String(length=128), nullable=True),
-        sa.Column("lease_expire_time", sa.DateTime(), nullable=True),
-        sa.Column("queued_time", sa.DateTime(), nullable=True),
-        sa.Column("started_time", sa.DateTime(), nullable=True),
-        sa.Column("finished_time", sa.DateTime(), nullable=True),
-        sa.Column("error_code", sa.String(length=64), nullable=True),
-        sa.Column("error_message", sa.Text(), nullable=True),
-        sa.Column("id", sa.String(length=36), nullable=False),
-        sa.Column("created_by", sa.String(length=36), nullable=True),
-        sa.Column("updated_by", sa.String(length=36), nullable=True),
-        sa.Column("created_time", sa.DateTime(), nullable=False),
-        sa.Column("updated_time", sa.DateTime(), nullable=False),
-        sa.Column("deleted_time", sa.DateTime(), nullable=True),
-        sa.Column("version", sa.Integer(), nullable=False),
-        sa.PrimaryKeyConstraint("id"))
-    op.create_index("ix_node_run_run_id", "node_run", ["run_id"], unique=False)
-    op.create_index("ix_node_run_status", "node_run", ["status"], unique=False)
-
-
-def downgrade() -> None:
-    op.drop_index("ix_node_run_status", table_name="node_run")
-    op.drop_index("ix_node_run_run_id", table_name="node_run")
-    op.drop_table("node_run")
-
-    op.drop_index("ix_workflow_run_status", table_name="workflow_run")
-    op.drop_index("ix_workflow_run_session_id", table_name="workflow_run")
-    op.drop_index("ix_workflow_run_root_run_id", table_name="workflow_run")
-    op.drop_index("ix_workflow_run_app_id", table_name="workflow_run")
-    op.drop_table("workflow_run")
-

+ 0 - 26
services/runtime-service/alembic/versions/20260423_0002_add_node_run_outputs.py

@@ -1,26 +0,0 @@
-"""add node run outputs
-
-Revision ID: 20260423_0002
-Revises: 20260422_0001
-Create Date: 2026-04-23 17:20:00
-"""
-
-from collections.abc import Sequence
-
-import sqlalchemy as sa
-from alembic import op
-
-revision: str = "20260423_0002"
-down_revision: str | None = "20260422_0001"
-branch_labels: Sequence[str] | None = None
-depends_on: Sequence[str] | None = None
-
-
-def upgrade() -> None:
-    op.add_column("node_run", sa.Column("output_text", sa.Text(), nullable=True))
-    op.add_column("node_run", sa.Column("output_json", sa.JSON(), nullable=True))
-
-
-def downgrade() -> None:
-    op.drop_column("node_run", "output_json")
-    op.drop_column("node_run", "output_text")

+ 0 - 45
services/runtime-service/alembic/versions/20260423_0003_add_execution_logs.py

@@ -1,45 +0,0 @@
-"""add execution logs
-
-Revision ID: 20260423_0003
-Revises: 20260423_0002
-Create Date: 2026-04-23 16:30:00
-"""
-
-from collections.abc import Sequence
-
-import sqlalchemy as sa
-from alembic import op
-
-revision: str = "20260423_0003"
-down_revision: str | None = "20260423_0002"
-branch_labels: Sequence[str] | None = None
-depends_on: Sequence[str] | None = None
-
-
-def upgrade() -> None:
-    op.create_table(
-        "execution_log",
-        sa.Column("run_id", sa.String(length=36), nullable=False),
-        sa.Column("node_run_id", sa.String(length=36), nullable=True),
-        sa.Column("event_type", sa.String(length=64), nullable=False),
-        sa.Column("level", sa.String(length=16), nullable=False),
-        sa.Column("message", sa.Text(), nullable=False),
-        sa.Column("detail_json", sa.JSON(), nullable=True),
-        sa.Column("id", sa.String(length=36), nullable=False),
-        sa.Column("created_by", sa.String(length=36), nullable=True),
-        sa.Column("updated_by", sa.String(length=36), nullable=True),
-        sa.Column("created_time", sa.DateTime(), nullable=False),
-        sa.Column("updated_time", sa.DateTime(), nullable=False),
-        sa.Column("deleted_time", sa.DateTime(), nullable=True),
-        sa.Column("version", sa.Integer(), nullable=False),
-        sa.PrimaryKeyConstraint("id"))
-    op.create_index("ix_execution_log_run_id", "execution_log", ["run_id"], unique=False)
-    op.create_index("ix_execution_log_node_run_id", "execution_log", ["node_run_id"], unique=False)
-    op.create_index("ix_execution_log_event_type", "execution_log", ["event_type"], unique=False)
-
-
-def downgrade() -> None:
-    op.drop_index("ix_execution_log_event_type", table_name="execution_log")
-    op.drop_index("ix_execution_log_node_run_id", table_name="execution_log")
-    op.drop_index("ix_execution_log_run_id", table_name="execution_log")
-    op.drop_table("execution_log")

+ 0 - 51
services/runtime-service/alembic/versions/20260423_0004_add_node_artifacts.py

@@ -1,51 +0,0 @@
-"""add node artifacts
-
-Revision ID: 20260423_0004
-Revises: 20260423_0003
-Create Date: 2026-04-23 17:30:00
-"""
-
-from collections.abc import Sequence
-
-import sqlalchemy as sa
-from alembic import op
-
-revision: str = "20260423_0004"
-down_revision: str | None = "20260423_0003"
-branch_labels: Sequence[str] | None = None
-depends_on: Sequence[str] | None = None
-
-
-def upgrade() -> None:
-    op.create_table(
-        "node_artifact",
-        sa.Column("run_id", sa.String(length=36), nullable=False),
-        sa.Column("node_run_id", sa.String(length=36), nullable=False),
-        sa.Column("node_id", sa.String(length=128), nullable=False),
-        sa.Column("artifact_type", sa.String(length=64), nullable=False),
-        sa.Column("name", sa.String(length=128), nullable=False),
-        sa.Column("mime_type", sa.String(length=128), nullable=True),
-        sa.Column("content_text", sa.Text(), nullable=True),
-        sa.Column("content_json", sa.JSON(), nullable=True),
-        sa.Column("storage_uri", sa.String(length=512), nullable=True),
-        sa.Column("size_bytes", sa.Integer(), nullable=True),
-        sa.Column("id", sa.String(length=36), nullable=False),
-        sa.Column("created_by", sa.String(length=36), nullable=True),
-        sa.Column("updated_by", sa.String(length=36), nullable=True),
-        sa.Column("created_time", sa.DateTime(), nullable=False),
-        sa.Column("updated_time", sa.DateTime(), nullable=False),
-        sa.Column("deleted_time", sa.DateTime(), nullable=True),
-        sa.Column("version", sa.Integer(), nullable=False),
-        sa.PrimaryKeyConstraint("id"))
-    op.create_index("ix_node_artifact_run_id", "node_artifact", ["run_id"], unique=False)
-    op.create_index("ix_node_artifact_node_run_id", "node_artifact", ["node_run_id"], unique=False)
-    op.create_index("ix_node_artifact_node_id", "node_artifact", ["node_id"], unique=False)
-    op.create_index("ix_node_artifact_artifact_type", "node_artifact", ["artifact_type"], unique=False)
-
-
-def downgrade() -> None:
-    op.drop_index("ix_node_artifact_artifact_type", table_name="node_artifact")
-    op.drop_index("ix_node_artifact_node_id", table_name="node_artifact")
-    op.drop_index("ix_node_artifact_node_run_id", table_name="node_artifact")
-    op.drop_index("ix_node_artifact_run_id", table_name="node_artifact")
-    op.drop_table("node_artifact")

+ 0 - 55
services/runtime-service/alembic/versions/20260423_0005_add_trace_spans.py

@@ -1,55 +0,0 @@
-"""add trace spans
-
-Revision ID: 20260423_0005
-Revises: 20260423_0004
-Create Date: 2026-04-23 18:00:00
-"""
-
-from collections.abc import Sequence
-
-import sqlalchemy as sa
-from alembic import op
-
-revision: str = "20260423_0005"
-down_revision: str | None = "20260423_0004"
-branch_labels: Sequence[str] | None = None
-depends_on: Sequence[str] | None = None
-
-
-def upgrade() -> None:
-    op.create_table(
-        "trace_span",
-        sa.Column("run_id", sa.String(length=36), nullable=False),
-        sa.Column("node_run_id", sa.String(length=36), nullable=True),
-        sa.Column("parent_span_id", sa.String(length=36), nullable=True),
-        sa.Column("span_type", sa.String(length=64), nullable=False),
-        sa.Column("name", sa.String(length=128), nullable=False),
-        sa.Column("status", sa.String(length=32), nullable=False),
-        sa.Column("started_time", sa.DateTime(), nullable=False),
-        sa.Column("ended_time", sa.DateTime(), nullable=True),
-        sa.Column("duration_ms", sa.Integer(), nullable=True),
-        sa.Column("attributes_json", sa.JSON(), nullable=True),
-        sa.Column("error_code", sa.String(length=64), nullable=True),
-        sa.Column("error_message", sa.Text(), nullable=True),
-        sa.Column("id", sa.String(length=36), nullable=False),
-        sa.Column("created_by", sa.String(length=36), nullable=True),
-        sa.Column("updated_by", sa.String(length=36), nullable=True),
-        sa.Column("created_time", sa.DateTime(), nullable=False),
-        sa.Column("updated_time", sa.DateTime(), nullable=False),
-        sa.Column("deleted_time", sa.DateTime(), nullable=True),
-        sa.Column("version", sa.Integer(), nullable=False),
-        sa.PrimaryKeyConstraint("id"))
-    op.create_index("ix_trace_span_run_id", "trace_span", ["run_id"], unique=False)
-    op.create_index("ix_trace_span_node_run_id", "trace_span", ["node_run_id"], unique=False)
-    op.create_index("ix_trace_span_parent_span_id", "trace_span", ["parent_span_id"], unique=False)
-    op.create_index("ix_trace_span_span_type", "trace_span", ["span_type"], unique=False)
-    op.create_index("ix_trace_span_status", "trace_span", ["status"], unique=False)
-
-
-def downgrade() -> None:
-    op.drop_index("ix_trace_span_status", table_name="trace_span")
-    op.drop_index("ix_trace_span_span_type", table_name="trace_span")
-    op.drop_index("ix_trace_span_parent_span_id", table_name="trace_span")
-    op.drop_index("ix_trace_span_node_run_id", table_name="trace_span")
-    op.drop_index("ix_trace_span_run_id", table_name="trace_span")
-    op.drop_table("trace_span")

+ 0 - 27
services/runtime-service/alembic/versions/20260423_0006_add_runtime_worker_indexes.py

@@ -1,27 +0,0 @@
-"""add runtime worker indexes
-
-Revision ID: 20260423_0006
-Revises: 20260423_0005
-Create Date: 2026-04-23 18:40:00
-"""
-
-from collections.abc import Sequence
-
-from alembic import op
-
-revision: str = "20260423_0006"
-down_revision: str | None = "20260423_0005"
-branch_labels: Sequence[str] | None = None
-depends_on: Sequence[str] | None = None
-
-
-def upgrade() -> None:
-    op.create_index(
-        "ix_node_run_worker_queue",
-        "node_run",
-        ["status", "lease_expire_time", "created_time"],
-        unique=False)
-
-
-def downgrade() -> None:
-    op.drop_index("ix_node_run_worker_queue", table_name="node_run")

+ 0 - 30
services/runtime-service/alembic/versions/20260425_0007_add_node_run_scheduling.py

@@ -1,30 +0,0 @@
-"""add node run scheduling fields
-
-Revision ID: 20260425_0007
-Revises: 20260423_0006
-Create Date: 2026-04-25 16:20:00
-"""
-
-from collections.abc import Sequence
-
-import sqlalchemy as sa
-from alembic import op
-
-revision: str = "20260425_0007"
-down_revision: str | None = "20260423_0006"
-branch_labels: Sequence[str] | None = None
-depends_on: Sequence[str] | None = None
-
-
-def upgrade() -> None:
-    op.add_column("node_run", sa.Column("scheduled_time", sa.DateTime(), nullable=True))
-    op.add_column("node_run", sa.Column("timeout_time", sa.DateTime(), nullable=True))
-    op.create_index("ix_node_run_scheduled_time", "node_run", ["scheduled_time"], unique=False)
-    op.create_index("ix_node_run_timeout_time", "node_run", ["timeout_time"], unique=False)
-
-
-def downgrade() -> None:
-    op.drop_index("ix_node_run_timeout_time", table_name="node_run")
-    op.drop_index("ix_node_run_scheduled_time", table_name="node_run")
-    op.drop_column("node_run", "timeout_time")
-    op.drop_column("node_run", "scheduled_time")

+ 0 - 22
services/runtime-service/alembic/versions/20260429_9001_remove_runtime_versioning.py

@@ -1,22 +0,0 @@
-"""Remove business version schema artifacts.
-
-Revision ID: 20260429_9001_runtime
-Revises: 20260425_0007
-Create Date: 2026-04-29 00:00:00.000000
-"""
-
-from alembic import op
-
-revision: str = "20260429_9001_runtime"
-down_revision: str | None = "20260425_0007"
-branch_labels = None
-depends_on = None
-
-
-def upgrade() -> None:
-    op.execute("DO $$\nBEGIN\n    IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'workflow_run' AND column_name = 'app_version_id') THEN\n        ALTER TABLE workflow_run RENAME COLUMN app_version_id TO app_config_id;\n    END IF;\n    IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'workflow_run' AND column_name = 'workflow_version_id') THEN\n        ALTER TABLE workflow_run RENAME COLUMN workflow_version_id TO workflow_config_id;\n    END IF;\nEND $$;\nDO $$\nDECLARE\n    table_record record;\nBEGIN\n    FOR table_record IN\n        SELECT table_name\n        FROM information_schema.columns\n        WHERE table_schema = current_schema()\n          AND column_name = 'version'\n    LOOP\n        EXECUTE format('ALTER TABLE %I DROP COLUMN IF EXISTS version', table_record.table_name);\n    END LOOP;\nEND $$;")
-
-
-def downgrade() -> None:
-    # Business version tables and columns were intentionally removed.
-    pass

+ 0 - 1
services/runtime-service/app/__init__.py

@@ -1 +0,0 @@
-

+ 0 - 1
services/runtime-service/app/api/__init__.py

@@ -1 +0,0 @@
-

+ 0 - 465
services/runtime-service/app/api/routes.py

@@ -1,465 +0,0 @@
-from core_domain import ServiceHealth
-from fastapi import APIRouter, Depends, HTTPException, Query
-from sqlalchemy import text
-from sqlalchemy.orm import Session
-
-from app.application.services import (
-    RuntimeApplicationService,
-    RuntimeDebugSnapshot,
-    build_runtime_application_service,
-)
-from app.bootstrap.settings import RuntimeServiceSettings
-from app.db.session import get_db
-from app.infrastructure.code_runner_client import CodeRunnerClientError
-from app.infrastructure.human_client import HumanServiceClientError
-from app.infrastructure.model_gateway_client import ModelGatewayClientError
-from app.infrastructure.tool_client import ToolServiceClientError
-from app.infrastructure.workflow_client import WorkflowServiceClientError
-from app.schemas.run import (
-    ExecutionLogListRequest,
-    ExecutionLogResponse,
-    HumanNodeResumeRequest,
-    NodeArtifactListRequest,
-    NodeArtifactResponse,
-    NodeRunListRequest,
-    NodeRunExecuteRequest,
-    NodeRunExecuteResponse,
-    NodeRunResponse,
-    NodeRunStatusUpdateRequest,
-    RunBootstrapResponse,
-    RunCreateRequest,
-    RunExecuteRequest,
-    RunExecuteResponse,
-    RuntimeDebugContinueRequest,
-    RuntimeDebugSnapshotRequest,
-    RuntimeDebugSnapshotResponse,
-    RuntimeDebugStepResponse,
-    TraceSpanListRequest,
-    TraceSpanResponse,
-    WorkerExecuteNextRequest,
-    WorkerExecuteNextResponse,
-    WorkflowRunListRequest,
-    WorkflowRunResponse,
-    WorkflowRunStatusUpdateRequest,
-)
-
-router = APIRouter()
-
-
-def build_runtime_debug_snapshot_response(snapshot: RuntimeDebugSnapshot) -> RuntimeDebugSnapshotResponse:
-    return RuntimeDebugSnapshotResponse(
-        run=WorkflowRunResponse.from_entity(snapshot.run),
-        node_runs=[
-            NodeRunResponse.from_entity(item)
-            for item in snapshot.node_runs
-        ],
-        run_state_json=snapshot.run_state_json,
-        node_output_json_by_node_id=snapshot.node_output_json_by_node_id,
-        node_output_text_by_node_id=snapshot.node_output_text_by_node_id,
-        queued_node_ids=snapshot.queued_node_ids,
-        running_node_ids=snapshot.running_node_ids,
-        completed_node_ids=snapshot.completed_node_ids,
-        failed_node_ids=snapshot.failed_node_ids,
-        execution_logs=[
-            ExecutionLogResponse.from_entity(item)
-            for item in snapshot.execution_logs
-        ],
-        node_artifacts=[
-            NodeArtifactResponse.from_entity(item)
-            for item in snapshot.node_artifacts
-        ],
-        trace_spans=[
-            TraceSpanResponse.from_entity(item)
-            for item in snapshot.trace_spans
-        ])
-
-
-def get_runtime_settings() -> RuntimeServiceSettings:
-    return RuntimeServiceSettings()
-
-
-def get_runtime_application_service(
-    db: Session = Depends(get_db),
-    settings: RuntimeServiceSettings = Depends(get_runtime_settings)) -> RuntimeApplicationService:
-    return build_runtime_application_service(db=db, settings=settings)
-
-
-@router.get("/health", response_model=ServiceHealth)
-def health_check(db: Session = Depends(get_db)) -> ServiceHealth:
-    db.execute(text("SELECT 1"))
-    return ServiceHealth(service="runtime-service", status="ok", database="ok")
-
-
-@router.post("/runs", response_model=RunBootstrapResponse)
-def create_run(
-    payload: RunCreateRequest,
-    service: RuntimeApplicationService = Depends(get_runtime_application_service)) -> RunBootstrapResponse:
-    try:
-        workflow_run, initial_node = service.create_run(payload)
-    except (
-        CodeRunnerClientError,
-        ModelGatewayClientError,
-        HumanServiceClientError,
-        ToolServiceClientError,
-        WorkflowServiceClientError) as exc:
-        raise HTTPException(status_code=502, detail=str(exc)) from exc
-    return RunBootstrapResponse(
-        run=WorkflowRunResponse.from_entity(workflow_run),
-        initial_node=NodeRunResponse.from_entity(initial_node) if initial_node else None)
-
-
-@router.get("/runs", response_model=list[WorkflowRunResponse])
-def list_runs(
-    session_id: str | None = Query(default=None),
-    limit: int = Query(default=50, ge=1, le=500),
-    service: RuntimeApplicationService = Depends(get_runtime_application_service)) -> list[WorkflowRunResponse]:
-    return [
-        WorkflowRunResponse.from_entity(item)
-        for item in service.list_runs(session_id=session_id, limit=limit)
-    ]
-
-
-@router.post("/runs/list", response_model=list[WorkflowRunResponse])
-def list_runs_post(
-    payload: WorkflowRunListRequest,
-    service: RuntimeApplicationService = Depends(get_runtime_application_service)) -> list[WorkflowRunResponse]:
-    return [
-        WorkflowRunResponse.from_entity(item)
-        for item in service.list_runs(session_id=payload.session_id, limit=payload.limit)
-    ]
-
-
-@router.get("/node-runs", response_model=list[NodeRunResponse])
-def list_node_runs(
-    run_id: str = Query(...),
-    service: RuntimeApplicationService = Depends(get_runtime_application_service)) -> list[NodeRunResponse]:
-    return [
-        NodeRunResponse.from_entity(item)
-        for item in service.list_node_runs(run_id=run_id)
-    ]
-
-
-@router.post("/node-runs/list", response_model=list[NodeRunResponse])
-def list_node_runs_post(
-    payload: NodeRunListRequest,
-    service: RuntimeApplicationService = Depends(get_runtime_application_service)) -> list[NodeRunResponse]:
-    return [
-        NodeRunResponse.from_entity(item)
-        for item in service.list_node_runs(run_id=payload.run_id)
-    ]
-
-
-@router.get("/execution-logs", response_model=list[ExecutionLogResponse])
-def list_execution_logs(
-    run_id: str | None = Query(default=None),
-    node_run_id: str | None = Query(default=None),
-    service: RuntimeApplicationService = Depends(get_runtime_application_service)) -> list[ExecutionLogResponse]:
-    return [
-        ExecutionLogResponse.from_entity(item)
-        for item in service.list_execution_logs(
-            run_id=run_id,
-            node_run_id=node_run_id)
-    ]
-
-
-@router.post("/execution-logs/list", response_model=list[ExecutionLogResponse])
-def list_execution_logs_post(
-    payload: ExecutionLogListRequest,
-    service: RuntimeApplicationService = Depends(get_runtime_application_service)) -> list[ExecutionLogResponse]:
-    return [
-        ExecutionLogResponse.from_entity(item)
-        for item in service.list_execution_logs(
-            run_id=payload.run_id,
-            node_run_id=payload.node_run_id)
-    ]
-
-
-@router.get("/node-artifacts", response_model=list[NodeArtifactResponse])
-def list_node_artifacts(
-    run_id: str | None = Query(default=None),
-    node_run_id: str | None = Query(default=None),
-    artifact_type: str | None = Query(default=None),
-    service: RuntimeApplicationService = Depends(get_runtime_application_service)) -> list[NodeArtifactResponse]:
-    return [
-        NodeArtifactResponse.from_entity(item)
-        for item in service.list_node_artifacts(
-            run_id=run_id,
-            node_run_id=node_run_id,
-            artifact_type=artifact_type)
-    ]
-
-
-@router.post("/node-artifacts/list", response_model=list[NodeArtifactResponse])
-def list_node_artifacts_post(
-    payload: NodeArtifactListRequest,
-    service: RuntimeApplicationService = Depends(get_runtime_application_service)) -> list[NodeArtifactResponse]:
-    return [
-        NodeArtifactResponse.from_entity(item)
-        for item in service.list_node_artifacts(
-            run_id=payload.run_id,
-            node_run_id=payload.node_run_id,
-            artifact_type=payload.artifact_type)
-    ]
-
-
-@router.get("/trace-spans", response_model=list[TraceSpanResponse])
-def list_trace_spans(
-    run_id: str | None = Query(default=None),
-    node_run_id: str | None = Query(default=None),
-    span_type: str | None = Query(default=None),
-    service: RuntimeApplicationService = Depends(get_runtime_application_service)) -> list[TraceSpanResponse]:
-    return [
-        TraceSpanResponse.from_entity(item)
-        for item in service.list_trace_spans(
-            run_id=run_id,
-            node_run_id=node_run_id,
-            span_type=span_type)
-    ]
-
-
-@router.post("/trace-spans/list", response_model=list[TraceSpanResponse])
-def list_trace_spans_post(
-    payload: TraceSpanListRequest,
-    service: RuntimeApplicationService = Depends(get_runtime_application_service)) -> list[TraceSpanResponse]:
-    return [
-        TraceSpanResponse.from_entity(item)
-        for item in service.list_trace_spans(
-            run_id=payload.run_id,
-            node_run_id=payload.node_run_id,
-            span_type=payload.span_type)
-    ]
-
-
-@router.post("/runs/{run_id}/status", response_model=WorkflowRunResponse)
-def update_run_status(
-    run_id: str,
-    payload: WorkflowRunStatusUpdateRequest,
-    service: RuntimeApplicationService = Depends(get_runtime_application_service)) -> WorkflowRunResponse:
-    entity = service.update_run_status(run_id=run_id, payload=payload)
-    if entity is None:
-        raise HTTPException(status_code=404, detail=f"workflow_run not found: {run_id}")
-    return WorkflowRunResponse.from_entity(entity)
-
-
-@router.post("/node-runs/{node_run_id}/status", response_model=NodeRunResponse)
-def update_node_run_status(
-    node_run_id: str,
-    payload: NodeRunStatusUpdateRequest,
-    service: RuntimeApplicationService = Depends(get_runtime_application_service)) -> NodeRunResponse:
-    entity = service.update_node_run_status(node_run_id=node_run_id, payload=payload)
-    if entity is None:
-        raise HTTPException(status_code=404, detail=f"node_run not found: {node_run_id}")
-    return NodeRunResponse.from_entity(entity)
-
-
-@router.post("/node-runs/{node_run_id}/execute", response_model=NodeRunExecuteResponse)
-def execute_node_run(
-    node_run_id: str,
-    payload: NodeRunExecuteRequest,
-    service: RuntimeApplicationService = Depends(get_runtime_application_service)) -> NodeRunExecuteResponse:
-    try:
-        result = service.execute_node_run(node_run_id=node_run_id, payload=payload)
-    except (
-        CodeRunnerClientError,
-        ModelGatewayClientError,
-        HumanServiceClientError,
-        ToolServiceClientError,
-        WorkflowServiceClientError) as exc:
-        raise HTTPException(status_code=502, detail=str(exc)) from exc
-
-    if result is None:
-        raise HTTPException(status_code=404, detail=f"node_run not found: {node_run_id}")
-
-    workflow_run, node_run, executor_name = result
-    return NodeRunExecuteResponse(
-        run=WorkflowRunResponse.from_entity(workflow_run),
-        node_run=NodeRunResponse.from_entity(node_run),
-        executor_name=executor_name)
-
-
-@router.post("/runs/{run_id}/execute-next", response_model=NodeRunExecuteResponse)
-def execute_next_node_run(
-    run_id: str,
-    payload: NodeRunExecuteRequest,
-    service: RuntimeApplicationService = Depends(get_runtime_application_service)) -> NodeRunExecuteResponse:
-    try:
-        result = service.execute_next_node_run(
-            run_id=run_id,
-            payload=payload)
-    except (
-        CodeRunnerClientError,
-        ModelGatewayClientError,
-        HumanServiceClientError,
-        ToolServiceClientError,
-        WorkflowServiceClientError) as exc:
-        raise HTTPException(status_code=502, detail=str(exc)) from exc
-
-    if result is None:
-        raise HTTPException(status_code=404, detail=f"queued node_run not found for run: {run_id}")
-
-    workflow_run, node_run, executor_name = result
-    return NodeRunExecuteResponse(
-        run=WorkflowRunResponse.from_entity(workflow_run),
-        node_run=NodeRunResponse.from_entity(node_run),
-        executor_name=executor_name)
-
-
-@router.post("/runs/{run_id}/execute", response_model=RunExecuteResponse)
-def execute_run(
-    run_id: str,
-    payload: RunExecuteRequest,
-    service: RuntimeApplicationService = Depends(get_runtime_application_service)) -> RunExecuteResponse:
-    try:
-        result = service.execute_run(
-            run_id=run_id,
-            payload=payload)
-    except (
-        CodeRunnerClientError,
-        ModelGatewayClientError,
-        HumanServiceClientError,
-        ToolServiceClientError,
-        WorkflowServiceClientError) as exc:
-        raise HTTPException(status_code=502, detail=str(exc)) from exc
-
-    if result is None:
-        raise HTTPException(status_code=404, detail=f"workflow_run not found: {run_id}")
-
-    workflow_run, node_runs, executor_names = result
-    return RunExecuteResponse(
-        run=WorkflowRunResponse.from_entity(workflow_run),
-        node_runs=[NodeRunResponse.from_entity(item) for item in node_runs],
-        executor_names=executor_names)
-
-
-@router.get("/runs/{run_id}/debug/snapshot", response_model=RuntimeDebugSnapshotResponse)
-def get_runtime_debug_snapshot(
-    run_id: str,
-    service: RuntimeApplicationService = Depends(get_runtime_application_service)) -> RuntimeDebugSnapshotResponse:
-    snapshot = service.get_debug_snapshot(run_id=run_id)
-    if snapshot is None:
-        raise HTTPException(status_code=404, detail=f"workflow_run not found: {run_id}")
-    return build_runtime_debug_snapshot_response(snapshot)
-
-
-@router.post("/runs/debug/snapshot", response_model=RuntimeDebugSnapshotResponse)
-def get_runtime_debug_snapshot_post(
-    payload: RuntimeDebugSnapshotRequest,
-    service: RuntimeApplicationService = Depends(get_runtime_application_service)) -> RuntimeDebugSnapshotResponse:
-    snapshot = service.get_debug_snapshot(run_id=payload.run_id)
-    if snapshot is None:
-        raise HTTPException(status_code=404, detail=f"workflow_run not found: {payload.run_id}")
-    return build_runtime_debug_snapshot_response(snapshot)
-
-
-@router.post("/runs/{run_id}/debug/pause", response_model=RuntimeDebugSnapshotResponse)
-def pause_runtime_debug_run(
-    run_id: str,
-    service: RuntimeApplicationService = Depends(get_runtime_application_service)) -> RuntimeDebugSnapshotResponse:
-    snapshot = service.pause_run(run_id=run_id)
-    if snapshot is None:
-        raise HTTPException(status_code=404, detail=f"workflow_run not found: {run_id}")
-    return build_runtime_debug_snapshot_response(snapshot)
-
-
-@router.post("/runs/{run_id}/debug/resume", response_model=RuntimeDebugSnapshotResponse)
-def resume_runtime_debug_run(
-    run_id: str,
-    service: RuntimeApplicationService = Depends(get_runtime_application_service)) -> RuntimeDebugSnapshotResponse:
-    snapshot = service.resume_run(run_id=run_id)
-    if snapshot is None:
-        raise HTTPException(status_code=404, detail=f"workflow_run not found: {run_id}")
-    return build_runtime_debug_snapshot_response(snapshot)
-
-
-@router.post("/runs/{run_id}/debug/step", response_model=RuntimeDebugStepResponse)
-def step_runtime_debug_run(
-    run_id: str,
-    payload: NodeRunExecuteRequest,
-    service: RuntimeApplicationService = Depends(get_runtime_application_service)) -> RuntimeDebugStepResponse:
-    result = service.step_debug_run(
-        run_id=run_id,
-        worker_key=payload.worker_key)
-    if result is None:
-        raise HTTPException(status_code=404, detail=f"workflow_run not found: {run_id}")
-    snapshot, executed_node_runs, executor_names, reason = result
-    return RuntimeDebugStepResponse(
-        snapshot=build_runtime_debug_snapshot_response(snapshot),
-        executed_node_runs=[NodeRunResponse.from_entity(item) for item in executed_node_runs],
-        executor_names=executor_names,
-        reason=reason)
-
-
-@router.post("/runs/{run_id}/debug/continue", response_model=RuntimeDebugStepResponse)
-def continue_runtime_debug_run(
-    run_id: str,
-    payload: RuntimeDebugContinueRequest,
-    service: RuntimeApplicationService = Depends(get_runtime_application_service)) -> RuntimeDebugStepResponse:
-    result = service.continue_debug_run(
-        run_id=run_id,
-        payload=payload)
-    if result is None:
-        raise HTTPException(status_code=404, detail=f"workflow_run not found: {run_id}")
-    snapshot, executed_node_runs, executor_names, paused_before_node_id, reason = result
-    return RuntimeDebugStepResponse(
-        snapshot=build_runtime_debug_snapshot_response(snapshot),
-        executed_node_runs=[NodeRunResponse.from_entity(item) for item in executed_node_runs],
-        executor_names=executor_names,
-        paused_before_node_id=paused_before_node_id,
-        reason=reason)
-
-
-@router.post("/node-runs/{node_run_id}/resume-human", response_model=NodeRunExecuteResponse)
-def resume_human_node_run(
-    node_run_id: str,
-    payload: HumanNodeResumeRequest,
-    service: RuntimeApplicationService = Depends(get_runtime_application_service)) -> NodeRunExecuteResponse:
-    try:
-        result = service.resume_human_node_run(
-            node_run_id=node_run_id,
-            payload=payload)
-    except (
-        CodeRunnerClientError,
-        ModelGatewayClientError,
-        HumanServiceClientError,
-        ToolServiceClientError,
-        WorkflowServiceClientError) as exc:
-        raise HTTPException(status_code=502, detail=str(exc)) from exc
-
-    if result is None:
-        raise HTTPException(
-            status_code=404,
-            detail=f"human node_run not found or task mismatch: {node_run_id}")
-
-    workflow_run, node_run, executor_name = result
-    return NodeRunExecuteResponse(
-        run=WorkflowRunResponse.from_entity(workflow_run),
-        node_run=NodeRunResponse.from_entity(node_run),
-        executor_name=executor_name)
-
-
-@router.post("/workers/execute-next", response_model=WorkerExecuteNextResponse)
-def execute_next_worker_task(
-    payload: WorkerExecuteNextRequest,
-    settings: RuntimeServiceSettings = Depends(get_runtime_settings),
-    service: RuntimeApplicationService = Depends(get_runtime_application_service)) -> WorkerExecuteNextResponse:
-    try:
-        result = service.execute_next_claimed_node_run(
-            worker_key=payload.worker_key,
-            lease_seconds=payload.lease_seconds or settings.worker_lease_seconds)
-    except (
-        CodeRunnerClientError,
-        ModelGatewayClientError,
-        HumanServiceClientError,
-        ToolServiceClientError,
-        WorkflowServiceClientError) as exc:
-        raise HTTPException(status_code=502, detail=str(exc)) from exc
-
-    if result is None:
-        raise HTTPException(status_code=404, detail="queued worker task not found")
-
-    workflow_run, node_run, executor_name, released_lease_count = result
-    return WorkerExecuteNextResponse(
-        run=WorkflowRunResponse.from_entity(workflow_run),
-        node_run=NodeRunResponse.from_entity(node_run),
-        executor_name=executor_name,
-        released_lease_count=released_lease_count)

+ 0 - 1
services/runtime-service/app/application/__init__.py

@@ -1 +0,0 @@
-

+ 0 - 1260
services/runtime-service/app/application/services.py

@@ -1,1260 +0,0 @@
-from dataclasses import dataclass
-from datetime import datetime, timedelta
-
-from core_domain import (
-    InitialNodeContract,
-    NodeExecutionContextContract,
-    NodeExecutionResultContract,
-    NodeRunStatus,
-    WorkflowRunStatus,
-    WorkflowConfigContract,
-)
-from core_dsl import parse_workflow_definition
-from core_events import EventPublishContract, EventServiceClient, EventServiceClientError
-from core_shared import JSONValue, try_build_redis_client
-from core_shared.task_queue import TaskQueuePublisher
-from sqlalchemy.orm import Session
-
-from app.bootstrap.settings import RuntimeServiceSettings
-from app.db.models import ExecutionLog, NodeArtifact, NodeRun, TraceSpan, WorkflowRun
-from app.domain.repositories import (
-    ExecutionLogRepository,
-    NodeArtifactRepository,
-    NodeRunRepository,
-    TraceSpanRepository,
-    WorkflowRunRepository,
-)
-from app.infrastructure.code_runner_client import CodeRunnerClient
-from app.infrastructure.executors import (
-    NodeExecutionDispatcher,
-    build_node_execution_dispatcher_with_clients,
-)
-from app.infrastructure.human_client import HumanServiceClient
-from app.infrastructure.knowledge_client import KnowledgeServiceClient
-from app.infrastructure.model_gateway_client import ModelGatewayClient
-from app.infrastructure.planner import (
-    derive_initial_node,
-    derive_node_config,
-    derive_successor_nodes,
-)
-from app.infrastructure.tool_client import ToolServiceClient
-from app.infrastructure.workflow_client import WorkflowServiceClient
-from app.schemas.run import (
-    HumanNodeResumeRequest,
-    NodeRunExecuteRequest,
-    NodeRunStatusUpdateRequest,
-    RunCreateRequest,
-    RunExecuteRequest,
-    RuntimeDebugContinueRequest,
-    WorkflowRunStatusUpdateRequest,
-)
-
-
-@dataclass(frozen=True)
-class RuntimeDebugSnapshot:
-    run: WorkflowRun
-    node_runs: list[NodeRun]
-    run_state_json: dict[str, JSONValue]
-    node_output_json_by_node_id: dict[str, dict[str, JSONValue]]
-    node_output_text_by_node_id: dict[str, str]
-    queued_node_ids: list[str]
-    running_node_ids: list[str]
-    completed_node_ids: list[str]
-    failed_node_ids: list[str]
-    execution_logs: list[ExecutionLog]
-    node_artifacts: list[NodeArtifact]
-    trace_spans: list[TraceSpan]
-
-
-class RuntimeApplicationService:
-    def __init__(
-        self,
-        workflow_run_repository: WorkflowRunRepository,
-        node_run_repository: NodeRunRepository,
-        execution_log_repository: ExecutionLogRepository,
-        node_artifact_repository: NodeArtifactRepository,
-        trace_span_repository: TraceSpanRepository,
-        execution_dispatcher: NodeExecutionDispatcher,
-        workflow_client: WorkflowServiceClient | None = None,
-        event_client: EventServiceClient | None = None,
-        task_queue_publisher: TaskQueuePublisher | None = None) -> None:
-        self.workflow_run_repository = workflow_run_repository
-        self.node_run_repository = node_run_repository
-        self.execution_log_repository = execution_log_repository
-        self.node_artifact_repository = node_artifact_repository
-        self.trace_span_repository = trace_span_repository
-        self.execution_dispatcher = execution_dispatcher
-        self.workflow_client = workflow_client
-        self.event_client = event_client
-        self.task_queue_publisher = task_queue_publisher
-
-    def create_run(self, payload: RunCreateRequest) -> tuple[WorkflowRun, NodeRun | None]:
-        initial_node = payload.initial_node or self._plan_initial_node(payload)
-        workflow_run = self.workflow_run_repository.create(
-            app_id=payload.app_id,
-            app_config_id=payload.app_config_id,
-            workflow_id=payload.workflow_id,
-            workflow_config_id=payload.workflow_config_id,
-            session_id=payload.session_id,
-            parent_run_id=payload.parent_run_id,
-            root_run_id=payload.root_run_id,
-            run_type=payload.run_type,
-            trigger_type=payload.trigger_type,
-            priority=payload.priority)
-
-        node_run = None
-        if initial_node is not None:
-            self.workflow_run_repository.update_node_count(
-                run_id=workflow_run.id,
-                current_node_count=1)
-            initial_config = self._resolve_node_config(
-                workflow_config_id=payload.workflow_config_id,
-                node_id=initial_node.node_id)
-            scheduled_time, timeout_time = self._build_node_timing(initial_config)
-            node_run = self.node_run_repository.create(
-                run_id=workflow_run.id,
-                node_id=initial_node.node_id,
-                node_type=initial_node.node_type,
-                status=initial_node.status,
-                scheduled_time=scheduled_time,
-                timeout_time=timeout_time)
-            self._log_event(
-                run_id=workflow_run.id,
-                node_run_id=node_run.id,
-                event_type="node_queued",
-                message=f"initial node queued: {initial_node.node_id}",
-                detail_json={
-                    "node_id": initial_node.node_id,
-                    "node_type": initial_node.node_type,
-                    "status": initial_node.status,
-                })
-            self._publish_node_run_to_queue(node_run)
-
-        self._log_event(
-            run_id=workflow_run.id,
-            node_run_id=node_run.id if node_run is not None else None,
-            event_type="run_created",
-            message="workflow run created",
-            detail_json={
-                "workflow_id": payload.workflow_id,
-                "workflow_config_id": payload.workflow_config_id,
-                "session_id": payload.session_id,
-            })
-        self._publish_event(
-            event_type="workflow.run.created",
-            workflow_run=workflow_run,
-            payload_json={
-                "run_id": workflow_run.id,
-                "workflow_id": workflow_run.workflow_id,
-                "workflow_config_id": workflow_run.workflow_config_id,
-                "status": workflow_run.status,
-            })
-
-        return workflow_run, node_run
-
-    def list_runs(
-        self,
-        session_id: str | None = None,
-        *,
-        limit: int = 50) -> list[WorkflowRun]:
-        return self.workflow_run_repository.list_by_scope(
-            session_id=session_id,
-            limit=limit)
-
-    def list_node_runs(self, run_id: str) -> list[NodeRun]:
-        return self.node_run_repository.list_by_run(run_id=run_id)
-
-    def list_execution_logs(
-        self,
-        run_id: str | None = None,
-        node_run_id: str | None = None):
-        return self.execution_log_repository.list_by_scope(
-            run_id=run_id,
-            node_run_id=node_run_id)
-
-    def list_node_artifacts(
-        self,
-        run_id: str | None = None,
-        node_run_id: str | None = None,
-        artifact_type: str | None = None):
-        return self.node_artifact_repository.list_by_scope(
-            run_id=run_id,
-            node_run_id=node_run_id,
-            artifact_type=artifact_type)
-
-    def list_trace_spans(
-        self,
-        run_id: str | None = None,
-        node_run_id: str | None = None,
-        span_type: str | None = None):
-        return self.trace_span_repository.list_by_scope(
-            run_id=run_id,
-            node_run_id=node_run_id,
-            span_type=span_type)
-
-    def get_debug_snapshot(
-        self,
-        *,
-        run_id: str) -> RuntimeDebugSnapshot | None:
-        workflow_run = self.workflow_run_repository.get_by_id(run_id)
-        if workflow_run is None:
-            return None
-        return self._build_debug_snapshot(workflow_run)
-
-    def update_run_status(
-        self,
-        run_id: str,
-        payload: WorkflowRunStatusUpdateRequest) -> WorkflowRun | None:
-        entity = self.workflow_run_repository.update_status(
-            run_id=run_id,
-            status=payload.status,
-            error_code=payload.error_code,
-            error_message=payload.error_message)
-        if entity is not None:
-            self._publish_event(
-                event_type=f"workflow.run.{entity.status}",
-                workflow_run=entity,
-                payload_json={
-                    "run_id": entity.id,
-                    "status": entity.status,
-                    "error_code": entity.error_code,
-                })
-        return entity
-
-    def update_node_run_status(
-        self,
-        node_run_id: str,
-        payload: NodeRunStatusUpdateRequest) -> NodeRun | None:
-        node_run = self.node_run_repository.update_status(
-            node_run_id=node_run_id,
-            status=payload.status,
-            worker_key=payload.worker_key,
-            error_code=payload.error_code,
-            error_message=payload.error_message,
-            output_text=payload.output_text,
-            output_json=payload.output_json)
-        if node_run is None:
-            return None
-
-        self._log_event(
-            run_id=node_run.run_id,
-            node_run_id=node_run.id,
-            event_type="node_status_updated",
-            message=f"node status updated to {payload.status}",
-            detail_json={
-                "node_id": node_run.node_id,
-                "node_type": node_run.node_type,
-                "status": payload.status,
-                "error_code": payload.error_code,
-            })
-        self._publish_event(
-            event_type=f"workflow.node.{node_run.status}",
-            workflow_run=None,
-            node_run=node_run,
-            payload_json={
-                "run_id": node_run.run_id,
-                "node_run_id": node_run.id,
-                "node_id": node_run.node_id,
-                "node_type": node_run.node_type,
-                "status": node_run.status,
-                "error_code": node_run.error_code,
-            })
-
-        if payload.status == "completed":
-            self._schedule_successor_nodes(node_run)
-        if payload.status == "failed":
-            workflow_run = self.workflow_run_repository.get_by_id(node_run.run_id)
-            if workflow_run is not None:
-                node_config = self._resolve_node_config(
-                    workflow_config_id=workflow_run.workflow_config_id,
-                    node_id=node_run.node_id)
-                self._schedule_compensation_node(node_run=node_run, node_config=node_config)
-
-        self._sync_workflow_run_status_from_nodes(
-            run_id=node_run.run_id)
-        return node_run
-
-    def execute_node_run(
-        self,
-        node_run_id: str,
-        payload: NodeRunExecuteRequest) -> tuple[WorkflowRun, NodeRun, str] | None:
-        node_run = self.node_run_repository.get_by_id(node_run_id)
-        if node_run is None:
-            return None
-
-        workflow_run = self.workflow_run_repository.get_by_id(node_run.run_id)
-        if workflow_run is None:
-            return None
-
-        if workflow_run.status == "paused":
-            return workflow_run, node_run, "debug_paused"
-
-        if node_run.status in {"completed", "failed", "skipped"}:
-            executor_name = self.execution_dispatcher.resolve_executor(
-                node_run.node_type
-            ).executor_name
-            return workflow_run, node_run, executor_name
-
-        node_config = self._resolve_node_config(
-            workflow_config_id=workflow_run.workflow_config_id,
-            node_id=node_run.node_id)
-        if self._node_has_timed_out(node_run):
-            timed_out_node_run = self.update_node_run_status(
-                node_run_id=node_run.id,
-                payload=NodeRunStatusUpdateRequest(
-                    status="failed",
-                    worker_key=payload.worker_key,
-                    error_code="node_timeout",
-                    error_message=f"node timed out: {node_run.node_id}",
-                    output_json={
-                        "timeout_time": node_run.timeout_time.isoformat()
-                        if node_run.timeout_time is not None
-                        else None,
-                    }))
-            if timed_out_node_run is None:
-                return None
-            executor_name = self.execution_dispatcher.resolve_executor(
-                node_run.node_type
-            ).executor_name
-            return workflow_run, timed_out_node_run, executor_name
-
-        running_node_run = self.node_run_repository.update_status(
-            node_run_id=node_run_id,
-            status="running",
-            worker_key=payload.worker_key)
-        if running_node_run is None:
-            return None
-
-        self._log_event(
-            run_id=running_node_run.run_id,
-            node_run_id=running_node_run.id,
-            event_type="node_execution_started",
-            message=f"executing node {running_node_run.node_id}",
-            detail_json={
-                "node_id": running_node_run.node_id,
-                "node_type": running_node_run.node_type,
-                "worker_key": payload.worker_key,
-            })
-
-        context = self._build_execution_context(
-            workflow_run=workflow_run,
-            node_run=running_node_run,
-            worker_key=payload.worker_key,
-            node_config_json=node_config)
-        executor_name = self.execution_dispatcher.resolve_executor(
-            running_node_run.node_type
-        ).executor_name
-        trace_span = self.trace_span_repository.start(
-            run_id=running_node_run.run_id,
-            node_run_id=running_node_run.id,
-            parent_span_id=None,
-            span_type="node_execution",
-            name=f"{running_node_run.node_type}:{running_node_run.node_id}",
-            attributes_json={
-                "node_id": running_node_run.node_id,
-                "node_type": running_node_run.node_type,
-                "executor_name": executor_name,
-                "worker_key": payload.worker_key,
-            })
-
-        try:
-            result, executor_name = self.execution_dispatcher.execute(
-                context=context,
-                request=payload)
-        except Exception as exc:
-            result = NodeExecutionResultContract(
-                status="failed",
-                worker_key=payload.worker_key,
-                error_code="executor_error",
-                error_message=str(exc))
-
-        if result.status == "failed" and self._should_retry_node(
-            node_run=running_node_run,
-            node_config_json=context.node_config_json):
-            retry_time, retry_timeout_time = self._build_retry_timing(context.node_config_json)
-            retried_node_run = self.node_run_repository.requeue_for_retry(
-                node_run_id=running_node_run.id,
-                scheduled_time=retry_time,
-                timeout_time=retry_timeout_time,
-                error_code=result.error_code,
-                error_message=result.error_message,
-                output_text=result.output_text,
-                output_json={
-                    **(result.output_json or {}),
-                    "retry_scheduled_time": retry_time.isoformat(),
-                    "retry_reason": result.error_code or "node_failed",
-                })
-            if retried_node_run is None:
-                return None
-            self._publish_node_run_to_queue(retried_node_run)
-            self.trace_span_repository.finish(
-                span_id=trace_span.id,
-                status="error",
-                error_code=result.error_code,
-                error_message=result.error_message,
-                attributes_json={
-                    "node_status": "queued",
-                    "executor_name": executor_name,
-                    "retry_scheduled": True,
-                    "attempt_no": retried_node_run.attempt_no,
-                })
-            self._log_event(
-                run_id=retried_node_run.run_id,
-                node_run_id=retried_node_run.id,
-                event_type="node_retry_scheduled",
-                message=f"node retry scheduled: {retried_node_run.node_id}",
-                detail_json={
-                    "node_id": retried_node_run.node_id,
-                    "attempt_no": retried_node_run.attempt_no,
-                    "scheduled_time": retry_time.isoformat(),
-                    "error_code": result.error_code,
-                })
-            self._sync_workflow_run_status_from_nodes(
-                run_id=retried_node_run.run_id)
-            workflow_run = self.workflow_run_repository.get_by_id(retried_node_run.run_id)
-            if workflow_run is None:
-                return None
-            return workflow_run, retried_node_run, executor_name
-
-        final_node_run = self.update_node_run_status(
-            node_run_id=running_node_run.id,
-            payload=NodeRunStatusUpdateRequest(
-                status=result.status,
-                worker_key=result.worker_key,
-                error_code=result.error_code,
-                error_message=result.error_message,
-                output_text=result.output_text,
-                output_json=result.output_json))
-        if final_node_run is None:
-            return None
-
-        self.trace_span_repository.finish(
-            span_id=trace_span.id,
-            status="ok" if final_node_run.status == "completed" else "error",
-            error_code=final_node_run.error_code,
-            error_message=final_node_run.error_message,
-            attributes_json={
-                "node_status": final_node_run.status,
-                "executor_name": executor_name,
-                "has_output_text": final_node_run.output_text is not None,
-                "has_output_json": final_node_run.output_json is not None,
-            })
-        self._persist_node_execution_artifact(final_node_run)
-
-        self._log_event(
-            run_id=final_node_run.run_id,
-            node_run_id=final_node_run.id,
-            event_type="node_execution_finished",
-            message=f"node execution finished with status {final_node_run.status}",
-            detail_json={
-                "node_id": final_node_run.node_id,
-                "node_type": final_node_run.node_type,
-                "executor_name": executor_name,
-                "status": final_node_run.status,
-            })
-
-        workflow_run = self.workflow_run_repository.get_by_id(final_node_run.run_id)
-        if workflow_run is None:
-            return None
-        return workflow_run, final_node_run, executor_name
-
-    def execute_next_node_run(
-        self,
-        run_id: str,
-        payload: NodeRunExecuteRequest) -> tuple[WorkflowRun, NodeRun, str] | None:
-        next_node_run = self.node_run_repository.get_next_queued_by_run(
-            run_id=run_id)
-        if next_node_run is None:
-            return None
-        return self.execute_node_run(node_run_id=next_node_run.id, payload=payload)
-
-    def execute_run(
-        self,
-        run_id: str,
-        payload: RunExecuteRequest) -> tuple[WorkflowRun, list[NodeRun], list[str]] | None:
-        workflow_run = self.workflow_run_repository.get_by_id(run_id)
-        if workflow_run is None:
-            return None
-
-        executed_node_runs: list[NodeRun] = []
-        executor_names: list[str] = []
-
-        for _ in range(payload.max_steps):
-            step_result = self.execute_next_node_run(
-                run_id=run_id,
-                payload=NodeRunExecuteRequest(worker_key=payload.worker_key))
-            if step_result is None:
-                break
-
-            workflow_run, node_run, executor_name = step_result
-            executed_node_runs.append(node_run)
-            executor_names.append(executor_name)
-
-            if node_run.status != "completed":
-                break
-
-        final_run = self.workflow_run_repository.get_by_id(run_id)
-        if final_run is None:
-            return None
-        return final_run, executed_node_runs, executor_names
-
-    def pause_run(
-        self,
-        *,
-        run_id: str,
-        reason: str = "debug_pause") -> RuntimeDebugSnapshot | None:
-        workflow_run = self.workflow_run_repository.get_by_id(run_id)
-        if workflow_run is None:
-            return None
-        self._sync_workflow_run_status_from_nodes(run_id=run_id)
-        latest_run = self.workflow_run_repository.get_by_id(run_id)
-        if latest_run is None:
-            return None
-        if latest_run.status in {"completed", "failed", "cancelled"}:
-            paused_run = latest_run
-        else:
-            paused_run = self.workflow_run_repository.update_status(
-                run_id=run_id,
-                status="paused")
-            if paused_run is None:
-                return None
-        self._log_event(
-            run_id=run_id,
-            node_run_id=None,
-            event_type="debug_run_paused",
-            message=f"workflow run paused: {reason}",
-            detail_json={"reason": reason})
-        return self._build_debug_snapshot(paused_run)
-
-    def resume_run(
-        self,
-        *,
-        run_id: str,
-        reason: str = "debug_resume") -> RuntimeDebugSnapshot | None:
-        workflow_run = self.workflow_run_repository.get_by_id(run_id)
-        if workflow_run is None:
-            return None
-        resumed_run = self.workflow_run_repository.update_status(
-            run_id=run_id,
-            status="running")
-        if resumed_run is None:
-            return None
-        self._log_event(
-            run_id=run_id,
-            node_run_id=None,
-            event_type="debug_run_resumed",
-            message=f"workflow run resumed: {reason}",
-            detail_json={"reason": reason})
-        return self._build_debug_snapshot(resumed_run)
-
-    def step_debug_run(
-        self,
-        *,
-        run_id: str,
-        worker_key: str | None = None) -> tuple[RuntimeDebugSnapshot, list[NodeRun], list[str], str] | None:
-        workflow_run = self.workflow_run_repository.get_by_id(run_id)
-        if workflow_run is None:
-            return None
-        if workflow_run.status == "paused":
-            self.workflow_run_repository.update_status(run_id=run_id, status="running")
-
-        result = self.execute_next_node_run(
-            run_id=run_id,
-            payload=NodeRunExecuteRequest(worker_key=worker_key))
-        executed_node_runs: list[NodeRun] = []
-        executor_names: list[str] = []
-        reason = "no_queued_node"
-        if result is not None:
-            _, node_run, executor_name = result
-            executed_node_runs.append(node_run)
-            executor_names.append(executor_name)
-            reason = "step_completed"
-
-        self._sync_workflow_run_status_from_nodes(run_id=run_id)
-        latest_run = self.workflow_run_repository.get_by_id(run_id)
-        if latest_run is None:
-            return None
-        if latest_run.status in {"completed", "failed", "cancelled"}:
-            paused_run = latest_run
-        else:
-            paused_run = self.workflow_run_repository.update_status(
-                run_id=run_id,
-                status="paused")
-            if paused_run is None:
-                return None
-        self._log_event(
-            run_id=run_id,
-            node_run_id=executed_node_runs[-1].id if executed_node_runs else None,
-            event_type="debug_step_finished",
-            message=f"debug step finished: {reason}",
-            detail_json={
-                "reason": reason,
-                "executed_node_ids": [item.node_id for item in executed_node_runs],
-            })
-        return self._build_debug_snapshot(paused_run), executed_node_runs, executor_names, reason
-
-    def continue_debug_run(
-        self,
-        *,
-        run_id: str,
-        payload: RuntimeDebugContinueRequest) -> tuple[RuntimeDebugSnapshot, list[NodeRun], list[str], str | None, str] | None:
-        workflow_run = self.workflow_run_repository.get_by_id(run_id)
-        if workflow_run is None:
-            return None
-        if workflow_run.status == "paused":
-            self.workflow_run_repository.update_status(run_id=run_id, status="running")
-
-        breakpoint_node_ids = set(payload.breakpoint_node_ids)
-        executed_node_runs: list[NodeRun] = []
-        executor_names: list[str] = []
-        paused_before_node_id: str | None = None
-        reason = "completed"
-
-        for _ in range(payload.max_steps):
-            next_node_run = self.node_run_repository.get_next_queued_by_run(
-                run_id=run_id)
-            if next_node_run is None:
-                reason = "no_queued_node"
-                break
-            if next_node_run.node_id in breakpoint_node_ids:
-                paused_before_node_id = next_node_run.node_id
-                reason = "breakpoint_hit"
-                break
-
-            step_result = self.execute_node_run(
-                node_run_id=next_node_run.id,
-                payload=NodeRunExecuteRequest(worker_key=payload.worker_key))
-            if step_result is None:
-                reason = "node_not_found"
-                break
-            _, node_run, executor_name = step_result
-            executed_node_runs.append(node_run)
-            executor_names.append(executor_name)
-            if node_run.status != "completed":
-                reason = f"node_{node_run.status}"
-                break
-        else:
-            reason = "max_steps_reached"
-
-        self._sync_workflow_run_status_from_nodes(run_id=run_id)
-        latest_run = self.workflow_run_repository.get_by_id(run_id)
-        if latest_run is None:
-            return None
-        if latest_run.status in {"completed", "failed", "cancelled"}:
-            paused_run = latest_run
-        else:
-            paused_run = self.workflow_run_repository.update_status(
-                run_id=run_id,
-                status="paused")
-            if paused_run is None:
-                return None
-        self._log_event(
-            run_id=run_id,
-            node_run_id=executed_node_runs[-1].id if executed_node_runs else None,
-            event_type="debug_continue_paused",
-            message=f"debug continue paused: {reason}",
-            detail_json={
-                "reason": reason,
-                "paused_before_node_id": paused_before_node_id,
-                "executed_node_ids": [item.node_id for item in executed_node_runs],
-                "breakpoint_node_ids": list(breakpoint_node_ids),
-            })
-        return (
-            self._build_debug_snapshot(paused_run),
-            executed_node_runs,
-            executor_names,
-            paused_before_node_id,
-            reason)
-
-    def resume_human_node_run(
-        self,
-        *,
-        node_run_id: str,
-        payload: HumanNodeResumeRequest) -> tuple[WorkflowRun, NodeRun, str] | None:
-        node_run = self.node_run_repository.get_by_id(node_run_id)
-        if node_run is None:
-            return None
-
-        output_json = dict(node_run.output_json or {})
-        existing_human_task_id = output_json.get("human_task_id")
-        if existing_human_task_id is not None and existing_human_task_id != payload.human_task_id:
-            return None
-
-        if existing_human_task_id is None:
-            output_json["human_task_id"] = payload.human_task_id
-            self.node_run_repository.update_status(
-                node_run_id=node_run.id,
-                status="pending",
-                worker_key=payload.worker_key,
-                output_json=output_json)
-
-        return self.execute_node_run(
-            node_run_id=node_run_id,
-            payload=NodeRunExecuteRequest(worker_key=payload.worker_key))
-
-    def execute_next_claimed_node_run(
-        self,
-        *,
-        worker_key: str,
-        lease_seconds: int,
-        redis_client: object | None = None) -> tuple[WorkflowRun, NodeRun, str, int] | None:
-        released_lease_count = self.node_run_repository.release_expired_leases(
-            now_time=datetime.utcnow())
-        claimed_node_run = self.node_run_repository.claim_next_queued(
-            worker_key=worker_key,
-            lease_expire_time=datetime.utcnow() + timedelta(seconds=lease_seconds))
-        if claimed_node_run is None:
-            return None
-
-        if redis_client is not None:
-            from core_shared.redis_primitives import DistributedLock, IdempotencyStore
-
-            lock = DistributedLock(
-                client=redis_client,
-                name=f"node-run:{claimed_node_run.id}:lock",
-                ttl_seconds=lease_seconds)
-            if not lock.acquire():
-                return None
-            idempotency_store = IdempotencyStore(
-                client=redis_client,
-                prefix="node-run-idempotency")
-            if not idempotency_store.begin(key=claimed_node_run.id):
-                lock.release()
-                return None
-        else:
-            lock = None
-            idempotency_store = None
-
-        try:
-            result = self.execute_node_run(
-                node_run_id=claimed_node_run.id,
-                payload=NodeRunExecuteRequest(worker_key=worker_key))
-            if idempotency_store is not None and result is not None:
-                _, node_run, executor_name = result
-                idempotency_store.complete(
-                    key=claimed_node_run.id,
-                    result={
-                        "status": node_run.status,
-                        "node_run_id": node_run.id,
-                        "executor_name": executor_name,
-                    })
-        finally:
-            if lock is not None:
-                lock.release()
-        if result is None:
-            return None
-
-        workflow_run, node_run, executor_name = result
-        return workflow_run, node_run, executor_name, released_lease_count
-
-    def _persist_node_execution_artifact(self, node_run: NodeRun) -> None:
-        if node_run.output_text is None and node_run.output_json is None:
-            return
-
-        size_bytes = len(node_run.output_text.encode("utf-8")) if node_run.output_text else None
-        self.node_artifact_repository.create(
-            run_id=node_run.run_id,
-            node_run_id=node_run.id,
-            node_id=node_run.node_id,
-            artifact_type="execution_result",
-            name=f"{node_run.node_id}-execution-result",
-            mime_type="application/json" if node_run.output_json is not None else "text/plain",
-            content_text=node_run.output_text,
-            content_json=node_run.output_json,
-            size_bytes=size_bytes)
-
-    def _plan_initial_node(self, payload: RunCreateRequest) -> InitialNodeContract | None:
-        if self.workflow_client is None:
-            return None
-        workflow_config = self.workflow_client.get_workflow_config(
-            workflow_config_id=payload.workflow_config_id)
-        return derive_initial_node(workflow_config)
-
-    def _resolve_node_config(
-        self,
-        *,
-        workflow_config_id: str,
-        node_id: str) -> dict[str, JSONValue]:
-        if self.workflow_client is None:
-            return {}
-        workflow_config = self.workflow_client.get_workflow_config(
-            workflow_config_id=workflow_config_id)
-        return derive_node_config(workflow_config, node_id)
-
-    def _schedule_successor_nodes(self, node_run: NodeRun) -> None:
-        if self.workflow_client is None:
-            return
-
-        workflow_run = self.workflow_run_repository.get_by_id(node_run.run_id)
-        if workflow_run is None:
-            return
-
-        workflow_config = self.workflow_client.get_workflow_config(
-            workflow_config_id=workflow_run.workflow_config_id)
-        run_state_json, node_output_json_by_node_id, node_output_text_by_node_id = (
-            self._build_run_state_maps(
-                run_id=node_run.run_id)
-        )
-        successor_nodes = derive_successor_nodes(
-            workflow_config,
-            node_run.node_id,
-            current_output_json=node_run.output_json,
-            run_state_json=run_state_json,
-            node_output_json_by_node_id=node_output_json_by_node_id,
-            node_output_text_by_node_id=node_output_text_by_node_id)
-        if not successor_nodes:
-            return
-
-        existing_nodes = self.node_run_repository.list_by_run_and_node_ids(
-            run_id=node_run.run_id,
-            node_ids=[item.node_id for item in successor_nodes])
-        existing_node_counts: dict[str, int] = {}
-        for item in existing_nodes:
-            existing_node_counts[item.node_id] = existing_node_counts.get(item.node_id, 0) + 1
-
-        for successor in successor_nodes:
-            successor_config = derive_node_config(workflow_config, successor.node_id)
-            if not self._is_join_ready(
-                workflow_config=workflow_config,
-                run_node_runs=self.node_run_repository.list_by_run(
-                    run_id=node_run.run_id),
-                successor_node_id=successor.node_id,
-                successor_node_type=successor.node_type,
-                successor_config=successor_config):
-                self._log_event(
-                    run_id=node_run.run_id,
-                    node_run_id=None,
-                    event_type="join_waiting",
-                    message=f"join node waiting for predecessors: {successor.node_id}",
-                    detail_json={
-                        "node_id": successor.node_id,
-                        "source_node_id": node_run.node_id,
-                    })
-                continue
-            if not self._can_schedule_repeated_node(
-                successor_config,
-                existing_count=existing_node_counts.get(successor.node_id, 0)):
-                continue
-            scheduled_time, timeout_time = self._build_node_timing(successor_config)
-            created = self.node_run_repository.create(
-                run_id=node_run.run_id,
-                parent_node_run_id=node_run.id,
-                node_id=successor.node_id,
-                node_type=successor.node_type,
-                status=successor.status,
-                scheduled_time=scheduled_time,
-                timeout_time=timeout_time)
-            self._publish_node_run_to_queue(created)
-            existing_node_counts[successor.node_id] = (
-                existing_node_counts.get(successor.node_id, 0) + 1
-            )
-            self._log_event(
-                run_id=node_run.run_id,
-                node_run_id=None,
-                event_type="node_queued",
-                message=f"successor node queued: {successor.node_id}",
-                detail_json={
-                    "node_id": successor.node_id,
-                    "node_type": successor.node_type,
-                    "status": successor.status,
-                    "source_node_id": node_run.node_id,
-                })
-
-    def _build_execution_context(
-        self,
-        *,
-        workflow_run: WorkflowRun,
-        node_run: NodeRun,
-        worker_key: str | None,
-        node_config_json: dict[str, JSONValue] | None = None) -> NodeExecutionContextContract:
-        run_state_json, node_output_json_by_node_id, node_output_text_by_node_id = (
-            self._build_run_state_maps(
-                run_id=node_run.run_id)
-        )
-        return NodeExecutionContextContract(
-            run_id=node_run.run_id,
-            node_run_id=node_run.id,
-            node_id=node_run.node_id,
-            node_type=node_run.node_type,
-            node_config_json=node_config_json
-            if node_config_json is not None
-            else self._resolve_node_config(
-                workflow_config_id=workflow_run.workflow_config_id,
-                node_id=node_run.node_id),
-            run_state_json=run_state_json,
-            node_output_json_by_node_id=node_output_json_by_node_id,
-            node_output_text_by_node_id=node_output_text_by_node_id,
-            worker_key=worker_key)
-
-    def _build_run_state_maps(
-        self,
-        *,
-        run_id: str) -> tuple[
-        dict[str, JSONValue],
-        dict[str, dict[str, JSONValue]],
-        dict[str, str],
-    ]:
-        node_runs = self.node_run_repository.list_by_run(run_id=run_id)
-        run_state_json: dict[str, JSONValue] = {}
-        node_output_json_by_node_id: dict[str, dict[str, JSONValue]] = {}
-        node_output_text_by_node_id: dict[str, str] = {}
-
-        for item in node_runs:
-            if item.output_json is not None:
-                node_output_json_by_node_id[item.node_id] = dict(item.output_json)
-
-                state_updates = item.output_json.get("state_updates")
-                if isinstance(state_updates, dict):
-                    for state_key, state_value in state_updates.items():
-                        run_state_json[str(state_key)] = state_value
-
-            if item.output_text is not None:
-                node_output_text_by_node_id[item.node_id] = item.output_text
-
-        return run_state_json, node_output_json_by_node_id, node_output_text_by_node_id
-
-    def _build_debug_snapshot(self, workflow_run: WorkflowRun) -> RuntimeDebugSnapshot:
-        node_runs = self.node_run_repository.list_by_run(
-            run_id=workflow_run.id)
-        run_state_json, node_output_json_by_node_id, node_output_text_by_node_id = (
-            self._build_run_state_maps(
-                run_id=workflow_run.id)
-        )
-        return RuntimeDebugSnapshot(
-            run=workflow_run,
-            node_runs=node_runs,
-            run_state_json=run_state_json,
-            node_output_json_by_node_id=node_output_json_by_node_id,
-            node_output_text_by_node_id=node_output_text_by_node_id,
-            queued_node_ids=[
-                item.node_id for item in node_runs if item.status in {"pending", "queued"}
-            ],
-            running_node_ids=[item.node_id for item in node_runs if item.status == "running"],
-            completed_node_ids=[
-                item.node_id for item in node_runs if item.status in {"completed", "skipped"}
-            ],
-            failed_node_ids=[item.node_id for item in node_runs if item.status == "failed"],
-            execution_logs=self.execution_log_repository.list_by_scope(
-                run_id=workflow_run.id),
-            node_artifacts=self.node_artifact_repository.list_by_scope(
-                run_id=workflow_run.id),
-            trace_spans=self.trace_span_repository.list_by_scope(
-                run_id=workflow_run.id))
-
-    def _build_node_timing(
-        self,
-        node_config_json: dict[str, JSONValue]) -> tuple[datetime, datetime | None]:
-        now = datetime.utcnow()
-        delay_seconds = self._read_int_value(node_config_json, "delay_seconds", default=0)
-        timeout_seconds = self._read_int_value(node_config_json, "timeout_seconds", default=0)
-        scheduled_time = now + timedelta(seconds=max(delay_seconds, 0))
-        timeout_time = (
-            scheduled_time + timedelta(seconds=timeout_seconds)
-            if timeout_seconds > 0
-            else None
-        )
-        return scheduled_time, timeout_time
-
-    def _node_has_timed_out(self, node_run: NodeRun) -> bool:
-        return node_run.timeout_time is not None and node_run.timeout_time <= datetime.utcnow()
-
-    def _should_retry_node(
-        self,
-        *,
-        node_run: NodeRun,
-        node_config_json: dict[str, JSONValue]) -> bool:
-        retry_policy = self._read_dict_value(node_config_json, "retry_policy")
-        max_attempts = self._read_int_value(retry_policy, "max_attempts", default=1)
-        return max_attempts > node_run.attempt_no
-
-    def _read_retry_delay_seconds(self, node_config_json: dict[str, JSONValue]) -> int:
-        retry_policy = self._read_dict_value(node_config_json, "retry_policy")
-        return self._read_int_value(retry_policy, "retry_delay_seconds", default=0)
-
-    def _build_retry_timing(
-        self,
-        node_config_json: dict[str, JSONValue]) -> tuple[datetime, datetime | None]:
-        retry_time = datetime.utcnow() + timedelta(
-            seconds=self._read_retry_delay_seconds(node_config_json)
-        )
-        timeout_seconds = self._read_int_value(node_config_json, "timeout_seconds", default=0)
-        timeout_time = (
-            retry_time + timedelta(seconds=timeout_seconds)
-            if timeout_seconds > 0
-            else None
-        )
-        return retry_time, timeout_time
-
-    def _is_join_ready(
-        self,
-        *,
-        workflow_config: WorkflowConfigContract,
-        run_node_runs: list[NodeRun],
-        successor_node_id: str,
-        successor_node_type: str,
-        successor_config: dict[str, JSONValue]) -> bool:
-        join_policy = self._read_string_value(successor_config, "join_policy")
-        if join_policy is None and successor_node_type != "join":
-            return True
-        workflow = self._parse_workflow(workflow_config)
-        if workflow is None:
-            return True
-        predecessor_ids = [
-            edge.source for edge in workflow.edges if edge.target == successor_node_id
-        ]
-        if not predecessor_ids:
-            return True
-        completed_node_ids = {
-            item.node_id
-            for item in run_node_runs
-            if item.status in {"completed", "skipped"}
-        }
-        if join_policy in {None, "all_completed"}:
-            return all(predecessor_id in completed_node_ids for predecessor_id in predecessor_ids)
-        if join_policy == "any_completed":
-            return any(predecessor_id in completed_node_ids for predecessor_id in predecessor_ids)
-        return True
-
-    def _can_schedule_repeated_node(
-        self,
-        node_config_json: dict[str, JSONValue],
-        *,
-        existing_count: int) -> bool:
-        if existing_count == 0:
-            return True
-        allow_loop = self._read_bool_value(node_config_json, "allow_loop", default=False)
-        max_iterations = self._read_int_value(node_config_json, "max_iterations", default=1)
-        return allow_loop and existing_count < max_iterations
-
-    def _schedule_compensation_node(
-        self,
-        *,
-        node_run: NodeRun,
-        node_config: dict[str, JSONValue]) -> None:
-        compensation_node_id = self._read_string_value(node_config, "compensation_node_id")
-        if compensation_node_id is None:
-            compensation_config = self._read_dict_value(node_config, "compensation")
-            compensation_node_id = self._read_string_value(compensation_config, "node_id")
-        if compensation_node_id is None:
-            return
-
-        workflow_run = self.workflow_run_repository.get_by_id(node_run.run_id)
-        if workflow_run is None:
-            return
-        compensation_config = self._resolve_node_config(
-            workflow_config_id=workflow_run.workflow_config_id,
-            node_id=compensation_node_id)
-        existing_nodes = self.node_run_repository.list_by_run_and_node_ids(
-            run_id=node_run.run_id,
-            node_ids=[compensation_node_id])
-        if existing_nodes and not self._can_schedule_repeated_node(
-            compensation_config,
-            existing_count=len(existing_nodes)):
-            return
-        compensation_node_type = self._resolve_workflow_node_type(
-            workflow_config_id=workflow_run.workflow_config_id,
-            node_id=compensation_node_id) or "compensation"
-        scheduled_time, timeout_time = self._build_node_timing(compensation_config)
-        created = self.node_run_repository.create(
-            run_id=node_run.run_id,
-            parent_node_run_id=node_run.id,
-            node_id=compensation_node_id,
-            node_type=compensation_node_type,
-            status="queued",
-            scheduled_time=scheduled_time,
-            timeout_time=timeout_time)
-        self._publish_node_run_to_queue(created)
-        self._log_event(
-            run_id=node_run.run_id,
-            node_run_id=created.id,
-            event_type="compensation_queued",
-            message=f"compensation node queued: {compensation_node_id}",
-            detail_json={
-                "failed_node_id": node_run.node_id,
-                "compensation_node_id": compensation_node_id,
-            })
-
-    def _parse_workflow(self, workflow_config: WorkflowConfigContract):
-        return parse_workflow_definition(workflow_config.dsl_json)
-
-    def _resolve_workflow_node_type(
-        self,
-        *,
-        workflow_config_id: str,
-        node_id: str) -> str | None:
-        if self.workflow_client is None:
-            return None
-        workflow_config = self.workflow_client.get_workflow_config(
-            workflow_config_id=workflow_config_id)
-        workflow = self._parse_workflow(workflow_config)
-        if workflow is None:
-            return None
-        for node in workflow.nodes:
-            if node.id == node_id:
-                return node.type
-        return None
-
-    def _read_string_value(self, payload: dict[str, JSONValue], key: str) -> str | None:
-        value = payload.get(key)
-        if isinstance(value, str) and value:
-            return value
-        return None
-
-    def _read_bool_value(
-        self,
-        payload: dict[str, JSONValue],
-        key: str,
-        *,
-        default: bool) -> bool:
-        value = payload.get(key)
-        if isinstance(value, bool):
-            return value
-        return default
-
-    def _read_int_value(
-        self,
-        payload: dict[str, JSONValue],
-        key: str,
-        *,
-        default: int) -> int:
-        value = payload.get(key)
-        if isinstance(value, int) and not isinstance(value, bool):
-            return value
-        return default
-
-    def _read_dict_value(
-        self,
-        payload: dict[str, JSONValue],
-        key: str) -> dict[str, JSONValue]:
-        value = payload.get(key)
-        if isinstance(value, dict):
-            return {str(item_key): item_value for item_key, item_value in value.items()}
-        return {}
-
-    def _sync_workflow_run_status_from_nodes(self, *, run_id: str) -> None:
-        node_runs = self.node_run_repository.list_by_run(run_id=run_id)
-        if not node_runs:
-            return
-
-        self.workflow_run_repository.update_node_count(
-            run_id=run_id,
-            current_node_count=len(node_runs))
-
-        next_status, error_code, error_message = self._derive_run_status(node_runs)
-        self.workflow_run_repository.update_status(
-            run_id=run_id,
-            status=next_status,
-            error_code=error_code,
-            error_message=error_message)
-        self._log_event(
-            run_id=run_id,
-            node_run_id=None,
-            event_type="run_status_synced",
-            message=f"workflow run status synced to {next_status}",
-            detail_json={
-                "status": next_status,
-                "error_code": error_code,
-            })
-
-    def _derive_run_status(
-        self,
-        node_runs: list[NodeRun]) -> tuple[WorkflowRunStatus, str | None, str | None]:
-        statuses = {node_run.status for node_run in node_runs}
-
-        active_statuses: set[NodeRunStatus] = {"pending", "queued", "running"}
-        if statuses.intersection(active_statuses):
-            return "running", None, None
-
-        if "failed" in statuses:
-            failed_node = next((item for item in node_runs if item.status == "failed"), None)
-            error_code = failed_node.error_code if failed_node is not None else None
-            error_message = failed_node.error_message if failed_node is not None else None
-            return "failed", error_code, error_message
-
-        terminal_statuses: set[NodeRunStatus] = {"completed", "skipped"}
-        if statuses and statuses.issubset(terminal_statuses):
-            return "completed", None, None
-
-        return "running", None, None
-
-    def _log_event(
-        self,
-        *,
-        run_id: str,
-        node_run_id: str | None,
-        event_type: str,
-        message: str,
-        detail_json: dict[str, JSONValue] | None,
-        level: str = "info") -> None:
-        self.execution_log_repository.create(
-            run_id=run_id,
-            node_run_id=node_run_id,
-            event_type=event_type,
-            level=level,
-            message=message,
-            detail_json=detail_json)
-
-    def _publish_event(
-        self,
-        *,
-        event_type: str,
-        payload_json: dict[str, JSONValue],
-        workflow_run: WorkflowRun | None = None,
-        node_run: NodeRun | None = None) -> None:
-        if self.event_client is None:
-            return
-        aggregate_id = workflow_run.id if workflow_run is not None else None
-        if aggregate_id is None and node_run is not None:
-            aggregate_id = node_run.id
-        aggregate_type = "workflow_run" if workflow_run is not None else "node_run"
-        correlation_id = workflow_run.session_id if workflow_run is not None else None
-        if correlation_id is None and node_run is not None:
-            correlation_id = node_run.run_id
-        try:
-            self.event_client.publish_event(
-                EventPublishContract(
-                    event_type=event_type,
-                    source_service="runtime-service",
-                    aggregate_type=aggregate_type,
-                    aggregate_id=aggregate_id,
-                    correlation_id=correlation_id,
-                    payload_json=payload_json)
-            )
-        except EventServiceClientError:
-            return
-
-    def _publish_node_run_to_queue(self, node_run: NodeRun) -> None:
-        if node_run.status != "queued" or self.task_queue_publisher is None:
-            return
-        self.task_queue_publisher.publish_runtime_node_run(
-            node_run_id=node_run.id)
-
-
-def build_runtime_application_service(
-    *,
-    db: Session,
-    settings: RuntimeServiceSettings) -> RuntimeApplicationService:
-    redis_client = try_build_redis_client(settings.redis_url)
-    return RuntimeApplicationService(
-        workflow_run_repository=WorkflowRunRepository(db),
-        node_run_repository=NodeRunRepository(db),
-        execution_log_repository=ExecutionLogRepository(db),
-        node_artifact_repository=NodeArtifactRepository(db),
-        trace_span_repository=TraceSpanRepository(db),
-        execution_dispatcher=build_node_execution_dispatcher_with_clients(
-            code_runner_client=CodeRunnerClient(base_url=settings.code_runner_service_url),
-            model_gateway_client=ModelGatewayClient(base_url=settings.model_gateway_service_url),
-            tool_client=ToolServiceClient(base_url=settings.tool_service_url),
-            human_client=HumanServiceClient(
-                base_url=settings.human_service_url,
-                timeout_seconds=settings.human_service_timeout_seconds),
-            knowledge_client=KnowledgeServiceClient(
-                base_url=settings.knowledge_service_url,
-                timeout_seconds=settings.knowledge_service_timeout_seconds)),
-        workflow_client=WorkflowServiceClient(base_url=settings.workflow_service_url),
-        event_client=EventServiceClient(
-            base_url=settings.event_service_url,
-            timeout_seconds=settings.event_service_timeout_seconds),
-        task_queue_publisher=(
-            TaskQueuePublisher(client=redis_client) if redis_client is not None else None
-        ))

+ 0 - 1
services/runtime-service/app/bootstrap/__init__.py

@@ -1 +0,0 @@
-

+ 0 - 20
services/runtime-service/app/bootstrap/app.py

@@ -1,20 +0,0 @@
-from core_shared.observability import add_observability
-from core_shared.security import add_internal_service_auth
-from fastapi import FastAPI
-
-from app.api.routes import router
-from app.bootstrap.settings import RuntimeServiceSettings
-from app.db.session import build_session_factory
-
-
-def create_app() -> FastAPI:
-    settings = RuntimeServiceSettings()
-    app = FastAPI(
-        title="agent-platform runtime-service",
-        version="0.1.0")
-    app.state.settings = settings
-    app.state.session_factory = build_session_factory(settings)
-    add_observability(app, settings.service_name)
-    add_internal_service_auth(app, settings)
-    app.include_router(router, prefix="/runtime", tags=["runtime"])
-    return app

+ 0 - 19
services/runtime-service/app/bootstrap/settings.py

@@ -1,19 +0,0 @@
-from core_shared import ServiceSettings
-
-
-class RuntimeServiceSettings(ServiceSettings):
-    service_name: str = "runtime-service"
-    service_port: int = 8003
-    workflow_service_url: str = "http://127.0.0.1:8002"
-    tool_service_url: str = "http://127.0.0.1:8004"
-    model_gateway_service_url: str = "http://127.0.0.1:8005"
-    code_runner_service_url: str = "http://127.0.0.1:8006"
-    human_service_url: str = "http://127.0.0.1:8011"
-    human_service_timeout_seconds: float = 10.0
-    knowledge_service_url: str = "http://127.0.0.1:8012"
-    knowledge_service_timeout_seconds: float = 10.0
-    event_service_url: str = "http://127.0.0.1:8013"
-    event_service_timeout_seconds: float = 5.0
-    worker_poll_interval_seconds: float = 1.0
-    worker_lease_seconds: int = 300
-    worker_max_idle_cycles: int | None = None

+ 0 - 1
services/runtime-service/app/db/__init__.py

@@ -1 +0,0 @@
-

+ 0 - 9
services/runtime-service/app/db/models/__init__.py

@@ -1,9 +0,0 @@
-from core_db import Base
-
-from .execution_log import ExecutionLog
-from .node_artifact import NodeArtifact
-from .node_run import NodeRun
-from .trace_span import TraceSpan
-from .workflow_run import WorkflowRun
-
-__all__ = ["Base", "ExecutionLog", "NodeArtifact", "NodeRun", "TraceSpan", "WorkflowRun"]

+ 0 - 16
services/runtime-service/app/db/models/execution_log.py

@@ -1,16 +0,0 @@
-from core_db import AuditMixin, Base, EntityMixin
-from core_shared import JSONValue
-from sqlalchemy import String, Text
-from sqlalchemy import JSON
-from sqlalchemy.orm import Mapped, mapped_column
-
-
-class ExecutionLog(EntityMixin, AuditMixin, Base):
-    __tablename__ = "execution_log"
-
-    run_id: Mapped[str] = mapped_column(String(36), index=True)
-    node_run_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
-    event_type: Mapped[str] = mapped_column(String(64), index=True)
-    level: Mapped[str] = mapped_column(String(16), default="info")
-    message: Mapped[str] = mapped_column(Text)
-    detail_json: Mapped[dict[str, JSONValue] | None] = mapped_column(JSON, nullable=True)

+ 0 - 20
services/runtime-service/app/db/models/node_artifact.py

@@ -1,20 +0,0 @@
-from core_db import AuditMixin, Base, EntityMixin
-from core_shared import JSONValue
-from sqlalchemy import Integer, String, Text
-from sqlalchemy import JSON
-from sqlalchemy.orm import Mapped, mapped_column
-
-
-class NodeArtifact(EntityMixin, AuditMixin, Base):
-    __tablename__ = "node_artifact"
-
-    run_id: Mapped[str] = mapped_column(String(36), index=True)
-    node_run_id: Mapped[str] = mapped_column(String(36), index=True)
-    node_id: Mapped[str] = mapped_column(String(128), index=True)
-    artifact_type: Mapped[str] = mapped_column(String(64), index=True)
-    name: Mapped[str] = mapped_column(String(128))
-    mime_type: Mapped[str | None] = mapped_column(String(128), nullable=True)
-    content_text: Mapped[str | None] = mapped_column(Text, nullable=True)
-    content_json: Mapped[dict[str, JSONValue] | None] = mapped_column(JSON, nullable=True)
-    storage_uri: Mapped[str | None] = mapped_column(String(512), nullable=True)
-    size_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)

+ 0 - 29
services/runtime-service/app/db/models/node_run.py

@@ -1,29 +0,0 @@
-from datetime import datetime
-
-from core_db import AuditMixin, Base, EntityMixin
-from core_shared import JSONValue
-from sqlalchemy import DateTime, Integer, String, Text
-from sqlalchemy import JSON
-from sqlalchemy.orm import Mapped, mapped_column
-
-
-class NodeRun(EntityMixin, AuditMixin, Base):
-    __tablename__ = "node_run"
-
-    run_id: Mapped[str] = mapped_column(String(36), index=True)
-    parent_node_run_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
-    node_id: Mapped[str] = mapped_column(String(128))
-    node_type: Mapped[str] = mapped_column(String(32))
-    attempt_no: Mapped[int] = mapped_column(Integer, default=1)
-    status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
-    worker_key: Mapped[str | None] = mapped_column(String(128), nullable=True)
-    lease_expire_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
-    scheduled_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
-    timeout_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
-    queued_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
-    started_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
-    finished_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
-    output_text: Mapped[str | None] = mapped_column(Text, nullable=True)
-    output_json: Mapped[dict[str, JSONValue] | None] = mapped_column(JSON, nullable=True)
-    error_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
-    error_message: Mapped[str | None] = mapped_column(Text, nullable=True)

+ 0 - 24
services/runtime-service/app/db/models/trace_span.py

@@ -1,24 +0,0 @@
-from datetime import datetime
-
-from core_db import AuditMixin, Base, EntityMixin
-from core_shared import JSONValue
-from sqlalchemy import DateTime, Integer, String, Text
-from sqlalchemy import JSON
-from sqlalchemy.orm import Mapped, mapped_column
-
-
-class TraceSpan(EntityMixin, AuditMixin, Base):
-    __tablename__ = "trace_span"
-
-    run_id: Mapped[str] = mapped_column(String(36), index=True)
-    node_run_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
-    parent_span_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
-    span_type: Mapped[str] = mapped_column(String(64), index=True)
-    name: Mapped[str] = mapped_column(String(128))
-    status: Mapped[str] = mapped_column(String(32), default="running", index=True)
-    started_time: Mapped[datetime] = mapped_column(DateTime)
-    ended_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
-    duration_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
-    attributes_json: Mapped[dict[str, JSONValue] | None] = mapped_column(JSON, nullable=True)
-    error_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
-    error_message: Mapped[str | None] = mapped_column(Text, nullable=True)

+ 0 - 27
services/runtime-service/app/db/models/workflow_run.py

@@ -1,27 +0,0 @@
-from datetime import datetime
-
-from core_db import AuditMixin, Base, EntityMixin
-from sqlalchemy import DateTime, Integer, String, Text
-from sqlalchemy.orm import Mapped, mapped_column
-
-
-class WorkflowRun(EntityMixin, AuditMixin, Base):
-    __tablename__ = "workflow_run"
-
-    app_id: Mapped[str] = mapped_column(String(36), index=True)
-    app_config_id: Mapped[str] = mapped_column(String(36))
-    workflow_id: Mapped[str] = mapped_column(String(36))
-    workflow_config_id: Mapped[str] = mapped_column(String(36))
-    session_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
-    parent_run_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
-    root_run_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
-    run_type: Mapped[str] = mapped_column(String(32), default="main")
-    status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
-    trigger_type: Mapped[str] = mapped_column(String(32), default="user")
-    priority: Mapped[int] = mapped_column(Integer, default=0)
-    current_node_count: Mapped[int] = mapped_column(Integer, default=0)
-    started_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
-    finished_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
-    error_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
-    error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
-

+ 0 - 27
services/runtime-service/app/db/session.py

@@ -1,27 +0,0 @@
-from collections.abc import Generator
-
-from core_db import DatabaseSettings, create_engine_from_settings, create_session_factory
-from fastapi import Request
-from sqlalchemy.orm import Session, sessionmaker
-
-from app.bootstrap.settings import RuntimeServiceSettings
-
-
-def build_session_factory(
-    settings: RuntimeServiceSettings | None = None) -> sessionmaker[Session]:
-    resolved_settings = settings or RuntimeServiceSettings()
-    db_settings = DatabaseSettings(
-        database_url=resolved_settings.database_url,
-        echo_sql=resolved_settings.echo_sql)
-    engine = create_engine_from_settings(db_settings)
-    return create_session_factory(engine)
-
-
-def get_db(request: Request) -> Generator[Session, None, None]:
-    session_factory: sessionmaker[Session] = request.app.state.session_factory
-    session = session_factory()
-    try:
-        yield session
-    finally:
-        session.close()
-

+ 0 - 1
services/runtime-service/app/domain/__init__.py

@@ -1 +0,0 @@
-

+ 0 - 445
services/runtime-service/app/domain/repositories.py

@@ -1,445 +0,0 @@
-from datetime import datetime
-
-from core_domain import NodeRunStatus, WorkflowRunStatus
-from core_shared import JSONValue
-from sqlalchemy import or_, select
-from sqlalchemy.orm import Session
-
-from app.db.models import ExecutionLog, NodeArtifact, NodeRun, TraceSpan, WorkflowRun
-
-
-class WorkflowRunRepository:
-    def __init__(self, db: Session) -> None:
-        self.db = db
-
-    def create(
-        self,
-        *,
-        app_id: str,
-        app_config_id: str,
-        workflow_id: str,
-        workflow_config_id: str,
-        session_id: str | None,
-        parent_run_id: str | None,
-        root_run_id: str | None,
-        run_type: str,
-        trigger_type: str,
-        priority: int) -> WorkflowRun:
-        now = datetime.utcnow()
-        entity = WorkflowRun(
-            app_id=app_id,
-            app_config_id=app_config_id,
-            workflow_id=workflow_id,
-            workflow_config_id=workflow_config_id,
-            session_id=session_id,
-            parent_run_id=parent_run_id,
-            root_run_id=root_run_id,
-            run_type=run_type,
-            trigger_type=trigger_type,
-            priority=priority,
-            status="running",
-            started_time=now)
-        self.db.add(entity)
-        self.db.commit()
-        if entity.root_run_id is None:
-            entity.root_run_id = entity.id
-            self.db.commit()
-        self.db.refresh(entity)
-        return entity
-
-    def list_by_scope(
-        self,
-        *,
-        session_id: str | None = None,
-        limit: int = 50) -> list[WorkflowRun]:
-        stmt = select(WorkflowRun)
-        if session_id:
-            stmt = stmt.where(WorkflowRun.session_id == session_id)
-        stmt = stmt.order_by(WorkflowRun.created_time.desc()).limit(limit)
-        return list(self.db.scalars(stmt))
-
-    def update_node_count(self, *, run_id: str, current_node_count: int) -> None:
-        entity = self.db.get(WorkflowRun, run_id)
-        if entity is None:
-            return
-        entity.current_node_count = current_node_count
-        self.db.commit()
-
-    def get_by_id(self, run_id: str) -> WorkflowRun | None:
-        return self.db.get(WorkflowRun, run_id)
-
-    def update_status(
-        self,
-        *,
-        run_id: str,
-        status: WorkflowRunStatus,
-        error_code: str | None = None,
-        error_message: str | None = None) -> WorkflowRun | None:
-        entity = self.db.get(WorkflowRun, run_id)
-        if entity is None:
-            return None
-
-        entity.status = status
-        entity.error_code = error_code
-        entity.error_message = error_message
-
-        now = datetime.utcnow()
-        if status == "running" and entity.started_time is None:
-            entity.started_time = now
-        if status in {"completed", "failed", "cancelled"}:
-            entity.finished_time = now
-
-        self.db.commit()
-        self.db.refresh(entity)
-        return entity
-
-
-class NodeRunRepository:
-    def __init__(self, db: Session) -> None:
-        self.db = db
-
-    def create(
-        self,
-        *,
-        run_id: str,
-        node_id: str,
-        node_type: str,
-        status: str,
-        scheduled_time: datetime | None = None,
-        timeout_time: datetime | None = None,
-        parent_node_run_id: str | None = None) -> NodeRun:
-        now = datetime.utcnow()
-        entity = NodeRun(
-            run_id=run_id,
-            parent_node_run_id=parent_node_run_id,
-            node_id=node_id,
-            node_type=node_type,
-            status=status,
-            queued_time=now,
-            scheduled_time=scheduled_time or now,
-            timeout_time=timeout_time)
-        self.db.add(entity)
-        self.db.commit()
-        self.db.refresh(entity)
-        return entity
-
-    def list_by_run(self, *, run_id: str) -> list[NodeRun]:
-        stmt = (
-            select(NodeRun)
-            .where(NodeRun.run_id == run_id)
-            .order_by(NodeRun.created_time.asc())
-        )
-        return list(self.db.scalars(stmt))
-
-    def list_by_run_and_node_ids(
-        self,
-        *,
-        run_id: str,
-        node_ids: list[str]) -> list[NodeRun]:
-        if not node_ids:
-            return []
-        stmt = (
-            select(NodeRun)
-            .where(NodeRun.run_id == run_id)
-            .where(NodeRun.node_id.in_(node_ids))
-        )
-        return list(self.db.scalars(stmt))
-
-    def get_by_id(self, node_run_id: str) -> NodeRun | None:
-        return self.db.get(NodeRun, node_run_id)
-
-    def get_next_queued_by_run(self, *, run_id: str) -> NodeRun | None:
-        stmt = (
-            select(NodeRun)
-            .where(NodeRun.run_id == run_id)
-            .where(NodeRun.status == "queued")
-            .where(
-                or_(
-                    NodeRun.scheduled_time.is_(None),
-                    NodeRun.scheduled_time <= datetime.utcnow())
-            )
-            .order_by(NodeRun.created_time.asc())
-            .limit(1)
-        )
-        return self.db.scalar(stmt)
-
-    def claim_next_queued(
-        self,
-        *,
-        worker_key: str,
-        lease_expire_time: datetime) -> NodeRun | None:
-        stmt = (
-            select(NodeRun)
-            .join(WorkflowRun, NodeRun.run_id == WorkflowRun.id)
-            .where(NodeRun.status == "queued")
-            .where(
-                or_(
-                    NodeRun.scheduled_time.is_(None),
-                    NodeRun.scheduled_time <= datetime.utcnow())
-            )
-            .order_by(WorkflowRun.priority.desc(), NodeRun.created_time.asc())
-            .with_for_update(skip_locked=True)
-            .limit(1)
-        )
-        entity = self.db.scalar(stmt)
-        if entity is None:
-            return None
-
-        now = datetime.utcnow()
-        entity.status = "running"
-        entity.worker_key = worker_key
-        entity.started_time = entity.started_time or now
-        entity.lease_expire_time = lease_expire_time
-        self.db.commit()
-        self.db.refresh(entity)
-        return entity
-
-    def release_expired_leases(self, *, now_time: datetime, max_items: int = 100) -> int:
-        stmt = (
-            select(NodeRun)
-            .where(NodeRun.status == "running")
-            .where(NodeRun.lease_expire_time.is_not(None))
-            .where(NodeRun.lease_expire_time <= now_time)
-            .order_by(NodeRun.lease_expire_time.asc())
-            .limit(max_items)
-        )
-        entities = list(self.db.scalars(stmt))
-        for entity in entities:
-            entity.status = "queued"
-            entity.worker_key = None
-            entity.lease_expire_time = None
-            entity.scheduled_time = now_time
-            entity.queued_time = now_time
-            entity.started_time = None
-            entity.finished_time = None
-            entity.attempt_no += 1
-
-        if entities:
-            self.db.commit()
-
-        return len(entities)
-
-    def update_status(
-        self,
-        *,
-        node_run_id: str,
-        status: NodeRunStatus,
-        worker_key: str | None = None,
-        error_code: str | None = None,
-        error_message: str | None = None,
-        output_text: str | None = None,
-        output_json: dict[str, JSONValue] | None = None) -> NodeRun | None:
-        entity = self.db.get(NodeRun, node_run_id)
-        if entity is None:
-            return None
-
-        entity.status = status
-        entity.worker_key = worker_key
-        entity.error_code = error_code
-        entity.error_message = error_message
-        entity.output_text = output_text
-        entity.output_json = output_json
-
-        now = datetime.utcnow()
-        if status == "running" and entity.started_time is None:
-            entity.started_time = now
-        if status != "running":
-            entity.lease_expire_time = None
-        if status in {"completed", "failed", "skipped"}:
-            entity.finished_time = now
-
-        self.db.commit()
-        self.db.refresh(entity)
-        return entity
-
-    def requeue_for_retry(
-        self,
-        *,
-        node_run_id: str,
-        scheduled_time: datetime,
-        timeout_time: datetime | None,
-        error_code: str | None,
-        error_message: str | None,
-        output_text: str | None,
-        output_json: dict[str, JSONValue] | None) -> NodeRun | None:
-        entity = self.db.get(NodeRun, node_run_id)
-        if entity is None:
-            return None
-
-        entity.status = "queued"
-        entity.attempt_no += 1
-        entity.worker_key = None
-        entity.lease_expire_time = None
-        entity.scheduled_time = scheduled_time
-        entity.timeout_time = timeout_time
-        entity.queued_time = datetime.utcnow()
-        entity.started_time = None
-        entity.finished_time = None
-        entity.error_code = error_code
-        entity.error_message = error_message
-        entity.output_text = output_text
-        entity.output_json = output_json
-        self.db.commit()
-        self.db.refresh(entity)
-        return entity
-
-
-class ExecutionLogRepository:
-    def __init__(self, db: Session) -> None:
-        self.db = db
-
-    def create(
-        self,
-        *,
-        run_id: str,
-        node_run_id: str | None,
-        event_type: str,
-        level: str,
-        message: str,
-        detail_json: dict[str, JSONValue] | None) -> ExecutionLog:
-        entity = ExecutionLog(
-            run_id=run_id,
-            node_run_id=node_run_id,
-            event_type=event_type,
-            level=level,
-            message=message,
-            detail_json=detail_json)
-        self.db.add(entity)
-        self.db.commit()
-        self.db.refresh(entity)
-        return entity
-
-    def list_by_scope(
-        self,
-        *,
-        run_id: str | None = None,
-        node_run_id: str | None = None) -> list[ExecutionLog]:
-        stmt = select(ExecutionLog)
-        if run_id is not None:
-            stmt = stmt.where(ExecutionLog.run_id == run_id)
-        if node_run_id is not None:
-            stmt = stmt.where(ExecutionLog.node_run_id == node_run_id)
-        stmt = stmt.order_by(ExecutionLog.created_time.asc())
-        return list(self.db.scalars(stmt))
-
-
-class NodeArtifactRepository:
-    def __init__(self, db: Session) -> None:
-        self.db = db
-
-    def create(
-        self,
-        *,
-        run_id: str,
-        node_run_id: str,
-        node_id: str,
-        artifact_type: str,
-        name: str,
-        mime_type: str | None,
-        content_text: str | None,
-        content_json: dict[str, JSONValue] | None,
-        storage_uri: str | None = None,
-        size_bytes: int | None = None) -> NodeArtifact:
-        entity = NodeArtifact(
-            run_id=run_id,
-            node_run_id=node_run_id,
-            node_id=node_id,
-            artifact_type=artifact_type,
-            name=name,
-            mime_type=mime_type,
-            content_text=content_text,
-            content_json=content_json,
-            storage_uri=storage_uri,
-            size_bytes=size_bytes)
-        self.db.add(entity)
-        self.db.commit()
-        self.db.refresh(entity)
-        return entity
-
-    def list_by_scope(
-        self,
-        *,
-        run_id: str | None = None,
-        node_run_id: str | None = None,
-        artifact_type: str | None = None) -> list[NodeArtifact]:
-        stmt = select(NodeArtifact)
-        if run_id is not None:
-            stmt = stmt.where(NodeArtifact.run_id == run_id)
-        if node_run_id is not None:
-            stmt = stmt.where(NodeArtifact.node_run_id == node_run_id)
-        if artifact_type is not None:
-            stmt = stmt.where(NodeArtifact.artifact_type == artifact_type)
-        stmt = stmt.order_by(NodeArtifact.created_time.asc())
-        return list(self.db.scalars(stmt))
-
-
-class TraceSpanRepository:
-    def __init__(self, db: Session) -> None:
-        self.db = db
-
-    def start(
-        self,
-        *,
-        run_id: str,
-        node_run_id: str | None,
-        parent_span_id: str | None,
-        span_type: str,
-        name: str,
-        attributes_json: dict[str, JSONValue] | None = None) -> TraceSpan:
-        entity = TraceSpan(
-            run_id=run_id,
-            node_run_id=node_run_id,
-            parent_span_id=parent_span_id,
-            span_type=span_type,
-            name=name,
-            status="running",
-            started_time=datetime.utcnow(),
-            attributes_json=attributes_json)
-        self.db.add(entity)
-        self.db.commit()
-        self.db.refresh(entity)
-        return entity
-
-    def finish(
-        self,
-        *,
-        span_id: str,
-        status: str,
-        error_code: str | None = None,
-        error_message: str | None = None,
-        attributes_json: dict[str, JSONValue] | None = None) -> TraceSpan | None:
-        entity = self.db.get(TraceSpan, span_id)
-        if entity is None:
-            return None
-
-        ended_time = datetime.utcnow()
-        entity.status = status
-        entity.ended_time = ended_time
-        entity.duration_ms = int((ended_time - entity.started_time).total_seconds() * 1000)
-        entity.error_code = error_code
-        entity.error_message = error_message
-        if attributes_json is not None:
-            entity.attributes_json = {
-                **(entity.attributes_json or {}),
-                **attributes_json,
-            }
-
-        self.db.commit()
-        self.db.refresh(entity)
-        return entity
-
-    def list_by_scope(
-        self,
-        *,
-        run_id: str | None = None,
-        node_run_id: str | None = None,
-        span_type: str | None = None) -> list[TraceSpan]:
-        stmt = select(TraceSpan)
-        if run_id is not None:
-            stmt = stmt.where(TraceSpan.run_id == run_id)
-        if node_run_id is not None:
-            stmt = stmt.where(TraceSpan.node_run_id == node_run_id)
-        if span_type is not None:
-            stmt = stmt.where(TraceSpan.span_type == span_type)
-        stmt = stmt.order_by(TraceSpan.started_time.asc())
-        return list(self.db.scalars(stmt))

+ 0 - 20
services/runtime-service/app/infrastructure/__init__.py

@@ -1,20 +0,0 @@
-from .code_runner_client import CodeRunnerClient, CodeRunnerClientError
-from .executors import (
-    NodeExecutionDispatcher,
-    build_node_execution_dispatcher,
-    build_node_execution_dispatcher_with_clients,
-)
-from .model_gateway_client import ModelGatewayClient, ModelGatewayClientError
-from .tool_client import ToolServiceClient, ToolServiceClientError
-
-__all__ = [
-    "CodeRunnerClient",
-    "CodeRunnerClientError",
-    "NodeExecutionDispatcher",
-    "ModelGatewayClient",
-    "ModelGatewayClientError",
-    "ToolServiceClient",
-    "ToolServiceClientError",
-    "build_node_execution_dispatcher",
-    "build_node_execution_dispatcher_with_clients",
-]

+ 0 - 25
services/runtime-service/app/infrastructure/code_runner_client.py

@@ -1,25 +0,0 @@
-import httpx
-from core_domain import CodeExecutionRequestContract, CodeExecutionResponseContract
-
-
-class CodeRunnerClientError(Exception):
-    pass
-
-
-class CodeRunnerClient:
-    def __init__(self, base_url: str, timeout_seconds: float = 60.0) -> None:
-        self.base_url = base_url.rstrip("/")
-        self.timeout_seconds = timeout_seconds
-
-    def execute_code(
-        self,
-        payload: CodeExecutionRequestContract) -> CodeExecutionResponseContract:
-        try:
-            with httpx.Client(timeout=self.timeout_seconds) as client:
-                response = client.post(
-                    f"{self.base_url}/code/execute",
-                    json=payload.model_dump(mode="json"))
-                response.raise_for_status()
-                return CodeExecutionResponseContract.model_validate(response.json())
-        except httpx.HTTPError as exc:
-            raise CodeRunnerClientError(f"code-runner-service request failed: {exc}") from exc

+ 0 - 190
services/runtime-service/app/infrastructure/context.py

@@ -1,190 +0,0 @@
-import json
-import re
-from collections.abc import Callable
-
-from core_shared import JSONValue
-
-TEMPLATE_PATTERN = re.compile(r"\{\{\s*(?P<expr>[^{}]+?)\s*\}\}")
-COMPARISON_OPERATORS = ("==", "!=", ">=", "<=", ">", "<")
-
-
-def build_template_context(
-    *,
-    node_id: str,
-    node_type: str,
-    run_state_json: dict[str, JSONValue],
-    node_output_json_by_node_id: dict[str, dict[str, JSONValue]],
-    node_output_text_by_node_id: dict[str, str]) -> dict[str, JSONValue]:
-    current_node_outputs = node_output_json_by_node_id.get(node_id, {})
-    current_node_text = node_output_text_by_node_id.get(node_id)
-
-    return {
-        "state": run_state_json,
-        "nodes": {
-            item_node_id: {
-                "output": output_json,
-                "text": node_output_text_by_node_id.get(item_node_id),
-            }
-            for item_node_id, output_json in node_output_json_by_node_id.items()
-        },
-        "current": {
-            "node_id": node_id,
-            "node_type": node_type,
-            "output": current_node_outputs,
-            "text": current_node_text,
-        },
-    }
-
-
-def render_template_string(template: str, context: dict[str, JSONValue]) -> str:
-    def replace(match: re.Match[str]) -> str:
-        expression = match.group("expr").strip()
-        value = resolve_expression(context, expression)
-        if value is None:
-            return ""
-        if isinstance(value, (dict, list)):
-            return json.dumps(value, ensure_ascii=True, separators=(",", ":"))
-        return str(value)
-
-    return TEMPLATE_PATTERN.sub(replace, template)
-
-
-def render_json_value(value: JSONValue, context: dict[str, JSONValue]) -> JSONValue:
-    if isinstance(value, str):
-        return render_template_string(value, context)
-    if isinstance(value, list):
-        return [render_json_value(item, context) for item in value]
-    if isinstance(value, dict):
-        return {
-            str(item_key): render_json_value(item_value, context)
-            for item_key, item_value in value.items()
-        }
-    return value
-
-
-def evaluate_condition_expression(expression: str, context: dict[str, JSONValue]) -> bool:
-    stripped_expression = expression.strip()
-    if not stripped_expression:
-        return False
-
-    for operator in COMPARISON_OPERATORS:
-        if operator in stripped_expression:
-            left_text, right_text = stripped_expression.split(operator, 1)
-            left_value = resolve_expression(context, left_text.strip())
-            right_value = resolve_expression(context, right_text.strip())
-            return compare_values(left_value, right_value, operator)
-
-    resolved = resolve_expression(context, stripped_expression)
-    return coerce_bool(resolved)
-
-
-def resolve_expression(context: dict[str, JSONValue], expression: str) -> JSONValue:
-    if expression == "":
-        return None
-
-    if (expression.startswith('"') and expression.endswith('"')) or (
-        expression.startswith("'") and expression.endswith("'")
-    ):
-        return expression[1:-1]
-
-    lowered = expression.lower()
-    if lowered == "true":
-        return True
-    if lowered == "false":
-        return False
-    if lowered == "null":
-        return None
-
-    integer_value = try_parse_int(expression)
-    if integer_value is not None:
-        return integer_value
-
-    float_value = try_parse_float(expression)
-    if float_value is not None:
-        return float_value
-
-    return resolve_reference(context, expression)
-
-
-def resolve_reference(context: dict[str, JSONValue], path: str) -> JSONValue:
-    current: JSONValue = context
-    for segment in path.split("."):
-        if not segment:
-            return None
-        if isinstance(current, dict):
-            current = current.get(segment)
-            continue
-        if isinstance(current, list) and segment.isdigit():
-            index = int(segment)
-            if index < 0 or index >= len(current):
-                return None
-            current = current[index]
-            continue
-        return None
-    return current
-
-
-def coerce_bool(value: JSONValue) -> bool:
-    if isinstance(value, bool):
-        return value
-    if value is None:
-        return False
-    if isinstance(value, (int, float)):
-        return value != 0
-    if isinstance(value, str):
-        lowered = value.strip().lower()
-        if lowered in {"", "false", "0", "null", "none"}:
-            return False
-        return True
-    if isinstance(value, (list, dict)):
-        return len(value) > 0
-    return False
-
-
-def compare_values(left: JSONValue, right: JSONValue, operator: str) -> bool:
-    if operator == "==":
-        return left == right
-    if operator == "!=":
-        return left != right
-    if operator == ">":
-        return compare_order(left, right, lambda x, y: x > y)
-    if operator == "<":
-        return compare_order(left, right, lambda x, y: x < y)
-    if operator == ">=":
-        return compare_order(left, right, lambda x, y: x >= y)
-    if operator == "<=":
-        return compare_order(left, right, lambda x, y: x <= y)
-    return False
-
-
-def compare_order(
-    left: JSONValue,
-    right: JSONValue,
-    operator: Callable[[int | float | str, int | float | str], bool]) -> bool:
-    if isinstance(left, (int, float)) and isinstance(right, (int, float)):
-        return bool(operator(left, right))
-    if isinstance(left, str) and isinstance(right, str):
-        return bool(operator(left, right))
-    return False
-
-
-def try_parse_int(value: str) -> int | None:
-    if not value or any(item in value for item in {".", "e", "E"}):
-        return None
-    if value.startswith(("+", "-")):
-        digits = value[1:]
-    else:
-        digits = value
-    if not digits.isdigit():
-        return None
-    return int(value)
-
-
-def try_parse_float(value: str) -> float | None:
-    try:
-        parsed = float(value)
-    except ValueError:
-        return None
-    if parsed.is_integer() and "." not in value and "e" not in value.lower():
-        return None
-    return parsed

+ 0 - 1230
services/runtime-service/app/infrastructure/executors.py

@@ -1,1230 +0,0 @@
-import re
-from abc import ABC, abstractmethod
-from dataclasses import dataclass
-from datetime import datetime, timedelta
-from typing import cast
-
-import httpx
-from core_domain import (
-    ChatCompletionRequestContract,
-    ChatMessageContract,
-    CodeExecutionRequestContract,
-    HumanTaskCreateContract,
-    HumanTaskType,
-    KnowledgeSearchRequestContract,
-    NodeExecutionContextContract,
-    NodeExecutionRequestContract,
-    NodeExecutionResultContract,
-    ToolBindingDetailContract,
-)
-from core_shared import JSONValue
-
-from .code_runner_client import CodeRunnerClient, CodeRunnerClientError
-from .context import (
-    build_template_context,
-    coerce_bool,
-    evaluate_condition_expression,
-    render_json_value,
-    render_template_string,
-    resolve_expression,
-)
-from .human_client import HumanServiceClient, HumanServiceClientError
-from .knowledge_client import KnowledgeServiceClient, KnowledgeServiceClientError
-from .model_gateway_client import ModelGatewayClient, ModelGatewayClientError
-from .tool_client import ToolServiceClient, ToolServiceClientError
-
-
-class NodeExecutor(ABC):
-    executor_name: str
-    supported_node_types: frozenset[str]
-
-    @abstractmethod
-    def execute(
-        self,
-        context: NodeExecutionContextContract,
-        request: NodeExecutionRequestContract) -> NodeExecutionResultContract:
-        raise NotImplementedError
-
-
-class CompletedNodeExecutor(NodeExecutor):
-    def __init__(self, *, executor_name: str, supported_node_types: frozenset[str]) -> None:
-        self.executor_name = executor_name
-        self.supported_node_types = supported_node_types
-
-    def execute(
-        self,
-        context: NodeExecutionContextContract,
-        request: NodeExecutionRequestContract) -> NodeExecutionResultContract:
-        worker_key = request.worker_key or f"{self.executor_name}:{context.node_type}"
-        return NodeExecutionResultContract(
-            status="completed",
-            worker_key=worker_key,
-            output_json={
-                "executor_name": self.executor_name,
-                "node_type": context.node_type,
-            })
-
-
-class DefaultNodeExecutor(CompletedNodeExecutor):
-    def __init__(self) -> None:
-        super().__init__(
-            executor_name="default-executor",
-            supported_node_types=frozenset())
-
-
-class LLMNodeExecutor(CompletedNodeExecutor):
-    def __init__(self, model_gateway_client: ModelGatewayClient | None = None) -> None:
-        super().__init__(
-            executor_name="llm-executor",
-            supported_node_types=frozenset({"llm"}))
-        self.model_gateway_client = model_gateway_client
-
-    def execute(
-        self,
-        context: NodeExecutionContextContract,
-        request: NodeExecutionRequestContract) -> NodeExecutionResultContract:
-        worker_key = request.worker_key or f"{self.executor_name}:{context.node_type}"
-        render_context = _build_executor_template_context(context)
-        rendered_config_json = _render_config_json(context.node_config_json, render_context)
-        chat_request = _build_chat_completion_request(rendered_config_json)
-        if chat_request is None:
-            return NodeExecutionResultContract(
-                status="failed",
-                worker_key=worker_key,
-                error_code="llm_config_missing",
-                error_message="llm node config requires prompt or messages")
-        if self.model_gateway_client is None:
-            return NodeExecutionResultContract(
-                status="failed",
-                worker_key=worker_key,
-                error_code="llm_gateway_missing",
-                error_message="model gateway client is not configured")
-
-        try:
-            response = self.model_gateway_client.create_chat_completion(chat_request)
-        except ModelGatewayClientError as exc:
-            return NodeExecutionResultContract(
-                status="failed",
-                worker_key=worker_key,
-                error_code="llm_request_failed",
-                error_message=str(exc))
-
-        return NodeExecutionResultContract(
-            status="completed",
-            worker_key=worker_key,
-            output_text=response.content,
-            output_json={
-                "executor_name": self.executor_name,
-                "model": response.model,
-                "finish_reason": response.finish_reason,
-                "usage_json": response.usage_json,
-                "raw_response_json": response.raw_response_json,
-            })
-
-
-class ToolNodeExecutor(CompletedNodeExecutor):
-    def __init__(self, tool_client: ToolServiceClient | None = None) -> None:
-        super().__init__(
-            executor_name="tool-executor",
-            supported_node_types=frozenset({"tool"}))
-        self.tool_client = tool_client
-
-    def execute(
-        self,
-        context: NodeExecutionContextContract,
-        request: NodeExecutionRequestContract) -> NodeExecutionResultContract:
-        tool_binding_id = _read_string_value(context.node_config_json, "tool_binding_id")
-        tool_code = _read_string_value(context.node_config_json, "tool_code")
-        worker_key = request.worker_key or f"{self.executor_name}:{context.node_type}"
-
-        if tool_binding_id is None and tool_code is None:
-            return NodeExecutionResultContract(
-                status="failed",
-                worker_key=worker_key,
-                error_code="tool_config_missing",
-                error_message="tool node config requires tool_binding_id or tool_code")
-
-        if tool_binding_id is not None and self.tool_client is not None:
-            try:
-                detail = self.tool_client.get_tool_binding_detail(
-                    binding_id=tool_binding_id)
-            except ToolServiceClientError as exc:
-                return NodeExecutionResultContract(
-                    status="failed",
-                    worker_key=worker_key,
-                    error_code="tool_binding_lookup_failed",
-                    error_message=str(exc))
-            if not detail.binding.enabled:
-                return NodeExecutionResultContract(
-                    status="failed",
-                    worker_key=worker_key,
-                    error_code="tool_binding_disabled",
-                    error_message=f"tool binding is disabled: {tool_binding_id}")
-
-            resolved_tool_code = detail.tool_definition.code
-            resolved_tool_connection_id = detail.connection.id
-            resolved_tool_name = detail.tool_definition.name
-            invoke_result = self._invoke_http_tool(
-                context=context,
-                detail=detail,
-                worker_key=worker_key)
-            if invoke_result is not None:
-                return invoke_result
-        else:
-            resolved_tool_code = tool_code
-            resolved_tool_connection_id = None
-            resolved_tool_name = None
-
-        return NodeExecutionResultContract(
-            status="completed",
-            worker_key=worker_key,
-            output_text=f"tool node completed: {resolved_tool_code or 'unknown-tool'}",
-            output_json={
-                "executor_name": self.executor_name,
-                "tool_binding_id": tool_binding_id,
-                "tool_code": resolved_tool_code,
-                "tool_connection_id": resolved_tool_connection_id,
-                "tool_name": resolved_tool_name,
-            })
-
-    def _invoke_http_tool(
-        self,
-        *,
-        context: NodeExecutionContextContract,
-        detail: ToolBindingDetailContract,
-        worker_key: str) -> NodeExecutionResultContract | None:
-        if detail.tool_definition.tool_type != "http":
-            return None
-
-        invoke_config_json = detail.connection.invoke_config_json or {}
-        binding_config_json = detail.binding.config_json or {}
-        render_context = _build_executor_template_context(context)
-        request_headers = _merge_json_dicts(
-            _render_json_dict(_read_dict_value(invoke_config_json, "headers"), render_context),
-            _render_json_dict(_read_dict_value(binding_config_json, "headers"), render_context),
-            _render_json_dict(
-                _read_dict_value(context.node_config_json, "headers"),
-                render_context))
-        request_query = _merge_json_dicts(
-            _render_json_dict(_read_dict_value(invoke_config_json, "query"), render_context),
-            _render_json_dict(_read_dict_value(context.node_config_json, "query"), render_context))
-        request_body = _merge_json_dicts(
-            _render_json_dict(_read_dict_value(invoke_config_json, "body"), render_context),
-            _render_json_dict(_read_dict_value(context.node_config_json, "body"), render_context))
-
-        method = (_read_string_value(invoke_config_json, "method") or "GET").upper()
-        base_url = (
-            _read_string_value(context.node_config_json, "base_url")
-            or _read_string_value(binding_config_json, "base_url")
-            or _read_string_value(invoke_config_json, "base_url")
-        )
-        path = _read_string_value(context.node_config_json, "path") or _read_string_value(
-            invoke_config_json, "path"
-        )
-        url = _read_string_value(context.node_config_json, "url") or _read_string_value(
-            invoke_config_json, "url"
-        )
-
-        resolved_url = _resolve_http_url(url=url, base_url=base_url, path=path)
-        if resolved_url is None:
-            return NodeExecutionResultContract(
-                status="failed",
-                worker_key=worker_key,
-                error_code="tool_http_url_missing",
-                error_message="http tool requires url or base_url with path")
-
-        timeout_ms = detail.connection.timeout_ms or 10000
-
-        try:
-            with httpx.Client(timeout=timeout_ms / 1000) as client:
-                response = client.request(
-                    method=method,
-                    url=resolved_url,
-                    params=_coerce_http_params(request_query),
-                    headers=_coerce_http_headers(request_headers),
-                    json=request_body if request_body else None)
-                response.raise_for_status()
-        except httpx.HTTPError as exc:
-            return NodeExecutionResultContract(
-                status="failed",
-                worker_key=worker_key,
-                error_code="tool_http_request_failed",
-                error_message=str(exc),
-                output_json={
-                    "executor_name": self.executor_name,
-                    "tool_binding_id": detail.binding.id,
-                    "tool_code": detail.tool_definition.code,
-                    "request_url": resolved_url,
-                    "request_method": method,
-                })
-
-        response_json = _try_parse_json_response(response)
-        response_text = None if response_json is not None else response.text
-        return NodeExecutionResultContract(
-            status="completed",
-            worker_key=worker_key,
-            output_text=response_text,
-            output_json={
-                "executor_name": self.executor_name,
-                "tool_binding_id": detail.binding.id,
-                "tool_code": detail.tool_definition.code,
-                "tool_connection_id": detail.connection.id,
-                "tool_name": detail.tool_definition.name,
-                "request_url": resolved_url,
-                "request_method": method,
-                "response_status_code": response.status_code,
-                "response_headers": dict(response.headers),
-                "response_json": response_json,
-            })
-
-
-class CodeNodeExecutor(CompletedNodeExecutor):
-    def __init__(self, code_runner_client: CodeRunnerClient | None = None) -> None:
-        super().__init__(
-            executor_name="code-executor",
-            supported_node_types=frozenset({"code"}))
-        self.code_runner_client = code_runner_client
-
-    def execute(
-        self,
-        context: NodeExecutionContextContract,
-        request: NodeExecutionRequestContract) -> NodeExecutionResultContract:
-        worker_key = request.worker_key or f"{self.executor_name}:{context.node_type}"
-        code = _read_string_value(context.node_config_json, "code")
-        if code is None:
-            return NodeExecutionResultContract(
-                status="failed",
-                worker_key=worker_key,
-                error_code="code_config_missing",
-                error_message="code node config requires code")
-        if self.code_runner_client is None:
-            return NodeExecutionResultContract(
-                status="failed",
-                worker_key=worker_key,
-                error_code="code_runner_missing",
-                error_message="code runner client is not configured")
-
-        render_context = _build_executor_template_context(context)
-        input_json = _render_json_dict(
-            _read_dict_value(context.node_config_json, "input_json"),
-            render_context)
-        language = _read_string_value(context.node_config_json, "language") or "python"
-        timeout_seconds = _read_int_value(context.node_config_json, "timeout_seconds") or 10
-        code_request = CodeExecutionRequestContract(
-            language=language,
-            code=code,
-            input_json=input_json,
-            timeout_seconds=timeout_seconds)
-
-        try:
-            response = self.code_runner_client.execute_code(code_request)
-        except CodeRunnerClientError as exc:
-            return NodeExecutionResultContract(
-                status="failed",
-                worker_key=worker_key,
-                error_code="code_request_failed",
-                error_message=str(exc))
-
-        if not response.success:
-            return NodeExecutionResultContract(
-                status="failed",
-                worker_key=worker_key,
-                error_code="code_execution_failed",
-                error_message=response.error_message or response.stderr,
-                output_text=response.stdout,
-                output_json={
-                    "executor_name": self.executor_name,
-                    "stderr": response.stderr,
-                    "output_json": response.output_json,
-                })
-
-        return NodeExecutionResultContract(
-            status="completed",
-            worker_key=worker_key,
-            output_text=response.stdout,
-            output_json={
-                "executor_name": self.executor_name,
-                "stderr": response.stderr,
-                "result_json": response.output_json,
-            })
-
-
-class HumanNodeExecutor(CompletedNodeExecutor):
-    def __init__(self, human_client: HumanServiceClient | None = None) -> None:
-        super().__init__(
-            executor_name="human-executor",
-            supported_node_types=frozenset(
-                {"human", "approval", "human-input", "human-takeover"}
-            ))
-        self.human_client = human_client
-
-    def execute(
-        self,
-        context: NodeExecutionContextContract,
-        request: NodeExecutionRequestContract) -> NodeExecutionResultContract:
-        worker_key = request.worker_key or f"{self.executor_name}:{context.node_type}"
-        if self.human_client is None:
-            return NodeExecutionResultContract(
-                status="failed",
-                worker_key=worker_key,
-                error_code="human_service_missing",
-                error_message="human service client is not configured")
-
-        human_task_id = _resolve_existing_human_task_id(context)
-        if human_task_id is None:
-            return self._create_waiting_task(context=context, worker_key=worker_key)
-
-        try:
-            task = self.human_client.get_task(
-                human_task_id=human_task_id)
-        except HumanServiceClientError as exc:
-            return NodeExecutionResultContract(
-                status="failed",
-                worker_key=worker_key,
-                error_code="human_task_lookup_failed",
-                error_message=str(exc))
-
-        output_json: dict[str, JSONValue] = {
-            "executor_name": self.executor_name,
-            "human_task_id": task.id,
-            "human_task_status": task.status,
-            "response_payload_json": task.response_payload_json or {},
-        }
-        if task.status in {"pending", "claimed"}:
-            return NodeExecutionResultContract(
-                status="pending",
-                worker_key=worker_key,
-                output_text=f"waiting for human task: {task.id}",
-                output_json=output_json)
-        if task.status in {"approved", "completed"}:
-            return NodeExecutionResultContract(
-                status="completed",
-                worker_key=worker_key,
-                output_json=output_json)
-        return NodeExecutionResultContract(
-            status="failed",
-            worker_key=worker_key,
-            error_code=f"human_task_{task.status}",
-            error_message=f"human task ended with status: {task.status}",
-            output_json=output_json)
-
-    def _create_waiting_task(
-        self,
-        *,
-        context: NodeExecutionContextContract,
-        worker_key: str) -> NodeExecutionResultContract:
-        render_context = _build_executor_template_context(context)
-        task_type = _resolve_human_task_type(context.node_type, context.node_config_json)
-        title = render_template_string(
-            _read_string_value(context.node_config_json, "title")
-            or f"Human task for {context.node_id}",
-            render_context)
-        description_template = _read_string_value(context.node_config_json, "description")
-        description = (
-            render_template_string(description_template, render_context)
-            if description_template is not None
-            else None
-        )
-        request_payload_json = _render_json_dict(
-            _read_dict_value(context.node_config_json, "request_payload_json"),
-            render_context)
-
-        try:
-            task = self.human_client.create_task(
-                HumanTaskCreateContract(
-                    task_type=task_type,
-                    title=title,
-                    description=description,
-                    source_type="runtime-node",
-                    source_id=context.node_run_id,
-                    run_id=context.run_id,
-                    node_run_id=context.node_run_id,
-                    requested_by=_read_string_value(
-                        context.node_config_json,
-                        "requested_by"),
-                    assigned_to=_read_string_value(
-                        context.node_config_json,
-                        "assigned_to"),
-                    request_payload_json=request_payload_json,
-                    due_time=_resolve_due_time(context.node_config_json))
-            )
-        except HumanServiceClientError as exc:
-            return NodeExecutionResultContract(
-                status="failed",
-                worker_key=worker_key,
-                error_code="human_task_create_failed",
-                error_message=str(exc))
-
-        return NodeExecutionResultContract(
-            status="pending",
-            worker_key=worker_key,
-            output_text=f"waiting for human task: {task.id}",
-            output_json={
-                "executor_name": self.executor_name,
-                "human_task_id": task.id,
-                "human_task_status": task.status,
-                "task_type": task.task_type,
-            })
-
-
-class AnswerNodeExecutor(CompletedNodeExecutor):
-    def execute(
-        self,
-        context: NodeExecutionContextContract,
-        request: NodeExecutionRequestContract) -> NodeExecutionResultContract:
-        answer_text = _read_string_value(context.node_config_json, "text")
-        template = _read_string_value(context.node_config_json, "template")
-        worker_key = request.worker_key or f"{self.executor_name}:{context.node_type}"
-        if answer_text is None and template is None:
-            return NodeExecutionResultContract(
-                status="failed",
-                worker_key=worker_key,
-                error_code="answer_config_missing",
-                error_message="answer node config requires text or template")
-        render_context = _build_executor_template_context(context)
-        rendered_text = render_template_string(answer_text or template or "", render_context)
-        return NodeExecutionResultContract(
-            status="completed",
-            worker_key=worker_key,
-            output_text=rendered_text,
-            output_json={
-                "executor_name": self.executor_name,
-                "render_mode": "text" if answer_text is not None else "template",
-            })
-
-    def __init__(self) -> None:
-        super().__init__(
-            executor_name="answer-executor",
-            supported_node_types=frozenset({"answer"}))
-
-
-class ConditionNodeExecutor(CompletedNodeExecutor):
-    def execute(
-        self,
-        context: NodeExecutionContextContract,
-        request: NodeExecutionRequestContract) -> NodeExecutionResultContract:
-        worker_key = request.worker_key or f"{self.executor_name}:{context.node_type}"
-        render_context = _build_executor_template_context(context)
-
-        expression = _read_string_value(context.node_config_json, "expression")
-        path = _read_string_value(context.node_config_json, "path")
-
-        if expression is not None:
-            condition_result = evaluate_condition_expression(expression, render_context)
-            evaluated_expression = expression
-        elif path is not None:
-            condition_result = _evaluate_path_condition(
-                context.node_config_json,
-                path,
-                render_context)
-            evaluated_expression = path
-        else:
-            return NodeExecutionResultContract(
-                status="failed",
-                worker_key=worker_key,
-                error_code="condition_config_missing",
-                error_message="condition node config requires expression or path")
-
-        route = "true" if condition_result else "false"
-        return NodeExecutionResultContract(
-            status="completed",
-            worker_key=worker_key,
-            output_json={
-                "executor_name": self.executor_name,
-                "condition_result": condition_result,
-                "route": route,
-                "evaluated_expression": evaluated_expression,
-            })
-
-    def __init__(self) -> None:
-        super().__init__(
-            executor_name="condition-executor",
-            supported_node_types=frozenset({"if-else", "condition"}))
-
-
-class AssignerNodeExecutor(CompletedNodeExecutor):
-    def execute(
-        self,
-        context: NodeExecutionContextContract,
-        request: NodeExecutionRequestContract) -> NodeExecutionResultContract:
-        worker_key = request.worker_key or f"{self.executor_name}:{context.node_type}"
-        assignments = _read_dict_value(context.node_config_json, "assignments")
-        if not assignments:
-            return NodeExecutionResultContract(
-                status="failed",
-                worker_key=worker_key,
-                error_code="assignments_missing",
-                error_message="assigner node config requires assignments")
-
-        render_context = _build_executor_template_context(context)
-        rendered_assignments = _render_json_dict(assignments, render_context)
-        return NodeExecutionResultContract(
-            status="completed",
-            worker_key=worker_key,
-            output_json={
-                "executor_name": self.executor_name,
-                "assigned_values": rendered_assignments,
-                "state_updates": rendered_assignments,
-            })
-
-    def __init__(self) -> None:
-        super().__init__(
-            executor_name="assigner-executor",
-            supported_node_types=frozenset({"assigner"}))
-
-
-class RetrieverNodeExecutor(CompletedNodeExecutor):
-    def __init__(self, knowledge_client: KnowledgeServiceClient | None = None) -> None:
-        super().__init__(
-            executor_name="retriever-executor",
-            supported_node_types=frozenset({"knowledge-retrieval", "retriever"}))
-        self.knowledge_client = knowledge_client
-
-    def execute(
-        self,
-        context: NodeExecutionContextContract,
-        request: NodeExecutionRequestContract) -> NodeExecutionResultContract:
-        worker_key = request.worker_key or f"{self.executor_name}:{context.node_type}"
-        render_context = _build_executor_template_context(context)
-        query = _resolve_retriever_query(context.node_config_json, render_context)
-        documents = _read_retriever_documents(context.node_config_json, render_context)
-        source_url = _read_string_value(context.node_config_json, "source_url")
-        knowledge_base_id = _read_string_value(context.node_config_json, "knowledge_base_id")
-        top_k = _read_int_value(context.node_config_json, "top_k") or 3
-
-        if query is None:
-            return NodeExecutionResultContract(
-                status="failed",
-                worker_key=worker_key,
-                error_code="retriever_query_missing",
-                error_message="retriever node config requires query or query_template")
-        if source_url is not None:
-            try:
-                documents.extend(
-                    _fetch_retriever_documents_from_url(
-                        source_url=render_template_string(source_url, render_context),
-                        timeout_ms=_read_int_value(context.node_config_json, "timeout_ms") or 10000,
-                        render_context=render_context)
-                )
-            except httpx.HTTPError as exc:
-                return NodeExecutionResultContract(
-                    status="failed",
-                    worker_key=worker_key,
-                    error_code="retriever_source_request_failed",
-                    error_message=str(exc))
-            except ValueError as exc:
-                return NodeExecutionResultContract(
-                    status="failed",
-                    worker_key=worker_key,
-                    error_code="retriever_source_invalid",
-                    error_message=str(exc))
-
-        knowledge_results: list[dict[str, JSONValue]] = []
-        if knowledge_base_id is not None:
-            if self.knowledge_client is None:
-                return NodeExecutionResultContract(
-                    status="failed",
-                    worker_key=worker_key,
-                    error_code="knowledge_client_missing",
-                    error_message="knowledge-service client is not configured")
-            try:
-                knowledge_results = [
-                    item.model_dump(mode="json")
-                    for item in self.knowledge_client.search(
-                        KnowledgeSearchRequestContract(
-                            knowledge_base_id=render_template_string(
-                                knowledge_base_id,
-                                render_context),
-                            query=query,
-                            top_k=top_k,
-                            filters_json=_render_json_dict(
-                                _read_dict_value(context.node_config_json, "filters_json"),
-                                render_context))
-                    )
-                ]
-            except KnowledgeServiceClientError as exc:
-                return NodeExecutionResultContract(
-                    status="failed",
-                    worker_key=worker_key,
-                    error_code="knowledge_search_failed",
-                    error_message=str(exc))
-            documents.extend(_knowledge_results_to_retriever_documents(knowledge_results))
-
-        if not documents:
-            return NodeExecutionResultContract(
-                status="failed",
-                worker_key=worker_key,
-                error_code="retriever_documents_missing",
-                error_message=(
-                    "retriever node config requires documents, source_url, "
-                    "or knowledge_base_id"
-                ))
-
-        ranked_documents = rank_documents(query=query, documents=documents, top_k=top_k)
-        output_documents = [item.to_output_json() for item in ranked_documents]
-        output_text = "\n\n".join(item.text for item in ranked_documents)
-        return NodeExecutionResultContract(
-            status="completed",
-            worker_key=worker_key,
-            output_text=output_text,
-            output_json={
-                "executor_name": self.executor_name,
-                "query": query,
-                "top_k": top_k,
-                "retrieved_documents": output_documents,
-                "knowledge_base_id": knowledge_base_id,
-                "knowledge_results": knowledge_results,
-            })
-
-
-class TemplateNodeExecutor(CompletedNodeExecutor):
-    def execute(
-        self,
-        context: NodeExecutionContextContract,
-        request: NodeExecutionRequestContract) -> NodeExecutionResultContract:
-        worker_key = request.worker_key or f"{self.executor_name}:{context.node_type}"
-        render_context = _build_executor_template_context(context)
-        template = _read_string_value(context.node_config_json, "template")
-        template_json = _read_dict_value(context.node_config_json, "template_json")
-
-        if template is None and not template_json:
-            return NodeExecutionResultContract(
-                status="failed",
-                worker_key=worker_key,
-                error_code="template_config_missing",
-                error_message="template node config requires template or template_json")
-
-        rendered_text = None
-        rendered_json = None
-        if template is not None:
-            rendered_text = render_template_string(template, render_context)
-        if template_json:
-            rendered_json = _render_json_dict(template_json, render_context)
-
-        output_json: dict[str, JSONValue] = {"executor_name": self.executor_name}
-        if rendered_json is not None:
-            output_json["rendered_json"] = rendered_json
-
-        return NodeExecutionResultContract(
-            status="completed",
-            worker_key=worker_key,
-            output_text=rendered_text,
-            output_json=output_json)
-
-    def __init__(self) -> None:
-        super().__init__(
-            executor_name="template-executor",
-            supported_node_types=frozenset({"template-transform", "template"}))
-
-
-class NodeExecutionDispatcher:
-    def __init__(
-        self,
-        executors: list[NodeExecutor],
-        default_executor: NodeExecutor) -> None:
-        self.executors = executors
-        self.default_executor = default_executor
-
-    def resolve_executor(self, node_type: str) -> NodeExecutor:
-        for executor in self.executors:
-            if node_type in executor.supported_node_types:
-                return executor
-        return self.default_executor
-
-    def execute(
-        self,
-        context: NodeExecutionContextContract,
-        request: NodeExecutionRequestContract) -> tuple[NodeExecutionResultContract, str]:
-        executor = self.resolve_executor(context.node_type)
-        result = executor.execute(context, request)
-        return result, executor.executor_name
-
-
-def build_node_execution_dispatcher() -> NodeExecutionDispatcher:
-    executors: list[NodeExecutor] = [
-        LLMNodeExecutor(),
-        ToolNodeExecutor(),
-        CodeNodeExecutor(),
-        HumanNodeExecutor(),
-        AnswerNodeExecutor(),
-        ConditionNodeExecutor(),
-        AssignerNodeExecutor(),
-        RetrieverNodeExecutor(),
-        TemplateNodeExecutor(),
-    ]
-    return NodeExecutionDispatcher(
-        executors=executors,
-        default_executor=DefaultNodeExecutor())
-
-
-def build_node_execution_dispatcher_with_clients(
-    *,
-    code_runner_client: CodeRunnerClient | None = None,
-    model_gateway_client: ModelGatewayClient | None = None,
-    tool_client: ToolServiceClient | None = None,
-    knowledge_client: KnowledgeServiceClient | None = None,
-    human_client: HumanServiceClient | None = None) -> NodeExecutionDispatcher:
-    executors: list[NodeExecutor] = [
-        LLMNodeExecutor(model_gateway_client=model_gateway_client),
-        ToolNodeExecutor(tool_client=tool_client),
-        CodeNodeExecutor(code_runner_client=code_runner_client),
-        HumanNodeExecutor(human_client=human_client),
-        AnswerNodeExecutor(),
-        ConditionNodeExecutor(),
-        AssignerNodeExecutor(),
-        RetrieverNodeExecutor(knowledge_client=knowledge_client),
-        TemplateNodeExecutor(),
-    ]
-    return NodeExecutionDispatcher(
-        executors=executors,
-        default_executor=DefaultNodeExecutor())
-
-
-def _read_string_value(payload: dict[str, JSONValue], key: str) -> str | None:
-    value = payload.get(key)
-    if isinstance(value, str):
-        return value
-    return None
-
-
-def _read_dict_value(payload: dict[str, JSONValue], key: str) -> dict[str, JSONValue]:
-    value = payload.get(key)
-    if isinstance(value, dict):
-        return {str(item_key): item_value for item_key, item_value in value.items()}
-    return {}
-
-
-def _merge_json_dicts(*items: dict[str, JSONValue]) -> dict[str, JSONValue]:
-    merged: dict[str, JSONValue] = {}
-    for item in items:
-        merged.update(item)
-    return merged
-
-
-def _render_json_dict(
-    payload: dict[str, JSONValue],
-    context: dict[str, JSONValue]) -> dict[str, JSONValue]:
-    rendered = render_json_value(payload, context)
-    if isinstance(rendered, dict):
-        return {str(key): value for key, value in rendered.items()}
-    return {}
-
-
-def _render_config_json(
-    payload: dict[str, JSONValue],
-    context: dict[str, JSONValue]) -> dict[str, JSONValue]:
-    return _render_json_dict(payload, context)
-
-
-def _resolve_http_url(*, url: str | None, base_url: str | None, path: str | None) -> str | None:
-    if url is not None:
-        return url
-    if base_url is None or path is None:
-        return None
-    return f"{base_url.rstrip('/')}/{path.lstrip('/')}"
-
-
-def _coerce_http_headers(payload: dict[str, JSONValue]) -> dict[str, str]:
-    headers: dict[str, str] = {}
-    for key, value in payload.items():
-        if isinstance(value, (str, int, float, bool)):
-            headers[key] = str(value)
-    return headers
-
-
-def _coerce_http_params(payload: dict[str, JSONValue]) -> dict[str, str]:
-    params: dict[str, str] = {}
-    for key, value in payload.items():
-        if isinstance(value, (str, int, float, bool)):
-            params[key] = str(value)
-    return params
-
-
-def _try_parse_json_response(response: httpx.Response) -> JSONValue | None:
-    content_type = response.headers.get("content-type", "")
-    if "json" not in content_type.lower():
-        return None
-    try:
-        payload = response.json()
-    except ValueError:
-        return None
-    if isinstance(payload, (dict, list, str, int, float, bool)) or payload is None:
-        return payload
-    return None
-
-
-def _build_chat_completion_request(
-    payload: dict[str, JSONValue]) -> ChatCompletionRequestContract | None:
-    messages = _read_message_list(payload, "messages")
-    if not messages:
-        system_prompt = _read_string_value(payload, "system_prompt")
-        prompt = _read_string_value(payload, "prompt")
-        if system_prompt is not None:
-            messages.append(ChatMessageContract(role="system", content=system_prompt))
-        if prompt is not None:
-            messages.append(ChatMessageContract(role="user", content=prompt))
-
-    if not messages:
-        return None
-
-    temperature = _read_float_value(payload, "temperature")
-    max_tokens = _read_int_value(payload, "max_tokens")
-    model = _read_string_value(payload, "model")
-    return ChatCompletionRequestContract(
-        model=model,
-        messages=messages,
-        temperature=temperature,
-        max_tokens=max_tokens)
-
-
-def _read_message_list(
-    payload: dict[str, JSONValue],
-    key: str) -> list[ChatMessageContract]:
-    value = payload.get(key)
-    if not isinstance(value, list):
-        return []
-
-    messages: list[ChatMessageContract] = []
-    for item in value:
-        if not isinstance(item, dict):
-            continue
-        role = item.get("role")
-        content = item.get("content")
-        name = item.get("name")
-        if isinstance(role, str) and isinstance(content, str):
-            messages.append(
-                ChatMessageContract(
-                    role=role,
-                    content=content,
-                    name=name if isinstance(name, str) else None)
-            )
-    return messages
-
-
-def _read_float_value(payload: dict[str, JSONValue], key: str) -> float | None:
-    value = payload.get(key)
-    if isinstance(value, (int, float)) and not isinstance(value, bool):
-        return float(value)
-    return None
-
-
-def _read_int_value(payload: dict[str, JSONValue], key: str) -> int | None:
-    value = payload.get(key)
-    if isinstance(value, int) and not isinstance(value, bool):
-        return value
-    return None
-
-
-def _resolve_existing_human_task_id(context: NodeExecutionContextContract) -> str | None:
-    configured_task_id = _read_string_value(context.node_config_json, "human_task_id")
-    if configured_task_id is not None:
-        return configured_task_id
-
-    current_node_output = context.node_output_json_by_node_id.get(context.node_id)
-    if current_node_output is None:
-        return None
-    return _read_string_value(current_node_output, "human_task_id")
-
-
-def _resolve_human_task_type(
-    node_type: str,
-    payload: dict[str, JSONValue]) -> HumanTaskType:
-    configured_task_type = _read_string_value(payload, "task_type")
-    if configured_task_type in {"approval", "input", "takeover", "pause", "resume"}:
-        return cast(HumanTaskType, configured_task_type)
-    if node_type == "approval":
-        return "approval"
-    if node_type == "human-input":
-        return "input"
-    if node_type == "human-takeover":
-        return "takeover"
-    return "input"
-
-
-def _resolve_due_time(payload: dict[str, JSONValue]) -> datetime | None:
-    due_time = _read_datetime_value(payload, "due_time")
-    if due_time is not None:
-        return due_time
-
-    due_after_seconds = _read_int_value(payload, "due_after_seconds")
-    if due_after_seconds is None or due_after_seconds <= 0:
-        return None
-    return datetime.utcnow() + timedelta(seconds=due_after_seconds)
-
-
-def _read_datetime_value(payload: dict[str, JSONValue], key: str) -> datetime | None:
-    value = payload.get(key)
-    if isinstance(value, str) and value.strip():
-        normalized_value = value.strip().replace("Z", "+00:00")
-        try:
-            return datetime.fromisoformat(normalized_value)
-        except ValueError:
-            return None
-    return None
-
-
-def _build_executor_template_context(context: NodeExecutionContextContract) -> dict[str, JSONValue]:
-    return build_template_context(
-        node_id=context.node_id,
-        node_type=context.node_type,
-        run_state_json=context.run_state_json,
-        node_output_json_by_node_id=context.node_output_json_by_node_id,
-        node_output_text_by_node_id=context.node_output_text_by_node_id)
-
-
-def _evaluate_path_condition(
-    payload: dict[str, JSONValue],
-    path: str,
-    render_context: dict[str, JSONValue]) -> bool:
-    value = resolve_expression(render_context, path)
-
-    if "equals" in payload:
-        return value == render_json_value(payload["equals"], render_context)
-    if "not_equals" in payload:
-        return value != render_json_value(payload["not_equals"], render_context)
-    if "gt" in payload:
-        return _compare_numeric(value, render_json_value(payload["gt"], render_context), ">")
-    if "gte" in payload:
-        return _compare_numeric(value, render_json_value(payload["gte"], render_context), ">=")
-    if "lt" in payload:
-        return _compare_numeric(value, render_json_value(payload["lt"], render_context), "<")
-    if "lte" in payload:
-        return _compare_numeric(value, render_json_value(payload["lte"], render_context), "<=")
-    if "exists" in payload:
-        expected = payload["exists"]
-        if isinstance(expected, bool):
-            return (value is not None) is expected
-    return coerce_bool(value)
-
-
-def _compare_numeric(left: JSONValue, right: JSONValue, operator: str) -> bool:
-    if not isinstance(left, (int, float)) or not isinstance(right, (int, float)):
-        return False
-    if operator == ">":
-        return left > right
-    if operator == ">=":
-        return left >= right
-    if operator == "<":
-        return left < right
-    if operator == "<=":
-        return left <= right
-    return False
-
-
-@dataclass(frozen=True)
-class RetrieverDocument:
-    document_id: str
-    title: str | None
-    text: str
-    metadata: dict[str, JSONValue]
-
-
-@dataclass(frozen=True)
-class RankedRetrieverDocument:
-    document_id: str
-    title: str | None
-    text: str
-    metadata: dict[str, JSONValue]
-    score: float
-
-    def to_output_json(self) -> dict[str, JSONValue]:
-        return {
-            "document_id": self.document_id,
-            "title": self.title,
-            "text": self.text,
-            "metadata": self.metadata,
-            "score": self.score,
-        }
-
-
-def _resolve_retriever_query(
-    payload: dict[str, JSONValue],
-    render_context: dict[str, JSONValue]) -> str | None:
-    query = _read_string_value(payload, "query")
-    query_template = _read_string_value(payload, "query_template")
-    if query is not None:
-        rendered_query = render_template_string(query, render_context)
-    elif query_template is not None:
-        rendered_query = render_template_string(query_template, render_context)
-    else:
-        return None
-
-    stripped_query = rendered_query.strip()
-    if not stripped_query:
-        return None
-    return stripped_query
-
-
-def _read_retriever_documents(
-    payload: dict[str, JSONValue],
-    render_context: dict[str, JSONValue]) -> list[RetrieverDocument]:
-    value = payload.get("documents")
-    if not isinstance(value, list):
-        return []
-
-    documents: list[RetrieverDocument] = []
-    for index, item in enumerate(value):
-        document = _parse_retriever_document(
-            item,
-            index=index,
-            render_context=render_context)
-        if document is not None:
-            documents.append(document)
-    return documents
-
-
-def _fetch_retriever_documents_from_url(
-    *,
-    source_url: str,
-    timeout_ms: int,
-    render_context: dict[str, JSONValue]) -> list[RetrieverDocument]:
-    if not source_url.strip():
-        return []
-
-    with httpx.Client(timeout=timeout_ms / 1000) as client:
-        response = client.get(source_url)
-        response.raise_for_status()
-
-    payload = response.json()
-    if isinstance(payload, dict):
-        documents_payload = payload.get("documents")
-    else:
-        documents_payload = payload
-
-    if not isinstance(documents_payload, list):
-        raise ValueError("retriever source must return a JSON list or object.documents list")
-
-    documents: list[RetrieverDocument] = []
-    for index, item in enumerate(documents_payload):
-        if not _is_json_value(item):
-            continue
-        document = _parse_retriever_document(
-            item,
-            index=index,
-            render_context=render_context)
-        if document is not None:
-            documents.append(document)
-    return documents
-
-
-def _parse_retriever_document(
-    value: JSONValue,
-    *,
-    index: int,
-    render_context: dict[str, JSONValue]) -> RetrieverDocument | None:
-    if isinstance(value, str):
-        text = render_template_string(value, render_context).strip()
-        if not text:
-            return None
-        return RetrieverDocument(
-            document_id=f"doc-{index + 1}",
-            title=None,
-            text=text,
-            metadata={})
-
-    if not isinstance(value, dict):
-        return None
-
-    rendered = _render_json_dict({str(key): item for key, item in value.items()}, render_context)
-    text_value = rendered.get("text") or rendered.get("content")
-    if not isinstance(text_value, str) or not text_value.strip():
-        return None
-
-    document_id_value = rendered.get("id") or rendered.get("document_id")
-    title_value = rendered.get("title")
-    metadata_value = rendered.get("metadata")
-    return RetrieverDocument(
-        document_id=str(document_id_value) if document_id_value is not None else f"doc-{index + 1}",
-        title=title_value if isinstance(title_value, str) else None,
-        text=text_value.strip(),
-        metadata=metadata_value if isinstance(metadata_value, dict) else {})
-
-
-def _knowledge_results_to_retriever_documents(
-    results: list[dict[str, JSONValue]]) -> list[RetrieverDocument]:
-    documents: list[RetrieverDocument] = []
-    for index, result in enumerate(results):
-        chunk = result.get("chunk")
-        document = result.get("document")
-        score = result.get("score")
-        score_json = result.get("score_json")
-        if not isinstance(chunk, dict) or not isinstance(document, dict):
-            continue
-        content_text = chunk.get("content_text")
-        if not isinstance(content_text, str) or not content_text.strip():
-            continue
-        document_id = document.get("id")
-        title = document.get("title")
-        metadata: dict[str, JSONValue] = {
-            "source": "knowledge-service",
-            "chunk_id": str(chunk.get("id")) if chunk.get("id") is not None else None,
-            "chunk_index": (
-                chunk.get("chunk_index")
-                if isinstance(chunk.get("chunk_index"), int)
-                else index
-            ),
-            "score": score if isinstance(score, (int, float)) else None,
-            "score_json": score_json if isinstance(score_json, dict) else {},
-        }
-        documents.append(
-            RetrieverDocument(
-                document_id=str(document_id)
-                if document_id is not None
-                else f"knowledge-{index + 1}",
-                title=title if isinstance(title, str) else None,
-                text=content_text.strip(),
-                metadata=metadata)
-        )
-    return documents
-
-
-def rank_documents(
-    *,
-    query: str,
-    documents: list[RetrieverDocument],
-    top_k: int) -> list[RankedRetrieverDocument]:
-    normalized_top_k = max(top_k, 1)
-    query_tokens = tokenize_text(query)
-    ranked_documents: list[RankedRetrieverDocument] = []
-
-    for document in documents:
-        document_tokens = tokenize_text(" ".join(filter(None, [document.title, document.text])))
-        score = calculate_keyword_score(query_tokens=query_tokens, document_tokens=document_tokens)
-        ranked_documents.append(
-            RankedRetrieverDocument(
-                document_id=document.document_id,
-                title=document.title,
-                text=document.text,
-                metadata=document.metadata,
-                score=score)
-        )
-
-    ranked_documents.sort(key=lambda item: item.score, reverse=True)
-    return ranked_documents[:normalized_top_k]
-
-
-def calculate_keyword_score(
-    *,
-    query_tokens: set[str],
-    document_tokens: set[str]) -> float:
-    if not query_tokens or not document_tokens:
-        return 0.0
-    overlap_count = len(query_tokens.intersection(document_tokens))
-    if overlap_count == 0:
-        return 0.0
-    return round(overlap_count / len(query_tokens), 4)
-
-
-def tokenize_text(value: str) -> set[str]:
-    tokens = {item.lower() for item in re.findall(r"[\w\u4e00-\u9fff]+", value)}
-    return {item for item in tokens if item}
-
-
-def _is_json_value(value: object) -> bool:
-    if value is None or isinstance(value, (str, int, float, bool)):
-        return True
-    if isinstance(value, list):
-        return all(_is_json_value(item) for item in value)
-    if isinstance(value, dict):
-        return all(isinstance(key, str) and _is_json_value(item) for key, item in value.items())
-    return False

+ 0 - 34
services/runtime-service/app/infrastructure/human_client.py

@@ -1,34 +0,0 @@
-import httpx
-from core_domain import HumanTaskContract, HumanTaskCreateContract
-
-
-class HumanServiceClientError(Exception):
-    pass
-
-
-class HumanServiceClient:
-    def __init__(self, base_url: str, timeout_seconds: float = 10.0) -> None:
-        self.base_url = base_url.rstrip("/")
-        self.timeout_seconds = timeout_seconds
-
-    def create_task(self, payload: HumanTaskCreateContract) -> HumanTaskContract:
-        try:
-            with httpx.Client(timeout=self.timeout_seconds) as client:
-                response = client.post(
-                    f"{self.base_url}/human/tasks",
-                    json=payload.model_dump(mode="json"))
-                response.raise_for_status()
-                return HumanTaskContract.model_validate(response.json())
-        except httpx.HTTPError as exc:
-            raise HumanServiceClientError(
-                f"human-service create task failed: {exc}"
-            ) from exc
-
-    def get_task(self, *, human_task_id: str) -> HumanTaskContract:
-        try:
-            with httpx.Client(timeout=self.timeout_seconds) as client:
-                response = client.get(f"{self.base_url}/human/tasks/{human_task_id}")
-                response.raise_for_status()
-                return HumanTaskContract.model_validate(response.json())
-        except httpx.HTTPError as exc:
-            raise HumanServiceClientError(f"human-service get task failed: {exc}") from exc

+ 0 - 30
services/runtime-service/app/infrastructure/knowledge_client.py

@@ -1,30 +0,0 @@
-import httpx
-from core_domain import KnowledgeSearchRequestContract, KnowledgeSearchResultContract
-
-
-class KnowledgeServiceClientError(Exception):
-    pass
-
-
-class KnowledgeServiceClient:
-    def __init__(self, base_url: str, timeout_seconds: float = 10.0) -> None:
-        self.base_url = base_url.rstrip("/")
-        self.timeout_seconds = timeout_seconds
-
-    def search(
-        self,
-        payload: KnowledgeSearchRequestContract) -> list[KnowledgeSearchResultContract]:
-        try:
-            with httpx.Client(timeout=self.timeout_seconds) as client:
-                response = client.post(
-                    f"{self.base_url}/knowledge/search",
-                    json=payload.model_dump(mode="json"))
-                response.raise_for_status()
-                return [
-                    KnowledgeSearchResultContract.model_validate(item)
-                    for item in response.json()
-                ]
-        except httpx.HTTPError as exc:
-            raise KnowledgeServiceClientError(
-                f"knowledge-service search failed: {exc}"
-            ) from exc

+ 0 - 25
services/runtime-service/app/infrastructure/model_gateway_client.py

@@ -1,25 +0,0 @@
-import httpx
-from core_domain import ChatCompletionRequestContract, ChatCompletionResponseContract
-
-
-class ModelGatewayClientError(Exception):
-    pass
-
-
-class ModelGatewayClient:
-    def __init__(self, base_url: str, timeout_seconds: float = 60.0) -> None:
-        self.base_url = base_url.rstrip("/")
-        self.timeout_seconds = timeout_seconds
-
-    def create_chat_completion(
-        self,
-        payload: ChatCompletionRequestContract) -> ChatCompletionResponseContract:
-        try:
-            with httpx.Client(timeout=self.timeout_seconds) as client:
-                response = client.post(
-                    f"{self.base_url}/models/chat-completions",
-                    json=payload.model_dump(mode="json"))
-                response.raise_for_status()
-                return ChatCompletionResponseContract.model_validate(response.json())
-        except httpx.HTTPError as exc:
-            raise ModelGatewayClientError(f"model-gateway-service request failed: {exc}") from exc

+ 0 - 122
services/runtime-service/app/infrastructure/planner.py

@@ -1,122 +0,0 @@
-from core_domain import InitialNodeContract, WorkflowConfigContract
-from core_dsl import (
-    EdgeDefinition,
-    get_initial_node_definition,
-    get_node_definition,
-    parse_workflow_definition,
-)
-from core_shared import JSONValue
-
-from .context import build_template_context, evaluate_condition_expression
-
-
-def derive_initial_node(workflow_config: WorkflowConfigContract) -> InitialNodeContract | None:
-    workflow = parse_workflow_definition(workflow_config.dsl_json)
-    if workflow is None:
-        return None
-
-    node = get_initial_node_definition(workflow)
-    if node is None:
-        return None
-    return InitialNodeContract(node_id=node.id, node_type=node.type, status="queued")
-
-
-def derive_successor_nodes(
-    workflow_config: WorkflowConfigContract,
-    current_node_id: str,
-    current_output_json: dict[str, JSONValue] | None = None,
-    run_state_json: dict[str, JSONValue] | None = None,
-    node_output_json_by_node_id: dict[str, dict[str, JSONValue]] | None = None,
-    node_output_text_by_node_id: dict[str, str] | None = None) -> list[InitialNodeContract]:
-    workflow = parse_workflow_definition(workflow_config.dsl_json)
-    if workflow is None:
-        return []
-
-    node_map = {node.id: node for node in workflow.nodes}
-    template_context = build_template_context(
-        node_id=current_node_id,
-        node_type=node_map.get(current_node_id).type if current_node_id in node_map else "unknown",
-        run_state_json=run_state_json or {},
-        node_output_json_by_node_id=node_output_json_by_node_id or {},
-        node_output_text_by_node_id=node_output_text_by_node_id or {})
-    edge_context: dict[str, JSONValue] = {
-        **template_context,
-        "output": current_output_json or {},
-        "route": _read_string_value(current_output_json or {}, "route"),
-        "condition_result": _read_bool_value(current_output_json or {}, "condition_result"),
-    }
-
-    successors: list[InitialNodeContract] = []
-    for edge in _get_matching_edges(
-        workflow.edges,
-        current_node_id=current_node_id,
-        edge_context=edge_context):
-        successor = node_map.get(edge.target)
-        if successor is None:
-            continue
-        successors.append(
-            InitialNodeContract(
-                node_id=successor.id,
-                node_type=successor.type,
-                status="queued")
-        )
-    return successors
-
-
-def derive_node_config(
-    workflow_config: WorkflowConfigContract,
-    node_id: str) -> dict[str, JSONValue]:
-    workflow = parse_workflow_definition(workflow_config.dsl_json)
-    if workflow is None:
-        return {}
-
-    node = get_node_definition(workflow, node_id)
-    if node is None:
-        return {}
-    return dict(node.config)
-
-
-def _get_matching_edges(
-    edges: list[EdgeDefinition],
-    *,
-    current_node_id: str,
-    edge_context: dict[str, JSONValue]) -> list[EdgeDefinition]:
-    matching_edges: list[EdgeDefinition] = []
-    for edge in edges:
-        if edge.source != current_node_id:
-            continue
-        if _matches_edge_condition(edge.condition, edge_context):
-            matching_edges.append(edge)
-    return matching_edges
-
-
-def _matches_edge_condition(
-    condition: str | None,
-    context: dict[str, JSONValue]) -> bool:
-    if condition is None or not condition.strip():
-        return True
-
-    stripped = condition.strip()
-    route = context.get("route")
-    if isinstance(route, str) and stripped == route:
-        return True
-
-    condition_result = context.get("condition_result")
-    if isinstance(condition_result, bool) and stripped.lower() in {"true", "false"}:
-        return condition_result is (stripped.lower() == "true")
-
-    return evaluate_condition_expression(stripped, context)
-
-
-def _read_string_value(payload: dict[str, JSONValue], key: str) -> str | None:
-    value = payload.get(key)
-    if isinstance(value, str):
-        return value
-    return None
-
-
-def _read_bool_value(payload: dict[str, JSONValue], key: str) -> bool | None:
-    value = payload.get(key)
-    if isinstance(value, bool):
-        return value
-    return None

+ 0 - 24
services/runtime-service/app/infrastructure/tool_client.py

@@ -1,24 +0,0 @@
-import httpx
-from core_domain import ToolBindingDetailContract
-
-
-class ToolServiceClientError(Exception):
-    pass
-
-
-class ToolServiceClient:
-    def __init__(self, base_url: str, timeout_seconds: float = 10.0) -> None:
-        self.base_url = base_url.rstrip("/")
-        self.timeout_seconds = timeout_seconds
-
-    def get_tool_binding_detail(
-        self,
-        *,
-        binding_id: str) -> ToolBindingDetailContract:
-        try:
-            with httpx.Client(timeout=self.timeout_seconds) as client:
-                response = client.get(f"{self.base_url}/tools/bindings/{binding_id}")
-                response.raise_for_status()
-                return ToolBindingDetailContract.model_validate(response.json())
-        except httpx.HTTPError as exc:
-            raise ToolServiceClientError(f"tool-service request failed: {exc}") from exc

+ 0 - 24
services/runtime-service/app/infrastructure/workflow_client.py

@@ -1,24 +0,0 @@
-import httpx
-from core_domain import WorkflowConfigContract
-
-
-class WorkflowServiceClientError(Exception):
-    pass
-
-
-class WorkflowServiceClient:
-    def __init__(self, base_url: str, timeout_seconds: float = 10.0) -> None:
-        self.base_url = base_url.rstrip("/")
-        self.timeout_seconds = timeout_seconds
-
-    def get_workflow_config(
-        self,
-        *,
-        workflow_config_id: str) -> WorkflowConfigContract:
-        try:
-            with httpx.Client(timeout=self.timeout_seconds) as client:
-                response = client.get(f"{self.base_url}/workflows/configs/{workflow_config_id}")
-                response.raise_for_status()
-                return WorkflowConfigContract.model_validate(response.json())
-        except httpx.HTTPError as exc:
-            raise WorkflowServiceClientError(f"workflow-service request failed: {exc}") from exc

+ 0 - 4
services/runtime-service/app/main.py

@@ -1,4 +0,0 @@
-from app.bootstrap.app import create_app
-
-app = create_app()
-

+ 0 - 1
services/runtime-service/app/schemas/__init__.py

@@ -1 +0,0 @@
-

+ 0 - 205
services/runtime-service/app/schemas/run.py

@@ -1,205 +0,0 @@
-from datetime import datetime
-from typing import TYPE_CHECKING
-
-from core_domain import (
-    InitialNodeContract,
-    NodeExecutionRequestContract,
-    NodeRunContract,
-    NodeRunStatusUpdateContract,
-    RunBootstrapContract,
-    RunCreateContract,
-    RunExecutionRequestContract,
-    WorkflowRunContract,
-    WorkflowRunStatusUpdateContract,
-)
-from core_shared import JSONValue
-from pydantic import BaseModel, Field
-
-if TYPE_CHECKING:
-    from app.db.models import ExecutionLog, NodeArtifact, NodeRun, TraceSpan, WorkflowRun
-
-
-class InitialNodeCreateRequest(InitialNodeContract):
-    pass
-
-
-class RunCreateRequest(RunCreateContract):
-    initial_node: InitialNodeCreateRequest | None = None
-
-
-class WorkflowRunListRequest(BaseModel):
-    session_id: str | None = None
-    limit: int = Field(default=50, ge=1, le=500)
-
-
-class NodeRunListRequest(BaseModel):
-    run_id: str
-
-
-class ExecutionLogListRequest(BaseModel):
-    run_id: str | None = None
-    node_run_id: str | None = None
-
-
-class NodeArtifactListRequest(BaseModel):
-    run_id: str | None = None
-    node_run_id: str | None = None
-    artifact_type: str | None = None
-
-
-class TraceSpanListRequest(BaseModel):
-    run_id: str | None = None
-    node_run_id: str | None = None
-    span_type: str | None = None
-
-
-class RuntimeDebugSnapshotRequest(BaseModel):
-    run_id: str
-
-
-class WorkflowRunResponse(WorkflowRunContract):
-
-    @classmethod
-    def from_entity(cls, entity: "WorkflowRun") -> "WorkflowRunResponse":
-        return cls.model_validate(entity, from_attributes=True)
-
-
-class NodeRunResponse(NodeRunContract):
-
-    @classmethod
-    def from_entity(cls, entity: "NodeRun") -> "NodeRunResponse":
-        return cls.model_validate(entity, from_attributes=True)
-
-
-class RunBootstrapResponse(RunBootstrapContract):
-    run: WorkflowRunResponse
-    initial_node: NodeRunResponse | None = None
-
-
-class WorkflowRunStatusUpdateRequest(WorkflowRunStatusUpdateContract):
-    pass
-
-
-class NodeRunStatusUpdateRequest(NodeRunStatusUpdateContract):
-    pass
-
-
-class NodeRunExecuteRequest(NodeExecutionRequestContract):
-    pass
-
-
-class NodeRunExecuteResponse(BaseModel):
-    run: WorkflowRunResponse
-    node_run: NodeRunResponse
-    executor_name: str
-
-
-class RunExecuteRequest(RunExecutionRequestContract):
-    pass
-
-
-class RunExecuteResponse(BaseModel):
-    run: WorkflowRunResponse
-    node_runs: list[NodeRunResponse]
-    executor_names: list[str]
-
-
-class RuntimeDebugContinueRequest(BaseModel):
-    worker_key: str | None = None
-    max_steps: int = Field(default=32, ge=1, le=500)
-    breakpoint_node_ids: list[str] = Field(default_factory=list)
-
-
-class WorkerExecuteNextRequest(BaseModel):
-    worker_key: str
-    lease_seconds: int | None = Field(default=None, gt=0)
-
-
-class WorkerExecuteNextResponse(BaseModel):
-    run: WorkflowRunResponse
-    node_run: NodeRunResponse
-    executor_name: str
-    released_lease_count: int = 0
-
-
-class HumanNodeResumeRequest(BaseModel):
-    human_task_id: str
-    worker_key: str | None = None
-
-
-class ExecutionLogResponse(BaseModel):
-    id: str
-    run_id: str
-    node_run_id: str | None = None
-    event_type: str
-    level: str
-    message: str
-    detail_json: dict[str, JSONValue] | None = None
-    created_time: datetime
-
-    @classmethod
-    def from_entity(cls, entity: "ExecutionLog") -> "ExecutionLogResponse":
-        return cls.model_validate(entity, from_attributes=True)
-
-
-class NodeArtifactResponse(BaseModel):
-    id: str
-    run_id: str
-    node_run_id: str
-    node_id: str
-    artifact_type: str
-    name: str
-    mime_type: str | None = None
-    content_text: str | None = None
-    content_json: dict[str, JSONValue] | None = None
-    storage_uri: str | None = None
-    size_bytes: int | None = None
-    created_time: datetime
-
-    @classmethod
-    def from_entity(cls, entity: "NodeArtifact") -> "NodeArtifactResponse":
-        return cls.model_validate(entity, from_attributes=True)
-
-
-class TraceSpanResponse(BaseModel):
-    id: str
-    run_id: str
-    node_run_id: str | None = None
-    parent_span_id: str | None = None
-    span_type: str
-    name: str
-    status: str
-    started_time: datetime
-    ended_time: datetime | None = None
-    duration_ms: int | None = None
-    attributes_json: dict[str, JSONValue] | None = None
-    error_code: str | None = None
-    error_message: str | None = None
-    created_time: datetime
-
-    @classmethod
-    def from_entity(cls, entity: "TraceSpan") -> "TraceSpanResponse":
-        return cls.model_validate(entity, from_attributes=True)
-
-
-class RuntimeDebugSnapshotResponse(BaseModel):
-    run: WorkflowRunResponse
-    node_runs: list[NodeRunResponse]
-    run_state_json: dict[str, JSONValue]
-    node_output_json_by_node_id: dict[str, dict[str, JSONValue]]
-    node_output_text_by_node_id: dict[str, str]
-    queued_node_ids: list[str]
-    running_node_ids: list[str]
-    completed_node_ids: list[str]
-    failed_node_ids: list[str]
-    execution_logs: list[ExecutionLogResponse]
-    node_artifacts: list[NodeArtifactResponse]
-    trace_spans: list[TraceSpanResponse]
-
-
-class RuntimeDebugStepResponse(BaseModel):
-    snapshot: RuntimeDebugSnapshotResponse
-    executed_node_runs: list[NodeRunResponse]
-    executor_names: list[str]
-    paused_before_node_id: str | None = None
-    reason: str

+ 0 - 120
services/runtime-service/app/worker.py

@@ -1,120 +0,0 @@
-from __future__ import annotations
-
-import os
-import socket
-import time
-import traceback
-from dataclasses import dataclass
-from math import ceil
-from uuid import uuid4
-
-from core_shared import try_build_redis_client
-from core_shared.task_queue import RUNTIME_NODE_RUN_QUEUE, build_task_queue_consumer
-from sqlalchemy.orm import Session, sessionmaker
-
-from app.application.services import build_runtime_application_service
-from app.bootstrap.settings import RuntimeServiceSettings
-from app.db.session import build_session_factory
-
-
-@dataclass(frozen=True)
-class RuntimeWorkerStats:
-    worker_key: str
-    executed_count: int = 0
-    idle_count: int = 0
-    error_count: int = 0
-
-
-class RuntimeWorker:
-    def __init__(
-        self,
-        *,
-        settings: RuntimeServiceSettings,
-        session_factory: sessionmaker[Session],
-        worker_key: str) -> None:
-        self.settings = settings
-        self.session_factory = session_factory
-        self.worker_key = worker_key
-        self.redis_client = try_build_redis_client(settings.redis_url)
-        self.task_queue = build_task_queue_consumer(
-            client=self.redis_client,
-            queue_name=RUNTIME_NODE_RUN_QUEUE)
-
-    def run_forever(self) -> RuntimeWorkerStats:
-        executed_count = 0
-        idle_count = 0
-        error_count = 0
-
-        while True:
-            try:
-                executed = self.run_once()
-            except Exception:
-                error_count += 1
-                traceback.print_exc()
-                executed = False
-
-            if executed:
-                executed_count += 1
-                idle_count = 0
-            else:
-                idle_count += 1
-                if self.task_queue is None:
-                    time.sleep(self.settings.worker_poll_interval_seconds)
-
-            if self.settings.worker_max_idle_cycles is not None:
-                if idle_count >= self.settings.worker_max_idle_cycles:
-                    return RuntimeWorkerStats(
-                        worker_key=self.worker_key,
-                        executed_count=executed_count,
-                        idle_count=idle_count,
-                        error_count=error_count)
-
-    def run_once(self) -> bool:
-        self._wait_for_queue_signal()
-        db = self.session_factory()
-        try:
-            service = build_runtime_application_service(db=db, settings=self.settings)
-            result = service.execute_next_claimed_node_run(
-                worker_key=self.worker_key,
-                lease_seconds=self.settings.worker_lease_seconds,
-                redis_client=self.redis_client)
-            return result is not None
-        finally:
-            db.close()
-
-    def _wait_for_queue_signal(self) -> None:
-        if self.task_queue is None:
-            return
-        try:
-            self.task_queue.dequeue(
-                timeout_seconds=max(1, ceil(self.settings.worker_poll_interval_seconds)))
-        except Exception:
-            return
-
-
-def build_worker_key() -> str:
-    configured_key = os.getenv("AGENT_PLATFORM_WORKER_KEY")
-    if configured_key:
-        return configured_key
-    hostname = socket.gethostname()
-    return f"{hostname}-{uuid4().hex[:8]}"
-
-
-def main() -> None:
-    settings = RuntimeServiceSettings()
-    worker = RuntimeWorker(
-        settings=settings,
-        session_factory=build_session_factory(settings),
-        worker_key=build_worker_key())
-    stats = worker.run_forever()
-    print(
-        "runtime-worker stopped "
-        f"worker_key={stats.worker_key} "
-        f"executed_count={stats.executed_count} "
-        f"idle_count={stats.idle_count} "
-        f"error_count={stats.error_count}",
-        flush=True)
-
-
-if __name__ == "__main__":
-    main()

+ 0 - 28
services/runtime-service/pyproject.toml

@@ -1,28 +0,0 @@
-[build-system]
-requires = ["setuptools>=68"]
-build-backend = "setuptools.build_meta"
-
-[project]
-name = "runtime-service"
-version = "0.1.0"
-description = "Runtime service for agent platform."
-requires-python = ">=3.11"
-dependencies = [
-  "alembic>=1.13,<2.0",
-  "fastapi>=0.111,<1.0",
-  "httpx>=0.27,<1.0",
-  "uvicorn[standard]>=0.30,<1.0",
-  "pydantic>=2.7,<3.0",
-  "sqlalchemy>=2.0,<3.0",
-  "core-db",
-  "core-domain",
-  "core-dsl",
-  "core-events",
-  "core-shared",
-]
-
-[tool.setuptools]
-package-dir = {"" = "."}
-
-[tool.setuptools.packages.find]
-where = ["."]

+ 2 - 16
services/session-service/app/api/routes.py

@@ -1,5 +1,5 @@
 from core_domain import ServiceHealth
-from fastapi import APIRouter, Depends, HTTPException, Query
+from fastapi import APIRouter, Depends, Query
 from sqlalchemy import text
 from sqlalchemy.orm import Session
 
@@ -7,11 +7,8 @@ from app.application.services import SessionApplicationService
 from app.bootstrap.settings import SessionServiceSettings
 from app.db.session import get_db
 from app.domain.repositories import MessageRepository, RunRequestRepository, SessionRepository
-from app.infrastructure.runtime_client import RuntimeServiceClient, RuntimeServiceClientError
 from app.schemas.message import MessageCreateRequest, MessageListRequest, MessageResponse
 from app.schemas.run_request import (
-    DispatchRunRequest,
-    DispatchRunResponse,
     RunRequestCreateRequest,
     RunRequestListRequest,
     RunRequestResponse,
@@ -31,8 +28,7 @@ def get_session_application_service(
     return SessionApplicationService(
         session_repository=SessionRepository(db),
         message_repository=MessageRepository(db),
-        run_request_repository=RunRequestRepository(db),
-        runtime_client=RuntimeServiceClient(base_url=settings.runtime_service_url))
+        run_request_repository=RunRequestRepository(db))
 
 
 @router.get("/health", response_model=ServiceHealth)
@@ -117,13 +113,3 @@ def list_run_requests_post(
         RunRequestResponse.from_entity(item)
         for item in service.list_run_requests(session_id=payload.session_id)
     ]
-
-
-@router.post("/run-requests/dispatch", response_model=DispatchRunResponse)
-def dispatch_run_request(
-    payload: DispatchRunRequest,
-    service: SessionApplicationService = Depends(get_session_application_service)) -> DispatchRunResponse:
-    try:
-        return service.dispatch_run_request(payload)
-    except RuntimeServiceClientError as exc:
-        raise HTTPException(status_code=502, detail=str(exc)) from exc

+ 2 - 42
services/session-service/app/application/services.py

@@ -1,11 +1,8 @@
-from core_domain import InitialNodeContract, RunCreateContract
-
 from app.db.models import Message, RunRequest
 from app.db.models import Session as SessionModel
 from app.domain.repositories import MessageRepository, RunRequestRepository, SessionRepository
-from app.infrastructure.runtime_client import RuntimeServiceClient
 from app.schemas.message import MessageCreateRequest
-from app.schemas.run_request import DispatchRunRequest, DispatchRunResponse, RunRequestCreateRequest
+from app.schemas.run_request import RunRequestCreateRequest
 from app.schemas.session import SessionCreateRequest
 
 
@@ -14,12 +11,10 @@ class SessionApplicationService:
         self,
         session_repository: SessionRepository,
         message_repository: MessageRepository,
-        run_request_repository: RunRequestRepository,
-        runtime_client: RuntimeServiceClient | None = None) -> None:
+        run_request_repository: RunRequestRepository) -> None:
         self.session_repository = session_repository
         self.message_repository = message_repository
         self.run_request_repository = run_request_repository
-        self.runtime_client = runtime_client
 
     def create_session(self, payload: SessionCreateRequest) -> SessionModel:
         return self.session_repository.create(
@@ -54,38 +49,3 @@ class SessionApplicationService:
 
     def list_run_requests(self, session_id: str) -> list[RunRequest]:
         return self.run_request_repository.list_by_session(session_id=session_id)
-
-    def dispatch_run_request(self, payload: DispatchRunRequest) -> DispatchRunResponse:
-        run_request = self.create_run_request(
-            RunRequestCreateRequest(
-                session_id=payload.session_id,
-                app_config_id=payload.app_config_id,
-                workflow_config_id=payload.workflow_config_id,
-                trigger_type=payload.trigger_type,
-                request_payload_json=payload.request_payload_json,
-                request_status="accepted")
-        )
-
-        if self.runtime_client is None:
-            raise RuntimeError("runtime client is not configured")
-
-        runtime_response = self.runtime_client.create_run(
-            RunCreateContract(
-                app_id=payload.app_id,
-                app_config_id=payload.app_config_id,
-                workflow_id=payload.workflow_id,
-                workflow_config_id=payload.workflow_config_id,
-                session_id=payload.session_id,
-                trigger_type=payload.trigger_type,
-                priority=payload.priority,
-                initial_node=(
-                    InitialNodeContract(
-                        node_id=payload.initial_node.node_id,
-                        node_type=payload.initial_node.node_type,
-                        status=payload.initial_node.status)
-                    if payload.initial_node is not None
-                    else None
-                ))
-        )
-
-        return DispatchRunResponse.from_parts(run_request=run_request, runtime_response=runtime_response)

+ 0 - 1
services/session-service/app/bootstrap/settings.py

@@ -4,4 +4,3 @@ from core_shared import ServiceSettings
 class SessionServiceSettings(ServiceSettings):
     service_name: str = "session-service"
     service_port: int = 8001
-    runtime_service_url: str = "http://127.0.0.1:8003"

+ 0 - 23
services/session-service/app/infrastructure/runtime_client.py

@@ -1,23 +0,0 @@
-import httpx
-from core_domain import RunBootstrapContract, RunCreateContract
-
-
-class RuntimeServiceClientError(Exception):
-    pass
-
-
-class RuntimeServiceClient:
-    def __init__(self, base_url: str, timeout_seconds: float = 10.0) -> None:
-        self.base_url = base_url.rstrip("/")
-        self.timeout_seconds = timeout_seconds
-
-    def create_run(self, payload: RunCreateContract) -> RunBootstrapContract:
-        try:
-            with httpx.Client(timeout=self.timeout_seconds) as client:
-                response = client.post(
-                    f"{self.base_url}/runtime/runs",
-                    json=payload.model_dump(mode="json"))
-                response.raise_for_status()
-                return RunBootstrapContract.model_validate(response.json())
-        except httpx.HTTPError as exc:
-            raise RuntimeServiceClientError(f"runtime-service request failed: {exc}") from exc

+ 0 - 32
services/session-service/app/schemas/run_request.py

@@ -1,7 +1,6 @@
 from datetime import datetime
 from typing import TYPE_CHECKING
 
-from core_domain import InitialNodeContract, RunBootstrapContract
 from core_shared import JSONValue
 from pydantic import BaseModel, Field
 
@@ -9,10 +8,6 @@ if TYPE_CHECKING:
     from app.db.models import RunRequest
 
 
-class InitialNodeRequest(InitialNodeContract):
-    pass
-
-
 class RunRequestCreateRequest(BaseModel):
     session_id: str
     app_config_id: str
@@ -39,30 +34,3 @@ class RunRequestResponse(BaseModel):
     @classmethod
     def from_entity(cls, entity: "RunRequest") -> "RunRequestResponse":
         return cls.model_validate(entity, from_attributes=True)
-
-
-class DispatchRunRequest(BaseModel):
-    session_id: str
-    app_id: str
-    app_config_id: str
-    workflow_id: str
-    workflow_config_id: str
-    trigger_type: str = "chat"
-    priority: int = 0
-    request_payload_json: dict[str, JSONValue] = Field(default_factory=dict)
-    initial_node: InitialNodeRequest | None = None
-
-
-class DispatchRunResponse(BaseModel):
-    run_request: RunRequestResponse
-    runtime_response: RunBootstrapContract
-
-    @classmethod
-    def from_parts(
-        cls,
-        *,
-        run_request: "RunRequest",
-        runtime_response: RunBootstrapContract) -> "DispatchRunResponse":
-        return cls(
-            run_request=RunRequestResponse.from_entity(run_request),
-            runtime_response=runtime_response)

+ 0 - 36
services/workflow-service/alembic.ini

@@ -1,36 +0,0 @@
-[alembic]
-script_location = alembic
-prepend_sys_path = .
-sqlalchemy.url = postgresql+psycopg://admin:hFOvG5UBeK5KIGhz5cQH@git.newpoint.work:5432/vectordb
-
-[loggers]
-keys = root,sqlalchemy,alembic
-
-[handlers]
-keys = console
-
-[formatters]
-keys = generic
-
-[logger_root]
-level = WARN
-handlers = console
-
-[logger_sqlalchemy]
-level = WARN
-handlers =
-qualname = sqlalchemy.engine
-
-[logger_alembic]
-level = INFO
-handlers = console
-qualname = alembic
-
-[handler_console]
-class = StreamHandler
-args = (sys.stderr,)
-level = NOTSET
-formatter = generic
-
-[formatter_generic]
-format = %(levelname)-5.5s [%(name)s] %(message)s

+ 0 - 53
services/workflow-service/alembic/env.py

@@ -1,53 +0,0 @@
-import os
-from logging.config import fileConfig
-
-from alembic import context
-from app.db.models import Base
-from sqlalchemy import engine_from_config, pool
-
-SERVICE_VERSION_TABLE = "workflow_alembic_version"
-
-config = context.config
-database_url = os.getenv("AGENT_PLATFORM_DATABASE_URL")
-if database_url:
-    config.set_main_option("sqlalchemy.url", database_url.replace("%", "%%"))
-
-if config.config_file_name is not None:
-    fileConfig(config.config_file_name)
-
-target_metadata = Base.metadata
-
-
-def run_migrations_offline() -> None:
-    url = config.get_main_option("sqlalchemy.url")
-    context.configure(
-        url=url,
-        target_metadata=target_metadata,
-        literal_binds=True,
-        version_table=SERVICE_VERSION_TABLE)
-
-    with context.begin_transaction():
-        context.run_migrations()
-
-
-def run_migrations_online() -> None:
-    connectable = engine_from_config(
-        config.get_section(config.config_ini_section, {}),
-        prefix="sqlalchemy.",
-        poolclass=pool.NullPool)
-
-    with connectable.connect() as connection:
-        context.configure(
-            connection=connection,
-            target_metadata=target_metadata,
-            version_table=SERVICE_VERSION_TABLE)
-
-        with context.begin_transaction():
-            context.run_migrations()
-
-
-if context.is_offline_mode():
-    run_migrations_offline()
-else:
-    run_migrations_online()
-

+ 0 - 1
services/workflow-service/alembic/versions/.gitkeep

@@ -1 +0,0 @@
-

+ 0 - 113
services/workflow-service/alembic/versions/20260422_0001_init_workflow_models.py

@@ -1,113 +0,0 @@
-"""init workflow models
-
-Revision ID: 20260422_0001
-Revises:
-Create Date: 2026-04-22 17:20:00
-"""
-
-from collections.abc import Sequence
-
-import sqlalchemy as sa
-from alembic import op
-
-revision: str = "20260422_0001"
-down_revision: str | None = None
-branch_labels: Sequence[str] | None = None
-depends_on: Sequence[str] | None = None
-
-
-def upgrade() -> None:
-    op.create_table(
-        "app_definition",
-        sa.Column("code", sa.String(length=64), nullable=False),
-        sa.Column("name", sa.String(length=128), nullable=False),
-        sa.Column("description", sa.Text(), nullable=True),
-        sa.Column("app_type", sa.String(length=32), nullable=False),
-        sa.Column("status", sa.String(length=32), nullable=False),
-        sa.Column("owner_user_id", sa.String(length=36), nullable=True),
-        sa.Column("settings_json", sa.JSON(), nullable=True),
-        sa.Column("id", sa.String(length=36), nullable=False),
-        sa.Column("created_by", sa.String(length=36), nullable=True),
-        sa.Column("updated_by", sa.String(length=36), nullable=True),
-        sa.Column("created_time", sa.DateTime(), nullable=False),
-        sa.Column("updated_time", sa.DateTime(), nullable=False),
-        sa.Column("deleted_time", sa.DateTime(), nullable=True),
-        sa.Column("version", sa.Integer(), nullable=False),
-        sa.PrimaryKeyConstraint("id"))
-    op.create_index("ix_app_definition_code", "app_definition", ["code"], unique=False)
-
-    op.create_table(
-        "app_version",
-        sa.Column("app_id", sa.String(length=36), nullable=False),
-        sa.Column("version_no", sa.Integer(), nullable=False),
-        sa.Column("status", sa.String(length=32), nullable=False),
-        sa.Column("workflow_version_id", sa.String(length=36), nullable=False),
-        sa.Column("published_time", sa.DateTime(), nullable=True),
-        sa.Column("published_by", sa.String(length=36), nullable=True),
-        sa.Column("changelog", sa.Text(), nullable=True),
-        sa.Column("id", sa.String(length=36), nullable=False),
-        sa.Column("created_by", sa.String(length=36), nullable=True),
-        sa.Column("updated_by", sa.String(length=36), nullable=True),
-        sa.Column("created_time", sa.DateTime(), nullable=False),
-        sa.Column("updated_time", sa.DateTime(), nullable=False),
-        sa.Column("deleted_time", sa.DateTime(), nullable=True),
-        sa.Column("version", sa.Integer(), nullable=False),
-        sa.PrimaryKeyConstraint("id"))
-    op.create_index("ix_app_version_app_id", "app_version", ["app_id"], unique=False)
-
-    op.create_table(
-        "workflow_definition",
-        sa.Column("app_id", sa.String(length=36), nullable=False),
-        sa.Column("code", sa.String(length=64), nullable=False),
-        sa.Column("name", sa.String(length=128), nullable=False),
-        sa.Column("workflow_type", sa.String(length=32), nullable=False),
-        sa.Column("latest_version_no", sa.Integer(), nullable=False),
-        sa.Column("id", sa.String(length=36), nullable=False),
-        sa.Column("created_by", sa.String(length=36), nullable=True),
-        sa.Column("updated_by", sa.String(length=36), nullable=True),
-        sa.Column("created_time", sa.DateTime(), nullable=False),
-        sa.Column("updated_time", sa.DateTime(), nullable=False),
-        sa.Column("deleted_time", sa.DateTime(), nullable=True),
-        sa.Column("version", sa.Integer(), nullable=False),
-        sa.PrimaryKeyConstraint("id"))
-    op.create_index("ix_workflow_definition_app_id", "workflow_definition", ["app_id"], unique=False)
-    op.create_index("ix_workflow_definition_code", "workflow_definition", ["code"], unique=False)
-
-    op.create_table(
-        "workflow_version",
-        sa.Column("workflow_id", sa.String(length=36), nullable=False),
-        sa.Column("version_no", sa.Integer(), nullable=False),
-        sa.Column("dsl_json", sa.JSON(), nullable=True),
-        sa.Column("compiled_plan_json", sa.JSON(), nullable=True),
-        sa.Column("schema_version", sa.String(length=32), nullable=True),
-        sa.Column("checksum", sa.String(length=128), nullable=True),
-        sa.Column("status", sa.String(length=32), nullable=False),
-        sa.Column("id", sa.String(length=36), nullable=False),
-        sa.Column("created_by", sa.String(length=36), nullable=True),
-        sa.Column("updated_by", sa.String(length=36), nullable=True),
-        sa.Column("created_time", sa.DateTime(), nullable=False),
-        sa.Column("updated_time", sa.DateTime(), nullable=False),
-        sa.Column("deleted_time", sa.DateTime(), nullable=True),
-        sa.Column("version", sa.Integer(), nullable=False),
-        sa.PrimaryKeyConstraint("id"))
-    op.create_index(
-        "ix_workflow_version_workflow_id",
-        "workflow_version",
-        ["workflow_id"],
-        unique=False)
-
-
-def downgrade() -> None:
-    op.drop_index("ix_workflow_version_workflow_id", table_name="workflow_version")
-    op.drop_table("workflow_version")
-
-    op.drop_index("ix_workflow_definition_code", table_name="workflow_definition")
-    op.drop_index("ix_workflow_definition_app_id", table_name="workflow_definition")
-    op.drop_table("workflow_definition")
-
-    op.drop_index("ix_app_version_app_id", table_name="app_version")
-    op.drop_table("app_version")
-
-    op.drop_index("ix_app_definition_code", table_name="app_definition")
-    op.drop_table("app_definition")
-

+ 0 - 22
services/workflow-service/alembic/versions/20260429_9001_remove_workflow_versioning.py

@@ -1,22 +0,0 @@
-"""Remove business version schema artifacts.
-
-Revision ID: 20260429_9001_workflow
-Revises: 20260422_0001
-Create Date: 2026-04-29 00:00:00.000000
-"""
-
-from alembic import op
-
-revision: str = "20260429_9001_workflow"
-down_revision: str | None = "20260422_0001"
-branch_labels = None
-depends_on = None
-
-
-def upgrade() -> None:
-    op.execute("DO $$\nBEGIN\n    IF to_regclass('workflow_version') IS NOT NULL AND to_regclass('workflow_config') IS NULL THEN\n        ALTER TABLE workflow_version RENAME TO workflow_config;\n    END IF;\n    IF to_regclass('app_version') IS NOT NULL AND to_regclass('app_config') IS NULL THEN\n        ALTER TABLE app_version RENAME TO app_config;\n    END IF;\n    IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'app_config' AND column_name = 'workflow_version_id') THEN\n        ALTER TABLE app_config RENAME COLUMN workflow_version_id TO workflow_config_id;\n    END IF;\nEND $$;\nALTER TABLE IF EXISTS workflow_definition DROP COLUMN IF EXISTS latest_version_no;\nALTER TABLE IF EXISTS workflow_config DROP COLUMN IF EXISTS version_no;\nALTER TABLE IF EXISTS workflow_config DROP COLUMN IF EXISTS schema_version;\nALTER TABLE IF EXISTS workflow_config DROP COLUMN IF EXISTS status;\nALTER TABLE IF EXISTS app_config DROP COLUMN IF EXISTS version_no;\nALTER TABLE IF EXISTS app_config DROP COLUMN IF EXISTS status;\nALTER TABLE IF EXISTS app_config DROP COLUMN IF EXISTS published_time;\nALTER TABLE IF EXISTS app_config DROP COLUMN IF EXISTS published_by;\nALTER TABLE IF EXISTS app_config DROP COLUMN IF EXISTS changelog;\nDO $$\nDECLARE\n    table_record record;\nBEGIN\n    FOR table_record IN\n        SELECT table_name\n        FROM information_schema.columns\n        WHERE table_schema = current_schema()\n          AND column_name = 'version'\n    LOOP\n        EXECUTE format('ALTER TABLE %I DROP COLUMN IF EXISTS version', table_record.table_name);\n    END LOOP;\nEND $$;")
-
-
-def downgrade() -> None:
-    # Business version tables and columns were intentionally removed.
-    pass

+ 0 - 1
services/workflow-service/app/__init__.py

@@ -1 +0,0 @@
-

+ 0 - 1
services/workflow-service/app/api/__init__.py

@@ -1 +0,0 @@
-

+ 0 - 228
services/workflow-service/app/api/routes.py

@@ -1,228 +0,0 @@
-from core_domain import ServiceHealth
-from core_dsl import EdgeDefinition, NodeDefinition, WorkflowDefinition
-from fastapi import APIRouter, Depends, HTTPException, Query
-from sqlalchemy import text
-from sqlalchemy.orm import Session
-
-from app.application.services import WorkflowApplicationService
-from app.db.session import get_db
-from app.domain.repositories import (
-    AppDefinitionRepository,
-    AppConfigRepository,
-    WorkflowDefinitionRepository,
-    WorkflowConfigRepository,
-)
-from app.schemas.app import (
-    AppCreateRequest,
-    AppListRequest,
-    AppResponse,
-    AppConfigCreateRequest,
-    AppConfigListRequest,
-    AppConfigResponse,
-)
-from app.schemas.workflow import (
-    WorkflowCreateRequest,
-    WorkflowDebuggerPlanRequest,
-    WorkflowDebuggerPlanResponse,
-    WorkflowDefinitionResponse,
-    WorkflowDesignerValidateRequest,
-    WorkflowDesignerValidateResponse,
-    WorkflowListRequest,
-    WorkflowConfigCreateRequest,
-    WorkflowConfigDebuggerPlanRequest,
-    WorkflowConfigDetailRequest,
-    WorkflowConfigListRequest,
-    WorkflowConfigResponse,
-)
-
-router = APIRouter()
-
-
-def get_workflow_application_service(db: Session = Depends(get_db)) -> WorkflowApplicationService:
-    return WorkflowApplicationService(
-        app_repository=AppDefinitionRepository(db),
-        workflow_repository=WorkflowDefinitionRepository(db),
-        app_config_repository=AppConfigRepository(db),
-        workflow_config_repository=WorkflowConfigRepository(db))
-
-
-@router.get("/health", response_model=ServiceHealth)
-def health_check(db: Session = Depends(get_db)) -> ServiceHealth:
-    db.execute(text("SELECT 1"))
-    return ServiceHealth(service="workflow-service", status="ok", database="ok")
-
-
-@router.get("/sample", response_model=WorkflowDefinition)
-def get_sample_workflow() -> WorkflowDefinition:
-    return WorkflowDefinition(
-        code="sample_workflow",
-        name="Sample Workflow",
-        nodes=[
-            NodeDefinition(id="start", type="llm"),
-            NodeDefinition(id="end", type="answer"),
-        ],
-        edges=[EdgeDefinition(source="start", target="end")])
-
-
-@router.post("/sample", response_model=WorkflowDefinition)
-def get_sample_workflow_post() -> WorkflowDefinition:
-    return get_sample_workflow()
-
-
-@router.post("/designer/validate", response_model=WorkflowDesignerValidateResponse)
-def validate_workflow_designer_dsl(
-    payload: WorkflowDesignerValidateRequest,
-    service: WorkflowApplicationService = Depends(get_workflow_application_service)) -> WorkflowDesignerValidateResponse:
-    inspection = service.validate_designer_workflow(payload)
-    return WorkflowDesignerValidateResponse.from_inspection(inspection)
-
-
-@router.post("/designer/debug", response_model=WorkflowDebuggerPlanResponse)
-def debug_workflow_designer_dsl(
-    payload: WorkflowDebuggerPlanRequest,
-    service: WorkflowApplicationService = Depends(get_workflow_application_service)) -> WorkflowDebuggerPlanResponse:
-    plan = service.build_designer_debug_plan(payload)
-    return WorkflowDebuggerPlanResponse.from_plan(plan)
-
-
-@router.post("/apps", response_model=AppResponse)
-def create_app(
-    payload: AppCreateRequest,
-    service: WorkflowApplicationService = Depends(get_workflow_application_service)) -> AppResponse:
-    entity = service.create_app(payload)
-    return AppResponse.from_entity(entity)
-
-
-@router.get("/apps", response_model=list[AppResponse])
-def list_apps(
-    service: WorkflowApplicationService = Depends(get_workflow_application_service)) -> list[AppResponse]:
-    return [AppResponse.from_entity(item) for item in service.list_apps()]
-
-
-@router.post("/apps/list", response_model=list[AppResponse])
-def list_apps_post(
-    payload: AppListRequest,
-    service: WorkflowApplicationService = Depends(get_workflow_application_service)) -> list[AppResponse]:
-    return [AppResponse.from_entity(item) for item in service.list_apps()]
-
-
-@router.post("/apps/configs", response_model=AppConfigResponse)
-def create_app_config(
-    payload: AppConfigCreateRequest,
-    service: WorkflowApplicationService = Depends(get_workflow_application_service)) -> AppConfigResponse:
-    entity = service.create_app_config(payload)
-    return AppConfigResponse.from_entity(entity)
-
-
-@router.get("/apps/configs", response_model=list[AppConfigResponse])
-def list_app_configs(
-    app_id: str = Query(...),
-    service: WorkflowApplicationService = Depends(get_workflow_application_service)) -> list[AppConfigResponse]:
-    return [AppConfigResponse.from_entity(item) for item in service.list_app_configs(app_id)]
-
-
-@router.post("/apps/configs/list", response_model=list[AppConfigResponse])
-def list_app_configs_post(
-    payload: AppConfigListRequest,
-    service: WorkflowApplicationService = Depends(get_workflow_application_service)) -> list[AppConfigResponse]:
-    return [AppConfigResponse.from_entity(item) for item in service.list_app_configs(payload.app_id)]
-
-
-@router.post("", response_model=WorkflowDefinitionResponse)
-def create_workflow(
-    payload: WorkflowCreateRequest,
-    service: WorkflowApplicationService = Depends(get_workflow_application_service)) -> WorkflowDefinitionResponse:
-    entity = service.create_workflow(payload)
-    return WorkflowDefinitionResponse.from_entity(entity)
-
-
-@router.get("", response_model=list[WorkflowDefinitionResponse])
-def list_workflows(
-    app_id: str | None = Query(default=None),
-    service: WorkflowApplicationService = Depends(get_workflow_application_service)) -> list[WorkflowDefinitionResponse]:
-    items = service.list_workflows(app_id=app_id)
-    return [WorkflowDefinitionResponse.from_entity(item) for item in items]
-
-
-@router.post("/list", response_model=list[WorkflowDefinitionResponse])
-def list_workflows_post(
-    payload: WorkflowListRequest,
-    service: WorkflowApplicationService = Depends(get_workflow_application_service)) -> list[WorkflowDefinitionResponse]:
-    items = service.list_workflows(app_id=payload.app_id)
-    return [WorkflowDefinitionResponse.from_entity(item) for item in items]
-
-
-@router.post("/configs", response_model=WorkflowConfigResponse)
-def create_workflow_config(
-    payload: WorkflowConfigCreateRequest,
-    service: WorkflowApplicationService = Depends(get_workflow_application_service)) -> WorkflowConfigResponse:
-    try:
-        entity = service.create_workflow_config(payload)
-    except ValueError as exc:
-        raise HTTPException(status_code=422, detail=str(exc)) from exc
-    return WorkflowConfigResponse.from_entity(entity)
-
-
-@router.get("/configs", response_model=list[WorkflowConfigResponse])
-def list_workflow_configs(
-    workflow_id: str = Query(...),
-    service: WorkflowApplicationService = Depends(get_workflow_application_service)) -> list[WorkflowConfigResponse]:
-    items = service.list_workflow_configs(workflow_id=workflow_id)
-    return [WorkflowConfigResponse.from_entity(item) for item in items]
-
-
-@router.post("/configs/list", response_model=list[WorkflowConfigResponse])
-def list_workflow_configs_post(
-    payload: WorkflowConfigListRequest,
-    service: WorkflowApplicationService = Depends(get_workflow_application_service)) -> list[WorkflowConfigResponse]:
-    items = service.list_workflow_configs(workflow_id=payload.workflow_id)
-    return [WorkflowConfigResponse.from_entity(item) for item in items]
-
-
-@router.get("/configs/{workflow_config_id}", response_model=WorkflowConfigResponse)
-def get_workflow_config(
-    workflow_config_id: str,
-    service: WorkflowApplicationService = Depends(get_workflow_application_service)) -> WorkflowConfigResponse:
-    entity = service.get_workflow_config(workflow_config_id=workflow_config_id)
-    if entity is None:
-        raise HTTPException(status_code=404, detail=f"workflow_config not found: {workflow_config_id}")
-    return WorkflowConfigResponse.from_entity(entity)
-
-
-@router.post("/configs/detail", response_model=WorkflowConfigResponse)
-def get_workflow_config_post(
-    payload: WorkflowConfigDetailRequest,
-    service: WorkflowApplicationService = Depends(get_workflow_application_service)) -> WorkflowConfigResponse:
-    entity = service.get_workflow_config(workflow_config_id=payload.workflow_config_id)
-    if entity is None:
-        raise HTTPException(
-            status_code=404,
-            detail=f"workflow_config not found: {payload.workflow_config_id}")
-    return WorkflowConfigResponse.from_entity(entity)
-
-
-@router.get("/configs/{workflow_config_id}/debug", response_model=WorkflowDebuggerPlanResponse)
-def debug_workflow_config(
-    workflow_config_id: str,
-    max_preview_steps: int = Query(default=50, ge=1, le=500),
-    service: WorkflowApplicationService = Depends(get_workflow_application_service)) -> WorkflowDebuggerPlanResponse:
-    plan = service.build_config_debug_plan(
-        workflow_config_id=workflow_config_id,
-        max_preview_steps=max_preview_steps)
-    if plan is None:
-        raise HTTPException(status_code=404, detail=f"workflow_config not found: {workflow_config_id}")
-    return WorkflowDebuggerPlanResponse.from_plan(plan)
-
-
-@router.post("/configs/debug", response_model=WorkflowDebuggerPlanResponse)
-def debug_workflow_config_post(
-    payload: WorkflowConfigDebuggerPlanRequest,
-    service: WorkflowApplicationService = Depends(get_workflow_application_service)) -> WorkflowDebuggerPlanResponse:
-    plan = service.build_config_debug_plan(
-        workflow_config_id=payload.workflow_config_id,
-        max_preview_steps=payload.max_preview_steps)
-    if plan is None:
-        raise HTTPException(
-            status_code=404,
-            detail=f"workflow_config not found: {payload.workflow_config_id}")
-    return WorkflowDebuggerPlanResponse.from_plan(plan)

+ 0 - 1
services/workflow-service/app/application/__init__.py

@@ -1 +0,0 @@
-

+ 0 - 338
services/workflow-service/app/application/designer.py

@@ -1,338 +0,0 @@
-from __future__ import annotations
-
-from dataclasses import dataclass
-from typing import Literal
-
-from core_dsl import WorkflowDefinition, parse_workflow_definition
-from core_shared import JSONValue
-from pydantic import ValidationError
-
-DiagnosticSeverity = Literal["error", "warning", "info"]
-
-
-@dataclass(frozen=True)
-class WorkflowDiagnostic:
-    severity: DiagnosticSeverity
-    code: str
-    message: str
-    node_id: str | None = None
-    edge_index: int | None = None
-
-
-@dataclass(frozen=True)
-class WorkflowNodeInspection:
-    id: str
-    type: str
-    name: str | None
-    incoming_count: int
-    outgoing_count: int
-    reachable: bool
-
-
-@dataclass(frozen=True)
-class WorkflowEdgeInspection:
-    source: str
-    target: str
-    condition: str | None
-    valid_source: bool
-    valid_target: bool
-
-
-@dataclass(frozen=True)
-class WorkflowDebugStep:
-    step_index: int
-    node_id: str
-    node_type: str
-    name: str | None
-    next_node_ids: list[str]
-
-
-@dataclass(frozen=True)
-class WorkflowInspection:
-    valid: bool
-    diagnostics: list[WorkflowDiagnostic]
-    workflow: WorkflowDefinition | None
-    nodes: list[WorkflowNodeInspection]
-    edges: list[WorkflowEdgeInspection]
-    entry_node_ids: list[str]
-    terminal_node_ids: list[str]
-    isolated_node_ids: list[str]
-    unreachable_node_ids: list[str]
-    cycle_detected: bool
-
-
-@dataclass(frozen=True)
-class WorkflowDebugPlan:
-    inspection: WorkflowInspection
-    execution_preview: list[WorkflowDebugStep]
-    max_preview_steps: int
-    truncated: bool
-
-
-def inspect_workflow_dsl(payload: dict[str, JSONValue] | None) -> WorkflowInspection:
-    if payload is None:
-        return WorkflowInspection(
-            valid=False,
-            diagnostics=[
-                WorkflowDiagnostic(
-                    severity="error",
-                    code="dsl.required",
-                    message="workflow dsl_json is required")
-            ],
-            workflow=None,
-            nodes=[],
-            edges=[],
-            entry_node_ids=[],
-            terminal_node_ids=[],
-            isolated_node_ids=[],
-            unreachable_node_ids=[],
-            cycle_detected=False)
-
-    try:
-        workflow = parse_workflow_definition(payload)
-    except ValidationError as exc:
-        return WorkflowInspection(
-            valid=False,
-            diagnostics=[
-                WorkflowDiagnostic(
-                    severity="error",
-                    code="dsl.schema_invalid",
-                    message=str(exc))
-            ],
-            workflow=None,
-            nodes=[],
-            edges=[],
-            entry_node_ids=[],
-            terminal_node_ids=[],
-            isolated_node_ids=[],
-            unreachable_node_ids=[],
-            cycle_detected=False)
-
-    if workflow is None:
-        return WorkflowInspection(
-            valid=False,
-            diagnostics=[
-                WorkflowDiagnostic(
-                    severity="error",
-                    code="dsl.required",
-                    message="workflow dsl_json is required")
-            ],
-            workflow=None,
-            nodes=[],
-            edges=[],
-            entry_node_ids=[],
-            terminal_node_ids=[],
-            isolated_node_ids=[],
-            unreachable_node_ids=[],
-            cycle_detected=False)
-
-    diagnostics: list[WorkflowDiagnostic] = []
-    node_ids = [node.id for node in workflow.nodes]
-    node_id_set = set(node_ids)
-    duplicate_node_ids = sorted({node_id for node_id in node_ids if node_ids.count(node_id) > 1})
-    for node_id in duplicate_node_ids:
-        diagnostics.append(
-            WorkflowDiagnostic(
-                severity="error",
-                code="node.duplicate_id",
-                message=f"duplicate node id: {node_id}",
-                node_id=node_id)
-        )
-
-    incoming_counts = {node_id: 0 for node_id in node_ids}
-    outgoing_counts = {node_id: 0 for node_id in node_ids}
-    adjacency: dict[str, list[str]] = {node_id: [] for node_id in node_ids}
-    edge_inspections: list[WorkflowEdgeInspection] = []
-
-    for edge_index, edge in enumerate(workflow.edges):
-        valid_source = edge.source in node_id_set
-        valid_target = edge.target in node_id_set
-        edge_inspections.append(
-            WorkflowEdgeInspection(
-                source=edge.source,
-                target=edge.target,
-                condition=edge.condition,
-                valid_source=valid_source,
-                valid_target=valid_target)
-        )
-        if not valid_source:
-            diagnostics.append(
-                WorkflowDiagnostic(
-                    severity="error",
-                    code="edge.source_missing",
-                    message=f"edge source node does not exist: {edge.source}",
-                    node_id=edge.source,
-                    edge_index=edge_index)
-            )
-        if not valid_target:
-            diagnostics.append(
-                WorkflowDiagnostic(
-                    severity="error",
-                    code="edge.target_missing",
-                    message=f"edge target node does not exist: {edge.target}",
-                    node_id=edge.target,
-                    edge_index=edge_index)
-            )
-        if valid_source and valid_target:
-            outgoing_counts[edge.source] = outgoing_counts.get(edge.source, 0) + 1
-            incoming_counts[edge.target] = incoming_counts.get(edge.target, 0) + 1
-            adjacency.setdefault(edge.source, []).append(edge.target)
-
-    if not workflow.nodes:
-        diagnostics.append(
-            WorkflowDiagnostic(
-                severity="error",
-                code="workflow.nodes_required",
-                message="workflow must contain at least one node")
-        )
-
-    entry_node_ids = [node_id for node_id in node_ids if incoming_counts.get(node_id, 0) == 0]
-    terminal_node_ids = [node_id for node_id in node_ids if outgoing_counts.get(node_id, 0) == 0]
-    isolated_node_ids = [
-        node_id
-        for node_id in node_ids
-        if incoming_counts.get(node_id, 0) == 0 and outgoing_counts.get(node_id, 0) == 0
-    ]
-
-    if len(entry_node_ids) > 1:
-        diagnostics.append(
-            WorkflowDiagnostic(
-                severity="warning",
-                code="workflow.multiple_entry_nodes",
-                message=f"workflow has multiple entry nodes: {', '.join(entry_node_ids)}")
-        )
-    if not terminal_node_ids and workflow.nodes:
-        diagnostics.append(
-            WorkflowDiagnostic(
-                severity="warning",
-                code="workflow.no_terminal_node",
-                message="workflow has no terminal node")
-        )
-
-    reachable_node_ids = _find_reachable_nodes(entry_node_ids, adjacency)
-    unreachable_node_ids = [node_id for node_id in node_ids if node_id not in reachable_node_ids]
-    for node_id in unreachable_node_ids:
-        diagnostics.append(
-            WorkflowDiagnostic(
-                severity="warning",
-                code="node.unreachable",
-                message=f"node is not reachable from an entry node: {node_id}",
-                node_id=node_id)
-        )
-
-    cycle_detected = _detect_cycle(node_ids, adjacency)
-    if cycle_detected:
-        diagnostics.append(
-            WorkflowDiagnostic(
-                severity="warning",
-                code="workflow.cycle_detected",
-                message="workflow graph contains a cycle; debugger preview may be truncated")
-        )
-
-    node_inspections = [
-        WorkflowNodeInspection(
-            id=node.id,
-            type=node.type,
-            name=node.name,
-            incoming_count=incoming_counts.get(node.id, 0),
-            outgoing_count=outgoing_counts.get(node.id, 0),
-            reachable=node.id in reachable_node_ids)
-        for node in workflow.nodes
-    ]
-    valid = not any(item.severity == "error" for item in diagnostics)
-    return WorkflowInspection(
-        valid=valid,
-        diagnostics=diagnostics,
-        workflow=workflow,
-        nodes=node_inspections,
-        edges=edge_inspections,
-        entry_node_ids=entry_node_ids,
-        terminal_node_ids=terminal_node_ids,
-        isolated_node_ids=isolated_node_ids,
-        unreachable_node_ids=unreachable_node_ids,
-        cycle_detected=cycle_detected)
-
-
-def build_debug_plan(
-    payload: dict[str, JSONValue] | None,
-    *,
-    max_preview_steps: int = 50) -> WorkflowDebugPlan:
-    inspection = inspect_workflow_dsl(payload)
-    workflow = inspection.workflow
-    if workflow is None or not inspection.valid:
-        return WorkflowDebugPlan(
-            inspection=inspection,
-            execution_preview=[],
-            max_preview_steps=max_preview_steps,
-            truncated=False)
-
-    node_map = {node.id: node for node in workflow.nodes}
-    adjacency: dict[str, list[str]] = {node.id: [] for node in workflow.nodes}
-    for edge in workflow.edges:
-        if edge.source in node_map and edge.target in node_map:
-            adjacency.setdefault(edge.source, []).append(edge.target)
-
-    preview: list[WorkflowDebugStep] = []
-    queue = list(inspection.entry_node_ids)
-    seen_visits: dict[str, int] = {}
-    truncated = False
-
-    while queue and len(preview) < max_preview_steps:
-        node_id = queue.pop(0)
-        node = node_map.get(node_id)
-        if node is None:
-            continue
-        seen_visits[node_id] = seen_visits.get(node_id, 0) + 1
-        if seen_visits[node_id] > 1:
-            continue
-        next_node_ids = adjacency.get(node_id, [])
-        preview.append(
-            WorkflowDebugStep(
-                step_index=len(preview),
-                node_id=node.id,
-                node_type=node.type,
-                name=node.name,
-                next_node_ids=next_node_ids)
-        )
-        queue.extend(next_node_ids)
-
-    if queue:
-        truncated = True
-
-    return WorkflowDebugPlan(
-        inspection=inspection,
-        execution_preview=preview,
-        max_preview_steps=max_preview_steps,
-        truncated=truncated)
-
-
-def _find_reachable_nodes(entry_node_ids: list[str], adjacency: dict[str, list[str]]) -> set[str]:
-    reachable: set[str] = set()
-    stack = list(entry_node_ids)
-    while stack:
-        node_id = stack.pop()
-        if node_id in reachable:
-            continue
-        reachable.add(node_id)
-        stack.extend(adjacency.get(node_id, []))
-    return reachable
-
-
-def _detect_cycle(node_ids: list[str], adjacency: dict[str, list[str]]) -> bool:
-    visiting: set[str] = set()
-    visited: set[str] = set()
-
-    def visit(node_id: str) -> bool:
-        if node_id in visiting:
-            return True
-        if node_id in visited:
-            return False
-        visiting.add(node_id)
-        for next_node_id in adjacency.get(node_id, []):
-            if visit(next_node_id):
-                return True
-        visiting.remove(node_id)
-        visited.add(node_id)
-        return False
-
-    return any(visit(node_id) for node_id in node_ids)

+ 0 - 153
services/workflow-service/app/application/services.py

@@ -1,153 +0,0 @@
-from core_dsl import parse_workflow_definition
-from core_shared import JSONValue
-from pydantic import ValidationError
-
-from app.application.designer import (
-    WorkflowDebugPlan,
-    WorkflowInspection,
-    build_debug_plan,
-    inspect_workflow_dsl,
-)
-from app.db.models import AppDefinition, AppConfig, WorkflowDefinitionModel, WorkflowConfig
-from app.domain.repositories import (
-    AppDefinitionRepository,
-    AppConfigRepository,
-    WorkflowDefinitionRepository,
-    WorkflowConfigRepository,
-)
-from app.schemas.app import AppCreateRequest, AppConfigCreateRequest
-from app.schemas.workflow import (
-    WorkflowCreateRequest,
-    WorkflowDebuggerPlanRequest,
-    WorkflowDesignerValidateRequest,
-    WorkflowConfigCreateRequest,
-)
-
-
-class WorkflowApplicationService:
-    def __init__(
-        self,
-        app_repository: AppDefinitionRepository,
-        workflow_repository: WorkflowDefinitionRepository,
-        app_config_repository: AppConfigRepository,
-        workflow_config_repository: WorkflowConfigRepository) -> None:
-        self.app_repository = app_repository
-        self.workflow_repository = workflow_repository
-        self.app_config_repository = app_config_repository
-        self.workflow_config_repository = workflow_config_repository
-
-    def create_app(self, payload: AppCreateRequest) -> AppDefinition:
-        return self.app_repository.create(
-            code=payload.code,
-            name=payload.name,
-            description=payload.description,
-            owner_user_id=payload.owner_user_id,
-            settings_json=payload.settings_json)
-
-    def list_apps(self) -> list[AppDefinition]:
-        return self.app_repository.list_all()
-
-    def create_workflow(self, payload: WorkflowCreateRequest) -> WorkflowDefinitionModel:
-        return self.workflow_repository.create(
-            app_id=payload.app_id,
-            code=payload.code,
-            name=payload.name,
-            workflow_type=payload.workflow_type)
-
-    def list_workflows(self, app_id: str | None = None) -> list[WorkflowDefinitionModel]:
-        return self.workflow_repository.list_by_scope(app_id=app_id)
-
-    def create_workflow_config(self, payload: WorkflowConfigCreateRequest) -> WorkflowConfig:
-        dsl_json = self._validate_workflow_dsl(payload.dsl_json)
-        compiled_plan_json = payload.compiled_plan_json
-        if compiled_plan_json is None and dsl_json is not None:
-            compiled_plan_json = self._build_compiled_plan_json(dsl_json)
-        return self.workflow_config_repository.create(
-            workflow_id=payload.workflow_id,
-            dsl_json=dsl_json,
-            compiled_plan_json=compiled_plan_json,
-            checksum=payload.checksum)
-
-    def list_workflow_configs(self, workflow_id: str) -> list[WorkflowConfig]:
-        return self.workflow_config_repository.list_by_workflow(
-            workflow_id=workflow_id)
-
-    def get_workflow_config(self, workflow_config_id: str) -> WorkflowConfig | None:
-        return self.workflow_config_repository.get_by_id(
-            workflow_config_id=workflow_config_id)
-
-    def validate_designer_workflow(
-        self,
-        payload: WorkflowDesignerValidateRequest) -> WorkflowInspection:
-        return inspect_workflow_dsl(payload.dsl_json)
-
-    def build_designer_debug_plan(
-        self,
-        payload: WorkflowDebuggerPlanRequest) -> WorkflowDebugPlan:
-        return build_debug_plan(
-            payload.dsl_json,
-            max_preview_steps=payload.max_preview_steps)
-
-    def build_config_debug_plan(
-        self,
-        *,
-        workflow_config_id: str,
-        max_preview_steps: int) -> WorkflowDebugPlan | None:
-        entity = self.get_workflow_config(
-            workflow_config_id=workflow_config_id)
-        if entity is None:
-            return None
-        return build_debug_plan(
-            entity.dsl_json,
-            max_preview_steps=max_preview_steps)
-
-    def create_app_config(self, payload: AppConfigCreateRequest) -> AppConfig:
-        return self.app_config_repository.create(
-            app_id=payload.app_id,
-            workflow_config_id=payload.workflow_config_id)
-
-    def list_app_configs(self, app_id: str) -> list[AppConfig]:
-        return self.app_config_repository.list_by_app(app_id=app_id)
-
-    def _validate_workflow_dsl(
-        self,
-        dsl_json: dict[str, JSONValue] | None) -> dict[str, JSONValue] | None:
-        if dsl_json is None:
-            return None
-
-        try:
-            workflow = parse_workflow_definition(dsl_json)
-        except ValidationError as exc:
-            raise ValueError(f"invalid workflow dsl: {exc}") from exc
-
-        inspection = inspect_workflow_dsl(dsl_json)
-        errors = [item for item in inspection.diagnostics if item.severity == "error"]
-        if errors:
-            message = "; ".join(f"{item.code}: {item.message}" for item in errors)
-            raise ValueError(f"invalid workflow dsl: {message}")
-
-        if workflow is None:
-            return None
-        return workflow.model_dump(mode="json")
-
-    def _build_compiled_plan_json(
-        self,
-        dsl_json: dict[str, JSONValue]) -> dict[str, JSONValue]:
-        plan = build_debug_plan(dsl_json)
-        return {
-            "valid": plan.inspection.valid,
-            "entry_node_ids": plan.inspection.entry_node_ids,
-            "terminal_node_ids": plan.inspection.terminal_node_ids,
-            "node_count": len(plan.inspection.nodes),
-            "edge_count": len(plan.inspection.edges),
-            "execution_preview": [
-                {
-                    "step_index": item.step_index,
-                    "node_id": item.node_id,
-                    "node_type": item.node_type,
-                    "name": item.name,
-                    "next_node_ids": item.next_node_ids,
-                }
-                for item in plan.execution_preview
-            ],
-        }

+ 0 - 1
services/workflow-service/app/bootstrap/__init__.py

@@ -1 +0,0 @@
-

+ 0 - 20
services/workflow-service/app/bootstrap/app.py

@@ -1,20 +0,0 @@
-from core_shared.observability import add_observability
-from core_shared.security import add_internal_service_auth
-from fastapi import FastAPI
-
-from app.api.routes import router
-from app.bootstrap.settings import WorkflowServiceSettings
-from app.db.session import build_session_factory
-
-
-def create_app() -> FastAPI:
-    settings = WorkflowServiceSettings()
-    app = FastAPI(
-        title="agent-platform workflow-service",
-        version="0.1.0")
-    app.state.settings = settings
-    app.state.session_factory = build_session_factory(settings)
-    add_observability(app, settings.service_name)
-    add_internal_service_auth(app, settings)
-    app.include_router(router, prefix="/workflows", tags=["workflows"])
-    return app

+ 0 - 6
services/workflow-service/app/bootstrap/settings.py

@@ -1,6 +0,0 @@
-from core_shared import ServiceSettings
-
-
-class WorkflowServiceSettings(ServiceSettings):
-    service_name: str = "workflow-service"
-    service_port: int = 8002

+ 0 - 1
services/workflow-service/app/db/__init__.py

@@ -1 +0,0 @@
-

+ 0 - 15
services/workflow-service/app/db/models/__init__.py

@@ -1,15 +0,0 @@
-from core_db import Base
-
-from .app_definition import AppDefinition
-from .app_config import AppConfig
-from .workflow_definition import WorkflowDefinitionModel
-from .workflow_config import WorkflowConfig
-
-__all__ = [
-    "AppDefinition",
-    "AppConfig",
-    "Base",
-    "WorkflowDefinitionModel",
-    "WorkflowConfig",
-]
-

+ 0 - 11
services/workflow-service/app/db/models/app_config.py

@@ -1,11 +0,0 @@
-from core_db import AuditMixin, Base, EntityMixin
-from sqlalchemy import String
-from sqlalchemy.orm import Mapped, mapped_column
-
-
-class AppConfig(EntityMixin, AuditMixin, Base):
-    __tablename__ = "app_config"
-
-    app_id: Mapped[str] = mapped_column(String(36), index=True)
-    workflow_config_id: Mapped[str] = mapped_column(String(36))
-

+ 0 - 17
services/workflow-service/app/db/models/app_definition.py

@@ -1,17 +0,0 @@
-from core_db import AuditMixin, Base, EntityMixin
-from sqlalchemy import String, Text
-from sqlalchemy import JSON
-from sqlalchemy.orm import Mapped, mapped_column
-
-
-class AppDefinition(EntityMixin, AuditMixin, Base):
-    __tablename__ = "app_definition"
-
-    code: Mapped[str] = mapped_column(String(64), index=True)
-    name: Mapped[str] = mapped_column(String(128))
-    description: Mapped[str | None] = mapped_column(Text, nullable=True)
-    app_type: Mapped[str] = mapped_column(String(32), default="workflow")
-    status: Mapped[str] = mapped_column(String(32), default="draft")
-    owner_user_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
-    settings_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
-

+ 0 - 14
services/workflow-service/app/db/models/workflow_config.py

@@ -1,14 +0,0 @@
-from core_db import AuditMixin, Base, EntityMixin
-from sqlalchemy import String
-from sqlalchemy import JSON
-from sqlalchemy.orm import Mapped, mapped_column
-
-
-class WorkflowConfig(EntityMixin, AuditMixin, Base):
-    __tablename__ = "workflow_config"
-
-    workflow_id: Mapped[str] = mapped_column(String(36), index=True)
-    dsl_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
-    compiled_plan_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
-    checksum: Mapped[str | None] = mapped_column(String(128), nullable=True)
-

+ 0 - 13
services/workflow-service/app/db/models/workflow_definition.py

@@ -1,13 +0,0 @@
-from core_db import AuditMixin, Base, EntityMixin
-from sqlalchemy import String
-from sqlalchemy.orm import Mapped, mapped_column
-
-
-class WorkflowDefinitionModel(EntityMixin, AuditMixin, Base):
-    __tablename__ = "workflow_definition"
-
-    app_id: Mapped[str] = mapped_column(String(36), index=True)
-    code: Mapped[str] = mapped_column(String(64), index=True)
-    name: Mapped[str] = mapped_column(String(128))
-    workflow_type: Mapped[str] = mapped_column(String(32), default="main")
-

+ 0 - 27
services/workflow-service/app/db/session.py

@@ -1,27 +0,0 @@
-from collections.abc import Generator
-
-from core_db import DatabaseSettings, create_engine_from_settings, create_session_factory
-from fastapi import Request
-from sqlalchemy.orm import Session, sessionmaker
-
-from app.bootstrap.settings import WorkflowServiceSettings
-
-
-def build_session_factory(
-    settings: WorkflowServiceSettings | None = None) -> sessionmaker[Session]:
-    resolved_settings = settings or WorkflowServiceSettings()
-    db_settings = DatabaseSettings(
-        database_url=resolved_settings.database_url,
-        echo_sql=resolved_settings.echo_sql)
-    engine = create_engine_from_settings(db_settings)
-    return create_session_factory(engine)
-
-
-def get_db(request: Request) -> Generator[Session, None, None]:
-    session_factory: sessionmaker[Session] = request.app.state.session_factory
-    session = session_factory()
-    try:
-        yield session
-    finally:
-        session.close()
-

+ 0 - 1
services/workflow-service/app/domain/__init__.py

@@ -1 +0,0 @@
-

+ 0 - 124
services/workflow-service/app/domain/repositories.py

@@ -1,124 +0,0 @@
-from sqlalchemy import select
-from sqlalchemy.orm import Session
-
-from app.db.models import AppDefinition, AppConfig, WorkflowDefinitionModel, WorkflowConfig
-
-
-class AppDefinitionRepository:
-    def __init__(self, db: Session) -> None:
-        self.db = db
-
-    def create(
-        self,
-        *,
-        code: str,
-        name: str,
-        description: str | None,
-        owner_user_id: str | None,
-        settings_json: dict | None) -> AppDefinition:
-        entity = AppDefinition(
-            code=code,
-            name=name,
-            description=description,
-            owner_user_id=owner_user_id,
-            settings_json=settings_json)
-        self.db.add(entity)
-        self.db.commit()
-        self.db.refresh(entity)
-        return entity
-
-    def list_all(self) -> list[AppDefinition]:
-        stmt = select(AppDefinition)
-        return list(self.db.scalars(stmt))
-
-
-class WorkflowDefinitionRepository:
-    def __init__(self, db: Session) -> None:
-        self.db = db
-
-    def create(
-        self,
-        *,
-        app_id: str,
-        code: str,
-        name: str,
-        workflow_type: str) -> WorkflowDefinitionModel:
-        entity = WorkflowDefinitionModel(
-            app_id=app_id,
-            code=code,
-            name=name,
-            workflow_type=workflow_type)
-        self.db.add(entity)
-        self.db.commit()
-        self.db.refresh(entity)
-        return entity
-
-    def list_by_scope(self, *, app_id: str | None = None) -> list[WorkflowDefinitionModel]:
-        stmt = select(WorkflowDefinitionModel)
-        if app_id:
-            stmt = stmt.where(WorkflowDefinitionModel.app_id == app_id)
-        return list(self.db.scalars(stmt))
-
-
-class AppConfigRepository:
-    def __init__(self, db: Session) -> None:
-        self.db = db
-
-    def create(
-        self,
-        *,
-        app_id: str,
-        workflow_config_id: str) -> AppConfig:
-        entity = AppConfig(
-            app_id=app_id,
-            workflow_config_id=workflow_config_id)
-        self.db.add(entity)
-        self.db.commit()
-        self.db.refresh(entity)
-        return entity
-
-    def list_by_app(self, *, app_id: str) -> list[AppConfig]:
-        stmt = (
-            select(AppConfig)
-            .where(AppConfig.app_id == app_id)
-            .order_by(AppConfig.created_time.desc())
-        )
-        return list(self.db.scalars(stmt))
-
-
-class WorkflowConfigRepository:
-    def __init__(self, db: Session) -> None:
-        self.db = db
-
-    def create(
-        self,
-        *,
-        workflow_id: str,
-        dsl_json: dict | None,
-        compiled_plan_json: dict | None,
-        checksum: str | None) -> WorkflowConfig:
-        entity = WorkflowConfig(
-            workflow_id=workflow_id,
-            dsl_json=dsl_json,
-            compiled_plan_json=compiled_plan_json,
-            checksum=checksum)
-        self.db.add(entity)
-        self.db.commit()
-        self.db.refresh(entity)
-        return entity
-
-    def list_by_workflow(self, *, workflow_id: str) -> list[WorkflowConfig]:
-        stmt = (
-            select(WorkflowConfig)
-            .where(WorkflowConfig.workflow_id == workflow_id)
-            .order_by(WorkflowConfig.created_time.desc())
-        )
-        return list(self.db.scalars(stmt))
-
-    def get_by_id(self, *, workflow_config_id: str) -> WorkflowConfig | None:
-        stmt = (
-            select(WorkflowConfig)
-            .where(WorkflowConfig.id == workflow_config_id)
-        )
-        return self.db.scalar(stmt)
-

+ 0 - 4
services/workflow-service/app/main.py

@@ -1,4 +0,0 @@
-from app.bootstrap.app import create_app
-
-app = create_app()
-

+ 0 - 1
services/workflow-service/app/schemas/__init__.py

@@ -1 +0,0 @@
-

+ 0 - 54
services/workflow-service/app/schemas/app.py

@@ -1,54 +0,0 @@
-from datetime import datetime
-from typing import TYPE_CHECKING
-
-from core_shared import JSONValue
-from pydantic import BaseModel, Field
-
-if TYPE_CHECKING:
-    from app.db.models import AppDefinition, AppConfig
-
-
-class AppCreateRequest(BaseModel):
-    code: str
-    name: str
-    description: str | None = None
-    owner_user_id: str | None = None
-    settings_json: dict[str, JSONValue] = Field(default_factory=dict)
-
-
-class AppListRequest(BaseModel):
-    pass
-
-
-class AppResponse(BaseModel):
-    id: str
-    code: str
-    name: str
-    description: str | None = None
-    owner_user_id: str | None = None
-    settings_json: dict[str, JSONValue] | None = None
-    created_time: datetime
-
-    @classmethod
-    def from_entity(cls, entity: "AppDefinition") -> "AppResponse":
-        return cls.model_validate(entity, from_attributes=True)
-
-
-class AppConfigCreateRequest(BaseModel):
-    app_id: str
-    workflow_config_id: str
-
-
-class AppConfigListRequest(BaseModel):
-    app_id: str
-
-
-class AppConfigResponse(BaseModel):
-    id: str
-    app_id: str
-    workflow_config_id: str
-    created_time: datetime
-
-    @classmethod
-    def from_entity(cls, entity: "AppConfig") -> "AppConfigResponse":
-        return cls.model_validate(entity, from_attributes=True)

+ 0 - 199
services/workflow-service/app/schemas/workflow.py

@@ -1,199 +0,0 @@
-from datetime import datetime
-from typing import TYPE_CHECKING
-
-from core_domain import WorkflowConfigContract
-from core_shared import JSONValue
-from pydantic import BaseModel, Field
-
-from app.application.designer import (
-    DiagnosticSeverity,
-    WorkflowDebugPlan,
-    WorkflowDiagnostic,
-    WorkflowEdgeInspection,
-    WorkflowInspection,
-    WorkflowNodeInspection,
-)
-
-if TYPE_CHECKING:
-    from app.db.models import WorkflowDefinitionModel, WorkflowConfig
-
-
-class WorkflowCreateRequest(BaseModel):
-    app_id: str
-    code: str
-    name: str
-    workflow_type: str = "main"
-
-
-class WorkflowDefinitionResponse(BaseModel):
-    id: str
-    app_id: str
-    code: str
-    name: str
-    workflow_type: str
-    created_time: datetime
-
-    @classmethod
-    def from_entity(cls, entity: "WorkflowDefinitionModel") -> "WorkflowDefinitionResponse":
-        return cls.model_validate(entity, from_attributes=True)
-
-
-class WorkflowListRequest(BaseModel):
-    app_id: str | None = None
-
-
-class WorkflowConfigCreateRequest(BaseModel):
-    workflow_id: str
-    dsl_json: dict[str, JSONValue] | None = None
-    compiled_plan_json: dict[str, JSONValue] | None = None
-    checksum: str | None = None
-
-
-class WorkflowConfigResponse(WorkflowConfigContract):
-
-    @classmethod
-    def from_entity(cls, entity: "WorkflowConfig") -> "WorkflowConfigResponse":
-        return cls.model_validate(entity, from_attributes=True)
-
-
-class WorkflowConfigListRequest(BaseModel):
-    workflow_id: str
-
-
-class WorkflowConfigDetailRequest(BaseModel):
-    workflow_config_id: str
-
-
-class WorkflowDesignerValidateRequest(BaseModel):
-    dsl_json: dict[str, JSONValue]
-
-
-class WorkflowDesignerDiagnosticResponse(BaseModel):
-    severity: DiagnosticSeverity
-    code: str
-    message: str
-    node_id: str | None = None
-    edge_index: int | None = None
-
-    @classmethod
-    def from_inspection(cls, item: WorkflowDiagnostic) -> "WorkflowDesignerDiagnosticResponse":
-        return cls(
-            severity=item.severity,
-            code=item.code,
-            message=item.message,
-            node_id=item.node_id,
-            edge_index=item.edge_index)
-
-
-class WorkflowDesignerNodeResponse(BaseModel):
-    id: str
-    type: str
-    name: str | None = None
-    incoming_count: int
-    outgoing_count: int
-    reachable: bool
-
-    @classmethod
-    def from_inspection(cls, item: WorkflowNodeInspection) -> "WorkflowDesignerNodeResponse":
-        return cls(
-            id=item.id,
-            type=item.type,
-            name=item.name,
-            incoming_count=item.incoming_count,
-            outgoing_count=item.outgoing_count,
-            reachable=item.reachable)
-
-
-class WorkflowDesignerEdgeResponse(BaseModel):
-    source: str
-    target: str
-    condition: str | None = None
-    valid_source: bool
-    valid_target: bool
-
-    @classmethod
-    def from_inspection(cls, item: WorkflowEdgeInspection) -> "WorkflowDesignerEdgeResponse":
-        return cls(
-            source=item.source,
-            target=item.target,
-            condition=item.condition,
-            valid_source=item.valid_source,
-            valid_target=item.valid_target)
-
-
-class WorkflowDesignerValidateResponse(BaseModel):
-    valid: bool
-    diagnostics: list[WorkflowDesignerDiagnosticResponse]
-    node_count: int
-    edge_count: int
-    nodes: list[WorkflowDesignerNodeResponse]
-    edges: list[WorkflowDesignerEdgeResponse]
-    entry_node_ids: list[str]
-    terminal_node_ids: list[str]
-    isolated_node_ids: list[str]
-    unreachable_node_ids: list[str]
-    cycle_detected: bool
-    normalized_dsl_json: dict[str, JSONValue] | None = None
-
-    @classmethod
-    def from_inspection(cls, inspection: WorkflowInspection) -> "WorkflowDesignerValidateResponse":
-        normalized_dsl_json: dict[str, JSONValue] | None = None
-        if inspection.workflow is not None:
-            normalized_dsl_json = inspection.workflow.model_dump(mode="json")
-        return cls(
-            valid=inspection.valid,
-            diagnostics=[
-                WorkflowDesignerDiagnosticResponse.from_inspection(item)
-                for item in inspection.diagnostics
-            ],
-            node_count=len(inspection.nodes),
-            edge_count=len(inspection.edges),
-            nodes=[WorkflowDesignerNodeResponse.from_inspection(item) for item in inspection.nodes],
-            edges=[WorkflowDesignerEdgeResponse.from_inspection(item) for item in inspection.edges],
-            entry_node_ids=inspection.entry_node_ids,
-            terminal_node_ids=inspection.terminal_node_ids,
-            isolated_node_ids=inspection.isolated_node_ids,
-            unreachable_node_ids=inspection.unreachable_node_ids,
-            cycle_detected=inspection.cycle_detected,
-            normalized_dsl_json=normalized_dsl_json)
-
-
-class WorkflowDebuggerPlanRequest(BaseModel):
-    dsl_json: dict[str, JSONValue]
-    max_preview_steps: int = Field(default=50, ge=1, le=500)
-
-
-class WorkflowConfigDebuggerPlanRequest(BaseModel):
-    workflow_config_id: str
-    max_preview_steps: int = Field(default=50, ge=1, le=500)
-
-
-class WorkflowDebuggerStepResponse(BaseModel):
-    step_index: int
-    node_id: str
-    node_type: str
-    name: str | None = None
-    next_node_ids: list[str]
-
-
-class WorkflowDebuggerPlanResponse(WorkflowDesignerValidateResponse):
-    execution_preview: list[WorkflowDebuggerStepResponse]
-    max_preview_steps: int
-    truncated: bool
-
-    @classmethod
-    def from_plan(cls, plan: WorkflowDebugPlan) -> "WorkflowDebuggerPlanResponse":
-        base = WorkflowDesignerValidateResponse.from_inspection(plan.inspection)
-        return cls(
-            **base.model_dump(),
-            execution_preview=[
-                WorkflowDebuggerStepResponse(
-                    step_index=item.step_index,
-                    node_id=item.node_id,
-                    node_type=item.node_type,
-                    name=item.name,
-                    next_node_ids=item.next_node_ids)
-                for item in plan.execution_preview
-            ],
-            max_preview_steps=plan.max_preview_steps,
-            truncated=plan.truncated)

+ 0 - 26
services/workflow-service/pyproject.toml

@@ -1,26 +0,0 @@
-[build-system]
-requires = ["setuptools>=68"]
-build-backend = "setuptools.build_meta"
-
-[project]
-name = "workflow-service"
-version = "0.1.0"
-description = "Workflow service for agent platform."
-requires-python = ">=3.11"
-dependencies = [
-  "alembic>=1.13,<2.0",
-  "fastapi>=0.111,<1.0",
-  "uvicorn[standard]>=0.30,<1.0",
-  "pydantic>=2.7,<3.0",
-  "sqlalchemy>=2.0,<3.0",
-  "core-db",
-  "core-domain",
-  "core-dsl",
-  "core-shared",
-]
-
-[tool.setuptools]
-package-dir = {"" = "."}
-
-[tool.setuptools.packages.find]
-where = ["."]

+ 0 - 6
tests/conftest.py

@@ -49,9 +49,6 @@ SERVICE_IMPORT_CONFIGS: dict[str, ServiceImportConfig] = {
     "model-gateway-service": ServiceImportConfig(
         service_name="model-gateway-service",
         libs=("core-domain", "core-shared", "core-db")),
-    "runtime-service": ServiceImportConfig(
-        service_name="runtime-service",
-        libs=("core-domain", "core-shared", "core-db", "core-events", "core-dsl")),
     "scheduler-service": ServiceImportConfig(
         service_name="scheduler-service",
         libs=("core-domain", "core-shared", "core-db")),
@@ -67,9 +64,6 @@ SERVICE_IMPORT_CONFIGS: dict[str, ServiceImportConfig] = {
     "team-service": ServiceImportConfig(
         service_name="team-service",
         libs=("core-domain", "core-shared", "core-db", "core-events")),
-    "workflow-service": ServiceImportConfig(
-        service_name="workflow-service",
-        libs=("core-domain", "core-shared", "core-db", "core-dsl")),
 }
 
 

+ 20 - 0
tests/test_model_service.py

@@ -50,6 +50,15 @@ def test_model_service_post_contract_supports_models_and_providers(
     assert provider_payload["apiKeyRef"] == "loc***masked"
     assert provider_payload["models"][0]["modelId"] == "llama3.1"
 
+    auto_synced_response = client.post(
+        "/models/list",
+        json={"page": 1, "pageSize": 20},
+    )
+    assert auto_synced_response.status_code == 200
+    auto_synced_payload = auto_synced_response.json()["data"]
+    assert auto_synced_payload["total"] == 1
+    assert auto_synced_payload["items"][0]["modelName"] == "llama3.1"
+
     providers_response = client.post(
         "/models/providers/list",
         json={"page": 1, "pageSize": 20},
@@ -64,6 +73,16 @@ def test_model_service_post_contract_supports_models_and_providers(
     assert discover_response.status_code == 200
     assert discover_response.json()["data"]["models"][0]["modelId"] == "llama3.1"
 
+    synced_models_response = client.post(
+        "/models/list",
+        json={"page": 1, "pageSize": 20},
+    )
+    assert synced_models_response.status_code == 200
+    synced_models_payload = synced_models_response.json()["data"]
+    assert synced_models_payload["total"] == 1
+    assert synced_models_payload["items"][0]["modelName"] == "llama3.1"
+    assert synced_models_payload["items"][0]["providerId"] == provider_payload["id"]
+
     model_response = client.post(
         "/models/create",
         json={
@@ -80,6 +99,7 @@ def test_model_service_post_contract_supports_models_and_providers(
     model_payload = model_response.json()["data"]
     assert model_payload["modelName"] == "llama3.1"
     assert model_payload["providerId"] == provider_payload["id"]
+    assert model_payload["id"] == synced_models_payload["items"][0]["id"]
     assert model_payload["providerBaseUrl"] == "http://127.0.0.1:11434/v1"
     assert model_payload["hasProviderApiKey"] is True
     assert "code" not in model_payload

+ 0 - 98
tests/test_post_contract_aliases.py

@@ -145,33 +145,6 @@ def test_session_service_post_contract_aliases(
     assert requests_response.json()[0]["session_id"] == session["id"]
 
 
-def test_runtime_service_post_contract_aliases(
-    tmp_path: Path,
-    monkeypatch,
-) -> None:
-    prepare_known_service_import("runtime-service")
-
-    from app.bootstrap.app import create_app
-    from app.db.models import Base
-    from core_db import create_session_factory
-
-    database_url = build_postgres_database_url(tmp_path, "runtime-contracts")
-    monkeypatch.setenv("AGENT_PLATFORM_DATABASE_URL", database_url)
-    monkeypatch.setenv("AGENT_PLATFORM_REDIS_URL", "")
-    engine = build_postgres_engine(database_url)
-    Base.metadata.create_all(engine)
-
-    app = create_app()
-    app.state.session_factory = create_session_factory(engine)
-    client = build_fastapi_test_client(app)
-
-    assert client.post("/runtime/runs/list", json={"limit": 20}).status_code == 200
-    assert client.post("/runtime/node-runs/list", json={"run_id": "missing"}).status_code == 200
-    assert client.post("/runtime/execution-logs/list", json={"run_id": "missing"}).status_code == 200
-    assert client.post("/runtime/node-artifacts/list", json={"run_id": "missing"}).status_code == 200
-    assert client.post("/runtime/trace-spans/list", json={"run_id": "missing"}).status_code == 200
-
-
 def test_gateway_api_key_post_contract_aliases(
     tmp_path: Path,
     monkeypatch,
@@ -322,74 +295,3 @@ def test_human_event_scheduler_post_contract_aliases(
     )
     assert status_response.status_code == 200
     assert status_response.json()["status"] == "cancelled"
-
-
-def test_workflow_service_post_contract_aliases(
-    tmp_path: Path,
-    monkeypatch,
-) -> None:
-    prepare_known_service_import("workflow-service")
-
-    from app.bootstrap.app import create_app
-    from app.db.models import Base
-    from core_db import create_session_factory
-
-    database_url = build_postgres_database_url(tmp_path, "workflow-contracts")
-    monkeypatch.setenv("AGENT_PLATFORM_DATABASE_URL", database_url)
-    engine = build_postgres_engine(database_url)
-    Base.metadata.create_all(engine)
-
-    app = create_app()
-    app.state.session_factory = create_session_factory(engine)
-    client = build_fastapi_test_client(app)
-
-    sample_response = client.post("/workflows/sample", json={})
-    assert sample_response.status_code == 200
-    sample = sample_response.json()
-
-    app_response = client.post(
-        "/workflows/apps",
-        json={"code": "contract_app", "name": "Contract App"},
-    )
-    assert app_response.status_code == 200
-    app_payload = app_response.json()
-
-    apps_response = client.post("/workflows/apps/list", json={})
-    assert apps_response.status_code == 200
-    assert apps_response.json()[0]["id"] == app_payload["id"]
-
-    workflow_response = client.post(
-        "/workflows",
-        json={"app_id": app_payload["id"], "code": "contract_workflow", "name": "Contract Workflow"},
-    )
-    assert workflow_response.status_code == 200
-    workflow = workflow_response.json()
-
-    workflows_response = client.post("/workflows/list", json={"app_id": app_payload["id"]})
-    assert workflows_response.status_code == 200
-    assert workflows_response.json()[0]["id"] == workflow["id"]
-
-    config_response = client.post(
-        "/workflows/configs",
-        json={"workflow_id": workflow["id"], "dsl_json": sample},
-    )
-    assert config_response.status_code == 200
-    config = config_response.json()
-
-    configs_response = client.post("/workflows/configs/list", json={"workflow_id": workflow["id"]})
-    assert configs_response.status_code == 200
-    assert configs_response.json()[0]["id"] == config["id"]
-
-    detail_response = client.post(
-        "/workflows/configs/detail",
-        json={"workflow_config_id": config["id"]},
-    )
-    assert detail_response.status_code == 200
-    assert detail_response.json()["id"] == config["id"]
-
-    debug_response = client.post(
-        "/workflows/configs/debug",
-        json={"workflow_config_id": config["id"], "max_preview_steps": 20},
-    )
-    assert debug_response.status_code == 200
-    assert debug_response.json()["valid"] is True

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно