logplane 0.13.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.
logplane/__init__.py ADDED
@@ -0,0 +1,113 @@
1
+ """Stable public integration SDK for logplane.
2
+
3
+ Application code should import from this package (or ``logplane.adapters``
4
+ and ``logplane.protocol``) rather than from implementation modules.
5
+ """
6
+
7
+ from logplane.adoption import (
8
+ AdoptionBridge,
9
+ AdoptionMode,
10
+ CanarySelector,
11
+ ExecutionOwner,
12
+ ExternalOrigin,
13
+ OwnershipDecision,
14
+ decide_ownership,
15
+ )
16
+ from logplane.artifacts import (
17
+ ArtifactError,
18
+ ArtifactIntegrityError,
19
+ ArtifactStore,
20
+ validate_artifact_ref,
21
+ )
22
+ from logplane.client import BusClient, BusProtocolError
23
+ from logplane.conformance import (
24
+ ConformanceCheck,
25
+ ConformanceReport,
26
+ check_executor,
27
+ probe_assignment,
28
+ )
29
+ from logplane.executor_protocol import (
30
+ CURRENT_PROTOCOL_VERSION,
31
+ PROTOCOL_NAME,
32
+ SUPPORTED_PROTOCOL_VERSIONS,
33
+ assignment_message,
34
+ cancellation_message,
35
+ outcome_message,
36
+ parse_assignment_message,
37
+ parse_outcome_message,
38
+ )
39
+ from logplane.executors import (
40
+ AssignmentContext,
41
+ Blocked,
42
+ Completed,
43
+ Executor,
44
+ Outcome,
45
+ PermanentFailure,
46
+ RetryableFailure,
47
+ )
48
+ from logplane.integration import (
49
+ CliAgentAdapter,
50
+ CliAgentConfig,
51
+ HttpAgentAdapter,
52
+ HttpAgentConfig,
53
+ IntegrationConfig,
54
+ PythonAgentAdapter,
55
+ load_python_target,
56
+ )
57
+ from logplane.runtime import WorkerRuntime
58
+ from logplane.scheduling import (
59
+ DEFAULT_TASK_PRIORITY,
60
+ TASK_PRIORITY_CLASSES,
61
+ validate_task_priority,
62
+ )
63
+ from logplane.telemetry import BusTelemetrySink, ProducerIdentity, TelemetrySink
64
+
65
+
66
+ __all__ = [
67
+ "AdoptionBridge",
68
+ "AdoptionMode",
69
+ "ArtifactError",
70
+ "ArtifactIntegrityError",
71
+ "ArtifactStore",
72
+ "AssignmentContext",
73
+ "Blocked",
74
+ "BusClient",
75
+ "BusProtocolError",
76
+ "BusTelemetrySink",
77
+ "CURRENT_PROTOCOL_VERSION",
78
+ "CanarySelector",
79
+ "CliAgentAdapter",
80
+ "CliAgentConfig",
81
+ "Completed",
82
+ "ConformanceCheck",
83
+ "ConformanceReport",
84
+ "ExecutionOwner",
85
+ "Executor",
86
+ "ExternalOrigin",
87
+ "HttpAgentAdapter",
88
+ "HttpAgentConfig",
89
+ "IntegrationConfig",
90
+ "Outcome",
91
+ "OwnershipDecision",
92
+ "PROTOCOL_NAME",
93
+ "PermanentFailure",
94
+ "ProducerIdentity",
95
+ "PythonAgentAdapter",
96
+ "RetryableFailure",
97
+ "SUPPORTED_PROTOCOL_VERSIONS",
98
+ "DEFAULT_TASK_PRIORITY",
99
+ "TASK_PRIORITY_CLASSES",
100
+ "TelemetrySink",
101
+ "WorkerRuntime",
102
+ "assignment_message",
103
+ "cancellation_message",
104
+ "check_executor",
105
+ "decide_ownership",
106
+ "load_python_target",
107
+ "outcome_message",
108
+ "parse_assignment_message",
109
+ "parse_outcome_message",
110
+ "probe_assignment",
111
+ "validate_task_priority",
112
+ "validate_artifact_ref",
113
+ ]
logplane/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow ``python -m logplane`` as an alternative to the ``logplane`` command."""
2
+
3
+ from logplane.cli import main
4
+
5
+ raise SystemExit(main())
@@ -0,0 +1,209 @@
1
+ """Internal process boundary for CLI probes; not an adapter security sandbox."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import math
8
+ import os
9
+ from pathlib import Path
10
+ import signal
11
+ import stat
12
+ import subprocess
13
+ import sys
14
+ import tempfile
15
+ import threading
16
+ import time
17
+
18
+ DEFAULT_CHECK_TIMEOUT = 30.0
19
+ MAX_REPORT_BYTES = 64 * 1024
20
+ CLEANUP_GRACE_SECONDS = 0.25
21
+ GROUP_INSPECTION_SECONDS = 1.0
22
+
23
+
24
+ def _darwin_group_exited(process: subprocess.Popen) -> bool:
25
+ """Prove a denied Darwin group signal has no executing target left.
26
+
27
+ XNU killpg1 filters SZOMB members before counting signalable processes, so
28
+ a zombie-only group can return EPERM rather than ESRCH. Never interpret
29
+ EPERM alone (or just the leader's exit) as successful descendant cleanup.
30
+ """
31
+ if sys.platform != 'darwin' or process.poll() is None:
32
+ return False
33
+ try:
34
+ result = subprocess.run(
35
+ ['/bin/ps', '-A', '-o', 'pgid=,stat='], stdin=subprocess.DEVNULL,
36
+ capture_output=True, text=True, timeout=GROUP_INSPECTION_SECONDS,
37
+ env={'LC_ALL': 'C', 'PATH': '/usr/bin:/bin'})
38
+ if result.returncode != 0 or not result.stdout.strip():
39
+ return False
40
+ for line in result.stdout.splitlines():
41
+ fields = line.split()
42
+ if len(fields) != 2 or not fields[0].isdigit():
43
+ return False
44
+ if int(fields[0]) == process.pid and not fields[1].startswith('Z'):
45
+ return False
46
+ return True
47
+ except (OSError, subprocess.TimeoutExpired, UnicodeError):
48
+ return False
49
+
50
+
51
+ def _signal_group(process: subprocess.Popen, sig: int) -> bool:
52
+ """Return false for an absent/exited group; fail closed on denied cleanup."""
53
+ try:
54
+ os.killpg(process.pid, sig)
55
+ return True
56
+ except ProcessLookupError:
57
+ return False
58
+ except PermissionError as exc:
59
+ if _darwin_group_exited(process):
60
+ return False
61
+ raise PermissionError(
62
+ f'adapter cleanup could not signal process group {process.pid}; '
63
+ 'no-live-members verification failed; child processes may remain') from exc
64
+
65
+
66
+ def _stop_group(process: subprocess.Popen) -> None:
67
+ # Do not skip group cleanup when its leader has already exited: an adapter
68
+ # may have left a child alive. Only our newly-created process group is used.
69
+ if not _signal_group(process, signal.SIGTERM):
70
+ process.wait(timeout=1)
71
+ return
72
+ try:
73
+ process.wait(timeout=CLEANUP_GRACE_SECONDS)
74
+ except subprocess.TimeoutExpired:
75
+ pass
76
+ # A child can ignore TERM even if the leader exits promptly.
77
+ _signal_group(process, signal.SIGKILL)
78
+ process.wait(timeout=1)
79
+
80
+
81
+ def run_adapter_check(*, python_target: str | None = None,
82
+ integration_config: str | None = None,
83
+ timeout: float = DEFAULT_CHECK_TIMEOUT):
84
+ """Bound loading, execution, and close; return the ordinary conformance report.
85
+
86
+ Library callers with already-created executors still use the synchronous
87
+ conformance.check_executor(). This internal helper loads CLI targets afresh.
88
+ """
89
+ from logplane.conformance import ConformanceCheck, ConformanceReport
90
+
91
+ if bool(python_target) == bool(integration_config):
92
+ raise ValueError('choose exactly one Python target or integration config')
93
+ if isinstance(timeout, bool) or not math.isfinite(timeout) or timeout <= 0:
94
+ raise ValueError('adapter check timeout must be a positive finite number')
95
+
96
+ def failed(name, detail):
97
+ return ConformanceReport((ConformanceCheck(name, False, detail),))
98
+
99
+ def interrupted(*_):
100
+ raise KeyboardInterrupt
101
+
102
+ main_thread = threading.current_thread() is threading.main_thread()
103
+ previous = signal.getsignal(signal.SIGTERM) if main_thread else None
104
+ process = None
105
+ with tempfile.TemporaryDirectory(prefix='logplane-adapter-check-') as tmp:
106
+ report_path = Path(tmp) / 'report.json'
107
+ args = [sys.executable, '-m', 'logplane.adapter_check', '--report', str(report_path)]
108
+ # Resolve/read the adapter configuration only in the timed child.
109
+ args += (['--python-target', python_target] if python_target
110
+ else ['--config', str(integration_config)])
111
+ try:
112
+ if main_thread:
113
+ signal.signal(signal.SIGTERM, interrupted)
114
+ deadline = time.monotonic() + timeout
115
+ process = subprocess.Popen(
116
+ args, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
117
+ stderr=subprocess.DEVNULL, start_new_session=True)
118
+ try:
119
+ process.wait(timeout=max(0, deadline - time.monotonic()))
120
+ except subprocess.TimeoutExpired:
121
+ return failed('probe timeout',
122
+ f'adapter check exceeded {timeout:g}s during loading, execution, '
123
+ 'or cleanup; check for blocking hooks or use --timeout with '
124
+ 'a larger value for an intentionally slow trusted probe')
125
+ if process.returncode != 0:
126
+ return failed('probe process',
127
+ f'adapter check process exited with status {process.returncode} '
128
+ 'before a valid result; check adapter startup and native dependencies')
129
+ try:
130
+ # A broken adapter must not turn the result channel into a
131
+ # blocking FIFO/device or a symlink after it exits.
132
+ descriptor = os.open(report_path, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW)
133
+ with os.fdopen(descriptor, 'rb') as stream:
134
+ if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
135
+ raise ValueError('report is not a regular file')
136
+ raw = stream.read(MAX_REPORT_BYTES + 1)
137
+ if len(raw) > MAX_REPORT_BYTES:
138
+ raise ValueError('oversized report')
139
+ envelope = json.loads(raw)
140
+ if envelope.get('kind') == 'error':
141
+ error = envelope['message']
142
+ if not isinstance(error, str):
143
+ raise ValueError('invalid error')
144
+ else:
145
+ error = None
146
+ value = envelope['report']
147
+ checks = value['checks']
148
+ outcome = value['outcome_type']
149
+ if (envelope.get('kind') != 'report' or not isinstance(checks, list)
150
+ or not checks or (outcome is not None and not isinstance(outcome, str))):
151
+ raise ValueError('invalid report')
152
+ decoded = []
153
+ for check in checks:
154
+ if (set(check) != {'name', 'ok', 'detail', 'required'}
155
+ or not isinstance(check['name'], str)
156
+ or not isinstance(check['detail'], str)
157
+ or type(check['ok']) is not bool
158
+ or type(check['required']) is not bool):
159
+ raise ValueError('invalid check')
160
+ decoded.append(ConformanceCheck(**check))
161
+ except (OSError, ValueError, KeyError, TypeError, AttributeError):
162
+ return failed('probe report', 'adapter check returned a missing, oversized, or invalid report')
163
+ if error is not None:
164
+ raise ValueError(error)
165
+ return ConformanceReport(tuple(decoded), outcome)
166
+ finally:
167
+ try:
168
+ if process is not None:
169
+ _stop_group(process)
170
+ finally:
171
+ if main_thread:
172
+ signal.signal(signal.SIGTERM, previous)
173
+
174
+
175
+ def _child(args) -> None:
176
+ # All adapter-controlled hooks, including imports and constructors, stay
177
+ # within this process and therefore within the parent's deadline.
178
+ from logplane.conformance import check_executor
179
+ from logplane.integration import IntegrationConfig, PythonAgentAdapter, load_python_target
180
+
181
+ try:
182
+ if args.config:
183
+ executor = IntegrationConfig.from_file(args.config).build_executor()
184
+ else:
185
+ target = load_python_target(args.python_target)
186
+ executor = target if callable(getattr(target, 'execute', None)) else PythonAgentAdapter(target)
187
+ envelope = {'kind': 'report', 'report': check_executor(executor).to_dict()}
188
+ except Exception as exc:
189
+ envelope = {'kind': 'error', 'message': f'{type(exc).__name__}: {exc}'[:2048]}
190
+ encoded = json.dumps(envelope, allow_nan=False).encode('utf-8')
191
+ if len(encoded) > MAX_REPORT_BYTES:
192
+ envelope = {'kind': 'report', 'report': {
193
+ 'outcome_type': None,
194
+ 'checks': [{'name': 'probe report', 'ok': False, 'required': True,
195
+ 'detail': 'adapter check report exceeds the 64 KiB limit'}]}}
196
+ encoded = json.dumps(envelope).encode('utf-8')
197
+ # The enclosing directory is private. Direct adapter stdout/stderr are
198
+ # discarded, so noise or inherited pipe handles cannot corrupt/block JSON.
199
+ with open(args.report, 'xb') as stream:
200
+ stream.write(encoded)
201
+
202
+
203
+ if __name__ == '__main__':
204
+ parser = argparse.ArgumentParser(description=__doc__)
205
+ parser.add_argument('--report', required=True)
206
+ group = parser.add_mutually_exclusive_group(required=True)
207
+ group.add_argument('--python-target')
208
+ group.add_argument('--config')
209
+ _child(parser.parse_args())
logplane/adapters.py ADDED
@@ -0,0 +1,21 @@
1
+ """Public adapter imports kept separate for discoverability."""
2
+
3
+ from logplane.integration import (
4
+ CliAgentAdapter,
5
+ CliAgentConfig,
6
+ HttpAgentAdapter,
7
+ HttpAgentConfig,
8
+ IntegrationConfig,
9
+ PythonAgentAdapter,
10
+ load_python_target,
11
+ )
12
+
13
+ __all__ = [
14
+ "CliAgentAdapter",
15
+ "CliAgentConfig",
16
+ "HttpAgentAdapter",
17
+ "HttpAgentConfig",
18
+ "IntegrationConfig",
19
+ "PythonAgentAdapter",
20
+ "load_python_target",
21
+ ]
logplane/adoption.py ADDED
@@ -0,0 +1,231 @@
1
+ """Safe rollout helpers for controlled, shadow, and canary adoption."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import math
8
+ from dataclasses import dataclass
9
+ from enum import Enum
10
+ from typing import Any, Iterable, Mapping, Optional
11
+
12
+ from logplane.client import BusClient
13
+ from logplane.scheduling import validate_task_priority
14
+
15
+
16
+ class AdoptionMode(str, Enum):
17
+ CONTROLLED = "controlled"
18
+ SHADOW = "shadow"
19
+ CANARY = "canary"
20
+
21
+
22
+ class ExecutionOwner(str, Enum):
23
+ # Stored in event history under the pre-0.13 project name; keep the
24
+ # value unchanged so existing logs replay with the same ownership.
25
+ AGENT_BUS = "agent-bus"
26
+ EXTERNAL = "external"
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class ExternalOrigin:
31
+ system: str
32
+ task_ref: str
33
+
34
+ def __post_init__(self) -> None:
35
+ for field_name, value in (
36
+ ("system", self.system),
37
+ ("task_ref", self.task_ref),
38
+ ):
39
+ if not isinstance(value, str) or not value.strip():
40
+ raise ValueError(f"{field_name} must be a non-empty string")
41
+ if len(value) > 128:
42
+ raise ValueError(f"{field_name} must not exceed 128 characters")
43
+
44
+ @property
45
+ def key(self) -> str:
46
+ return json.dumps(
47
+ [self.system, self.task_ref],
48
+ ensure_ascii=False,
49
+ separators=(",", ":"),
50
+ )
51
+
52
+ def to_dict(self) -> dict:
53
+ return {"system": self.system, "task_ref": self.task_ref}
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class OwnershipDecision:
58
+ mode: AdoptionMode
59
+ owner: ExecutionOwner
60
+
61
+ def to_dict(self) -> dict:
62
+ return {"mode": self.mode.value, "owner": self.owner.value}
63
+
64
+
65
+ class CanarySelector:
66
+ """Deterministically select external tasks using a stable hash."""
67
+
68
+ def __init__(
69
+ self,
70
+ percentage: float,
71
+ *,
72
+ namespace: str = "default",
73
+ include_refs: Iterable[str] = (),
74
+ ):
75
+ if (
76
+ not isinstance(percentage, (int, float))
77
+ or isinstance(percentage, bool)
78
+ or not 0 <= percentage <= 100
79
+ ):
80
+ raise ValueError("percentage must be between 0 and 100")
81
+ if not isinstance(namespace, str) or not namespace.strip():
82
+ raise ValueError("namespace must be a non-empty string")
83
+ self.basis_points = round(float(percentage) * 100)
84
+ self.namespace = namespace
85
+ if isinstance(include_refs, (str, bytes)):
86
+ raise ValueError("include_refs must be an iterable of task references")
87
+ self.include_refs = frozenset(include_refs)
88
+ if not all(isinstance(item, str) and item for item in self.include_refs):
89
+ raise ValueError("include_refs must contain non-empty strings")
90
+
91
+ def selects(self, origin: ExternalOrigin) -> bool:
92
+ if origin.task_ref in self.include_refs:
93
+ return True
94
+ digest = hashlib.sha256(
95
+ json.dumps(
96
+ [self.namespace, origin.system, origin.task_ref],
97
+ ensure_ascii=False,
98
+ separators=(",", ":"),
99
+ ).encode("utf-8")
100
+ ).digest()
101
+ bucket = int.from_bytes(digest[:8], "big") % 10_000
102
+ return bucket < self.basis_points
103
+
104
+
105
+ def decide_ownership(
106
+ mode: AdoptionMode,
107
+ origin: ExternalOrigin,
108
+ selector: Optional[CanarySelector] = None,
109
+ ) -> OwnershipDecision:
110
+ if not isinstance(mode, AdoptionMode):
111
+ mode = AdoptionMode(mode)
112
+ if mode is AdoptionMode.CONTROLLED:
113
+ return OwnershipDecision(mode, ExecutionOwner.AGENT_BUS)
114
+ if mode is AdoptionMode.SHADOW:
115
+ return OwnershipDecision(mode, ExecutionOwner.EXTERNAL)
116
+ if selector is None:
117
+ raise ValueError("canary mode requires a CanarySelector")
118
+ owner = (
119
+ ExecutionOwner.AGENT_BUS
120
+ if selector.selects(origin)
121
+ else ExecutionOwner.EXTERNAL
122
+ )
123
+ return OwnershipDecision(mode, owner)
124
+
125
+
126
+ class AdoptionBridge:
127
+ """Record one immutable ownership decision for external work.
128
+
129
+ The same actor/idempotency key is used whether the decision is bus-owned
130
+ or external. Re-evaluating an origin to a different owner therefore causes
131
+ an idempotency conflict instead of silently creating dual ownership.
132
+ """
133
+
134
+ def __init__(self, bus: BusClient):
135
+ self.bus = bus
136
+
137
+ def adopt(
138
+ self,
139
+ *,
140
+ origin: ExternalOrigin,
141
+ title: str,
142
+ mode: AdoptionMode,
143
+ selector: Optional[CanarySelector] = None,
144
+ context: Optional[Mapping[str, Any]] = None,
145
+ required_capabilities: Iterable[str] = (),
146
+ max_retries: Optional[int] = None,
147
+ priority: Optional[str] = None,
148
+ not_before: Optional[float] = None,
149
+ deadline_at: Optional[float] = None,
150
+ correlation_id: Optional[str] = None,
151
+ ) -> dict:
152
+ if not isinstance(title, str) or not title.strip():
153
+ raise ValueError("title must be a non-empty string")
154
+ normalized_context = self._json_object(
155
+ context if context is not None else {},
156
+ "context",
157
+ )
158
+ capabilities = tuple(dict.fromkeys(required_capabilities))
159
+ if not all(isinstance(item, str) and item.strip() for item in capabilities):
160
+ raise ValueError("required_capabilities must contain non-empty strings")
161
+ if max_retries is not None and (
162
+ not isinstance(max_retries, int)
163
+ or isinstance(max_retries, bool)
164
+ or max_retries < 0
165
+ ):
166
+ raise ValueError("max_retries must be a non-negative integer")
167
+ if priority is not None:
168
+ priority = validate_task_priority(priority)
169
+ if not_before is not None and (
170
+ not isinstance(not_before, (int, float))
171
+ or isinstance(not_before, bool)
172
+ or not math.isfinite(not_before)
173
+ or not_before <= 0
174
+ ):
175
+ raise ValueError("not_before must be a positive finite timestamp")
176
+ if deadline_at is not None and (
177
+ not isinstance(deadline_at, (int, float))
178
+ or isinstance(deadline_at, bool)
179
+ or not math.isfinite(deadline_at)
180
+ or deadline_at <= 0
181
+ ):
182
+ raise ValueError("deadline_at must be a positive finite timestamp")
183
+ if (
184
+ not_before is not None
185
+ and deadline_at is not None
186
+ and not_before >= deadline_at
187
+ ):
188
+ raise ValueError("not_before must be earlier than deadline_at")
189
+
190
+ decision = decide_ownership(mode, origin, selector)
191
+ payload = {
192
+ "title": title,
193
+ "context": normalized_context,
194
+ "required_capabilities": list(capabilities),
195
+ "external_origin": origin.to_dict(),
196
+ "ownership": decision.to_dict(),
197
+ }
198
+ if max_retries is not None:
199
+ payload["retry_policy"] = {"max_retries": max_retries}
200
+ if priority is not None:
201
+ payload["priority"] = priority
202
+ if not_before is not None:
203
+ payload["not_before"] = float(not_before)
204
+ if deadline_at is not None:
205
+ payload["deadline_at"] = float(deadline_at)
206
+
207
+ digest = hashlib.sha256(origin.key.encode("utf-8")).hexdigest()
208
+ idempotency_key = f"adopt:{digest}"
209
+ if decision.owner is ExecutionOwner.AGENT_BUS:
210
+ topic = "task.created"
211
+ effective_correlation = correlation_id
212
+ else:
213
+ topic = "integration.task_observed"
214
+ effective_correlation = correlation_id or f"external:{digest[:24]}"
215
+ return self.bus.publish(
216
+ topic,
217
+ payload,
218
+ idempotency_key=idempotency_key,
219
+ correlation_id=effective_correlation,
220
+ )
221
+
222
+ @staticmethod
223
+ def _json_object(value: object, field_name: str) -> dict:
224
+ if not isinstance(value, Mapping):
225
+ raise ValueError(f"{field_name} must be a JSON object")
226
+ try:
227
+ return json.loads(json.dumps(dict(value), allow_nan=False))
228
+ except (TypeError, ValueError) as exc:
229
+ raise ValueError(
230
+ f"{field_name} must contain JSON-compatible values"
231
+ ) from exc