Skip to content

Commit 3b23b17

Browse files
feat(clone): v2 full fidelity — share replication + lookup version history
Make the cloned cloud entities match the source, including user-group sharing. Sharing (sharing.py): extract the user/group axis mapping into reusable helpers and add replicate_share(), generic over the write mechanism (POST /share/ or a detail PATCH). Existing /share/-POST phases are unchanged (thin wrapper). - lookups + agentic projects now replicate shared_to_org + shared_users (mapped by email) via their detail PATCH; lookups have no group axis, agentic group sharing is polymorphic/read-only and is warned, not dropped silently. Lookups published-version replay: reproduce each source published version (stage template + remapped adapters + that version's reference files onto the draft, then publish in version_number order, recording a lookup_version remap), then restore the draft to the source's current draft. Assignments now resolve published-pinned versions via the version remap instead of being skipped. Residual: assignment_values_snapshot is backend-derived at publish time (best effort). Fixes list_lookup_versions to unwrap the {"versions": [...]} envelope. Manual review: auto_approved_users remapped by email (was carried verbatim); auto_approved_document_classes carried with a verify-on-target warning. MR rows have no share fields (inherit workflow/org visibility). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CsGrHbs5SWmQkKqiimg6CF
1 parent 7d27d4a commit 3b23b17

8 files changed

Lines changed: 978 additions & 103 deletions

File tree

‎src/unstract/clone/client.py‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,72 @@ def create_lookup_assignment(self, payload: dict[str, Any]) -> dict[str, Any]:
666666
"""
667667
return self._request("POST", "lookups/assignments/", json=payload)
668668

669+
def update_lookup_share(
670+
self, lookup_id: str, payload: dict[str, Any]
671+
) -> dict[str, Any]:
672+
"""Replicate share state onto a lookup via its detail PATCH.
673+
674+
``payload`` carries ``shared_to_org`` + ``shared_users`` (target user
675+
pks). The model has no group sharing, so no ``shared_groups`` axis.
676+
"""
677+
return self._request(
678+
"PATCH", f"lookups/definitions/{lookup_id}/", json=payload
679+
)
680+
681+
def list_lookup_versions(self, lookup_id: str) -> list[dict[str, Any]]:
682+
"""List a lookup's versions (draft + published).
683+
684+
Rows carry ``version_id``, ``is_draft``, ``version_number``,
685+
``version_name``; the detail (``get_lookup_version``) inlines content.
686+
"""
687+
result = self._request(
688+
"GET", f"lookups/definitions/{lookup_id}/versions/"
689+
)
690+
if isinstance(result, list):
691+
return result
692+
# This endpoint wraps rows as {"versions": [...], "next_version_number"}.
693+
return (result or {}).get("versions", (result or {}).get("results", []))
694+
695+
def get_lookup_version(
696+
self, lookup_id: str, version_id: str
697+
) -> dict[str, Any]:
698+
"""Fetch a version's detail (``prompt_template``, adapters, files)."""
699+
return self._request(
700+
"GET", f"lookups/definitions/{lookup_id}/versions/{version_id}/"
701+
)
702+
703+
def download_lookup_version_file(
704+
self, lookup_id: str, version_id: str, file_id: str
705+
) -> bytes:
706+
"""Download a published version's reference-file bytes (raw body)."""
707+
path = (
708+
f"lookups/definitions/{lookup_id}/versions/{version_id}/"
709+
f"files/{file_id}/content/"
710+
)
711+
url = self._url(path)
712+
logger.debug("GET %s", url)
713+
resp = self._session.get(url, timeout=self.timeout, verify=self.verify)
714+
if not 200 <= resp.status_code < 300:
715+
raise PlatformAPIError(
716+
f"GET {path} returned {resp.status_code}",
717+
status_code=resp.status_code,
718+
body=resp.text[:2000],
719+
)
720+
return resp.content
721+
722+
def publish_lookup_version(
723+
self, lookup_id: str, payload: dict[str, Any]
724+
) -> dict[str, Any]:
725+
"""Freeze the current draft into a published version.
726+
727+
``payload`` carries ``version_name`` (+ optional ``rebind_assignments``).
728+
Returns the new published version (``version_id``). Used to replay a
729+
source lookup's published-version history onto the target.
730+
"""
731+
return self._request(
732+
"POST", f"lookups/definitions/{lookup_id}/versions/", json=payload
733+
)
734+
669735
# ----- manual review / HITL (cloud-only) -----
670736
#
671737
# Each workflow can hold one RuleEngine row per ``rule_type`` (DB / API)
@@ -779,6 +845,19 @@ def create_agentic_project(self, payload: dict[str, Any]) -> dict[str, Any]:
779845
"""Create an agentic project. Returns the created row (carries ``id``)."""
780846
return self._request("POST", "agentic/projects/", json=payload)
781847

848+
def update_agentic_project_share(
849+
self, project_id: str, payload: dict[str, Any]
850+
) -> dict[str, Any]:
851+
"""Replicate share state onto an agentic project via its detail PATCH.
852+
853+
``payload`` carries ``shared_to_org`` + ``shared_users`` (target user
854+
pks). ``shared_groups`` is polymorphic/read-only on this serializer and
855+
is handled by the share helper's group-omission warning.
856+
"""
857+
return self._request(
858+
"PATCH", f"agentic/projects/{project_id}/", json=payload
859+
)
860+
782861
def list_agentic_prompt_versions(
783862
self, *, project_id: str | None = None
784863
) -> list[dict[str, Any]]:

‎src/unstract/clone/phases/agentic_studio.py‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
from unstract.clone.exceptions import NameConflictError
4545
from unstract.clone.phases.base import Phase, build_post_payload
4646
from unstract.clone.report import CloneReport, PhaseResult
47+
from unstract.clone.sharing import replicate_share
4748

4849
logger = logging.getLogger(__name__)
4950

@@ -166,9 +167,10 @@ def _clone_project(
166167
tgt_project_id,
167168
)
168169

169-
# Children + registry write to the real target only.
170+
# Children + registry + share write to the real target only.
170171
if self.ctx.options.dry_run:
171172
return
173+
self._replicate_share(src, name, tgt_project_id, result, lock)
172174
self._clone_prompt_versions(name, src_project_id, tgt_project_id, result, lock)
173175
self._clone_schemas(name, src_project_id, tgt_project_id, result, lock)
174176
self._republish_registry(name, src_project_id, tgt_project_id, result, lock)
@@ -207,6 +209,35 @@ def _build_project_payload(
207209
payload[slot] = tgt_adapter_id
208210
return payload
209211

212+
# ----- share state -----
213+
214+
def _replicate_share(
215+
self,
216+
src: dict[str, Any],
217+
name: str,
218+
tgt_project_id: str,
219+
result: PhaseResult,
220+
lock: threading.Lock,
221+
) -> None:
222+
# The project list/detail share the same serializer, so the source row
223+
# already carries shared_users (target-mappable user pks), shared_to_org
224+
# and created_by — no detail fetch needed.
225+
# ponytail: agentic group sharing is deferred — shared_groups is
226+
# polymorphic/read-only on the project serializer (share via the detail
227+
# PATCH only reaches shared_users + shared_to_org), so include_groups is
228+
# off and a source group share yields a single warning.
229+
replicate_share(
230+
self.ctx,
231+
apply_fn=lambda p: self.ctx.target.update_agentic_project_share(
232+
tgt_project_id, p
233+
),
234+
entity_label=f"agentic project '{name}'",
235+
src=src,
236+
result=result,
237+
lock=lock,
238+
include_groups=False,
239+
)
240+
210241
# ----- prompt versions -----
211242

212243
def _clone_prompt_versions(

0 commit comments

Comments
 (0)