boundflow 0.1.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.
Files changed (53) hide show
  1. boundflow/__init__.py +88 -0
  2. boundflow/_transport.py +213 -0
  3. boundflow/anthropic_client.py +79 -0
  4. boundflow/cli/__init__.py +42 -0
  5. boundflow/cli/__main__.py +5 -0
  6. boundflow/cli/_client.py +42 -0
  7. boundflow/cli/_output.py +90 -0
  8. boundflow/cli/commands/__init__.py +0 -0
  9. boundflow/cli/commands/audit.py +62 -0
  10. boundflow/cli/commands/policies.py +100 -0
  11. boundflow/cli/commands/pricing.py +30 -0
  12. boundflow/cli/commands/tenants.py +63 -0
  13. boundflow/cli/commands/workflows.py +133 -0
  14. boundflow/control_plane.py +680 -0
  15. boundflow/errors.py +79 -0
  16. boundflow/examples/__init__.py +1 -0
  17. boundflow/examples/approval_gate.py +87 -0
  18. boundflow/examples/hello.py +62 -0
  19. boundflow/examples/model_switching.py +69 -0
  20. boundflow/examples/runtime_caps.py +78 -0
  21. boundflow/examples/self_healing.py +78 -0
  22. boundflow/lifecycle.py +138 -0
  23. boundflow/llm.py +415 -0
  24. boundflow/policies.py +128 -0
  25. boundflow/trace.py +272 -0
  26. boundflow/v1/__init__.py +1 -0
  27. boundflow/v1/agent_policy_pb2.py +53 -0
  28. boundflow/v1/agent_policy_pb2_grpc.py +24 -0
  29. boundflow/v1/lifecycle_pb2.py +147 -0
  30. boundflow/v1/lifecycle_pb2_grpc.py +916 -0
  31. boundflow/v1/operation_pb2.py +68 -0
  32. boundflow/v1/operation_pb2_grpc.py +24 -0
  33. boundflow/v1/policy_pb2.py +50 -0
  34. boundflow/v1/policy_pb2_grpc.py +24 -0
  35. boundflow/v1/pricing_pb2.py +37 -0
  36. boundflow/v1/pricing_pb2_grpc.py +24 -0
  37. boundflow/v1/registration_pb2.py +76 -0
  38. boundflow/v1/registration_pb2_grpc.py +452 -0
  39. boundflow/v1/tenant_group_pb2.py +40 -0
  40. boundflow/v1/tenant_group_pb2_grpc.py +24 -0
  41. boundflow/v1/tenant_pb2.py +39 -0
  42. boundflow/v1/tenant_pb2_grpc.py +24 -0
  43. boundflow/v1/worker_pb2.py +52 -0
  44. boundflow/v1/worker_pb2_grpc.py +112 -0
  45. boundflow/v1/workflow_pb2.py +42 -0
  46. boundflow/v1/workflow_pb2_grpc.py +24 -0
  47. boundflow/worker.py +415 -0
  48. boundflow-0.1.0.dist-info/METADATA +22 -0
  49. boundflow-0.1.0.dist-info/RECORD +53 -0
  50. boundflow-0.1.0.dist-info/WHEEL +5 -0
  51. boundflow-0.1.0.dist-info/entry_points.txt +2 -0
  52. boundflow-0.1.0.dist-info/licenses/LICENSE +21 -0
  53. boundflow-0.1.0.dist-info/top_level.txt +1 -0
