plan-manager-client-dev 0.2.186__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 (37) hide show
  1. plan_manager_client/__init__.py +10 -0
  2. plan_manager_client/_version.py +58 -0
  3. plan_manager_client/authoring_diagnostics.py +137 -0
  4. plan_manager_client/client.py +75 -0
  5. plan_manager_client/config.py +320 -0
  6. plan_manager_client/delivery/__init__.py +7 -0
  7. plan_manager_client/delivery/archive.py +140 -0
  8. plan_manager_client/delivery/atomicity.py +93 -0
  9. plan_manager_client/delivery/commit.py +113 -0
  10. plan_manager_client/delivery/destination.py +140 -0
  11. plan_manager_client/delivery/provenance.py +88 -0
  12. plan_manager_client/delivery/upload.py +118 -0
  13. plan_manager_client/dispatch.py +380 -0
  14. plan_manager_client/export_fetch_chunks.py +166 -0
  15. plan_manager_client/export_fetch_request.py +88 -0
  16. plan_manager_client/export_fetch_unpack.py +149 -0
  17. plan_manager_client/export_fetch_verify.py +101 -0
  18. plan_manager_client/facade_agent_config_mixin.py +224 -0
  19. plan_manager_client/facade_bug_project_mixin.py +202 -0
  20. plan_manager_client/facade_context_gate_mixin.py +303 -0
  21. plan_manager_client/facade_entity_common_mixin.py +38 -0
  22. plan_manager_client/facade_hrs_file_mixin.py +206 -0
  23. plan_manager_client/facade_plan_hrs_mixin.py +200 -0
  24. plan_manager_client/facade_runtime_mixin/__init__.py +11 -0
  25. plan_manager_client/facade_runtime_mixin/authoring_part.py +442 -0
  26. plan_manager_client/facade_runtime_mixin/facade.py +380 -0
  27. plan_manager_client/facade_runtime_mixin/overlay_part.py +192 -0
  28. plan_manager_client/facade_step_graph_mixin.py +158 -0
  29. plan_manager_client/id_rule.py +115 -0
  30. plan_manager_client/info_walker.py +133 -0
  31. plan_manager_client/scenario_runtime.py +65 -0
  32. plan_manager_client/server_api.py +427 -0
  33. plan_manager_client/server_api_mutating.py +105 -0
  34. plan_manager_client_dev-0.2.186.dist-info/METADATA +10 -0
  35. plan_manager_client_dev-0.2.186.dist-info/RECORD +37 -0
  36. plan_manager_client_dev-0.2.186.dist-info/WHEEL +5 -0
  37. plan_manager_client_dev-0.2.186.dist-info/top_level.txt +1 -0
