subactor-shell 0.2.2__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,287 @@
1
+ """Bounded operational HTTP commands exposed by Subactor Shell."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ from collections.abc import Mapping, Sequence
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any
12
+ from urllib.parse import urlsplit, urlunsplit
13
+
14
+ import httpx
15
+ from rich.console import Console
16
+ from rich.table import Table
17
+ from rich.text import Text
18
+
19
+ from .control_env import apply_control_environment
20
+ from .terminal import canonical_ticket_links, terminal_hyperlinks_enabled
21
+
22
+
23
+ _TERMINAL_TICKET_STATES = frozenset({"done", "completed", "closed", "rejected", "cancelled"})
24
+ _WRITE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
25
+ _URI = re.compile(r"^[a-z][a-z0-9+.-]*://[^\s]+$", re.IGNORECASE)
26
+ _ENDPOINTS = """# Subactor Control API
27
+ GET /health
28
+ GET /api/system/dashboard
29
+ GET /api/plans
30
+ GET /api/integrations
31
+ GET /api/delegation/manager
32
+ GET /api/llm/status
33
+ POST /api/delegation/dispatch
34
+ POST /api/processes/run
35
+
36
+ # Canonical shell
37
+ subactor health
38
+ subactor status
39
+ subactor tickets --open
40
+ subactor plans remote
41
+ subactor uri <uri-process> [json-payload]
42
+ subactor get /api/system/dashboard
43
+ subactor post /api/delegation/dispatch '{}' --confirm EXECUTE
44
+ subactor api GET|POST|PUT|PATCH|DELETE <path> [json-body]
45
+ """
46
+
47
+
48
+ class OperationsError(RuntimeError):
49
+ """Safe user-facing operational command failure."""
50
+
51
+
52
+ def _validated_origin(value: str, label: str) -> str:
53
+ parsed = urlsplit(value.strip())
54
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname:
55
+ raise OperationsError(f"{label} musi być adresem http(s)")
56
+ if parsed.username or parsed.password or parsed.query or parsed.fragment:
57
+ raise OperationsError(f"{label} nie może zawierać credentials, query ani fragmentu")
58
+ path = parsed.path.rstrip("/")
59
+ return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
60
+
61
+
62
+ def _read_token(values: Mapping[str, str]) -> str:
63
+ token = values.get("SUBACTOR_ADMIN_TOKEN", "").strip()
64
+ token_file = values.get("SUBACTOR_ADMIN_TOKEN_FILE", "").strip()
65
+ if token or not token_file:
66
+ return token
67
+ path = Path(token_file).expanduser()
68
+ try:
69
+ if path.stat().st_size > 16_384:
70
+ raise OperationsError("Plik tokenu jest zbyt duży")
71
+ return path.read_text(encoding="utf-8").strip()
72
+ except OSError as exc:
73
+ raise OperationsError("Nie można odczytać pliku tokenu") from exc
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class OperationSettings:
78
+ control_url: str
79
+ planfile_url: str
80
+ token: str
81
+ timeout_seconds: float = 20.0
82
+
83
+ @classmethod
84
+ def from_environment(cls, env: Mapping[str, str] | None = None) -> "OperationSettings":
85
+ if env is None:
86
+ apply_control_environment()
87
+ values = os.environ
88
+ else:
89
+ values = env
90
+ control_url = _validated_origin(
91
+ values.get("SUBACTOR_CONTROL_URL", "http://127.0.0.1:8091"), "SUBACTOR_CONTROL_URL"
92
+ )
93
+ planfile_url = _validated_origin(
94
+ values.get("SUBACTOR_PLANFILE_URL", values.get("PLANFILE_URL", "http://127.0.0.1:8765")),
95
+ "SUBACTOR_PLANFILE_URL",
96
+ )
97
+ return cls(control_url, planfile_url, _read_token(values))
98
+
99
+
100
+ class OperationsClient:
101
+ def __init__(self, settings: OperationSettings, *, transport: httpx.BaseTransport | None = None) -> None:
102
+ self.settings = settings
103
+ self._transport = transport
104
+
105
+ def request(
106
+ self,
107
+ method: str,
108
+ path: str,
109
+ *,
110
+ body: Any = None,
111
+ service: str = "control",
112
+ authenticated: bool = True,
113
+ ) -> tuple[Any, str]:
114
+ verb = method.upper()
115
+ if verb not in {"GET", "POST", "PUT", "PATCH", "DELETE"}:
116
+ raise OperationsError(f"Niedozwolona metoda HTTP: {verb}")
117
+ if not path.startswith("/") or path.startswith("//") or "\n" in path or "\r" in path:
118
+ raise OperationsError("Ścieżka API musi być względna wobec skonfigurowanej usługi")
119
+ if authenticated and not self.settings.token:
120
+ raise OperationsError(
121
+ "Brak SUBACTOR_ADMIN_TOKEN lub SUBACTOR_ADMIN_TOKEN_FILE; operacja nie została wysłana"
122
+ )
123
+ base = self.settings.planfile_url if service == "planfile" else self.settings.control_url
124
+ headers = {"Accept": "application/json"}
125
+ if authenticated:
126
+ headers["Authorization"] = f"Bearer {self.settings.token}"
127
+ try:
128
+ with httpx.Client(timeout=self.settings.timeout_seconds, transport=self._transport) as client:
129
+ response = client.request(verb, f"{base}{path}", json=body, headers=headers)
130
+ except httpx.HTTPError as exc:
131
+ raise OperationsError(f"Usługa {service} jest niedostępna ({exc.__class__.__name__})") from exc
132
+ if response.status_code >= 400:
133
+ code = "request_failed"
134
+ try:
135
+ problem = response.json()
136
+ if isinstance(problem, dict):
137
+ code = str(problem.get("code") or problem.get("type") or code)[:160]
138
+ except ValueError:
139
+ pass
140
+ raise OperationsError(f"{service} odrzucił żądanie: HTTP {response.status_code} ({code})")
141
+ try:
142
+ return response.json(), response.text
143
+ except ValueError:
144
+ return response.text, response.text
145
+
146
+
147
+ def _ticket_open(ticket: Mapping[str, Any]) -> bool:
148
+ return str(ticket.get("status", "")).lower() not in _TERMINAL_TICKET_STATES
149
+
150
+
151
+ def _ticket_urgent(ticket: Mapping[str, Any]) -> bool:
152
+ priority = str(ticket.get("priority", "")).lower()
153
+ labels = {str(value).lower() for value in ticket.get("labels", []) if isinstance(value, str)}
154
+ name = str(ticket.get("name") or ticket.get("title") or "").upper()
155
+ return priority in {"urgent", "critical", "high"} or "urgent" in labels or name.startswith(("PILNE", "URGENT"))
156
+
157
+
158
+ def filter_tickets(rows: Sequence[Mapping[str, Any]], args: Any) -> list[Mapping[str, Any]]:
159
+ result = list(rows)
160
+ if args.open or args.urgent:
161
+ result = [item for item in result if _ticket_open(item)]
162
+ if args.urgent:
163
+ result = [item for item in result if _ticket_urgent(item)]
164
+ filters = {
165
+ "queue": args.queue,
166
+ "state": args.state,
167
+ "priority": args.priority,
168
+ "project": args.project,
169
+ }
170
+ for field, expected in filters.items():
171
+ if not expected:
172
+ continue
173
+ needle = expected.lower()
174
+ if field in {"queue", "state"}:
175
+ result = [item for item in result if needle in str(item.get("execution", {}).get(field, "")).lower()]
176
+ else:
177
+ result = [item for item in result if needle in str(item.get(field, "")).lower()]
178
+ if args.text:
179
+ needle = args.text.lower()
180
+ result = [item for item in result if needle in json.dumps(item, ensure_ascii=False).lower()]
181
+ return sorted(
182
+ result,
183
+ key=lambda item: (_ticket_urgent(item), str(item.get("updated_at") or item.get("created_at") or "")),
184
+ reverse=True,
185
+ )
186
+
187
+
188
+ def _json_payload(raw: str | None) -> Any:
189
+ if raw is None:
190
+ return None
191
+ if len(raw) > 131_072:
192
+ raise OperationsError("Payload JSON jest zbyt duży")
193
+ try:
194
+ return json.loads(raw)
195
+ except json.JSONDecodeError as exc:
196
+ raise OperationsError("Payload musi być poprawnym JSON") from exc
197
+
198
+
199
+ def _require_execute(confirmation: str, operation: str) -> None:
200
+ if confirmation != "EXECUTE":
201
+ raise OperationsError(f"{operation} wymaga --confirm EXECUTE")
202
+
203
+
204
+ def _print_json(console: Console, payload: Any) -> None:
205
+ console.print_json(json.dumps(payload, ensure_ascii=False))
206
+
207
+
208
+ def _print_tickets(console: Console, rows: Sequence[Mapping[str, Any]], control_url: str) -> None:
209
+ table = Table("Ticket", "Priorytet", "Status", "Kolejka", "Stan", "Nazwa")
210
+ hyperlinks = terminal_hyperlinks_enabled(is_terminal=console.is_terminal)
211
+ for item in rows[:50]:
212
+ ticket = str(item.get("id", "?"))
213
+ links = canonical_ticket_links(ticket, control_url, limit=1)
214
+ ticket_text = Text(ticket)
215
+ if links and hyperlinks:
216
+ ticket_text.stylize(f"link {links[0][1]}")
217
+ execution = item.get("execution") if isinstance(item.get("execution"), dict) else {}
218
+ table.add_row(
219
+ ticket_text,
220
+ str(item.get("priority", "normal")),
221
+ str(item.get("status", "?")),
222
+ str(execution.get("queue", "?")),
223
+ str(execution.get("state", "?")),
224
+ str(item.get("name") or item.get("title") or "")[:80],
225
+ )
226
+ console.print(f"{len(rows)} ticket(s)")
227
+ console.print(table)
228
+
229
+
230
+ def run_operational_command(args: Any, console: Console, client: OperationsClient) -> int:
231
+ command = args.command
232
+ if command == "health":
233
+ payload, text = client.request("GET", "/health", authenticated=False)
234
+ _print_json(console, payload) if not isinstance(payload, str) else console.print(text, markup=False)
235
+ return 0
236
+ if command == "status":
237
+ payload, _ = client.request("GET", "/api/system/dashboard")
238
+ _print_json(console, payload)
239
+ return 0
240
+ if command == "tickets":
241
+ payload, _ = client.request("GET", "/tickets?sprint=all", service="planfile")
242
+ rows = payload if isinstance(payload, list) else payload.get("tickets", [])
243
+ filtered = filter_tickets([item for item in rows if isinstance(item, dict)], args)
244
+ _print_json(console, filtered) if args.json else _print_tickets(console, filtered, client.settings.control_url)
245
+ return 0
246
+ if command == "plans":
247
+ payload, _ = client.request("GET", "/api/plans?view=summary")
248
+ plans = payload.get("plans", []) if isinstance(payload, dict) else []
249
+ if args.status:
250
+ plans = [item for item in plans if str(item.get("status", "")) == args.status]
251
+ _print_json(console, plans)
252
+ return 0
253
+ if command == "dispatch":
254
+ _require_execute(args.confirm, "dispatch")
255
+ payload, _ = client.request("POST", "/api/delegation/dispatch", body={})
256
+ _print_json(console, payload)
257
+ return 0
258
+ if command == "uri":
259
+ if not _URI.fullmatch(args.uri):
260
+ raise OperationsError("Nieprawidłowy URI procesu")
261
+ body = _json_payload(args.payload) or {}
262
+ if not isinstance(body, dict):
263
+ raise OperationsError("Payload URI musi być obiektem JSON")
264
+ if body.get("apply") is True:
265
+ _require_execute(args.confirm, "URI apply")
266
+ elif "/command/" in args.uri and "apply" not in body:
267
+ body["apply"] = False
268
+ payload, _ = client.request(
269
+ "POST",
270
+ "/api/processes/run",
271
+ body={"uri": args.uri, "payload": body, "reason": f"Canonical Subactor shell: {args.uri}"},
272
+ )
273
+ _print_json(console, payload)
274
+ return 0
275
+ if command in {"api", "get", "post"}:
276
+ method = args.method.upper() if command == "api" else command.upper()
277
+ path = args.path
278
+ raw_body = getattr(args, "payload", None)
279
+ if method in _WRITE_METHODS:
280
+ _require_execute(args.confirm, f"HTTP {method}")
281
+ payload, text = client.request(method, path, body=_json_payload(raw_body))
282
+ _print_json(console, payload) if not isinstance(payload, str) else console.print(text, markup=False)
283
+ return 0
284
+ if command == "endpoints":
285
+ console.print(_ENDPOINTS, markup=False)
286
+ return 0
287
+ raise OperationsError(f"Nieobsługiwana komenda operacyjna: {command}")
@@ -0,0 +1,324 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ from dataclasses import dataclass
6
+ from typing import Any, Callable
7
+
8
+ from .catalog import IntentCatalog
9
+ from .compiler import CompileError, ExecutionPlan, PlanCompiler, compute_plan_hash
10
+ from .config import AppConfig
11
+ from .connectors import (
12
+ ConnectorError,
13
+ ConnectorExecutor,
14
+ ConnectorRegistry,
15
+ ExecutionReceipt,
16
+ )
17
+ from .models import Session
18
+ from .policy import PolicyEngine
19
+ from .providers import ProviderBundle
20
+ from .routing import Router, RoutingDecision
21
+ from .secret_refs import SecretResolver
22
+ from .store import Store
23
+
24
+
25
+ class OrchestrationError(RuntimeError):
26
+ pass
27
+
28
+
29
+ ProviderBuilder = Callable[[Any, SecretResolver], ProviderBundle]
30
+
31
+
32
+ @dataclass(slots=True)
33
+ class OrchestrationOutcome:
34
+ decision: RoutingDecision
35
+ direct_text: str = ""
36
+ provider: str = ""
37
+ model: str = ""
38
+ route_context: dict[str, Any] | None = None
39
+ plan: ExecutionPlan | None = None
40
+ receipt: ExecutionReceipt | None = None
41
+
42
+
43
+ class OrchestrationService:
44
+ def __init__(
45
+ self,
46
+ config: AppConfig,
47
+ store: Store,
48
+ resolver: SecretResolver,
49
+ provider_builder: ProviderBuilder,
50
+ ):
51
+ self.config = config
52
+ self.store = store
53
+ self.resolver = resolver
54
+ self.provider_builder = provider_builder
55
+ self.catalog = IntentCatalog.load(config.intent_catalog_paths())
56
+ self.registry = ConnectorRegistry(config)
57
+ self.router = Router(
58
+ config,
59
+ store,
60
+ resolver,
61
+ provider_builder,
62
+ self.catalog,
63
+ )
64
+ self.compiler = PlanCompiler()
65
+ self.policy = PolicyEngine(
66
+ allow_destructive=bool(config.orchestration.get("allow_destructive", False))
67
+ )
68
+ self.executor = ConnectorExecutor(config, store, resolver, self.registry)
69
+ self.mode = str(config.orchestration.get("mode", "active")).strip().lower()
70
+
71
+ async def prepare(
72
+ self,
73
+ session: Session,
74
+ text: str,
75
+ *,
76
+ cancel_event=None,
77
+ ) -> OrchestrationOutcome:
78
+ decision = await self.router.route(session, text, cancel_event=cancel_event)
79
+ for record in decision.parser_usage:
80
+ try:
81
+ profile = self.config.provider(record.provider)
82
+ pricing = {
83
+ "input_cost_per_million": profile.input_cost_per_million,
84
+ "cached_input_cost_per_million": profile.cached_input_cost_per_million,
85
+ "output_cost_per_million": profile.output_cost_per_million,
86
+ }
87
+ except (KeyError, ValueError):
88
+ pricing = {}
89
+ self.store.record_provider_usage(
90
+ session.id,
91
+ provider=record.provider,
92
+ model=record.model,
93
+ purpose=record.purpose,
94
+ usage=record.usage,
95
+ metadata={"latency_ms": record.latency_ms},
96
+ **pricing,
97
+ )
98
+ self.store.record_routing_decision(
99
+ session.id,
100
+ route=decision.route,
101
+ reason=decision.reason,
102
+ intent_id=decision.intent_id,
103
+ confidence=decision.confidence,
104
+ provider=decision.provider,
105
+ model=decision.model,
106
+ candidates=[item.to_dict() for item in decision.candidates],
107
+ metadata={
108
+ "parser_errors": decision.parser_errors,
109
+ "cache_hit": decision.cache_hit,
110
+ "orchestration_mode": self.mode,
111
+ },
112
+ )
113
+
114
+ if self.mode == "shadow":
115
+ return OrchestrationOutcome(
116
+ decision=decision,
117
+ provider=self.router.large_provider or session.provider,
118
+ model=self.router.large_model or session.model,
119
+ route_context=decision.route_context() | {"shadow_mode": True},
120
+ )
121
+
122
+ if not decision.intent:
123
+ return OrchestrationOutcome(
124
+ decision=decision,
125
+ provider=decision.provider or session.provider,
126
+ model=decision.model or session.model,
127
+ route_context=decision.route_context(),
128
+ )
129
+
130
+ definition = self.catalog.get(decision.intent.intent_id)
131
+ if not definition:
132
+ return self._fallback(session, decision, "Intent nie istnieje już w katalogu")
133
+ if decision.intent.unresolved:
134
+ missing = ", ".join(decision.intent.unresolved)
135
+ return OrchestrationOutcome(
136
+ decision=decision,
137
+ direct_text=(
138
+ f"Rozpoznałem intent `{decision.intent.intent_id}`, ale brakuje pól: {missing}. "
139
+ "Doprecyzuj je w kolejnej wiadomości."
140
+ ),
141
+ )
142
+ if str(definition.execution.get("kind", "chat")) == "chat":
143
+ return self._fallback(session, decision, "Intent wymaga odpowiedzi konwersacyjnej")
144
+
145
+ try:
146
+ plan = self.compiler.compile(
147
+ session_id=session.id,
148
+ intent=decision.intent,
149
+ definition=definition,
150
+ state_fingerprint=self.current_state_fingerprint(),
151
+ catalog_fingerprint=self.catalog.fingerprint,
152
+ )
153
+ except CompileError as exc:
154
+ return OrchestrationOutcome(
155
+ decision=decision,
156
+ direct_text=f"Nie udało się skompilować IntentIR: {exc}",
157
+ )
158
+
159
+ connector_error = self._preflight_connectors(plan)
160
+ policy = self.policy.evaluate(plan)
161
+ if connector_error:
162
+ plan.status = "blocked"
163
+ self.store.save_execution_plan(plan.to_dict())
164
+ return OrchestrationOutcome(
165
+ decision=decision,
166
+ plan=plan,
167
+ direct_text=(
168
+ self.format_plan(plan)
169
+ + "\n\nPlan jest zablokowany: "
170
+ + connector_error
171
+ + ". Skonfiguruj nazwany connector/operation; bridge nie uruchomi arbitralnego shella."
172
+ ),
173
+ )
174
+ if not policy.allowed:
175
+ plan.status = "blocked"
176
+ self.store.save_execution_plan(plan.to_dict())
177
+ return OrchestrationOutcome(
178
+ decision=decision,
179
+ plan=plan,
180
+ direct_text=self.format_plan(plan) + f"\n\nPlan zablokowany przez politykę: {policy.reason}",
181
+ )
182
+
183
+ if policy.auto_execute:
184
+ plan.status = "running"
185
+ self.store.save_execution_plan(plan.to_dict())
186
+ receipt = await self.executor.execute(plan, approved=False)
187
+ self.store.save_execution_receipt(receipt.to_dict())
188
+ plan.status = "executed" if receipt.ok else "failed"
189
+ self.store.update_plan_status(plan.id, plan.status)
190
+ self.store.record_router_feedback(
191
+ session.id,
192
+ intent_id=plan.intent_id,
193
+ route=decision.route,
194
+ success=receipt.ok,
195
+ metadata={"plan_id": plan.id, "receipt_id": receipt.id},
196
+ )
197
+ return OrchestrationOutcome(
198
+ decision=decision,
199
+ plan=plan,
200
+ receipt=receipt,
201
+ direct_text=self.format_receipt(receipt),
202
+ )
203
+
204
+ plan.status = "pending_approval" if policy.requires_approval else "planned"
205
+ self.store.save_execution_plan(plan.to_dict())
206
+ suffix = (
207
+ f"\n\nAby wykonać plan, użyj `/apply {plan.id}` i wpisz dokładnie `EXECUTE`."
208
+ if policy.requires_approval
209
+ else f"\n\nPlan zapisano. Możesz użyć `/apply {plan.id}`."
210
+ )
211
+ return OrchestrationOutcome(
212
+ decision=decision,
213
+ plan=plan,
214
+ direct_text=self.format_plan(plan) + suffix,
215
+ )
216
+
217
+ async def apply_plan(self, plan_id: str, *, confirmation: str = "") -> ExecutionReceipt:
218
+ payload = self.store.get_execution_plan(plan_id)
219
+ if not payload:
220
+ raise OrchestrationError(f"Nie ma planu {plan_id}")
221
+ plan = ExecutionPlan.from_dict(payload)
222
+ if plan.status not in {"planned", "pending_approval", "failed"}:
223
+ raise OrchestrationError(f"Plan ma status {plan.status} i nie może zostać zastosowany")
224
+ if plan.plan_hash != compute_plan_hash(plan):
225
+ raise OrchestrationError("Plan hash nie zgadza się z zapisaną treścią planu")
226
+ if plan.effect != "read" and confirmation != "EXECUTE":
227
+ raise OrchestrationError("Operacja zmienia stan; wymagane jest dokładne potwierdzenie EXECUTE")
228
+ current = self.current_state_fingerprint()
229
+ if current != plan.state_fingerprint:
230
+ raise OrchestrationError(
231
+ "Lokalny stan/katalog/connector registry zmienił się od utworzenia planu; utwórz nowy plan"
232
+ )
233
+ connector_error = self._preflight_connectors(plan)
234
+ if connector_error:
235
+ raise OrchestrationError(connector_error)
236
+ policy = self.policy.evaluate(plan)
237
+ if not policy.allowed:
238
+ raise OrchestrationError(policy.reason)
239
+ self.store.update_plan_status(plan.id, "running")
240
+ receipt = await self.executor.execute(plan, approved=True)
241
+ self.store.save_execution_receipt(receipt.to_dict())
242
+ self.store.update_plan_status(plan.id, "executed" if receipt.ok else "failed")
243
+ self.store.record_router_feedback(
244
+ plan.session_id,
245
+ intent_id=plan.intent_id,
246
+ route="approved_plan",
247
+ success=receipt.ok,
248
+ metadata={"plan_id": plan.id, "receipt_id": receipt.id},
249
+ )
250
+ return receipt
251
+
252
+ def current_state_fingerprint(self) -> str:
253
+ encoded = ":".join(
254
+ [self.store.state_fingerprint(), self.catalog.fingerprint, self.registry.fingerprint]
255
+ )
256
+ return hashlib.sha256(encoded.encode("ascii")).hexdigest()
257
+
258
+ def _preflight_connectors(self, plan: ExecutionPlan) -> str:
259
+ try:
260
+ for step in plan.steps:
261
+ self.registry.validate_step(step)
262
+ except ConnectorError as exc:
263
+ return str(exc)
264
+ return ""
265
+
266
+ def _fallback(
267
+ self, session: Session, decision: RoutingDecision, reason: str
268
+ ) -> OrchestrationOutcome:
269
+ provider = decision.provider or self.router.large_provider or session.provider
270
+ model = decision.model or self.router.large_model
271
+ if not model:
272
+ try:
273
+ model = self.config.provider(provider).model
274
+ except (KeyError, ValueError):
275
+ model = session.model
276
+ context = decision.route_context()
277
+ context["fallback_reason"] = reason
278
+ return OrchestrationOutcome(
279
+ decision=decision,
280
+ provider=provider,
281
+ model=model,
282
+ route_context=context,
283
+ )
284
+
285
+ @staticmethod
286
+ def format_plan(plan: ExecutionPlan) -> str:
287
+ lines = [
288
+ f"Plan `{plan.id}`",
289
+ f"intent: `{plan.intent_id}`",
290
+ f"effect: `{plan.effect}` · status: `{plan.status}`",
291
+ f"hash: `{plan.plan_hash}`",
292
+ "kroki:",
293
+ ]
294
+ for index, step in enumerate(plan.steps, start=1):
295
+ args = json.dumps(step.args, ensure_ascii=False, separators=(",", ":"))
296
+ if len(args) > 500:
297
+ args = args[:500] + "…"
298
+ lines.append(f"{index}. `{step.connector}:{step.operation}` ({step.effect}) args={args}")
299
+ return "\n".join(lines)
300
+
301
+ @staticmethod
302
+ def format_receipt(receipt: ExecutionReceipt) -> str:
303
+ # Builtin help is more useful as plain text than as nested JSON.
304
+ if len(receipt.steps) == 1 and receipt.steps[0].get("ok"):
305
+ result = receipt.steps[0].get("result")
306
+ if isinstance(result, dict) and isinstance(result.get("message"), str):
307
+ return result["message"] + f"\n\nReceipt `{receipt.id}`"
308
+ lines = [
309
+ f"Receipt `{receipt.id}` · plan `{receipt.plan_id}`",
310
+ f"wynik: `{'ok' if receipt.ok else 'failed'}`",
311
+ receipt.summary,
312
+ ]
313
+ for step in receipt.steps:
314
+ state = "ok" if step.get("ok") else "failed"
315
+ lines.append(f"- `{step.get('connector')}:{step.get('operation')}`: {state}")
316
+ result = step.get("result")
317
+ if isinstance(result, dict) and result:
318
+ encoded = json.dumps(result, ensure_ascii=False, default=str)
319
+ if len(encoded) > 1500:
320
+ encoded = encoded[:1500] + "…"
321
+ lines.append(f" {encoded}")
322
+ if step.get("error"):
323
+ lines.append(f" {str(step['error'])[:1000]}")
324
+ return "\n".join(lines)
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+
6
+ from .compiler import ExecutionPlan
7
+
8
+
9
+ @dataclass(slots=True)
10
+ class PolicyDecision:
11
+ allowed: bool
12
+ reason: str
13
+ requires_approval: bool = False
14
+ auto_execute: bool = False
15
+
16
+
17
+ class PolicyEngine:
18
+ def __init__(self, *, allow_destructive: bool = False):
19
+ self.allow_destructive = allow_destructive
20
+
21
+ def evaluate(self, plan: ExecutionPlan) -> PolicyDecision:
22
+ encoded = json.dumps(plan.to_dict(), ensure_ascii=False).casefold()
23
+ if "{{secret:" in encoded or "vault://" in encoded or "env://" in encoded or "file://" in encoded:
24
+ return PolicyDecision(
25
+ False,
26
+ "Plan nie może przenosić wartości ani referencji sekretów; connector może użyć wyłącznie lokalnego env_ref z konfiguracji",
27
+ )
28
+ if plan.effect == "destructive" and not self.allow_destructive:
29
+ return PolicyDecision(False, "Operacje destructive są wyłączone przez politykę")
30
+ if plan.effect == "read":
31
+ if plan.mode == "execute":
32
+ return PolicyDecision(True, "Odczyt może zostać wykonany lokalnie", auto_execute=True)
33
+ return PolicyDecision(True, "Użytkownik zażądał tylko planu odczytu")
34
+ return PolicyDecision(
35
+ True,
36
+ "Operacja zmienia stan i wymaga jawnego grantu apply",
37
+ requires_approval=True,
38
+ )