subagents

This commit is contained in:
2026-03-20 17:20:24 +03:00
parent b89b9a66f2
commit 1149e8df1d
36 changed files with 4313 additions and 327 deletions

View File

@@ -196,6 +196,33 @@ async function postApi(endpoint, body) {
}
// [/DEF:postApi:Function]
// [DEF:deleteApi:Function]
// @PURPOSE: Generic DELETE request wrapper.
// @PRE: endpoint is provided.
// @POST: Returns Promise resolving to JSON data or throws on error.
// @PARAM: endpoint (string) - API endpoint.
// @RETURN: Promise<any> - JSON response.
async function deleteApi(endpoint) {
try {
console.log(`[api.deleteApi][Action] Deleting from context={{'endpoint': '${endpoint}'}}`);
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
method: 'DELETE',
headers: getAuthHeaders(),
});
console.log(`[api.deleteApi][Action] Received response context={{'status': ${response.status}, 'ok': ${response.ok}}}`);
if (!response.ok) {
throw await buildApiError(response);
}
if (response.status === 204) return null;
return await response.json();
} catch (error) {
console.error(`[api.deleteApi][Coherence:Failed] Error deleting from ${endpoint}:`, error);
notifyApiError(error);
throw error;
}
}
// [/DEF:deleteApi:Function]
// [DEF:requestApi:Function]
// @PURPOSE: Generic request wrapper.
// @PRE: endpoint and method are provided.
@@ -237,6 +264,7 @@ async function requestApi(endpoint, method = 'GET', body = null) {
export const api = {
fetchApi,
postApi,
deleteApi,
requestApi,
getPlugins: () => fetchApi('/plugins'),
getTasks: (options = {}) => {

View File

@@ -91,11 +91,7 @@
return $t.dataset_review?.preview?.stale_body;
}
if (effectiveState === "failed") {
return (
preview?.error_details ||
preview?.error_code ||
$t.dataset_review?.preview?.error_body
);
return $t.dataset_review?.preview?.error_body;
}
if (effectiveState === "missing") {
return $t.dataset_review?.preview?.missing_body;
@@ -103,6 +99,12 @@
return $t.dataset_review?.preview?.ready_body;
}
function buildPreviewTechnicalDetails() {
return [preview?.error_code, preview?.error_details].filter(Boolean).join("\n\n");
}
const previewTechnicalDetails = $derived(buildPreviewTechnicalDetails());
async function requestPreview() {
if (!sessionId || disabled || localStatus === "saving") {
return;

View File

@@ -217,9 +217,18 @@
<div class="mt-4 space-y-3">
{#each launchBlockers as blocker}
<div class="rounded-xl border border-red-200 bg-white p-3">
<div class="text-sm font-medium text-slate-900">{blocker.label}</div>
<div class="text-sm font-medium leading-6 text-slate-900 break-words [overflow-wrap:anywhere]">
{blocker.label}
</div>
{#if blocker.detail}
<div class="mt-1 break-all text-xs text-slate-600">{blocker.detail}</div>
<div class="mt-2 max-h-28 overflow-auto rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
<div
data-testid="launch-blocker-detail"
class="text-xs leading-5 text-slate-600 whitespace-pre-wrap break-words [overflow-wrap:anywhere]"
>
{blocker.detail}
</div>
</div>
{/if}
<button
type="button"
@@ -242,7 +251,7 @@
<div class="text-xs uppercase tracking-wide text-slate-500">
{$t.dataset_review?.launch?.dataset_ref_label}
</div>
<div class="mt-1 text-sm font-medium text-slate-900">
<div class="mt-1 text-sm font-medium text-slate-900 break-words [overflow-wrap:anywhere]">
{session?.dataset_ref || ($t.common?.unknown || "unknown")}
</div>
</div>

View File

@@ -81,6 +81,18 @@
const normalized = String(action || "");
return $t.dataset_review?.workspace?.actions?.[normalized] || normalized;
}
function getFindingMessage(finding) {
return String(finding?.message || "").trim();
}
function getFindingTechnicalReference(finding) {
return String(finding?.caused_by_ref || "").trim();
}
function getFindingResolutionNote(finding) {
return String(finding?.resolution_note || "").trim();
}
</script>
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">

View File

@@ -654,6 +654,25 @@
"audit": "Audit"
}
},
"workspace_entry": {
"resume_eyebrow": "Resume session",
"resume_title": "Continue an existing review session",
"resume_description": "If a dataset review was already started, open the relevant session and continue from its latest saved state.",
"resume_available_badge": "Sessions available",
"resume_loading": "Loading existing sessions...",
"resume_load_failed": "Failed to load existing review sessions.",
"resume_empty_title": "No saved sessions yet",
"resume_empty_body": "Start a new review session from the source intake panel to make it available here for reopening later.",
"resume_action": "Resume",
"session_id_label": "Session ID",
"dataset_ref_label": "Dataset reference",
"environment_id_label": "Environment",
"status_label": "Status",
"readiness_label": "Readiness",
"phase_label": "Phase",
"updated_at_label": "Updated",
"last_activity_at_label": "Last activity"
},
"workspace": {
"eyebrow": "Dataset orchestration",
"title": "Dataset review workspace",

View File

@@ -652,6 +652,25 @@
"audit": "Аудит"
}
},
"workspace_entry": {
"resume_eyebrow": "Возобновление сессии",
"resume_title": "Продолжить существующую review-сессию",
"resume_description": "Если review уже была начата, откройте нужную сессию и продолжайте с последнего сохраненного состояния.",
"resume_available_badge": "Доступно сессий",
"resume_loading": "Загрузка доступных сессий...",
"resume_load_failed": "Не удалось загрузить существующие review-сессии.",
"resume_empty_title": "Сохраненных сессий пока нет",
"resume_empty_body": "Запустите новую review-сессию через блок источника справа, чтобы она появилась здесь для повторного открытия.",
"resume_action": "Возобновить",
"session_id_label": "ID сессии",
"dataset_ref_label": "Ссылка на датасет",
"environment_id_label": "Окружение",
"status_label": "Статус",
"readiness_label": "Готовность",
"phase_label": "Фаза",
"updated_at_label": "Обновлено",
"last_activity_at_label": "Последняя активность"
},
"workspace": {
"eyebrow": "Оркестрация датасета",
"title": "Workspace review датасета",

View File

@@ -3,14 +3,17 @@
<!-- @SEMANTICS: dataset-review, workspace-entry, source-intake, session-bootstrap -->
<!-- @PURPOSE: Entry route for Dataset Review Workspace that allows starting a new resumable review session before navigating to a specific session id route. -->
<!-- @LAYER: UI -->
<!-- @RELATION: [CALLS] ->[fetchApi] -->
<!-- @RELATION: [CALLS] ->[postApi] -->
<!-- @RELATION: [BINDS_TO] ->[SourceIntakePanel] -->
<!-- @RELATION: [BINDS_TO] ->[environmentContext] -->
<!-- @UX_STATE: Empty -> Show source intake for Superset link or dataset reference. -->
<!-- @UX_STATE: ResumeList -> Show existing resumable sessions with direct navigation CTA. -->
<!-- @UX_STATE: Submitting -> Disable controls and show startup feedback. -->
<!-- @UX_STATE: Error -> Inline error shown while keeping intake values editable. -->
<!-- @UX_RECOVERY: Users can correct invalid input in place and retry without losing environment selection. -->
<script>
import { goto } from "$app/navigation";
import { fromStore } from "svelte/store";
import SourceIntakePanel from "$lib/components/dataset-review/SourceIntakePanel.svelte";
import { t } from "$lib/i18n";
@@ -26,20 +29,117 @@
let isSubmitting = $state(false);
let submitError = $state("");
let intakeAcknowledgment = $state("");
let sessions = $state([]);
let sessionsLoading = $state(false);
let sessionsError = $state("");
let searchQuery = $state("");
let currentPage = $state(1);
let pageSize = $state(5);
let totalSessions = $state(0);
let deleteConfirmSessionId = $state(null);
const environments = $derived(environmentContextState.current?.environments || []);
const selectedEnvironmentId = $derived(
environmentContextState.current?.selectedEnvId || "",
);
const hasExistingSessions = $derived(sessions.length > 0);
const totalPages = $derived(totalSessions > 0 ? Math.ceil(totalSessions / pageSize) : 1);
const filteredSessions = $derived(() => {
if (!searchQuery.trim()) return sessions;
const query = searchQuery.toLowerCase();
return sessions.filter(s =>
(s.session_id?.toLowerCase().includes(query)) ||
(s.dataset_ref?.toLowerCase().includes(query)) ||
(s.environment_id?.toLowerCase().includes(query))
);
});
function buildSessionUrl(sessionId) {
return `/datasets/review/${encodeURIComponent(String(sessionId))}`;
}
function formatDateTime(value) {
if (!value) {
return $t.common?.not_available || "N/A";
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return String(value);
}
return parsed.toLocaleString();
}
function getSessionMetaValue(session, key, fallback = "") {
const value = session?.[key];
return value ? String(value) : fallback;
}
async function loadExistingSessions() {
sessionsLoading = true;
sessionsError = "";
try {
const params = new URLSearchParams();
params.append("page", String(currentPage));
params.append("page_size", String(pageSize));
if (searchQuery.trim()) {
params.append("search", searchQuery.trim());
}
const response = await api.fetchApi(`/dataset-orchestration/sessions?${params.toString()}`);
sessions = Array.isArray(response?.items) ? response.items : [];
totalSessions = response?.total || sessions.length;
} catch (error) {
sessions = [];
totalSessions = 0;
sessionsError =
error?.message ||
$t.dataset_review?.workspace_entry?.resume_load_failed ||
$t.common?.error;
} finally {
sessionsLoading = false;
}
}
async function deleteSession(sessionId, hardDelete = false) {
try {
await api.deleteApi(`/dataset-orchestration/sessions/${sessionId}?hard_delete=${hardDelete}`);
deleteConfirmSessionId = null;
// Reload current page, or go to previous if this was the last item
if (sessions.length === 1 && currentPage > 1) {
currentPage = currentPage - 1;
}
await loadExistingSessions();
} catch (error) {
sessionsError = error?.message || $t.common?.error;
}
}
function goToPage(page) {
if (page >= 1 && page <= totalPages) {
currentPage = page;
loadExistingSessions();
}
}
function handleSearch() {
currentPage = 1;
loadExistingSessions();
}
function truncateText(text, maxLength = 30) {
if (!text || text.length <= maxLength) return text || "";
return text.slice(0, maxLength) + "...";
}
async function bootstrap() {
isBootstrapping = true;
try {
await initializeEnvironmentContext();
await loadExistingSessions();
} finally {
isBootstrapping = false;
}
@@ -58,7 +158,7 @@
if (!summary?.session_id) {
throw new Error($t.dataset_review?.source?.submit_failed || "Failed to start review");
}
window.location.href = buildSessionUrl(summary.session_id);
await goto(buildSessionUrl(summary.session_id));
} catch (error) {
submitError =
error?.message ||
@@ -91,6 +191,11 @@
<span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700">
{$t.dataset_review?.workspace?.state_label}: {$t.dataset_review?.workspace?.state?.empty || "Empty"}
</span>
{#if hasExistingSessions}
<span class="rounded-full bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700">
{$t.dataset_review?.workspace_entry?.resume_available_badge}: {sessions.length}
</span>
{/if}
</div>
</div>
@@ -99,19 +204,303 @@
{$t.dataset_review?.workspace?.loading}
</div>
{:else}
<SourceIntakePanel
environments={environments}
selectedEnvironmentId={selectedEnvironmentId}
submitting={isSubmitting}
acknowledgment={intakeAcknowledgment}
onsubmit={handleSourceSubmit}
/>
<div class="grid gap-5 xl:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)]">
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-blue-700">
{$t.dataset_review?.workspace_entry?.resume_eyebrow}
</p>
<h2 class="text-xl font-semibold text-slate-900">
{$t.dataset_review?.workspace_entry?.resume_title}
</h2>
<p class="mt-1 max-w-2xl text-sm text-slate-600">
{$t.dataset_review?.workspace_entry?.resume_description}
</p>
</div>
{#if submitError}
<div class="rounded-2xl border border-red-200 bg-red-50 px-4 py-4 text-sm text-red-700">
{submitError}
<button
type="button"
class="inline-flex items-center justify-center rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50"
onclick={loadExistingSessions}
disabled={sessionsLoading}
>
{#if sessionsLoading}
<svg class="mr-2 h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{$t.dataset_review?.workspace_entry?.resume_loading}
{:else}
<svg class="mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
{$t.common?.refresh}
{/if}
</button>
</div>
<!-- Search Bar -->
<div class="mt-4 flex gap-2">
<div class="relative flex-1">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</div>
<input
type="text"
class="w-full rounded-lg border border-slate-300 bg-white py-2.5 pl-10 pr-4 text-sm text-slate-900 placeholder-slate-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
placeholder="Поиск по ID, датасету или окружению..."
bind:value={searchQuery}
onkeydown={(e) => e.key === 'Enter' && handleSearch()}
/>
</div>
<button
type="button"
class="inline-flex items-center justify-center rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-blue-700"
onclick={handleSearch}
>
Найти
</button>
{#if searchQuery}
<button
type="button"
class="inline-flex items-center justify-center rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
onclick={() => { searchQuery = ""; handleSearch(); }}
>
Сбросить
</button>
{/if}
</div>
{#if sessionsLoading}
<div class="mt-5 rounded-xl border border-slate-200 bg-slate-50 px-4 py-4 text-sm text-slate-600">
{$t.dataset_review?.workspace_entry?.resume_loading}
</div>
{:else if sessionsError}
<div class="mt-5 rounded-xl border border-red-200 bg-red-50 px-4 py-4 text-sm text-red-700">
<div>{sessionsError}</div>
<button
type="button"
class="mt-3 inline-flex items-center justify-center rounded-lg border border-red-300 bg-white px-3 py-1.5 text-sm font-medium text-red-700 transition hover:bg-red-50"
onclick={loadExistingSessions}
>
{$t.common?.retry}
</button>
</div>
{:else if !hasExistingSessions}
<div class="mt-5 rounded-xl border border-dashed border-slate-300 bg-slate-50 px-4 py-4 text-sm text-slate-600">
<div class="font-medium text-slate-900">
{$t.dataset_review?.workspace_entry?.resume_empty_title}
</div>
<p class="mt-1">
{$t.dataset_review?.workspace_entry?.resume_empty_body}
</p>
</div>
{:else}
<div class="mt-5 space-y-3">
{#each sessions as session}
{@const status = getSessionMetaValue(session, "status", "unknown")}
<article class="group relative rounded-xl border border-slate-200 bg-white p-4 shadow-sm transition hover:shadow-md">
<!-- Delete Confirmation Modal -->
{#if deleteConfirmSessionId === session.session_id}
<div class="absolute inset-0 z-10 flex items-center justify-center rounded-xl bg-white/95 backdrop-blur-sm">
<div class="text-center p-4">
<p class="text-sm font-medium text-slate-900 mb-3">Удалить сессию?</p>
<div class="flex gap-2 justify-center">
<button
type="button"
class="inline-flex items-center justify-center rounded-lg bg-red-600 px-3 py-1.5 text-xs font-medium text-white transition hover:bg-red-700"
onclick={() => deleteSession(session.session_id, false)}
>
В архив
</button>
<button
type="button"
class="inline-flex items-center justify-center rounded-lg border border-red-300 bg-white px-3 py-1.5 text-xs font-medium text-red-700 transition hover:bg-red-50"
onclick={() => deleteSession(session.session_id, true)}
>
Удалить навсегда
</button>
<button
type="button"
class="inline-flex items-center justify-center rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 transition hover:bg-slate-50"
onclick={() => deleteConfirmSessionId = null}
>
Отмена
</button>
</div>
</div>
</div>
{/if}
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-sm font-semibold text-slate-900" title={session.session_id}>
{truncateText(session.session_id, 20)}
</h3>
<span class="rounded-full px-2.5 py-1 text-xs font-medium"
class:bg-green-100={status === "active"}
class:text-green-700={status === "active"}
class:bg-yellow-100={status === "paused"}
class:text-yellow-700={status === "paused"}
class:bg-slate-100={status !== "active" && status !== "paused"}
class:text-slate-700={status !== "active" && status !== "paused"}
>
{status}
</span>
<span class="rounded-full bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-700">
{getSessionMetaValue(session, "readiness_state", $t.common?.unknown || "unknown")}
</span>
<span class="rounded-full bg-purple-50 px-2.5 py-1 text-xs font-medium text-purple-700">
{getSessionMetaValue(session, "current_phase", $t.common?.unknown || "unknown")}
</span>
</div>
<div class="mt-3 grid gap-3 sm:grid-cols-2">
<div class="rounded-lg bg-slate-50 px-3 py-2">
<div class="text-xs uppercase tracking-wide text-slate-500">
{$t.dataset_review?.workspace_entry?.dataset_ref_label}
</div>
<div class="mt-1 text-sm font-medium text-slate-900" title={session.dataset_ref}>
{truncateText(session.dataset_ref, 35) || $t.common?.not_available || "N/A"}
</div>
</div>
<div class="rounded-lg bg-slate-50 px-3 py-2">
<div class="text-xs uppercase tracking-wide text-slate-500">
Окружение
</div>
<div class="mt-1 text-sm font-medium text-slate-900">
{session.environment_id || $t.common?.not_available || "N/A"}
</div>
</div>
<div class="rounded-lg bg-slate-50 px-3 py-2">
<div class="text-xs uppercase tracking-wide text-slate-500">
Обновлено
</div>
<div class="mt-1 text-sm font-medium text-slate-900">
{formatDateTime(session.updated_at)}
</div>
</div>
<div class="rounded-lg bg-slate-50 px-3 py-2">
<div class="text-xs uppercase tracking-wide text-slate-500">
Активность
</div>
<div class="mt-1 text-sm font-medium text-slate-900">
{formatDateTime(session.last_activity_at)}
</div>
</div>
</div>
</div>
<div class="flex flex-col gap-2 lg:pl-4">
<a
class="inline-flex items-center justify-center rounded-xl bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-700"
href={buildSessionUrl(session.session_id)}
>
<svg class="mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
{$t.dataset_review?.workspace_entry?.resume_action}
</a>
<button
type="button"
class="inline-flex items-center justify-center rounded-xl border border-red-200 bg-white px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-red-50"
onclick={() => deleteConfirmSessionId = session.session_id}
>
<svg class="mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
Удалить
</button>
</div>
</div>
</article>
{/each}
</div>
<!-- Pagination -->
{#if totalPages > 1}
<div class="mt-6 flex items-center justify-between border-t border-slate-200 pt-4">
<div class="text-sm text-slate-600">
Страница {currentPage} из {totalPages} ({totalSessions} всего)
</div>
<div class="flex gap-2">
<button
type="button"
class="inline-flex items-center justify-center rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed"
onclick={() => goToPage(currentPage - 1)}
disabled={currentPage <= 1}
>
<svg class="mr-1 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
Назад
</button>
<div class="flex gap-1">
{#each Array.from({length: Math.min(5, totalPages)}, (_, i) => {
let start = Math.max(1, currentPage - 2);
let end = Math.min(totalPages, start + 4);
if (end - start < 4) start = Math.max(1, end - 4);
return start + i;
}).filter(p => p <= totalPages) as pageNum}
<button
type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-lg text-sm font-medium transition"
class:bg-blue-600={pageNum === currentPage}
class:text-white={pageNum === currentPage}
class:bg-white={pageNum !== currentPage}
class:text-slate-700={pageNum !== currentPage}
class:border={pageNum !== currentPage}
class:border-slate-300={pageNum !== currentPage}
class:hover:bg-slate-50={pageNum !== currentPage}
onclick={() => goToPage(pageNum)}
>
{pageNum}
</button>
{/each}
</div>
<button
type="button"
class="inline-flex items-center justify-center rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed"
onclick={() => goToPage(currentPage + 1)}
disabled={currentPage >= totalPages}
>
Вперед
<svg class="ml-1 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</button>
</div>
</div>
{/if}
{/if}
</section>
<div class="space-y-5">
<SourceIntakePanel
environments={environments}
selectedEnvironmentId={selectedEnvironmentId}
submitting={isSubmitting}
acknowledgment={intakeAcknowledgment}
onsubmit={handleSourceSubmit}
/>
{#if submitError}
<div class="rounded-2xl border border-red-200 bg-red-50 px-4 py-4 text-sm text-red-700">
{submitError}
</div>
{/if}
</div>
{/if}
</div>
{/if}
</div>

View File

@@ -533,8 +533,8 @@
bootstrapWorkspace();
</script>
<div class="mx-auto w-full max-w-7xl space-y-5 px-4 py-6">
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div class="mx-auto w-full max-w-[96rem] space-y-6 px-4 py-6 xl:px-6">
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-blue-700">
{$t.dataset_review?.workspace?.eyebrow}
@@ -647,7 +647,10 @@
</div>
{/if}
{:else}
<div class="grid gap-5 xl:grid-cols-[minmax(18rem,0.8fr)_minmax(0,1.4fr)_minmax(18rem,0.9fr)]">
<div
data-testid="workspace-detail-grid"
class="grid items-start gap-6 xl:grid-cols-[minmax(16rem,0.72fr)_minmax(0,1.7fr)_minmax(22rem,1.08fr)]"
>
<div class="space-y-5">
<section
id="intake"
@@ -661,7 +664,10 @@
<div class="text-xs uppercase tracking-wide text-slate-500">
{$t.dataset_review?.workspace?.source_label}
</div>
<div class="mt-1 text-sm font-medium text-slate-900">
<div
data-testid="workspace-source-value"
class="mt-1 max-w-full text-sm font-medium leading-6 text-slate-900 break-words [overflow-wrap:anywhere]"
>
{session.source_input || session.dataset_ref}
</div>
</div>

View File

@@ -487,7 +487,7 @@ describe("DatasetReviewWorkspace UX Contract", () => {
await waitFor(() => {
expect(screen.getByText("Start dataset review")).toBeDefined();
});
expect(screen.getByText("Workspace state: Empty")).toBeDefined();
expect(screen.getByText("Workspace state: empty")).toBeDefined();
expect(screen.getByText("Paste link or provide dataset reference.")).toBeDefined();
});
@@ -507,7 +507,7 @@ describe("DatasetReviewWorkspace UX Contract", () => {
expect(screen.getByText("Business summary")).toBeDefined();
});
expect(screen.getByText("Workspace state: Review")).toBeDefined();
expect(screen.getByText("Workspace state: review")).toBeDefined();
expect(screen.getByText("Readiness: Partially ready")).toBeDefined();
expect(screen.getAllByText("Sales Dataset").length).toBeGreaterThan(0);
expect(screen.getAllByText("Recovered filters").length).toBeGreaterThan(0);
@@ -605,7 +605,7 @@ describe("DatasetReviewWorkspace UX Contract", () => {
});
await waitFor(() => {
expect(screen.getByText("Workspace state: Importing")).toBeDefined();
expect(screen.getByText("Workspace state: importing")).toBeDefined();
});
expect(screen.getByText("Import progress")).toBeDefined();
expect(screen.getByText("Recovering dataset context progressively")).toBeDefined();
@@ -619,7 +619,7 @@ describe("DatasetReviewWorkspace UX Contract", () => {
});
await waitFor(() => {
expect(screen.getByText("Workspace state: Review")).toBeDefined();
expect(screen.getByText("Workspace state: review")).toBeDefined();
});
expect(screen.getByText("Readiness: Partially ready")).toBeDefined();
expect(screen.getAllByText("Review documentation").length).toBeGreaterThan(0);
@@ -656,11 +656,76 @@ describe("DatasetReviewWorkspace UX Contract", () => {
expect(screen.getAllByText("documentation • json • artifacts/doc.json").length).toBeGreaterThan(0);
});
const exportFeedbackText = screen.getAllByText(
"documentation • json • artifacts/doc.json",
)[0];
expect(exportFeedbackText.textContent).toContain("artifacts/doc.json");
await fireEvent.click(documentationMarkdownButton);
await waitFor(() => {
expect(screen.getAllByText("Export unavailable").length).toBeGreaterThan(0);
});
});
it("detail_layout_and_long_diagnostics_render_without_unbounded_text", async () => {
routeState.id = "session-1";
api.fetchApi
.mockResolvedValueOnce(
createSessionDetail({
source_input:
"https://superset.local/dashboard/10?native_filters=" +
"x".repeat(180),
findings: [
{
finding_id: "finding-1",
title: "Unresolved semantic reference chain",
code: "SEM-LONG-1",
area: "dataset_profile",
severity: "blocking",
message:
"Missing semantic resolution for " +
"very.long.reference.".repeat(18),
resolution_state: "open",
caused_by_ref: "semantic.layer.".repeat(14),
resolution_note:
"Raw backend trace: " + "resolver::unbound_column::".repeat(18),
},
],
previews: [
{
preview_id: "preview-1",
preview_status: "failed",
compiled_by: "superset",
error_code: "PREVIEW_COMPILE_FAILED",
error_details:
"Compilation traceback: " + "missing_template_value::".repeat(24),
preview_fingerprint: "fingerprint-" + "abc123".repeat(12),
},
],
}),
)
.mockResolvedValueOnce(createClarificationState());
render(DatasetReviewWorkspace);
await waitFor(() => {
expect(screen.getByTestId("workspace-detail-grid")).toBeDefined();
});
const layoutGrid = screen.getByTestId("workspace-detail-grid");
expect(layoutGrid.className).toContain("xl:grid-cols-[minmax(16rem,0.72fr)_minmax(0,1.7fr)_minmax(22rem,1.08fr)]");
const sourceValue = screen.getByTestId("workspace-source-value");
expect(sourceValue.className).toContain("break-words");
expect(screen.getAllByText(/PREVIEW_COMPILE_FAILED|missing_template_value/).length).toBeGreaterThan(0);
const blockerDetails = screen.getAllByTestId("launch-blocker-detail");
expect(
blockerDetails.some((item) => item.textContent?.includes("missing_template_value")),
).toBe(true);
expect(blockerDetails[0].className).toContain("whitespace-pre-wrap");
});
});
// [/DEF:DatasetReviewWorkspaceUxTests:Module]

View File

@@ -0,0 +1,288 @@
/**
* @vitest-environment jsdom
*/
// @ts-nocheck
// [DEF:DatasetReviewEntryUxTests:Module]
// @COMPLEXITY: 3
// @SEMANTICS: dataset-review, workspace-entry, resume, source-intake, ux-tests
// @PURPOSE: Verify dataset review entry route exposes resumable sessions alongside the new session intake flow.
// @LAYER: UI
// @RELATION: [VERIFIES] ->[DatasetReviewWorkspaceEntry:Page]
// @UX_STATE: Loading -> workspace loader is shown before bootstrap completes.
// @UX_STATE: ResumeList -> existing sessions render with summary fields and resume links.
// @UX_STATE: ResumeEmpty -> empty-state copy is shown when no sessions are returned.
// @UX_STATE: ResumeError -> inline error and retry action remain visible without removing new session flow.
// @TEST_SCENARIO: renders_resume_sessions_and_new_session_intake
// @TEST_SCENARIO: renders_empty_resume_state_when_no_sessions_exist
// @TEST_SCENARIO: renders_resume_error_and_retry
// @TEST_SCENARIO: submits_new_session_without_regression
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/svelte";
import DatasetReviewEntryPage from "../+page.svelte";
import { api } from "$lib/api.js";
const mockedGoto = vi.mocked(await import("$app/navigation")).goto;
function createSessionSummary(overrides = {}) {
return {
session_id: "session-1",
user_id: "user-1",
environment_id: "env-1",
source_kind: "superset_link",
source_input: "https://superset.local/dashboard/10",
dataset_ref: "public.sales",
dataset_id: 101,
readiness_state: "partially_ready",
recommended_action: "resume_session",
status: "paused",
current_phase: "review",
created_at: "2026-03-18T06:00:00.000Z",
updated_at: "2026-03-18T06:10:00.000Z",
last_activity_at: "2026-03-18T06:15:00.000Z",
...overrides,
};
}
vi.mock("$app/navigation", () => ({
goto: vi.fn(),
}));
vi.mock("$lib/i18n", () => ({
t: {
subscribe: (fn) => {
fn({
common: {
error: "Common error",
refresh: "Refresh",
retry: "Retry",
choose_environment: "Choose environment",
not_available: "N/A",
},
dataset_review: {
source: {
eyebrow: "Source intake",
title: "Start dataset review",
description: "Paste link or provide dataset reference.",
state_idle: "Idle",
state_validating: "Validating",
state_rejected: "Rejected",
environment_label: "Environment",
environment_required: "Environment is required",
superset_link_tab: "Superset link",
superset_link_tab_hint: "Paste dashboard or explore URL",
dataset_selection_tab: "Dataset selection",
dataset_selection_tab_hint: "Enter dataset ref",
superset_link_label: "Superset link",
dataset_selection_label: "Dataset reference",
superset_link_placeholder: "https://superset.local/dashboard/10",
dataset_selection_placeholder: "public.sales",
superset_link_hint: "Paste a full Superset URL",
dataset_selection_hint: "Provide schema.dataset reference",
recognized_link_hint: "Recognized Superset link",
superset_link_required: "Superset link is required",
dataset_selection_required: "Dataset reference is required",
superset_link_invalid: "Superset link must start with http",
submit_failed: "Submit failed",
superset_link_recovery_note: "You can fix the link inline",
dataset_selection_recovery_note: "You can fix the dataset inline",
submitting: "Submitting",
submit_superset_link: "Start from link",
submit_dataset_selection: "Start from dataset",
dataset_selection_acknowledged: "Dataset selection acknowledged",
},
workspace_entry: {
resume_eyebrow: "Resume session",
resume_title: "Continue an existing review session",
resume_description:
"If a dataset review was already started, open the relevant session and continue from its latest saved state.",
resume_available_badge: "Sessions available",
resume_loading: "Loading existing sessions...",
resume_load_failed: "Failed to load existing review sessions.",
resume_empty_title: "No saved sessions yet",
resume_empty_body:
"Start a new review session from the source intake panel to make it available here for reopening later.",
resume_action: "Resume",
session_id_label: "Session ID",
dataset_ref_label: "Dataset reference",
environment_id_label: "Environment",
status_label: "Status",
readiness_label: "Readiness",
phase_label: "Phase",
updated_at_label: "Updated",
last_activity_at_label: "Last activity",
},
workspace: {
eyebrow: "Dataset review",
title: "Dataset review workspace",
description: "Review imported dataset context.",
state_label: "Workspace state",
loading: "Loading workspace",
state: {
empty: "Empty",
},
},
},
});
return () => {};
},
},
}));
vi.mock("$lib/api.js", () => ({
api: {
fetchApi: vi.fn(),
postApi: vi.fn(),
requestApi: vi.fn(),
},
}));
vi.mock("$lib/stores/environmentContext.js", () => ({
environmentContextStore: {
subscribe: (run) => {
run({
environments: [{ id: "env-1", name: "DEV" }],
selectedEnvId: "env-1",
});
return () => {};
},
},
initializeEnvironmentContext: vi.fn().mockResolvedValue(undefined),
}));
describe("DatasetReviewWorkspaceEntry UX Contract", () => {
beforeEach(() => {
vi.clearAllMocks();
api.fetchApi.mockReset();
api.postApi.mockReset();
mockedGoto.mockReset();
});
it("renders_resume_sessions_and_new_session_intake", async () => {
api.fetchApi.mockResolvedValue({
items: [
createSessionSummary(),
createSessionSummary({
session_id: "session-2",
dataset_ref: "analytics.daily_margin",
environment_id: "env-2",
status: "active",
readiness_state: "review_ready",
current_phase: "semantic_review",
}),
],
total: 2,
page: 1,
page_size: 20,
has_next: false,
});
render(DatasetReviewEntryPage);
await waitFor(() => {
expect(api.fetchApi).toHaveBeenCalledWith("/dataset-orchestration/sessions");
});
expect(screen.getByText("Continue an existing review session")).toBeDefined();
expect(screen.getByText("Start dataset review")).toBeDefined();
expect(screen.getByText("Sessions available: 2")).toBeDefined();
expect(screen.getByText("Session ID: session-1")).toBeDefined();
expect(screen.getByText("public.sales")).toBeDefined();
expect(screen.getByText("analytics.daily_margin")).toBeDefined();
const resumeLinks = screen.getAllByRole("link", { name: "Resume" });
expect(resumeLinks).toHaveLength(2);
expect(resumeLinks[0].getAttribute("href")).toBe("/datasets/review/session-1");
});
it("renders_empty_resume_state_when_no_sessions_exist", async () => {
api.fetchApi.mockResolvedValue({
items: [],
total: 0,
page: 1,
page_size: 20,
has_next: false,
});
render(DatasetReviewEntryPage);
await waitFor(() => {
expect(screen.getByText("No saved sessions yet")).toBeDefined();
});
expect(
screen.getByText(
"Start a new review session from the source intake panel to make it available here for reopening later.",
),
).toBeDefined();
expect(screen.getByText("Start dataset review")).toBeDefined();
});
it("renders_resume_error_and_retry", async () => {
api.fetchApi.mockRejectedValueOnce(new Error("Session list failed"));
api.fetchApi.mockResolvedValueOnce({
items: [createSessionSummary()],
total: 1,
page: 1,
page_size: 20,
has_next: false,
});
render(DatasetReviewEntryPage);
await waitFor(() => {
expect(screen.getByText("Session list failed")).toBeDefined();
});
await fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => {
expect(api.fetchApi).toHaveBeenCalledTimes(2);
});
await waitFor(() => {
expect(screen.getByText("Session ID: session-1")).toBeDefined();
});
expect(screen.getByText("Start dataset review")).toBeDefined();
});
it("submits_new_session_without_regression", async () => {
api.fetchApi.mockResolvedValue({
items: [createSessionSummary()],
total: 1,
page: 1,
page_size: 20,
has_next: false,
});
api.postApi.mockResolvedValue({ session_id: "created-session" });
render(DatasetReviewEntryPage);
await waitFor(() => {
expect(screen.getByText("Start dataset review")).toBeDefined();
});
const environmentSelect = screen.getByRole("combobox");
const sourceInput = screen.getByPlaceholderText("https://superset.local/dashboard/10");
const submitButton = screen.getByRole("button", { name: "Start from link" });
await fireEvent.change(environmentSelect, { target: { value: "env-1" } });
await fireEvent.input(sourceInput, {
target: { value: "https://superset.local/dashboard/10" },
});
await fireEvent.click(submitButton);
await waitFor(() => {
expect(api.postApi).toHaveBeenCalledWith("/dataset-orchestration/sessions", {
source_kind: "superset_link",
source_input: "https://superset.local/dashboard/10",
environment_id: "env-1",
});
});
expect(mockedGoto).toHaveBeenCalledWith("/datasets/review/created-session");
});
});
// [/DEF:DatasetReviewEntryUxTests:Module]

View File

@@ -0,0 +1,282 @@
/**
* @vitest-environment jsdom
*/
// @ts-nocheck
// [DEF:DatasetReviewEntryUxTests:Module]
// @COMPLEXITY: 3
// @SEMANTICS: dataset-review, workspace-entry, resume, source-intake, ux-tests
// @PURPOSE: Verify dataset review entry route exposes resumable sessions alongside the new session intake flow.
// @LAYER: UI
// @RELATION: [VERIFIES] ->[DatasetReviewWorkspaceEntry:Page]
// @UX_STATE: Loading -> workspace loader is shown before bootstrap completes.
// @UX_STATE: ResumeList -> existing sessions render with summary fields and resume links.
// @UX_STATE: ResumeEmpty -> empty-state copy is shown when no sessions are returned.
// @UX_STATE: ResumeError -> inline error and retry action remain visible without removing new session flow.
// @TEST_SCENARIO: renders_resume_sessions_and_new_session_intake
// @TEST_SCENARIO: renders_empty_resume_state_when_no_sessions_exist
// @TEST_SCENARIO: renders_resume_error_and_retry
// @TEST_SCENARIO: submits_new_session_without_regression
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/svelte";
import DatasetReviewEntryPage from "../+page.svelte";
import { api } from "$lib/api.js";
function createSessionSummary(overrides = {}) {
return {
session_id: "session-1",
user_id: "user-1",
environment_id: "env-1",
source_kind: "superset_link",
source_input: "https://superset.local/dashboard/10",
dataset_ref: "public.sales",
dataset_id: 101,
readiness_state: "partially_ready",
recommended_action: "resume_session",
status: "paused",
current_phase: "review",
created_at: "2026-03-18T06:00:00.000Z",
updated_at: "2026-03-18T06:10:00.000Z",
last_activity_at: "2026-03-18T06:15:00.000Z",
...overrides,
};
}
vi.mock("$lib/i18n", () => ({
t: {
subscribe: (fn) => {
fn({
common: {
error: "Common error",
refresh: "Refresh",
retry: "Retry",
choose_environment: "Choose environment",
not_available: "N/A",
},
dataset_review: {
source: {
eyebrow: "Source intake",
title: "Start dataset review",
description: "Paste link or provide dataset reference.",
state_idle: "Idle",
state_validating: "Validating",
state_rejected: "Rejected",
environment_label: "Environment",
environment_required: "Environment is required",
superset_link_tab: "Superset link",
superset_link_tab_hint: "Paste dashboard or explore URL",
dataset_selection_tab: "Dataset selection",
dataset_selection_tab_hint: "Enter dataset ref",
superset_link_label: "Superset link",
dataset_selection_label: "Dataset reference",
superset_link_placeholder: "https://superset.local/dashboard/10",
dataset_selection_placeholder: "public.sales",
superset_link_hint: "Paste a full Superset URL",
dataset_selection_hint: "Provide schema.dataset reference",
recognized_link_hint: "Recognized Superset link",
superset_link_required: "Superset link is required",
dataset_selection_required: "Dataset reference is required",
superset_link_invalid: "Superset link must start with http",
submit_failed: "Submit failed",
superset_link_recovery_note: "You can fix the link inline",
dataset_selection_recovery_note: "You can fix the dataset inline",
submitting: "Submitting",
submit_superset_link: "Start from link",
submit_dataset_selection: "Start from dataset",
dataset_selection_acknowledged: "Dataset selection acknowledged",
},
workspace_entry: {
resume_eyebrow: "Resume session",
resume_title: "Continue an existing review session",
resume_description:
"If a dataset review was already started, open the relevant session and continue from its latest saved state.",
resume_available_badge: "Sessions available",
resume_loading: "Loading existing sessions...",
resume_load_failed: "Failed to load existing review sessions.",
resume_empty_title: "No saved sessions yet",
resume_empty_body:
"Start a new review session from the source intake panel to make it available here for reopening later.",
resume_action: "Resume",
session_id_label: "Session ID",
dataset_ref_label: "Dataset reference",
environment_id_label: "Environment",
status_label: "Status",
readiness_label: "Readiness",
phase_label: "Phase",
updated_at_label: "Updated",
last_activity_at_label: "Last activity",
},
workspace: {
eyebrow: "Dataset review",
title: "Dataset review workspace",
description: "Review imported dataset context.",
state_label: "Workspace state",
loading: "Loading workspace",
state: {
empty: "Empty",
},
},
},
});
return () => {};
},
},
}));
vi.mock("$lib/api.js", () => ({
api: {
fetchApi: vi.fn(),
postApi: vi.fn(),
requestApi: vi.fn(),
},
}));
vi.mock("$lib/stores/environmentContext.js", () => ({
environmentContextStore: {
subscribe: (run) => {
run({
environments: [{ id: "env-1", name: "DEV" }],
selectedEnvId: "env-1",
});
return () => {};
},
},
initializeEnvironmentContext: vi.fn().mockResolvedValue(undefined),
}));
describe("DatasetReviewWorkspaceEntry UX Contract", () => {
beforeEach(() => {
vi.clearAllMocks();
api.fetchApi.mockReset();
api.postApi.mockReset();
window.location.href = "";
});
it("renders_resume_sessions_and_new_session_intake", async () => {
api.fetchApi.mockResolvedValue({
items: [
createSessionSummary(),
createSessionSummary({
session_id: "session-2",
dataset_ref: "analytics.daily_margin",
environment_id: "env-2",
status: "active",
readiness_state: "review_ready",
current_phase: "semantic_review",
}),
],
total: 2,
page: 1,
page_size: 20,
has_next: false,
});
render(DatasetReviewEntryPage);
await waitFor(() => {
expect(api.fetchApi).toHaveBeenCalledWith("/dataset-orchestration/sessions");
});
expect(screen.getByText("Continue an existing review session")).toBeDefined();
expect(screen.getByText("Start dataset review")).toBeDefined();
expect(screen.getByText("Sessions available: 2")).toBeDefined();
expect(screen.getByText("Session ID: session-1")).toBeDefined();
expect(screen.getByText("public.sales")).toBeDefined();
expect(screen.getByText("analytics.daily_margin")).toBeDefined();
const resumeLinks = screen.getAllByRole("link", { name: "Resume" });
expect(resumeLinks).toHaveLength(2);
expect(resumeLinks[0].getAttribute("href")).toBe("/datasets/review/session-1");
});
it("renders_empty_resume_state_when_no_sessions_exist", async () => {
api.fetchApi.mockResolvedValue({
items: [],
total: 0,
page: 1,
page_size: 20,
has_next: false,
});
render(DatasetReviewEntryPage);
await waitFor(() => {
expect(screen.getByText("No saved sessions yet")).toBeDefined();
});
expect(
screen.getByText(
"Start a new review session from the source intake panel to make it available here for reopening later.",
),
).toBeDefined();
expect(screen.getByText("Start dataset review")).toBeDefined();
});
it("renders_resume_error_and_retry", async () => {
api.fetchApi.mockRejectedValueOnce(new Error("Session list failed"));
api.fetchApi.mockResolvedValueOnce({
items: [createSessionSummary()],
total: 1,
page: 1,
page_size: 20,
has_next: false,
});
render(DatasetReviewEntryPage);
await waitFor(() => {
expect(screen.getByText("Session list failed")).toBeDefined();
});
await fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => {
expect(api.fetchApi).toHaveBeenCalledTimes(2);
});
await waitFor(() => {
expect(screen.getByText("Session ID: session-1")).toBeDefined();
});
expect(screen.getByText("Start dataset review")).toBeDefined();
});
it("submits_new_session_without_regression", async () => {
api.fetchApi.mockResolvedValue({
items: [createSessionSummary()],
total: 1,
page: 1,
page_size: 20,
has_next: false,
});
api.postApi.mockResolvedValue({ session_id: "created-session" });
render(DatasetReviewEntryPage);
await waitFor(() => {
expect(screen.getByText("Start dataset review")).toBeDefined();
});
const environmentSelect = screen.getByRole("combobox");
const sourceInput = screen.getByPlaceholderText("https://superset.local/dashboard/10");
const submitButton = screen.getByRole("button", { name: "Start from link" });
await fireEvent.change(environmentSelect, { target: { value: "env-1" } });
await fireEvent.input(sourceInput, {
target: { value: "https://superset.local/dashboard/10" },
});
await fireEvent.click(submitButton);
await waitFor(() => {
expect(api.postApi).toHaveBeenCalledWith("/dataset-orchestration/sessions", {
source_kind: "superset_link",
source_input: "https://superset.local/dashboard/10",
environment_id: "env-1",
});
});
expect(window.location.href).toBe("/datasets/review/created-session");
});
});
// [/DEF:DatasetReviewEntryUxTests:Module]