chp-adapter-composition 0.8.0__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.
- chp_adapter_composition/__init__.py +3 -0
- chp_adapter_composition/adapter.py +322 -0
- chp_adapter_composition-0.8.0.dist-info/METADATA +20 -0
- chp_adapter_composition-0.8.0.dist-info/RECORD +6 -0
- chp_adapter_composition-0.8.0.dist-info/WHEEL +4 -0
- chp_adapter_composition-0.8.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""CompositionAdapter — define and run named capability workflows as CHP capabilities.
|
|
2
|
+
|
|
3
|
+
Evidence hygiene (MUST PRESERVE):
|
|
4
|
+
* Step ``payload`` — NOT in evidence (may contain secrets/PII).
|
|
5
|
+
* Step result ``data`` — NOT in evidence.
|
|
6
|
+
* Only ``step_id``, ``capability_id``, ``success``, ``duration_ms``, workflow
|
|
7
|
+
name and aggregate counts are recorded.
|
|
8
|
+
|
|
9
|
+
Workflow definitions are stored in-memory. ``on_register(host)`` captures the
|
|
10
|
+
host reference so ``run`` can call back via ``await self._host.ainvoke()``.
|
|
11
|
+
|
|
12
|
+
Four capabilities: define, run, list, get.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from chp_core import BaseAdapter, capability
|
|
22
|
+
|
|
23
|
+
_EMITS = [
|
|
24
|
+
"workflow_defined",
|
|
25
|
+
"workflow_run_started",
|
|
26
|
+
"workflow_step_started",
|
|
27
|
+
"workflow_step_completed",
|
|
28
|
+
"workflow_step_failed",
|
|
29
|
+
"workflow_run_complete",
|
|
30
|
+
"workflow_run_failed",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class WorkflowStep:
|
|
36
|
+
step_id: str
|
|
37
|
+
capability_id: str
|
|
38
|
+
payload: dict
|
|
39
|
+
skip_on_failure: bool = False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class WorkflowDefinition:
|
|
44
|
+
name: str
|
|
45
|
+
description: str
|
|
46
|
+
steps: list[WorkflowStep]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class CompositionConfig:
|
|
51
|
+
"""Config for CompositionAdapter.
|
|
52
|
+
|
|
53
|
+
``_store`` injects a pre-populated definition dict for tests.
|
|
54
|
+
"""
|
|
55
|
+
_store: dict[str, WorkflowDefinition] | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class CompositionAdapter(BaseAdapter):
|
|
59
|
+
"""Compose CHP capabilities into named reusable workflows."""
|
|
60
|
+
|
|
61
|
+
adapter_id = "chp.adapters.composition"
|
|
62
|
+
adapter_name = "Composition"
|
|
63
|
+
adapter_description = "Define and execute named multi-step capability workflows."
|
|
64
|
+
adapter_category = "core"
|
|
65
|
+
adapter_tags = ["workflow", "composition", "orchestration"]
|
|
66
|
+
|
|
67
|
+
def __init__(self, config: CompositionConfig | None = None) -> None:
|
|
68
|
+
self._config = config or CompositionConfig()
|
|
69
|
+
self._workflows: dict[str, WorkflowDefinition] = (
|
|
70
|
+
dict(self._config._store) if self._config._store else {}
|
|
71
|
+
)
|
|
72
|
+
self._host: Any = None
|
|
73
|
+
|
|
74
|
+
def on_register(self, host: Any) -> None:
|
|
75
|
+
self._host = host
|
|
76
|
+
|
|
77
|
+
# ------------------------------------------------------------------
|
|
78
|
+
# Capabilities
|
|
79
|
+
# ------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
@capability(
|
|
82
|
+
id="chp.adapters.composition.define",
|
|
83
|
+
version="1.0.0",
|
|
84
|
+
description="Register a named workflow (sequence of capability invocations).",
|
|
85
|
+
category="core",
|
|
86
|
+
risk="medium",
|
|
87
|
+
input_schema={
|
|
88
|
+
"type": "object",
|
|
89
|
+
"properties": {
|
|
90
|
+
"name": {"type": "string", "minLength": 1,
|
|
91
|
+
"description": "Unique workflow name."},
|
|
92
|
+
"description": {"type": "string"},
|
|
93
|
+
"steps": {
|
|
94
|
+
"type": "array",
|
|
95
|
+
"minItems": 1,
|
|
96
|
+
"items": {
|
|
97
|
+
"type": "object",
|
|
98
|
+
"properties": {
|
|
99
|
+
"step_id": {"type": "string"},
|
|
100
|
+
"capability_id": {"type": "string", "minLength": 1},
|
|
101
|
+
"payload": {"type": "object"},
|
|
102
|
+
"skip_on_failure": {"type": "boolean"},
|
|
103
|
+
},
|
|
104
|
+
"required": ["capability_id"],
|
|
105
|
+
"additionalProperties": False,
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
"required": ["name", "steps"],
|
|
110
|
+
"additionalProperties": False,
|
|
111
|
+
},
|
|
112
|
+
emits=_EMITS,
|
|
113
|
+
tags=["workflow"],
|
|
114
|
+
)
|
|
115
|
+
async def define(self, ctx: Any, payload: dict) -> dict:
|
|
116
|
+
name = payload["name"]
|
|
117
|
+
description = payload.get("description", "")
|
|
118
|
+
raw_steps = payload["steps"]
|
|
119
|
+
|
|
120
|
+
steps = []
|
|
121
|
+
for i, s in enumerate(raw_steps):
|
|
122
|
+
steps.append(WorkflowStep(
|
|
123
|
+
step_id=s.get("step_id") or f"step_{i + 1}",
|
|
124
|
+
capability_id=s["capability_id"],
|
|
125
|
+
payload=s.get("payload") or {},
|
|
126
|
+
skip_on_failure=bool(s.get("skip_on_failure", False)),
|
|
127
|
+
))
|
|
128
|
+
|
|
129
|
+
self._workflows[name] = WorkflowDefinition(
|
|
130
|
+
name=name, description=description, steps=steps
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
ctx.emit("workflow_defined", {
|
|
134
|
+
"name": name,
|
|
135
|
+
"step_count": len(steps),
|
|
136
|
+
"step_ids": [s.step_id for s in steps],
|
|
137
|
+
"capability_ids": [s.capability_id for s in steps],
|
|
138
|
+
# payloads intentionally not recorded
|
|
139
|
+
})
|
|
140
|
+
return {"name": name, "step_count": len(steps), "defined": True}
|
|
141
|
+
|
|
142
|
+
@capability(
|
|
143
|
+
id="chp.adapters.composition.run",
|
|
144
|
+
version="1.0.0",
|
|
145
|
+
description="Execute a registered workflow by name.",
|
|
146
|
+
category="core",
|
|
147
|
+
risk="medium",
|
|
148
|
+
input_schema={
|
|
149
|
+
"type": "object",
|
|
150
|
+
"properties": {
|
|
151
|
+
"name": {"type": "string", "description": "Registered workflow name."},
|
|
152
|
+
"workflow_id": {"type": "string",
|
|
153
|
+
"description": "Optional correlation ID for this run."},
|
|
154
|
+
},
|
|
155
|
+
"required": ["name"],
|
|
156
|
+
"additionalProperties": False,
|
|
157
|
+
},
|
|
158
|
+
emits=_EMITS,
|
|
159
|
+
tags=["workflow"],
|
|
160
|
+
)
|
|
161
|
+
async def run(self, ctx: Any, payload: dict) -> dict:
|
|
162
|
+
if self._host is None:
|
|
163
|
+
raise RuntimeError("CompositionAdapter must be registered with a host before run()")
|
|
164
|
+
|
|
165
|
+
name = payload["name"]
|
|
166
|
+
workflow_id = payload.get("workflow_id") or f"wfrun_{name}"
|
|
167
|
+
|
|
168
|
+
wf = self._workflows.get(name)
|
|
169
|
+
if wf is None:
|
|
170
|
+
raise KeyError(f"Workflow {name!r} is not defined")
|
|
171
|
+
|
|
172
|
+
ctx.emit("workflow_run_started", {
|
|
173
|
+
"workflow_id": workflow_id,
|
|
174
|
+
"name": name,
|
|
175
|
+
"step_count": len(wf.steps),
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
step_results = []
|
|
179
|
+
run_start = time.perf_counter()
|
|
180
|
+
|
|
181
|
+
for step in wf.steps:
|
|
182
|
+
ctx.emit("workflow_step_started", {
|
|
183
|
+
"workflow_id": workflow_id,
|
|
184
|
+
"step_id": step.step_id,
|
|
185
|
+
"capability_id": step.capability_id,
|
|
186
|
+
# payload intentionally not recorded
|
|
187
|
+
})
|
|
188
|
+
t0 = time.perf_counter()
|
|
189
|
+
step_error: str | None = None
|
|
190
|
+
success = False
|
|
191
|
+
try:
|
|
192
|
+
result = await self._host.ainvoke(
|
|
193
|
+
step.capability_id,
|
|
194
|
+
step.payload,
|
|
195
|
+
correlation={"correlation_id": ctx.correlation_id},
|
|
196
|
+
)
|
|
197
|
+
dur = round((time.perf_counter() - t0) * 1000, 2)
|
|
198
|
+
success = result.success
|
|
199
|
+
if not success:
|
|
200
|
+
step_error = str(result.error) if result.error else "step returned non-success"
|
|
201
|
+
except Exception as exc:
|
|
202
|
+
dur = round((time.perf_counter() - t0) * 1000, 2)
|
|
203
|
+
step_error = str(exc)
|
|
204
|
+
|
|
205
|
+
if success:
|
|
206
|
+
ctx.emit("workflow_step_completed", {
|
|
207
|
+
"workflow_id": workflow_id,
|
|
208
|
+
"step_id": step.step_id,
|
|
209
|
+
"capability_id": step.capability_id,
|
|
210
|
+
"duration_ms": dur,
|
|
211
|
+
# result data intentionally not recorded
|
|
212
|
+
})
|
|
213
|
+
else:
|
|
214
|
+
ctx.emit("workflow_step_failed", {
|
|
215
|
+
"workflow_id": workflow_id,
|
|
216
|
+
"step_id": step.step_id,
|
|
217
|
+
"capability_id": step.capability_id,
|
|
218
|
+
"error": step_error,
|
|
219
|
+
"duration_ms": dur,
|
|
220
|
+
})
|
|
221
|
+
if not step.skip_on_failure:
|
|
222
|
+
ctx.emit("workflow_run_failed", {
|
|
223
|
+
"workflow_id": workflow_id,
|
|
224
|
+
"failed_at_step": step.step_id,
|
|
225
|
+
"error": step_error,
|
|
226
|
+
})
|
|
227
|
+
raise RuntimeError(
|
|
228
|
+
f"Workflow {name!r} failed at step {step.step_id!r}: {step_error}"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
step_results.append({
|
|
232
|
+
"step_id": step.step_id,
|
|
233
|
+
"capability_id": step.capability_id,
|
|
234
|
+
"success": success,
|
|
235
|
+
"error": step_error,
|
|
236
|
+
"duration_ms": dur,
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
total_ms = round((time.perf_counter() - run_start) * 1000, 2)
|
|
240
|
+
completed = sum(1 for s in step_results if s["success"])
|
|
241
|
+
failed = sum(1 for s in step_results if not s["success"])
|
|
242
|
+
|
|
243
|
+
ctx.emit("workflow_run_complete", {
|
|
244
|
+
"workflow_id": workflow_id,
|
|
245
|
+
"name": name,
|
|
246
|
+
"completed_steps": completed,
|
|
247
|
+
"failed_steps": failed,
|
|
248
|
+
"total_duration_ms": total_ms,
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
"workflow_id": workflow_id,
|
|
253
|
+
"name": name,
|
|
254
|
+
"steps": step_results,
|
|
255
|
+
"completed_steps": completed,
|
|
256
|
+
"failed_steps": failed,
|
|
257
|
+
"total_duration_ms": total_ms,
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
@capability(
|
|
261
|
+
id="chp.adapters.composition.list",
|
|
262
|
+
version="1.0.0",
|
|
263
|
+
description="List all registered workflows.",
|
|
264
|
+
category="core",
|
|
265
|
+
risk="low",
|
|
266
|
+
input_schema={
|
|
267
|
+
"type": "object",
|
|
268
|
+
"properties": {},
|
|
269
|
+
"additionalProperties": False,
|
|
270
|
+
},
|
|
271
|
+
emits=_EMITS,
|
|
272
|
+
tags=["workflow"],
|
|
273
|
+
)
|
|
274
|
+
async def list_workflows(self, ctx: Any, payload: dict) -> dict:
|
|
275
|
+
workflows = [
|
|
276
|
+
{
|
|
277
|
+
"name": wf.name,
|
|
278
|
+
"description": wf.description,
|
|
279
|
+
"step_count": len(wf.steps),
|
|
280
|
+
}
|
|
281
|
+
for wf in self._workflows.values()
|
|
282
|
+
]
|
|
283
|
+
ctx.emit("workflows_listed", {"count": len(workflows)}, redacted=False)
|
|
284
|
+
return {"workflows": workflows, "count": len(workflows)}
|
|
285
|
+
|
|
286
|
+
@capability(
|
|
287
|
+
id="chp.adapters.composition.get",
|
|
288
|
+
version="1.0.0",
|
|
289
|
+
description="Get a workflow definition by name.",
|
|
290
|
+
category="core",
|
|
291
|
+
risk="low",
|
|
292
|
+
input_schema={
|
|
293
|
+
"type": "object",
|
|
294
|
+
"properties": {
|
|
295
|
+
"name": {"type": "string"},
|
|
296
|
+
},
|
|
297
|
+
"required": ["name"],
|
|
298
|
+
"additionalProperties": False,
|
|
299
|
+
},
|
|
300
|
+
emits=_EMITS,
|
|
301
|
+
tags=["workflow"],
|
|
302
|
+
)
|
|
303
|
+
async def get_workflow(self, ctx: Any, payload: dict) -> dict:
|
|
304
|
+
name = payload["name"]
|
|
305
|
+
wf = self._workflows.get(name)
|
|
306
|
+
if wf is None:
|
|
307
|
+
raise KeyError(f"Workflow {name!r} is not defined")
|
|
308
|
+
ctx.emit("workflow_retrieved", {"name": name, "step_count": len(wf.steps)}, redacted=False)
|
|
309
|
+
return {
|
|
310
|
+
"name": wf.name,
|
|
311
|
+
"description": wf.description,
|
|
312
|
+
"steps": [
|
|
313
|
+
{
|
|
314
|
+
"step_id": s.step_id,
|
|
315
|
+
"capability_id": s.capability_id,
|
|
316
|
+
"skip_on_failure": s.skip_on_failure,
|
|
317
|
+
# payload intentionally not returned (may contain secrets)
|
|
318
|
+
}
|
|
319
|
+
for s in wf.steps
|
|
320
|
+
],
|
|
321
|
+
"step_count": len(wf.steps),
|
|
322
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chp-adapter-composition
|
|
3
|
+
Version: 0.8.0
|
|
4
|
+
Summary: CHP capability adapter — compose capabilities into named reusable workflows
|
|
5
|
+
Author: Auxo
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Keywords: adapter,capability-host-protocol,chp,composition,workflow
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: chp-core>=0.7.0
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
chp_adapter_composition/__init__.py,sha256=-7od5OUMlTHdh01GnY8iD20SMNzUk-MWSnHxNF7A1OA,114
|
|
2
|
+
chp_adapter_composition/adapter.py,sha256=LbO_UqAm7zB05XxDIkCuL4n4M57_syA8VqVFjHpQr_o,11026
|
|
3
|
+
chp_adapter_composition-0.8.0.dist-info/METADATA,sha256=WcPUFjvJT7EU9dkn4rOMLse-_SyTmtnuT_7o0hj47II,849
|
|
4
|
+
chp_adapter_composition-0.8.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
5
|
+
chp_adapter_composition-0.8.0.dist-info/entry_points.txt,sha256=9Uiwd-w3xialdpmIcPdEFcGtSIzCrqEOFCzc0PwYXGk,72
|
|
6
|
+
chp_adapter_composition-0.8.0.dist-info/RECORD,,
|