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.
@@ -0,0 +1,3 @@
1
+ from .adapter import CompositionAdapter, CompositionConfig
2
+
3
+ __all__ = ["CompositionAdapter", "CompositionConfig"]
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [chp.adapters]
2
+ composition = chp_adapter_composition:CompositionAdapter