synapse-orch-ai 1.6.6 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +28 -1
  2. package/backend/core/agent_logger.py +114 -26
  3. package/backend/core/config.py +40 -10
  4. package/backend/core/llm_providers.py +13 -5
  5. package/backend/core/models.py +20 -0
  6. package/backend/core/orchestration/engine.py +38 -4
  7. package/backend/core/orchestration/logger.py +104 -25
  8. package/backend/core/orchestration/steps.py +9 -11
  9. package/backend/core/react_engine.py +16 -6
  10. package/backend/core/routes/api_v2.py +742 -0
  11. package/backend/core/routes/data.py +58 -9
  12. package/backend/core/routes/orchestrations.py +72 -10
  13. package/backend/core/routes/scale.py +701 -0
  14. package/backend/core/routes/tools.py +39 -0
  15. package/backend/core/routes/vault.py +207 -30
  16. package/backend/core/s3_storage.py +149 -0
  17. package/backend/core/scale/__init__.py +0 -0
  18. package/backend/core/scale/config.py +104 -0
  19. package/backend/core/scale/context.py +92 -0
  20. package/backend/core/scale/db.py +56 -0
  21. package/backend/core/scale/event_bridge.py +229 -0
  22. package/backend/core/scale/heartbeat.py +121 -0
  23. package/backend/core/scale/llm_keys.py +73 -0
  24. package/backend/core/scale/metrics.py +113 -0
  25. package/backend/core/scale/models_db.py +230 -0
  26. package/backend/core/scale/pubsub.py +175 -0
  27. package/backend/core/scale/state_pg.py +157 -0
  28. package/backend/core/scale/sync.py +306 -0
  29. package/backend/core/scale/telemetry.py +101 -0
  30. package/backend/core/scale/webhook.py +60 -0
  31. package/backend/core/scale/worker.py +787 -0
  32. package/backend/core/scale/worker_engine_adapter.py +274 -0
  33. package/backend/core/scale/worker_server_module.py +232 -0
  34. package/backend/core/schedule_logger.py +117 -29
  35. package/backend/core/scheduler.py +2 -0
  36. package/backend/core/server.py +89 -12
  37. package/backend/core/tools_registry.py +43 -0
  38. package/backend/core/vault.py +74 -5
  39. package/backend/requirements.txt +6 -1
  40. package/backend/tools/code_search.py +10 -0
  41. package/backend/tools/file_reader.py +190 -0
  42. package/backend/tools/sandbox.py +123 -11
  43. package/backend/tools/web_scraper.py +33 -7
  44. package/frontend-build/.next/BUILD_ID +1 -1
  45. package/frontend-build/.next/build-manifest.json +3 -3
  46. package/frontend-build/.next/prerender-manifest.json +3 -3
  47. package/frontend-build/.next/server/app/_global-error.html +1 -1
  48. package/frontend-build/.next/server/app/_global-error.rsc +1 -1
  49. package/frontend-build/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  50. package/frontend-build/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  51. package/frontend-build/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  52. package/frontend-build/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  53. package/frontend-build/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  54. package/frontend-build/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  55. package/frontend-build/.next/server/app/_not-found.html +1 -1
  56. package/frontend-build/.next/server/app/_not-found.rsc +2 -2
  57. package/frontend-build/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  58. package/frontend-build/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  59. package/frontend-build/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  60. package/frontend-build/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  61. package/frontend-build/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  62. package/frontend-build/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  63. package/frontend-build/.next/server/app/index.html +1 -1
  64. package/frontend-build/.next/server/app/index.rsc +2 -2
  65. package/frontend-build/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  66. package/frontend-build/.next/server/app/index.segments/_full.segment.rsc +2 -2
  67. package/frontend-build/.next/server/app/index.segments/_head.segment.rsc +1 -1
  68. package/frontend-build/.next/server/app/index.segments/_index.segment.rsc +2 -2
  69. package/frontend-build/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  70. package/frontend-build/.next/server/app/login/page_client-reference-manifest.js +1 -1
  71. package/frontend-build/.next/server/app/login.html +1 -1
  72. package/frontend-build/.next/server/app/login.rsc +2 -2
  73. package/frontend-build/.next/server/app/login.segments/_full.segment.rsc +2 -2
  74. package/frontend-build/.next/server/app/login.segments/_head.segment.rsc +1 -1
  75. package/frontend-build/.next/server/app/login.segments/_index.segment.rsc +2 -2
  76. package/frontend-build/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  77. package/frontend-build/.next/server/app/login.segments/login/__PAGE__.segment.rsc +1 -1
  78. package/frontend-build/.next/server/app/login.segments/login.segment.rsc +1 -1
  79. package/frontend-build/.next/server/app/page_client-reference-manifest.js +1 -1
  80. package/frontend-build/.next/server/app/settings/[tab]/page_client-reference-manifest.js +1 -1
  81. package/frontend-build/.next/server/chunks/ssr/_0b~n.nn._.js +314 -10
  82. package/frontend-build/.next/server/chunks/ssr/_0rwng5.._.js +1 -1
  83. package/frontend-build/.next/server/middleware-build-manifest.js +3 -3
  84. package/frontend-build/.next/server/middleware-manifest.json +5 -5
  85. package/frontend-build/.next/server/pages/404.html +1 -1
  86. package/frontend-build/.next/server/pages/500.html +1 -1
  87. package/frontend-build/.next/server/server-reference-manifest.js +1 -1
  88. package/frontend-build/.next/server/server-reference-manifest.json +1 -1
  89. package/frontend-build/.next/static/chunks/01jwck3c_vyp2.css +1 -0
  90. package/frontend-build/.next/static/chunks/{02k3e9vyxajlw.js → 0560sc9qpz__k.js} +1 -1
  91. package/frontend-build/.next/static/chunks/{0_8po4-o2i03b.js → 06h113nhz_q9o.js} +328 -24
  92. package/package.json +1 -1
  93. package/frontend-build/.next/static/chunks/06ydx~dmu5o7z.css +0 -1
  94. /package/frontend-build/.next/static/{m4R7kM-i8HghwNcddY-ZF → -0bSwm8mQKkWfZUVOyM4q}/_buildManifest.js +0 -0
  95. /package/frontend-build/.next/static/{m4R7kM-i8HghwNcddY-ZF → -0bSwm8mQKkWfZUVOyM4q}/_clientMiddlewareManifest.js +0 -0
  96. /package/frontend-build/.next/static/{m4R7kM-i8HghwNcddY-ZF → -0bSwm8mQKkWfZUVOyM4q}/_ssgManifest.js +0 -0
