feat: add verified manual pipeline migration for plugin runners

This commit is contained in:
RockChinQ
2026-09-16 18:13:11 +00:00
parent 273b1ea3cf
commit 8c119bc4b6
97 changed files with 16662 additions and 123 deletions
+14 -2
View File
@@ -32,7 +32,13 @@ import AgentFormComponent, {
RunnerStatus,
} from './components/AgentFormComponent';
export default function AgentDetailContent({ id }: { id: string }) {
export default function AgentDetailContent({
id,
pipelineRevision,
}: {
id: string;
pipelineRevision?: string;
}) {
const isCreateMode = id === 'new';
const navigate = useNavigate();
const { t } = useTranslation();
@@ -130,7 +136,13 @@ export default function AgentDetailContent({ id }: { id: string }) {
if (loading || !agent) return <EntityLoadState />;
if (agent.kind === 'pipeline') {
return <PipelineDetailContent id={id} routeBase="/home/agents" />;
return (
<PipelineDetailContent
key={pipelineRevision}
id={id}
routeBase="/home/agents"
/>
);
}
async function saveBasicInfo(values: EntityBasicInfoValues) {
+34 -6
View File
@@ -1,19 +1,47 @@
import { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useCurrentWorkspace } from '@/app/infra/http';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import PipelineMigration, {
migrationWorkspaceKey,
} from '@/app/home/pipelines/PipelineMigration';
import AgentDetailContent from './AgentDetailContent';
export default function AgentsPage() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const detailId = searchParams.get('id');
if (detailId) {
return <AgentDetailContent key={detailId} id={detailId} />;
}
const workspace = useCurrentWorkspace();
const { refreshPipelines } = useSidebarData();
const [revision, setRevision] = useState(0);
const scopeKey = migrationWorkspaceKey(workspace);
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
<p>{t('agents.selectFromSidebar')}</p>
<div className="flex h-full min-h-0 flex-col">
{workspace && workspace.permissions.includes('resource.view') && (
<PipelineMigration
key={scopeKey}
workspace={workspace}
onComplete={() => {
void refreshPipelines();
setRevision((current) => current + 1);
}}
/>
)}
<div className="flex-1 min-h-0">
{detailId ? (
<AgentDetailContent
key={detailId}
id={detailId}
pipelineRevision={`${scopeKey}:${revision}`}
/>
) : (
<div className="flex h-full items-center justify-center text-muted-foreground">
<p>{t('agents.selectFromSidebar')}</p>
</div>
)}
</div>
</div>
);
}
@@ -6,6 +6,7 @@ import {
import { useForm } from 'react-hook-form';
import type { ControllerRenderProps } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { isJsonValue, isPromptValue } from './StructuredFieldValue';
import { z } from 'zod';
import {
Form,
@@ -133,13 +134,10 @@ function getValueSchema(spec: DynamicFormValueSpec) {
fallbacks: z.array(z.string()),
reasoning: z.record(z.string()),
});
case DynamicFormItemType.JSON:
return z.custom(isJsonValue);
case DynamicFormItemType.PROMPT_EDITOR:
return z.array(
z.object({
content: z.string(),
role: z.string(),
}),
);
return z.custom(isPromptValue);
default:
return z.string();
}
@@ -656,6 +654,7 @@ export default function DynamicFormComponent({
};
const fieldKey = config.id || config.name || `field-${index}`;
let isHiddenByCondition = false;
if (config.show_if) {
const dependValue = resolveShowIfValue(
config.show_if.field,
@@ -668,23 +667,32 @@ export default function DynamicFormComponent({
config.show_if.operator === 'eq' &&
dependValue !== config.show_if.value
) {
return null;
isHiddenByCondition = true;
}
if (
config.show_if.operator === 'neq' &&
dependValue === config.show_if.value
) {
return null;
isHiddenByCondition = true;
}
if (
config.show_if.operator === 'in' &&
Array.isArray(config.show_if.value) &&
!config.show_if.value.includes(dependValue)
) {
return null;
isHiddenByCondition = true;
}
}
// Keep structured drafts mounted across conditional hiding so invalid
// JSON cannot disappear from validation when Advanced Settings closes.
if (
isHiddenByCondition &&
normalizedConfig.type !== DynamicFormItemType.JSON &&
normalizedConfig.type !== DynamicFormItemType.PROMPT_EDITOR
)
return null;
// Keep locked fields visible and resolve only the applicable reason.
const { isDisabledByCondition, disabledTooltip: tooltip } =
resolveDisabledState(
@@ -957,7 +965,10 @@ export default function DynamicFormComponent({
setFormValue,
});
return (
<FormItem className="min-w-0">
<FormItem
className={cn('min-w-0', isHiddenByCondition && 'hidden')}
hidden={isHiddenByCondition}
>
<FormLabel className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 break-words">
{extractI18nObject(config.label)}{' '}
@@ -3,6 +3,8 @@ import {
IDynamicFormItemSchema,
IFileConfig,
} from '@/app/infra/entities/form/dynamic';
import StructuredFieldEditor from './StructuredFieldEditor';
import { isSimplePrompt } from './StructuredFieldValue';
import { Input } from '@/components/ui/input';
import {
Select,
@@ -490,14 +492,7 @@ export default function DynamicFormItemComponent({
);
case DynamicFormItemType.JSON:
return (
<Textarea
{...field}
value={field.value ?? ''}
className="min-h-[200px] font-mono text-sm"
placeholder='{"key": "value"}'
/>
);
return <StructuredFieldEditor field={field} />;
case DynamicFormItemType.BOOLEAN:
return (
@@ -1261,11 +1256,11 @@ export default function DynamicFormItemComponent({
const updateModelReasoning = (
modelUuid: string,
level: ReasoningLevel,
level: ReasoningLevel | undefined,
) => {
if (!modelUuid) return;
const updated = { ...modelValue.reasoning };
if (level === 'provider_default') {
if (level === undefined) {
delete updated[modelUuid];
} else {
updated[modelUuid] = level;
@@ -1311,6 +1306,8 @@ export default function DynamicFormItemComponent({
<ReasoningLevelPicker
value={currentLevel}
levels={levels}
inherited={!Object.hasOwn(modelValue.reasoning, modelUuid)}
onInherit={() => updateModelReasoning(modelUuid, undefined)}
onChange={(level) => updateModelReasoning(modelUuid, level)}
/>
);
@@ -1872,6 +1869,9 @@ export default function DynamicFormItemComponent({
);
case DynamicFormItemType.PROMPT_EDITOR: {
if (!isSimplePrompt(field.value)) {
return <StructuredFieldEditor field={field} prompt />;
}
// Guard: field.value may be undefined when the form resets or
// initialValues haven't propagated yet. Fall back to a default
// single system-prompt entry to prevent the .map() crash.
@@ -56,7 +56,6 @@ export function parseDynamicFormItemType(value: string): DynamicFormItemType {
[DynamicFormItemType.SELECT_KNOWLEDGE_BASES]:
DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR,
[DynamicFormItemType.NUMBER]: DynamicFormItemType.FLOAT,
[DynamicFormItemType.JSON]: DynamicFormItemType.TEXT,
};
if (value in typeMap) {
return typeMap[value];
@@ -80,6 +80,7 @@ export function normalizeDynamicFormFieldValue(
}
const reasoningLevels = new Set([
'provider_default',
'disabled',
'enabled',
'minimal',
@@ -0,0 +1,73 @@
import { useEffect, useRef, useState } from 'react';
import type { ControllerRenderProps } from 'react-hook-form';
import { useFormContext } from 'react-hook-form';
import { Textarea } from '@/components/ui/textarea';
import { useTranslation } from 'react-i18next';
import { parseStructuredDraft } from './StructuredFieldValue';
/** Draft text stays local: only explicitly parsed edits enter persisted state. */
export default function StructuredFieldEditor({
field,
prompt = false,
}: {
field: ControllerRenderProps<any, any>;
prompt?: boolean;
}) {
const { t } = useTranslation();
const form = useFormContext();
const [draft, setDraft] = useState(
() => JSON.stringify(field.value, null, 2) ?? '',
);
const [invalid, setInvalid] = useState(false);
const emitted = useRef(field.value);
const input = useRef<HTMLTextAreaElement | null>(null);
const error = t('agents.debugData.invalidField', {
field: `${field.name} (JSON)`,
});
useEffect(() => {
if (JSON.stringify(field.value) !== JSON.stringify(emitted.current)) {
emitted.current = field.value;
setDraft(JSON.stringify(field.value, null, 2) ?? '');
setInvalid(false);
input.current?.setCustomValidity('');
}
}, [field.value]);
return (
<div className="space-y-2">
<Textarea
ref={(element) => {
input.current = element;
field.ref(element);
}}
name={field.name}
aria-label={`${field.name} (JSON)`}
data-testid={`structured-editor-${field.name}`}
aria-invalid={invalid}
className="min-h-48 font-mono"
value={draft}
onBlur={field.onBlur}
onChange={(event) => {
const next = event.target.value;
setDraft(next);
try {
const value = parseStructuredDraft(next, prompt);
event.target.setCustomValidity('');
setInvalid(false);
form.clearErrors(field.name);
emitted.current = value;
field.onChange(value);
} catch {
event.target.setCustomValidity(error);
setInvalid(true);
form.setError(field.name, { type: 'json', message: error });
}
}}
/>
{invalid && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
</div>
);
}
@@ -0,0 +1,67 @@
export type JsonValue =
| null
| boolean
| number
| string
| JsonValue[]
| { [key: string]: JsonValue };
export type PromptMessage = {
role: string;
content?: string | null | JsonValue[];
[key: string]: JsonValue | undefined;
};
export function isJsonValue(value: unknown): value is JsonValue {
if (value === null || typeof value === 'string' || typeof value === 'boolean')
return true;
if (typeof value === 'number') return Number.isFinite(value);
if (Array.isArray(value)) return value.every(isJsonValue);
return (
typeof value === 'object' &&
value !== null &&
Object.values(value).every(isJsonValue)
);
}
export function isPromptValue(value: unknown): value is PromptMessage[] {
return (
Array.isArray(value) &&
value.every(
(item) =>
item !== null &&
typeof item === 'object' &&
!Array.isArray(item) &&
typeof item.role === 'string' &&
(!Object.prototype.hasOwnProperty.call(item, 'content') ||
typeof item.content === 'string' ||
item.content === null ||
Array.isArray(item.content)) &&
isJsonValue(item),
)
);
}
export function parseStructuredDraft(
draft: string,
prompt: boolean,
): JsonValue {
const value: unknown = JSON.parse(draft);
if (!(prompt ? isPromptValue(value) : isJsonValue(value)))
throw new TypeError('JSON');
return value as JsonValue;
}
export function isSimplePrompt(
value: unknown,
): value is { role: string; content: string }[] {
return (
Array.isArray(value) &&
value.length > 0 &&
value.every(
(item, index) =>
item &&
typeof item.content === 'string' &&
(index === 0
? item.role === 'system'
: ['user', 'assistant'].includes(item.role)) &&
Object.keys(item).every((key) => key === 'role' || key === 'content'),
)
);
}
@@ -37,6 +37,8 @@ interface ReasoningLevelPickerProps {
value: ReasoningLevel;
levels: ReasoningLevel[];
disabled?: boolean;
inherited?: boolean;
onInherit?: () => void;
onChange: (value: ReasoningLevel) => void;
}
@@ -44,6 +46,8 @@ export default function ReasoningLevelPicker({
value,
levels,
disabled = false,
inherited = false,
onInherit,
onChange,
}: ReasoningLevelPickerProps) {
const { t } = useTranslation();
@@ -52,8 +56,14 @@ export default function ReasoningLevelPicker({
const safeValue: ReasoningLevel = safeLevels.includes(value)
? value
: safeLevels[0];
const currentLabel = t(REASONING_LEVEL_LABEL_KEYS[safeValue]);
const isExplicit = safeValue !== 'provider_default';
const isInherited = Boolean(onInherit && inherited);
const currentLabel = t(
isInherited
? 'models.reasoningLevels.useModelSetting'
: REASONING_LEVEL_LABEL_KEYS[safeValue],
);
const isExplicit =
!isInherited && (Boolean(onInherit) || safeValue !== 'provider_default');
const currentIndex = Math.max(0, safeLevels.indexOf(safeValue));
return (
@@ -63,7 +73,7 @@ export default function ReasoningLevelPicker({
type="button"
variant="outline"
size="sm"
disabled={disabled || safeLevels.length <= 1}
disabled={disabled || (safeLevels.length <= 1 && !onInherit)}
aria-label={`${t('models.reasoningLevel')}: ${currentLabel}`}
className="h-9 w-9 shrink-0 gap-1.5 px-2.5 text-xs font-normal sm:w-auto sm:max-w-36"
>
@@ -77,6 +87,30 @@ export default function ReasoningLevelPicker({
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-[272px] p-4">
{onInherit && (
<div className="mb-3 flex flex-col gap-1">
<Button
type="button"
variant="outline"
size="sm"
aria-pressed={isInherited}
onClick={onInherit}
>
{t('models.reasoningLevels.useModelSetting')}
</Button>
{safeLevels.includes('provider_default') && (
<Button
type="button"
variant="outline"
size="sm"
aria-pressed={!isInherited && safeValue === 'provider_default'}
onClick={() => onChange('provider_default')}
>
{t(REASONING_LEVEL_LABEL_KEYS.provider_default)}
</Button>
)}
</div>
)}
<div className="flex h-5 items-center gap-0.5 text-sm text-muted-foreground">
<span>{currentLabel}</span>
<ChevronRight className="size-3.5" />
@@ -1,8 +1,10 @@
import EntityLoadState from '@/components/EntityLoadState';
import { isCurrentPipelineConfig } from './pipeline-config-safety';
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import PipelineFormComponent, {
PipelineFormHandle,
} from '@/app/home/pipelines/components/pipeline-form/PipelineFormComponent';
@@ -150,6 +152,25 @@ export default function PipelineDetailContent({
);
if (!pipelineDetails) return <EntityLoadState />;
// Never hydrate plugin defaults or mount debug autosave for legacy bindings.
if (!isCurrentPipelineConfig(pipelineDetails.config)) {
return (
<div className="space-y-4">
<h1 className="text-xl font-semibold">
{pipelineDetails.emoji || '⚙️'} {pipelineDetails.name}
</h1>
<p className="text-sm text-muted-foreground">
{pipelineDetails.description}
</p>
<Alert>
<AlertDescription>
{t('pipelineMigration.legacyGate')}
</AlertDescription>
</Alert>
</div>
);
}
// ==================== Edit Mode ====================
const pipelineName =
pipelineDetails?.name ||
@@ -178,6 +199,7 @@ export default function PipelineDetailContent({
<fieldset className="contents" disabled={!canManage}>
<PipelineFormComponent
ref={pipelineFormRef}
onLegacyPipeline={setPipelineDetails}
pipelineId={id}
isEditMode={true}
disableForm={!canManage}
@@ -0,0 +1,540 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { httpClient } from '@/app/infra/http/HttpClient';
import { getCurrentWorkspaceSnapshot } from '@/app/infra/http/currentWorkspaceStore';
import { migrationIssueKey } from './pipeline-migration-issues';
import type { CurrentWorkspace } from '@/app/infra/entities/workspace';
import type {
PipelineMigrationIssue,
PipelineMigrationItem,
PipelineMigrationPreview,
PipelineMigrationResult,
} from '@/app/infra/entities/api/pipeline-migration';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
export function migrationWorkspaceKey(workspace: CurrentWorkspace | null) {
return workspace
? `${workspace.workspace.instance_uuid}:${workspace.workspace.uuid}:${workspace.placement_generation}:${workspace.permissions.join(',')}`
: '';
}
function safeField(field?: string | null) {
return field && field.length <= 180 && /^[a-zA-Z0-9_.\-[\]]+$/.test(field)
? field
: null;
}
const resultStates = new Set([
'pending',
'migrated',
'already_current',
'blocked',
'stale',
'failed',
'activation_pending',
]);
type Status =
| 'idle'
| 'submitting'
| 'running'
| 'finished'
| 'failed'
| 'lost'
| 'requestError';
export default function PipelineMigration({
workspace,
onComplete,
}: {
workspace: CurrentWorkspace;
onComplete: () => void;
}) {
const { t } = useTranslation();
const scopeKey = migrationWorkspaceKey(workspace);
const canManage = workspace.permissions.includes('resource.manage');
const [open, setOpen] = useState(false);
const [preview, setPreview] = useState<PipelineMigrationPreview | null>(null);
const [loading, setLoading] = useState(false);
const [previewError, setPreviewError] = useState(false);
const [valid, setValid] = useState(false);
const [selected, setSelected] = useState<string[]>([]);
const [confirmed, setConfirmed] = useState(false);
const [status, setStatus] = useState<Status>('idle');
const [results, setResults] = useState<PipelineMigrationResult[]>([]);
const active = useRef(false);
const submitting = useRef(false);
const previewGeneration = useRef(0);
const controller = useRef<AbortController | null>(null);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const onCompleteRef = useRef(onComplete);
onCompleteRef.current = onComplete;
const isCurrent = useCallback(
() =>
active.current &&
migrationWorkspaceKey(getCurrentWorkspaceSnapshot()) === scopeKey,
[scopeKey],
);
const refresh = useCallback(
async (allowSelection = true) => {
if (!isCurrent()) return;
const generation = ++previewGeneration.current;
setSelected([]);
setConfirmed(false);
setValid(false);
setLoading(true);
setPreviewError(false);
try {
const response = await httpClient.getPipelineMigrationPreview({
signal: controller.current?.signal,
});
if (!isCurrent() || generation !== previewGeneration.current) return;
if (
response.workspace_uuid !== workspace.workspace.uuid ||
!Array.isArray(response.items) ||
response.total !== response.items.length
)
throw new Error('Invalid preview');
setPreview(response);
setValid(allowSelection);
} catch {
if (isCurrent() && generation === previewGeneration.current) {
setPreviewError(true);
setPreview(null);
}
} finally {
if (isCurrent() && generation === previewGeneration.current)
setLoading(false);
}
},
[isCurrent, workspace.workspace.uuid],
);
useEffect(() => {
active.current = true;
controller.current = new AbortController();
void refresh();
const onFocus = () => {
if (!submitting.current) void refresh();
};
window.addEventListener('focus', onFocus);
return () => {
active.current = false;
controller.current?.abort();
if (timer.current) clearTimeout(timer.current);
window.removeEventListener('focus', onFocus);
};
}, [refresh]);
const busy = status === 'submitting' || status === 'running';
const eligible = (item: PipelineMigrationItem) =>
['ready', 'activation_pending'].includes(item.state) &&
!!item.preview_token;
const rows = preview?.items ?? [];
const count = rows.filter(
(item) => !['already_current', 'not_legacy'].includes(item.state),
).length;
function renderIssue(issue: PipelineMigrationIssue, warning = false) {
// Unknown server codes use localized fallbacks, never raw upstream messages.
const key = migrationIssueKey(issue.code);
const fallback = t(
warning
? 'pipelineMigration.warningFallback'
: 'pipelineMigration.blockerFallback',
);
const message = key
? t(`pipelineMigration.notices.${key}`, { defaultValue: fallback })
: fallback;
const field = safeField(issue.field);
return (
<span>
{message}
{field && (
<>
{' '}
— <code className="text-xs">{field}</code>
</>
)}
</span>
);
}
async function execute() {
if (
submitting.current ||
!isCurrent() ||
!canManage ||
!valid ||
loading ||
!confirmed ||
selected.length === 0 ||
selected.length > 50
)
return;
const items = rows
.filter((item) => selected.includes(item.pipeline_uuid) && eligible(item))
.map((item) => ({
pipeline_uuid: item.pipeline_uuid,
preview_token: item.preview_token!,
}));
if (items.length !== selected.length) return;
submitting.current = true;
++previewGeneration.current;
setStatus('submitting');
setValid(false);
setConfirmed(false);
setResults(
items.map((item) => ({
pipeline_uuid: item.pipeline_uuid,
state: 'pending',
code: null,
})),
);
const loseObservation = () => {
if (!isCurrent()) return;
submitting.current = false;
setStatus('lost');
void refresh(false);
onCompleteRef.current();
};
try {
const { task_id } = await httpClient.executePipelineMigration(
{ confirmed: true, items },
{ signal: controller.current?.signal },
);
if (!isCurrent()) return;
setStatus('running');
const poll = async () => {
if (!isCurrent()) return;
try {
const task = await httpClient.getAsyncTask(task_id, {
signal: controller.current?.signal,
});
if (!isCurrent()) return;
const metadata = task.task_context?.metadata;
if (
task.id !== task_id ||
metadata?.kind !== 'pipeline_migration' ||
!Array.isArray(metadata.results)
) {
loseObservation();
return;
}
const outcome = metadata.results as PipelineMigrationResult[];
if (
outcome.some(
(item) =>
!item ||
!resultStates.has(item.state) ||
!items.some(
(submitted) => submitted.pipeline_uuid === item.pipeline_uuid,
),
) ||
(task.runtime.done && outcome.length !== items.length) ||
new Set(outcome.map((item) => item.pipeline_uuid)).size !==
outcome.length
) {
loseObservation();
return;
}
const scopedResults = items.map(
(item) =>
outcome.find(
(result) => result.pipeline_uuid === item.pipeline_uuid,
) ?? {
pipeline_uuid: item.pipeline_uuid,
state: 'pending' as const,
code: null,
},
);
setResults(scopedResults);
if (task.runtime.done) {
if (
!task.runtime.exception &&
scopedResults.some((item) => item.state === 'pending')
) {
loseObservation();
return;
}
submitting.current = false;
setStatus(task.runtime.exception ? 'failed' : 'finished');
void refresh(false);
onCompleteRef.current();
} else {
timer.current = setTimeout(() => {
void poll();
}, 1000);
}
} catch {
loseObservation();
}
};
void poll();
} catch (error) {
if (!isCurrent()) return;
const code =
typeof error === 'object' && error && 'code' in error
? error.code
: undefined;
if (code === -1 || code === -2 || code === undefined) {
loseObservation();
return;
}
submitting.current = false;
setStatus('requestError');
setSelected([]);
}
}
function changeOpen(next: boolean) {
setOpen(next);
if (!submitting.current) {
setSelected([]);
setConfirmed(false);
if (next) void refresh(status === 'idle');
}
}
return (
<>
{(count > 0 || previewError || results.length > 0) && (
<Alert className="mb-4 shrink-0">
<AlertTitle>{t('pipelineMigration.title')}</AlertTitle>
<AlertDescription className="flex items-center justify-between gap-3">
<span>
{previewError
? t('pipelineMigration.previewError')
: t('pipelineMigration.detected', { count })}
</span>
<Button
variant="outline"
size="sm"
onClick={() => changeOpen(true)}
>
{t('pipelineMigration.review')}
</Button>
</AlertDescription>
</Alert>
)}
<Dialog open={open} onOpenChange={changeOpen}>
<DialogContent className="sm:max-w-2xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>{t('pipelineMigration.title')}</DialogTitle>
<DialogDescription>
{t('pipelineMigration.description')}
</DialogDescription>
</DialogHeader>
{!canManage && (
<Alert>
<AlertDescription>
{t('pipelineMigration.readOnly')}
</AlertDescription>
</Alert>
)}
{previewError && (
<Alert variant="destructive">
<AlertDescription>
{t('pipelineMigration.previewError')}
</AlertDescription>
</Alert>
)}
{status !== 'idle' && (
<Alert
variant={
status === 'failed' || status === 'requestError'
? 'destructive'
: 'default'
}
>
<AlertDescription role="status">
{t(`pipelineMigration.${status}`)}
</AlertDescription>
</Alert>
)}
<ScrollArea className="min-h-0 flex-1 overflow-y-auto">
<div className="space-y-3 pr-3">
{rows.map((item) => (
<div
key={item.pipeline_uuid}
className="rounded-md border p-3 space-y-2"
>
<div className="flex items-start gap-3">
<Checkbox
aria-label={item.name}
checked={selected.includes(item.pipeline_uuid)}
disabled={
!canManage ||
busy ||
loading ||
!valid ||
!eligible(item) ||
(selected.length >= 50 &&
!selected.includes(item.pipeline_uuid))
}
onCheckedChange={(checked) => {
setConfirmed(false);
setSelected((current) =>
checked
? [...current, item.pipeline_uuid]
: current.filter((id) => id !== item.pipeline_uuid),
);
}}
/>
<div className="min-w-0 flex-1 space-y-1">
<p className="font-medium text-sm break-words">
{item.name}
</p>
<p className="text-xs text-muted-foreground break-words">
{item.legacy_runner ?? '—'} →{' '}
{item.target_plugin?.name ?? '—'}
</p>
</div>
<Badge variant="secondary">
{t(`pipelineMigration.states.${item.state}`)}
</Badge>
</div>
{item.blockers.map((issue, index) => (
<p className="text-sm text-destructive" key={`b-${index}`}>
{renderIssue(issue)}
</p>
))}
{item.warnings.map((issue, index) => (
<p
className="text-sm text-muted-foreground"
key={`w-${index}`}
>
{renderIssue(issue, true)}
</p>
))}
{item.changed_paths.filter((path) => safeField(path)).length >
0 && (
<p className="text-xs text-muted-foreground">
{t('pipelineMigration.changedFields')}:{' '}
{item.changed_paths
.filter((path) => safeField(path))
.join(', ')}
</p>
)}
{item.state === 'activation_pending' && (
<p className="text-sm text-muted-foreground">
{t(
item.preview_token
? 'pipelineMigration.activationRetryHint'
: 'pipelineMigration.activationHint',
)}
</p>
)}
</div>
))}
{rows.some((item) => item.state === 'needs_plugin') && (
<Alert>
<AlertDescription className="space-y-2">
<p>{t('pipelineMigration.pluginHint')}</p>
<Button variant="link" asChild className="h-auto p-0">
<a
href="/home/extensions"
target="_blank"
rel="noopener noreferrer"
>
{t('pipelineMigration.extensions')}
</a>
</Button>
</AlertDescription>
</Alert>
)}
{results.length > 0 && (
<div
className="space-y-2"
aria-label={t('pipelineMigration.results')}
>
<h3 className="text-sm font-medium">
{t('pipelineMigration.results')}
</h3>
{results.map((result) => (
<div
className="rounded-md border p-3 text-sm"
key={result.pipeline_uuid}
data-testid={`migration-result-${result.pipeline_uuid}`}
>
<p>
{rows.find(
(item) => item.pipeline_uuid === result.pipeline_uuid,
)?.name ?? result.pipeline_uuid}{' '}
— {t(`pipelineMigration.states.${result.state}`)}
</p>
{result.code && (
<p className="text-muted-foreground">
{renderIssue({ code: result.code })}
</p>
)}
{result.state === 'activation_pending' && (
<p>{t('pipelineMigration.activationHint')}</p>
)}
</div>
))}
</div>
)}
</div>
</ScrollArea>
<p className="text-xs text-muted-foreground">
{t('pipelineMigration.selection', { count: selected.length })}
</p>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={confirmed}
onCheckedChange={(checked) => setConfirmed(checked === true)}
disabled={
!canManage || busy || loading || !valid || !selected.length
}
/>
{t('pipelineMigration.confirm')}
</label>
<DialogFooter>
<Button variant="ghost" onClick={() => changeOpen(false)}>
{t('common.cancel')}
</Button>
<Button
variant="outline"
disabled={busy || loading}
onClick={() => {
setStatus('idle');
void refresh();
}}
>
{t('pipelineMigration.refresh')}
</Button>
<Button
disabled={
!canManage ||
busy ||
loading ||
!valid ||
!selected.length ||
!confirmed
}
onClick={() => {
void execute();
}}
>
{t('pipelineMigration.execute')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -1,4 +1,8 @@
import EntityLoadState from '@/components/EntityLoadState';
import { preserveRunnerConfig } from './RunnerConfigPreservation';
import type { ComponentProps } from 'react';
import { isCurrentPipelineConfig } from '../../pipeline-config-safety';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
forwardRef,
useCallback,
@@ -67,6 +71,35 @@ import {
type InstalledRunner,
} from '@/app/home/agents/runner-marketplace';
/** A mount-scoped editing session: normalized defaults are UI state, not edits. */
function PersistedRunnerForm(
props: ComponentProps<typeof DynamicFormComponent>,
) {
const initialValues = useRef(props.initialValues);
const previous = useRef<Record<string, unknown> | undefined>(undefined);
const current = useRef(props.initialValues || {});
current.current = props.initialValues || {};
return (
<DynamicFormComponent
{...props}
initialValues={initialValues.current}
onSubmit={(values) => {
const emitted = values as Record<string, unknown>;
const next = preserveRunnerConfig(
current.current,
previous.current,
emitted,
);
previous.current = structuredClone(emitted);
if (JSON.stringify(next) !== JSON.stringify(current.current)) {
current.current = next;
props.onSubmit?.(next);
}
}}
/>
);
}
interface PipelineFormComponentProps {
pipelineId?: string;
isEditMode: boolean;
@@ -78,6 +111,7 @@ interface PipelineFormComponentProps {
onCancel?: () => void;
onDirtyChange?: (dirty: boolean) => void;
onSavingChange?: (saving: boolean) => void;
onLegacyPipeline?: (pipeline: Pipeline) => void;
}
export interface PipelineFormHandle {
@@ -103,6 +137,7 @@ const PipelineFormComponent = forwardRef<
onCancel,
onDirtyChange,
onSavingChange,
onLegacyPipeline,
},
ref,
) {
@@ -111,7 +146,10 @@ const PipelineFormComponent = forwardRef<
const [showCopyConfirm, setShowCopyConfirm] = useState(false);
const [isDefaultPipeline, setIsDefaultPipeline] = useState<boolean>(false);
const [isSaving, setIsSaving] = useState(false);
const pipelineFormElement = useRef<HTMLFormElement>(null);
const isSavingRef = useRef(false);
const legacyConfigRef = useRef(false);
const [legacyConfig, setLegacyConfig] = useState(false);
const formSchema = isEditMode
? z.object({
@@ -291,6 +329,12 @@ const PipelineFormComponent = forwardRef<
.getPipeline(pipelineId || '')
.then((resp: GetPipelineResponseData) => {
if (cancelled) return;
if (!isCurrentPipelineConfig(resp.pipeline.config)) {
legacyConfigRef.current = true;
setLegacyConfig(true);
onLegacyPipeline?.(resp.pipeline);
return;
}
setIsDefaultPipeline(resp.pipeline.is_default ?? false);
const loadedValues = {
@@ -316,7 +360,7 @@ const PipelineFormComponent = forwardRef<
return () => {
cancelled = true;
};
}, [form, isEditMode, pipelineId, loadAttempt]);
}, [form, isEditMode, pipelineId, loadAttempt, onLegacyPipeline]);
useEffect(() => {
if (
@@ -404,7 +448,15 @@ const PipelineFormComponent = forwardRef<
}
async function handleModify(values: FormValues): Promise<boolean> {
if (isSavingRef.current) return false;
// Imperative saves bypass native form submission validation. In particular,
// never save an old structured value while its visible JSON draft is invalid.
if (
pipelineFormElement.current &&
!pipelineFormElement.current.checkValidity()
)
return false;
if (isSavingRef.current || legacyConfigRef.current || !pipelineLoaded)
return false;
const submittedSnapshot = JSON.stringify(values);
const realConfig = {
ai: values.ai,
@@ -468,6 +520,7 @@ const PipelineFormComponent = forwardRef<
}
},
async save() {
if (legacyConfigRef.current || !pipelineLoaded) return false;
if (!hasUnsavedChangesRef.current) return true;
if (isSavingRef.current || !isEditMode) return false;
const valid = await form.trigger();
@@ -488,7 +541,9 @@ const PipelineFormComponent = forwardRef<
values: object,
) {
const stageKey = `${String(formName)}.${stageName}`;
const isFirstEmission = !initializedStagesRef.current.has(stageKey);
const isFirstEmission =
!initializedStagesRef.current.has(stageKey) &&
!(formName === 'output' && stageName === 'misc');
const currentValues =
(form.getValues(formName) as Record<string, unknown>) || {};
@@ -524,6 +579,29 @@ const PipelineFormComponent = forwardRef<
}
form.setValue(formName, nextValues);
if (formName === 'output' && stageName === 'misc') {
const removeThink = (values as Record<string, unknown>)['remove-think'];
const previousThink = (
currentValues.misc as Record<string, unknown> | undefined
)?.['remove-think'];
const runnerId = form.getValues('ai.runner.id') as string;
const supported = aiConfigTabSchema?.stages.some(
(stage) =>
stage.name === runnerId &&
stage.config.some((item) => item.name === 'remove-think'),
);
if (
supported &&
typeof removeThink === 'boolean' &&
removeThink !== previousThink
) {
const configs = form.getValues('ai.runner_config') || {};
form.setValue('ai.runner_config', {
...configs,
[runnerId]: { ...configs[runnerId], 'remove-think': removeThink },
});
}
}
if (isFirstEmission) {
initializedStagesRef.current.add(stageKey);
@@ -539,22 +617,23 @@ const PipelineFormComponent = forwardRef<
}
function handleRunnerConfigEmit(stageName: string, values: object) {
const stageKey = `ai.runner_config.${stageName}`;
const isFirstEmission = !initializedStagesRef.current.has(stageKey);
const currentRunnerConfigs =
(form.getValues('ai.runner_config') as Record<string, unknown>) || {};
(form.getValues('ai.runner_config') as Record<
string,
Record<string, unknown>
>) || {};
const removeThink = (values as Record<string, unknown>)['remove-think'];
const previousThink = currentRunnerConfigs[stageName]?.['remove-think'];
form.setValue('ai.runner_config', {
...currentRunnerConfigs,
[stageName]: values,
});
if (isFirstEmission) {
initializedStagesRef.current.add(stageKey);
const currentSnapshot = JSON.stringify(form.getValues());
if (savedSnapshotRef.current === '' || !hasUnsavedChangesRef.current) {
savedSnapshotRef.current = currentSnapshot;
}
if (typeof removeThink === 'boolean' && removeThink !== previousThink) {
const output = form.getValues('output') || {};
form.setValue('output', {
...output,
misc: { ...output.misc, 'remove-think': removeThink },
});
}
}
@@ -631,7 +710,7 @@ const PipelineFormComponent = forwardRef<
)}
</CardHeader>
<CardContent className="space-y-6">
<DynamicFormComponent
<PersistedRunnerForm
itemConfigList={stage.config}
initialValues={stageInitialValues}
systemContext={dynamicFormSystemContext}
@@ -645,6 +724,10 @@ const PipelineFormComponent = forwardRef<
}
}
const StageForm =
formName === 'output' && stage.name === 'misc'
? PersistedRunnerForm
: DynamicFormComponent;
const stageInitialValues: Record<string, any> =
(form.watch(formName) as Record<string, any>)?.[stage.name] || {};
@@ -659,7 +742,7 @@ const PipelineFormComponent = forwardRef<
)}
</CardHeader>
<CardContent className="space-y-6">
<DynamicFormComponent
<StageForm
itemConfigList={stage.config}
initialValues={stageInitialValues}
systemContext={dynamicFormSystemContext}
@@ -711,6 +794,12 @@ const PipelineFormComponent = forwardRef<
}
};
if (legacyConfig)
return (
<Alert>
<AlertDescription>{t('pipelineMigration.legacyGate')}</AlertDescription>
</Alert>
);
if (loadFailed)
return (
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
@@ -723,6 +812,7 @@ const PipelineFormComponent = forwardRef<
<Form {...form}>
<form
id="pipeline-form"
ref={pipelineFormElement}
onSubmit={form.handleSubmit(handleFormSubmit)}
className="h-full flex flex-col flex-1 min-h-0 mb-2"
>
@@ -920,8 +1010,11 @@ const PipelineFormComponent = forwardRef<
{/* Dynamic config sections (edit mode only) */}
{isEditMode && (
<>
{activeSection === 'ai' && aiConfigTabSchema && (
<div className="space-y-6">
{aiConfigTabSchema && (
<div
className="space-y-6"
hidden={activeSection !== 'ai'}
>
{aiConfigTabSchema.stages.map((stage) =>
renderDynamicForms(stage, 'ai'),
)}
@@ -0,0 +1,35 @@
type Values = Record<string, unknown>;
const isRecord = (value: unknown): value is Values =>
value !== null && typeof value === 'object' && !Array.isArray(value);
/** Apply only edits relative to the mounted form's normalized emission.
* The raw persisted object remains authoritative for unedited values, including
* fields not represented by the schema, nulls, omitted defaults and whitespace.
*/
export function preserveRunnerConfig(
current: Values,
previous: Values | undefined,
emitted: Values,
): Values {
if (!previous) return current;
const result = { ...current };
for (const key of new Set([
...Object.keys(previous),
...Object.keys(emitted),
])) {
if (JSON.stringify(previous[key]) === JSON.stringify(emitted[key]))
continue;
if (!Object.prototype.hasOwnProperty.call(emitted, key)) {
delete result[key];
} else if (isRecord(previous[key]) && isRecord(emitted[key])) {
result[key] = preserveRunnerConfig(
isRecord(current[key]) ? current[key] : {},
previous[key],
emitted[key],
);
} else {
result[key] = emitted[key];
}
}
return result;
}
+31 -6
View File
@@ -1,19 +1,44 @@
import { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useCurrentWorkspace } from '@/app/infra/http';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import PipelineDetailContent from './PipelineDetailContent';
import PipelineMigration, { migrationWorkspaceKey } from './PipelineMigration';
export default function PipelineConfigPage() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const detailId = searchParams.get('id');
if (detailId) {
return <PipelineDetailContent key={detailId} id={detailId} />;
}
const workspace = useCurrentWorkspace();
const { refreshPipelines } = useSidebarData();
const [revision, setRevision] = useState(0);
const scopeKey = migrationWorkspaceKey(workspace);
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
<p>{t('pipelines.selectFromSidebar')}</p>
<div className="flex h-full min-h-0 flex-col">
{workspace && workspace.permissions.includes('resource.view') && (
<PipelineMigration
key={scopeKey}
workspace={workspace}
onComplete={() => {
void refreshPipelines();
setRevision((current) => current + 1);
}}
/>
)}
<div className="flex-1 min-h-0">
{detailId ? (
<PipelineDetailContent
key={`${scopeKey}:${detailId}:${revision}`}
id={detailId}
/>
) : (
<div className="flex h-full items-center justify-center text-muted-foreground">
<p>{t('pipelines.selectFromSidebar')}</p>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,26 @@
function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
/** Only unambiguous current containers may hydrate editor defaults. */
export function isCurrentPipelineConfig(config: unknown): boolean {
if (!record(config) || !record(config.ai) || !record(config.ai.runner))
return false;
const ai = config.ai;
const runner = ai.runner as Record<string, unknown>;
if (
Object.keys(ai).some((key) => !['runner', 'runner_config'].includes(key)) ||
Object.keys(runner).some((key) => !['id', 'expire-time'].includes(key)) ||
typeof runner.id !== 'string'
)
return false;
// The canonical current default has no selected runner yet. Do not confuse
// it with a legacy selector or an orphaned configuration needing review.
if (runner.id === '')
return (
record(ai.runner_config) && Object.keys(ai.runner_config).length === 0
);
return runner.id.startsWith('plugin:');
}
@@ -0,0 +1,35 @@
const issueKeys: Record<string, string> = {
'local.context_defaults': 'contextDefaults',
'local.model_reasoning': 'modelReasoning',
'local.serial_tools_preserved': 'serialTools',
'local.retrieval_defaults': 'retrievalDefaults',
'local.box_state_reset': 'boxReset',
'coze.persistent_history': 'persistentHistory',
'langflow.persistent_history': 'persistentHistory',
'langflow.tweaks_default': 'tweaksDefault',
'dify.timeout_default': 'timeoutDefault',
'n8n.session_ids_reset': 'historyReset',
'weknora.session_title_changed': 'sessionTitle',
'migration.alias_repaired': 'aliasRepaired',
'migration.null_default': 'nullDefault',
'migration.output_policy_copied': 'outputPolicy',
'migration.filtered_variables': 'filteredVariables',
'migration.history_reset': 'historyReset',
'migration.identity_preserved': 'identityPreserved',
'migration.new_defaults': 'newDefaults',
'migration.legacy_sections_archived': 'legacyArchive',
'external.state_validation_required': 'externalState',
'runtime.plugin_version_mismatch': 'pluginVersion',
'runtime.schema_invalid': 'schemaChanged',
'runtime.schema_missing': 'schemaChanged',
'extensions.runner_excluded': 'runnerExcluded',
'local.box_scope': 'boxScope',
'runtime.pending_interaction': 'pendingInteraction',
};
/** Only locally reviewed codes may select localized copy. */
export function migrationIssueKey(code: string): string | undefined {
return Object.prototype.hasOwnProperty.call(issueKeys, code)
? issueKeys[code]
: undefined;
}
@@ -0,0 +1,55 @@
export type PipelineMigrationState =
| 'ready'
| 'needs_plugin'
| 'blocked'
| 'already_current'
| 'not_legacy'
| 'activation_pending';
export interface PipelineMigrationIssue {
code: string;
field?: string | null;
}
export interface PipelineMigrationItem {
pipeline_uuid: string;
name: string;
state: PipelineMigrationState;
legacy_runner: string | null;
target_runner_id: string | null;
target_plugin: { author: string; name: string; version: string } | null;
changed_paths: string[];
blockers: PipelineMigrationIssue[];
warnings: PipelineMigrationIssue[];
preview_token: string | null;
}
export interface PipelineMigrationPreview {
planner_version: string;
workspace_uuid: string;
items: PipelineMigrationItem[];
total: number;
}
export interface PipelineMigrationRequest {
confirmed: true;
items: { pipeline_uuid: string; preview_token: string }[];
}
export interface PipelineMigrationResult {
pipeline_uuid: string;
state:
| 'pending'
| 'migrated'
| 'already_current'
| 'blocked'
| 'stale'
| 'failed'
| 'activation_pending';
code: string | null;
}
export interface PipelineMigrationTaskMetadata {
kind: 'pipeline_migration';
results: PipelineMigrationResult[];
}
+19 -2
View File
@@ -1,4 +1,8 @@
import { BaseHttpClient, type RequestConfig } from './BaseHttpClient';
import type {
PipelineMigrationPreview,
PipelineMigrationRequest,
} from '@/app/infra/entities/api/pipeline-migration';
import type { DebugExecutionEvent } from '@/app/infra/entities/api/agent-debug';
import type {
CodexAuthStatus,
@@ -441,6 +445,19 @@ export class BackendClient extends BaseHttpClient {
return this.get(`/api/v1/pipelines/${uuid}`);
}
public getPipelineMigrationPreview(
config?: RequestConfig,
): Promise<PipelineMigrationPreview> {
return this.get('/api/v1/pipelines/_/migration/preview', undefined, config);
}
public executePipelineMigration(
body: PipelineMigrationRequest,
config?: RequestConfig,
): Promise<AsyncTaskCreatedResp> {
return this.post('/api/v1/pipelines/_/migration/execute', body, config);
}
public createPipeline(pipeline: Pipeline): Promise<{
uuid: string;
}> {
@@ -1344,8 +1361,8 @@ export class BackendClient extends BaseHttpClient {
return this.get(`/api/v1/system/tasks${qs ? `?${qs}` : ''}`);
}
public getAsyncTask(id: number): Promise<AsyncTask> {
return this.get(`/api/v1/system/tasks/${id}`);
public getAsyncTask(id: number, config?: RequestConfig): Promise<AsyncTask> {
return this.get(`/api/v1/system/tasks/${id}`, undefined, config);
}
public getPluginSystemStatus(): Promise<ApiRespPluginSystemStatus> {