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 +113 -0
- logplane/__main__.py +5 -0
- logplane/adapter_check.py +209 -0
- logplane/adapters.py +21 -0
- logplane/adoption.py +231 -0
- logplane/artifacts.py +187 -0
- logplane/bus.py +2065 -0
- logplane/cli.py +1303 -0
- logplane/client.py +566 -0
- logplane/conformance.py +203 -0
- logplane/demo.py +1075 -0
- logplane/executor_protocol.py +120 -0
- logplane/executors.py +779 -0
- logplane/integration.py +574 -0
- logplane/legacy.py +46 -0
- logplane/limits.py +10 -0
- logplane/local_config.py +251 -0
- logplane/observer.py +169 -0
- logplane/operations.py +1022 -0
- logplane/operator_controls.py +101 -0
- logplane/pm_agent.py +917 -0
- logplane/projection.py +1793 -0
- logplane/projection_store.py +282 -0
- logplane/protocol.py +23 -0
- logplane/recovery.py +281 -0
- logplane/runtime.py +772 -0
- logplane/scheduling.py +31 -0
- logplane/telemetry.py +369 -0
- logplane/topics.py +54 -0
- logplane/version.py +3 -0
- logplane/worker.py +124 -0
- logplane-0.13.0.dist-info/METADATA +359 -0
- logplane-0.13.0.dist-info/RECORD +37 -0
- logplane-0.13.0.dist-info/WHEEL +5 -0
- logplane-0.13.0.dist-info/entry_points.txt +2 -0
- logplane-0.13.0.dist-info/licenses/LICENSE +21 -0
- logplane-0.13.0.dist-info/top_level.txt +1 -0
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,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
|