Skip to content

Commit 876861f

Browse files
feat(clone): skip unmigratable resources upfront + failures summary
- Detect frictionless adapter dependence in custom_tool phase (source service-account view hides frictionless adapters; profile names not in that view can't be migrated). Skip the tool and cascade-skip its workflow via a registry-id set on CloneContext. - Detect OAuth-backed connectors (metadata carries access_token / refresh_token) and skip ahead of POST. Avoids the backend's OAuthTimeOut 408 and surfaces a re-auth instruction for the operator. - CloneReport now prints a "Failures" section before the table with one truncated line per recorded error (capped at 30 rows). Entity labels in the section are bold-cyan to match the phase column. - Drop "workflow_owner" from POST payloads (server-managed).
1 parent e87822d commit 876861f

9 files changed

Lines changed: 296 additions & 15 deletions

File tree

src/unstract/clone/context.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,6 @@ class CloneContext:
102102
target: PlatformClient
103103
options: CloneOptions
104104
remap: RemapTable = field(default_factory=RemapTable)
105+
# Source prompt_registry_ids whose CustomTool was skipped; used to
106+
# cascade-skip dependent workflows downstream.
107+
skipped_custom_tool_registry_ids: set[str] = field(default_factory=set)

src/unstract/clone/phases/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"created_at",
3434
"modified_at",
3535
"shared_users",
36+
"workflow_owner",
3637
}
3738
)
3839

src/unstract/clone/phases/connector.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@
3131

3232
CONNECTOR_PATH = "connector/"
3333

34+
# Backend POST serializer trips on these keys (connector_v2/serializers.py)
35+
# by trying to refresh against the source user's social auth — guaranteed
36+
# OAuthTimeOut on target. Detect here and skip ahead of POST.
37+
_OAUTH_TOKEN_KEYS: frozenset[str] = frozenset({"access_token", "refresh_token"})
38+
39+
40+
def _has_oauth_tokens(metadata: dict[str, Any]) -> bool:
41+
return any(metadata.get(k) for k in _OAUTH_TOKEN_KEYS)
42+
3443

