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.
- plan_manager_client/__init__.py +10 -0
- plan_manager_client/_version.py +58 -0
- plan_manager_client/authoring_diagnostics.py +137 -0
- plan_manager_client/client.py +75 -0
- plan_manager_client/config.py +320 -0
- plan_manager_client/delivery/__init__.py +7 -0
- plan_manager_client/delivery/archive.py +140 -0
- plan_manager_client/delivery/atomicity.py +93 -0
- plan_manager_client/delivery/commit.py +113 -0
- plan_manager_client/delivery/destination.py +140 -0
- plan_manager_client/delivery/provenance.py +88 -0
- plan_manager_client/delivery/upload.py +118 -0
- plan_manager_client/dispatch.py +380 -0
- plan_manager_client/export_fetch_chunks.py +166 -0
- plan_manager_client/export_fetch_request.py +88 -0
- plan_manager_client/export_fetch_unpack.py +149 -0
- plan_manager_client/export_fetch_verify.py +101 -0
- plan_manager_client/facade_agent_config_mixin.py +224 -0
- plan_manager_client/facade_bug_project_mixin.py +202 -0
- plan_manager_client/facade_context_gate_mixin.py +303 -0
- plan_manager_client/facade_entity_common_mixin.py +38 -0
- plan_manager_client/facade_hrs_file_mixin.py +206 -0
- plan_manager_client/facade_plan_hrs_mixin.py +200 -0
- plan_manager_client/facade_runtime_mixin/__init__.py +11 -0
- plan_manager_client/facade_runtime_mixin/authoring_part.py +442 -0
- plan_manager_client/facade_runtime_mixin/facade.py +380 -0
- plan_manager_client/facade_runtime_mixin/overlay_part.py +192 -0
- plan_manager_client/facade_step_graph_mixin.py +158 -0
- plan_manager_client/id_rule.py +115 -0
- plan_manager_client/info_walker.py +133 -0
- plan_manager_client/scenario_runtime.py +65 -0
- plan_manager_client/server_api.py +427 -0
- plan_manager_client/server_api_mutating.py +105 -0
- plan_manager_client_dev-0.2.186.dist-info/METADATA +10 -0
- plan_manager_client_dev-0.2.186.dist-info/RECORD +37 -0
- plan_manager_client_dev-0.2.186.dist-info/WHEEL +5 -0
- plan_manager_client_dev-0.2.186.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Path A digest-verified CA tree upload composition.
|
|
2
|
+
|
|
3
|
+
Walks the materialized export tree, verifying each entry against its
|
|
4
|
+
digest (DeliveryIntegrityContract, MRS concept C-007) BEFORE uploading it
|
|
5
|
+
through the CA service's own client (CodeAnalysisClientDependency, MRS
|
|
6
|
+
concept C-015) to its own destination path, so the delivered tree
|
|
7
|
+
reproduces the export layout rather than a flat set of names, realizing
|
|
8
|
+
the upload phase of PathACodeAnalysisDelivery (MRS concept C-003).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
import hashlib
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Literal
|
|
15
|
+
from code_analysis_client.client import CodeAnalysisAsyncClient
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DigestMismatchError(Exception):
|
|
19
|
+
"""Raised when a tree entry's content does not match its digest.
|
|
20
|
+
|
|
21
|
+
Raised by :func:`verify_and_upload_tree` before any CA-side write is
|
|
22
|
+
attempted for the mismatched entry, per DeliveryIntegrityContract
|
|
23
|
+
(MRS concept C-007).
|
|
24
|
+
"""
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class PendingEntry:
|
|
30
|
+
"""One export tree entry queued for digest-verified upload.
|
|
31
|
+
|
|
32
|
+
Attributes:
|
|
33
|
+
relative_path: The entry's POSIX path relative to the plan export
|
|
34
|
+
root, preserved unchanged end to end (for example
|
|
35
|
+
'G-001-x/T-001-y/atomic_steps/A-001-z.yaml').
|
|
36
|
+
destination_path: The resolved project-relative POSIX destination
|
|
37
|
+
path inside the CA project, already carrying relative_path's
|
|
38
|
+
position beneath the destination subdirectory.
|
|
39
|
+
content: The entry's unpacked content bytes.
|
|
40
|
+
declared_digest: The declared sha256 hex digest for content.
|
|
41
|
+
already_indexed: True selects UPDATE mode (upload); False selects
|
|
42
|
+
CREATE mode (upload_new).
|
|
43
|
+
"""
|
|
44
|
+
relative_path: str
|
|
45
|
+
destination_path: str
|
|
46
|
+
content: bytes
|
|
47
|
+
declared_digest: str
|
|
48
|
+
already_indexed: bool
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class UploadResult:
|
|
53
|
+
"""Outcome of one tree entry's digest-verified upload to the CA project."""
|
|
54
|
+
relative_path: str
|
|
55
|
+
destination_path: str
|
|
56
|
+
mode: Literal["create", "update"]
|
|
57
|
+
ca_file_id: str
|
|
58
|
+
success: bool
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def verify_and_upload_tree(
|
|
62
|
+
ca_client: CodeAnalysisAsyncClient,
|
|
63
|
+
resolved_project_id: str,
|
|
64
|
+
pending_entries: list[PendingEntry],
|
|
65
|
+
) -> list[UploadResult]:
|
|
66
|
+
"""Digest-verify and upload each tree entry through the CA client.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
ca_client: An already-connected CodeAnalysisAsyncClient instance
|
|
70
|
+
(MRS concept C-015); this function does not construct or close it.
|
|
71
|
+
resolved_project_id: The CA project_id to upload into.
|
|
72
|
+
pending_entries: Tree entries queued for upload, in tree order.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
One UploadResult per entry of pending_entries, in the same order.
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
DigestMismatchError: If, for any entry, hashlib.sha256(entry.content)
|
|
79
|
+
.hexdigest() does not equal entry.declared_digest. Processing
|
|
80
|
+
stops at the first mismatch: no upload call is made for that
|
|
81
|
+
entry or any later entry.
|
|
82
|
+
"""
|
|
83
|
+
result: list[UploadResult] = []
|
|
84
|
+
file_sessions = ca_client.file_sessions
|
|
85
|
+
|
|
86
|
+
for entry in pending_entries:
|
|
87
|
+
digest = hashlib.sha256(entry.content).hexdigest()
|
|
88
|
+
|
|
89
|
+
if digest != entry.declared_digest:
|
|
90
|
+
raise DigestMismatchError(
|
|
91
|
+
f"digest mismatch for {entry.relative_path!r} at {entry.destination_path!r}"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if entry.already_indexed is False:
|
|
95
|
+
ca_file_id = await file_sessions.upload_new(
|
|
96
|
+
project_id=resolved_project_id,
|
|
97
|
+
file_path=entry.destination_path,
|
|
98
|
+
content=entry.content,
|
|
99
|
+
)
|
|
100
|
+
mode = "create"
|
|
101
|
+
else:
|
|
102
|
+
ca_file_id = await file_sessions.upload(
|
|
103
|
+
file_id=entry.destination_path,
|
|
104
|
+
content=entry.content,
|
|
105
|
+
)
|
|
106
|
+
mode = "update"
|
|
107
|
+
|
|
108
|
+
result.append(
|
|
109
|
+
UploadResult(
|
|
110
|
+
relative_path=entry.relative_path,
|
|
111
|
+
destination_path=entry.destination_path,
|
|
112
|
+
mode=mode,
|
|
113
|
+
ca_file_id=str(ca_file_id),
|
|
114
|
+
success=True,
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return result
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""Command dispatch mixin: thin async wrapper over the held JsonRpcClient.execute_command_unified.
|
|
2
|
+
|
|
3
|
+
Author: Vasiliy Zdanovskiy
|
|
4
|
+
email: vasilyvz@gmail.com
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from mcp_proxy_adapter.client.jsonrpc_client.exceptions import CommandSessionFailedError
|
|
13
|
+
|
|
14
|
+
from plan_manager_client.authoring_diagnostics import (
|
|
15
|
+
AUTHORING_DIAGNOSTIC_COMMANDS,
|
|
16
|
+
emit as emit_authoring_diagnostic,
|
|
17
|
+
new_correlation_id,
|
|
18
|
+
)
|
|
19
|
+
from plan_manager_client.id_rule import InvalidIdentifierError, check_call_ids
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _adapter_validation_error_result(status: object) -> dict[str, Any] | None:
|
|
23
|
+
"""Return an adapter validation ErrorResult from a terminal job status.
|
|
24
|
+
|
|
25
|
+
``queue_get_job_status`` has returned both the ErrorResult itself and the
|
|
26
|
+
queue-status envelope containing it across adapter transport paths. Only
|
|
27
|
+
the documented result locations are considered; a failed job is otherwise
|
|
28
|
+
left as the adapter's ``CommandSessionFailedError``.
|
|
29
|
+
"""
|
|
30
|
+
candidates: list[object] = [status]
|
|
31
|
+
if isinstance(status, dict):
|
|
32
|
+
candidates.extend((status.get("result"), status.get("data")))
|
|
33
|
+
result = status.get("result")
|
|
34
|
+
if isinstance(result, dict):
|
|
35
|
+
candidates.append(result.get("data"))
|
|
36
|
+
# A refused command run as a job keeps the ErrorResult one level deeper,
|
|
37
|
+
# at status.result.result and status.data.result.result, with
|
|
38
|
+
# command_execution true (bug ccb5f95f).
|
|
39
|
+
candidates.append(result.get("result"))
|
|
40
|
+
data = status.get("data")
|
|
41
|
+
if isinstance(data, dict):
|
|
42
|
+
data_result = data.get("result")
|
|
43
|
+
if isinstance(data_result, dict):
|
|
44
|
+
candidates.append(data_result.get("result"))
|
|
45
|
+
|
|
46
|
+
for candidate in candidates:
|
|
47
|
+
if not isinstance(candidate, dict):
|
|
48
|
+
continue
|
|
49
|
+
error = candidate.get("error")
|
|
50
|
+
if (
|
|
51
|
+
candidate.get("success") is False
|
|
52
|
+
and isinstance(error, dict)
|
|
53
|
+
and error.get("code") == -32602
|
|
54
|
+
):
|
|
55
|
+
return candidate
|
|
56
|
+
|
|
57
|
+
# The server's pre-execution refusals (command_execution false) and "Method not
|
|
58
|
+
# found" keep a bare ``{status: "error", code, message, data}`` result at
|
|
59
|
+
# status.data.result / status.result, with no success/error keys (bug ccb5f95f).
|
|
60
|
+
if isinstance(status, dict):
|
|
61
|
+
bare_candidates: list[object] = []
|
|
62
|
+
for holder in (status.get("result"), status.get("data")):
|
|
63
|
+
bare_candidates.append(holder)
|
|
64
|
+
if isinstance(holder, dict):
|
|
65
|
+
bare_candidates.append(holder.get("result"))
|
|
66
|
+
for bare in bare_candidates:
|
|
67
|
+
if (
|
|
68
|
+
isinstance(bare, dict)
|
|
69
|
+
and bare.get("status") == "error"
|
|
70
|
+
and bare.get("code") in _BARE_REFUSAL_CODES
|
|
71
|
+
and isinstance(bare.get("message"), str)
|
|
72
|
+
):
|
|
73
|
+
bare_data = bare.get("data")
|
|
74
|
+
return {
|
|
75
|
+
"success": False,
|
|
76
|
+
"error": {
|
|
77
|
+
"code": bare["code"],
|
|
78
|
+
"message": bare["message"],
|
|
79
|
+
"data": bare_data if isinstance(bare_data, dict) else {},
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# A command that EXECUTED and ended with its own ErrorResult (command_execution true,
|
|
84
|
+
# job_success false) keeps that ErrorResult at status.data.result.result (bug 970e0443:
|
|
85
|
+
# ``help`` with an unknown cmdname, code -32601). The caller gets the job's own error
|
|
86
|
+
# envelope, as for a non-queued command, never a CommandSessionFailedError.
|
|
87
|
+
if isinstance(status, dict):
|
|
88
|
+
executed = status.get("data")
|
|
89
|
+
if (
|
|
90
|
+
isinstance(executed, dict)
|
|
91
|
+
and executed.get("status") == "failed"
|
|
92
|
+
and executed.get("command_execution") is True
|
|
93
|
+
and executed.get("job_success") is False
|
|
94
|
+
):
|
|
95
|
+
executed_result = executed.get("result")
|
|
96
|
+
inner = executed_result.get("result") if isinstance(executed_result, dict) else None
|
|
97
|
+
if (
|
|
98
|
+
isinstance(inner, dict)
|
|
99
|
+
and inner.get("success") is False
|
|
100
|
+
and isinstance(inner.get("error"), dict)
|
|
101
|
+
and isinstance(inner["error"].get("code"), int)
|
|
102
|
+
and isinstance(inner["error"].get("message"), str)
|
|
103
|
+
):
|
|
104
|
+
return inner
|
|
105
|
+
|
|
106
|
+
# A command-session failure persists the queue terminal status instead of
|
|
107
|
+
# the ErrorResult. Adapter pre-execution validation failures retain an
|
|
108
|
+
# unambiguous status discriminator and the original ValidationError message.
|
|
109
|
+
status_data = status.get("data") if isinstance(status, dict) else None
|
|
110
|
+
if not isinstance(status_data, dict):
|
|
111
|
+
return None
|
|
112
|
+
message = status_data.get("error")
|
|
113
|
+
if message is None:
|
|
114
|
+
# A ValidationError raised by a command's own ``validate_params`` inside
|
|
115
|
+
# the queue worker leaves ``error`` null; the text survives only in
|
|
116
|
+
# ``description`` / ``result.message`` behind the worker's prefix.
|
|
117
|
+
prefix = "Command execution failed: "
|
|
118
|
+
result_part = status_data.get("result")
|
|
119
|
+
for key_text in (
|
|
120
|
+
status_data.get("description"),
|
|
121
|
+
result_part.get("message") if isinstance(result_part, dict) else None,
|
|
122
|
+
):
|
|
123
|
+
if isinstance(key_text, str) and key_text.startswith(prefix):
|
|
124
|
+
message = key_text.removeprefix(prefix)
|
|
125
|
+
break
|
|
126
|
+
if not (
|
|
127
|
+
status_data.get("status") == "failed"
|
|
128
|
+
and status_data.get("command_execution") is False
|
|
129
|
+
and status_data.get("job_success") is False
|
|
130
|
+
and isinstance(message, str)
|
|
131
|
+
):
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
missing_prefix = "Missing required parameters: "
|
|
135
|
+
if message.startswith(missing_prefix):
|
|
136
|
+
missing_parameters = [
|
|
137
|
+
parameter.strip()
|
|
138
|
+
for parameter in message.removeprefix(missing_prefix).split(",")
|
|
139
|
+
if parameter.strip()
|
|
140
|
+
]
|
|
141
|
+
if not missing_parameters:
|
|
142
|
+
return None
|
|
143
|
+
return {
|
|
144
|
+
"success": False,
|
|
145
|
+
"error": {
|
|
146
|
+
"code": -32602,
|
|
147
|
+
"message": message,
|
|
148
|
+
"data": {"missing_parameters": missing_parameters, "violation": "missing"},
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
# Value messages (``must be one of`` / ``must be false when`` ...) are tried BEFORE the
|
|
153
|
+
# generic type pattern: ``[^,]+`` in the type regex also matches ``be false when hard=true (...)``.
|
|
154
|
+
value_error = re.fullmatch(
|
|
155
|
+
r"[A-Za-z_][A-Za-z0-9_]*: parameter '(?P<field>[^']+)' must (?:be one of|be >=|be <=|be at least|be at most|match pattern|be false when) .*",
|
|
156
|
+
message,
|
|
157
|
+
flags=re.S,
|
|
158
|
+
)
|
|
159
|
+
if value_error is not None:
|
|
160
|
+
return {
|
|
161
|
+
"success": False,
|
|
162
|
+
"error": {
|
|
163
|
+
"code": -32602,
|
|
164
|
+
"message": message,
|
|
165
|
+
"data": {"field": value_error.group("field"), "violation": "value"},
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
type_error = re.fullmatch(
|
|
170
|
+
r"[A-Za-z_][A-Za-z0-9_]*: parameter '(?P<field>[^']+)' "
|
|
171
|
+
r"must be (?P<expected>[^,]+), got (?P<got>[A-Za-z_][A-Za-z0-9_]*)",
|
|
172
|
+
message,
|
|
173
|
+
)
|
|
174
|
+
if type_error is not None:
|
|
175
|
+
return {
|
|
176
|
+
"success": False,
|
|
177
|
+
"error": {
|
|
178
|
+
"code": -32602,
|
|
179
|
+
"message": message,
|
|
180
|
+
"data": {
|
|
181
|
+
"field": type_error.group("field"),
|
|
182
|
+
"violation": "type",
|
|
183
|
+
"expected": type_error.group("expected"),
|
|
184
|
+
"got": type_error.group("got"),
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
# The adapter's own phase-1 texts carry their violation class; the queue keeps only
|
|
190
|
+
# the text, so restore the structured ``data`` the direct (non-queued) path returns.
|
|
191
|
+
unknown_match = re.fullmatch(r"Invalid parameters: (?P<names>.+?)\. Allowed parameters: .*", message, flags=re.S)
|
|
192
|
+
if unknown_match is not None:
|
|
193
|
+
return {
|
|
194
|
+
"success": False,
|
|
195
|
+
"error": {
|
|
196
|
+
"code": -32602,
|
|
197
|
+
"message": message,
|
|
198
|
+
"data": {
|
|
199
|
+
"invalid_parameters": [n.strip() for n in unknown_match.group("names").split(",") if n.strip()],
|
|
200
|
+
"violation": "unknown",
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
# The adapter keeps no structured code on a failed queue job: only the
|
|
206
|
+
# exception text survives. A job that failed with ``command_execution`` False
|
|
207
|
+
# (checked above) never reached ``execute()``; the adapter turns every
|
|
208
|
+
# domain/runtime failure inside ``execute()`` into a completed ErrorResult, so
|
|
209
|
+
# an exception escaping ``Command.run()`` before that point is the
|
|
210
|
+
# parameter-validation phase (``validate_params``: unknown/missing/typed/
|
|
211
|
+
# vocabulary/UUID-format parameters). Messages of that phase are free text
|
|
212
|
+
# (command-specific), so they cannot all be enumerated. Queue lifecycle
|
|
213
|
+
# failures (stopped, timed out, worker crash, deleted) are NOT validation and
|
|
214
|
+
# must stay a CommandSessionFailedError.
|
|
215
|
+
lowered = message.lower()
|
|
216
|
+
if not message.startswith(_UNKNOWN_PARAMETERS_PREFIX) and any(
|
|
217
|
+
marker in lowered for marker in _NON_VALIDATION_MARKERS
|
|
218
|
+
):
|
|
219
|
+
return None
|
|
220
|
+
return {
|
|
221
|
+
"success": False,
|
|
222
|
+
"error": {
|
|
223
|
+
"code": -32602,
|
|
224
|
+
"message": message,
|
|
225
|
+
"data": {"recovered_from": "queued_job_status", "violation": _relation_violation(lowered)},
|
|
226
|
+
},
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _relation_violation(lowered: str) -> str:
|
|
231
|
+
"""Classify a free-text ``validate_params`` relation failure into the adapter vocabulary."""
|
|
232
|
+
if "must be absent" in lowered:
|
|
233
|
+
return "unknown"
|
|
234
|
+
if " is required" in lowered or " requires " in lowered:
|
|
235
|
+
return "missing"
|
|
236
|
+
return "value"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
_BARE_REFUSAL_CODES = (-32602, -32601)
|
|
240
|
+
_UNKNOWN_PARAMETERS_PREFIX = "Invalid parameters: "
|
|
241
|
+
_NON_VALIDATION_MARKERS = (
|
|
242
|
+
"timed out",
|
|
243
|
+
"was stopped",
|
|
244
|
+
"was terminated",
|
|
245
|
+
"was cancelled",
|
|
246
|
+
"was canceled",
|
|
247
|
+
"job deleted",
|
|
248
|
+
"traceback",
|
|
249
|
+
"internal error",
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
async def _queued_validation_error_result(
|
|
254
|
+
rpc: Any, exc: CommandSessionFailedError
|
|
255
|
+
) -> dict[str, Any] | None:
|
|
256
|
+
"""Recover only a terminal adapter validation result from a failed session."""
|
|
257
|
+
details = getattr(exc, "details", None)
|
|
258
|
+
if not isinstance(details, dict):
|
|
259
|
+
return None
|
|
260
|
+
terminal_event = details.get("terminal_event")
|
|
261
|
+
if not isinstance(terminal_event, dict):
|
|
262
|
+
return None
|
|
263
|
+
job_id = terminal_event.get("job_id")
|
|
264
|
+
if not isinstance(job_id, str) or not job_id:
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
try:
|
|
268
|
+
status = await rpc.queue_get_job_status(job_id)
|
|
269
|
+
except Exception:
|
|
270
|
+
# A status retrieval failure must not change the command failure that
|
|
271
|
+
# the adapter already classified.
|
|
272
|
+
return None
|
|
273
|
+
return _adapter_validation_error_result(status)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class _CommandDispatchMixin:
|
|
277
|
+
"""Mixin providing the single dispatch point used by every command-family mixin.
|
|
278
|
+
|
|
279
|
+
Assumes it is mixed into a class that stores a composed JsonRpcClient on the
|
|
280
|
+
instance attribute ``self._rpc`` (an instance of
|
|
281
|
+
mcp_proxy_adapter.client.jsonrpc_client.client.JsonRpcClient), which supplies
|
|
282
|
+
the coroutine:
|
|
283
|
+
|
|
284
|
+
async def execute_command_unified(
|
|
285
|
+
self,
|
|
286
|
+
command: str,
|
|
287
|
+
params: Optional[Dict[str, Any]] = None,
|
|
288
|
+
*,
|
|
289
|
+
use_cmd_endpoint: bool = False,
|
|
290
|
+
expect_queue: Optional[bool] = None,
|
|
291
|
+
auto_poll: bool = True,
|
|
292
|
+
poll_interval: float = 1.0,
|
|
293
|
+
timeout: Optional[float] = None,
|
|
294
|
+
status_hook: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
|
|
295
|
+
manual_event_handling: bool = False,
|
|
296
|
+
) -> Any
|
|
297
|
+
|
|
298
|
+
which returns {"mode": "immediate", "result": ...} for immediate commands or
|
|
299
|
+
{"mode": "queued", "job_id": ..., "status": ..., "result": ...} for commands
|
|
300
|
+
the server ran via the queue (auto_poll=True waits for completion via
|
|
301
|
+
WebSocket before returning, applying queue_get_job_status semantics). The
|
|
302
|
+
composed JsonRpcClient is held, never inherited, so no transport coroutine
|
|
303
|
+
leaks onto the public facade surface.
|
|
304
|
+
"""
|
|
305
|
+
|
|
306
|
+
async def _call(self, command: str, params: dict[str, Any] | None = None) -> Any:
|
|
307
|
+
"""Dispatch one plan_manager command and return its unwrapped result.
|
|
308
|
+
|
|
309
|
+
Always calls self._rpc.execute_command_unified(command, params, auto_poll=True)
|
|
310
|
+
so a queued command is auto-polled to completion (queue_get_job_status
|
|
311
|
+
semantics) before this coroutine returns. The held JsonRpcClient's
|
|
312
|
+
configured timeout is forwarded unchanged so the adapter's own terminal
|
|
313
|
+
wait remains bounded by the caller-selected budget. Returns the "result"
|
|
314
|
+
value from the unified response for both immediate and queued commands.
|
|
315
|
+
"""
|
|
316
|
+
try:
|
|
317
|
+
check_call_ids(params, command) # uuid4 discipline (block 2, D7): an invalid identifier is refused before the call is sent
|
|
318
|
+
except InvalidIdentifierError as refusal:
|
|
319
|
+
# The refusal is the command's own answer (-32602, violation value), never an exception out of the facade (0153 GI2).
|
|
320
|
+
return refusal.as_answer()
|
|
321
|
+
diagnostic = command in AUTHORING_DIAGNOSTIC_COMMANDS
|
|
322
|
+
correlation_id = new_correlation_id() if diagnostic else ""
|
|
323
|
+
if diagnostic:
|
|
324
|
+
emit_authoring_diagnostic(
|
|
325
|
+
"client.dispatch.start",
|
|
326
|
+
command=command,
|
|
327
|
+
correlation_id=correlation_id,
|
|
328
|
+
parameters=params or {},
|
|
329
|
+
)
|
|
330
|
+
try:
|
|
331
|
+
response = await self._rpc.execute_command_unified( # type: ignore[attr-defined]
|
|
332
|
+
command,
|
|
333
|
+
params or {},
|
|
334
|
+
auto_poll=True,
|
|
335
|
+
timeout=getattr(self._rpc, "timeout", None), # type: ignore[attr-defined]
|
|
336
|
+
)
|
|
337
|
+
except CommandSessionFailedError as exc:
|
|
338
|
+
validation_error = await _queued_validation_error_result(self._rpc, exc)
|
|
339
|
+
if validation_error is not None:
|
|
340
|
+
if diagnostic:
|
|
341
|
+
emit_authoring_diagnostic(
|
|
342
|
+
"client.response.error",
|
|
343
|
+
command=command,
|
|
344
|
+
correlation_id=correlation_id,
|
|
345
|
+
parameters=params or {},
|
|
346
|
+
error=validation_error,
|
|
347
|
+
)
|
|
348
|
+
return validation_error
|
|
349
|
+
if diagnostic:
|
|
350
|
+
emit_authoring_diagnostic(
|
|
351
|
+
"client.response.error",
|
|
352
|
+
command=command,
|
|
353
|
+
correlation_id=correlation_id,
|
|
354
|
+
parameters=params or {},
|
|
355
|
+
error=exc,
|
|
356
|
+
)
|
|
357
|
+
raise
|
|
358
|
+
result = response.get("result")
|
|
359
|
+
if diagnostic:
|
|
360
|
+
event = "client.response.error" if _is_error_result(result) else "client.response.success"
|
|
361
|
+
emit_authoring_diagnostic(
|
|
362
|
+
event,
|
|
363
|
+
command=command,
|
|
364
|
+
correlation_id=correlation_id,
|
|
365
|
+
parameters=params or {},
|
|
366
|
+
result=None if event.endswith("error") else result,
|
|
367
|
+
error=result if event.endswith("error") else None,
|
|
368
|
+
)
|
|
369
|
+
return result
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _is_error_result(result: object) -> bool:
|
|
373
|
+
return (
|
|
374
|
+
isinstance(result, dict)
|
|
375
|
+
and result.get("success") is False
|
|
376
|
+
and isinstance(result.get("error"), dict)
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
__all__ = ["_CommandDispatchMixin"]
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Chunked retrieval and in-memory reassembly of exported plan files (Path B).
|
|
2
|
+
|
|
3
|
+
The plan_manager server serves exported files only through the export_read command, which
|
|
4
|
+
returns a bounded base64 chunk of one file together with the whole file's size, its sha256
|
|
5
|
+
digest, and an end-of-file marker. This module drives that command in a loop to reassemble
|
|
6
|
+
each requested file byte-identically in memory. Nothing here touches the filesystem and
|
|
7
|
+
nothing here verifies the digest: the reassembled buffer carries the digest the server
|
|
8
|
+
declared so a later verification gate can check it before any byte is persisted.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import base64
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any, Protocol, Sequence
|
|
16
|
+
|
|
17
|
+
# Maximum number of DECODED bytes export_read returns in a single chunk.
|
|
18
|
+
MAX_CHUNK_BYTES: int = 262144
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ExportChunkProtocolError(RuntimeError):
|
|
22
|
+
"""Raised when an export_read chunk sequence cannot be reassembled.
|
|
23
|
+
|
|
24
|
+
Covers a chunk that makes no progress before the end-of-file marker, a file whose
|
|
25
|
+
declared size or digest changes between chunks, and a reassembled length that
|
|
26
|
+
disagrees with the declared total size. It names only the plan-relative file name:
|
|
27
|
+
it never carries connection, credential, or server-side path information.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, filename: str, detail: str) -> None:
|
|
31
|
+
super().__init__(f"export chunk protocol error for {filename!r}: {detail}")
|
|
32
|
+
self.filename = filename
|
|
33
|
+
self.detail = detail
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SupportsExportRead(Protocol):
|
|
37
|
+
"""The single export_read call this module needs from a client facade."""
|
|
38
|
+
|
|
39
|
+
async def export_read(
|
|
40
|
+
self, plan: str, file: str, offset: int = 0, limit: int = MAX_CHUNK_BYTES
|
|
41
|
+
) -> dict[str, Any]:
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class ReassembledFile:
|
|
47
|
+
"""One exported file reassembled in memory, pending digest verification.
|
|
48
|
+
|
|
49
|
+
Attributes:
|
|
50
|
+
filename: The plan-relative name of the file, exactly as requested from the server.
|
|
51
|
+
content: The fully reassembled bytes of the file, in original order.
|
|
52
|
+
declared_total_size: The whole-file size the server declared for this file.
|
|
53
|
+
declared_sha256: The whole-file hex sha256 digest the server declared for this file.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
filename: str
|
|
57
|
+
content: bytes
|
|
58
|
+
declared_total_size: int
|
|
59
|
+
declared_sha256: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def fetch_export_file(
|
|
63
|
+
client: SupportsExportRead,
|
|
64
|
+
plan: str,
|
|
65
|
+
file: str,
|
|
66
|
+
chunk_limit: int = MAX_CHUNK_BYTES,
|
|
67
|
+
) -> ReassembledFile:
|
|
68
|
+
"""Reassemble one exported file in memory by looping export_read.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
client: An object exposing the export_read coroutine.
|
|
72
|
+
plan: The plan identifier whose export directory owns the file.
|
|
73
|
+
file: The plan-relative path of the file to retrieve.
|
|
74
|
+
chunk_limit: Maximum decoded bytes to request per chunk; must be in [1, 262144].
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
The ReassembledFile carrying the file's bytes and the server-declared size and digest.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
ValueError: If chunk_limit is outside [1, 262144].
|
|
81
|
+
ExportChunkProtocolError: If the chunk sequence cannot be reassembled.
|
|
82
|
+
"""
|
|
83
|
+
if chunk_limit < 1 or chunk_limit > MAX_CHUNK_BYTES:
|
|
84
|
+
raise ValueError(f"chunk_limit must be an integer in [1, {MAX_CHUNK_BYTES}]")
|
|
85
|
+
|
|
86
|
+
buffer = bytearray()
|
|
87
|
+
offset = 0
|
|
88
|
+
declared_total_size: int | None = None
|
|
89
|
+
declared_sha256: str | None = None
|
|
90
|
+
|
|
91
|
+
while True:
|
|
92
|
+
response = await client.export_read(
|
|
93
|
+
plan=plan, file=file, offset=offset, limit=chunk_limit
|
|
94
|
+
)
|
|
95
|
+
total_size = int(response["total_size"])
|
|
96
|
+
sha256 = str(response["sha256"])
|
|
97
|
+
if declared_total_size is None:
|
|
98
|
+
declared_total_size = total_size
|
|
99
|
+
declared_sha256 = sha256
|
|
100
|
+
elif total_size != declared_total_size or sha256 != declared_sha256:
|
|
101
|
+
raise ExportChunkProtocolError(
|
|
102
|
+
file, "declared size or digest changed between chunks"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
chunk = base64.b64decode(response["chunk_base64"])
|
|
106
|
+
eof = bool(response["eof"])
|
|
107
|
+
if not chunk and not eof:
|
|
108
|
+
raise ExportChunkProtocolError(
|
|
109
|
+
file, "server returned an empty chunk before the end of the file"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
buffer.extend(chunk)
|
|
113
|
+
offset += len(chunk)
|
|
114
|
+
if eof:
|
|
115
|
+
break
|
|
116
|
+
|
|
117
|
+
if declared_total_size is None or declared_sha256 is None:
|
|
118
|
+
raise ExportChunkProtocolError(file, "no chunk was returned")
|
|
119
|
+
if len(buffer) != declared_total_size:
|
|
120
|
+
raise ExportChunkProtocolError(
|
|
121
|
+
file, "reassembled length does not match the declared total size"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
return ReassembledFile(
|
|
125
|
+
filename=file,
|
|
126
|
+
content=bytes(buffer),
|
|
127
|
+
declared_total_size=declared_total_size,
|
|
128
|
+
declared_sha256=declared_sha256,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def fetch_export_files(
|
|
133
|
+
client: SupportsExportRead,
|
|
134
|
+
plan: str,
|
|
135
|
+
files: Sequence[str],
|
|
136
|
+
chunk_limit: int = MAX_CHUNK_BYTES,
|
|
137
|
+
) -> list[ReassembledFile]:
|
|
138
|
+
"""Reassemble every named exported file, in the order given.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
client: An object exposing the export_read coroutine.
|
|
142
|
+
plan: The plan identifier whose export directory owns the files.
|
|
143
|
+
files: The plan-relative paths of the files to retrieve.
|
|
144
|
+
chunk_limit: Maximum decoded bytes to request per chunk; must be in [1, 262144].
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
One ReassembledFile per requested file, in the same order as files.
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
ValueError: If chunk_limit is outside [1, 262144].
|
|
151
|
+
ExportChunkProtocolError: If any file's chunk sequence cannot be reassembled.
|
|
152
|
+
"""
|
|
153
|
+
return [
|
|
154
|
+
await fetch_export_file(client, plan, file, chunk_limit=chunk_limit)
|
|
155
|
+
for file in files
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
__all__ = [
|
|
160
|
+
"MAX_CHUNK_BYTES",
|
|
161
|
+
"ExportChunkProtocolError",
|
|
162
|
+
"ReassembledFile",
|
|
163
|
+
"SupportsExportRead",
|
|
164
|
+
"fetch_export_file",
|
|
165
|
+
"fetch_export_files",
|
|
166
|
+
]
|