|
@@ -9,6 +9,7 @@ business Artifact.
|
|
|
from __future__ import annotations
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
import base64
|
|
import base64
|
|
|
|
|
+import json
|
|
|
from collections.abc import Mapping, Sequence
|
|
from collections.abc import Mapping, Sequence
|
|
|
from dataclasses import asdict, dataclass, replace
|
|
from dataclasses import asdict, dataclass, replace
|
|
|
from hashlib import sha256
|
|
from hashlib import sha256
|
|
@@ -64,6 +65,11 @@ from script_build_host.domain.task_contracts import (
|
|
|
ScriptTaskContractV1,
|
|
ScriptTaskContractV1,
|
|
|
ScriptTaskKind,
|
|
ScriptTaskKind,
|
|
|
)
|
|
)
|
|
|
|
|
+from script_build_host.domain.workbench import (
|
|
|
|
|
+ CommandReceipt,
|
|
|
|
|
+ paragraph_target_key,
|
|
|
|
|
+ semantic_handle,
|
|
|
|
|
+)
|
|
|
from script_build_host.domain.workspaces import (
|
|
from script_build_host.domain.workspaces import (
|
|
|
CandidateWorkspaceSnapshot,
|
|
CandidateWorkspaceSnapshot,
|
|
|
CandidateWriteContext,
|
|
CandidateWriteContext,
|
|
@@ -122,6 +128,10 @@ class CandidateWorkspaceOperations(Protocol):
|
|
|
self, context: CandidateWriteContext, updates: Sequence[Mapping[str, Any]]
|
|
self, context: CandidateWriteContext, updates: Sequence[Mapping[str, Any]]
|
|
|
) -> tuple[int, ...]: ...
|
|
) -> tuple[int, ...]: ...
|
|
|
|
|
|
|
|
|
|
+ async def save_paragraphs(
|
|
|
|
|
+ self, context: CandidateWriteContext, paragraphs: Sequence[Mapping[str, Any]]
|
|
|
|
|
+ ) -> tuple[Mapping[str, int], tuple[int, ...]]: ...
|
|
|
|
|
+
|
|
|
async def append_paragraph_atoms(
|
|
async def append_paragraph_atoms(
|
|
|
self, context: CandidateWriteContext, **values: Any
|
|
self, context: CandidateWriteContext, **values: Any
|
|
|
) -> int: ...
|
|
) -> int: ...
|
|
@@ -130,6 +140,13 @@ class CandidateWorkspaceOperations(Protocol):
|
|
|
|
|
|
|
|
async def create_element(self, context: CandidateWriteContext, **values: Any) -> int: ...
|
|
async def create_element(self, context: CandidateWriteContext, **values: Any) -> int: ...
|
|
|
|
|
|
|
|
|
|
+ async def create_elements(
|
|
|
|
|
+ self,
|
|
|
|
|
+ context: CandidateWriteContext,
|
|
|
|
|
+ elements: Sequence[Mapping[str, Any]],
|
|
|
|
|
+ links: Sequence[Mapping[str, Any]],
|
|
|
|
|
+ ) -> tuple[Mapping[str, int], int]: ...
|
|
|
|
|
+
|
|
|
async def update_element(
|
|
async def update_element(
|
|
|
self, context: CandidateWriteContext, *, element_id: int, values: Mapping[str, Any]
|
|
self, context: CandidateWriteContext, *, element_id: int, values: Mapping[str, Any]
|
|
|
) -> None: ...
|
|
) -> None: ...
|
|
@@ -255,7 +272,7 @@ class PhaseTwoCandidateService:
|
|
|
async def view_frozen_images(
|
|
async def view_frozen_images(
|
|
|
self,
|
|
self,
|
|
|
*,
|
|
*,
|
|
|
- raw_artifact_refs: Sequence[str],
|
|
|
|
|
|
|
+ image_handles: Sequence[str],
|
|
|
context: Mapping[str, Any],
|
|
context: Mapping[str, Any],
|
|
|
) -> Sequence[Mapping[str, Any]]:
|
|
) -> Sequence[Mapping[str, Any]]:
|
|
|
if self._raw_artifacts is None:
|
|
if self._raw_artifacts is None:
|
|
@@ -263,22 +280,24 @@ class PhaseTwoCandidateService:
|
|
|
"LEGACY_REFERENCE_INVALID", "raw Artifact storage is not configured"
|
|
"LEGACY_REFERENCE_INVALID", "raw Artifact storage is not configured"
|
|
|
)
|
|
)
|
|
|
if (
|
|
if (
|
|
|
- not raw_artifact_refs
|
|
|
|
|
- or len(raw_artifact_refs) > self._max_images
|
|
|
|
|
- or len(set(raw_artifact_refs)) != len(raw_artifact_refs)
|
|
|
|
|
|
|
+ not image_handles
|
|
|
|
|
+ or len(image_handles) > self._max_images
|
|
|
|
|
+ or len(set(image_handles)) != len(image_handles)
|
|
|
):
|
|
):
|
|
|
raise PhaseTwoCandidateError(
|
|
raise PhaseTwoCandidateError(
|
|
|
"LEGACY_REFERENCE_INVALID", "image references exceed the frozen view limit"
|
|
"LEGACY_REFERENCE_INVALID", "image references exceed the frozen view limit"
|
|
|
)
|
|
)
|
|
|
scope, bundle = await self._resolve_bundle(context)
|
|
scope, bundle = await self._resolve_bundle(context)
|
|
|
allowed = await self._allowed_raw_refs(scope, bundle, context)
|
|
allowed = await self._allowed_raw_refs(scope, bundle, context)
|
|
|
- if any(ref not in allowed for ref in raw_artifact_refs):
|
|
|
|
|
|
|
+ refs_by_handle = {semantic_handle("image", ref): ref for ref in allowed}
|
|
|
|
|
+ if any(handle not in refs_by_handle for handle in image_handles):
|
|
|
raise PhaseTwoCandidateError(
|
|
raise PhaseTwoCandidateError(
|
|
|
"LEGACY_REFERENCE_INVALID", "image is outside the accepted frozen inputs"
|
|
"LEGACY_REFERENCE_INVALID", "image is outside the accepted frozen inputs"
|
|
|
)
|
|
)
|
|
|
total = 0
|
|
total = 0
|
|
|
output: list[Mapping[str, Any]] = []
|
|
output: list[Mapping[str, Any]] = []
|
|
|
- for ref in raw_artifact_refs:
|
|
|
|
|
|
|
+ for image_handle in image_handles:
|
|
|
|
|
+ ref = refs_by_handle[image_handle]
|
|
|
content = await self._raw_artifacts.read_bytes(ref)
|
|
content = await self._raw_artifacts.read_bytes(ref)
|
|
|
expected = ref.removeprefix(_RAW_PREFIX)
|
|
expected = ref.removeprefix(_RAW_PREFIX)
|
|
|
actual = sha256(content).hexdigest()
|
|
actual = sha256(content).hexdigest()
|
|
@@ -301,8 +320,8 @@ class PhaseTwoCandidateService:
|
|
|
"type": "base64",
|
|
"type": "base64",
|
|
|
"media_type": media_type,
|
|
"media_type": media_type,
|
|
|
"data": base64.b64encode(content).decode("ascii"),
|
|
"data": base64.b64encode(content).decode("ascii"),
|
|
|
- "raw_artifact_ref": ref,
|
|
|
|
|
- "digest": f"sha256:{actual}",
|
|
|
|
|
|
|
+ "image_handle": image_handle,
|
|
|
|
|
+ "sha256": f"sha256:{actual}",
|
|
|
"size_bytes": len(content),
|
|
"size_bytes": len(content),
|
|
|
}
|
|
}
|
|
|
)
|
|
)
|
|
@@ -377,7 +396,8 @@ class PhaseTwoCandidateService:
|
|
|
and lineage.supersedes_decision_ids == scope.contract.supersedes_decision_ids
|
|
and lineage.supersedes_decision_ids == scope.contract.supersedes_decision_ids
|
|
|
and lineage.base_artifact_ref == (base.uri if base else None)
|
|
and lineage.base_artifact_ref == (base.uri if base else None)
|
|
|
and lineage.base_artifact_digest == (base.digest if base else None)
|
|
and lineage.base_artifact_digest == (base.digest if base else None)
|
|
|
- and lineage.base_revision == (int(base.version) if base else None)
|
|
|
|
|
|
|
+ and lineage.base_revision
|
|
|
|
|
+ == (int(base.version) if base is not None and base.version is not None else None)
|
|
|
)
|
|
)
|
|
|
rules.append(
|
|
rules.append(
|
|
|
{
|
|
{
|
|
@@ -458,9 +478,7 @@ class PhaseTwoCandidateService:
|
|
|
raise PhaseTwoCandidateError(
|
|
raise PhaseTwoCandidateError(
|
|
|
"GOAL_COVERAGE_INVALID", "Portfolio adoption is not a StructuredScript"
|
|
"GOAL_COVERAGE_INVALID", "Portfolio adoption is not a StructuredScript"
|
|
|
)
|
|
)
|
|
|
- if structured.direction_ref != (
|
|
|
|
|
- f"script-build://artifact-versions/{direction_id}"
|
|
|
|
|
- ):
|
|
|
|
|
|
|
+ if structured.direction_ref != (f"script-build://artifact-versions/{direction_id}"):
|
|
|
raise PhaseTwoCandidateError(
|
|
raise PhaseTwoCandidateError(
|
|
|
"GOAL_COVERAGE_INVALID", "StructuredScript uses a stale Direction"
|
|
"GOAL_COVERAGE_INVALID", "StructuredScript uses a stale Direction"
|
|
|
)
|
|
)
|
|
@@ -562,6 +580,80 @@ class PhaseTwoCandidateService:
|
|
|
ids = await self._workspace_repo().create_paragraphs(scope.write_context, ordered)
|
|
ids = await self._workspace_repo().create_paragraphs(scope.write_context, ordered)
|
|
|
return {"paragraph_ids_by_client_key": dict(ids), "created": len(ids)}
|
|
return {"paragraph_ids_by_client_key": dict(ids), "created": len(ids)}
|
|
|
|
|
|
|
|
|
|
+ async def save_script_paragraphs(
|
|
|
|
|
+ self,
|
|
|
|
|
+ *,
|
|
|
|
|
+ paragraphs: Sequence[Mapping[str, Any]],
|
|
|
|
|
+ expected_state_revision: str,
|
|
|
|
|
+ context: Mapping[str, Any],
|
|
|
|
|
+ ) -> Mapping[str, Any]:
|
|
|
|
|
+ scope = await self._workspace_scope(context, paragraph=True)
|
|
|
|
|
+ before = await self._workspace_repo().snapshot(scope.write_context)
|
|
|
|
|
+ ledger = await self._task_store.load(scope.root_trace_id)
|
|
|
|
|
+ current_revision = _candidate_state_revision(scope, before, ledger.revision)
|
|
|
|
|
+ if expected_state_revision != current_revision:
|
|
|
|
|
+ applied = _applied_paragraph_targets(scope, before, paragraphs)
|
|
|
|
|
+ if applied is None:
|
|
|
|
|
+ _require_state_revision(scope, before, ledger.revision, expected_state_revision)
|
|
|
|
|
+ raise AssertionError("state revision check must reject a changed command")
|
|
|
|
|
+ return CommandReceipt(
|
|
|
|
|
+ state_revision=current_revision,
|
|
|
|
|
+ status="not_modified",
|
|
|
|
|
+ changed_target_keys=applied,
|
|
|
|
|
+ workspace_digest=_workspace_digest(before),
|
|
|
|
|
+ ).to_payload()
|
|
|
|
|
+ by_target = {
|
|
|
|
|
+ paragraph_target_key(
|
|
|
|
|
+ scope.root_trace_id,
|
|
|
|
|
+ scope.task.task_id,
|
|
|
|
|
+ scope.attempt.attempt_id,
|
|
|
|
|
+ item.paragraph_id,
|
|
|
|
|
+ ): item.paragraph_id
|
|
|
|
|
+ for item in before.paragraphs
|
|
|
|
|
+ }
|
|
|
|
|
+ normalized: list[dict[str, Any]] = []
|
|
|
|
|
+ for raw in paragraphs:
|
|
|
|
|
+ item = dict(raw)
|
|
|
|
|
+ target = item.pop("paragraph_target_key", None)
|
|
|
|
|
+ parent_target = item.pop("parent_target_key", None)
|
|
|
|
|
+ if target is not None:
|
|
|
|
|
+ paragraph_id = by_target.get(str(target))
|
|
|
|
|
+ if paragraph_id is None:
|
|
|
|
|
+ raise PhaseTwoCandidateError(
|
|
|
|
|
+ "LEGACY_REFERENCE_INVALID", "paragraph target is stale or unknown"
|
|
|
|
|
+ )
|
|
|
|
|
+ item["paragraph_id"] = paragraph_id
|
|
|
|
|
+ if parent_target is not None:
|
|
|
|
|
+ parent_id = by_target.get(str(parent_target))
|
|
|
|
|
+ if parent_id is None:
|
|
|
|
|
+ raise PhaseTwoCandidateError(
|
|
|
|
|
+ "LEGACY_REFERENCE_INVALID", "paragraph parent target is stale or unknown"
|
|
|
|
|
+ )
|
|
|
|
|
+ item["parent_paragraph_id"] = parent_id
|
|
|
|
|
+ normalized.append(item)
|
|
|
|
|
+ created, updated = await self._workspace_repo().save_paragraphs(
|
|
|
|
|
+ scope.write_context, normalized
|
|
|
|
|
+ )
|
|
|
|
|
+ after = await self._workspace_repo().snapshot(scope.write_context)
|
|
|
|
|
+ receipt = CommandReceipt(
|
|
|
|
|
+ state_revision=_candidate_state_revision(scope, after, ledger.revision),
|
|
|
|
|
+ status="saved",
|
|
|
|
|
+ created=len(created),
|
|
|
|
|
+ updated=len(updated),
|
|
|
|
|
+ changed_target_keys=tuple(
|
|
|
|
|
+ paragraph_target_key(
|
|
|
|
|
+ scope.root_trace_id,
|
|
|
|
|
+ scope.task.task_id,
|
|
|
|
|
+ scope.attempt.attempt_id,
|
|
|
|
|
+ item.paragraph_id,
|
|
|
|
|
+ )
|
|
|
|
|
+ for item in after.paragraphs
|
|
|
|
|
+ if item.paragraph_id in set(created.values()) | set(updated)
|
|
|
|
|
+ ),
|
|
|
|
|
+ workspace_digest=_workspace_digest(after),
|
|
|
|
|
+ )
|
|
|
|
|
+ return receipt.to_payload()
|
|
|
|
|
+
|
|
|
async def append_paragraph_atoms(
|
|
async def append_paragraph_atoms(
|
|
|
self, *, payload: Mapping[str, Any], context: Mapping[str, Any]
|
|
self, *, payload: Mapping[str, Any], context: Mapping[str, Any]
|
|
|
) -> Mapping[str, Any]:
|
|
) -> Mapping[str, Any]:
|
|
@@ -615,6 +707,84 @@ class PhaseTwoCandidateService:
|
|
|
)
|
|
)
|
|
|
return {"element_id": element_id}
|
|
return {"element_id": element_id}
|
|
|
|
|
|
|
|
|
|
+ async def create_script_elements(
|
|
|
|
|
+ self,
|
|
|
|
|
+ *,
|
|
|
|
|
+ elements: Sequence[Mapping[str, Any]],
|
|
|
|
|
+ links: Sequence[Mapping[str, Any]],
|
|
|
|
|
+ context: Mapping[str, Any],
|
|
|
|
|
+ ) -> Mapping[str, Any]:
|
|
|
|
|
+ scope = await self._workspace_scope(context, element=True)
|
|
|
|
|
+ ids, linked = await self._workspace_repo().create_elements(
|
|
|
|
|
+ scope.write_context,
|
|
|
|
|
+ elements,
|
|
|
|
|
+ links,
|
|
|
|
|
+ )
|
|
|
|
|
+ return {
|
|
|
|
|
+ "element_ids_by_client_key": dict(ids),
|
|
|
|
|
+ "created": len(ids),
|
|
|
|
|
+ "linked": linked,
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async def save_script_elements(
|
|
|
|
|
+ self,
|
|
|
|
|
+ *,
|
|
|
|
|
+ elements: Sequence[Mapping[str, Any]],
|
|
|
|
|
+ links: Sequence[Mapping[str, Any]],
|
|
|
|
|
+ expected_state_revision: str,
|
|
|
|
|
+ context: Mapping[str, Any],
|
|
|
|
|
+ ) -> Mapping[str, Any]:
|
|
|
|
|
+ scope = await self._workspace_scope(context, element=True)
|
|
|
|
|
+ before = await self._workspace_repo().snapshot(scope.write_context)
|
|
|
|
|
+ ledger = await self._task_store.load(scope.root_trace_id)
|
|
|
|
|
+ current_revision = _candidate_state_revision(scope, before, ledger.revision)
|
|
|
|
|
+ if expected_state_revision != current_revision:
|
|
|
|
|
+ if not _applied_element_command(scope, before, elements, links):
|
|
|
|
|
+ _require_state_revision(scope, before, ledger.revision, expected_state_revision)
|
|
|
|
|
+ return CommandReceipt(
|
|
|
|
|
+ state_revision=current_revision,
|
|
|
|
|
+ status="not_modified",
|
|
|
|
|
+ workspace_digest=_workspace_digest(before),
|
|
|
|
|
+ ).to_payload()
|
|
|
|
|
+ if before.elements or before.links:
|
|
|
|
|
+ raise PhaseTwoCandidateError(
|
|
|
|
|
+ "LEGACY_WRITE_INVALID", "ElementSet must be saved once per Attempt"
|
|
|
|
|
+ )
|
|
|
|
|
+ by_target = {
|
|
|
|
|
+ paragraph_target_key(
|
|
|
|
|
+ scope.root_trace_id,
|
|
|
|
|
+ scope.task.task_id,
|
|
|
|
|
+ scope.attempt.attempt_id,
|
|
|
|
|
+ item.paragraph_id,
|
|
|
|
|
+ ): item.paragraph_id
|
|
|
|
|
+ for item in before.paragraphs
|
|
|
|
|
+ }
|
|
|
|
|
+ normalized_links: list[dict[str, Any]] = []
|
|
|
|
|
+ for raw in links:
|
|
|
|
|
+ target = str(raw.get("paragraph_target_key") or "")
|
|
|
|
|
+ paragraph_id = by_target.get(target)
|
|
|
|
|
+ if paragraph_id is None:
|
|
|
|
|
+ raise PhaseTwoCandidateError(
|
|
|
|
|
+ "LEGACY_REFERENCE_INVALID", "Element link paragraph target is invalid"
|
|
|
|
|
+ )
|
|
|
|
|
+ normalized_links.append(
|
|
|
|
|
+ {
|
|
|
|
|
+ "paragraph_id": paragraph_id,
|
|
|
|
|
+ "element_client_keys": raw.get("element_client_keys"),
|
|
|
|
|
+ }
|
|
|
|
|
+ )
|
|
|
|
|
+ ids, linked = await self._workspace_repo().create_elements(
|
|
|
|
|
+ scope.write_context, elements, normalized_links
|
|
|
|
|
+ )
|
|
|
|
|
+ after = await self._workspace_repo().snapshot(scope.write_context)
|
|
|
|
|
+ return CommandReceipt(
|
|
|
|
|
+ state_revision=_candidate_state_revision(scope, after, ledger.revision),
|
|
|
|
|
+ status="saved",
|
|
|
|
|
+ created=len(ids),
|
|
|
|
|
+ linked=linked,
|
|
|
|
|
+ workspace_digest=_workspace_digest(after),
|
|
|
|
|
+ ).to_payload()
|
|
|
|
|
+
|
|
|
async def update_script_element(
|
|
async def update_script_element(
|
|
|
self, *, payload: Mapping[str, Any], context: Mapping[str, Any]
|
|
self, *, payload: Mapping[str, Any], context: Mapping[str, Any]
|
|
|
) -> Mapping[str, Any]:
|
|
) -> Mapping[str, Any]:
|
|
@@ -684,15 +854,25 @@ class PhaseTwoCandidateService:
|
|
|
"ACCEPTED_INPUT_CONFLICT", "comparison requires two frozen DecisionRefs"
|
|
"ACCEPTED_INPUT_CONFLICT", "comparison requires two frozen DecisionRefs"
|
|
|
)
|
|
)
|
|
|
await self._require_bundle_refs(bundle, scope.contract.comparison_decision_refs)
|
|
await self._require_bundle_refs(bundle, scope.contract.comparison_decision_refs)
|
|
|
|
|
+ recommended_decision_id = _required_text(payload, "recommended_decision_id")
|
|
|
|
|
+ recommendation_by_decision = {
|
|
|
|
|
+ item.decision_id: item.artifact_ref.uri
|
|
|
|
|
+ for item in scope.contract.comparison_decision_refs
|
|
|
|
|
+ }
|
|
|
|
|
+ if recommended_decision_id not in recommendation_by_decision:
|
|
|
|
|
+ raise PhaseTwoCandidateError(
|
|
|
|
|
+ "CHILD_DECISION_INVALID", "recommended Decision is outside frozen candidates"
|
|
|
|
|
+ )
|
|
|
|
|
+ criterion_results = tuple(
|
|
|
|
|
+ _comparison_criterion_result(item, recommendation_by_decision)
|
|
|
|
|
+ for item in _mapping_sequence(payload.get("criterion_results"), required=True)
|
|
|
|
|
+ )
|
|
|
artifact = ComparisonArtifactV1(
|
|
artifact = ComparisonArtifactV1(
|
|
|
lineage=await self._lineage(scope, bundle),
|
|
lineage=await self._lineage(scope, bundle),
|
|
|
candidate_artifact_refs=expected,
|
|
candidate_artifact_refs=expected,
|
|
|
- criterion_results=tuple(
|
|
|
|
|
- dict(item)
|
|
|
|
|
- for item in _mapping_sequence(payload.get("criterion_results"), required=True)
|
|
|
|
|
- ),
|
|
|
|
|
|
|
+ criterion_results=criterion_results,
|
|
|
conflicts=_text_sequence(payload.get("conflicts")),
|
|
conflicts=_text_sequence(payload.get("conflicts")),
|
|
|
- recommendation=_required_text(payload, "recommendation"),
|
|
|
|
|
|
|
+ recommendation=recommendation_by_decision[recommended_decision_id],
|
|
|
)
|
|
)
|
|
|
return await self._freeze_direct(scope, artifact)
|
|
return await self._freeze_direct(scope, artifact)
|
|
|
|
|
|
|
@@ -1112,6 +1292,25 @@ class PhaseTwoCandidateService:
|
|
|
)
|
|
)
|
|
|
if workspace.branch_id <= 0:
|
|
if workspace.branch_id <= 0:
|
|
|
raise PhaseTwoCandidateError("LEGACY_WRITE_INVALID", "workspace cannot use branch 0")
|
|
raise PhaseTwoCandidateError("LEGACY_WRITE_INVALID", "workspace cannot use branch 0")
|
|
|
|
|
+ if scope.contract.task_kind is ScriptTaskKind.ELEMENT_SET and base is None:
|
|
|
|
|
+ snapshot = await self._workspace_repo().snapshot(scope.write_context)
|
|
|
|
|
+ if not snapshot.paragraphs and not snapshot.elements and not snapshot.links:
|
|
|
|
|
+ _, bundle = await self._resolve_bundle(context)
|
|
|
|
|
+ paragraph_versions = tuple(
|
|
|
|
|
+ [
|
|
|
|
|
+ await self._read_accepted_version(scope, item)
|
|
|
|
|
+ for item in bundle.inputs
|
|
|
|
|
+ if item.task_kind is ScriptTaskKind.PARAGRAPH
|
|
|
|
|
+ ]
|
|
|
|
|
+ )
|
|
|
|
|
+ if paragraph_versions:
|
|
|
|
|
+ await self._materialize(
|
|
|
|
|
+ scope,
|
|
|
|
|
+ paragraph_versions,
|
|
|
|
|
+ (),
|
|
|
|
|
+ require_structure_coverage=False,
|
|
|
|
|
+ require_complete=False,
|
|
|
|
|
+ )
|
|
|
return scope
|
|
return scope
|
|
|
|
|
|
|
|
def _workspace_repo(self) -> CandidateWorkspaceOperations:
|
|
def _workspace_repo(self) -> CandidateWorkspaceOperations:
|
|
@@ -1286,6 +1485,9 @@ class PhaseTwoCandidateService:
|
|
|
scope: CandidateScope,
|
|
scope: CandidateScope,
|
|
|
versions: Sequence[ArtifactVersion],
|
|
versions: Sequence[ArtifactVersion],
|
|
|
support_versions: Sequence[ArtifactVersion],
|
|
support_versions: Sequence[ArtifactVersion],
|
|
|
|
|
+ *,
|
|
|
|
|
+ require_structure_coverage: bool = True,
|
|
|
|
|
+ require_complete: bool = True,
|
|
|
) -> None:
|
|
) -> None:
|
|
|
"""Materialize the explicitly ordered logical projection once.
|
|
"""Materialize the explicitly ordered logical projection once.
|
|
|
|
|
|
|
@@ -1416,7 +1618,7 @@ class PhaseTwoCandidateService:
|
|
|
link_projection.add((linked_paragraph_key, linked_element_key))
|
|
link_projection.add((linked_paragraph_key, linked_element_key))
|
|
|
|
|
|
|
|
uncovered = paragraph_candidate_keys - structure_paragraph_keys
|
|
uncovered = paragraph_candidate_keys - structure_paragraph_keys
|
|
|
- if uncovered:
|
|
|
|
|
|
|
+ if require_structure_coverage and uncovered:
|
|
|
raise PhaseTwoCandidateError(
|
|
raise PhaseTwoCandidateError(
|
|
|
"INPUT_SCOPE_MISMATCH",
|
|
"INPUT_SCOPE_MISMATCH",
|
|
|
"adopted Paragraphs must be covered or absorbed by an adopted Structure",
|
|
"adopted Paragraphs must be covered or absorbed by an adopted Structure",
|
|
@@ -1436,11 +1638,12 @@ class PhaseTwoCandidateService:
|
|
|
"LEGACY_REFERENCE_INVALID",
|
|
"LEGACY_REFERENCE_INVALID",
|
|
|
"every adopted Element must be linked to an adopted Paragraph",
|
|
"every adopted Element must be linked to an adopted Paragraph",
|
|
|
)
|
|
)
|
|
|
- _require_complete_projection(
|
|
|
|
|
- paragraph_projection=paragraph_projection,
|
|
|
|
|
- element_projection=element_projection,
|
|
|
|
|
- links=active_links,
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ if require_complete:
|
|
|
|
|
+ _require_complete_projection(
|
|
|
|
|
+ paragraph_projection=paragraph_projection,
|
|
|
|
|
+ element_projection=element_projection,
|
|
|
|
|
+ links=active_links,
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
paragraph_ids: dict[tuple[str, str, int], int] = {}
|
|
paragraph_ids: dict[tuple[str, str, int], int] = {}
|
|
|
next_index = 1
|
|
next_index = 1
|
|
@@ -1609,6 +1812,210 @@ def _workspace_payload(value: CandidateWorkspaceSnapshot) -> dict[str, Any]:
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def _workspace_digest(value: CandidateWorkspaceSnapshot) -> str:
|
|
|
|
|
+ encoded = json.dumps(
|
|
|
|
|
+ _workspace_payload(value),
|
|
|
|
|
+ ensure_ascii=False,
|
|
|
|
|
+ sort_keys=True,
|
|
|
|
|
+ separators=(",", ":"),
|
|
|
|
|
+ default=str,
|
|
|
|
|
+ ).encode()
|
|
|
|
|
+ return "sha256:" + sha256(encoded).hexdigest()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _candidate_state_revision(
|
|
|
|
|
+ scope: CandidateScope, snapshot: CandidateWorkspaceSnapshot, ledger_revision: int
|
|
|
|
|
+) -> str:
|
|
|
|
|
+ encoded = json.dumps(
|
|
|
|
|
+ {
|
|
|
|
|
+ "ledger": ledger_revision,
|
|
|
|
|
+ "workspace": _workspace_digest(snapshot),
|
|
|
|
|
+ "contract": scope.contract.to_payload(),
|
|
|
|
|
+ },
|
|
|
|
|
+ ensure_ascii=False,
|
|
|
|
|
+ sort_keys=True,
|
|
|
|
|
+ separators=(",", ":"),
|
|
|
|
|
+ default=str,
|
|
|
|
|
+ ).encode()
|
|
|
|
|
+ return "sha256:" + sha256(encoded).hexdigest()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _require_state_revision(
|
|
|
|
|
+ scope: CandidateScope,
|
|
|
|
|
+ snapshot: CandidateWorkspaceSnapshot,
|
|
|
|
|
+ ledger_revision: int,
|
|
|
|
|
+ expected: str,
|
|
|
|
|
+) -> None:
|
|
|
|
|
+ current = _candidate_state_revision(scope, snapshot, ledger_revision)
|
|
|
|
|
+ if expected != current:
|
|
|
|
|
+ raise PhaseTwoCandidateError(
|
|
|
|
|
+ "STALE_WORKBENCH_STATE",
|
|
|
|
|
+ "Workbench state changed; refresh role context before saving",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _applied_paragraph_targets(
|
|
|
|
|
+ scope: CandidateScope,
|
|
|
|
|
+ snapshot: CandidateWorkspaceSnapshot,
|
|
|
|
|
+ paragraphs: Sequence[Mapping[str, Any]],
|
|
|
|
|
+) -> tuple[str, ...] | None:
|
|
|
|
|
+ """Recognize an exact replay after the first transaction committed."""
|
|
|
|
|
+
|
|
|
|
|
+ by_id = {item.paragraph_id: item for item in snapshot.paragraphs}
|
|
|
|
|
+ by_target = {
|
|
|
|
|
+ paragraph_target_key(
|
|
|
|
|
+ scope.root_trace_id,
|
|
|
|
|
+ scope.task.task_id,
|
|
|
|
|
+ scope.attempt.attempt_id,
|
|
|
|
|
+ item.paragraph_id,
|
|
|
|
|
+ ): item
|
|
|
|
|
+ for item in snapshot.paragraphs
|
|
|
|
|
+ }
|
|
|
|
|
+ by_identity: dict[tuple[int, str], list[Any]] = {}
|
|
|
|
|
+ for item in snapshot.paragraphs:
|
|
|
|
|
+ by_identity.setdefault((item.paragraph_index, item.name), []).append(item)
|
|
|
|
|
+
|
|
|
|
|
+ resolved: list[tuple[Mapping[str, Any], Any]] = []
|
|
|
|
|
+ client_ids: dict[str, int] = {}
|
|
|
|
|
+ for raw in paragraphs:
|
|
|
|
|
+ target = raw.get("paragraph_target_key")
|
|
|
|
|
+ client_key = str(raw.get("client_key") or "").strip()
|
|
|
|
|
+ if target is not None:
|
|
|
|
|
+ actual = by_target.get(str(target))
|
|
|
|
|
+ else:
|
|
|
|
|
+ index = raw.get("paragraph_index")
|
|
|
|
|
+ name = str(raw.get("name") or "").strip()
|
|
|
|
|
+ if isinstance(index, bool) or not isinstance(index, int) or not name:
|
|
|
|
|
+ return None
|
|
|
|
|
+ candidates = by_identity.get((index, name), [])
|
|
|
|
|
+ actual = candidates[0] if len(candidates) == 1 else None
|
|
|
|
|
+ if actual is None or (client_key and client_key in client_ids):
|
|
|
|
|
+ return None
|
|
|
|
|
+ if client_key:
|
|
|
|
|
+ client_ids[client_key] = actual.paragraph_id
|
|
|
|
|
+ resolved.append((raw, actual))
|
|
|
|
|
+
|
|
|
|
|
+ comparable = {
|
|
|
|
|
+ "paragraph_index",
|
|
|
|
|
+ "name",
|
|
|
|
|
+ "content_range",
|
|
|
|
|
+ "level",
|
|
|
|
|
+ "theme_elements",
|
|
|
|
|
+ "form_elements",
|
|
|
|
|
+ "function_elements",
|
|
|
|
|
+ "feeling_elements",
|
|
|
|
|
+ "theme",
|
|
|
|
|
+ "form",
|
|
|
|
|
+ "function",
|
|
|
|
|
+ "feeling",
|
|
|
|
|
+ "description",
|
|
|
|
|
+ "full_description",
|
|
|
|
|
+ }
|
|
|
|
|
+ for raw, actual in resolved:
|
|
|
|
|
+ payload = actual.to_payload()
|
|
|
|
|
+ for field in comparable & set(raw):
|
|
|
|
|
+ if _json_equivalent(payload.get(field), raw.get(field)) is False:
|
|
|
|
|
+ return None
|
|
|
|
|
+ if "parent_client_key" in raw or "parent_target_key" in raw:
|
|
|
|
|
+ parent_client = raw.get("parent_client_key")
|
|
|
|
|
+ parent_target = raw.get("parent_target_key")
|
|
|
|
|
+ if parent_client is not None:
|
|
|
|
|
+ expected_parent = client_ids.get(str(parent_client))
|
|
|
|
|
+ elif parent_target is not None:
|
|
|
|
|
+ parent = by_target.get(str(parent_target))
|
|
|
|
|
+ expected_parent = parent.paragraph_id if parent is not None else None
|
|
|
|
|
+ else:
|
|
|
|
|
+ expected_parent = None
|
|
|
|
|
+ if actual.parent_id != expected_parent:
|
|
|
|
|
+ return None
|
|
|
|
|
+ if actual.parent_id is not None and actual.parent_id not in by_id:
|
|
|
|
|
+ return None
|
|
|
|
|
+ return tuple(
|
|
|
|
|
+ paragraph_target_key(
|
|
|
|
|
+ scope.root_trace_id,
|
|
|
|
|
+ scope.task.task_id,
|
|
|
|
|
+ scope.attempt.attempt_id,
|
|
|
|
|
+ actual.paragraph_id,
|
|
|
|
|
+ )
|
|
|
|
|
+ for _, actual in resolved
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _applied_element_command(
|
|
|
|
|
+ scope: CandidateScope,
|
|
|
|
|
+ snapshot: CandidateWorkspaceSnapshot,
|
|
|
|
|
+ elements: Sequence[Mapping[str, Any]],
|
|
|
|
|
+ links: Sequence[Mapping[str, Any]],
|
|
|
|
|
+) -> bool:
|
|
|
|
|
+ """Recognize an exact Element/Link command replay without storing a second state."""
|
|
|
|
|
+
|
|
|
|
|
+ if not snapshot.elements or not snapshot.links:
|
|
|
|
|
+ return False
|
|
|
|
|
+ actual_by_key = {
|
|
|
|
|
+ (item.name, item.dimension_primary, item.dimension_secondary): item
|
|
|
|
|
+ for item in snapshot.elements
|
|
|
|
|
+ }
|
|
|
|
|
+ client_ids: dict[str, int] = {}
|
|
|
|
|
+ comparable = {
|
|
|
|
|
+ "name",
|
|
|
|
|
+ "dimension_primary",
|
|
|
|
|
+ "dimension_secondary",
|
|
|
|
|
+ "commonality_analysis",
|
|
|
|
|
+ "topic_support",
|
|
|
|
|
+ "weight_score",
|
|
|
|
|
+ "support_elements",
|
|
|
|
|
+ }
|
|
|
|
|
+ for raw in elements:
|
|
|
|
|
+ client_key = str(raw.get("client_key") or "").strip()
|
|
|
|
|
+ identity = (
|
|
|
|
|
+ str(raw.get("name") or "").strip(),
|
|
|
|
|
+ str(raw.get("dimension_primary") or "").strip(),
|
|
|
|
|
+ str(raw.get("dimension_secondary") or "").strip(),
|
|
|
|
|
+ )
|
|
|
|
|
+ actual = actual_by_key.get(identity)
|
|
|
|
|
+ if not client_key or client_key in client_ids or actual is None:
|
|
|
|
|
+ return False
|
|
|
|
|
+ payload = actual.to_payload()
|
|
|
|
|
+ for field in comparable & set(raw):
|
|
|
|
|
+ if not _json_equivalent(payload.get(field), raw.get(field)):
|
|
|
|
|
+ return False
|
|
|
|
|
+ client_ids[client_key] = actual.element_id
|
|
|
|
|
+ if len(client_ids) != len(snapshot.elements):
|
|
|
|
|
+ return False
|
|
|
|
|
+ by_target = {
|
|
|
|
|
+ paragraph_target_key(
|
|
|
|
|
+ scope.root_trace_id,
|
|
|
|
|
+ scope.task.task_id,
|
|
|
|
|
+ scope.attempt.attempt_id,
|
|
|
|
|
+ item.paragraph_id,
|
|
|
|
|
+ ): item.paragraph_id
|
|
|
|
|
+ for item in snapshot.paragraphs
|
|
|
|
|
+ }
|
|
|
|
|
+ expected_links: set[tuple[int, int]] = set()
|
|
|
|
|
+ for raw in links:
|
|
|
|
|
+ paragraph_id = by_target.get(str(raw.get("paragraph_target_key") or ""))
|
|
|
|
|
+ element_keys = raw.get("element_client_keys")
|
|
|
|
|
+ if (
|
|
|
|
|
+ paragraph_id is None
|
|
|
|
|
+ or not isinstance(element_keys, Sequence)
|
|
|
|
|
+ or isinstance(element_keys, (str, bytes))
|
|
|
|
|
+ ):
|
|
|
|
|
+ return False
|
|
|
|
|
+ for key in element_keys:
|
|
|
|
|
+ element_id = client_ids.get(str(key))
|
|
|
|
|
+ if element_id is None:
|
|
|
|
|
+ return False
|
|
|
|
|
+ expected_links.add((paragraph_id, element_id))
|
|
|
|
|
+ actual_links = {(item.paragraph_id, item.element_id) for item in snapshot.links}
|
|
|
|
|
+ return expected_links == actual_links
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _json_equivalent(left: Any, right: Any) -> bool:
|
|
|
|
|
+ return json.dumps(
|
|
|
|
|
+ left, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str
|
|
|
|
|
+ ) == json.dumps(right, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def _artifact_payload(version: ArtifactVersion) -> Mapping[str, Any]:
|
|
def _artifact_payload(version: ArtifactVersion) -> Mapping[str, Any]:
|
|
|
content_payload = getattr(version.artifact, "content_payload", None)
|
|
content_payload = getattr(version.artifact, "content_payload", None)
|
|
|
if callable(content_payload):
|
|
if callable(content_payload):
|
|
@@ -1664,9 +2071,7 @@ def _accepted_direction(versions: Sequence[ArtifactVersion]) -> DirectionArtifac
|
|
|
|
|
|
|
|
def _require_paragraph_structures(versions: Sequence[ArtifactVersion]) -> None:
|
|
def _require_paragraph_structures(versions: Sequence[ArtifactVersion]) -> None:
|
|
|
structures = [
|
|
structures = [
|
|
|
- item.artifact
|
|
|
|
|
- for item in versions
|
|
|
|
|
- if isinstance(item.artifact, StructureArtifactV1)
|
|
|
|
|
|
|
+ item.artifact for item in versions if isinstance(item.artifact, StructureArtifactV1)
|
|
|
]
|
|
]
|
|
|
for version in versions:
|
|
for version in versions:
|
|
|
paragraph = version.artifact
|
|
paragraph = version.artifact
|
|
@@ -1696,13 +2101,8 @@ def _require_complete_projection(
|
|
|
element_projection: Mapping[tuple[str, str, int], ScriptElementV1],
|
|
element_projection: Mapping[tuple[str, str, int], ScriptElementV1],
|
|
|
links: set[tuple[tuple[str, str, int], tuple[str, str, int]]],
|
|
links: set[tuple[tuple[str, str, int], tuple[str, str, int]]],
|
|
|
) -> None:
|
|
) -> None:
|
|
|
- paragraph_keys = {
|
|
|
|
|
- key: index
|
|
|
|
|
- for index, key in enumerate(sorted(paragraph_projection), start=1)
|
|
|
|
|
- }
|
|
|
|
|
- element_keys = {
|
|
|
|
|
- key: index for index, key in enumerate(sorted(element_projection), start=1)
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ paragraph_keys = {key: index for index, key in enumerate(sorted(paragraph_projection), start=1)}
|
|
|
|
|
+ element_keys = {key: index for index, key in enumerate(sorted(element_projection), start=1)}
|
|
|
paragraphs = tuple(
|
|
paragraphs = tuple(
|
|
|
replace(
|
|
replace(
|
|
|
paragraph,
|
|
paragraph,
|
|
@@ -1775,6 +2175,22 @@ def _ordered_paragraph_batch(
|
|
|
"function_elements": _mapping_sequence(raw.get("function_elements")),
|
|
"function_elements": _mapping_sequence(raw.get("function_elements")),
|
|
|
"feeling_elements": _mapping_sequence(raw.get("feeling_elements")),
|
|
"feeling_elements": _mapping_sequence(raw.get("feeling_elements")),
|
|
|
}
|
|
}
|
|
|
|
|
+ for field in (
|
|
|
|
|
+ "theme",
|
|
|
|
|
+ "form",
|
|
|
|
|
+ "function",
|
|
|
|
|
+ "feeling",
|
|
|
|
|
+ "description",
|
|
|
|
|
+ "full_description",
|
|
|
|
|
+ ):
|
|
|
|
|
+ if field not in raw:
|
|
|
|
|
+ continue
|
|
|
|
|
+ value = raw[field]
|
|
|
|
|
+ if not isinstance(value, str) or not value.strip():
|
|
|
|
|
+ raise PhaseTwoCandidateError(
|
|
|
|
|
+ "LEGACY_WRITE_INVALID", f"paragraph {field} must not be blank"
|
|
|
|
|
+ )
|
|
|
|
|
+ by_key[key][field] = value.strip()
|
|
|
for item in by_key.values():
|
|
for item in by_key.values():
|
|
|
parent = item["parent_client_key"]
|
|
parent = item["parent_client_key"]
|
|
|
if parent is not None and parent not in by_key:
|
|
if parent is not None and parent not in by_key:
|
|
@@ -1812,6 +2228,41 @@ def _required_context(context: Mapping[str, Any], key: str) -> str:
|
|
|
return value
|
|
return value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+def _comparison_criterion_result(
|
|
|
|
|
+ value: Mapping[str, Any], candidates: Mapping[str, str]
|
|
|
|
|
+) -> dict[str, Any]:
|
|
|
|
|
+ unknown = set(value) - {"criterion_id", "candidate_results"}
|
|
|
|
|
+ results = value.get("candidate_results")
|
|
|
|
|
+ if unknown or not isinstance(results, Sequence) or isinstance(results, (str, bytes)):
|
|
|
|
|
+ raise PhaseTwoCandidateError(
|
|
|
|
|
+ "LEGACY_WRITE_INVALID", "comparison criterion result has an invalid shape"
|
|
|
|
|
+ )
|
|
|
|
|
+ normalized: list[dict[str, Any]] = []
|
|
|
|
|
+ seen: set[str] = set()
|
|
|
|
|
+ for raw in results:
|
|
|
|
|
+ if not isinstance(raw, Mapping) or set(raw) - {"decision_id", "reason"}:
|
|
|
|
|
+ raise PhaseTwoCandidateError(
|
|
|
|
|
+ "LEGACY_WRITE_INVALID", "comparison candidate result has an invalid shape"
|
|
|
|
|
+ )
|
|
|
|
|
+ decision_id = _required_text(raw, "decision_id")
|
|
|
|
|
+ if decision_id not in candidates or decision_id in seen:
|
|
|
|
|
+ raise PhaseTwoCandidateError(
|
|
|
|
|
+ "CHILD_DECISION_INVALID", "comparison result references a non-candidate Decision"
|
|
|
|
|
+ )
|
|
|
|
|
+ seen.add(decision_id)
|
|
|
|
|
+ normalized.append(
|
|
|
|
|
+ {"artifact_ref": candidates[decision_id], "reason": _required_text(raw, "reason")}
|
|
|
|
|
+ )
|
|
|
|
|
+ if seen != set(candidates):
|
|
|
|
|
+ raise PhaseTwoCandidateError(
|
|
|
|
|
+ "CHILD_DECISION_INVALID", "comparison must evaluate every frozen candidate"
|
|
|
|
|
+ )
|
|
|
|
|
+ return {
|
|
|
|
|
+ "criterion_id": _required_text(value, "criterion_id"),
|
|
|
|
|
+ "candidate_results": normalized,
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def _required_text(payload: Mapping[str, Any], key: str) -> str:
|
|
def _required_text(payload: Mapping[str, Any], key: str) -> str:
|
|
|
value = payload.get(key)
|
|
value = payload.get(key)
|
|
|
if not isinstance(value, str) or not value.strip():
|
|
if not isinstance(value, str) or not value.strip():
|