3544
class ConnectorPhase(Phase):
3645
name = "connector"
@@ -74,7 +83,8 @@ def _clone_one(
7483
result.errors.append(f"GET source detail {name}: {e}")
7584
return
7685

77-
if not src.get("connector_metadata"):
86+
metadata = src.get("connector_metadata") or {}
87+
if not metadata:
7888
logger.info(
7989
"skipping connector '%s' (src=%s, catalog=%s) — source returned no metadata",
8090
name,
@@ -85,6 +95,19 @@ def _clone_one(
8595
result.skipped += 1
8696
return
8797

98+
if _has_oauth_tokens(metadata):
99+
logger.warning(
100+
"skipping connector '%s' (src=%s, catalog=%s) — OAuth-backed; "
101+
"re-authorise on target after the clone, then re-run to wire "
102+
"dependent workflow endpoints.",
103+
name,
104+
src_id,
105+
src.get("connector_id"),
106+
)
107+
with lock:
108+
result.skipped += 1
109+
return
110+
88111
try:
89112
existing = self.ctx.target.list_connectors(name=name)
90113
except Exception as e:

src/unstract/clone/phases/custom_tool.py

Lines changed: 95 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,18 @@ def run(self, report: CloneReport) -> PhaseResult:
7979
result.errors.append(f"list target tools: {e}")
8080
return result
8181

82+
# Source's service-account view hides frictionless adapters; a
83+
# profile-referenced name missing here flags a tool we can't migrate.
84+
try:
85+
self._src_adapter_names = {
86+
a["adapter_name"] for a in self.ctx.source.list_adapters()
87+
}
88+
except Exception as e:
89+
logger.exception("Failed to list source adapters: %s", e)
90+
result.failed += 1
91+
result.errors.append(f"list source adapters for visibility check: {e}")
92+
return result
93+
8294
# Updated under lock when a fresh create lands so duplicate
8395
# same-name source rows adopt instead of recreating.
8496
target_by_name: dict[str, dict[str, Any]] = {
@@ -235,7 +247,25 @@ def _create_fresh(
235247
)
236248
return None
237249

238-
adapter_ids = self._resolve_target_adapter_ids(src_tool_id, tool_name)
250+
default_profile = self._source_default_profile(src_tool_id, tool_name)
251+
if default_profile is None:
252+
with lock:
253+
result.failed += 1
254+
result.errors.append(
255+
f"import {tool_name}: no default profile on source"
256+
)
257+
return None
258+
259+
invisible = self._invisible_source_adapter_names(default_profile)
260+
if invisible:
261+
self._register_frictionless_skip(
262+
src_tool_id, tool_name, invisible, result, lock
263+
)
264+
return None
265+
266+
adapter_ids = self._resolve_target_adapter_ids(
267+
default_profile, tool_name
268+
)
239269
if adapter_ids is None:
240270
with lock:
241271
result.failed += 1
@@ -265,17 +295,9 @@ def _create_fresh(
265295
)
266296
return tgt_tool_id
267297

268-
def _resolve_target_adapter_ids(
298+
def _source_default_profile(
269299
self, src_tool_id: str, tool_name: str
270-
) -> dict[str, str] | None:
271-
"""Source profile carries adapter NAMES (per serializer); resolve
272-
each name to a target adapter UUID via ``list_adapters(name=...)``.
273-
274-
Returns ``None`` if any of the four required adapters can't be
275-
found on target — caller fails the tool. AdapterPhase preserves
276-
names across orgs so this lookup should always hit when the
277-
adapter clone ran cleanly.
278-
"""
300+
) -> dict[str, Any] | None:
279301
try:
280302
src_profiles = self.ctx.source.list_profiles(src_tool_id)
281303
except Exception as e:
@@ -293,11 +315,71 @@ def _resolve_target_adapter_ids(
293315
"source tool '%s' has no profiles to derive adapter ids from",
294316
tool_name,
295317
)
296-
return None
318+
return default
319+
320+
def _invisible_source_adapter_names(
321+
self, default_profile: dict[str, Any]
322+
) -> list[str]:
323+
"""Profile adapter names not in the source's visible adapter set
324+
(typically frictionless) — these can't be migrated.
325+
"""
326+
missing: list[str] = []
327+
for src_field, _ in _PROFILE_ADAPTER_FIELDS:
328+
adapter_name = _extract_adapter_name(default_profile.get(src_field))
329+
if adapter_name and adapter_name not in self._src_adapter_names:
330+
missing.append(adapter_name)
331+
return missing
332+
333+
def _register_frictionless_skip(
334+
self,
335+
src_tool_id: str,
336+
tool_name: str,
337+
missing_adapters: list[str],
338+
result: PhaseResult,
339+
lock: threading.Lock,
340+
) -> None:
341+
"""Record the skip + source registry id so dependent workflows
342+
cascade-skip downstream.
343+
"""
344+
logger.warning(
345+
"skipping tool '%s' src=%s — default profile references adapters "
346+
"not visible to the source service account (frictionless?): %s. "
347+
"Wire equivalents on target and re-run.",
348+
tool_name,
349+
src_tool_id,
350+
missing_adapters,
351+
)
352+
try:
353+
src_regs = self.ctx.source.list_registries(custom_tool=src_tool_id)
354+
except Exception as e:
355+
logger.warning(
356+
"registry lookup failed for skipped tool '%s' — "
357+
"downstream cascade-skip may not fire: %s",
358+
tool_name,
359+
e,
360+
)
361+
src_regs = []
362+
with lock:
363+
result.skipped += 1
364+
for reg in src_regs:
365+
reg_id = reg.get("prompt_registry_id")
366+
if reg_id:
367+
self.ctx.skipped_custom_tool_registry_ids.add(reg_id)
368+
369+
def _resolve_target_adapter_ids(
370+
self, default_profile: dict[str, Any], tool_name: str
371+
) -> dict[str, str] | None:
372+
"""Source profile carries adapter NAMES (per serializer); resolve
373+
each name to a target adapter UUID via ``list_adapters(name=...)``.
297374
375+
Returns ``None`` if any of the four required adapters can't be
376+
found on target — caller fails the tool. AdapterPhase preserves
377+
names across orgs so this lookup should always hit when the
378+
adapter clone ran cleanly.
379+
"""
298380
resolved: dict[str, str] = {}
299381
for src_field, form_field in _PROFILE_ADAPTER_FIELDS:
300-
adapter_name = _extract_adapter_name(default.get(src_field))
382+
adapter_name = _extract_adapter_name(default_profile.get(src_field))
301383
if not adapter_name:
302384
logger.warning(
303385
"source default profile for tool '%s' missing adapter '%s'",

src/unstract/clone/phases/workflow.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,19 +50,57 @@ def run(self, report: CloneReport) -> PhaseResult:
5050
result.errors.append(f"list source workflows: {e}")
5151
return result
5252

53+
# Built once so per-workflow cascade-skip checks stay O(1).
54+
self._wf_to_src_tool_id = self._collect_wf_tool_map(result)
55+
5356
logger.info("Found %d workflow(s) in source org", len(src_workflows))
5457
self.parallel_map(
5558
src_workflows,
5659
lambda src, lock: self._clone_one(src, result, lock),
5760
)
5861
return result
5962

63+
def _collect_wf_tool_map(self, result: PhaseResult) -> dict[str, str]:
64+
"""Map source workflow_id to its ToolInstance.tool_id; listed once
65+
to avoid N+1 fetches.
66+
"""
67+
if not self.ctx.skipped_custom_tool_registry_ids:
68+
return {}
69+
try:
70+
tis = self.ctx.source.list_tool_instances()
71+
except Exception as e:
72+
logger.warning(
73+
"workflow phase: failed to list source tool_instances for "
74+
"cascade-skip lookup (%s); proceeding without cascade",
75+
e,
76+
)
77+
return {}
78+
mapping: dict[str, str] = {}
79+
for ti in tis:
80+
wf_id = ti.get("workflow")
81+
tool_id = ti.get("tool_id")
82+
if wf_id and tool_id:
83+
mapping[wf_id] = tool_id
84+
return mapping
85+
6086
def _clone_one(
6187
self, src: dict[str, Any], result: PhaseResult, lock: threading.Lock
6288
) -> None:
6389
name = src["workflow_name"]
6490
src_id = src["id"]
6591

92+
src_tool_id = self._wf_to_src_tool_id.get(src_id)
93+
if src_tool_id and src_tool_id in self.ctx.skipped_custom_tool_registry_ids:
94+
logger.warning(
95+
"skipping workflow '%s' src=%s — its tool was skipped in "
96+
"custom_tool phase (frictionless adapter dependence)",
97+
name,
98+
src_id,
99+
)
100+
with lock:
101+
result.skipped += 1
102+
return
103+
66104
try:
67105
existing = self.ctx.target.list_workflows(name=name)
68106
except Exception as e:

src/unstract/clone/report.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ def render(self) -> str:
7575
console = Console(
7676
file=buf, force_terminal=True, color_system="truecolor", width=100
7777
)
78+
# Actionable summary first so it doesn't scroll past the table.
79+
self._render_failures_summary(console_print=console.print, rich=True)
7880
self._render_endpoints(console.print)
7981
table = Table(title="Clone Report", header_style="bold cyan")
8082
table.add_column("Phase", style="bold", justify="left")
@@ -151,7 +153,9 @@ def _fmt_duration_plain(seconds: float) -> str:
151153
return f"{int(mins)}m{secs:.0f}s"
152154

153155
def _render_plain(self) -> str:
154-
lines = ["Clone Report", "=" * 60]
156+
lines: list[str] = []
157+
self._render_failures_summary(console_print=lines.append, rich=False)
158+
lines.extend(["Clone Report", "=" * 60])
155159
self._render_endpoints(lines.append)
156160
header = (
157161
f"{'Phase':<24}{'Created':>10}{'Adopted':>10}"
@@ -281,6 +285,47 @@ def _files_sections_plain(self) -> list[str]:
281285
lines.append(f" - {self._describe_file_row(row)}")
282286
return lines
283287

288+
# Caps so a long traceback or many failures don't dominate the report.
289+
_FAILURE_LINE_MAX_CHARS = 200
290+
_FAILURE_MAX_ROWS = 30
291+
292+
def _render_failures_summary(self, console_print: Any, rich: bool) -> None:
293+
rows: list[tuple[str, str]] = []
294+
for p in self.phases:
295+
for err in p.errors:
296+
rows.append((p.name, err))
297+
if not rows:
298+
return
299+
header = "Failures (see WARNING/ERROR log lines above for full detail)"
300+
if rich:
301+
console_print(f"[red]{header}:[/red]")
302+
else:
303+
console_print(f"{header}:")
304+
shown = rows[: self._FAILURE_MAX_ROWS]
305+
for phase_name, err in shown:
306+
truncated = self._truncate(err, self._FAILURE_LINE_MAX_CHARS)
307+
if rich:
308+
console_print(
309+
f" - [bold cyan]{phase_name}[/bold cyan]: {truncated}",
310+
highlight=False,
311+
)
312+
else:
313+
console_print(f" - {phase_name}: {truncated}")
314+
remaining = len(rows) - len(shown)
315+
if remaining > 0:
316+
tail = f" ... +{remaining} more — see logs"
317+
if rich:
318+
console_print(f"[dim]{tail}[/dim]")
319+
else:
320+
console_print(tail)
321+
322+
@staticmethod
323+
def _truncate(text: str, limit: int) -> str:
324+
text = text.replace("\n", " ")
325+
if len(text) <= limit:
326+
return text
327+
return text[: limit - 1] + "…"
328+
284329
@staticmethod
285330
def _describe_file_row(row: dict[str, Any]) -> str:
286331
tool = row.get("tool_name") or row.get("tool_id") or "?"

tests/clone/test_connector_phase.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,31 @@ def test_redacted_metadata_connector_skipped():
118118
assert ctx.remap.resolve("connector", "src-ucs") is None
119119

120120

121+
def test_oauth_connector_skipped_before_post():
122+
"""OAuth-backed connectors (metadata carries access_token/refresh_token)
123+
would fail target POST with OAuthTimeOut — skip ahead of POST so the
124+
operator re-authorises post-clone.
125+
"""
126+
oauth = _src("src-gdrive", "Unstract's google drive")
127+
oauth["connector_metadata"] = {
128+
"provider": "google-oauth2",
129+
"uid": "src-user",
130+
"access_token": "ya29.src-access",
131+
"refresh_token": "1//src-refresh",
132+
}
133+
src = FakeClient([oauth])
134+
tgt = FakeClient()
135+
ctx = _ctx(src, tgt)
136+
137+
result = ConnectorPhase(ctx).run(CloneReport())
138+
139+
assert result.skipped == 1
140+
assert result.created == 0
141+
assert result.failed == 0
142+
assert tgt.posts == []
143+
assert ctx.remap.resolve("connector", "src-gdrive") is None
144+
145+
121146
def test_idempotency_zero_creates_on_rerun():
122147
src = FakeClient([_src("src-a", "Prod PG")])
123148
tgt = FakeClient(

0 commit comments

Comments
 (0)