chp-adapter-composition 0.8.0__tar.gz

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,23 @@
1
+ node_modules/
2
+ dist/
3
+ .yalc/
4
+ yalc.lock
5
+ *.tgz
6
+ .env
7
+ .env.*
8
+ coverage/
9
+ dashboard/
10
+ components/
11
+ .tech-hub-cache/
12
+ __pycache__/
13
+ *.py[cod]
14
+ .pytest_cache/
15
+ .chp/
16
+ .DS_Store
17
+ .coverage
18
+ .forge/
19
+ .hypothesis/
20
+ .claude/
21
+
22
+ # chp-agent evidence store
23
+ .chp-agent/sessions.sqlite
@@ -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'
File without changes
@@ -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,40 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "chp-adapter-composition"
7
+ version = "0.8.0"
8
+ description = "CHP capability adapter — compose capabilities into named reusable workflows"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "Apache-2.0" }
12
+ authors = [{ name = "Auxo" }]
13
+ keywords = ["chp", "capability-host-protocol", "workflow", "composition", "adapter"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: Apache Software License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Software Development :: Libraries :: Python Modules",
24
+ ]
25
+ dependencies = [
26
+ "chp-core>=0.7.0",
27
+ ]
28
+
29
+ [project.entry-points."chp.adapters"]
30
+ composition = "chp_adapter_composition:CompositionAdapter"
31
+
32
+ [project.optional-dependencies]
33
+ dev = ["pytest>=8.0"]
34
+
35
+ [tool.pytest.ini_options]
36
+ testpaths = ["tests"]
37
+ pythonpath = ["."]
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["chp_adapter_composition"]
@@ -0,0 +1,524 @@
1
+ """Tests for chp_adapter_composition.adapter — uses a fake host to avoid live invocations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+ from unittest.mock import AsyncMock
9
+
10
+ import pytest
11
+
12
+ from chp_core import LocalCapabilityHost, register_adapter
13
+ from chp_core.store import SQLiteEvidenceStore
14
+
15
+ from chp_adapter_composition import CompositionAdapter, CompositionConfig
16
+
17
+
18
+ # --------------------------------------------------------------------------
19
+ # Fake host helpers
20
+ # --------------------------------------------------------------------------
21
+
22
+ @dataclass
23
+ class FakeResult:
24
+ success: bool
25
+ data: Any = None
26
+ error: Any = None
27
+ outcome: str = "success"
28
+
29
+
30
+ class FakeHost:
31
+ """Minimal host stub for testing CompositionAdapter without a real host."""
32
+
33
+ def __init__(self, responses: dict[str, FakeResult] | None = None) -> None:
34
+ self._responses: dict[str, FakeResult] = responses or {}
35
+ self.calls: list[tuple[str, dict]] = []
36
+
37
+ async def ainvoke(self, capability_id: str, payload: dict, **_kw) -> FakeResult:
38
+ self.calls.append((capability_id, payload))
39
+ result = self._responses.get(capability_id)
40
+ if result is None:
41
+ return FakeResult(success=True, data={}, outcome="success")
42
+ return result
43
+
44
+
45
+ def _make_real_host(adapter: CompositionAdapter) -> LocalCapabilityHost:
46
+ host = LocalCapabilityHost(store=SQLiteEvidenceStore(":memory:"))
47
+ register_adapter(host, adapter)
48
+ return host
49
+
50
+
51
+ # --------------------------------------------------------------------------
52
+ # 1. Capability shaping
53
+ # --------------------------------------------------------------------------
54
+
55
+ class TestCapabilityShaping:
56
+ def test_capability_ids(self):
57
+ adapter = CompositionAdapter()
58
+ ids = {c.descriptor.id for c in adapter.capabilities()}
59
+ assert ids == {
60
+ "chp.adapters.composition.define",
61
+ "chp.adapters.composition.run",
62
+ "chp.adapters.composition.list",
63
+ "chp.adapters.composition.get",
64
+ }
65
+
66
+ def test_adapter_id(self):
67
+ assert CompositionAdapter().adapter_id == "chp.adapters.composition"
68
+
69
+ def test_define_is_medium_risk(self):
70
+ adapter = CompositionAdapter()
71
+ cap = next(c for c in adapter.capabilities()
72
+ if c.descriptor.id == "chp.adapters.composition.define")
73
+ assert cap.descriptor.risk == "medium"
74
+
75
+ def test_run_is_medium_risk(self):
76
+ adapter = CompositionAdapter()
77
+ cap = next(c for c in adapter.capabilities()
78
+ if c.descriptor.id == "chp.adapters.composition.run")
79
+ assert cap.descriptor.risk == "medium"
80
+
81
+ def test_list_is_low_risk(self):
82
+ adapter = CompositionAdapter()
83
+ cap = next(c for c in adapter.capabilities()
84
+ if c.descriptor.id == "chp.adapters.composition.list")
85
+ assert cap.descriptor.risk == "low"
86
+
87
+ def test_get_is_low_risk(self):
88
+ adapter = CompositionAdapter()
89
+ cap = next(c for c in adapter.capabilities()
90
+ if c.descriptor.id == "chp.adapters.composition.get")
91
+ assert cap.descriptor.risk == "low"
92
+
93
+
94
+ # --------------------------------------------------------------------------
95
+ # 2. define capability
96
+ # --------------------------------------------------------------------------
97
+
98
+ class TestDefine:
99
+ def test_define_returns_name_and_step_count(self):
100
+ host = _make_real_host(CompositionAdapter())
101
+ r = host.invoke("chp.adapters.composition.define", {
102
+ "name": "wf1",
103
+ "steps": [{"capability_id": "some.cap"}],
104
+ })
105
+ assert r.outcome == "success"
106
+ assert r.data["name"] == "wf1"
107
+ assert r.data["step_count"] == 1
108
+ assert r.data["defined"] is True
109
+
110
+ def test_define_multiple_steps(self):
111
+ host = _make_real_host(CompositionAdapter())
112
+ r = host.invoke("chp.adapters.composition.define", {
113
+ "name": "multi",
114
+ "steps": [
115
+ {"capability_id": "a.cap"},
116
+ {"capability_id": "b.cap"},
117
+ {"capability_id": "c.cap"},
118
+ ],
119
+ })
120
+ assert r.data["step_count"] == 3
121
+
122
+ def test_define_auto_generates_step_ids(self):
123
+ host = _make_real_host(CompositionAdapter())
124
+ r = host.invoke("chp.adapters.composition.define", {
125
+ "name": "wfauto",
126
+ "steps": [{"capability_id": "x"}, {"capability_id": "y"}],
127
+ })
128
+ # The workflow is stored — verify via get
129
+ rg = host.invoke("chp.adapters.composition.get", {"name": "wfauto"})
130
+ step_ids = [s["step_id"] for s in rg.data["steps"]]
131
+ assert step_ids == ["step_1", "step_2"]
132
+
133
+ def test_define_explicit_step_ids_preserved(self):
134
+ host = _make_real_host(CompositionAdapter())
135
+ host.invoke("chp.adapters.composition.define", {
136
+ "name": "explicit",
137
+ "steps": [{"capability_id": "a", "step_id": "fetch"}, {"capability_id": "b", "step_id": "transform"}],
138
+ })
139
+ rg = host.invoke("chp.adapters.composition.get", {"name": "explicit"})
140
+ ids = [s["step_id"] for s in rg.data["steps"]]
141
+ assert ids == ["fetch", "transform"]
142
+
143
+ def test_define_empty_steps_denied(self):
144
+ host = _make_real_host(CompositionAdapter())
145
+ r = host.invoke("chp.adapters.composition.define", {
146
+ "name": "bad",
147
+ "steps": [],
148
+ })
149
+ assert r.outcome == "denied"
150
+
151
+ def test_define_missing_name_denied(self):
152
+ host = _make_real_host(CompositionAdapter())
153
+ r = host.invoke("chp.adapters.composition.define", {
154
+ "steps": [{"capability_id": "a"}],
155
+ })
156
+ assert r.outcome == "denied"
157
+
158
+ def test_define_unknown_field_denied(self):
159
+ host = _make_real_host(CompositionAdapter())
160
+ r = host.invoke("chp.adapters.composition.define", {
161
+ "name": "wf",
162
+ "steps": [{"capability_id": "a"}],
163
+ "bogus": True,
164
+ })
165
+ assert r.outcome == "denied"
166
+
167
+ def test_define_overwrites_existing(self):
168
+ host = _make_real_host(CompositionAdapter())
169
+ host.invoke("chp.adapters.composition.define", {
170
+ "name": "wf",
171
+ "steps": [{"capability_id": "a"}],
172
+ })
173
+ host.invoke("chp.adapters.composition.define", {
174
+ "name": "wf",
175
+ "steps": [{"capability_id": "b"}, {"capability_id": "c"}],
176
+ })
177
+ rg = host.invoke("chp.adapters.composition.get", {"name": "wf"})
178
+ assert rg.data["step_count"] == 2
179
+
180
+ def test_define_emits_workflow_defined(self):
181
+ host = _make_real_host(CompositionAdapter())
182
+ host.invoke("chp.adapters.composition.define", {
183
+ "name": "wf_ev",
184
+ "steps": [{"capability_id": "cap.x"}],
185
+ })
186
+ events = [e for e in host.store.all()
187
+ if e["event_type"] == "workflow_defined"]
188
+ assert len(events) == 1
189
+ assert events[0]["payload"]["name"] == "wf_ev"
190
+
191
+ def test_define_payload_not_in_evidence(self):
192
+ host = _make_real_host(CompositionAdapter())
193
+ host.invoke("chp.adapters.composition.define", {
194
+ "name": "sensitive_wf",
195
+ "steps": [{"capability_id": "cap", "payload": {"secret_key": "hunter2"}}],
196
+ })
197
+ dump = str([e["payload"] for e in host.store.all()])
198
+ assert "hunter2" not in dump
199
+
200
+
201
+ # --------------------------------------------------------------------------
202
+ # 3. list capability
203
+ # --------------------------------------------------------------------------
204
+
205
+ class TestList:
206
+ def test_list_empty_initially(self):
207
+ host = _make_real_host(CompositionAdapter())
208
+ r = host.invoke("chp.adapters.composition.list", {})
209
+ assert r.outcome == "success"
210
+ assert r.data["count"] == 0
211
+ assert r.data["workflows"] == []
212
+
213
+ def test_list_after_define(self):
214
+ host = _make_real_host(CompositionAdapter())
215
+ host.invoke("chp.adapters.composition.define", {
216
+ "name": "wf1",
217
+ "description": "First workflow",
218
+ "steps": [{"capability_id": "cap.a"}],
219
+ })
220
+ host.invoke("chp.adapters.composition.define", {
221
+ "name": "wf2",
222
+ "steps": [{"capability_id": "cap.b"}, {"capability_id": "cap.c"}],
223
+ })
224
+ r = host.invoke("chp.adapters.composition.list", {})
225
+ assert r.data["count"] == 2
226
+ names = {w["name"] for w in r.data["workflows"]}
227
+ assert names == {"wf1", "wf2"}
228
+
229
+ def test_list_includes_step_count(self):
230
+ host = _make_real_host(CompositionAdapter())
231
+ host.invoke("chp.adapters.composition.define", {
232
+ "name": "three_step",
233
+ "steps": [{"capability_id": "a"}, {"capability_id": "b"}, {"capability_id": "c"}],
234
+ })
235
+ r = host.invoke("chp.adapters.composition.list", {})
236
+ wf = next(w for w in r.data["workflows"] if w["name"] == "three_step")
237
+ assert wf["step_count"] == 3
238
+
239
+ def test_list_unknown_field_denied(self):
240
+ host = _make_real_host(CompositionAdapter())
241
+ r = host.invoke("chp.adapters.composition.list", {"extra": True})
242
+ assert r.outcome == "denied"
243
+
244
+
245
+ # --------------------------------------------------------------------------
246
+ # 4. get capability
247
+ # --------------------------------------------------------------------------
248
+
249
+ class TestGet:
250
+ def test_get_existing_workflow(self):
251
+ host = _make_real_host(CompositionAdapter())
252
+ host.invoke("chp.adapters.composition.define", {
253
+ "name": "fetch_wf",
254
+ "description": "Fetches stuff",
255
+ "steps": [
256
+ {"capability_id": "cap.a", "step_id": "a"},
257
+ {"capability_id": "cap.b", "step_id": "b", "skip_on_failure": True},
258
+ ],
259
+ })
260
+ r = host.invoke("chp.adapters.composition.get", {"name": "fetch_wf"})
261
+ assert r.outcome == "success"
262
+ assert r.data["name"] == "fetch_wf"
263
+ assert r.data["description"] == "Fetches stuff"
264
+ assert r.data["step_count"] == 2
265
+ steps = r.data["steps"]
266
+ assert steps[0]["step_id"] == "a"
267
+ assert steps[1]["skip_on_failure"] is True
268
+
269
+ def test_get_payload_not_returned(self):
270
+ host = _make_real_host(CompositionAdapter())
271
+ host.invoke("chp.adapters.composition.define", {
272
+ "name": "secret_wf",
273
+ "steps": [{"capability_id": "cap", "payload": {"token": "abc123"}}],
274
+ })
275
+ r = host.invoke("chp.adapters.composition.get", {"name": "secret_wf"})
276
+ dump = str(r.data)
277
+ assert "abc123" not in dump
278
+ # Steps should not include payload key
279
+ assert "payload" not in r.data["steps"][0]
280
+
281
+ def test_get_undefined_workflow_fails(self):
282
+ host = _make_real_host(CompositionAdapter())
283
+ r = host.invoke("chp.adapters.composition.get", {"name": "not_here"})
284
+ assert r.outcome == "failure"
285
+
286
+ def test_get_missing_name_denied(self):
287
+ host = _make_real_host(CompositionAdapter())
288
+ r = host.invoke("chp.adapters.composition.get", {})
289
+ assert r.outcome == "denied"
290
+
291
+ def test_get_unknown_field_denied(self):
292
+ host = _make_real_host(CompositionAdapter())
293
+ r = host.invoke("chp.adapters.composition.get", {"name": "wf", "extra": True})
294
+ assert r.outcome == "denied"
295
+
296
+
297
+ # --------------------------------------------------------------------------
298
+ # 5. run capability — async, uses fake host
299
+ # --------------------------------------------------------------------------
300
+
301
+ @pytest.mark.asyncio
302
+ class TestRun:
303
+ async def _run_with_fake(self, wf_def: dict, responses: dict | None = None) -> tuple[Any, FakeHost]:
304
+ adapter = CompositionAdapter()
305
+ real_host = LocalCapabilityHost(store=SQLiteEvidenceStore(":memory:"))
306
+ register_adapter(real_host, adapter)
307
+ # define the workflow
308
+ await real_host.ainvoke("chp.adapters.composition.define", wf_def)
309
+ # now inject a fake host so ainvoke goes to our stub
310
+ fake = FakeHost(responses or {})
311
+ adapter._host = fake
312
+ result = await real_host.ainvoke("chp.adapters.composition.run", {"name": wf_def["name"]})
313
+ return result, fake
314
+
315
+ async def test_run_all_steps_succeed(self):
316
+ wf = {
317
+ "name": "happy_path",
318
+ "steps": [
319
+ {"capability_id": "cap.a", "step_id": "a"},
320
+ {"capability_id": "cap.b", "step_id": "b"},
321
+ ],
322
+ }
323
+ r, fake = await self._run_with_fake(wf)
324
+ assert r.outcome == "success"
325
+ assert r.data["completed_steps"] == 2
326
+ assert r.data["failed_steps"] == 0
327
+ assert len(fake.calls) == 2
328
+ assert fake.calls[0][0] == "cap.a"
329
+ assert fake.calls[1][0] == "cap.b"
330
+
331
+ async def test_run_step_payloads_forwarded_to_host(self):
332
+ wf = {
333
+ "name": "payload_wf",
334
+ "steps": [{"capability_id": "cap.a", "payload": {"key": "val"}}],
335
+ }
336
+ r, fake = await self._run_with_fake(wf)
337
+ assert fake.calls[0][1] == {"key": "val"}
338
+
339
+ async def test_run_failing_step_halts_workflow(self):
340
+ wf = {
341
+ "name": "fail_wf",
342
+ "steps": [
343
+ {"capability_id": "cap.a", "step_id": "a"},
344
+ {"capability_id": "cap.b", "step_id": "b"},
345
+ {"capability_id": "cap.c", "step_id": "c"},
346
+ ],
347
+ }
348
+ responses = {"cap.b": FakeResult(success=False, error="something broke", outcome="failure")}
349
+ r, fake = await self._run_with_fake(wf, responses)
350
+ # Halts at b — c should never be called
351
+ assert r.outcome == "failure"
352
+ assert len(fake.calls) == 2 # a + b; c skipped
353
+
354
+ async def test_run_skip_on_failure_continues(self):
355
+ wf = {
356
+ "name": "skip_wf",
357
+ "steps": [
358
+ {"capability_id": "cap.a", "step_id": "a"},
359
+ {"capability_id": "cap.b", "step_id": "b", "skip_on_failure": True},
360
+ {"capability_id": "cap.c", "step_id": "c"},
361
+ ],
362
+ }
363
+ responses = {"cap.b": FakeResult(success=False, error="non-fatal")}
364
+ r, fake = await self._run_with_fake(wf, responses)
365
+ assert r.outcome == "success"
366
+ assert r.data["completed_steps"] == 2
367
+ assert r.data["failed_steps"] == 1
368
+ assert len(fake.calls) == 3 # all three called
369
+
370
+ async def test_run_undefined_workflow_fails(self):
371
+ adapter = CompositionAdapter()
372
+ real_host = LocalCapabilityHost(store=SQLiteEvidenceStore(":memory:"))
373
+ register_adapter(real_host, adapter)
374
+ fake = FakeHost()
375
+ adapter._host = fake
376
+ r = await real_host.ainvoke("chp.adapters.composition.run", {"name": "missing_wf"})
377
+ assert r.outcome == "failure"
378
+
379
+ async def test_run_missing_name_denied(self):
380
+ adapter = CompositionAdapter()
381
+ real_host = LocalCapabilityHost(store=SQLiteEvidenceStore(":memory:"))
382
+ register_adapter(real_host, adapter)
383
+ fake = FakeHost()
384
+ adapter._host = fake
385
+ r = await real_host.ainvoke("chp.adapters.composition.run", {})
386
+ assert r.outcome == "denied"
387
+
388
+ async def test_run_unknown_field_denied(self):
389
+ adapter = CompositionAdapter()
390
+ real_host = LocalCapabilityHost(store=SQLiteEvidenceStore(":memory:"))
391
+ register_adapter(real_host, adapter)
392
+ fake = FakeHost()
393
+ adapter._host = fake
394
+ r = await real_host.ainvoke("chp.adapters.composition.run", {"name": "wf", "extra": True})
395
+ assert r.outcome == "denied"
396
+
397
+
398
+ # --------------------------------------------------------------------------
399
+ # 6. run evidence hygiene
400
+ # --------------------------------------------------------------------------
401
+
402
+ @pytest.mark.asyncio
403
+ class TestRunEvidence:
404
+ async def test_step_payload_not_in_evidence(self):
405
+ adapter = CompositionAdapter()
406
+ real_host = LocalCapabilityHost(store=SQLiteEvidenceStore(":memory:"))
407
+ register_adapter(real_host, adapter)
408
+ await real_host.ainvoke("chp.adapters.composition.define", {
409
+ "name": "sec_wf",
410
+ "steps": [{"capability_id": "cap", "payload": {"secret": "topsecret"}}],
411
+ })
412
+ adapter._host = FakeHost()
413
+ await real_host.ainvoke("chp.adapters.composition.run", {"name": "sec_wf"})
414
+ dump = str([e["payload"] for e in real_host.store.all()])
415
+ assert "topsecret" not in dump
416
+
417
+ async def test_step_result_data_not_in_evidence(self):
418
+ adapter = CompositionAdapter()
419
+ real_host = LocalCapabilityHost(store=SQLiteEvidenceStore(":memory:"))
420
+ register_adapter(real_host, adapter)
421
+ await real_host.ainvoke("chp.adapters.composition.define", {
422
+ "name": "result_wf",
423
+ "steps": [{"capability_id": "cap"}],
424
+ })
425
+ adapter._host = FakeHost({"cap": FakeResult(success=True, data={"private": "leaked"})})
426
+ await real_host.ainvoke("chp.adapters.composition.run", {"name": "result_wf"})
427
+ dump = str([e["payload"] for e in real_host.store.all()])
428
+ assert "leaked" not in dump
429
+
430
+ async def test_run_emits_workflow_events(self):
431
+ adapter = CompositionAdapter()
432
+ real_host = LocalCapabilityHost(store=SQLiteEvidenceStore(":memory:"))
433
+ register_adapter(real_host, adapter)
434
+ await real_host.ainvoke("chp.adapters.composition.define", {
435
+ "name": "ev_wf",
436
+ "steps": [{"capability_id": "cap.a"}, {"capability_id": "cap.b"}],
437
+ })
438
+ adapter._host = FakeHost()
439
+ await real_host.ainvoke("chp.adapters.composition.run", {"name": "ev_wf"})
440
+ event_types = {e["event_type"] for e in real_host.store.all()}
441
+ assert "workflow_run_started" in event_types
442
+ assert "workflow_step_started" in event_types
443
+ assert "workflow_step_completed" in event_types
444
+ assert "workflow_run_complete" in event_types
445
+
446
+ async def test_run_records_step_metadata(self):
447
+ adapter = CompositionAdapter()
448
+ real_host = LocalCapabilityHost(store=SQLiteEvidenceStore(":memory:"))
449
+ register_adapter(real_host, adapter)
450
+ await real_host.ainvoke("chp.adapters.composition.define", {
451
+ "name": "meta_wf",
452
+ "steps": [{"capability_id": "cap.x", "step_id": "my_step"}],
453
+ })
454
+ adapter._host = FakeHost()
455
+ await real_host.ainvoke("chp.adapters.composition.run", {"name": "meta_wf"})
456
+ completed = [e for e in real_host.store.all()
457
+ if e["event_type"] == "workflow_step_completed"]
458
+ assert completed[0]["payload"]["step_id"] == "my_step"
459
+ assert completed[0]["payload"]["capability_id"] == "cap.x"
460
+ assert "duration_ms" in completed[0]["payload"]
461
+
462
+
463
+ # --------------------------------------------------------------------------
464
+ # 7. on_register hook
465
+ # --------------------------------------------------------------------------
466
+
467
+ class TestOnRegister:
468
+ def test_host_captured_on_register(self):
469
+ adapter = CompositionAdapter()
470
+ fake = FakeHost()
471
+ adapter.on_register(fake)
472
+ assert adapter._host is fake
473
+
474
+ def test_run_fails_without_host(self):
475
+ # When CompositionAdapter is NOT registered with a real host
476
+ # but _host is None, run() raises RuntimeError (failure outcome)
477
+ adapter = CompositionAdapter()
478
+ real_host = LocalCapabilityHost(store=SQLiteEvidenceStore(":memory:"))
479
+ register_adapter(real_host, adapter)
480
+ # Manually clear the host ref
481
+ adapter._host = None
482
+ # define a workflow
483
+ real_host.invoke("chp.adapters.composition.define", {
484
+ "name": "orphan",
485
+ "steps": [{"capability_id": "cap"}],
486
+ })
487
+ r = real_host.invoke("chp.adapters.composition.run", {"name": "orphan"})
488
+ assert r.outcome == "failure"
489
+
490
+
491
+ # --------------------------------------------------------------------------
492
+ # 8. injectable store (CompositionConfig._store)
493
+ # --------------------------------------------------------------------------
494
+
495
+ class TestInjectableStore:
496
+ def test_pre_populated_store(self):
497
+ from chp_adapter_composition.adapter import WorkflowDefinition, WorkflowStep
498
+ preset = {
499
+ "existing": WorkflowDefinition(
500
+ name="existing",
501
+ description="pre-built",
502
+ steps=[WorkflowStep(step_id="s1", capability_id="cap.x", payload={})],
503
+ )
504
+ }
505
+ adapter = CompositionAdapter(CompositionConfig(_store=preset))
506
+ real_host = LocalCapabilityHost(store=SQLiteEvidenceStore(":memory:"))
507
+ register_adapter(real_host, adapter)
508
+ r = real_host.invoke("chp.adapters.composition.get", {"name": "existing"})
509
+ assert r.outcome == "success"
510
+ assert r.data["description"] == "pre-built"
511
+
512
+ def test_store_is_copied_not_shared(self):
513
+ from chp_adapter_composition.adapter import WorkflowDefinition, WorkflowStep
514
+ shared_store: dict = {}
515
+ adapter = CompositionAdapter(CompositionConfig(_store=shared_store))
516
+ real_host = LocalCapabilityHost(store=SQLiteEvidenceStore(":memory:"))
517
+ register_adapter(real_host, adapter)
518
+ # Add to adapter
519
+ real_host.invoke("chp.adapters.composition.define", {
520
+ "name": "added",
521
+ "steps": [{"capability_id": "cap"}],
522
+ })
523
+ # Original dict should be unchanged
524
+ assert "added" not in shared_store