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.
- abstractruntime/__init__.py +83 -3
- abstractruntime/core/config.py +82 -2
- abstractruntime/core/event_keys.py +62 -0
- abstractruntime/core/models.py +17 -1
- abstractruntime/core/policy.py +74 -3
- abstractruntime/core/runtime.py +3334 -28
- abstractruntime/core/vars.py +103 -2
- abstractruntime/evidence/__init__.py +10 -0
- abstractruntime/evidence/recorder.py +325 -0
- abstractruntime/history_bundle.py +772 -0
- abstractruntime/integrations/abstractcore/__init__.py +6 -0
- abstractruntime/integrations/abstractcore/constants.py +19 -0
- abstractruntime/integrations/abstractcore/default_tools.py +258 -0
- abstractruntime/integrations/abstractcore/effect_handlers.py +2622 -32
- abstractruntime/integrations/abstractcore/embeddings_client.py +69 -0
- abstractruntime/integrations/abstractcore/factory.py +149 -16
- abstractruntime/integrations/abstractcore/llm_client.py +891 -55
- abstractruntime/integrations/abstractcore/mcp_worker.py +587 -0
- abstractruntime/integrations/abstractcore/observability.py +80 -0
- abstractruntime/integrations/abstractcore/session_attachments.py +946 -0
- abstractruntime/integrations/abstractcore/summarizer.py +154 -0
- abstractruntime/integrations/abstractcore/tool_executor.py +509 -31
- abstractruntime/integrations/abstractcore/workspace_scoped_tools.py +561 -0
- abstractruntime/integrations/abstractmemory/__init__.py +3 -0
- abstractruntime/integrations/abstractmemory/effect_handlers.py +946 -0
- abstractruntime/memory/__init__.py +21 -0
- abstractruntime/memory/active_context.py +751 -0
- abstractruntime/memory/active_memory.py +452 -0
- abstractruntime/memory/compaction.py +105 -0
- abstractruntime/memory/kg_packets.py +164 -0
- abstractruntime/memory/memact_composer.py +175 -0
- abstractruntime/memory/recall_levels.py +163 -0
- abstractruntime/memory/token_budget.py +86 -0
- abstractruntime/rendering/__init__.py +17 -0
- abstractruntime/rendering/agent_trace_report.py +256 -0
- abstractruntime/rendering/json_stringify.py +136 -0
- abstractruntime/scheduler/scheduler.py +93 -2
- abstractruntime/storage/__init__.py +7 -2
- abstractruntime/storage/artifacts.py +175 -32
- abstractruntime/storage/base.py +17 -1
- abstractruntime/storage/commands.py +339 -0
- abstractruntime/storage/in_memory.py +41 -1
- abstractruntime/storage/json_files.py +210 -14
- abstractruntime/storage/observable.py +136 -0
- abstractruntime/storage/offloading.py +433 -0
- abstractruntime/storage/sqlite.py +836 -0
- abstractruntime/visualflow_compiler/__init__.py +29 -0
- abstractruntime/visualflow_compiler/adapters/__init__.py +11 -0
- abstractruntime/visualflow_compiler/adapters/agent_adapter.py +126 -0
- abstractruntime/visualflow_compiler/adapters/context_adapter.py +109 -0
- abstractruntime/visualflow_compiler/adapters/control_adapter.py +615 -0
- abstractruntime/visualflow_compiler/adapters/effect_adapter.py +1051 -0
- abstractruntime/visualflow_compiler/adapters/event_adapter.py +307 -0
- abstractruntime/visualflow_compiler/adapters/function_adapter.py +97 -0
- abstractruntime/visualflow_compiler/adapters/memact_adapter.py +114 -0
- abstractruntime/visualflow_compiler/adapters/subflow_adapter.py +74 -0
- abstractruntime/visualflow_compiler/adapters/variable_adapter.py +316 -0
- abstractruntime/visualflow_compiler/compiler.py +3832 -0
- abstractruntime/visualflow_compiler/flow.py +247 -0
- abstractruntime/visualflow_compiler/visual/__init__.py +13 -0
- abstractruntime/visualflow_compiler/visual/agent_ids.py +29 -0
- abstractruntime/visualflow_compiler/visual/builtins.py +1376 -0
- abstractruntime/visualflow_compiler/visual/code_executor.py +214 -0
- abstractruntime/visualflow_compiler/visual/executor.py +2804 -0
- abstractruntime/visualflow_compiler/visual/models.py +211 -0
- abstractruntime/workflow_bundle/__init__.py +52 -0
- abstractruntime/workflow_bundle/models.py +236 -0
- abstractruntime/workflow_bundle/packer.py +317 -0
- abstractruntime/workflow_bundle/reader.py +87 -0
- abstractruntime/workflow_bundle/registry.py +587 -0
- abstractruntime-0.4.1.dist-info/METADATA +177 -0
- abstractruntime-0.4.1.dist-info/RECORD +86 -0
- abstractruntime-0.4.1.dist-info/entry_points.txt +2 -0
- abstractruntime-0.2.0.dist-info/METADATA +0 -163
- abstractruntime-0.2.0.dist-info/RECORD +0 -32
- {abstractruntime-0.2.0.dist-info → abstractruntime-0.4.1.dist-info}/WHEEL +0 -0
- {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
|