package/README.md CHANGED
@@ -75,12 +75,38 @@ Then open `http://localhost:3000`. See the [Docker guide](https://docs.synapseor
75
75
 
76
76
  ---
77
77
 
78
+ ## Scale Mode
79
+
80
+ Run unlimited agents and orchestrations concurrently. When you need to go beyond a single process, the distributed scale layer handles the load:
81
+
82
+ - **Redis Cluster** — job queue, SSE event streams, pub/sub cancellation signals, auto-failover
83
+ - **ARQ worker fleet** — 1 to 100+ independent workers, each running up to 20 concurrent orchestrations; autoscale with KEDA on queue depth
84
+ - **PgBouncer** — multiplexes hundreds of worker connections into a small, stable Postgres pool
85
+ - **S3 artifact storage** — stream large file outputs directly to AWS S3, Cloudflare R2, or MinIO
86
+ - **Multi-tenant quotas** — per-team or per-customer concurrent run limits with HTTP 429 enforcement
87
+ - **Per-step checkpoint recovery** — worker crashes don't lose jobs; the next worker resumes from the last completed step
88
+
89
+ Three Docker images — pull only what you need:
90
+
91
+ ```bash
92
+ docker pull synapseorchai/synapse-ai:latest # full app (standalone mode)
93
+ docker pull synapseorchai/synapse-ai-api-server:latest # stateless API server (scale mode)
94
+ docker pull synapseorchai/synapse-ai-worker:latest # worker process (scale mode, run as many as needed)
95
+ ```
96
+
97
+ The `docker-compose.yml` in the repo spins up the full stack. Production K8s manifests are in `infra/k8s/`.
98
+
99
+ 📖 [**Scale Mode docs →**](https://docs.synapseorch.com/scale/overview)
100
+
101
+ ---
102
+
78
103
  ## What Makes Synapse Different
79
104
 
80
105
  - **Multi-Model Orchestrations** — Run a different LLM at every step. Use a fast model for routing, a powerful one for analysis. You control where the compute goes.
81
106
  - **Deterministic DAG Execution** — Orchestrations follow the exact path you designed. No hallucinated detours.
82
107
  - **Turn Anything Into a Tool** — Python scripts, REST APIs, webhooks, MCP servers, or entire orchestrations — all become agent-callable tools.
83
108
  - **Human-in-the-Loop** — Pause workflows for human review. Resumable across restarts. Connect via UI, Slack, Telegram, or any messaging channel.
109
+ - **Scales to Millions of Requests** — The distributed scale layer separates API servers, Redis job queues, and independent worker processes so you can run any number of agents or orchestrations concurrently. Start on one machine, grow to a Kubernetes cluster — the V2 API never changes.
84
110
  - **Local-First, No Lock-In** — Full local operation with Ollama. Mix local and cloud models freely. Your data stays yours.
85
111
  - **Built-In Scheduling & Messaging** — Cron-based automation with results pushed to Slack, Discord, Telegram, Teams, or WhatsApp.
86
112
  - **14+ LLM Providers** — Cloud, local, and CLI providers including Ollama, OpenAI, Anthropic, Gemini, xAI, DeepSeek, AWS Bedrock, and more.
@@ -122,7 +148,8 @@ https://github.com/user-attachments/assets/282cc99d-cdea-4ad0-b648-f22112c6e295
122
148
  | **AI Builder** | A meta-agent that designs and materializes orchestrations from natural language. [Docs →](https://docs.synapseorch.com/orchestrations/ai-builder) |
123
149
  | **Schedules** | Cron/interval automation with messaging notifications. [Docs →](https://docs.synapseorch.com/integrations/scheduling) |
124
150
  | **Messaging** | Slack, Discord, Telegram, Teams, WhatsApp — with multi-agent mode. [Docs →](https://docs.synapseorch.com/integrations/messaging) |
125
- | **REST API** | Trigger agents and orchestrations programmatically from any application. [Docs →](https://docs.synapseorch.com/api/overview) |
151
+ | **Scale Mode** | Distributed execution layer: Redis job queue, independent worker fleet, per-step Postgres checkpoints, S3 artifact storage, and multi-tenant quotas. [Docs →](https://docs.synapseorch.com/scale/overview) |
152
+ | **V2 API** | Stable versioned REST API for building products on top of Synapse — enqueue, stream, cancel, webhooks. [Docs →](https://docs.synapseorch.com/api/overview) |
126
153
  | **Vault** | Persistent file storage shared across agents and sessions. [Docs →](https://docs.synapseorch.com/vault) |
127
154
 
128
155
  ---
@@ -96,6 +96,37 @@ class AgentLogger:
96
96
  {'='*80}
97
97
  """)
98
98
 
99
+ def close(self) -> None:
100
+ """Drain the write queue, then upload the completed log to S3 (scale mode)."""
101
+ self._q.put(None) # poison pill — stops the drain thread
102
+ self._thread.join(timeout=10)
103
+ try:
104
+ from core.s3_storage import get_s3
105
+ s3 = get_s3()
106
+ if s3 and self.path.exists():
107
+ head = self.path.read_text(encoding="utf-8", errors="replace")[:1000]
108
+
109
+ def _extract(label: str) -> str:
110
+ for line in head.split("\n"):
111
+ if label in line:
112
+ return line.split(":", 1)[1].strip()
113
+ return ""
114
+
115
+ s3.upload_text(
116
+ f"logs/agent/{self.path.name}",
117
+ self.path.read_text(encoding="utf-8"),
118
+ metadata={
119
+ "agent_name": _extract("Agent Name :"),
120
+ "agent_id": _extract("Agent ID :"),
121
+ "source": _extract("Source :"),
122
+ "session_id": _extract("Session ID :"),
123
+ "started_at": _extract("Started at :"),
124
+ "user_input": _extract("User Input :")[:200],
125
+ },
126
+ )
127
+ except Exception:
128
+ pass # S3 upload failure must never surface to callers
129
+
99
130
  # ── Event logging ──────────────────────────────────────────────
100
131
 
101
132
  def log_event(self, event: dict):
@@ -212,44 +243,101 @@ class AgentLogger:
212
243
  @staticmethod
213
244
  def get_log(run_id: str) -> str | None:
214
245
  path = LOGS_DIR / f"{run_id}.log"
215
- if not path.exists():
216
- return None
217
- return path.read_text(encoding="utf-8")
246
+ if path.exists():
247
+ return path.read_text(encoding="utf-8")
248
+ # Fall back to S3 for logs written by other workers
249
+ try:
250
+ from core.s3_storage import get_s3
251
+ s3 = get_s3()
252
+ if s3:
253
+ return s3.download_text(f"logs/agent/{run_id}.log")
254
+ except Exception:
255
+ pass
256
+ return None
218
257
 
219
258
  @staticmethod
220
259
  def list_logs(limit: int = 100, offset: int = 0) -> list[dict]:
221
260
  _ensure_logs_dir()
222
- logs = []
261
+
262
+ def _parse_head(head: str, run_id: str, size_kb: float) -> dict:
263
+ def _extract(label: str) -> str:
264
+ for line in head.split("\n"):
265
+ if label in line:
266
+ return line.split(":", 1)[1].strip()
267
+ return ""
268
+ return {
269
+ "run_id": run_id,
270
+ "agent_name": _extract("Agent Name :"),
271
+ "agent_id": _extract("Agent ID :"),
272
+ "source": _extract("Source :"),
273
+ "session_id": _extract("Session ID :"),
274
+ "started_at": _extract("Started at :"),
275
+ "user_input": _extract("User Input :")[:200],
276
+ "file_size_kb": size_kb,
277
+ }
278
+
279
+ # Collect local entries
280
+ local_ids: set[str] = set()
281
+ logs: list[dict] = []
223
282
  files = sorted(LOGS_DIR.glob("*.log"), key=lambda p: p.stat().st_mtime, reverse=True)
224
- for f in files[offset : offset + limit]:
283
+ for f in files:
225
284
  run_id = f.stem
285
+ local_ids.add(run_id)
226
286
  try:
227
287
  head = f.read_text(encoding="utf-8", errors="replace")[:1000]
228
-
229
- def _extract(label: str) -> str:
230
- for line in head.split("\n"):
231
- if label in line:
232
- return line.split(":", 1)[1].strip()
233
- return ""
234
-
235
- logs.append({
236
- "run_id": run_id,
237
- "agent_name": _extract("Agent Name :"),
238
- "agent_id": _extract("Agent ID :"),
239
- "source": _extract("Source :"),
240
- "session_id": _extract("Session ID :"),
241
- "started_at": _extract("Started at :"),
242
- "user_input": _extract("User Input :")[:200],
243
- "file_size_kb": round(f.stat().st_size / 1024, 1),
244
- })
288
+ logs.append(_parse_head(head, run_id, round(f.stat().st_size / 1024, 1)))
245
289
  except Exception:
246
- logs.append({"run_id": run_id})
247
- return logs
290
+ logs.append({"run_id": run_id, "file_size_kb": 0})
291
+
292
+ # Merge S3 entries that aren't already local
293
+ try:
294
+ from core.s3_storage import get_s3
295
+ s3 = get_s3()
296
+ if s3:
297
+ from concurrent.futures import ThreadPoolExecutor
298
+ s3_keys = s3.list_keys("logs/agent/")
299
+ missing_keys = [k for k in s3_keys if k.endswith(".log") and Path(k).stem not in local_ids]
300
+
301
+ def _fetch_meta(rel_key: str) -> dict:
302
+ run_id = Path(rel_key).stem
303
+ try:
304
+ meta = s3.get_metadata(rel_key) or {}
305
+ return {
306
+ "run_id": run_id,
307
+ "agent_name": meta.get("agent_name", ""),
308
+ "agent_id": meta.get("agent_id", ""),
309
+ "source": meta.get("source", ""),
310
+ "session_id": meta.get("session_id", ""),
311
+ "started_at": meta.get("started_at", ""),
312
+ "user_input": meta.get("user_input", "")[:200],
313
+ "file_size_kb": 0,
314
+ }
315
+ except Exception:
316
+ return {"run_id": run_id}
317
+
318
+ with ThreadPoolExecutor(max_workers=10) as pool:
319
+ s3_entries = list(pool.map(_fetch_meta, missing_keys))
320
+ logs.extend(s3_entries)
321
+ except Exception:
322
+ pass
323
+
324
+ # Sort by started_at descending (local files already sorted; blend with S3)
325
+ logs.sort(key=lambda x: x.get("started_at", ""), reverse=True)
326
+ return logs[offset: offset + limit]
248
327
 
249
328
  @staticmethod
250
329
  def delete_log(run_id: str) -> bool:
251
330
  path = LOGS_DIR / f"{run_id}.log"
331
+ deleted = False
252
332
  if path.exists():
253
333
  path.unlink()
254
- return True
255
- return False
334
+ deleted = True
335
+ try:
336
+ from core.s3_storage import get_s3
337
+ s3 = get_s3()
338
+ if s3:
339
+ s3.delete(f"logs/agent/{run_id}.log")
340
+ deleted = True
341
+ except Exception:
342
+ pass
343
+ return deleted
@@ -39,6 +39,10 @@ def load_settings():
39
39
  "huggingface_token": "",
40
40
  "huggingface_models": "",
41
41
  "huggingface_max_new_tokens": 1024,
42
+ "anthropic_cli_models": "",
43
+ "gemini_cli_models": "",
44
+ "codex_cli_models": "",
45
+ "github_copilot_cli_models": "",
42
46
  "bedrock_api_key": "",
43
47
  "bedrock_inference_profile": "",
44
48
  "embedding_model": "",
@@ -77,19 +81,45 @@ def load_settings():
77
81
  "login_enabled": False,
78
82
  "login_username": "",
79
83
  "login_password_hash": "",
84
+ # Scale / distributed execution
85
+ "redis_url": "",
86
+ "scale_postgres_url": "",
87
+ "scale_mode_enabled": False,
88
+ "scale_auto_sync": False,
89
+ "worker_concurrency": 10,
90
+ "otlp_endpoint": "",
91
+ "metrics_token": "",
92
+ "max_global_queue_depth": 1_000_000,
93
+ "rate_limit_per_tenant_rps": 1000,
94
+ "pgbouncer_mode": False,
95
+ "num_queue_shards": 1,
80
96
  }
81
97
 
82
98
  if not os.path.exists(SETTINGS_FILE):
83
- return default_settings
84
-
85
- try:
86
- with open(SETTINGS_FILE, 'r') as f:
87
- data = json.load(f)
88
- # Merge defaults
89
- return {**default_settings, **data}
90
- except Exception as e:
91
- print(f"DEBUG: Error loading settings: {e}")
92
- return default_settings
99
+ file_settings = {}
100
+ else:
101
+ try:
102
+ with open(SETTINGS_FILE, 'r') as f:
103
+ file_settings = json.load(f)
104
+ except Exception as e:
105
+ print(f"DEBUG: Error loading settings: {e}")
106
+ file_settings = {}
107
+
108
+ settings = {**default_settings, **file_settings}
109
+
110
+ # In scale worker mode, inject_llm_env() populates SYNAPSE_SETTING_* env vars
111
+ # from Postgres. Overlay them here so all callers of load_settings() see the
112
+ # Postgres-sourced values without needing access to the local settings.json.
113
+ _prefix = "SYNAPSE_SETTING_"
114
+ for _env_key, _env_val in os.environ.items():
115
+ if _env_key.startswith(_prefix):
116
+ _setting_key = _env_key[len(_prefix):].lower()
117
+ try:
118
+ settings[_setting_key] = json.loads(_env_val)
119
+ except Exception:
120
+ settings[_setting_key] = _env_val
121
+
122
+ return settings
93
123
 
94
124
 
95
125
  def get_or_create_jwt_secret() -> str:
@@ -347,11 +347,16 @@ async def call_cli_provider(
347
347
  AUTH_PATTERNS = ["login", "authenticate", "auth", "expired", "sign in", "api key", "unauthorized"]
348
348
  RATE_LIMIT_PATTERNS = ["429", "rate limit", "ratelimit", "quota", "too many requests", "capacity", "resource_exhausted", "ratelimitexceeded"]
349
349
 
350
- parts = cli_model.lower().split(".")
350
+ # Split into base ("cli.<provider>") + variant, preserving dots in the
351
+ # variant (e.g. "cli.codex.gpt-5.4" → variant "gpt-5.4"). The base is matched
352
+ # case-insensitively; the variant is kept verbatim since model names can be
353
+ # case-sensitive and are passed straight through to the CLI's -m/--model flag.
354
+ parts = cli_model.split(".", 2)
351
355
  if len(parts) >= 2:
352
- base_cli = f"{parts[0]}.{parts[1]}"
356
+ base_cli = f"{parts[0]}.{parts[1]}".lower()
353
357
  else:
354
358
  base_cli = cli_model.lower()
359
+ variant = parts[2] if len(parts) > 2 else None
355
360
 
356
361
  base_cmd = _CLI_COMMANDS.get(base_cli)
357
362
  if not base_cmd:
@@ -365,8 +370,7 @@ async def call_cli_provider(
365
370
  cmd.extend(["--resume", cli_session_id])
366
371
 
367
372
  # ── Dynamic Model & Options Parsing ──
368
- if len(parts) > 2:
369
- variant = parts[2]
373
+ if variant:
370
374
  if base_cli == "cli.claude":
371
375
  # Extract base model by removing any thinking suffixes
372
376
  clean_variant = variant.replace('-thinking', '').replace('-max', '').replace('-high', '')
@@ -577,10 +581,14 @@ async def call_cli_provider(
577
581
  )
578
582
 
579
583
  if not clean:
584
+ # Report the TAIL of stderr, not the head. CLIs like codex echo a
585
+ # startup banner (version/workdir/model) to the head of stderr and
586
+ # write the actual failure (e.g. an invalid_request_error for an
587
+ # unsupported model) at the tail — so [:200] would hide the real cause.
580
588
  raise LLMError(
581
589
  f"CLI provider '{cli_model}' returned empty output. "
582
590
  f"Return code: {process.returncode}. "
583
- f"stderr: {stderr_text[:200]}"
591
+ f"stderr: {stderr_text[-600:]}"
584
592
  )
585
593
 
586
594
  print(f"DEBUG: ✅ CLI '{cli_model}' response ({len(clean)} chars)", flush=True)
@@ -90,6 +90,13 @@ class Settings(BaseModel):
90
90
  local_compatible_embed_models: str = "" # Comma-separated embedding model names for local v1-compatible
91
91
  huggingface_token: str = "" # Optional HF access token (required for gated models like Llama, Gemma)
92
92
  huggingface_models: str = "" # Comma- or newline-separated HF model IDs (e.g. Qwen/Qwen2.5-7B-Instruct)
93
+ # Comma-separated custom model names for the local CLI session providers.
94
+ # Each entry becomes a selectable cli.<provider>.<name> model (e.g. "gpt-5.4"
95
+ # → cli.codex.gpt-5.4), passed to the CLI via its -m/--model flag at call time.
96
+ anthropic_cli_models: str = ""
97
+ gemini_cli_models: str = ""
98
+ codex_cli_models: str = ""
99
+ github_copilot_cli_models: str = ""
93
100
  huggingface_max_new_tokens: int = 1024 # Max tokens to generate per HF call
94
101
  # Transform step Python execution runtime: "docker" (sandboxed, default) or
95
102
  # "host" (unsandboxed subprocess on host — needed for torch/GPU workloads,
@@ -124,6 +131,19 @@ class Settings(BaseModel):
124
131
  login_username: str = ""
125
132
  login_password_hash: str = ""
126
133
 
134
+ # Scale / distributed execution settings
135
+ redis_url: str = ""
136
+ scale_postgres_url: str = ""
137
+ scale_mode_enabled: bool = False
138
+ scale_auto_sync: bool = False
139
+ worker_concurrency: int = 10
140
+ otlp_endpoint: str = ""
141
+ metrics_token: str = ""
142
+ max_global_queue_depth: int = 1_000_000
143
+ rate_limit_per_tenant_rps: int = 1000
144
+ pgbouncer_mode: bool = False
145
+ num_queue_shards: int = 1
146
+
127
147
 
128
148
  class PersonalAddress(BaseModel):
129
149
  address1: str = ""
@@ -4,7 +4,7 @@ checkpointing, loop guards, and yielding SSE events.
4
4
  """
5
5
  import asyncio
6
6
  import time
7
- from typing import AsyncGenerator
7
+ from typing import AsyncGenerator, Awaitable, Callable
8
8
 
9
9
  import anyio
10
10
 
@@ -20,10 +20,20 @@ MAX_NESTED_DEPTH = 3
20
20
  class OrchestrationEngine:
21
21
  """Runs an orchestration by walking through its step graph."""
22
22
 
23
- def __init__(self, orchestration: Orchestration, server_module, depth: int = 0):
23
+ def __init__(
24
+ self,
25
+ orchestration: Orchestration,
26
+ server_module,
27
+ depth: int = 0,
28
+ cancel_hook: Callable[[], Awaitable[bool]] | None = None,
29
+ ):
24
30
  self.orch = orchestration
25
31
  self.server_module = server_module
26
32
  self.depth = depth # 0 = top-level run; >0 = nested sub-orchestration
33
+ # Optional async callable returning True when the run should be cancelled.
34
+ # Used by distributed workers to check Redis instead of the in-memory set.
35
+ # V1 never sets this — cancel_hook=None uses the existing in-memory check.
36
+ self.cancel_hook = cancel_hook
27
37
  self.step_map: dict[str, StepConfig] = {s.id: s for s in orchestration.steps}
28
38
  self.executors = STEP_EXECUTORS
29
39
  self.agent_names: dict[str, str] = self._load_agent_names()
@@ -98,10 +108,20 @@ class OrchestrationEngine:
98
108
  # before the next step starts sending new requests.
99
109
  await anyio.sleep(0)
100
110
 
101
- # Check if this run was cancelled via the cancel endpoint
111
+ # Check if this run was cancelled via the cancel endpoint.
112
+ # cancel_hook (set by distributed workers) checks Redis; the default
113
+ # path checks the in-memory _cancelled_run_ids set (V1 / standalone).
102
114
  from .state import _cancelled_run_ids
103
- if run.run_id in _cancelled_run_ids:
115
+ _is_cancelled = False
116
+ if self.cancel_hook:
117
+ try:
118
+ _is_cancelled = await self.cancel_hook()
119
+ except Exception:
120
+ pass
121
+ if not _is_cancelled and run.run_id in _cancelled_run_ids:
104
122
  _cancelled_run_ids.discard(run.run_id)
123
+ _is_cancelled = True
124
+ if _is_cancelled:
105
125
  run.status = "cancelled"
106
126
  print(f"DEBUG ENGINE: 🛑 run '{run.run_id}' cancelled by request", flush=True)
107
127
  break
@@ -195,6 +215,7 @@ class OrchestrationEngine:
195
215
  if logger:
196
216
  logger.step_end(step.id, "paused")
197
217
  logger.run_end("paused")
218
+ logger.close()
198
219
  # Break exits the async-for and then the
199
220
  # fail_after scope cleanly in this task.
200
221
  # The event is yielded below, outside the scope,
@@ -295,6 +316,7 @@ class OrchestrationEngine:
295
316
 
296
317
  if logger:
297
318
  logger.run_end(run.status)
319
+ logger.close()
298
320
 
299
321
  final_output = self._build_final_response(run)
300
322
 
@@ -376,19 +398,24 @@ class OrchestrationEngine:
376
398
  """Resume a paused orchestration after human input."""
377
399
  from .state import SharedState as SS
378
400
 
401
+ print(f"[engine.resume] ▶ restoring state from JSON checkpoint for run_id={run_id}", flush=True)
379
402
  restored = SS.restore(run_id)
380
403
  run = restored.run
404
+ print(f"[engine.resume] 📋 restored run: orch_id={run.orchestration_id} status={run.status} current_step_id={run.current_step_id} waiting_for_human={run.waiting_for_human} step_history_len={len(run.step_history)}", flush=True)
381
405
 
382
406
  # Load the orchestration definition
383
407
  from core.routes.orchestrations import load_orchestrations
384
408
  orchestrations = load_orchestrations()
409
+ print(f"[engine.resume] 📦 loaded {len(orchestrations)} orchestrations from disk, looking for id={run.orchestration_id}", flush=True)
385
410
  orch_data = next((o for o in orchestrations if o["id"] == run.orchestration_id), None)
386
411
  if not orch_data:
412
+ print(f"[engine.resume] ❌ orchestration '{run.orchestration_id}' NOT FOUND on disk — available ids: {[o.get('id') for o in orchestrations]}", flush=True)
387
413
  yield {"type": "orchestration_error", "error": f"Orchestration '{run.orchestration_id}' not found"}
388
414
  return
389
415
 
390
416
  orch = Orchestration.model_validate(orch_data)
391
417
  engine = cls(orch, server_module)
418
+ print(f"[engine.resume] 🗺 step_map keys: {list(engine.step_map.keys())}", flush=True)
392
419
 
393
420
  # Create logger — appends to existing log file if present
394
421
  engine.logger = OrchestrationLogger(
@@ -402,6 +429,7 @@ class OrchestrationEngine:
402
429
  # If the parent was paused because a NESTED orchestration hit a human step,
403
430
  # resume the sub-run and let it complete before continuing the parent.
404
431
  if run.nested_run_id:
432
+ print(f"[engine.resume] 🔗 nested run detected: nested_run_id={run.nested_run_id}, delegating to _resume_nested_orch", flush=True)
405
433
  async for event in cls._resume_nested_orch(run, engine, human_response, server_module):
406
434
  yield event
407
435
  return
@@ -409,6 +437,7 @@ class OrchestrationEngine:
409
437
  # Normal path: human step is directly in this orchestration.
410
438
  # Move to next step after the HUMAN step
411
439
  current_step = engine.step_map.get(run.current_step_id)
440
+ print(f"[engine.resume] 🔍 current_step lookup: current_step_id={run.current_step_id!r} → found={current_step is not None} type={current_step.type.value if current_step else 'N/A'}", flush=True)
412
441
 
413
442
  # Write human response to shared state under the step's configured output_key.
414
443
  # Falling back to "human_response" preserves backward-compat for steps with no output_key.
@@ -424,10 +453,15 @@ class OrchestrationEngine:
424
453
  if current_step:
425
454
  next_id, _ = engine._resolve_next(current_step, run)
426
455
  run.current_step_id = next_id
456
+ print(f"[engine.resume] ➡ _resolve_next({run.current_step_id!r} was HUMAN) → next_step_id={next_id!r}", flush=True)
457
+ else:
458
+ print(f"[engine.resume] ⚠️ current_step not found in step_map — current_step_id stays as {run.current_step_id!r}, loop will likely fail immediately", flush=True)
427
459
 
460
+ print(f"[engine.resume] 🚀 entering _execute_loop with current_step_id={run.current_step_id!r} status={run.status}", flush=True)
428
461
  state = SharedState(run)
429
462
  async for event in engine._execute_loop(run, state):
430
463
  yield event
464
+ print(f"[engine.resume] 🏁 _execute_loop finished, run.status={run.status} run.current_step_id={run.current_step_id!r}", flush=True)
431
465
 
432
466
  @classmethod
433
467
  async def _resume_nested_orch(
@@ -67,6 +67,34 @@ class OrchestrationLogger:
67
67
  {'='*80}
68
68
  """)
69
69
 
70
+ def close(self) -> None:
71
+ """Upload the completed log to S3 (scale mode). No-op in standalone mode."""
72
+ try:
73
+ from core.s3_storage import get_s3
74
+ s3 = get_s3()
75
+ if s3 and self.path.exists():
76
+ head = self.path.read_text(encoding="utf-8", errors="replace")[:1000]
77
+
78
+ def _extract(label: str) -> str:
79
+ for line in head.split("\n"):
80
+ if label in line:
81
+ return line.split(":", 1)[1].strip()
82
+ return ""
83
+
84
+ s3.upload_text(
85
+ f"logs/orchestration/{self.path.name}",
86
+ self.path.read_text(encoding="utf-8"),
87
+ metadata={
88
+ "orchestration_name": _extract("Orchestration :"),
89
+ "orchestration_id": _extract("Orchestration ID:"),
90
+ "session_id": _extract("Session ID :"),
91
+ "started_at": _extract("Started at :"),
92
+ "user_input": _extract("User Input :")[:200],
93
+ },
94
+ )
95
+ except Exception:
96
+ pass
97
+
70
98
  # ── Step lifecycle ─────────────────────────────────────────────
71
99
 
72
100
  def step_start(self, step_id: str, step_name: str, step_type: str,
@@ -295,43 +323,94 @@ class OrchestrationLogger:
295
323
  @staticmethod
296
324
  def get_log(run_id: str) -> str | None:
297
325
  path = LOGS_DIR / f"{run_id}.log"
298
- if not path.exists():
299
- return None
300
- return path.read_text(encoding="utf-8")
326
+ if path.exists():
327
+ return path.read_text(encoding="utf-8")
328
+ try:
329
+ from core.s3_storage import get_s3
330
+ s3 = get_s3()
331
+ if s3:
332
+ return s3.download_text(f"logs/orchestration/{run_id}.log")
333
+ except Exception:
334
+ pass
335
+ return None
301
336
 
302
337
  @staticmethod
303
338
  def list_logs(limit: int = 100, offset: int = 0) -> list[dict]:
304
339
  _ensure_logs_dir()
305
- logs = []
340
+
341
+ def _parse_head(head: str, run_id: str, size_kb: float) -> dict:
342
+ def _extract(label: str) -> str:
343
+ for line in head.split("\n"):
344
+ if label in line:
345
+ return line.split(":", 1)[1].strip()
346
+ return ""
347
+ return {
348
+ "run_id": run_id,
349
+ "orchestration_name": _extract("Orchestration :"),
350
+ "orchestration_id": _extract("Orchestration ID:"),
351
+ "session_id": _extract("Session ID :"),
352
+ "started_at": _extract("Started at :"),
353
+ "user_input": _extract("User Input :")[:200],
354
+ "file_size_kb": size_kb,
355
+ }
356
+
357
+ local_ids: set[str] = set()
358
+ logs: list[dict] = []
306
359
  files = sorted(LOGS_DIR.glob("*.log"), key=lambda p: p.stat().st_mtime, reverse=True)
307
- for f in files[offset : offset + limit]:
360
+ for f in files:
308
361
  run_id = f.stem
309
- # Parse header lines for summary
362
+ local_ids.add(run_id)
310
363
  try:
311
364
  head = f.read_text(encoding="utf-8", errors="replace")[:1000]
312
- def _extract(label: str) -> str:
313
- for line in head.split("\n"):
314
- if label in line:
315
- return line.split(":", 1)[1].strip()
316
- return ""
317
-
318
- logs.append({
319
- "run_id": run_id,
320
- "orchestration_name": _extract("Orchestration :"),
321
- "orchestration_id": _extract("Orchestration ID:"),
322
- "session_id": _extract("Session ID :"),
323
- "started_at": _extract("Started at :"),
324
- "user_input": _extract("User Input :")[:200],
325
- "file_size_kb": round(f.stat().st_size / 1024, 1),
326
- })
365
+ logs.append(_parse_head(head, run_id, round(f.stat().st_size / 1024, 1)))
327
366
  except Exception:
328
- logs.append({"run_id": run_id})
329
- return logs
367
+ logs.append({"run_id": run_id, "file_size_kb": 0})
368
+
369
+ try:
370
+ from core.s3_storage import get_s3
371
+ s3 = get_s3()
372
+ if s3:
373
+ from concurrent.futures import ThreadPoolExecutor
374
+ s3_keys = s3.list_keys("logs/orchestration/")
375
+ missing_keys = [k for k in s3_keys if k.endswith(".log") and Path(k).stem not in local_ids]
376
+
377
+ def _fetch_meta(rel_key: str) -> dict:
378
+ run_id = Path(rel_key).stem
379
+ try:
380
+ meta = s3.get_metadata(rel_key) or {}
381
+ return {
382
+ "run_id": run_id,
383
+ "orchestration_name": meta.get("orchestration_name", ""),
384
+ "orchestration_id": meta.get("orchestration_id", ""),
385
+ "session_id": meta.get("session_id", ""),
386
+ "started_at": meta.get("started_at", ""),
387
+ "user_input": meta.get("user_input", "")[:200],
388
+ "file_size_kb": 0,
389
+ }
390
+ except Exception:
391
+ return {"run_id": run_id}
392
+
393
+ with ThreadPoolExecutor(max_workers=10) as pool:
394
+ logs.extend(pool.map(_fetch_meta, missing_keys))
395
+ except Exception:
396
+ pass
397
+
398
+ logs.sort(key=lambda x: x.get("started_at", ""), reverse=True)
399
+ return logs[offset: offset + limit]
330
400
 
331
401
  @staticmethod
332
402
  def delete_log(run_id: str) -> bool:
333
403
  path = LOGS_DIR / f"{run_id}.log"
404
+ deleted = False
334
405
  if path.exists():
335
406
  path.unlink()
336
- return True
337
- return False
407
+ deleted = True
408
+ try:
409
+ from core.s3_storage import get_s3
410
+ s3 = get_s3()
411
+ if s3:
412
+ s3.delete(f"logs/orchestration/{run_id}.log")
413
+ deleted = True
414
+ except Exception:
415
+ pass
416
+ return deleted