|
|
@@ -0,0 +1,1570 @@
|
|
|
+"""SQLAlchemy adapter for isolated, Attempt-owned candidate workspaces."""
|
|
|
+
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import json
|
|
|
+from collections.abc import Mapping, Sequence
|
|
|
+from dataclasses import replace
|
|
|
+from datetime import UTC, datetime
|
|
|
+from typing import Any, cast
|
|
|
+
|
|
|
+from agent.orchestration import ArtifactRef
|
|
|
+from sqlalchemy import delete, insert, null, select, update
|
|
|
+from sqlalchemy.exc import IntegrityError
|
|
|
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
|
+
|
|
|
+from script_build_host.domain.artifacts import (
|
|
|
+ ArtifactKind,
|
|
|
+ ArtifactState,
|
|
|
+ ArtifactVersion,
|
|
|
+)
|
|
|
+from script_build_host.domain.phase_two_artifacts import (
|
|
|
+ CandidateLineageV1,
|
|
|
+ ElementSetArtifactV1,
|
|
|
+ ParagraphArtifactV1,
|
|
|
+ ScriptElementV1,
|
|
|
+ ScriptParagraphElementLinkV1,
|
|
|
+ ScriptParagraphV1,
|
|
|
+ StructureArtifactV1,
|
|
|
+ StructuredScriptArtifactV1,
|
|
|
+)
|
|
|
+from script_build_host.domain.workspaces import (
|
|
|
+ CandidateWorkspace,
|
|
|
+ CandidateWorkspaceSnapshot,
|
|
|
+ CandidateWriteContext,
|
|
|
+ WorkspaceError,
|
|
|
+)
|
|
|
+from script_build_host.infrastructure.canonical_json import canonical_sha256, normalize_json
|
|
|
+from script_build_host.infrastructure.legacy_tables import (
|
|
|
+ script_build_element,
|
|
|
+ script_build_paragraph,
|
|
|
+ script_build_paragraph_element,
|
|
|
+ script_build_task_plan_step,
|
|
|
+)
|
|
|
+from script_build_host.infrastructure.tables import artifact_version_table
|
|
|
+from script_build_host.repositories.sqlalchemy import (
|
|
|
+ SqlAlchemyScriptBusinessArtifactRepository,
|
|
|
+)
|
|
|
+
|
|
|
+_CREATIVE_KINDS = frozenset(
|
|
|
+ {
|
|
|
+ ArtifactKind.STRUCTURE,
|
|
|
+ ArtifactKind.PARAGRAPH,
|
|
|
+ ArtifactKind.ELEMENT_SET,
|
|
|
+ ArtifactKind.STRUCTURED_SCRIPT,
|
|
|
+ }
|
|
|
+)
|
|
|
+_ATOM_COLUMNS = frozenset(
|
|
|
+ {"theme_elements", "form_elements", "function_elements", "feeling_elements"}
|
|
|
+)
|
|
|
+_PARAGRAPH_KINDS = frozenset(
|
|
|
+ {ArtifactKind.STRUCTURE, ArtifactKind.PARAGRAPH, ArtifactKind.STRUCTURED_SCRIPT}
|
|
|
+)
|
|
|
+_ELEMENT_KINDS = frozenset({ArtifactKind.ELEMENT_SET, ArtifactKind.STRUCTURED_SCRIPT})
|
|
|
+
|
|
|
+
|
|
|
+class SqlAlchemyCandidateWorkspaceRepository:
|
|
|
+ def __init__(
|
|
|
+ self,
|
|
|
+ sessions: async_sessionmaker[AsyncSession],
|
|
|
+ artifacts: SqlAlchemyScriptBusinessArtifactRepository,
|
|
|
+ ) -> None:
|
|
|
+ self._sessions = sessions
|
|
|
+ self._artifacts = artifacts
|
|
|
+
|
|
|
+ async def get_or_create(
|
|
|
+ self,
|
|
|
+ context: CandidateWriteContext,
|
|
|
+ *,
|
|
|
+ artifact_kind: ArtifactKind,
|
|
|
+ base_artifact_ref: ArtifactRef | None = None,
|
|
|
+ ) -> CandidateWorkspace:
|
|
|
+ if artifact_kind not in _CREATIVE_KINDS:
|
|
|
+ raise WorkspaceError(
|
|
|
+ "LEGACY_WRITE_INVALID", "this artifact kind does not own a candidate workspace"
|
|
|
+ )
|
|
|
+ parent: ArtifactVersion | None = None
|
|
|
+ if base_artifact_ref is not None:
|
|
|
+ parent = await self._artifacts.read_by_ref(
|
|
|
+ base_artifact_ref, script_build_id=context.script_build_id
|
|
|
+ )
|
|
|
+ if parent.state not in {ArtifactState.FROZEN, ArtifactState.PUBLISHED}:
|
|
|
+ raise WorkspaceError(
|
|
|
+ "STALE_BASE_REVISION", "base artifact is not an immutable accepted candidate"
|
|
|
+ )
|
|
|
+ if parent.artifact_type not in _CREATIVE_KINDS:
|
|
|
+ raise WorkspaceError(
|
|
|
+ "STALE_BASE_REVISION",
|
|
|
+ "base artifact does not contain an immutable candidate projection",
|
|
|
+ )
|
|
|
+ now = _now()
|
|
|
+ try:
|
|
|
+ async with self._sessions() as session, session.begin():
|
|
|
+ existing = await self._row_by_attempt(session, context.attempt_id, lock=True)
|
|
|
+ if existing is not None:
|
|
|
+ return self._validate_workspace(existing, context, artifact_kind, parent)
|
|
|
+ result = await session.execute(
|
|
|
+ insert(artifact_version_table).values(
|
|
|
+ script_build_id=context.script_build_id,
|
|
|
+ task_id=context.task_id,
|
|
|
+ attempt_id=context.attempt_id,
|
|
|
+ spec_version=context.spec_version,
|
|
|
+ artifact_type=artifact_kind.value,
|
|
|
+ legacy_branch_id=None,
|
|
|
+ base_revision=(parent.artifact_version_id if parent else None),
|
|
|
+ parent_artifact_version_id=(parent.artifact_version_id if parent else None),
|
|
|
+ canonical_json=null(),
|
|
|
+ canonical_sha256=None,
|
|
|
+ state=ArtifactState.DRAFT.value,
|
|
|
+ created_at=now,
|
|
|
+ )
|
|
|
+ )
|
|
|
+ artifact_id = int(cast(Any, result).inserted_primary_key[0])
|
|
|
+ if artifact_id < 1:
|
|
|
+ raise WorkspaceError(
|
|
|
+ "LEGACY_WRITE_INVALID", "database did not allocate a workspace identity"
|
|
|
+ )
|
|
|
+ await session.execute(
|
|
|
+ update(artifact_version_table)
|
|
|
+ .where(artifact_version_table.c.id == artifact_id)
|
|
|
+ .values(legacy_branch_id=artifact_id)
|
|
|
+ )
|
|
|
+ row = await self._row_by_attempt(session, context.attempt_id, lock=True)
|
|
|
+ if row is None:
|
|
|
+ raise WorkspaceError(
|
|
|
+ "ATTEMPT_WORKSPACE_NOT_FOUND", "candidate workspace was not persisted"
|
|
|
+ )
|
|
|
+ workspace = self._workspace(row)
|
|
|
+ await self._ensure_task_plan(session, context, workspace)
|
|
|
+ if parent is not None:
|
|
|
+ await self._materialize_base(session, workspace, parent)
|
|
|
+ return workspace
|
|
|
+ except IntegrityError:
|
|
|
+ async with self._sessions() as session:
|
|
|
+ row = await self._row_by_attempt(session, context.attempt_id)
|
|
|
+ if row is None:
|
|
|
+ raise
|
|
|
+ return self._validate_workspace(row, context, artifact_kind, parent)
|
|
|
+
|
|
|
+ async def require(self, context: CandidateWriteContext) -> CandidateWorkspace:
|
|
|
+ async with self._sessions() as session:
|
|
|
+ row = await self._row_by_attempt(session, context.attempt_id)
|
|
|
+ if row is None:
|
|
|
+ raise WorkspaceError(
|
|
|
+ "ATTEMPT_WORKSPACE_NOT_FOUND", "candidate workspace does not exist"
|
|
|
+ )
|
|
|
+ workspace = self._workspace(row)
|
|
|
+ self._validate_context(workspace, context)
|
|
|
+ return workspace
|
|
|
+
|
|
|
+ async def snapshot(self, context: CandidateWriteContext) -> CandidateWorkspaceSnapshot:
|
|
|
+ async with self._sessions() as session:
|
|
|
+ workspace = await self._locked_workspace(session, context, require_draft=False)
|
|
|
+ return await self._snapshot_in_session(session, workspace)
|
|
|
+
|
|
|
+ async def create_paragraph(
|
|
|
+ self,
|
|
|
+ context: CandidateWriteContext,
|
|
|
+ *,
|
|
|
+ paragraph_index: int,
|
|
|
+ name: str,
|
|
|
+ content_range: Mapping[str, Any],
|
|
|
+ level: int = 1,
|
|
|
+ parent_paragraph_id: int | None = None,
|
|
|
+ theme_elements: Sequence[Mapping[str, Any]] = (),
|
|
|
+ form_elements: Sequence[Mapping[str, Any]] = (),
|
|
|
+ function_elements: Sequence[Mapping[str, Any]] = (),
|
|
|
+ feeling_elements: Sequence[Mapping[str, Any]] = (),
|
|
|
+ ) -> int:
|
|
|
+ if paragraph_index < 1 or level not in (1, 2) or not name.strip():
|
|
|
+ raise WorkspaceError("LEGACY_WRITE_INVALID", "paragraph fields are invalid")
|
|
|
+ if level == 1 and parent_paragraph_id is not None:
|
|
|
+ raise WorkspaceError(
|
|
|
+ "LEGACY_REFERENCE_INVALID", "level-one paragraph cannot have a parent"
|
|
|
+ )
|
|
|
+ if level == 2 and parent_paragraph_id is None:
|
|
|
+ raise WorkspaceError(
|
|
|
+ "LEGACY_REFERENCE_INVALID", "level-two paragraph requires a parent"
|
|
|
+ )
|
|
|
+ atoms = {
|
|
|
+ "theme_elements": _atoms(theme_elements, feeling=False),
|
|
|
+ "form_elements": _atoms(form_elements, feeling=False),
|
|
|
+ "function_elements": _atoms(function_elements, feeling=False),
|
|
|
+ "feeling_elements": _atoms(feeling_elements, feeling=True),
|
|
|
+ }
|
|
|
+ _authorize_write(
|
|
|
+ context,
|
|
|
+ entity="paragraphs",
|
|
|
+ operation="create",
|
|
|
+ identities=(paragraph_index, name.strip()),
|
|
|
+ content_range=content_range,
|
|
|
+ )
|
|
|
+ async with self._sessions() as session, session.begin():
|
|
|
+ workspace = await self._locked_workspace(session, context)
|
|
|
+ _require_workspace_kind(workspace, _PARAGRAPH_KINDS)
|
|
|
+ await self._ensure_task_plan(session, context, workspace)
|
|
|
+ if parent_paragraph_id is not None:
|
|
|
+ parent = await self._paragraph(session, workspace, parent_paragraph_id, active=True)
|
|
|
+ if parent is None or int(parent["level"]) != 1:
|
|
|
+ raise WorkspaceError(
|
|
|
+ "LEGACY_REFERENCE_INVALID", "paragraph parent is outside this workspace"
|
|
|
+ )
|
|
|
+ if not _content_range_subset(dict(content_range), parent["content_range"] or {}):
|
|
|
+ raise WorkspaceError(
|
|
|
+ "LEGACY_REFERENCE_INVALID", "child content range exceeds its parent"
|
|
|
+ )
|
|
|
+ result = await session.execute(
|
|
|
+ insert(script_build_paragraph).values(
|
|
|
+ script_build_id=workspace.script_build_id,
|
|
|
+ branch_id=workspace.branch_id,
|
|
|
+ base_ref_id=None,
|
|
|
+ paragraph_index=paragraph_index,
|
|
|
+ level=level,
|
|
|
+ parent_id=parent_paragraph_id,
|
|
|
+ name=name.strip(),
|
|
|
+ content_range=dict(content_range),
|
|
|
+ **atoms,
|
|
|
+ is_active=True,
|
|
|
+ created_at=_now(),
|
|
|
+ updated_at=_now(),
|
|
|
+ )
|
|
|
+ )
|
|
|
+ return int(cast(Any, result).inserted_primary_key[0])
|
|
|
+
|
|
|
+ async def batch_update_paragraphs(
|
|
|
+ self, context: CandidateWriteContext, updates: Sequence[Mapping[str, Any]]
|
|
|
+ ) -> tuple[int, ...]:
|
|
|
+ if not updates:
|
|
|
+ raise WorkspaceError("LEGACY_WRITE_INVALID", "paragraph update batch is empty")
|
|
|
+ allowed = {
|
|
|
+ "name",
|
|
|
+ "theme",
|
|
|
+ "form",
|
|
|
+ "function",
|
|
|
+ "feeling",
|
|
|
+ "full_description",
|
|
|
+ "description",
|
|
|
+ "content_range",
|
|
|
+ "paragraph_index",
|
|
|
+ "level",
|
|
|
+ "parent_paragraph_id",
|
|
|
+ "is_active",
|
|
|
+ }
|
|
|
+ async with self._sessions() as session, session.begin():
|
|
|
+ workspace = await self._locked_workspace(session, context)
|
|
|
+ _require_workspace_kind(workspace, _PARAGRAPH_KINDS)
|
|
|
+ prepared: list[tuple[int, dict[str, Any]]] = []
|
|
|
+ for item in updates:
|
|
|
+ paragraph_id = _positive_int(item.get("paragraph_id"), "paragraph_id")
|
|
|
+ row = await self._paragraph(session, workspace, paragraph_id)
|
|
|
+ if row is None:
|
|
|
+ raise WorkspaceError(
|
|
|
+ "LEGACY_REFERENCE_INVALID", "paragraph is outside this workspace"
|
|
|
+ )
|
|
|
+ unknown = set(item) - allowed - {"paragraph_id"}
|
|
|
+ if unknown:
|
|
|
+ raise WorkspaceError(
|
|
|
+ "LEGACY_WRITE_INVALID", "paragraph update contains unsupported fields"
|
|
|
+ )
|
|
|
+ values = {key: item[key] for key in allowed if key in item}
|
|
|
+ if "name" in values:
|
|
|
+ if not isinstance(values["name"], str) or not values["name"].strip():
|
|
|
+ raise WorkspaceError(
|
|
|
+ "LEGACY_WRITE_INVALID", "paragraph name must not be blank"
|
|
|
+ )
|
|
|
+ values["name"] = values["name"].strip()
|
|
|
+ for field in (
|
|
|
+ "theme",
|
|
|
+ "form",
|
|
|
+ "function",
|
|
|
+ "feeling",
|
|
|
+ "full_description",
|
|
|
+ "description",
|
|
|
+ ):
|
|
|
+ if (
|
|
|
+ field in values
|
|
|
+ and values[field] is not None
|
|
|
+ and not isinstance(values[field], str)
|
|
|
+ ):
|
|
|
+ raise WorkspaceError(
|
|
|
+ "LEGACY_WRITE_INVALID", f"paragraph {field} must be text or null"
|
|
|
+ )
|
|
|
+ if "content_range" in values:
|
|
|
+ if not isinstance(values["content_range"], Mapping):
|
|
|
+ raise WorkspaceError(
|
|
|
+ "LEGACY_WRITE_INVALID", "paragraph content_range must be an object"
|
|
|
+ )
|
|
|
+ values["content_range"] = dict(values["content_range"])
|
|
|
+ if "paragraph_index" in values:
|
|
|
+ values["paragraph_index"] = _positive_int(
|
|
|
+ values["paragraph_index"], "paragraph_index"
|
|
|
+ )
|
|
|
+ if "level" in values:
|
|
|
+ if (
|
|
|
+ isinstance(values["level"], bool)
|
|
|
+ or not isinstance(values["level"], int)
|
|
|
+ or values["level"] not in (1, 2)
|
|
|
+ ):
|
|
|
+ raise WorkspaceError(
|
|
|
+ "LEGACY_WRITE_INVALID", "paragraph level must be 1 or 2"
|
|
|
+ )
|
|
|
+ if "parent_paragraph_id" in values and values["parent_paragraph_id"] is not None:
|
|
|
+ values["parent_paragraph_id"] = _positive_int(
|
|
|
+ values["parent_paragraph_id"], "parent_paragraph_id"
|
|
|
+ )
|
|
|
+ if "is_active" in values and not isinstance(values["is_active"], bool):
|
|
|
+ raise WorkspaceError(
|
|
|
+ "LEGACY_WRITE_INVALID", "paragraph is_active must be boolean"
|
|
|
+ )
|
|
|
+ _authorize_write(
|
|
|
+ context,
|
|
|
+ entity="paragraphs",
|
|
|
+ operation="update",
|
|
|
+ identities=(paragraph_id,),
|
|
|
+ content_range=cast(
|
|
|
+ Mapping[str, Any], values.get("content_range", row["content_range"] or {})
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ if "parent_paragraph_id" in values:
|
|
|
+ parent_id = values.pop("parent_paragraph_id")
|
|
|
+ values["parent_id"] = parent_id
|
|
|
+ level = values.get("level", row["level"])
|
|
|
+ parent_id = values.get("parent_id", row["parent_id"])
|
|
|
+ if level not in (1, 2) or (level == 2 and parent_id is None):
|
|
|
+ raise WorkspaceError(
|
|
|
+ "LEGACY_REFERENCE_INVALID", "updated paragraph hierarchy is invalid"
|
|
|
+ )
|
|
|
+ if level == 1:
|
|
|
+ values["parent_id"] = None
|
|
|
+ elif parent_id == paragraph_id:
|
|
|
+ raise WorkspaceError(
|
|
|
+ "LEGACY_REFERENCE_INVALID", "paragraph cannot be its own parent"
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ parent = await self._paragraph(session, workspace, int(parent_id), active=True)
|
|
|
+ if parent is None or int(parent["level"]) != 1:
|
|
|
+ raise WorkspaceError(
|
|
|
+ "LEGACY_REFERENCE_INVALID", "updated paragraph parent is invalid"
|
|
|
+ )
|
|
|
+ content_range = cast(
|
|
|
+ Mapping[str, Any], values.get("content_range", row["content_range"] or {})
|
|
|
+ )
|
|
|
+ if not _content_range_subset(content_range, parent["content_range"] or {}):
|
|
|
+ raise WorkspaceError(
|
|
|
+ "LEGACY_REFERENCE_INVALID",
|
|
|
+ "updated child content range exceeds its parent",
|
|
|
+ )
|
|
|
+ if values.get("is_active") is False:
|
|
|
+ active_child = await session.scalar(
|
|
|
+ select(script_build_paragraph.c.id).where(
|
|
|
+ script_build_paragraph.c.script_build_id == workspace.script_build_id,
|
|
|
+ script_build_paragraph.c.branch_id == workspace.branch_id,
|
|
|
+ script_build_paragraph.c.parent_id == paragraph_id,
|
|
|
+ script_build_paragraph.c.is_active.is_(True),
|
|
|
+ )
|
|
|
+ )
|
|
|
+ if active_child is not None:
|
|
|
+ raise WorkspaceError(
|
|
|
+ "LEGACY_REFERENCE_INVALID",
|
|
|
+ "a paragraph with active children cannot be deactivated",
|
|
|
+ )
|
|
|
+ if not values:
|
|
|
+ raise WorkspaceError("LEGACY_WRITE_INVALID", "paragraph update is empty")
|
|
|
+ values["updated_at"] = _now()
|
|
|
+ prepared.append((paragraph_id, values))
|
|
|
+ for paragraph_id, values in prepared:
|
|
|
+ await session.execute(
|
|
|
+ update(script_build_paragraph)
|
|
|
+ .where(
|
|
|
+ script_build_paragraph.c.id == paragraph_id,
|
|
|
+ script_build_paragraph.c.script_build_id == workspace.script_build_id,
|
|
|
+ script_build_paragraph.c.branch_id == workspace.branch_id,
|
|
|
+ )
|
|
|
+ .values(**values)
|
|
|
+ )
|
|
|
+ if values.get("is_active") is False:
|
|
|
+ await session.execute(
|
|
|
+ delete(script_build_paragraph_element).where(
|
|
|
+ script_build_paragraph_element.c.script_build_id
|
|
|
+ == workspace.script_build_id,
|
|
|
+ script_build_paragraph_element.c.branch_id == workspace.branch_id,
|
|
|
+ script_build_paragraph_element.c.paragraph_id == paragraph_id,
|
|
|
+ )
|
|
|
+ )
|
|
|
+ return tuple(item[0] for item in prepared)
|
|
|
+
|
|
|
+ async def append_paragraph_atoms(
|
|
|
+ self,
|
|
|
+ context: CandidateWriteContext,
|
|
|
+ *,
|
|
|
+ paragraph_id: int,
|
|
|
+ column: str,
|
|
|
+ atoms: Sequence[Mapping[str, Any]],
|
|
|
+ ) -> int:
|
|
|
+ if column not in _ATOM_COLUMNS:
|
|
|
+ raise WorkspaceError("LEGACY_WRITE_INVALID", "paragraph atom column is invalid")
|
|
|
+ normalized = _atoms(atoms, feeling=column == "feeling_elements")
|
|
|
+ _authorize_write(
|
|
|
+ context, entity="paragraphs", operation="append-atoms", identities=(paragraph_id,)
|
|
|
+ )
|
|
|
+ async with self._sessions() as session, session.begin():
|
|
|
+ workspace = await self._locked_workspace(session, context)
|
|
|
+ _require_workspace_kind(workspace, _PARAGRAPH_KINDS)
|
|
|
+ row = await self._paragraph(session, workspace, paragraph_id, active=True)
|
|
|
+ if row is None:
|
|
|
+ raise WorkspaceError(
|
|
|
+ "LEGACY_REFERENCE_INVALID", "paragraph is outside this workspace"
|
|
|
+ )
|
|
|
+ existing = list(row[column] or [])
|
|
|
+ dimensions = {str(item.get("维度", "")) for item in existing}
|
|
|
+ added = [item for item in normalized if item["维度"] not in dimensions]
|
|
|
+ await session.execute(
|
|
|
+ update(script_build_paragraph)
|
|
|
+ .where(script_build_paragraph.c.id == paragraph_id)
|
|
|
+ .values(**{column: [*existing, *added], "updated_at": _now()})
|
|
|
+ )
|
|
|
+ return len(added)
|
|
|
+
|
|
|
+ async def delete_paragraph_atom(
|
|
|
+ self,
|
|
|
+ context: CandidateWriteContext,
|
|
|
+ *,
|
|
|
+ paragraph_id: int,
|
|
|
+ column: str,
|
|
|
+ dimension: str,
|
|
|
+ atom_value: str,
|
|
|
+ ) -> int:
|
|
|
+ if column not in _ATOM_COLUMNS or not dimension.strip() or not atom_value.strip():
|
|
|
+ raise WorkspaceError("LEGACY_WRITE_INVALID", "paragraph atom selector is invalid")
|
|
|
+ _authorize_write(
|
|
|
+ context, entity="paragraphs", operation="delete-atom", identities=(paragraph_id,)
|
|
|
+ )
|
|
|
+ async with self._sessions() as session, session.begin():
|
|
|
+ workspace = await self._locked_workspace(session, context)
|
|
|
+ _require_workspace_kind(workspace, _PARAGRAPH_KINDS)
|
|
|
+ row = await self._paragraph(session, workspace, paragraph_id)
|
|
|
+ if row is None:
|
|
|
+ raise WorkspaceError(
|
|
|
+ "LEGACY_REFERENCE_INVALID", "paragraph is outside this workspace"
|
|
|
+ )
|
|
|
+ existing = list(row[column] or [])
|
|
|
+ kept = [
|
|
|
+ item
|
|
|
+ for item in existing
|
|
|
+ if not (
|
|
|
+ str(item.get("维度", "")).strip() == dimension.strip()
|
|
|
+ and str(item.get("原子点", "")).strip() == atom_value.strip()
|
|
|
+ )
|
|
|
+ ]
|
|
|
+ deleted_count = len(existing) - len(kept)
|
|
|
+ if deleted_count == 0:
|
|
|
+ raise WorkspaceError("LEGACY_REFERENCE_INVALID", "paragraph atom does not exist")
|
|
|
+ await session.execute(
|
|
|
+ update(script_build_paragraph)
|
|
|
+ .where(script_build_paragraph.c.id == paragraph_id)
|
|
|
+ .values(**{column: kept, "updated_at": _now()})
|
|
|
+ )
|
|
|
+ return deleted_count
|
|
|
+
|
|
|
+ async def create_element(
|
|
|
+ self,
|
|
|
+ context: CandidateWriteContext,
|
|
|
+ *,
|
|
|
+ name: str,
|
|
|
+ dimension_primary: str,
|
|
|
+ dimension_secondary: str,
|
|
|
+ commonality_analysis: Mapping[str, Any] | None = None,
|
|
|
+ topic_support: Mapping[str, Any] | None = None,
|
|
|
+ weight_score: Mapping[str, Any] | None = None,
|
|
|
+ support_elements: Sequence[Mapping[str, Any]] = (),
|
|
|
+ ) -> int:
|
|
|
+ _element_fields(name, dimension_primary, dimension_secondary)
|
|
|
+ _authorize_write(context, entity="elements", operation="create", identities=(name.strip(),))
|
|
|
+ async with self._sessions() as session, session.begin():
|
|
|
+ workspace = await self._locked_workspace(session, context)
|
|
|
+ _require_workspace_kind(workspace, _ELEMENT_KINDS)
|
|
|
+ await self._ensure_task_plan(session, context, workspace)
|
|
|
+ existing = (
|
|
|
+ (
|
|
|
+ await session.execute(
|
|
|
+ select(script_build_element.c.id).where(
|
|
|
+ script_build_element.c.script_build_id == workspace.script_build_id,
|
|
|
+ script_build_element.c.branch_id == workspace.branch_id,
|
|
|
+ script_build_element.c.name == name.strip(),
|
|
|
+ script_build_element.c.dimension_primary == dimension_primary,
|
|
|
+ script_build_element.c.dimension_secondary
|
|
|
+ == dimension_secondary.strip(),
|
|
|
+ )
|
|
|
+ )
|
|
|
+ )
|
|
|
+ .scalars()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if existing is not None:
|
|
|
+ return int(existing)
|
|
|
+ result = await session.execute(
|
|
|
+ insert(script_build_element).values(
|
|
|
+ script_build_id=workspace.script_build_id,
|
|
|
+ branch_id=workspace.branch_id,
|
|
|
+ base_ref_id=None,
|
|
|
+ name=name.strip(),
|
|
|
+ dimension_primary=dimension_primary,
|
|
|
+ dimension_secondary=dimension_secondary.strip(),
|
|
|
+ commonality_analysis=(
|
|
|
+ dict(commonality_analysis) if commonality_analysis is not None else None
|
|
|
+ ),
|
|
|
+ topic_support=dict(topic_support) if topic_support is not None else None,
|
|
|
+ weight_score=dict(weight_score) if weight_score is not None else None,
|
|
|
+ support_elements=[dict(item) for item in support_elements],
|
|
|
+ is_active=True,
|
|
|
+ created_at=_now(),
|
|
|
+ updated_at=_now(),
|
|
|
+ )
|
|
|
+ )
|
|
|
+ return int(cast(Any, result).inserted_primary_key[0])
|
|
|
+
|
|
|
+ async def update_element(
|
|
|
+ self, context: CandidateWriteContext, *, element_id: int, values: Mapping[str, Any]
|
|
|
+ ) -> None:
|
|
|
+ allowed = {
|
|
|
+ "name",
|
|
|
+ "dimension_primary",
|
|
|
+ "dimension_secondary",
|
|
|
+ "commonality_analysis",
|
|
|
+ "topic_support",
|
|
|
+ "weight_score",
|
|
|
+ "support_elements",
|
|
|
+ "is_active",
|
|
|
+ }
|
|
|
+ if not values or set(values) - allowed:
|
|
|
+ raise WorkspaceError("LEGACY_WRITE_INVALID", "element update is invalid")
|
|
|
+ _authorize_write(context, entity="elements", operation="update", identities=(element_id,))
|
|
|
+ async with self._sessions() as session, session.begin():
|
|
|
+ workspace = await self._locked_workspace(session, context)
|
|
|
+ _require_workspace_kind(workspace, _ELEMENT_KINDS)
|
|
|
+ row = await self._element(session, workspace, element_id)
|
|
|
+ if row is None:
|
|
|
+ raise WorkspaceError(
|
|
|
+ "LEGACY_REFERENCE_INVALID", "element is outside this workspace"
|
|
|
+ )
|
|
|
+ name = values.get("name", row["name"])
|
|
|
+ primary = values.get("dimension_primary", row["dimension_primary"])
|
|
|
+ secondary = values.get("dimension_secondary", row["dimension_secondary"])
|
|
|
+ if not all(isinstance(item, str) for item in (name, primary, secondary)):
|
|
|
+ raise WorkspaceError("LEGACY_WRITE_INVALID", "element text fields are invalid")
|
|
|
+ _element_fields(name, primary, secondary)
|
|
|
+ normalized = dict(values)
|
|
|
+ if "name" in normalized:
|
|
|
+ normalized["name"] = name.strip()
|
|
|
+ if "dimension_secondary" in normalized:
|
|
|
+ normalized["dimension_secondary"] = secondary.strip()
|
|
|
+ for field in ("commonality_analysis", "topic_support", "weight_score"):
|
|
|
+ if field in normalized:
|
|
|
+ value = normalized[field]
|
|
|
+ if value is not None and not isinstance(value, Mapping):
|
|
|
+ raise WorkspaceError(
|
|
|
+ "LEGACY_WRITE_INVALID", f"element {field} must be an object or null"
|
|
|
+ )
|
|
|
+ normalized[field] = dict(value) if value is not None else None
|
|
|
+ if "support_elements" in normalized:
|
|
|
+ raw_support = normalized["support_elements"]
|
|
|
+ if (
|
|
|
+ not isinstance(raw_support, Sequence)
|
|
|
+ or isinstance(raw_support, (str, bytes))
|
|
|
+ or any(not isinstance(item, Mapping) for item in raw_support)
|
|
|
+ ):
|
|
|
+ raise WorkspaceError(
|
|
|
+ "LEGACY_WRITE_INVALID", "element support_elements must be an object array"
|
|
|
+ )
|
|
|
+ support_elements = cast(Sequence[Mapping[str, Any]], raw_support)
|
|
|
+ normalized["support_elements"] = [dict(item) for item in support_elements]
|
|
|
+ if "is_active" in normalized and not isinstance(normalized["is_active"], bool):
|
|
|
+ raise WorkspaceError("LEGACY_WRITE_INVALID", "element is_active must be boolean")
|
|
|
+ normalized["updated_at"] = _now()
|
|
|
+ await session.execute(
|
|
|
+ update(script_build_element)
|
|
|
+ .where(
|
|
|
+ script_build_element.c.id == element_id,
|
|
|
+ script_build_element.c.script_build_id == workspace.script_build_id,
|
|
|
+ script_build_element.c.branch_id == workspace.branch_id,
|
|
|
+ )
|
|
|
+ .values(**normalized)
|
|
|
+ )
|
|
|
+ if normalized.get("is_active") is False:
|
|
|
+ await session.execute(
|
|
|
+ delete(script_build_paragraph_element).where(
|
|
|
+ script_build_paragraph_element.c.script_build_id
|
|
|
+ == workspace.script_build_id,
|
|
|
+ script_build_paragraph_element.c.branch_id == workspace.branch_id,
|
|
|
+ script_build_paragraph_element.c.element_id == element_id,
|
|
|
+ )
|
|
|
+ )
|
|
|
+
|
|
|
+ async def batch_link(
|
|
|
+ self,
|
|
|
+ context: CandidateWriteContext,
|
|
|
+ links: Sequence[tuple[int, Sequence[int]]],
|
|
|
+ ) -> int:
|
|
|
+ if not links:
|
|
|
+ raise WorkspaceError("LEGACY_WRITE_INVALID", "link batch is empty")
|
|
|
+ for paragraph_id, element_ids in links:
|
|
|
+ if not element_ids:
|
|
|
+ raise WorkspaceError("LEGACY_REFERENCE_INVALID", "link element list is empty")
|
|
|
+ for element_id in element_ids:
|
|
|
+ _authorize_link_write(context, paragraph_id, int(element_id), operation="create")
|
|
|
+ async with self._sessions() as session, session.begin():
|
|
|
+ workspace = await self._locked_workspace(session, context)
|
|
|
+ _require_workspace_kind(workspace, _ELEMENT_KINDS)
|
|
|
+ pairs: set[tuple[int, int]] = set()
|
|
|
+ for paragraph_id, element_ids in links:
|
|
|
+ paragraph = await self._paragraph(session, workspace, paragraph_id, active=True)
|
|
|
+ if paragraph is None or not element_ids:
|
|
|
+ raise WorkspaceError("LEGACY_REFERENCE_INVALID", "link paragraph is invalid")
|
|
|
+ for element_id in element_ids:
|
|
|
+ element = await self._element(session, workspace, int(element_id), active=True)
|
|
|
+ if element is None:
|
|
|
+ raise WorkspaceError("LEGACY_REFERENCE_INVALID", "link element is invalid")
|
|
|
+ pairs.add((paragraph_id, int(element_id)))
|
|
|
+ inserted = 0
|
|
|
+ for paragraph_id, element_id in sorted(pairs):
|
|
|
+ exists = (
|
|
|
+ (
|
|
|
+ await session.execute(
|
|
|
+ select(script_build_paragraph_element.c.id).where(
|
|
|
+ script_build_paragraph_element.c.script_build_id
|
|
|
+ == workspace.script_build_id,
|
|
|
+ script_build_paragraph_element.c.branch_id == workspace.branch_id,
|
|
|
+ script_build_paragraph_element.c.paragraph_id == paragraph_id,
|
|
|
+ script_build_paragraph_element.c.element_id == element_id,
|
|
|
+ )
|
|
|
+ )
|
|
|
+ )
|
|
|
+ .scalars()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if exists is None:
|
|
|
+ await session.execute(
|
|
|
+ insert(script_build_paragraph_element).values(
|
|
|
+ script_build_id=workspace.script_build_id,
|
|
|
+ branch_id=workspace.branch_id,
|
|
|
+ paragraph_id=paragraph_id,
|
|
|
+ element_id=element_id,
|
|
|
+ )
|
|
|
+ )
|
|
|
+ inserted += 1
|
|
|
+ return inserted
|
|
|
+
|
|
|
+ async def delete_links(
|
|
|
+ self,
|
|
|
+ context: CandidateWriteContext,
|
|
|
+ *,
|
|
|
+ pairs: Sequence[tuple[int, int]] = (),
|
|
|
+ paragraph_ids: Sequence[int] = (),
|
|
|
+ element_ids: Sequence[int] = (),
|
|
|
+ only_dangling: bool = False,
|
|
|
+ ) -> int:
|
|
|
+ if not (pairs or paragraph_ids or element_ids or only_dangling):
|
|
|
+ raise WorkspaceError("LEGACY_WRITE_INVALID", "link deletion selector is empty")
|
|
|
+ if only_dangling:
|
|
|
+ _authorize_write(context, entity="links", operation="delete", identities=())
|
|
|
+ for paragraph_id, element_id in pairs:
|
|
|
+ _authorize_link_write(context, paragraph_id, element_id, operation="delete")
|
|
|
+ for paragraph_id in paragraph_ids:
|
|
|
+ _authorize_write(
|
|
|
+ context, entity="links", operation="delete", identities=(paragraph_id,)
|
|
|
+ )
|
|
|
+ for element_id in element_ids:
|
|
|
+ _authorize_write(context, entity="links", operation="delete", identities=(element_id,))
|
|
|
+ async with self._sessions() as session, session.begin():
|
|
|
+ workspace = await self._locked_workspace(session, context)
|
|
|
+ _require_workspace_kind(workspace, _ELEMENT_KINDS)
|
|
|
+ rows = (
|
|
|
+ (
|
|
|
+ await session.execute(
|
|
|
+ select(script_build_paragraph_element).where(
|
|
|
+ script_build_paragraph_element.c.script_build_id
|
|
|
+ == workspace.script_build_id,
|
|
|
+ script_build_paragraph_element.c.branch_id == workspace.branch_id,
|
|
|
+ )
|
|
|
+ )
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .all()
|
|
|
+ )
|
|
|
+ paragraph_set = set(paragraph_ids)
|
|
|
+ element_set = set(element_ids)
|
|
|
+ pair_set = set(pairs)
|
|
|
+ active_paragraphs = {
|
|
|
+ int(item["id"])
|
|
|
+ for item in (
|
|
|
+ (
|
|
|
+ await session.execute(
|
|
|
+ select(script_build_paragraph.c.id).where(
|
|
|
+ script_build_paragraph.c.script_build_id
|
|
|
+ == workspace.script_build_id,
|
|
|
+ script_build_paragraph.c.branch_id == workspace.branch_id,
|
|
|
+ script_build_paragraph.c.is_active.is_(True),
|
|
|
+ )
|
|
|
+ )
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .all()
|
|
|
+ )
|
|
|
+ }
|
|
|
+ active_elements = set(
|
|
|
+ (
|
|
|
+ await session.execute(
|
|
|
+ select(script_build_element.c.id).where(
|
|
|
+ script_build_element.c.script_build_id == workspace.script_build_id,
|
|
|
+ script_build_element.c.branch_id == workspace.branch_id,
|
|
|
+ script_build_element.c.is_active.is_(True),
|
|
|
+ )
|
|
|
+ )
|
|
|
+ ).scalars()
|
|
|
+ )
|
|
|
+ delete_ids = [
|
|
|
+ int(row["id"])
|
|
|
+ for row in rows
|
|
|
+ if (int(row["paragraph_id"]), int(row["element_id"])) in pair_set
|
|
|
+ or int(row["paragraph_id"]) in paragraph_set
|
|
|
+ or int(row["element_id"]) in element_set
|
|
|
+ or (
|
|
|
+ only_dangling
|
|
|
+ and (
|
|
|
+ int(row["paragraph_id"]) not in active_paragraphs
|
|
|
+ or int(row["element_id"]) not in active_elements
|
|
|
+ )
|
|
|
+ )
|
|
|
+ ]
|
|
|
+ if delete_ids:
|
|
|
+ await session.execute(
|
|
|
+ delete(script_build_paragraph_element).where(
|
|
|
+ script_build_paragraph_element.c.id.in_(delete_ids)
|
|
|
+ )
|
|
|
+ )
|
|
|
+ return len(delete_ids)
|
|
|
+
|
|
|
+ async def freeze(
|
|
|
+ self,
|
|
|
+ context: CandidateWriteContext,
|
|
|
+ *,
|
|
|
+ lineage: CandidateLineageV1,
|
|
|
+ structured_script: dict[str, Any] | None = None,
|
|
|
+ ) -> tuple[ArtifactVersion, ArtifactRef]:
|
|
|
+ existing_workspace = await self.require(context)
|
|
|
+ base_artifact: Any | None = None
|
|
|
+ if existing_workspace.parent_artifact_version_id is not None:
|
|
|
+ parent = await self._artifacts.get_by_id(
|
|
|
+ existing_workspace.parent_artifact_version_id,
|
|
|
+ script_build_id=context.script_build_id,
|
|
|
+ )
|
|
|
+ if parent.state not in {ArtifactState.FROZEN, ArtifactState.PUBLISHED}:
|
|
|
+ raise WorkspaceError("STALE_BASE_REVISION", "workspace base is no longer immutable")
|
|
|
+ base_artifact = parent.artifact
|
|
|
+ if (
|
|
|
+ lineage.base_revision != parent.artifact_version_id
|
|
|
+ or lineage.base_artifact_ref
|
|
|
+ != f"script-build://artifact-versions/{parent.artifact_version_id}"
|
|
|
+ or lineage.base_artifact_digest != parent.canonical_sha256
|
|
|
+ ):
|
|
|
+ raise WorkspaceError(
|
|
|
+ "STALE_BASE_REVISION",
|
|
|
+ "lineage does not identify the workspace base exactly",
|
|
|
+ )
|
|
|
+ elif any(
|
|
|
+ value is not None
|
|
|
+ for value in (
|
|
|
+ lineage.base_revision,
|
|
|
+ lineage.base_artifact_ref,
|
|
|
+ lineage.base_artifact_digest,
|
|
|
+ )
|
|
|
+ ):
|
|
|
+ raise WorkspaceError(
|
|
|
+ "STALE_BASE_REVISION", "a base-less workspace cannot claim base lineage"
|
|
|
+ )
|
|
|
+ async with self._sessions() as session, session.begin():
|
|
|
+ workspace = await self._locked_workspace(session, context, require_draft=False)
|
|
|
+ if workspace.base_revision != lineage.base_revision:
|
|
|
+ raise WorkspaceError(
|
|
|
+ "STALE_BASE_REVISION", "workspace base revision differs from the task contract"
|
|
|
+ )
|
|
|
+ if workspace.state is not ArtifactState.DRAFT:
|
|
|
+ version = await self._artifacts.get_by_id(
|
|
|
+ workspace.artifact_version_id, script_build_id=context.script_build_id
|
|
|
+ )
|
|
|
+ stored_lineage = getattr(version.artifact, "lineage", None)
|
|
|
+ if stored_lineage is not None and not _same_lineage_contract(
|
|
|
+ stored_lineage, lineage
|
|
|
+ ):
|
|
|
+ raise WorkspaceError(
|
|
|
+ "STALE_BASE_REVISION",
|
|
|
+ "frozen candidate belongs to a different immutable input closure",
|
|
|
+ )
|
|
|
+ return version, _ref(version)
|
|
|
+ snapshot = await self._snapshot_in_session(session, workspace)
|
|
|
+ if snapshot.source_identity_map:
|
|
|
+ lineage = replace(
|
|
|
+ lineage,
|
|
|
+ source_lineage=(
|
|
|
+ *lineage.source_lineage,
|
|
|
+ *snapshot.source_identity_map,
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ artifact = _workspace_artifact(
|
|
|
+ workspace,
|
|
|
+ snapshot,
|
|
|
+ lineage,
|
|
|
+ base_artifact=base_artifact,
|
|
|
+ structured_script=structured_script,
|
|
|
+ )
|
|
|
+ payload = cast(dict[str, Any], normalize_json(artifact.content_payload()))
|
|
|
+ digest = canonical_sha256(payload)
|
|
|
+ now = _now()
|
|
|
+ result = await session.execute(
|
|
|
+ update(artifact_version_table)
|
|
|
+ .where(
|
|
|
+ artifact_version_table.c.id == workspace.artifact_version_id,
|
|
|
+ artifact_version_table.c.state == ArtifactState.DRAFT.value,
|
|
|
+ )
|
|
|
+ .values(
|
|
|
+ canonical_json=payload,
|
|
|
+ canonical_sha256=digest.hex_value,
|
|
|
+ state=ArtifactState.FROZEN.value,
|
|
|
+ frozen_at=now,
|
|
|
+ )
|
|
|
+ )
|
|
|
+ if int(cast(Any, result).rowcount) != 1:
|
|
|
+ raise WorkspaceError(
|
|
|
+ "ATTEMPT_WORKSPACE_FROZEN", "candidate was frozen concurrently"
|
|
|
+ )
|
|
|
+ await session.execute(
|
|
|
+ update(script_build_task_plan_step)
|
|
|
+ .where(
|
|
|
+ script_build_task_plan_step.c.script_build_id == workspace.script_build_id,
|
|
|
+ script_build_task_plan_step.c.branch_id == workspace.branch_id,
|
|
|
+ script_build_task_plan_step.c.step_key == "1",
|
|
|
+ )
|
|
|
+ .values(
|
|
|
+ status="完成",
|
|
|
+ result_note=(
|
|
|
+ f"script-build://artifact-versions/{workspace.artifact_version_id} "
|
|
|
+ f"{digest.wire}"
|
|
|
+ ),
|
|
|
+ updated_at=now,
|
|
|
+ )
|
|
|
+ )
|
|
|
+ version = await self._artifacts.get_by_id(
|
|
|
+ workspace.artifact_version_id, script_build_id=context.script_build_id
|
|
|
+ )
|
|
|
+ return version, _ref(version)
|
|
|
+
|
|
|
+ async def discard(self, context: CandidateWriteContext, *, reason: str) -> CandidateWorkspace:
|
|
|
+ bounded_reason = " ".join(reason.split())
|
|
|
+ if not bounded_reason or len(bounded_reason) > 500:
|
|
|
+ raise WorkspaceError("LEGACY_WRITE_INVALID", "discard reason is invalid")
|
|
|
+ async with self._sessions() as session, session.begin():
|
|
|
+ workspace = await self._locked_workspace(session, context, require_draft=False)
|
|
|
+ if workspace.state is ArtifactState.DISCARDED:
|
|
|
+ return workspace
|
|
|
+ if workspace.state is not ArtifactState.DRAFT:
|
|
|
+ raise WorkspaceError(
|
|
|
+ "ATTEMPT_WORKSPACE_FROZEN", "an immutable workspace cannot be discarded"
|
|
|
+ )
|
|
|
+ now = _now()
|
|
|
+ result = await session.execute(
|
|
|
+ update(artifact_version_table)
|
|
|
+ .where(
|
|
|
+ artifact_version_table.c.id == workspace.artifact_version_id,
|
|
|
+ artifact_version_table.c.state == ArtifactState.DRAFT.value,
|
|
|
+ )
|
|
|
+ .values(state=ArtifactState.DISCARDED.value)
|
|
|
+ )
|
|
|
+ if int(cast(Any, result).rowcount) != 1:
|
|
|
+ raise WorkspaceError(
|
|
|
+ "ATTEMPT_WORKSPACE_FROZEN", "candidate workspace changed concurrently"
|
|
|
+ )
|
|
|
+ await session.execute(
|
|
|
+ update(script_build_task_plan_step)
|
|
|
+ .where(
|
|
|
+ script_build_task_plan_step.c.script_build_id == workspace.script_build_id,
|
|
|
+ script_build_task_plan_step.c.branch_id == workspace.branch_id,
|
|
|
+ script_build_task_plan_step.c.step_key == "1",
|
|
|
+ )
|
|
|
+ .values(status="受阻", result_note=bounded_reason, updated_at=now)
|
|
|
+ )
|
|
|
+ return replace(workspace, state=ArtifactState.DISCARDED)
|
|
|
+
|
|
|
+ async def _locked_workspace(
|
|
|
+ self,
|
|
|
+ session: AsyncSession,
|
|
|
+ context: CandidateWriteContext,
|
|
|
+ *,
|
|
|
+ require_draft: bool = True,
|
|
|
+ ) -> CandidateWorkspace:
|
|
|
+ row = await self._row_by_attempt(session, context.attempt_id, lock=True)
|
|
|
+ if row is None:
|
|
|
+ raise WorkspaceError(
|
|
|
+ "ATTEMPT_WORKSPACE_NOT_FOUND", "candidate workspace does not exist"
|
|
|
+ )
|
|
|
+ workspace = self._workspace(row)
|
|
|
+ self._validate_context(workspace, context)
|
|
|
+ if require_draft and workspace.state is not ArtifactState.DRAFT:
|
|
|
+ raise WorkspaceError("ATTEMPT_WORKSPACE_FROZEN", "candidate workspace is immutable")
|
|
|
+ if not context.write_scope:
|
|
|
+ raise WorkspaceError("WRITE_SCOPE_VIOLATION", "task has no candidate write scope")
|
|
|
+ return workspace
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ async def _row_by_attempt(
|
|
|
+ session: AsyncSession, attempt_id: str, *, lock: bool = False
|
|
|
+ ) -> Mapping[str, Any] | None:
|
|
|
+ statement = select(artifact_version_table).where(
|
|
|
+ artifact_version_table.c.attempt_id == attempt_id
|
|
|
+ )
|
|
|
+ if lock:
|
|
|
+ statement = statement.with_for_update()
|
|
|
+ return cast(
|
|
|
+ Mapping[str, Any] | None,
|
|
|
+ (await session.execute(statement)).mappings().one_or_none(),
|
|
|
+ )
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _workspace(row: Mapping[str, Any]) -> CandidateWorkspace:
|
|
|
+ branch = row["legacy_branch_id"]
|
|
|
+ if branch is None:
|
|
|
+ raise WorkspaceError(
|
|
|
+ "LEGACY_WRITE_INVALID", "candidate workspace has no allocated branch"
|
|
|
+ )
|
|
|
+ return CandidateWorkspace(
|
|
|
+ artifact_version_id=int(row["id"]),
|
|
|
+ script_build_id=int(row["script_build_id"]),
|
|
|
+ task_id=str(row["task_id"]),
|
|
|
+ attempt_id=str(row["attempt_id"]),
|
|
|
+ spec_version=int(row["spec_version"]),
|
|
|
+ artifact_kind=ArtifactKind(str(row["artifact_type"])),
|
|
|
+ branch_id=int(branch),
|
|
|
+ state=ArtifactState(str(row["state"])),
|
|
|
+ base_revision=(int(row["base_revision"]) if row["base_revision"] is not None else None),
|
|
|
+ parent_artifact_version_id=(
|
|
|
+ int(row["parent_artifact_version_id"])
|
|
|
+ if row["parent_artifact_version_id"] is not None
|
|
|
+ else None
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _validate_context(workspace: CandidateWorkspace, context: CandidateWriteContext) -> None:
|
|
|
+ if (
|
|
|
+ workspace.script_build_id != context.script_build_id
|
|
|
+ or workspace.task_id != context.task_id
|
|
|
+ or workspace.attempt_id != context.attempt_id
|
|
|
+ or workspace.spec_version != context.spec_version
|
|
|
+ ):
|
|
|
+ raise WorkspaceError(
|
|
|
+ "LEGACY_REFERENCE_INVALID", "workspace is outside the protected attempt context"
|
|
|
+ )
|
|
|
+
|
|
|
+ def _validate_workspace(
|
|
|
+ self,
|
|
|
+ row: Mapping[str, Any],
|
|
|
+ context: CandidateWriteContext,
|
|
|
+ artifact_kind: ArtifactKind,
|
|
|
+ parent: ArtifactVersion | None,
|
|
|
+ ) -> CandidateWorkspace:
|
|
|
+ workspace = self._workspace(row)
|
|
|
+ self._validate_context(workspace, context)
|
|
|
+ expected_parent = parent.artifact_version_id if parent else None
|
|
|
+ if (
|
|
|
+ workspace.artifact_kind is not artifact_kind
|
|
|
+ or workspace.parent_artifact_version_id != expected_parent
|
|
|
+ or workspace.base_revision != expected_parent
|
|
|
+ ):
|
|
|
+ raise WorkspaceError(
|
|
|
+ "STALE_BASE_REVISION", "attempt already owns a different workspace contract"
|
|
|
+ )
|
|
|
+ return workspace
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ async def _paragraph(
|
|
|
+ session: AsyncSession,
|
|
|
+ workspace: CandidateWorkspace,
|
|
|
+ paragraph_id: int,
|
|
|
+ *,
|
|
|
+ active: bool | None = None,
|
|
|
+ ) -> Mapping[str, Any] | None:
|
|
|
+ statement = select(script_build_paragraph).where(
|
|
|
+ script_build_paragraph.c.id == paragraph_id,
|
|
|
+ script_build_paragraph.c.script_build_id == workspace.script_build_id,
|
|
|
+ script_build_paragraph.c.branch_id == workspace.branch_id,
|
|
|
+ )
|
|
|
+ if active is not None:
|
|
|
+ statement = statement.where(script_build_paragraph.c.is_active.is_(active))
|
|
|
+ return cast(
|
|
|
+ Mapping[str, Any] | None,
|
|
|
+ (await session.execute(statement)).mappings().one_or_none(),
|
|
|
+ )
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ async def _element(
|
|
|
+ session: AsyncSession,
|
|
|
+ workspace: CandidateWorkspace,
|
|
|
+ element_id: int,
|
|
|
+ *,
|
|
|
+ active: bool | None = None,
|
|
|
+ ) -> Mapping[str, Any] | None:
|
|
|
+ statement = select(script_build_element).where(
|
|
|
+ script_build_element.c.id == element_id,
|
|
|
+ script_build_element.c.script_build_id == workspace.script_build_id,
|
|
|
+ script_build_element.c.branch_id == workspace.branch_id,
|
|
|
+ )
|
|
|
+ if active is not None:
|
|
|
+ statement = statement.where(script_build_element.c.is_active.is_(active))
|
|
|
+ return cast(
|
|
|
+ Mapping[str, Any] | None,
|
|
|
+ (await session.execute(statement)).mappings().one_or_none(),
|
|
|
+ )
|
|
|
+
|
|
|
+ async def _snapshot_in_session(
|
|
|
+ self, session: AsyncSession, workspace: CandidateWorkspace
|
|
|
+ ) -> CandidateWorkspaceSnapshot:
|
|
|
+ paragraph_rows = (
|
|
|
+ (
|
|
|
+ await session.execute(
|
|
|
+ select(script_build_paragraph)
|
|
|
+ .where(
|
|
|
+ script_build_paragraph.c.script_build_id == workspace.script_build_id,
|
|
|
+ script_build_paragraph.c.branch_id == workspace.branch_id,
|
|
|
+ )
|
|
|
+ .order_by(
|
|
|
+ script_build_paragraph.c.paragraph_index,
|
|
|
+ script_build_paragraph.c.id,
|
|
|
+ )
|
|
|
+ )
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .all()
|
|
|
+ )
|
|
|
+ element_rows = (
|
|
|
+ (
|
|
|
+ await session.execute(
|
|
|
+ select(script_build_element)
|
|
|
+ .where(
|
|
|
+ script_build_element.c.script_build_id == workspace.script_build_id,
|
|
|
+ script_build_element.c.branch_id == workspace.branch_id,
|
|
|
+ )
|
|
|
+ .order_by(script_build_element.c.id)
|
|
|
+ )
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .all()
|
|
|
+ )
|
|
|
+ link_rows = (
|
|
|
+ (
|
|
|
+ await session.execute(
|
|
|
+ select(script_build_paragraph_element)
|
|
|
+ .where(
|
|
|
+ script_build_paragraph_element.c.script_build_id
|
|
|
+ == workspace.script_build_id,
|
|
|
+ script_build_paragraph_element.c.branch_id == workspace.branch_id,
|
|
|
+ )
|
|
|
+ .order_by(
|
|
|
+ script_build_paragraph_element.c.paragraph_id,
|
|
|
+ script_build_paragraph_element.c.element_id,
|
|
|
+ )
|
|
|
+ )
|
|
|
+ )
|
|
|
+ .mappings()
|
|
|
+ .all()
|
|
|
+ )
|
|
|
+ source_identity_map: list[dict[str, int | str]] = [
|
|
|
+ {
|
|
|
+ "entity": "paragraph",
|
|
|
+ "source_local_id": int(row["base_ref_id"]),
|
|
|
+ "local_id": int(row["id"]),
|
|
|
+ }
|
|
|
+ for row in paragraph_rows
|
|
|
+ if row["base_ref_id"] is not None
|
|
|
+ ]
|
|
|
+ source_identity_map.extend(
|
|
|
+ {
|
|
|
+ "entity": "element",
|
|
|
+ "source_local_id": int(row["base_ref_id"]),
|
|
|
+ "local_id": int(row["id"]),
|
|
|
+ }
|
|
|
+ for row in element_rows
|
|
|
+ if row["base_ref_id"] is not None
|
|
|
+ )
|
|
|
+ return CandidateWorkspaceSnapshot(
|
|
|
+ workspace=workspace,
|
|
|
+ paragraphs=tuple(
|
|
|
+ _paragraph_record(cast(Mapping[str, Any], row)) for row in paragraph_rows
|
|
|
+ ),
|
|
|
+ elements=tuple(_element_record(cast(Mapping[str, Any], row)) for row in element_rows),
|
|
|
+ links=tuple(
|
|
|
+ ScriptParagraphElementLinkV1(
|
|
|
+ paragraph_id=int(row["paragraph_id"]),
|
|
|
+ element_id=int(row["element_id"]),
|
|
|
+ )
|
|
|
+ for row in link_rows
|
|
|
+ ),
|
|
|
+ source_identity_map=tuple(source_identity_map),
|
|
|
+ )
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ async def _ensure_task_plan(
|
|
|
+ session: AsyncSession,
|
|
|
+ context: CandidateWriteContext,
|
|
|
+ workspace: CandidateWorkspace,
|
|
|
+ ) -> None:
|
|
|
+ existing = (
|
|
|
+ (
|
|
|
+ await session.execute(
|
|
|
+ select(script_build_task_plan_step.c.id).where(
|
|
|
+ script_build_task_plan_step.c.script_build_id == workspace.script_build_id,
|
|
|
+ script_build_task_plan_step.c.branch_id == workspace.branch_id,
|
|
|
+ script_build_task_plan_step.c.step_key == "1",
|
|
|
+ )
|
|
|
+ )
|
|
|
+ )
|
|
|
+ .scalars()
|
|
|
+ .one_or_none()
|
|
|
+ )
|
|
|
+ if existing is not None:
|
|
|
+ return
|
|
|
+ await session.execute(
|
|
|
+ insert(script_build_task_plan_step).values(
|
|
|
+ script_build_id=workspace.script_build_id,
|
|
|
+ branch_id=workspace.branch_id,
|
|
|
+ round_index=None,
|
|
|
+ step_key="1",
|
|
|
+ title=context.objective[:500],
|
|
|
+ data_needs=json.dumps(
|
|
|
+ list(context.input_refs), ensure_ascii=False, separators=(",", ":")
|
|
|
+ ),
|
|
|
+ status="进行中",
|
|
|
+ result_note=None,
|
|
|
+ depends_on=[],
|
|
|
+ sort_order=1,
|
|
|
+ created_at=_now(),
|
|
|
+ updated_at=_now(),
|
|
|
+ )
|
|
|
+ )
|
|
|
+
|
|
|
+ async def _materialize_base(
|
|
|
+ self,
|
|
|
+ session: AsyncSession,
|
|
|
+ workspace: CandidateWorkspace,
|
|
|
+ parent: ArtifactVersion,
|
|
|
+ ) -> None:
|
|
|
+ artifact = parent.artifact
|
|
|
+ paragraphs = getattr(artifact, "paragraphs", ())
|
|
|
+ elements = getattr(artifact, "elements", ())
|
|
|
+ links = getattr(artifact, "paragraph_element_links", ())
|
|
|
+ paragraph_map: dict[int, int] = {}
|
|
|
+ for item in sorted(paragraphs, key=lambda value: (value.level, value.paragraph_index)):
|
|
|
+ parent_id = paragraph_map.get(item.parent_id) if item.parent_id is not None else None
|
|
|
+ result = await session.execute(
|
|
|
+ insert(script_build_paragraph).values(
|
|
|
+ script_build_id=workspace.script_build_id,
|
|
|
+ branch_id=workspace.branch_id,
|
|
|
+ base_ref_id=item.paragraph_id,
|
|
|
+ paragraph_index=item.paragraph_index,
|
|
|
+ level=item.level,
|
|
|
+ parent_id=parent_id,
|
|
|
+ name=item.name,
|
|
|
+ content_range=item.content_range,
|
|
|
+ theme=item.theme,
|
|
|
+ form=item.form,
|
|
|
+ function=item.function,
|
|
|
+ feeling=item.feeling,
|
|
|
+ theme_elements=list(item.theme_elements),
|
|
|
+ form_elements=list(item.form_elements),
|
|
|
+ function_elements=list(item.function_elements),
|
|
|
+ feeling_elements=list(item.feeling_elements),
|
|
|
+ description=item.description,
|
|
|
+ full_description=item.full_description,
|
|
|
+ is_active=item.is_active,
|
|
|
+ created_at=_now(),
|
|
|
+ updated_at=_now(),
|
|
|
+ )
|
|
|
+ )
|
|
|
+ paragraph_map[item.paragraph_id] = int(cast(Any, result).inserted_primary_key[0])
|
|
|
+ element_map: dict[int, int] = {}
|
|
|
+ for item in elements:
|
|
|
+ result = await session.execute(
|
|
|
+ insert(script_build_element).values(
|
|
|
+ script_build_id=workspace.script_build_id,
|
|
|
+ branch_id=workspace.branch_id,
|
|
|
+ base_ref_id=item.element_id,
|
|
|
+ name=item.name,
|
|
|
+ dimension_primary=item.dimension_primary,
|
|
|
+ dimension_secondary=item.dimension_secondary,
|
|
|
+ commonality_analysis=item.commonality_analysis,
|
|
|
+ topic_support=item.topic_support,
|
|
|
+ weight_score=item.weight_score,
|
|
|
+ support_elements=list(item.support_elements),
|
|
|
+ is_active=item.is_active,
|
|
|
+ created_at=_now(),
|
|
|
+ updated_at=_now(),
|
|
|
+ )
|
|
|
+ )
|
|
|
+ element_map[item.element_id] = int(cast(Any, result).inserted_primary_key[0])
|
|
|
+ for item in links:
|
|
|
+ paragraph_id = paragraph_map.get(item.paragraph_id)
|
|
|
+ element_id = element_map.get(item.element_id)
|
|
|
+ if paragraph_id is not None and element_id is not None:
|
|
|
+ await session.execute(
|
|
|
+ insert(script_build_paragraph_element).values(
|
|
|
+ script_build_id=workspace.script_build_id,
|
|
|
+ branch_id=workspace.branch_id,
|
|
|
+ paragraph_id=paragraph_id,
|
|
|
+ element_id=element_id,
|
|
|
+ )
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def _workspace_artifact(
|
|
|
+ workspace: CandidateWorkspace,
|
|
|
+ snapshot: CandidateWorkspaceSnapshot,
|
|
|
+ lineage: CandidateLineageV1,
|
|
|
+ *,
|
|
|
+ base_artifact: Any | None,
|
|
|
+ structured_script: dict[str, Any] | None,
|
|
|
+) -> Any:
|
|
|
+ change_manifest = _change_manifest(snapshot, base_artifact)
|
|
|
+ if workspace.artifact_kind is ArtifactKind.STRUCTURE:
|
|
|
+ return StructureArtifactV1(lineage=lineage, paragraphs=snapshot.paragraphs)
|
|
|
+ if workspace.artifact_kind is ArtifactKind.PARAGRAPH:
|
|
|
+ return ParagraphArtifactV1(
|
|
|
+ lineage=lineage,
|
|
|
+ paragraphs=snapshot.paragraphs,
|
|
|
+ elements=snapshot.elements,
|
|
|
+ paragraph_element_links=snapshot.links,
|
|
|
+ change_manifest=change_manifest,
|
|
|
+ )
|
|
|
+ if workspace.artifact_kind is ArtifactKind.ELEMENT_SET:
|
|
|
+ return ElementSetArtifactV1(
|
|
|
+ lineage=lineage,
|
|
|
+ paragraphs=snapshot.paragraphs,
|
|
|
+ elements=snapshot.elements,
|
|
|
+ paragraph_element_links=snapshot.links,
|
|
|
+ change_manifest=change_manifest,
|
|
|
+ )
|
|
|
+ if workspace.artifact_kind is ArtifactKind.STRUCTURED_SCRIPT:
|
|
|
+ if structured_script is None:
|
|
|
+ raise WorkspaceError(
|
|
|
+ "LEGACY_WRITE_INVALID", "StructuredScript freeze requires its closed manifest"
|
|
|
+ )
|
|
|
+ return StructuredScriptArtifactV1(
|
|
|
+ direction_ref=str(structured_script.get("direction_ref", "")),
|
|
|
+ input_closure_digest=lineage.input_closure_digest,
|
|
|
+ paragraphs=snapshot.paragraphs,
|
|
|
+ elements=snapshot.elements,
|
|
|
+ paragraph_element_links=snapshot.links,
|
|
|
+ source_artifact_refs=tuple(structured_script.get("source_artifact_refs", ())),
|
|
|
+ evidence_refs=tuple(structured_script.get("evidence_refs", ())),
|
|
|
+ acceptance_notes=tuple(structured_script.get("acceptance_notes", ())),
|
|
|
+ )
|
|
|
+ raise WorkspaceError("LEGACY_WRITE_INVALID", "unsupported workspace artifact kind")
|
|
|
+
|
|
|
+
|
|
|
+def _change_manifest(
|
|
|
+ snapshot: CandidateWorkspaceSnapshot,
|
|
|
+ base_artifact: Any | None,
|
|
|
+) -> dict[str, Any]:
|
|
|
+ paragraph_source_by_local = {
|
|
|
+ int(item["local_id"]): int(item["source_local_id"])
|
|
|
+ for item in snapshot.source_identity_map
|
|
|
+ if item["entity"] == "paragraph"
|
|
|
+ }
|
|
|
+ element_source_by_local = {
|
|
|
+ int(item["local_id"]): int(item["source_local_id"])
|
|
|
+ for item in snapshot.source_identity_map
|
|
|
+ if item["entity"] == "element"
|
|
|
+ }
|
|
|
+ base_paragraphs = {item.paragraph_id: item for item in getattr(base_artifact, "paragraphs", ())}
|
|
|
+ base_elements = {item.element_id: item for item in getattr(base_artifact, "elements", ())}
|
|
|
+
|
|
|
+ created_paragraph_ids = [
|
|
|
+ item.paragraph_id
|
|
|
+ for item in snapshot.paragraphs
|
|
|
+ if item.paragraph_id not in paragraph_source_by_local
|
|
|
+ ]
|
|
|
+ created_element_ids = [
|
|
|
+ item.element_id
|
|
|
+ for item in snapshot.elements
|
|
|
+ if item.element_id not in element_source_by_local
|
|
|
+ ]
|
|
|
+ updated_paragraph_ids = [
|
|
|
+ item.paragraph_id
|
|
|
+ for item in snapshot.paragraphs
|
|
|
+ if (source_id := paragraph_source_by_local.get(item.paragraph_id)) is not None
|
|
|
+ and source_id in base_paragraphs
|
|
|
+ and _paragraph_comparison_payload(item, paragraph_source_by_local)
|
|
|
+ != _paragraph_comparison_payload(base_paragraphs[source_id], {})
|
|
|
+ ]
|
|
|
+ updated_element_ids = [
|
|
|
+ item.element_id
|
|
|
+ for item in snapshot.elements
|
|
|
+ if (source_id := element_source_by_local.get(item.element_id)) is not None
|
|
|
+ and source_id in base_elements
|
|
|
+ and item.to_payload() | {"element_id": source_id} != base_elements[source_id].to_payload()
|
|
|
+ ]
|
|
|
+
|
|
|
+ base_links = {
|
|
|
+ (item.paragraph_id, item.element_id)
|
|
|
+ for item in getattr(base_artifact, "paragraph_element_links", ())
|
|
|
+ }
|
|
|
+ current_base_links: set[tuple[int, int]] = set()
|
|
|
+ created_links: list[dict[str, int]] = []
|
|
|
+ for item in snapshot.links:
|
|
|
+ source_paragraph = paragraph_source_by_local.get(item.paragraph_id)
|
|
|
+ source_element = element_source_by_local.get(item.element_id)
|
|
|
+ if source_paragraph is not None and source_element is not None:
|
|
|
+ current_base_links.add((source_paragraph, source_element))
|
|
|
+ else:
|
|
|
+ created_links.append(item.to_payload())
|
|
|
+
|
|
|
+ return {
|
|
|
+ "created": {
|
|
|
+ "paragraph_ids": created_paragraph_ids,
|
|
|
+ "element_ids": created_element_ids,
|
|
|
+ "links": created_links,
|
|
|
+ },
|
|
|
+ "updated": {
|
|
|
+ "paragraph_ids": updated_paragraph_ids,
|
|
|
+ "element_ids": updated_element_ids,
|
|
|
+ },
|
|
|
+ "deactivated": {
|
|
|
+ "paragraph_ids": [
|
|
|
+ item.paragraph_id for item in snapshot.paragraphs if not item.is_active
|
|
|
+ ],
|
|
|
+ "element_ids": [item.element_id for item in snapshot.elements if not item.is_active],
|
|
|
+ },
|
|
|
+ "deleted_links": [
|
|
|
+ {"paragraph_id": paragraph_id, "element_id": element_id}
|
|
|
+ for paragraph_id, element_id in sorted(base_links - current_base_links)
|
|
|
+ ],
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+def _paragraph_comparison_payload(
|
|
|
+ paragraph: ScriptParagraphV1,
|
|
|
+ source_by_local: Mapping[int, int],
|
|
|
+) -> dict[str, Any]:
|
|
|
+ payload = paragraph.to_payload()
|
|
|
+ payload["paragraph_id"] = source_by_local.get(paragraph.paragraph_id, paragraph.paragraph_id)
|
|
|
+ if paragraph.parent_id is not None:
|
|
|
+ payload["parent_id"] = source_by_local.get(paragraph.parent_id, paragraph.parent_id)
|
|
|
+ return payload
|
|
|
+
|
|
|
+
|
|
|
+def _same_lineage_contract(first: CandidateLineageV1, second: CandidateLineageV1) -> bool:
|
|
|
+ return (
|
|
|
+ first.scope_ref == second.scope_ref
|
|
|
+ and first.input_snapshot_ref == second.input_snapshot_ref
|
|
|
+ and first.input_closure_digest == second.input_closure_digest
|
|
|
+ and first.write_scope == second.write_scope
|
|
|
+ and first.base_artifact_ref == second.base_artifact_ref
|
|
|
+ and first.base_artifact_digest == second.base_artifact_digest
|
|
|
+ and first.base_revision == second.base_revision
|
|
|
+ and first.supersedes_decision_ids == second.supersedes_decision_ids
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def _paragraph_record(row: Mapping[str, Any]) -> ScriptParagraphV1:
|
|
|
+ return ScriptParagraphV1(
|
|
|
+ paragraph_id=int(row["id"]),
|
|
|
+ paragraph_index=int(row["paragraph_index"]),
|
|
|
+ level=int(row["level"]),
|
|
|
+ parent_id=int(row["parent_id"]) if row["parent_id"] is not None else None,
|
|
|
+ name=str(row["name"] or ""),
|
|
|
+ content_range=dict(row["content_range"] or {}),
|
|
|
+ theme=row["theme"],
|
|
|
+ form=row["form"],
|
|
|
+ function=row["function"],
|
|
|
+ feeling=row["feeling"],
|
|
|
+ theme_elements=tuple(row["theme_elements"] or ()),
|
|
|
+ form_elements=tuple(row["form_elements"] or ()),
|
|
|
+ function_elements=tuple(row["function_elements"] or ()),
|
|
|
+ feeling_elements=tuple(row["feeling_elements"] or ()),
|
|
|
+ description=row["description"],
|
|
|
+ full_description=row["full_description"],
|
|
|
+ is_active=bool(row["is_active"]),
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def _element_record(row: Mapping[str, Any]) -> ScriptElementV1:
|
|
|
+ return ScriptElementV1(
|
|
|
+ element_id=int(row["id"]),
|
|
|
+ name=str(row["name"]),
|
|
|
+ dimension_primary=str(row["dimension_primary"]),
|
|
|
+ dimension_secondary=str(row["dimension_secondary"]),
|
|
|
+ commonality_analysis=(
|
|
|
+ dict(row["commonality_analysis"]) if row["commonality_analysis"] is not None else None
|
|
|
+ ),
|
|
|
+ topic_support=dict(row["topic_support"]) if row["topic_support"] is not None else None,
|
|
|
+ weight_score=dict(row["weight_score"]) if row["weight_score"] is not None else None,
|
|
|
+ support_elements=tuple(row["support_elements"] or ()),
|
|
|
+ is_active=bool(row["is_active"]),
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def _ref(version: ArtifactVersion) -> ArtifactRef:
|
|
|
+ return ArtifactRef(
|
|
|
+ uri=f"script-build://artifact-versions/{version.artifact_version_id}",
|
|
|
+ kind=version.artifact_type.value,
|
|
|
+ version=str(version.artifact_version_id),
|
|
|
+ digest=version.canonical_sha256,
|
|
|
+ summary=(
|
|
|
+ f"kind={version.artifact_type.value};version={version.artifact_version_id};"
|
|
|
+ f"digest={version.canonical_sha256}"
|
|
|
+ ),
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def _now() -> datetime:
|
|
|
+ return datetime.now(UTC)
|
|
|
+
|
|
|
+
|
|
|
+def _positive_int(value: Any, label: str) -> int:
|
|
|
+ if isinstance(value, bool):
|
|
|
+ raise WorkspaceError("LEGACY_WRITE_INVALID", f"{label} must be a positive integer")
|
|
|
+ try:
|
|
|
+ result = int(value)
|
|
|
+ except (TypeError, ValueError) as exc:
|
|
|
+ raise WorkspaceError("LEGACY_WRITE_INVALID", f"{label} must be a positive integer") from exc
|
|
|
+ if result < 1:
|
|
|
+ raise WorkspaceError("LEGACY_WRITE_INVALID", f"{label} must be a positive integer")
|
|
|
+ return result
|
|
|
+
|
|
|
+
|
|
|
+def _atoms(values: Sequence[Mapping[str, Any]], *, feeling: bool) -> list[dict[str, Any]]:
|
|
|
+ if not values:
|
|
|
+ return []
|
|
|
+ output: list[dict[str, Any]] = []
|
|
|
+ dimensions: set[str] = set()
|
|
|
+ for value in values:
|
|
|
+ atom = str(value.get("原子点", "")).strip()
|
|
|
+ dimension = str(value.get("维度", "")).strip()
|
|
|
+ kind = value.get("维度类型")
|
|
|
+ if not atom or not dimension or (not feeling and kind not in {"主维度", "从维度"}):
|
|
|
+ raise WorkspaceError("LEGACY_WRITE_INVALID", "paragraph atom is invalid")
|
|
|
+ if dimension in dimensions:
|
|
|
+ raise WorkspaceError("LEGACY_WRITE_INVALID", "one atom batch cannot repeat a dimension")
|
|
|
+ dimensions.add(dimension)
|
|
|
+ normalized: dict[str, Any] = {"原子点": atom, "维度": dimension}
|
|
|
+ if not feeling:
|
|
|
+ normalized["维度类型"] = kind
|
|
|
+ if "id" in value:
|
|
|
+ normalized["id"] = value["id"]
|
|
|
+ output.append(normalized)
|
|
|
+ return output
|
|
|
+
|
|
|
+
|
|
|
+def _element_fields(name: str, primary: str, secondary: str) -> None:
|
|
|
+ if primary not in {"实质", "形式"} or not name.strip() or not secondary.strip():
|
|
|
+ raise WorkspaceError("LEGACY_WRITE_INVALID", "element fields are invalid")
|
|
|
+
|
|
|
+
|
|
|
+def _content_range_subset(child: Mapping[str, Any], parent: Mapping[str, Any]) -> bool:
|
|
|
+ for key, value in child.items():
|
|
|
+ if key not in parent:
|
|
|
+ return False
|
|
|
+ parent_value = parent[key]
|
|
|
+ if isinstance(value, list) and isinstance(parent_value, list):
|
|
|
+ if not set(value) <= set(parent_value):
|
|
|
+ return False
|
|
|
+ elif value != parent_value:
|
|
|
+ return False
|
|
|
+ return True
|
|
|
+
|
|
|
+
|
|
|
+def _authorize_write(
|
|
|
+ context: CandidateWriteContext,
|
|
|
+ *,
|
|
|
+ entity: str,
|
|
|
+ operation: str,
|
|
|
+ identities: Sequence[int | str],
|
|
|
+ content_range: Mapping[str, Any] | None = None,
|
|
|
+) -> None:
|
|
|
+ scopes = tuple(context.write_scope)
|
|
|
+ if not scopes:
|
|
|
+ raise WorkspaceError("WRITE_SCOPE_VIOLATION", "task has no candidate write scope")
|
|
|
+ if any(
|
|
|
+ value in {"script-build://writes", "script-build://writes/full"}
|
|
|
+ or value.startswith("script-build://scopes/")
|
|
|
+ for value in scopes
|
|
|
+ ):
|
|
|
+ return
|
|
|
+ entity_prefix = f"script-build://writes/{entity}"
|
|
|
+ operation_ref = f"script-build://writes/operations/{operation}"
|
|
|
+ for value in scopes:
|
|
|
+ if value in {entity_prefix, f"{entity_prefix}/*", operation_ref}:
|
|
|
+ return
|
|
|
+ if any(value == f"{entity_prefix}/{identity}" for identity in identities):
|
|
|
+ return
|
|
|
+ if value.startswith(f"{entity_prefix}/"):
|
|
|
+ selector = value.removeprefix(f"{entity_prefix}/").casefold()
|
|
|
+ # A named selector denotes the immutable business scope owned by this
|
|
|
+ # workspace; numeric selectors continue to address one local entity.
|
|
|
+ if selector and not selector.isdigit():
|
|
|
+ return
|
|
|
+ if selector and any(
|
|
|
+ selector in str(identity).casefold().replace("_", "-").split()
|
|
|
+ or str(identity).casefold().endswith(selector)
|
|
|
+ for identity in identities
|
|
|
+ ):
|
|
|
+ return
|
|
|
+ if entity == "paragraphs" and content_range is not None:
|
|
|
+ requested = tuple(
|
|
|
+ f"script-build://writes/content-ranges/{key}/{value}"
|
|
|
+ for key, raw in content_range.items()
|
|
|
+ for value in (raw if isinstance(raw, list) else [raw])
|
|
|
+ )
|
|
|
+ if requested and all(value in scopes for value in requested):
|
|
|
+ return
|
|
|
+ raise WorkspaceError(
|
|
|
+ "WRITE_SCOPE_VIOLATION",
|
|
|
+ f"{operation} on {entity} is outside the immutable task write scope",
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def _authorize_link_write(
|
|
|
+ context: CandidateWriteContext,
|
|
|
+ paragraph_id: int,
|
|
|
+ element_id: int,
|
|
|
+ *,
|
|
|
+ operation: str,
|
|
|
+) -> None:
|
|
|
+ scopes = tuple(context.write_scope)
|
|
|
+ pair_ref = f"script-build://writes/links/{paragraph_id}/{element_id}"
|
|
|
+ if pair_ref in scopes:
|
|
|
+ return
|
|
|
+ if any(
|
|
|
+ value == "script-build://writes/elements"
|
|
|
+ or value.startswith("script-build://writes/elements/")
|
|
|
+ for value in scopes
|
|
|
+ ):
|
|
|
+ # Element-set candidates own their paragraph placement links; repository
|
|
|
+ # ownership checks below still require both endpoints in this workspace.
|
|
|
+ return
|
|
|
+ _authorize_write(
|
|
|
+ context,
|
|
|
+ entity="links",
|
|
|
+ operation=operation,
|
|
|
+ identities=(paragraph_id, element_id),
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def _require_workspace_kind(
|
|
|
+ workspace: CandidateWorkspace, allowed: frozenset[ArtifactKind]
|
|
|
+) -> None:
|
|
|
+ if workspace.artifact_kind not in allowed:
|
|
|
+ raise WorkspaceError(
|
|
|
+ "WRITE_SCOPE_VIOLATION",
|
|
|
+ f"{workspace.artifact_kind.value} workspace cannot use this write adapter",
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+__all__ = ["SqlAlchemyCandidateWorkspaceRepository"]
|