@@ -0,0 +1,158 @@
1
+ """Step-core and graph/dependency command-family facade mixin.
2
+
3
+ Author: Vasiliy Zdanovskiy
4
+ email: vasilyvz@gmail.com
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+
12
+ class StepGraphCommandsMixin:
13
+ """One method per plan_manager command in the step-core and graph/dependency family.
14
+
15
+ Assumes it is mixed into a class that also inherits
16
+ plan_manager_client.dispatch._CommandDispatchMixin, which supplies the
17
+ coroutine async def _call(self, command: str, params: dict[str, Any] | None = None) -> Any
18
+ used by every method below.
19
+ """
20
+
21
+ async def step_get(self, **params: Any) -> Any:
22
+ """Return one step of a plan identified by step (its id, a uuid4), with resolved parent context."""
23
+ return await self._call("step_get", params)
24
+
25
+ async def step_tree(self, **params: Any) -> Any:
26
+ """Return a paginated page of the plan's full step tree as a flat, sorted list with statuses."""
27
+ return await self._call("step_tree", params)
28
+
29
+ async def step_create(self, **params: Any) -> Any:
30
+ """Scaffold a new step under a plan's declarative level schema."""
31
+ return await self._call("step_create", params)
32
+
33
+ async def step_update(self, **params: Any) -> Any:
34
+ """Patch level-specific fields of an existing step, re-validating touched references."""
35
+ return await self._call("step_update", params)
36
+
37
+ async def step_move(self, **params: Any) -> Any:
38
+ """Move a step to a new parent, rewriting every reference to it in one operation."""
39
+ return await self._call("step_move", params)
40
+
41
+ async def step_delete(self, **params: Any) -> Any:
42
+ """Delete a step, with a dry-run impact preview enabled by default."""
43
+ return await self._call("step_delete", params)
44
+
45
+ async def step_set_status(self, **params: Any) -> Any:
46
+ """Transition a step's status, refusing illegal transitions and the edit of a frozen step."""
47
+ return await self._call("step_set_status", params)
48
+
49
+ async def step_transition(self, **params: Any) -> Any:
50
+ """Transition one step or a scope of steps through the authoring lifecycle."""
51
+ return await self._call("step_transition", params)
52
+
53
+ async def step_runtime_get(self, **params: Any) -> Any:
54
+ """Return runtime parameters for one plan step."""
55
+ return await self._call("step_runtime_get", params)
56
+
57
+ async def step_runtime_report(self, **params: Any) -> Any:
58
+ """Merge runtime parameters for one plan step."""
59
+ return await self._call("step_runtime_report", params)
60
+
61
+ async def step_runtime_list(self, **params: Any) -> Any:
62
+ """List a paginated, artifact_path-sorted page of runtime parameters for plan steps in a scope."""
63
+ return await self._call("step_runtime_list", params)
64
+
65
+ async def step_list(self, **params: Any) -> Any:
66
+ """Return a flat, paginated listing of a plan's steps with full step fields, filterable by level, parent, status, and target_file."""
67
+ return await self._call("step_list", params)
68
+
69
+ async def step_search(self, **params: Any) -> Any:
70
+ """Search step content for an exact substring or regex, scoped to a plan or one branch."""
71
+ return await self._call("step_search", params)
72
+
73
+ async def files_report(self, **params: Any) -> Any:
74
+ """Return the target_file to writer-steps matrix for a plan scope, with ordering-conflict detection."""
75
+ return await self._call("files_report", params)
76
+
77
+ async def namespace_view(self, **params: Any) -> Any:
78
+ """Return a paginated page of one step's computed inherited namespace (ancestor-chain fields.names, optionally CA-probe seeded)."""
79
+ return await self._call("namespace_view", params)
80
+
81
+ async def namespace_rename_suggest(self, **params: Any) -> Any:
82
+ """Propose the full down-tree rename/signature-change cascade of one fields.names declaration entry; read-only."""
83
+ return await self._call("namespace_rename_suggest", params)
84
+
85
+ async def namespace_rename_apply(self, **params: Any) -> Any:
86
+ """Apply the rename/signature-change cascade atomically as one revision, or dry-run it (the default)."""
87
+ return await self._call("namespace_rename_apply", params)
88
+
89
+ async def hrs_prototype(self, **params: Any) -> Any:
90
+ """Assemble the mechanical HRS prototype of one plan: paginated per-G sections plus MRS/HRS digests, gaps seed, and the consumer-model guidance contract."""
91
+ return await self._call("hrs_prototype", params)
92
+
93
+ async def step_xref(self, **params: Any) -> Any:
94
+ """Cross-reference report over per-field content fingerprints: report where a signature/text fragment or a given step field is DEFINED versus where it is INLINED across the plan."""
95
+ return await self._call("step_xref", params)
96
+
97
+ async def graph_deps(self, **params: Any) -> Any:
98
+ """Return the dependency neighborhood (depends_on and dependents) of one step."""
99
+ return await self._call("graph_deps", params)
100
+
101
+ async def graph_order(self, **params: Any) -> Any:
102
+ """Return a paginated page of the topological execution order of a plan's steps."""
103
+ return await self._call("graph_order", params)
104
+
105
+ async def graph_parallel_map(self, **params: Any) -> Any:
106
+ """Return a paginated page of the parallel wave partition of a plan's steps by prerequisite depth."""
107
+ return await self._call("graph_parallel_map", params)
108
+
109
+ async def graph_impact(self, **params: Any) -> Any:
110
+ """Return the read-only transitive impact set of one step."""
111
+ return await self._call("graph_impact", params)
112
+
113
+ async def graph_dependents(self, **params: Any) -> Any:
114
+ """Return a paginated page of the transitive dependency-edge closure of one step."""
115
+ return await self._call("graph_dependents", params)
116
+
117
+ async def execution_graph(self, **params: Any) -> Any:
118
+ """Return a paginated page of the extended execution graph (explicit + inferred edges) of a plan's steps."""
119
+ return await self._call("execution_graph", params)
120
+
121
+ async def execution_dependency_suggest(self, **params: Any) -> Any:
122
+ """Propose depends_on additions for a plan's unambiguous inferred execution-graph edges; read-only."""
123
+ return await self._call("execution_dependency_suggest", params)
124
+
125
+ async def execution_dependency_apply(self, **params: Any) -> Any:
126
+ """Apply an execution_dependency_suggest proposal as depends_on additions, or dry-run it."""
127
+ return await self._call("execution_dependency_apply", params)
128
+
129
+ async def step_dependency_list(self, **params: Any) -> Any:
130
+ """List one step's top-level depends_on edges and the sibling steps that depend on it."""
131
+ return await self._call("step_dependency_list", params)
132
+
133
+ async def step_dependency_add(self, **params: Any) -> Any:
134
+ """Add one sibling dependency to a step's top-level depends_on; idempotent, cycle-safe, and admitted under the step mutation regime."""
135
+ return await self._call("step_dependency_add", params)
136
+
137
+ async def step_dependency_remove(self, **params: Any) -> Any:
138
+ """Remove one dependency from a step's top-level depends_on; idempotent and admitted under the step mutation regime."""
139
+ return await self._call("step_dependency_remove", params)
140
+
141
+ async def step_dependency_set(self, **params: Any) -> Any:
142
+ """Replace a step's entire top-level depends_on with a validated, deduplicated, cycle-safe sibling list."""
143
+ return await self._call("step_dependency_set", params)
144
+
145
+ async def step_dependency_clear(self, **params: Any) -> Any:
146
+ """Clear all of a step's top-level depends_on edges, admitted under the step mutation regime."""
147
+ return await self._call("step_dependency_clear", params)
148
+
149
+ async def step_dependency_preview(self, **params: Any) -> Any:
150
+ """Dry-run a batch of dependency changes and report validity, cycle risk, and the before/after execution order and parallel waves."""
151
+ return await self._call("step_dependency_preview", params)
152
+
153
+ async def step_dependency_apply(self, **params: Any) -> Any:
154
+ """Apply a batch of dependency changes as one revision, or dry-run them; all-or-nothing, cycle-safe."""
155
+ return await self._call("step_dependency_apply", params)
156
+
157
+
158
+ __all__ = ["StepGraphCommandsMixin"]
@@ -0,0 +1,115 @@
1
+ """Client-side check of the identifier rule D1 (uuid4 discipline, block 2, D7; TODO 93bc1adc-755b-461c-8f1e-20e51b3c0b25).
2
+
3
+ The client is a separate wheel, so the rule is a copy of ``plan_manager.storage.id_rule``: an identifier is canonical
4
+ 8-4-4-4-12 text, version 4 (RFC 4122 variant), not nil, and has no run of eight or more '0' hex digits (hyphens ignored).
5
+ ``check_call_ids`` runs in the facade before every call: a value of a parameter named in ``UUID_PARAMETER_NAMES`` (the
6
+ server's ``format: uuid`` parameters) that fails the rule is refused locally with the server's own answer shape
7
+ (-32602, violation ``value``, the parameter as field, the exact value). The server stays the authority.
8
+
9
+ Free-text identifiers are not in the table and are not checked: they are not uuids of this project (request, session and
10
+ call ids, provider request ids, uuid7 evidence ids, idempotency keys, sha256 hashes, dialogue and transcript references,
11
+ the server id of a tool). ``plan`` takes a name or an id: only a value of the id shape is checked (``ID_OR_NAME_PARAMETERS``).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ import uuid
18
+ from typing import Any
19
+
20
+ _ZERO_RUN = re.compile(r"0{8}")
21
+ _CANONICAL = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
22
+ EXPECTED = "a valid identifier: uuid4, not nil, without a run of eight zeros"
23
+
24
+ ID_OR_NAME_PARAMETERS: frozenset[str] = frozenset({"plan"})
25
+
26
+ UUID_PARAMETER_NAMES: frozenset[str] = frozenset({
27
+ "anchor_plan", "anchor_plan_uuid", "anchor_project_id", "anchor_ref_id", "anchor_revision_uuid", "assignment_uuid",
28
+ "attempt_id", "binding_uuid", "bug", "bug_fix", "bug_id", "calendar_entry", "comment", "comment_uuid", "dependency_uuid",
29
+ "duplicate_of_uuid", "entity_id", "entity_ids", "entity_uuid", "envelope_uuid", "escalation_uuid", "from_entity_uuid",
30
+ "from_todo", "identifiers", "impact_uuid", "link", "linked_plan_uuid", "membership_uuid", "model_uuid",
31
+ "new_anchor_plan_uuid", "new_anchor_project_id", "new_anchor_ref_id", "new_anchor_revision_uuid", "new_owner_id",
32
+ "new_source_plan_uuid", "new_source_project_id", "new_source_ref_id", "new_source_revision_uuid", "parent_bug_uuid",
33
+ "policy_uuid", "profile_uuid", "project", "propagation_id", "provider_uuid", "review_uuid", "revision", "role_uuid",
34
+ "source_plan_uuid", "source_project_id", "source_ref", "source_ref_id", "source_revision_uuid", "supersedes_comment_uuid",
35
+ "target_plan_uuid", "target_project_id", "target_ref_id", "target_revision_uuid", "to_entity_uuid", "to_todo", "todo",
36
+ "tool_uuid", "toolset_uuid", "wish",
37
+ })
38
+ """The parameters the server declares ``format: uuid`` (every command of the live catalog, generated from the schemas)."""
39
+
40
+ FREE_TEXT_PARAMETERS: dict[str, frozenset[str]] = {
41
+ "plan_comment_set": frozenset({"comment"}),
42
+ "project_update": frozenset({"comment"}),
43
+ }
44
+ """Per command: parameters whose NAME is in ``UUID_PARAMETER_NAMES`` for other commands but carry free text here (the
45
+ ``comment`` of plan_comment_set and project_update is prose, the ``comment`` of comment_update/comment_delete is a uuid)."""
46
+
47
+
48
+ class InvalidIdentifierError(ValueError):
49
+ """A client-side refusal of an identifier value: the server's -32602 / violation value shape, before the call."""
50
+
51
+ code = -32602
52
+ violation = "value"
53
+
54
+ def __init__(self, field: str, value: Any, defect: str) -> None:
55
+ self.field = field
56
+ self.value = value
57
+ super().__init__(
58
+ f"parameter {field!r} must be {EXPECTED}, got {value!r} ({defect})"
59
+ )
60
+
61
+ def as_answer(self) -> dict[str, Any]:
62
+ """The server's -32602 ErrorResult for this refusal: the answer a command gives, not an exception."""
63
+ return {
64
+ "success": False,
65
+ "error": {
66
+ "code": self.code,
67
+ "message": str(self),
68
+ "data": {"field": self.field, "violation": self.violation, "expected": EXPECTED, "got": self.value},
69
+ },
70
+ }
71
+
72
+
73
+ def id_defect(value: Any) -> str | None:
74
+ """Why ``value`` is not a valid identifier, or None when it is one (the rule D1 of the server)."""
75
+ if isinstance(value, uuid.UUID):
76
+ candidate = value
77
+ else:
78
+ try:
79
+ candidate = uuid.UUID(str(value))
80
+ except (AttributeError, TypeError, ValueError):
81
+ return f"invalid uuid: {value!r}"
82
+ if candidate.int == 0:
83
+ return f"nil uuid: {candidate}"
84
+ if candidate.version != 4:
85
+ return f"not version 4: {candidate}"
86
+ if _ZERO_RUN.search(candidate.hex):
87
+ return f"a run of eight zeros: {candidate}"
88
+ return None
89
+
90
+
91
+ def _is_id_shape(value: Any) -> bool:
92
+ return isinstance(value, str) and _CANONICAL.match(value) is not None
93
+
94
+
95
+ def check_call_ids(params: dict[str, Any] | None, command: str | None = None) -> None:
96
+ """Refuse locally every identifier value of a call that fails rule D1 (see the module docstring).
97
+
98
+ Raises InvalidIdentifierError naming the parameter and the exact value. Nothing is sent when it raises. A parameter
99
+ that ``FREE_TEXT_PARAMETERS`` lists for ``command`` is free text there and is never checked.
100
+ """
101
+ free_text = FREE_TEXT_PARAMETERS.get(command or "", frozenset())
102
+ for name, value in (params or {}).items():
103
+ if name in free_text:
104
+ continue
105
+ if name in UUID_PARAMETER_NAMES:
106
+ values = value if isinstance(value, list) else [value]
107
+ for item in values:
108
+ if isinstance(item, str):
109
+ defect = id_defect(item)
110
+ if defect is not None:
111
+ raise InvalidIdentifierError(name, item, defect)
112
+ elif name in ID_OR_NAME_PARAMETERS and _is_id_shape(value):
113
+ defect = id_defect(value)
114
+ if defect is not None:
115
+ raise InvalidIdentifierError(name, value, defect)
@@ -0,0 +1,133 @@
1
+ """Walk the `info` command to the end of a node, page by page (R116b info norm, client helper).
2
+
3
+ `info` answers one page at a time: a page has ``has_more`` and ``next_offset``, and the content of
4
+ the addressed node is under the key of its section. ``walk_info`` follows ``next_offset`` until
5
+ ``has_more`` is false and assembles the content. With ``follow_refs`` every oversize child that
6
+ came back as an ``info_ref`` stub is read the same way, so the result is the full section data.
7
+ Without a section the first page is read, and every section listed in its ``contents`` is walked.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Awaitable, Callable, Dict, Optional
13
+
14
+ InfoCall = Callable[..., Awaitable[Any]]
15
+
16
+ _MAX_DEPTH = 16
17
+ _MAX_PAGES = 100_000
18
+
19
+
20
+ def _is_stub(value: Any) -> bool:
21
+ return isinstance(value, dict) and "info_ref" in value and isinstance(value["info_ref"], dict)
22
+
23
+
24
+ def _append(kind: str, acc: Any, piece: Any) -> Any:
25
+ if kind == "object":
26
+ merged = {} if acc is None else acc
27
+ merged.update(piece)
28
+ return merged
29
+ if kind == "array":
30
+ items = [] if acc is None else acc
31
+ items.extend(piece)
32
+ return items
33
+ if kind == "string":
34
+ return ("" if acc is None else acc) + piece
35
+ return piece
36
+
37
+
38
+ async def _read_node(info: InfoCall, params: Dict[str, Any]) -> Any:
39
+ """Every page of one node, from offset 0 to next_offset null, assembled into its content."""
40
+ section = params["section"]
41
+ content: Any = None
42
+ offset = 0
43
+ pages = 0
44
+ while True:
45
+ page = await info(**params, offset=offset)
46
+ pages += 1
47
+ content = _append(page["kind"], content, page[section])
48
+ if not page["has_more"]:
49
+ return content
50
+ following = page["next_offset"]
51
+ if not isinstance(following, int) or following <= offset or pages >= _MAX_PAGES:
52
+ raise ValueError(
53
+ "info paging does not advance: offset %d, next_offset %r, page %d" % (offset, following, pages)
54
+ )
55
+ offset = following
56
+
57
+
58
+ async def _resolve(info: InfoCall, value: Any, depth: int) -> Any:
59
+ """Replace every info_ref stub inside a walked value by the content it points to."""
60
+ if depth > _MAX_DEPTH:
61
+ raise ValueError("info stubs nest deeper than %d levels" % _MAX_DEPTH)
62
+ if _is_stub(value):
63
+ ref = value["info_ref"]
64
+ child = {key: ref[key] for key in ("section", "subsection", "path") if key in ref}
65
+ return await walk_info(info, follow_refs=True, _depth=depth + 1, **child)
66
+ if isinstance(value, dict):
67
+ return {key: await _resolve(info, item, depth) for key, item in value.items()}
68
+ if isinstance(value, list):
69
+ return [await _resolve(info, item, depth) for item in value]
70
+ return value
71
+
72
+
73
+ async def walk_info(
74
+ info: InfoCall,
75
+ *,
76
+ section: Optional[str] = None,
77
+ subsection: Optional[str] = None,
78
+ path: Optional[str] = None,
79
+ follow_refs: bool = False,
80
+ _depth: int = 0,
81
+ ) -> Any:
82
+ """The assembled content of one `info` node, or of every section when ``section`` is None.
83
+
84
+ ``info`` is the async `info` call (the client method or any callable with the same parameters
85
+ that returns the unwrapped data of the answer). Pages are requested in stored order, each once.
86
+ """
87
+ if section is None:
88
+ first = await info()
89
+ walked = {}
90
+ for entry in first["contents"]:
91
+ name = entry["ref"]["section"]
92
+ walked[name] = await walk_info(info, section=name, follow_refs=follow_refs, _depth=_depth)
93
+ return walked
94
+ params: Dict[str, Any] = {"section": section}
95
+ if subsection is not None:
96
+ params["subsection"] = subsection
97
+ if path is not None:
98
+ params["path"] = path
99
+ content = await _read_node(info, params)
100
+ if follow_refs:
101
+ content = await _resolve(info, content, _depth)
102
+ return content
103
+
104
+
105
+ def _page_of_envelope(raw: Any) -> Any:
106
+ """The bare info page of a client reply.
107
+
108
+ The client `info` method answers the command envelope {command, job_id, status, result: {success,
109
+ data: page}} (bug 05336ae7). The walker reads the page itself (kind, has_more, next_offset and the
110
+ section key), so the envelope is unwrapped here; a reply that is already a bare page passes through.
111
+ A failed envelope raises, never a silent empty page.
112
+ """
113
+ if isinstance(raw, dict) and isinstance(raw.get("result"), dict):
114
+ result = raw["result"]
115
+ if result.get("success") is False:
116
+ raise RuntimeError("info failed: %r" % (result,))
117
+ if "data" in result:
118
+ return result["data"]
119
+ return raw
120
+
121
+
122
+ async def info_walk(client: Any, **params: Any) -> Any:
123
+ """Read one `info` node, or every section, to its end through a client's ``info`` method.
124
+
125
+ ``client`` is any object with an async ``info(**params)`` method (the PlanManagerClient). This is a
126
+ module function, not a facade method: the facade must expose only canonical command names.
127
+ Parameters are those of :func:`walk_info`: section, subsection, path and follow_refs.
128
+ The client reply is unwrapped to the bare page first (see :func:`_page_of_envelope`).
129
+ """
130
+ async def _info(**page_params: Any) -> Any:
131
+ return _page_of_envelope(await client.info(**page_params))
132
+
133
+ return await walk_info(_info, **params)
@@ -0,0 +1,65 @@
1
+ """Scenario helpers for installed public-client pipeline checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ from typing import Any
8
+
9
+ from plan_manager_client.config import ClientConnectionConfig
10
+
11
+
12
+ PREFIX = "live-smoke-"
13
+ DEFAULT_PROJECT_ID = "f06b7269-cc9c-4293-886b-24984e4033ba"
14
+
15
+ _CALL_TIMEOUT: float | None = None
16
+ _CALL_WATCHDOG_GRACE_SECONDS = 15.0
17
+
18
+
19
+ def configure_call_watchdog(timeout: float | None, *, grace_seconds: float = _CALL_WATCHDOG_GRACE_SECONDS) -> None:
20
+ """Configure the maximum public-client method duration for scenario calls."""
21
+ global _CALL_TIMEOUT
22
+ _CALL_TIMEOUT = None if timeout is None else float(timeout) + float(grace_seconds)
23
+
24
+
25
+ def build_config(args: argparse.Namespace) -> ClientConnectionConfig:
26
+ """Build client configuration from a pipeline scenario argparse namespace."""
27
+ return ClientConnectionConfig.from_sources(
28
+ base_url=getattr(args, "base_url", None),
29
+ protocol=getattr(args, "protocol", None),
30
+ host=getattr(args, "host", None),
31
+ port=getattr(args, "port", None),
32
+ timeout=getattr(args, "timeout", None),
33
+ env_prefix="PLAN_MANAGER_",
34
+ )
35
+
36
+
37
+ def _unwrap(raw: Any) -> tuple[bool, Any]:
38
+ current = raw
39
+ for _ in range(12):
40
+ if isinstance(current, dict):
41
+ if "status" in current and "result" in current:
42
+ if current.get("status") != "completed":
43
+ return False, current
44
+ current = current.get("result")
45
+ continue
46
+ if "success" in current and isinstance(current.get("success"), bool):
47
+ if not current["success"]:
48
+ return False, current.get("error", current)
49
+ return True, current.get("data", current)
50
+ return True, current
51
+ return False, {"message": "response envelope nesting exceeded", "raw": raw}
52
+
53
+
54
+ async def call(client: Any, name: str, params: dict[str, Any] | None = None) -> tuple[bool, Any]:
55
+ """Call one public client method and normalize success/error envelopes."""
56
+ try:
57
+ method = getattr(client, name)
58
+ coroutine = method(**(params or {}))
59
+ raw = await asyncio.wait_for(coroutine, timeout=_CALL_TIMEOUT) if _CALL_TIMEOUT else await coroutine
60
+ except Exception as exc: # noqa: BLE001 - scenario boundary.
61
+ return False, {"exception_type": type(exc).__name__, "message": str(exc)}
62
+ return _unwrap(raw)
63
+
64
+
65
+ __all__ = ["DEFAULT_PROJECT_ID", "PREFIX", "build_config", "call", "configure_call_watchdog"]