boundflow/__init__.py ADDED
@@ -0,0 +1,88 @@
1
+ """BoundFlow Python SDK — governance for agentic workflows."""
2
+
3
+ from .anthropic_client import AnthropicLlmClient
4
+ from .control_plane import (
5
+ ControlPlaneClient,
6
+ ApprovalAuditRecord,
7
+ ApprovalDecision,
8
+ PolicyActionRecord,
9
+ WorkflowPolicyAction,
10
+ AgentPolicyActionRecord,
11
+ LifecycleState,
12
+ RequestInfo,
13
+ Run,
14
+ RunOutcome,
15
+ RunStatus,
16
+ Tenant,
17
+ TenantGroup,
18
+ Workflow,
19
+ WorkflowConfig,
20
+ WorkflowState,
21
+ WorkflowSummary,
22
+ )
23
+ from .errors import (
24
+ AlreadyExistsError,
25
+ BoundflowError,
26
+ DeadlineExceededError,
27
+ FailedPreconditionError,
28
+ InvalidArgumentError,
29
+ NotFoundError,
30
+ PermissionDeniedError,
31
+ UnauthenticatedError,
32
+ UnavailableError,
33
+ )
34
+ from .llm import MockLlmClient, MockContext, Turn, turn, submit
35
+ from .trace import (
36
+ AgentRunTrace,
37
+ JsonlFileTraceSink,
38
+ LoggingTraceSink,
39
+ OperationTrace,
40
+ OTelTraceSink,
41
+ Span,
42
+ TraceSink,
43
+ )
44
+ from .policies import (
45
+ AgentMetric,
46
+ AgentRule,
47
+ Cooldown,
48
+ Op,
49
+ Pause,
50
+ RuntimePolicy,
51
+ SetMaxCostUsd,
52
+ SetMaxLlmCalls,
53
+ SetMaxTokensPerCall,
54
+ SetModel,
55
+ SetVersion,
56
+ ToolCallLimit,
57
+ WorkflowMetric,
58
+ WorkflowRule,
59
+ )
60
+ from .worker import (
61
+ AgentDefinition,
62
+ ApprovalRequest,
63
+ AwaitApproval,
64
+ BoundFlowWorker,
65
+ Complete,
66
+ Next,
67
+ OperationContext,
68
+ OperationResult,
69
+ Tool,
70
+ tool,
71
+ )
72
+
73
+ __all__ = [
74
+ "AnthropicLlmClient",
75
+ "ControlPlaneClient", "LifecycleState", "RunStatus", "RunOutcome", "Run", "RequestInfo",
76
+ "Tenant", "TenantGroup", "Workflow",
77
+ "WorkflowConfig", "WorkflowState", "WorkflowSummary", "ApprovalAuditRecord", "ApprovalDecision", "PolicyActionRecord", "WorkflowPolicyAction", "AgentPolicyActionRecord", "MockLlmClient", "MockContext", "Turn",
78
+ "turn", "submit", "AgentMetric", "AgentRule", "Cooldown", "Op", "Pause",
79
+ "RuntimePolicy", "SetMaxCostUsd", "SetMaxLlmCalls", "SetMaxTokensPerCall",
80
+ "SetModel", "SetVersion", "ToolCallLimit", "WorkflowMetric", "WorkflowRule",
81
+ "AgentDefinition", "ApprovalRequest", "AwaitApproval", "BoundFlowWorker",
82
+ "Complete", "Next", "OperationContext", "OperationResult", "Tool", "tool",
83
+ "AgentRunTrace", "OperationTrace", "Span", "TraceSink", "LoggingTraceSink",
84
+ "JsonlFileTraceSink", "OTelTraceSink",
85
+ "BoundflowError", "NotFoundError", "AlreadyExistsError", "InvalidArgumentError",
86
+ "FailedPreconditionError", "PermissionDeniedError", "UnauthenticatedError",
87
+ "UnavailableError", "DeadlineExceededError",
88
+ ]
@@ -0,0 +1,213 @@
1
+ """gRPC transport — worker bidi stream + proto⇄domain conversion.
2
+
3
+ Port of BoundFlow.SDK WorkerClient (the stream loop) and the MapToProto helpers
4
+ in BoundFlowWorker, on top of grpc.aio. The server drives the session with
5
+ Launch/Cancel commands; the worker acks IN_PROGRESS, runs the operation off the
6
+ receive loop, then reports the result and re-arms with ReadyForWork.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import logging
13
+ import uuid
14
+ from typing import Awaitable, Callable
15
+
16
+ import grpc
17
+
18
+ log = logging.getLogger("boundflow.worker")
19
+ from google.protobuf import json_format
20
+ from google.protobuf.struct_pb2 import Struct
21
+
22
+ from boundflow.v1 import agent_policy_pb2 as ap_pb
23
+ from boundflow.v1 import operation_pb2 as op_pb
24
+ from boundflow.v1 import worker_pb2 as wk_pb
25
+ from boundflow.v1 import worker_pb2_grpc as wk_grpc
26
+
27
+ # dispatch: given the launched operation proto, produce the result proto.
28
+ Dispatch = Callable[[op_pb.AtomicOperation], Awaitable[op_pb.AtomicOperationResult]]
29
+
30
+
31
+ def _strip_scheme(addr: str) -> str:
32
+ return addr.split("://", 1)[1] if "://" in addr else addr
33
+
34
+
35
+ def context_to_dict(op: op_pb.AtomicOperation) -> dict:
36
+ """AtomicOperation.context (Struct) → plain dict, camelCase top-level keys
37
+ (matching the .NET JsonFormatter), opaque policy contents preserved."""
38
+ if not op.HasField("context"):
39
+ return {}
40
+ return json_format.MessageToDict(op.context)
41
+
42
+
43
+ def dict_to_struct(d: dict) -> Struct:
44
+ s = Struct()
45
+ s.update(d or {})
46
+ return s
47
+
48
+
49
+ def new_approval_id() -> str:
50
+ return str(uuid.uuid4())
51
+
52
+
53
+ def metrics_to_proto(snapshot: dict) -> op_pb.AgentInvocationMetrics:
54
+ m = op_pb.AgentInvocationMetrics(
55
+ cost_usd=snapshot.get("cost_usd", 0.0),
56
+ llm_calls=snapshot.get("llm_calls", 0),
57
+ tokens_used=snapshot.get("tokens_used", 0),
58
+ ran_at=snapshot.get("ran_at", 0),
59
+ )
60
+ for tool, count in (snapshot.get("calls_per_tool") or {}).items():
61
+ m.calls_per_tool[tool] = count
62
+ for tool, count in (snapshot.get("tool_failure_counts") or {}).items():
63
+ m.tool_failure_counts[tool] = count
64
+ return m
65
+
66
+
67
+ _AGENT_METRIC_PB = {
68
+ "tokens_used": ap_pb.AGENT_METRIC_TOKENS_USED,
69
+ "cost_usd": ap_pb.AGENT_METRIC_COST_USD,
70
+ "llm_calls": ap_pb.AGENT_METRIC_LLM_CALLS,
71
+ "calls_per_tool": ap_pb.AGENT_METRIC_CALLS_PER_TOOL,
72
+ }
73
+ _AGENT_OP_PB = {
74
+ "less_than": ap_pb.AGENT_OP_LT,
75
+ "less_than_or_equal": ap_pb.AGENT_OP_LTE,
76
+ "greater_than": ap_pb.AGENT_OP_GT,
77
+ "greater_than_or_equal": ap_pb.AGENT_OP_GTE,
78
+ "equal": ap_pb.AGENT_OP_EQ,
79
+ }
80
+
81
+
82
+ def _enum_value(v) -> str:
83
+ return getattr(v, "value", v)
84
+
85
+
86
+ def _runtime_policy_to_proto(p) -> ap_pb.AgentRuntimePolicy:
87
+ return ap_pb.AgentRuntimePolicy(
88
+ model=p.model or "",
89
+ max_llm_calls=p.max_llm_calls,
90
+ max_cost_usd=p.max_cost_usd,
91
+ max_tokens_per_call=p.max_tokens_per_call,
92
+ tool_call_limits=[ap_pb.ToolCallLimit(tool=l.tool, max_calls=l.max_calls) for l in p.tool_call_limits],
93
+ )
94
+
95
+
96
+ def _agent_action_to_proto(action) -> ap_pb.AgentRuleAction:
97
+ field = action.field
98
+ if field == "model":
99
+ return ap_pb.AgentRuleAction(field=ap_pb.AGENT_RULE_ACTION_SET_MODEL, model=action.value)
100
+ if field == "max_llm_calls":
101
+ return ap_pb.AgentRuleAction(field=ap_pb.AGENT_RULE_ACTION_SET_MAX_LLM_CALLS, max_llm_calls=action.value)
102
+ if field == "max_cost_usd":
103
+ return ap_pb.AgentRuleAction(field=ap_pb.AGENT_RULE_ACTION_SET_MAX_COST_USD, max_cost_usd=action.value)
104
+ if field == "max_tokens_per_call":
105
+ return ap_pb.AgentRuleAction(field=ap_pb.AGENT_RULE_ACTION_SET_MAX_TOKENS_PER_CALL, max_tokens_per_call=action.value)
106
+ return ap_pb.AgentRuleAction()
107
+
108
+
109
+ def _agent_rule_to_proto(rule) -> ap_pb.AgentRule:
110
+ return ap_pb.AgentRule(
111
+ metric=_AGENT_METRIC_PB.get(_enum_value(rule.metric), ap_pb.AGENT_METRIC_UNSPECIFIED),
112
+ op=_AGENT_OP_PB.get(_enum_value(rule.op), ap_pb.AGENT_OP_UNSPECIFIED),
113
+ threshold=rule.threshold,
114
+ window=rule.window,
115
+ tool=rule.tool or "",
116
+ action=_agent_action_to_proto(rule.action),
117
+ )
118
+
119
+
120
+ def agent_policy_action_to_proto(action: dict) -> ap_pb.AgentPolicyAction:
121
+ """Map the SDK-side agent policy action ({base_policy, effective_policy,
122
+ fired_rules:[(rule, value)]}) to the typed proto for the operation result."""
123
+ return ap_pb.AgentPolicyAction(
124
+ base_policy=_runtime_policy_to_proto(action["base_policy"]),
125
+ effective_policy=_runtime_policy_to_proto(action["effective_policy"]),
126
+ fired_rules=[
127
+ ap_pb.FiredAgentRule(rule=_agent_rule_to_proto(rule), trigger_value=float(value))
128
+ for (rule, value) in action["fired_rules"]
129
+ ],
130
+ )
131
+
132
+
133
+ class WorkerSession:
134
+ """Owns the bidi stream and dispatch loop. One operation in flight at a time."""
135
+
136
+ def __init__(self, address: str, api_key: str, capabilities: list[tuple[str, int]] | None = None) -> None:
137
+ self._target = _strip_scheme(address)
138
+ self._secure = address.startswith("https://")
139
+ self._session_id = str(uuid.uuid4())
140
+ self._write_lock = asyncio.Lock()
141
+ self._metadata = (("x-api-key", api_key),)
142
+ self._capabilities = [
143
+ wk_pb.WorkerCapability(workflow_type=rt, workflow_version=v)
144
+ for rt, v in (capabilities or [])
145
+ ]
146
+
147
+ async def run(self, dispatch: Dispatch) -> None:
148
+ if self._secure:
149
+ channel_ctx = grpc.aio.secure_channel(self._target, grpc.ssl_channel_credentials())
150
+ else:
151
+ channel_ctx = grpc.aio.insecure_channel(self._target)
152
+ async with channel_ctx as channel:
153
+ stub = wk_grpc.WorkerServiceStub(channel)
154
+ call = stub.WorkerSession(metadata=self._metadata)
155
+ await self._write(call, self._ready())
156
+
157
+ op_task: asyncio.Task | None = None
158
+ op_id: str | None = None
159
+
160
+ async for command in call:
161
+ which = command.WhichOneof("payload")
162
+ if which == "launch":
163
+ op = command.launch.operation
164
+ op_id = op.id
165
+ log.debug("launch: op_id=%s workflow_type=%s name=%s version=%d",
166
+ op.id, op.workflow_type, op.name, op.workflow_version)
167
+ # Ack IN_PROGRESS before starting; keep the receive loop free.
168
+ await self._write(call, self._update(op.id, op_pb.OPERATION_STATUS_IN_PROGRESS))
169
+ op_task = asyncio.create_task(self._run_operation(call, op, dispatch))
170
+ elif which == "cancel":
171
+ if op_task is not None and command.cancel.operation_id == op_id:
172
+ op_task.cancel()
173
+ try:
174
+ await op_task
175
+ except asyncio.CancelledError:
176
+ await self._write(call, self._update(op_id, op_pb.OPERATION_STATUS_CANCELLED))
177
+ op_task, op_id = None, None
178
+ await self._write(call, self._ready())
179
+
180
+ async def _run_operation(self, call, op: op_pb.AtomicOperation, dispatch: Dispatch) -> None:
181
+ try:
182
+ result = await dispatch(op)
183
+ except asyncio.CancelledError:
184
+ raise # surfaced to the main loop, which sends CANCELLED
185
+ except Exception as ex: # noqa: BLE001 — report a handler failure
186
+ log.error("operation FAILED: op_id=%s error=%s", op.id, ex, exc_info=True)
187
+ await self._write(call, self._update(op.id, op_pb.OPERATION_STATUS_FAILED, str(ex)))
188
+ await self._write(call, self._ready())
189
+ return
190
+ await self._write(call, wk_pb.WorkerMessage(
191
+ session_id=self._session_id,
192
+ update=wk_pb.OperationUpdate(operation_id=op.id, result=result),
193
+ ))
194
+ await self._write(call, self._ready())
195
+
196
+ async def _write(self, call, msg: wk_pb.WorkerMessage) -> None:
197
+ async with self._write_lock:
198
+ await call.write(msg)
199
+
200
+ def _ready(self) -> wk_pb.WorkerMessage:
201
+ return wk_pb.WorkerMessage(
202
+ session_id=self._session_id,
203
+ ready=wk_pb.ReadyForWork(capabilities=self._capabilities),
204
+ )
205
+
206
+ def _update(self, operation_id: str, status, message: str = "") -> wk_pb.WorkerMessage:
207
+ return wk_pb.WorkerMessage(
208
+ session_id=self._session_id,
209
+ update=wk_pb.OperationUpdate(
210
+ operation_id=operation_id,
211
+ result=op_pb.AtomicOperationResult(status=status, message=message),
212
+ ),
213
+ )
@@ -0,0 +1,79 @@
1
+ """Real Anthropic API client implementing the LlmClient protocol."""
2
+ from __future__ import annotations
3
+
4
+ import anthropic as _anthropic
5
+
6
+ from .llm import LlmClient, LlmRequest, LlmResponse, TextBlock, ToolResultBlock, ToolUseBlock, Usage
7
+
8
+
9
+ def _encode(blocks: list) -> list:
10
+ out = []
11
+ for b in blocks:
12
+ if isinstance(b, TextBlock):
13
+ out.append({"type": "text", "text": b.text})
14
+ elif isinstance(b, ToolUseBlock):
15
+ out.append({"type": "tool_use", "id": b.id, "name": b.name, "input": b.input})
16
+ elif isinstance(b, ToolResultBlock):
17
+ out.append({"type": "tool_result", "tool_use_id": b.tool_use_id,
18
+ "content": b.content, "is_error": b.is_error})
19
+ return out
20
+
21
+
22
+ def _decode(blocks) -> list:
23
+ out = []
24
+ for b in blocks:
25
+ if b.type == "text":
26
+ out.append(TextBlock(b.text))
27
+ elif b.type == "tool_use":
28
+ out.append(ToolUseBlock(b.id, b.name, b.input))
29
+ return out
30
+
31
+
32
+ class AnthropicLlmClient:
33
+ """Wraps anthropic.AsyncAnthropic to implement LlmClient."""
34
+
35
+ def __init__(self, api_key: str) -> None:
36
+ self._client = _anthropic.AsyncAnthropic(api_key=api_key)
37
+
38
+ async def complete(self, request: LlmRequest) -> LlmResponse:
39
+ messages = [
40
+ {"role": m.role, "content": _encode(m.content) if isinstance(m.content, list) else m.content}
41
+ for m in request.messages
42
+ ]
43
+ tools = [
44
+ {"name": t.name, "description": t.description, "input_schema": t.input_schema}
45
+ for t in request.tools
46
+ ]
47
+ # Caching the system block also caches the tools (render order is
48
+ # tools -> system -> messages), so one breakpoint covers the stable prefix.
49
+ system = request.system
50
+ if request.cache:
51
+ system = [{"type": "text", "text": request.system,
52
+ "cache_control": {"type": "ephemeral"}}]
53
+
54
+ kwargs: dict = dict(
55
+ model=request.model,
56
+ max_tokens=request.max_tokens,
57
+ system=system,
58
+ messages=messages,
59
+ tools=tools,
60
+ )
61
+ if request.forced_tool:
62
+ kwargs["tool_choice"] = {"type": "tool", "name": request.forced_tool}
63
+
64
+ resp = await self._client.messages.create(**kwargs)
65
+ stop = resp.stop_reason
66
+ # Treat max_tokens like end_turn so the orchestrator can re-prompt with submit_result.
67
+ if stop == "max_tokens":
68
+ stop = "end_turn"
69
+ u = resp.usage
70
+ return LlmResponse(
71
+ content=_decode(resp.content),
72
+ stop_reason=stop,
73
+ usage=Usage(
74
+ u.input_tokens,
75
+ u.output_tokens,
76
+ getattr(u, "cache_creation_input_tokens", 0) or 0,
77
+ getattr(u, "cache_read_input_tokens", 0) or 0,
78
+ ),
79
+ )
@@ -0,0 +1,42 @@
1
+ """boundflow — BoundFlow control plane CLI."""
2
+
3
+ import typer
4
+
5
+ from boundflow.cli._client import configure
6
+ from boundflow.cli._output import set_json
7
+ from boundflow.cli.commands import audit, policies, pricing, tenants, workflows
8
+
9
+ app = typer.Typer(
10
+ name="boundflow",
11
+ help="BoundFlow control plane CLI — manage workflows, policies, and audit logs.",
12
+ no_args_is_help=True,
13
+ )
14
+
15
+ app.add_typer(tenants.app, name="tenant")
16
+ app.add_typer(workflows.app, name="workflow")
17
+ app.add_typer(policies.app, name="policy")
18
+ app.add_typer(audit.app, name="audit")
19
+ app.add_typer(pricing.app, name="pricing")
20
+
21
+
22
+ @app.callback()
23
+ def root(
24
+ server: str = typer.Option(
25
+ "", "--server", envvar="BOUNDFLOW_SERVER_ADDRESS",
26
+ help="gRPC server address (default: http://localhost:50051)",
27
+ ),
28
+ api_key: str = typer.Option(
29
+ "", "--api-key", envvar="BOUNDFLOW_API_KEY",
30
+ help="BoundFlow API key",
31
+ ),
32
+ json_output: bool = typer.Option(
33
+ False, "--json",
34
+ help="Output raw JSON (useful for scripting)",
35
+ ),
36
+ ):
37
+ set_json(json_output)
38
+ configure(server, api_key)
39
+
40
+
41
+ def main() -> None:
42
+ app()
@@ -0,0 +1,5 @@
1
+ """Enable `python -m boundflow.cli`."""
2
+
3
+ from boundflow.cli import main
4
+
5
+ main()
@@ -0,0 +1,42 @@
1
+ """Async bridge between sync Typer commands and the async ControlPlaneClient."""
2
+
3
+ import asyncio
4
+ import os
5
+
6
+ import typer
7
+
8
+ from boundflow.control_plane import ControlPlaneClient, DEFAULT_SERVER_ADDRESS
9
+
10
+ _server: str = DEFAULT_SERVER_ADDRESS
11
+ _api_key: str = ""
12
+
13
+
14
+ def configure(server: str, api_key: str) -> None:
15
+ # Runs in the root callback for every invocation — including `--help` — so it
16
+ # only resolves config, never fails. The API key is required lazily in cp_call,
17
+ # when a command actually calls the control plane.
18
+ global _server, _api_key
19
+ _server = server or os.environ.get("BOUNDFLOW_SERVER_ADDRESS") or DEFAULT_SERVER_ADDRESS
20
+ _api_key = api_key or os.environ.get("BOUNDFLOW_API_KEY") or ""
21
+
22
+
23
+ def cp_call(fn):
24
+ """Open a ControlPlaneClient, call fn(client), close, return result.
25
+
26
+ Converts any exception into a user-facing error message + Exit(1).
27
+ """
28
+ if not _api_key:
29
+ typer.echo("Error: no API key. Set BOUNDFLOW_API_KEY or pass --api-key.", err=True)
30
+ raise typer.Exit(1)
31
+
32
+ async def _run():
33
+ async with ControlPlaneClient(_server, api_key=_api_key) as cp:
34
+ return await fn(cp)
35
+
36
+ try:
37
+ return asyncio.run(_run())
38
+ except typer.Exit:
39
+ raise
40
+ except Exception as exc:
41
+ typer.echo(f"Error: {exc}", err=True)
42
+ raise typer.Exit(1)
@@ -0,0 +1,90 @@
1
+ """Rich table rendering and --json output helpers."""
2
+
3
+ import json
4
+ from dataclasses import asdict, is_dataclass
5
+ from datetime import datetime
6
+ from enum import Enum
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ console = Console()
13
+ _json_mode: bool = False
14
+
15
+
16
+ def set_json(value: bool) -> None:
17
+ global _json_mode
18
+ _json_mode = value
19
+
20
+
21
+ def _normalize(value):
22
+ """Recursively convert enum instances to their .value, so table display
23
+ and JSON output both show plain strings rather than 'EnumClass.member'."""
24
+ if isinstance(value, Enum):
25
+ return value.value
26
+ if isinstance(value, dict):
27
+ return {k: _normalize(v) for k, v in value.items()}
28
+ if isinstance(value, list):
29
+ return [_normalize(v) for v in value]
30
+ return value
31
+
32
+
33
+ def _to_dict(obj) -> dict:
34
+ if is_dataclass(obj):
35
+ raw = asdict(obj)
36
+ elif isinstance(obj, dict):
37
+ raw = obj
38
+ else:
39
+ raw = vars(obj)
40
+ return _normalize(raw)
41
+
42
+
43
+ def _json_default(o):
44
+ if isinstance(o, datetime):
45
+ return o.isoformat()
46
+ return str(o)
47
+
48
+
49
+ def output(data) -> None:
50
+ """Render a single record (dict/dataclass) or a list of them."""
51
+ if isinstance(data, list):
52
+ rows = [_to_dict(r) for r in data]
53
+ if _json_mode:
54
+ typer.echo(json.dumps(rows, default=_json_default, indent=2))
55
+ else:
56
+ _table(rows)
57
+ else:
58
+ rec = _to_dict(data)
59
+ if _json_mode:
60
+ typer.echo(json.dumps(rec, default=_json_default, indent=2))
61
+ else:
62
+ _record(rec)
63
+
64
+
65
+ def _table(rows: list[dict]) -> None:
66
+ if not rows:
67
+ console.print("[dim]No results.[/dim]")
68
+ return
69
+ t = Table(show_header=True, header_style="bold cyan")
70
+ for key in rows[0]:
71
+ t.add_column(str(key))
72
+ for row in rows:
73
+ t.add_row(*[str(v) if v is not None else "" for v in row.values()])
74
+ console.print(t)
75
+
76
+
77
+ def _record(rec: dict) -> None:
78
+ t = Table(show_header=False, box=None, padding=(0, 1))
79
+ t.add_column("Key", style="bold cyan", no_wrap=True)
80
+ t.add_column("Value")
81
+ for k, v in rec.items():
82
+ t.add_row(str(k), str(v) if v is not None else "")
83
+ console.print(t)
84
+
85
+
86
+ def success(msg: str) -> None:
87
+ if _json_mode:
88
+ typer.echo(json.dumps({"status": "ok", "message": msg}))
89
+ else:
90
+ console.print(f"[green]{msg}[/green]")
File without changes
@@ -0,0 +1,62 @@
1
+ """boundflow audit — approval and policy audit log commands."""
2
+
3
+ from dataclasses import asdict
4
+ from typing import Optional
5
+
6
+ import typer
7
+
8
+ from boundflow.cli._client import cp_call
9
+ from boundflow.cli._output import output
10
+
11
+ app = typer.Typer(help="View approval and policy audit records.")
12
+
13
+
14
+ def _flatten(record) -> dict:
15
+ """Flatten a dataclass to a dict, serialising nested structures to strings."""
16
+ d = asdict(record) if hasattr(record, "__dataclass_fields__") else vars(record)
17
+ return {k: str(v) if isinstance(v, (list, dict)) else v for k, v in d.items()}
18
+
19
+
20
+ @app.command("approvals")
21
+ def approvals(
22
+ workflow_id: str = typer.Argument(..., help="Workflow ID"),
23
+ approval_id: Optional[str] = typer.Option(None, "--approval-id", help="Look up a single approval by ID"),
24
+ ):
25
+ """List approval decisions for a workflow (or fetch one by approval ID)."""
26
+ if approval_id:
27
+ result = cp_call(lambda cp: cp.get_approval_audit_by_id(approval_id))
28
+ if result is None:
29
+ typer.echo(f"No approval found with ID {approval_id}.", err=True)
30
+ raise typer.Exit(1)
31
+ output(_flatten(result))
32
+ else:
33
+ results = cp_call(lambda cp: cp.get_approval_audit(workflow_id))
34
+ output([_flatten(r) for r in results])
35
+
36
+
37
+ @app.command("workflow")
38
+ def workflow_policy(
39
+ workflow_id: str = typer.Argument(..., help="Workflow ID"),
40
+ ):
41
+ """List workflow-lifecycle policy firings for a workflow."""
42
+ results = cp_call(lambda cp: cp.get_workflow_policy_audit(workflow_id))
43
+ output([_flatten(r) for r in results])
44
+
45
+
46
+ @app.command("agent")
47
+ def agent_policy(
48
+ workflow_id: str = typer.Argument(..., help="Workflow ID"),
49
+ agent_name: str = typer.Argument(..., help="Agent name"),
50
+ ):
51
+ """List agent-lifecycle policy firings for a specific agent."""
52
+ results = cp_call(lambda cp: cp.get_agent_policy_audit(workflow_id, agent_name))
53
+ output([_flatten(r) for r in results])
54
+
55
+
56
+ @app.command("log")
57
+ def log(
58
+ workflow_id: Optional[str] = typer.Argument(None, help="Workflow ID (omit for tenant-wide log)"),
59
+ ):
60
+ """Show the unified audit log (approvals + policy firings, newest first)."""
61
+ results = cp_call(lambda cp: cp.get_audit_log(workflow_id or ""))
62
+ output([_flatten(r) for r in results])