AbstractRuntime 0.2.0__py3-none-any.whl → 0.4.1__py3-none-any.whl

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 (77) hide show
  1. abstractruntime/__init__.py +83 -3
  2. abstractruntime/core/config.py +82 -2
  3. abstractruntime/core/event_keys.py +62 -0
  4. abstractruntime/core/models.py +17 -1
  5. abstractruntime/core/policy.py +74 -3
  6. abstractruntime/core/runtime.py +3334 -28
  7. abstractruntime/core/vars.py +103 -2
  8. abstractruntime/evidence/__init__.py +10 -0
  9. abstractruntime/evidence/recorder.py +325 -0
  10. abstractruntime/history_bundle.py +772 -0
  11. abstractruntime/integrations/abstractcore/__init__.py +6 -0
  12. abstractruntime/integrations/abstractcore/constants.py +19 -0
  13. abstractruntime/integrations/abstractcore/default_tools.py +258 -0
  14. abstractruntime/integrations/abstractcore/effect_handlers.py +2622 -32
  15. abstractruntime/integrations/abstractcore/embeddings_client.py +69 -0
  16. abstractruntime/integrations/abstractcore/factory.py +149 -16
  17. abstractruntime/integrations/abstractcore/llm_client.py +891 -55
  18. abstractruntime/integrations/abstractcore/mcp_worker.py +587 -0
  19. abstractruntime/integrations/abstractcore/observability.py +80 -0
  20. abstractruntime/integrations/abstractcore/session_attachments.py +946 -0
  21. abstractruntime/integrations/abstractcore/summarizer.py +154 -0
  22. abstractruntime/integrations/abstractcore/tool_executor.py +509 -31
  23. abstractruntime/integrations/abstractcore/workspace_scoped_tools.py +561 -0
  24. abstractruntime/integrations/abstractmemory/__init__.py +3 -0
  25. abstractruntime/integrations/abstractmemory/effect_handlers.py +946 -0
  26. abstractruntime/memory/__init__.py +21 -0
  27. abstractruntime/memory/active_context.py +751 -0
  28. abstractruntime/memory/active_memory.py +452 -0
  29. abstractruntime/memory/compaction.py +105 -0
  30. abstractruntime/memory/kg_packets.py +164 -0
  31. abstractruntime/memory/memact_composer.py +175 -0
  32. abstractruntime/memory/recall_levels.py +163 -0
  33. abstractruntime/memory/token_budget.py +86 -0
  34. abstractruntime/rendering/__init__.py +17 -0
  35. abstractruntime/rendering/agent_trace_report.py +256 -0
  36. abstractruntime/rendering/json_stringify.py +136 -0
  37. abstractruntime/scheduler/scheduler.py +93 -2
  38. abstractruntime/storage/__init__.py +7 -2
  39. abstractruntime/storage/artifacts.py +175 -32
  40. abstractruntime/storage/base.py +17 -1
  41. abstractruntime/storage/commands.py +339 -0
  42. abstractruntime/storage/in_memory.py +41 -1
  43. abstractruntime/storage/json_files.py +210 -14
  44. abstractruntime/storage/observable.py +136 -0
  45. abstractruntime/storage/offloading.py +433 -0
  46. abstractruntime/storage/sqlite.py +836 -0
  47. abstractruntime/visualflow_compiler/__init__.py +29 -0
  48. abstractruntime/visualflow_compiler/adapters/__init__.py +11 -0
  49. abstractruntime/visualflow_compiler/adapters/agent_adapter.py +126 -0
  50. abstractruntime/visualflow_compiler/adapters/context_adapter.py +109 -0
  51. abstractruntime/visualflow_compiler/adapters/control_adapter.py +615 -0
  52. abstractruntime/visualflow_compiler/adapters/effect_adapter.py +1051 -0
  53. abstractruntime/visualflow_compiler/adapters/event_adapter.py +307 -0
  54. abstractruntime/visualflow_compiler/adapters/function_adapter.py +97 -0
  55. abstractruntime/visualflow_compiler/adapters/memact_adapter.py +114 -0
  56. abstractruntime/visualflow_compiler/adapters/subflow_adapter.py +74 -0
  57. abstractruntime/visualflow_compiler/adapters/variable_adapter.py +316 -0
  58. abstractruntime/visualflow_compiler/compiler.py +3832 -0
  59. abstractruntime/visualflow_compiler/flow.py +247 -0
  60. abstractruntime/visualflow_compiler/visual/__init__.py +13 -0
  61. abstractruntime/visualflow_compiler/visual/agent_ids.py +29 -0
  62. abstractruntime/visualflow_compiler/visual/builtins.py +1376 -0
  63. abstractruntime/visualflow_compiler/visual/code_executor.py +214 -0
  64. abstractruntime/visualflow_compiler/visual/executor.py +2804 -0
  65. abstractruntime/visualflow_compiler/visual/models.py +211 -0
  66. abstractruntime/workflow_bundle/__init__.py +52 -0
  67. abstractruntime/workflow_bundle/models.py +236 -0
  68. abstractruntime/workflow_bundle/packer.py +317 -0
  69. abstractruntime/workflow_bundle/reader.py +87 -0
  70. abstractruntime/workflow_bundle/registry.py +587 -0
  71. abstractruntime-0.4.1.dist-info/METADATA +177 -0
  72. abstractruntime-0.4.1.dist-info/RECORD +86 -0
  73. abstractruntime-0.4.1.dist-info/entry_points.txt +2 -0
  74. abstractruntime-0.2.0.dist-info/METADATA +0 -163
  75. abstractruntime-0.2.0.dist-info/RECORD +0 -32
  76. {abstractruntime-0.2.0.dist-info → abstractruntime-0.4.1.dist-info}/WHEEL +0 -0
  77. {abstractruntime-0.2.0.dist-info → abstractruntime-0.4.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,587 @@
1
+ """WorkflowBundleRegistry (disk) for installed `.flow` bundles.
2
+
3
+ This registry is a host-side convenience layer:
4
+ - stores `.flow` bundles in a directory
5
+ - provides interface discovery from bundle manifests
6
+ - resolves bundle refs like `bundle_id@version` (or latest version)
7
+
8
+ Design goals:
9
+ - stdlib-only (to keep AbstractRuntime minimal)
10
+ - no global state; callers choose the directory
11
+ - correctness-first (scan bundles; optional caching can be added later)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import os
18
+ import re
19
+ import shutil
20
+ import tempfile
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
24
+
25
+ from .models import WorkflowBundleEntrypoint, WorkflowBundleManifest
26
+ from .reader import open_workflow_bundle
27
+
28
+
29
+ class WorkflowBundleRegistryError(ValueError):
30
+ """Raised when registry operations fail (install/remove/resolve)."""
31
+
32
+
33
+ _BUNDLE_ID_SAFE_RE = re.compile(r"[^a-zA-Z0-9_-]+")
34
+ _BUNDLE_VERSION_SAFE_RE = re.compile(r"[^a-zA-Z0-9_.-]+")
35
+
36
+
37
+ def sanitize_bundle_id(raw: str) -> str:
38
+ s = str(raw or "").strip()
39
+ if not s:
40
+ return ""
41
+ s = _BUNDLE_ID_SAFE_RE.sub("-", s)
42
+ s = re.sub(r"-{2,}", "-", s).strip("-")
43
+ return s
44
+
45
+
46
+ def sanitize_bundle_version(raw: str) -> str:
47
+ s = str(raw or "").strip()
48
+ if not s:
49
+ return ""
50
+ s = _BUNDLE_VERSION_SAFE_RE.sub("-", s)
51
+ s = re.sub(r"-{2,}", "-", s).strip("-")
52
+ return s
53
+
54
+
55
+ def _try_parse_semver(v: str) -> Optional[Tuple[int, int, int]]:
56
+ s = str(v or "").strip()
57
+ if not s:
58
+ return None
59
+ parts = [p.strip() for p in s.split(".")]
60
+ if not parts or any(not p for p in parts):
61
+ return None
62
+ nums: list[int] = []
63
+ for p in parts:
64
+ if not p.isdigit():
65
+ return None
66
+ nums.append(int(p))
67
+ while len(nums) < 3:
68
+ nums.append(0)
69
+ return (nums[0], nums[1], nums[2])
70
+
71
+
72
+ def default_workflow_bundles_dir() -> Path:
73
+ """Resolve the default bundles directory for `.flow` bundles.
74
+
75
+ Priority:
76
+ 1) `ABSTRACTFRAMEWORK_WORKFLOWS_DIR` (shared/cross-package)
77
+ 2) `ABSTRACTGATEWAY_FLOWS_DIR` (gateway bundle host)
78
+ 3) `ABSTRACTFLOW_PUBLISH_DIR` (authoring host publish)
79
+ 4) `ABSTRACTFLOW_FLOWS_DIR` (legacy)
80
+ 5) repo/dev fallback: `./flows/bundles/` if it exists
81
+ 6) user default: `~/.abstractframework/workflows/`
82
+ """
83
+ env_candidates = (
84
+ "ABSTRACTFRAMEWORK_WORKFLOWS_DIR",
85
+ "ABSTRACTGATEWAY_FLOWS_DIR",
86
+ "ABSTRACTFLOW_PUBLISH_DIR",
87
+ "ABSTRACTFLOW_FLOWS_DIR",
88
+ )
89
+ for name in env_candidates:
90
+ v = os.getenv(name)
91
+ if isinstance(v, str) and v.strip():
92
+ return Path(v.strip()).expanduser().resolve()
93
+
94
+ dev = Path("flows") / "bundles"
95
+ if dev.exists() and dev.is_dir():
96
+ return dev.resolve()
97
+
98
+ return (Path.home() / ".abstractframework" / "workflows").resolve()
99
+
100
+
101
+ def _sha256_file(path: Path) -> str:
102
+ h = hashlib.sha256()
103
+ with path.open("rb") as f:
104
+ for chunk in iter(lambda: f.read(1024 * 1024), b""):
105
+ h.update(chunk)
106
+ return h.hexdigest()
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class InstalledWorkflowBundle:
111
+ bundle_id: str
112
+ bundle_version: str
113
+ path: Path
114
+ manifest: WorkflowBundleManifest
115
+ sha256: Optional[str] = None
116
+
117
+ @property
118
+ def bundle_ref(self) -> str:
119
+ return f"{self.bundle_id}@{self.bundle_version}"
120
+
121
+
122
+ @dataclass(frozen=True)
123
+ class WorkflowEntrypointRef:
124
+ bundle_id: str
125
+ bundle_version: str
126
+ flow_id: str
127
+ name: str = ""
128
+ description: str = ""
129
+ interfaces: List[str] = field(default_factory=list)
130
+ is_default: bool = False
131
+
132
+ @property
133
+ def bundle_ref(self) -> str:
134
+ return f"{self.bundle_id}@{self.bundle_version}"
135
+
136
+ @property
137
+ def workflow_id(self) -> str:
138
+ return f"{self.bundle_ref}:{self.flow_id}"
139
+
140
+
141
+ def _split_bundle_ref(raw: str) -> tuple[str, Optional[str]]:
142
+ s = str(raw or "").strip()
143
+ if not s:
144
+ return ("", None)
145
+ # Allow passing a bundle ref with a `.flow` suffix (e.g. "basic-agent.flow")
146
+ # when referring to an installed bundle id (not a filesystem path).
147
+ if s.lower().endswith(".flow") and "/" not in s and "\\" not in s:
148
+ s = s[:-5]
149
+ if "@" not in s:
150
+ return (s, None)
151
+ a, b = s.split("@", 1)
152
+ a = a.strip()
153
+ b = b.strip()
154
+ if not a:
155
+ return ("", None)
156
+ if not b:
157
+ return (a, None)
158
+ return (a, b)
159
+
160
+
161
+ def _pick_latest_version(bundles_by_version: Dict[str, InstalledWorkflowBundle]) -> Optional[str]:
162
+ items = [(str(ver), b) for ver, b in (bundles_by_version or {}).items() if isinstance(ver, str)]
163
+ if not items:
164
+ return None
165
+
166
+ if all(_try_parse_semver(ver) is not None for ver, _ in items):
167
+ return max(items, key=lambda x: _try_parse_semver(x[0]) or (0, 0, 0))[0]
168
+
169
+ # Fallback: prefer newest created_at.
170
+ def _key(x: tuple[str, InstalledWorkflowBundle]) -> tuple[str, str]:
171
+ ver, b = x
172
+ created = str(getattr(getattr(b, "manifest", None), "created_at", "") or "")
173
+ return (created, ver)
174
+
175
+ return max(items, key=_key)[0]
176
+
177
+
178
+ class WorkflowBundleRegistry:
179
+ """A directory-backed registry for `.flow` bundles."""
180
+
181
+ def __init__(self, bundles_dir: str | Path | None = None) -> None:
182
+ base = Path(bundles_dir).expanduser() if bundles_dir is not None else default_workflow_bundles_dir()
183
+ self.bundles_dir = base.expanduser().resolve()
184
+
185
+ def ensure_dir(self) -> Path:
186
+ self.bundles_dir.mkdir(parents=True, exist_ok=True)
187
+ return self.bundles_dir
188
+
189
+ def scan(self) -> List[InstalledWorkflowBundle]:
190
+ """Scan the bundles directory for `.flow` bundles (best-effort)."""
191
+ if not self.bundles_dir.exists() or not self.bundles_dir.is_dir():
192
+ return []
193
+
194
+ out: list[InstalledWorkflowBundle] = []
195
+ for path in sorted(self.bundles_dir.glob("*.flow")):
196
+ if not path.is_file():
197
+ continue
198
+ try:
199
+ bundle = open_workflow_bundle(path)
200
+ except Exception:
201
+ continue
202
+ man = bundle.manifest
203
+ bid = str(getattr(man, "bundle_id", "") or "").strip()
204
+ bver = str(getattr(man, "bundle_version", "") or "0.0.0").strip() or "0.0.0"
205
+ if not bid:
206
+ continue
207
+ out.append(InstalledWorkflowBundle(bundle_id=bid, bundle_version=bver, path=path.resolve(), manifest=man))
208
+ return out
209
+
210
+ def bundles_by_id(self) -> Dict[str, Dict[str, InstalledWorkflowBundle]]:
211
+ out: Dict[str, Dict[str, InstalledWorkflowBundle]] = {}
212
+ for b in self.scan():
213
+ out.setdefault(b.bundle_id, {})[b.bundle_version] = b
214
+ return out
215
+
216
+ def resolve_bundle(self, bundle_ref: str) -> InstalledWorkflowBundle:
217
+ """Resolve `bundle_id[@version]` to an installed bundle (prefers latest)."""
218
+ bid, ver = _split_bundle_ref(bundle_ref)
219
+ if not bid:
220
+ raise WorkflowBundleRegistryError("bundle_ref must be 'bundle_id' or 'bundle_id@version'")
221
+
222
+ bundles = self.bundles_by_id().get(bid) or {}
223
+ if not bundles:
224
+ raise WorkflowBundleRegistryError(f"Bundle '{bid}' is not installed in {self.bundles_dir}")
225
+
226
+ if ver:
227
+ b = bundles.get(ver)
228
+ if b is None:
229
+ available = ", ".join(sorted(bundles.keys()))
230
+ raise WorkflowBundleRegistryError(f"Bundle '{bid}@{ver}' not found (available: {available})")
231
+ return b
232
+
233
+ latest = _pick_latest_version(bundles)
234
+ if not latest:
235
+ raise WorkflowBundleRegistryError(f"Bundle '{bid}' has no versions installed")
236
+ return bundles[latest]
237
+
238
+ def resolve_entrypoint(
239
+ self,
240
+ ref: str,
241
+ *,
242
+ interface: Optional[str] = None,
243
+ ) -> WorkflowEntrypointRef:
244
+ """Resolve a workflow reference to a bundle entrypoint.
245
+
246
+ Supported refs:
247
+ - `bundle_id` (uses manifest.default_entrypoint if set, else first entrypoint)
248
+ - `bundle_id@version`
249
+ - `bundle_id[:flow_id]` or `bundle_id@version:flow_id`
250
+ - `entrypoint_name` (unique match across bundles, best-effort)
251
+ """
252
+ s = str(ref or "").strip()
253
+ if not s:
254
+ raise WorkflowBundleRegistryError("workflow reference is required")
255
+
256
+ bundle_flow_id: Optional[str] = None
257
+ # Support bundle_id:flow_id (avoid clobbering Windows drive letters).
258
+ if ":" in s and not re.match(r"^[A-Za-z]:[\\\\/]", s):
259
+ left, right = s.split(":", 1)
260
+ if left.strip() and right.strip():
261
+ s = left.strip()
262
+ bundle_flow_id = right.strip()
263
+
264
+ # If the caller passed a `.flow` filename (not found relative to CWD),
265
+ # try resolving it within the registry directory.
266
+ if s.lower().endswith(".flow") and "/" not in s and "\\" not in s:
267
+ candidate = (self.bundles_dir / s).expanduser()
268
+ if candidate.exists() and candidate.is_file():
269
+ try:
270
+ b = open_workflow_bundle(candidate)
271
+ man = b.manifest
272
+ return self._entrypoint_from_manifest(
273
+ manifest=man,
274
+ bundle_id=str(getattr(man, "bundle_id", "") or "").strip(),
275
+ bundle_version=str(getattr(man, "bundle_version", "") or "0.0.0").strip() or "0.0.0",
276
+ flow_id=bundle_flow_id,
277
+ interface=interface,
278
+ )
279
+ except Exception:
280
+ pass
281
+
282
+ # Fast path: resolve by bundle ref.
283
+ try:
284
+ bundle = self.resolve_bundle(s)
285
+ man = bundle.manifest
286
+ return self._entrypoint_from_manifest(
287
+ manifest=man,
288
+ bundle_id=bundle.bundle_id,
289
+ bundle_version=bundle.bundle_version,
290
+ flow_id=bundle_flow_id,
291
+ interface=interface,
292
+ )
293
+ except WorkflowBundleRegistryError:
294
+ pass
295
+
296
+ # Best-effort: resolve by entrypoint name.
297
+ needle = s.casefold()
298
+ matches: list[WorkflowEntrypointRef] = []
299
+ for b in self.scan():
300
+ man = b.manifest
301
+ for ep in list(getattr(man, "entrypoints", None) or []):
302
+ ep_name = str(getattr(ep, "name", "") or "").strip()
303
+ if not ep_name or ep_name.casefold() != needle:
304
+ continue
305
+ epr = self._entrypoint_from_entrypoint(
306
+ ep=ep,
307
+ bundle_id=b.bundle_id,
308
+ bundle_version=b.bundle_version,
309
+ is_default=str(getattr(man, "default_entrypoint", "") or "").strip() == str(getattr(ep, "flow_id", "") or "").strip(),
310
+ )
311
+ if interface and interface not in epr.interfaces:
312
+ continue
313
+ matches.append(epr)
314
+
315
+ if not matches:
316
+ raise WorkflowBundleRegistryError(f"Workflow '{ref}' not found in {self.bundles_dir}")
317
+
318
+ if len(matches) > 1:
319
+ options = ", ".join(sorted({m.bundle_ref for m in matches}))
320
+ raise WorkflowBundleRegistryError(
321
+ f"Workflow name '{ref}' matches multiple bundles ({options}); use bundle_id[@version] instead"
322
+ )
323
+ return matches[0]
324
+
325
+ def list_entrypoints(
326
+ self,
327
+ *,
328
+ interface: Optional[str] = None,
329
+ latest_only: bool = True,
330
+ ) -> List[WorkflowEntrypointRef]:
331
+ """List available entrypoints (optionally filtered by interface)."""
332
+ bundles = self.bundles_by_id()
333
+ selected: Iterable[InstalledWorkflowBundle]
334
+ if latest_only:
335
+ latest: list[InstalledWorkflowBundle] = []
336
+ for bid, versions in bundles.items():
337
+ latest_ver = _pick_latest_version(versions)
338
+ if latest_ver:
339
+ latest.append(versions[latest_ver])
340
+ selected = latest
341
+ else:
342
+ selected = [b for versions in bundles.values() for b in versions.values()]
343
+
344
+ out: list[WorkflowEntrypointRef] = []
345
+ for b in selected:
346
+ man = b.manifest
347
+ default_fid = str(getattr(man, "default_entrypoint", "") or "").strip()
348
+ for ep in list(getattr(man, "entrypoints", None) or []):
349
+ epr = self._entrypoint_from_entrypoint(
350
+ ep=ep,
351
+ bundle_id=b.bundle_id,
352
+ bundle_version=b.bundle_version,
353
+ is_default=bool(default_fid and str(getattr(ep, "flow_id", "") or "").strip() == default_fid),
354
+ )
355
+ if interface and interface not in epr.interfaces:
356
+ continue
357
+ out.append(epr)
358
+
359
+ out.sort(key=lambda e: (e.bundle_id, e.bundle_version, e.name or e.flow_id))
360
+ return out
361
+
362
+ def install(self, source: str | Path, *, overwrite: bool = False) -> InstalledWorkflowBundle:
363
+ """Install a `.flow` bundle into the registry directory."""
364
+ src = Path(source).expanduser().resolve()
365
+ if not src.exists() or not src.is_file():
366
+ raise WorkflowBundleRegistryError(f"Bundle not found: {src}")
367
+
368
+ bundle = open_workflow_bundle(src)
369
+ man = bundle.manifest
370
+ bid = str(getattr(man, "bundle_id", "") or "").strip()
371
+ bver = str(getattr(man, "bundle_version", "") or "0.0.0").strip() or "0.0.0"
372
+ if not bid:
373
+ raise WorkflowBundleRegistryError(f"Bundle '{src}' has empty manifest.bundle_id")
374
+
375
+ safe_id = sanitize_bundle_id(bid)
376
+ safe_ver = sanitize_bundle_version(bver)
377
+ if safe_id != bid:
378
+ raise WorkflowBundleRegistryError(
379
+ f"Bundle id '{bid}' contains unsafe characters. "
380
+ f"Publish with a safe bundle_id (suggested: '{safe_id}')."
381
+ )
382
+ if safe_ver != bver:
383
+ raise WorkflowBundleRegistryError(
384
+ f"Bundle version '{bver}' contains unsafe characters. "
385
+ f"Publish with a safe bundle_version (suggested: '{safe_ver}')."
386
+ )
387
+
388
+ self.ensure_dir()
389
+ dest = (self.bundles_dir / f"{bid}@{bver}.flow").resolve()
390
+ if dest.exists():
391
+ if not overwrite:
392
+ raise WorkflowBundleRegistryError(f"Bundle already installed: {dest}")
393
+ try:
394
+ dest.unlink()
395
+ except Exception as e:
396
+ raise WorkflowBundleRegistryError(f"Failed removing existing bundle: {dest} ({e})") from e
397
+
398
+ try:
399
+ shutil.copy2(src, dest)
400
+ except Exception as e:
401
+ raise WorkflowBundleRegistryError(f"Failed installing bundle to {dest}: {e}") from e
402
+
403
+ try:
404
+ bundle2 = open_workflow_bundle(dest)
405
+ sha = _sha256_file(dest)
406
+ return InstalledWorkflowBundle(
407
+ bundle_id=str(bundle2.manifest.bundle_id),
408
+ bundle_version=str(bundle2.manifest.bundle_version),
409
+ path=dest,
410
+ manifest=bundle2.manifest,
411
+ sha256=sha,
412
+ )
413
+ except Exception:
414
+ # Best-effort: return what we know even if re-open/hash fails.
415
+ return InstalledWorkflowBundle(bundle_id=bid, bundle_version=bver, path=dest, manifest=man)
416
+
417
+ def install_bytes(
418
+ self,
419
+ content: bytes,
420
+ *,
421
+ filename_hint: str = "upload.flow",
422
+ overwrite: bool = False,
423
+ ) -> InstalledWorkflowBundle:
424
+ """Install bundle bytes into the registry directory.
425
+
426
+ This is primarily intended for hosts that receive `.flow` bytes over the network.
427
+ """
428
+ data = bytes(content or b"")
429
+ if not data:
430
+ raise WorkflowBundleRegistryError("Bundle content is empty")
431
+
432
+ self.ensure_dir()
433
+ suffix = ".flow" if str(filename_hint or "").lower().endswith(".flow") else ".flow"
434
+ try:
435
+ fd, tmp_path_str = tempfile.mkstemp(prefix=".upload_", suffix=suffix, dir=str(self.bundles_dir))
436
+ tmp_path = Path(tmp_path_str)
437
+ with os.fdopen(fd, "wb") as f:
438
+ f.write(data)
439
+ except Exception as e:
440
+ raise WorkflowBundleRegistryError(f"Failed writing upload temp file: {e}") from e
441
+
442
+ try:
443
+ bundle = open_workflow_bundle(tmp_path)
444
+ man = bundle.manifest
445
+ bid = str(getattr(man, "bundle_id", "") or "").strip()
446
+ bver = str(getattr(man, "bundle_version", "") or "0.0.0").strip() or "0.0.0"
447
+ if not bid:
448
+ raise WorkflowBundleRegistryError("Uploaded bundle has empty manifest.bundle_id")
449
+
450
+ safe_id = sanitize_bundle_id(bid)
451
+ safe_ver = sanitize_bundle_version(bver)
452
+ if safe_id != bid:
453
+ raise WorkflowBundleRegistryError(
454
+ f"Bundle id '{bid}' contains unsafe characters. "
455
+ f"Publish with a safe bundle_id (suggested: '{safe_id}')."
456
+ )
457
+ if safe_ver != bver:
458
+ raise WorkflowBundleRegistryError(
459
+ f"Bundle version '{bver}' contains unsafe characters. "
460
+ f"Publish with a safe bundle_version (suggested: '{safe_ver}')."
461
+ )
462
+
463
+ dest = (self.bundles_dir / f"{bid}@{bver}.flow").resolve()
464
+ if dest.exists() and not overwrite:
465
+ raise WorkflowBundleRegistryError(f"Bundle already installed: {dest.name}")
466
+
467
+ try:
468
+ tmp_path.replace(dest)
469
+ except Exception as e:
470
+ raise WorkflowBundleRegistryError(f"Failed installing bundle to {dest}: {e}") from e
471
+
472
+ try:
473
+ bundle2 = open_workflow_bundle(dest)
474
+ sha = _sha256_file(dest)
475
+ return InstalledWorkflowBundle(
476
+ bundle_id=str(bundle2.manifest.bundle_id),
477
+ bundle_version=str(bundle2.manifest.bundle_version),
478
+ path=dest,
479
+ manifest=bundle2.manifest,
480
+ sha256=sha,
481
+ )
482
+ except Exception:
483
+ return InstalledWorkflowBundle(bundle_id=bid, bundle_version=bver, path=dest, manifest=man)
484
+ finally:
485
+ try:
486
+ if tmp_path.exists():
487
+ tmp_path.unlink()
488
+ except Exception:
489
+ pass
490
+
491
+ def remove(self, bundle_ref: str) -> int:
492
+ """Remove installed bundle(s).
493
+
494
+ Args:
495
+ bundle_ref: `bundle_id` removes all versions, `bundle_id@version` removes that version.
496
+
497
+ Returns:
498
+ Number of removed files.
499
+ """
500
+ bid, ver = _split_bundle_ref(bundle_ref)
501
+ if not bid:
502
+ raise WorkflowBundleRegistryError("bundle_ref must be 'bundle_id' or 'bundle_id@version'")
503
+
504
+ bundles = self.bundles_by_id().get(bid) or {}
505
+ if not bundles:
506
+ return 0
507
+
508
+ removed = 0
509
+ if ver:
510
+ target = bundles.get(ver)
511
+ if target is None:
512
+ return 0
513
+ try:
514
+ target.path.unlink()
515
+ except Exception as e:
516
+ raise WorkflowBundleRegistryError(f"Failed removing {target.path}: {e}") from e
517
+ return 1
518
+
519
+ for b in bundles.values():
520
+ try:
521
+ b.path.unlink()
522
+ removed += 1
523
+ except Exception as e:
524
+ raise WorkflowBundleRegistryError(f"Failed removing {b.path}: {e}") from e
525
+ return removed
526
+
527
+ @staticmethod
528
+ def _entrypoint_from_entrypoint(
529
+ *,
530
+ ep: WorkflowBundleEntrypoint,
531
+ bundle_id: str,
532
+ bundle_version: str,
533
+ is_default: bool,
534
+ ) -> WorkflowEntrypointRef:
535
+ fid = str(getattr(ep, "flow_id", "") or "").strip()
536
+ name = str(getattr(ep, "name", "") or "").strip()
537
+ desc = str(getattr(ep, "description", "") or "")
538
+ interfaces = [str(x).strip() for x in list(getattr(ep, "interfaces", None) or []) if isinstance(x, str) and x.strip()]
539
+ return WorkflowEntrypointRef(
540
+ bundle_id=bundle_id,
541
+ bundle_version=bundle_version,
542
+ flow_id=fid,
543
+ name=name,
544
+ description=desc,
545
+ interfaces=interfaces,
546
+ is_default=bool(is_default),
547
+ )
548
+
549
+ def _entrypoint_from_manifest(
550
+ self,
551
+ *,
552
+ manifest: WorkflowBundleManifest,
553
+ bundle_id: str,
554
+ bundle_version: str,
555
+ flow_id: Optional[str],
556
+ interface: Optional[str],
557
+ ) -> WorkflowEntrypointRef:
558
+ eps = list(getattr(manifest, "entrypoints", None) or [])
559
+ if not eps:
560
+ raise WorkflowBundleRegistryError(f"Bundle '{bundle_id}@{bundle_version}' has no entrypoints")
561
+
562
+ default_fid = str(getattr(manifest, "default_entrypoint", "") or "").strip()
563
+ chosen = None
564
+ if flow_id:
565
+ chosen = next((ep for ep in eps if str(getattr(ep, "flow_id", "") or "").strip() == flow_id), None)
566
+ if chosen is None:
567
+ available = ", ".join(sorted({str(getattr(ep, 'flow_id', '') or '').strip() for ep in eps if str(getattr(ep, 'flow_id', '') or '').strip()}))
568
+ raise WorkflowBundleRegistryError(
569
+ f"Entrypoint '{flow_id}' not found in bundle '{bundle_id}@{bundle_version}' (available: {available})"
570
+ )
571
+ elif default_fid:
572
+ chosen = next((ep for ep in eps if str(getattr(ep, "flow_id", "") or "").strip() == default_fid), None)
573
+
574
+ if chosen is None:
575
+ chosen = eps[0]
576
+
577
+ epr = self._entrypoint_from_entrypoint(
578
+ ep=chosen,
579
+ bundle_id=bundle_id,
580
+ bundle_version=bundle_version,
581
+ is_default=bool(default_fid and str(getattr(chosen, "flow_id", "") or "").strip() == default_fid),
582
+ )
583
+ if interface and interface not in epr.interfaces:
584
+ raise WorkflowBundleRegistryError(
585
+ f"Selected entrypoint '{epr.workflow_id}' does not implement interface '{interface}'"
586
+ )
587
+ return epr