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,140 @@
|
|
|
1
|
+
"""Path A verified export tree materialization from the archive.
|
|
2
|
+
|
|
3
|
+
Verifies the retrieved export archive against its declared sha256 BEFORE
|
|
4
|
+
unpacking anything (DeliveryIntegrityContract, MRS concept C-007), then
|
|
5
|
+
unpacks the gzip-compressed tar in memory into ordered tree entries that
|
|
6
|
+
preserve every original name and relative position (ExportArchive, MRS
|
|
7
|
+
concept C-016). Every entry is validated before any entry is materialized,
|
|
8
|
+
so a refusal leaves nothing behind. Nothing enumerates an export, so the
|
|
9
|
+
archive is the only way Path A obtains a whole tree. Produces no local
|
|
10
|
+
files.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
import hashlib
|
|
15
|
+
import io
|
|
16
|
+
import tarfile
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ArchiveIntegrityError(Exception):
|
|
21
|
+
"""Raised when the export archive does not match its declared sha256.
|
|
22
|
+
|
|
23
|
+
Raised by :func:`materialize_tree` before any unpacking is attempted,
|
|
24
|
+
per DeliveryIntegrityContract (MRS concept C-007). Nothing is unpacked
|
|
25
|
+
and nothing reaches any destination when this is raised.
|
|
26
|
+
"""
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ArchiveEntryRefusedError(Exception):
|
|
31
|
+
"""Raised when an archive entry is refused during validation.
|
|
32
|
+
|
|
33
|
+
Raised by :func:`materialize_tree` for an entry whose stored path is
|
|
34
|
+
absolute or contains a '..' segment, or which is neither a regular
|
|
35
|
+
file nor a directory. Per ExportArchive (MRS concept C-016) every
|
|
36
|
+
entry is validated before any entry is materialized, so a refusal
|
|
37
|
+
leaves nothing behind.
|
|
38
|
+
"""
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class TreeEntry:
|
|
44
|
+
"""One file of the verified export tree, in memory.
|
|
45
|
+
|
|
46
|
+
Attributes:
|
|
47
|
+
relative_path: The entry's POSIX path relative to the plan export
|
|
48
|
+
root, exactly as stored in the archive (for example
|
|
49
|
+
'spec.yaml' or 'G-001-foo/T-001-bar/atomic_steps/A-001-baz.yaml').
|
|
50
|
+
content: The entry's unpacked bytes, byte-for-byte as archived.
|
|
51
|
+
sha256: The hex sha256 digest computed over content.
|
|
52
|
+
"""
|
|
53
|
+
relative_path: str
|
|
54
|
+
content: bytes
|
|
55
|
+
sha256: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _validate_member(member: tarfile.TarInfo) -> None:
|
|
59
|
+
"""Validate one archive member, refusing it if it is unsafe.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
member: The archive member to validate.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
ArchiveEntryRefusedError: If member is neither a regular file nor
|
|
66
|
+
a directory, or if member.name starts with '/' or any
|
|
67
|
+
'/'-separated segment of member.name equals '..'.
|
|
68
|
+
"""
|
|
69
|
+
if not member.isfile() and not member.isdir():
|
|
70
|
+
raise ArchiveEntryRefusedError(
|
|
71
|
+
f"archive entry is neither a regular file nor a directory: {member.name!r}"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if member.name.startswith('/') or '..' in member.name.split('/'):
|
|
75
|
+
raise ArchiveEntryRefusedError(
|
|
76
|
+
f"archive entry escapes the destination: {member.name!r}"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def materialize_tree(
|
|
81
|
+
archive_bytes: bytes,
|
|
82
|
+
declared_archive_sha256: str,
|
|
83
|
+
) -> list[TreeEntry]:
|
|
84
|
+
"""Verify the archive, then unpack its tree in memory.
|
|
85
|
+
|
|
86
|
+
Validation is exhaustive and precedes materialization: every member is
|
|
87
|
+
validated before any member's bytes are read, so a refusal returns
|
|
88
|
+
nothing and leaves nothing behind (ExportArchive, MRS concept C-016).
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
archive_bytes: The already-retrieved archive content, a
|
|
92
|
+
gzip-compressed tar.
|
|
93
|
+
declared_archive_sha256: The archive's declared whole-archive
|
|
94
|
+
sha256 hex digest.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
The tree entries in the order the archive stores them, each
|
|
98
|
+
carrying its export-root-relative path, its unpacked content, and
|
|
99
|
+
the sha256 computed over that content. Directory members are
|
|
100
|
+
skipped rather than returned.
|
|
101
|
+
|
|
102
|
+
Raises:
|
|
103
|
+
ArchiveIntegrityError: If sha256(archive_bytes) does not equal
|
|
104
|
+
declared_archive_sha256. Raised before any unpacking; nothing
|
|
105
|
+
is unpacked.
|
|
106
|
+
ArchiveEntryRefusedError: If ANY member is refused by
|
|
107
|
+
_validate_member. Raised during the validation pass, before
|
|
108
|
+
any member's bytes are read, so no entry is returned.
|
|
109
|
+
"""
|
|
110
|
+
computed = hashlib.sha256(archive_bytes).hexdigest()
|
|
111
|
+
|
|
112
|
+
if computed != declared_archive_sha256:
|
|
113
|
+
raise ArchiveIntegrityError(
|
|
114
|
+
f"archive digest mismatch: expected {declared_archive_sha256}, computed {computed}"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
with tarfile.open(fileobj=io.BytesIO(archive_bytes), mode="r:gz") as tar:
|
|
118
|
+
# Validation pass: exhaustive check before any materialization
|
|
119
|
+
members = tar.getmembers()
|
|
120
|
+
for member in members:
|
|
121
|
+
_validate_member(member)
|
|
122
|
+
|
|
123
|
+
# Materialization pass: extract bytes and build entries
|
|
124
|
+
result: list[TreeEntry] = []
|
|
125
|
+
for member in members:
|
|
126
|
+
if member.isdir():
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
extracted_file = tar.extractfile(member)
|
|
130
|
+
content = extracted_file.read()
|
|
131
|
+
entry_digest = hashlib.sha256(content).hexdigest()
|
|
132
|
+
result.append(
|
|
133
|
+
TreeEntry(
|
|
134
|
+
relative_path=member.name,
|
|
135
|
+
content=content,
|
|
136
|
+
sha256=entry_digest,
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
return result
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Path A failure atomicity and partial-write outcome tracking.
|
|
2
|
+
|
|
3
|
+
Assembles the delivery outcome record naming exactly which destination
|
|
4
|
+
paths were written and whether a commit was made, distinguishing every
|
|
5
|
+
abort point of the Path A composition, realizing DeliveryFailureAtomicity
|
|
6
|
+
(MRS concept C-011). An archive-verification or archive-entry-refusal
|
|
7
|
+
abort leaves nothing behind, so the record cannot express either of them
|
|
8
|
+
alongside written paths.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Literal, Optional
|
|
15
|
+
|
|
16
|
+
FailurePoint = Literal["archive_verification", "archive_entry_refused", "digest_verification", "upload", "commit"]
|
|
17
|
+
# The Path A composition stage at which a delivery failed, when it failed (MRS concept C-011).
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class DeliveryOutcome:
|
|
22
|
+
"""Outcome of one Path A delivery attempt.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
written_paths: Destination paths successfully written to the CA
|
|
26
|
+
project during this attempt, in delivery order, each carrying
|
|
27
|
+
its entry's relative position beneath the destination
|
|
28
|
+
subdirectory.
|
|
29
|
+
commit_made: True when a git commit was made for this attempt.
|
|
30
|
+
commit_hash: The git commit hash, when commit_made is True;
|
|
31
|
+
otherwise None.
|
|
32
|
+
failure_point: The composition stage at which the delivery failed,
|
|
33
|
+
or None when the delivery completed successfully. A failure at
|
|
34
|
+
'archive_verification' or 'archive_entry_refused' always
|
|
35
|
+
carries an empty written_paths: the archive was rejected or
|
|
36
|
+
the whole tree was refused before any entry was materialized,
|
|
37
|
+
so nothing was written and nothing was left behind.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
written_paths: list[str] = field(default_factory=list)
|
|
41
|
+
commit_made: bool = False
|
|
42
|
+
commit_hash: Optional[str] = None
|
|
43
|
+
failure_point: Optional[FailurePoint] = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_VALID_FAILURE_POINTS: tuple[str, ...] = ("archive_verification", "archive_entry_refused", "digest_verification", "upload", "commit")
|
|
47
|
+
|
|
48
|
+
_NOTHING_WRITTEN_FAILURE_POINTS: tuple[str, ...] = ("archive_verification", "archive_entry_refused")
|
|
49
|
+
# Abort points that precede every write: the archive never verified, or the whole tree was refused before any entry was materialized. Both leave nothing behind, so neither can accompany a non-empty written_paths.
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_delivery_outcome(
|
|
53
|
+
written_paths: list[str],
|
|
54
|
+
commit_hash: Optional[str],
|
|
55
|
+
failure_point: Optional[FailurePoint] = None,
|
|
56
|
+
) -> DeliveryOutcome:
|
|
57
|
+
"""Assemble a DeliveryOutcome from one Path A delivery attempt's results.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
written_paths: Destination paths successfully written during this
|
|
61
|
+
attempt, in delivery order.
|
|
62
|
+
commit_hash: The git commit hash if a commit was made, else None.
|
|
63
|
+
failure_point: The stage at which the delivery failed, or None for
|
|
64
|
+
a fully successful delivery.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
A DeliveryOutcome with commit_made set to (commit_hash is not
|
|
68
|
+
None), and written_paths, commit_hash, failure_point carried
|
|
69
|
+
through unchanged (written_paths copied, not aliased).
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
ValueError: If failure_point is not None and not one of
|
|
73
|
+
'archive_verification', 'archive_entry_refused',
|
|
74
|
+
'digest_verification', 'upload', 'commit'; or if
|
|
75
|
+
failure_point is 'archive_verification' or
|
|
76
|
+
'archive_entry_refused' while written_paths is non-empty,
|
|
77
|
+
which is an impossible outcome because both abort points
|
|
78
|
+
precede every write and leave nothing behind.
|
|
79
|
+
"""
|
|
80
|
+
if failure_point is not None and failure_point not in _VALID_FAILURE_POINTS:
|
|
81
|
+
raise ValueError(f"invalid failure_point: {failure_point!r}")
|
|
82
|
+
|
|
83
|
+
if failure_point in _NOTHING_WRITTEN_FAILURE_POINTS and len(written_paths) > 0:
|
|
84
|
+
raise ValueError(f"failure_point {failure_point!r} leaves nothing behind but written_paths is non-empty: {list(written_paths)!r}")
|
|
85
|
+
|
|
86
|
+
commit_made = commit_hash is not None
|
|
87
|
+
|
|
88
|
+
return DeliveryOutcome(
|
|
89
|
+
written_paths=list(written_paths),
|
|
90
|
+
commit_made=commit_made,
|
|
91
|
+
commit_hash=commit_hash,
|
|
92
|
+
failure_point=failure_point,
|
|
93
|
+
)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Path A explicit git staging and commit composition.
|
|
2
|
+
|
|
3
|
+
After the tree upload phase, explicitly stages the written destination
|
|
4
|
+
paths and issues an explicit commit through the CA service's own client
|
|
5
|
+
(CodeAnalysisClientDependency, MRS concept C-015), independent of the CA
|
|
6
|
+
service's commit-on-write configuration, realizing the commit phase of
|
|
7
|
+
PathACodeAnalysisDelivery (MRS concept C-003) and the deterministic-message
|
|
8
|
+
requirement of DeliveryFailureAtomicity (MRS concept C-011).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Optional
|
|
14
|
+
from code_analysis_client.client import CodeAnalysisAsyncClient
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class CommitReceipt:
|
|
19
|
+
"""Outcome of the explicit git staging and commit composition.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
commit_hash: The git commit hash the CA client returned, or None
|
|
23
|
+
when no commit was made (empty written_paths).
|
|
24
|
+
commit_message: The deterministic commit message text used, or
|
|
25
|
+
an empty string when no commit was made.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
commit_hash: Optional[str]
|
|
29
|
+
commit_message: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_commit_message(
|
|
33
|
+
plan_name: str,
|
|
34
|
+
export_revision_uuid: str,
|
|
35
|
+
per_entry_digest_set: dict[str, str],
|
|
36
|
+
) -> str:
|
|
37
|
+
"""Build the deterministic Path A commit message.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
plan_name: Name of the plan being delivered.
|
|
41
|
+
export_revision_uuid: Revision uuid of the export being delivered.
|
|
42
|
+
per_entry_digest_set: Mapping of each delivered tree entry's path
|
|
43
|
+
relative to the plan export root to its sha256 hex digest.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
A deterministic multi-line commit message: a header line naming
|
|
47
|
+
plan_name and export_revision_uuid, followed by one line per entry
|
|
48
|
+
of per_entry_digest_set sorted by relative path ascending (str
|
|
49
|
+
order), each formatted as two spaces then
|
|
50
|
+
'{relative_path}: sha256:{digest}'. Sorting makes the message
|
|
51
|
+
reproducible across retried deliveries regardless of input dict
|
|
52
|
+
ordering.
|
|
53
|
+
"""
|
|
54
|
+
header = f"plan_manager export delivery: {plan_name} (revision {export_revision_uuid})"
|
|
55
|
+
lines = [header] + [f" {relative_path}: sha256:{digest}" for relative_path, digest in sorted(per_entry_digest_set.items())]
|
|
56
|
+
return "\n".join(lines)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def stage_and_commit(
|
|
60
|
+
ca_client: CodeAnalysisAsyncClient,
|
|
61
|
+
resolved_project_id: str,
|
|
62
|
+
written_paths: list[str],
|
|
63
|
+
plan_name: str,
|
|
64
|
+
export_revision_uuid: str,
|
|
65
|
+
per_entry_digest_set: dict[str, str],
|
|
66
|
+
) -> CommitReceipt:
|
|
67
|
+
"""Explicitly stage and commit the written Path A tree.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
ca_client: An already-connected CodeAnalysisAsyncClient instance
|
|
71
|
+
(MRS concept C-015); this function does not construct or
|
|
72
|
+
close it.
|
|
73
|
+
resolved_project_id: The CA project_id the tree was written into.
|
|
74
|
+
written_paths: Destination paths successfully uploaded, each
|
|
75
|
+
carrying its entry's relative position beneath the destination
|
|
76
|
+
subdirectory.
|
|
77
|
+
plan_name: Name of the plan being delivered.
|
|
78
|
+
export_revision_uuid: Revision uuid of the export being delivered.
|
|
79
|
+
per_entry_digest_set: Mapping of each delivered tree entry's
|
|
80
|
+
export-root-relative path to its sha256 digest.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
A CommitReceipt. When written_paths is empty, CommitReceipt(None, '')
|
|
84
|
+
is returned without any CA git call. Otherwise the CA client's
|
|
85
|
+
git_add is called with resolved_project_id and written_paths, then
|
|
86
|
+
its git_commit is called with resolved_project_id and the message
|
|
87
|
+
from build_commit_message; the returned CommitReceipt carries the
|
|
88
|
+
commit hash the CA client reports and the message used.
|
|
89
|
+
"""
|
|
90
|
+
if len(written_paths) == 0:
|
|
91
|
+
return CommitReceipt(commit_hash=None, commit_message="")
|
|
92
|
+
|
|
93
|
+
await ca_client.commands.git_add(project_id=resolved_project_id, paths=written_paths)
|
|
94
|
+
|
|
95
|
+
message = build_commit_message(plan_name, export_revision_uuid, per_entry_digest_set)
|
|
96
|
+
|
|
97
|
+
result = await ca_client.commands.git_commit(project_id=resolved_project_id, message=message)
|
|
98
|
+
|
|
99
|
+
def extract_commit_hash(result_obj):
|
|
100
|
+
# Try attribute access first
|
|
101
|
+
if hasattr(result_obj, 'commit_hash'):
|
|
102
|
+
return result_obj.commit_hash
|
|
103
|
+
# Try dict-like access
|
|
104
|
+
try:
|
|
105
|
+
return result_obj["commit_hash"]
|
|
106
|
+
except (TypeError, KeyError):
|
|
107
|
+
pass
|
|
108
|
+
# Fall back to str
|
|
109
|
+
return str(result_obj)
|
|
110
|
+
|
|
111
|
+
commit_hash = extract_commit_hash(result)
|
|
112
|
+
|
|
113
|
+
return CommitReceipt(commit_hash=commit_hash, commit_message=message)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Path A destination resolution: CA project binding and tree paths.
|
|
2
|
+
|
|
3
|
+
Resolves the target code-analysis project identity under SHARED PROJECT
|
|
4
|
+
IDENTITY (DeliveryDestinationAddressing, MRS concept C-006) and maps each
|
|
5
|
+
export tree entry to its destination path inside the project's
|
|
6
|
+
documentation area, preserving the entry's relative position beneath the
|
|
7
|
+
destination subdirectory so the delivered tree reproduces the export
|
|
8
|
+
layout (PathACodeAnalysisDelivery, MRS concept C-003).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NoBoundProjectError(Exception):
|
|
17
|
+
"""Raised when a Path A delivery is attempted with no bound CA project.
|
|
18
|
+
|
|
19
|
+
Raised by :func:`resolve_destination` when both ``plan_project_binding``
|
|
20
|
+
and ``project_id_override`` are ``None``: SHARED PROJECT IDENTITY (C-006)
|
|
21
|
+
requires the plan to carry a bound primary code-analysis project_id, or
|
|
22
|
+
an explicit override, before a Path A call may proceed.
|
|
23
|
+
"""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DestinationPathTraversalError(Exception):
|
|
28
|
+
"""Raised when a resolved destination path escapes the project root.
|
|
29
|
+
|
|
30
|
+
Raised by :func:`resolve_destination` when the destination subdirectory
|
|
31
|
+
or a per-entry destination path is absolute or contains a '..' path
|
|
32
|
+
segment, per the traversal-refusal rule of DeliveryDestinationAddressing
|
|
33
|
+
(MRS concept C-006).
|
|
34
|
+
"""
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
DEFAULT_DESTINATION_SUBDIRECTORY_TEMPLATE: str = "docs/plan-exports/{plan_name}/"
|
|
39
|
+
# Default destination subdirectory template (MRS concept C-006); it roots the delivered tree and is formatted with plan_name when the caller supplies no override.
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class ResolvedDestination:
|
|
44
|
+
"""Resolved CA project and per-entry destination paths for Path A.
|
|
45
|
+
|
|
46
|
+
Attributes:
|
|
47
|
+
resolved_project_id: The code-analysis project_id the delivery
|
|
48
|
+
will write into.
|
|
49
|
+
resolved_destination_subdirectory: The destination documentation
|
|
50
|
+
subdirectory rooting the delivered tree, project-relative
|
|
51
|
+
POSIX, always ending in a single trailing '/'.
|
|
52
|
+
resolved_destination_paths: Ordered per-entry project-relative
|
|
53
|
+
POSIX destination paths, one per entry of tree_relative_paths
|
|
54
|
+
in the same order, each preserving that entry's relative
|
|
55
|
+
position beneath the subdirectory.
|
|
56
|
+
"""
|
|
57
|
+
resolved_project_id: str
|
|
58
|
+
resolved_destination_subdirectory: str
|
|
59
|
+
resolved_destination_paths: list[str] = field(default_factory=list)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _reject_traversal(path: str) -> None:
|
|
63
|
+
"""Raise DestinationPathTraversalError for an unsafe POSIX path.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
path: A POSIX path string to validate.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
DestinationPathTraversalError: If path starts with '/' or any
|
|
70
|
+
'/'-separated segment of path equals '..'.
|
|
71
|
+
"""
|
|
72
|
+
if path.startswith('/') or '..' in path.split('/'):
|
|
73
|
+
raise DestinationPathTraversalError(f"destination path escapes the project root: {path!r}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def resolve_destination(
|
|
77
|
+
plan_name: str,
|
|
78
|
+
tree_relative_paths: list[str],
|
|
79
|
+
plan_project_binding: Optional[str],
|
|
80
|
+
project_id_override: Optional[str] = None,
|
|
81
|
+
destination_subdirectory_override: Optional[str] = None,
|
|
82
|
+
) -> ResolvedDestination:
|
|
83
|
+
"""Resolve the CA project and per-entry destination paths for Path A.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
plan_name: Name of the plan being delivered; used to format the
|
|
87
|
+
default destination subdirectory when no override is supplied.
|
|
88
|
+
tree_relative_paths: The export tree entries' POSIX paths relative
|
|
89
|
+
to the plan export root, in tree order. These are nested paths
|
|
90
|
+
such as 'spec.yaml' or
|
|
91
|
+
'G-001-x/T-001-y/atomic_steps/A-001-z.yaml'; their relative
|
|
92
|
+
position is preserved into the destination.
|
|
93
|
+
plan_project_binding: The plan's bound primary code-analysis
|
|
94
|
+
project_id, or None when the plan has no bound project.
|
|
95
|
+
project_id_override: An explicit code-analysis project_id supplied
|
|
96
|
+
by the caller; takes precedence over plan_project_binding when
|
|
97
|
+
not None.
|
|
98
|
+
destination_subdirectory_override: An explicit destination
|
|
99
|
+
documentation subdirectory rooting the delivered tree; when
|
|
100
|
+
None, the default template formatted with plan_name is used.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
A ResolvedDestination carrying the resolved project_id, the
|
|
104
|
+
resolved destination subdirectory (normalized to end with a single
|
|
105
|
+
trailing '/'), and the per-entry destination paths in the same
|
|
106
|
+
order as tree_relative_paths.
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
NoBoundProjectError: If both plan_project_binding and
|
|
110
|
+
project_id_override are None.
|
|
111
|
+
DestinationPathTraversalError: If the resolved destination
|
|
112
|
+
subdirectory is unsafe per _reject_traversal, or if any entry
|
|
113
|
+
of tree_relative_paths is itself unsafe per _reject_traversal,
|
|
114
|
+
or if any resolved destination path is unsafe per
|
|
115
|
+
_reject_traversal.
|
|
116
|
+
"""
|
|
117
|
+
resolved_project_id = project_id_override if project_id_override is not None else plan_project_binding
|
|
118
|
+
|
|
119
|
+
if resolved_project_id is None:
|
|
120
|
+
raise NoBoundProjectError("Path A delivery requires a bound or overridden CA project_id")
|
|
121
|
+
|
|
122
|
+
subdirectory = destination_subdirectory_override if destination_subdirectory_override else DEFAULT_DESTINATION_SUBDIRECTORY_TEMPLATE.format(plan_name=plan_name)
|
|
123
|
+
|
|
124
|
+
if not subdirectory.endswith('/'):
|
|
125
|
+
subdirectory = subdirectory + '/'
|
|
126
|
+
|
|
127
|
+
_reject_traversal(subdirectory)
|
|
128
|
+
|
|
129
|
+
destination_paths = []
|
|
130
|
+
for relative_path in tree_relative_paths:
|
|
131
|
+
_reject_traversal(relative_path)
|
|
132
|
+
candidate = subdirectory + relative_path
|
|
133
|
+
_reject_traversal(candidate)
|
|
134
|
+
destination_paths.append(candidate)
|
|
135
|
+
|
|
136
|
+
return ResolvedDestination(
|
|
137
|
+
resolved_project_id=resolved_project_id,
|
|
138
|
+
resolved_destination_subdirectory=subdirectory,
|
|
139
|
+
resolved_destination_paths=destination_paths
|
|
140
|
+
)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Path A delivery provenance recording.
|
|
2
|
+
|
|
3
|
+
On a successful delivery, records a runtime comment on the exported plan
|
|
4
|
+
carrying the export revision, target CA project and the subdirectory
|
|
5
|
+
rooting the delivered tree, the per-entry digests keyed by relative path,
|
|
6
|
+
and the commit hash, realizing DeliveryProvenanceRecord (MRS concept
|
|
7
|
+
C-010).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
from typing import Any, Protocol
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CommentAddCapable(Protocol):
|
|
15
|
+
"""Structural type for a facade exposing the plan_manager comment_add command.
|
|
16
|
+
|
|
17
|
+
Matches the verified comment_add(plan, anchor_type, anchor_plan_uuid,
|
|
18
|
+
kind, visibility, author, body, created_by) command signature without
|
|
19
|
+
depending on the concrete facade class that implements it.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
async def comment_add(
|
|
23
|
+
self,
|
|
24
|
+
plan: str,
|
|
25
|
+
anchor_type: str,
|
|
26
|
+
anchor_plan_uuid: str,
|
|
27
|
+
kind: str,
|
|
28
|
+
visibility: str,
|
|
29
|
+
author: str,
|
|
30
|
+
body: str,
|
|
31
|
+
created_by: str,
|
|
32
|
+
) -> dict[str, Any]: ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def record_provenance(
|
|
36
|
+
planmgr_client: CommentAddCapable,
|
|
37
|
+
exported_plan_uuid: str,
|
|
38
|
+
plan_name: str,
|
|
39
|
+
export_revision_uuid: str,
|
|
40
|
+
resolved_project_id: str,
|
|
41
|
+
resolved_destination_subdirectory: str,
|
|
42
|
+
per_entry_digest_set: dict[str, str],
|
|
43
|
+
commit_hash: str,
|
|
44
|
+
author: str,
|
|
45
|
+
) -> str:
|
|
46
|
+
"""Record a successful Path A tree delivery as a runtime comment.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
planmgr_client: An object exposing an async comment_add method
|
|
50
|
+
matching CommentAddCapable; this function does not construct
|
|
51
|
+
or close it.
|
|
52
|
+
exported_plan_uuid: UUID of the plan that was exported and
|
|
53
|
+
delivered; used as both the comment_add 'plan' and
|
|
54
|
+
'anchor_plan_uuid' arguments.
|
|
55
|
+
plan_name: Name of the plan being delivered.
|
|
56
|
+
export_revision_uuid: Revision uuid of the delivered export.
|
|
57
|
+
resolved_project_id: Target CA project_id the tree was written
|
|
58
|
+
into.
|
|
59
|
+
resolved_destination_subdirectory: The destination subdirectory
|
|
60
|
+
rooting the delivered tree inside the CA project.
|
|
61
|
+
per_entry_digest_set: Mapping of each delivered tree entry's path
|
|
62
|
+
relative to the plan export root to its sha256 digest.
|
|
63
|
+
commit_hash: Git commit hash returned by the commit composition.
|
|
64
|
+
author: Identity recorded as both 'author' and 'created_by' on the
|
|
65
|
+
comment.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The 'comment_uuid' field of the comment_add response.
|
|
69
|
+
"""
|
|
70
|
+
digest_text = ", ".join(
|
|
71
|
+
f"{relative_path}=sha256:{digest}"
|
|
72
|
+
for relative_path, digest in sorted(per_entry_digest_set.items())
|
|
73
|
+
)
|
|
74
|
+
body = (
|
|
75
|
+
f"Path A tree delivery: revision {export_revision_uuid} -> project {resolved_project_id}, "
|
|
76
|
+
f"tree root {resolved_destination_subdirectory}; entries: {digest_text}; commit {commit_hash}"
|
|
77
|
+
)
|
|
78
|
+
response = await planmgr_client.comment_add(
|
|
79
|
+
plan=exported_plan_uuid,
|
|
80
|
+
anchor_type="plan",
|
|
81
|
+
anchor_plan_uuid=exported_plan_uuid,
|
|
82
|
+
kind="execution_note",
|
|
83
|
+
visibility="public_summary",
|
|
84
|
+
author=author,
|
|
85
|
+
body=body,
|
|
86
|
+
created_by=author,
|
|
87
|
+
)
|
|
88
|
+
return response["comment_uuid"]
|