diff --git a/TODOS.md b/TODOS.md index c85efbe52b..373ef8f311 100644 --- a/TODOS.md +++ b/TODOS.md @@ -22,3 +22,26 @@ (Eng Review Decisions → run-creation orchestration). - **Depends on / blocked by:** Backend team; relates to the FE evaluations migration landing first (FE rollback is the interim state). + +## Query Registry — fast-follows + +### Backend query-usage endpoint (enumerate referencing live evals) +- **What:** Add `POST /queries/revisions/{id}/usage` (or `/queries/{id}/usage`) returning the evaluation-run ids that reference a given query revision. +- **Why:** v1 ships a generic "this query may be in use by a live evaluation" confirm before archive, because there is no reverse-lookup today. This endpoint lets the manage drawer name the specific live evals before archiving — real safety instead of a generic warning. +- **Pros:** Turns the safe-archive UX from 7/10 (generic) to 10/10 (enumerated); reuses data that already exists. +- **Cons:** Backend work (new router/service/DAO); a reverse scan of eval-run references. +- **Context:** The reference data exists, flattened, in the evaluations domain under `QUERY_REFERENCE_KEY = "query_revision"` (`api/oss/src/dbs/postgres/evaluations/utils.py`). Eval runs store `data.steps[].references["query_revision"]`. There is currently NO reverse-lookup endpoint and archive does not block in-use queries (`api/oss/src/core/queries/service.py:844` `archive_query_revision` has no reference check). Verified during the eng review of branch `claude/intelligent-bassi-ca4cc0`. +- **Depends on / blocked by:** None. Independent of the FE registry; the FE swaps the generic confirm for enumeration when this lands. + +### (RESOLVED) Revision-history expand — no backend change needed +- **What:** The Query Registry's version-history expand is implemented. Each query + (artifact) row expands to its earlier revisions, lazy-loaded on first expand. +- **Resolution:** Revisions are queried by the **artifact ref** (`query_refs: [{id: queryId}]`), + not the variant ref — `QueryRevisionQueryRequest` accepts `query_refs`, and the + service maps `artifact_refs=query_refs`. Simple queries are single-variant, so this + returns the full version history. The earlier assumption that the list must return + `variant_id` was wrong; the list already returns the artifact `id` (= queryId), which + is all the expand needs. +- **Nice-to-have (not blocking):** the simple-queries list could still surface + `revision_id` so the head row shows its version badge without a fetch, but the expand + works without it. diff --git a/web/ee/src/pages/w/[workspace_id]/p/[project_id]/queries/archived/index.tsx b/web/ee/src/pages/w/[workspace_id]/p/[project_id]/queries/archived/index.tsx new file mode 100644 index 0000000000..4f66bd6b47 --- /dev/null +++ b/web/ee/src/pages/w/[workspace_id]/p/[project_id]/queries/archived/index.tsx @@ -0,0 +1,3 @@ +import ArchivedQueriesPage from "@agenta/oss/src/pages/w/[workspace_id]/p/[project_id]/queries/archived" + +export default ArchivedQueriesPage diff --git a/web/ee/src/pages/w/[workspace_id]/p/[project_id]/queries/index.tsx b/web/ee/src/pages/w/[workspace_id]/p/[project_id]/queries/index.tsx new file mode 100644 index 0000000000..0aba5ae7dd --- /dev/null +++ b/web/ee/src/pages/w/[workspace_id]/p/[project_id]/queries/index.tsx @@ -0,0 +1,3 @@ +import QueriesPage from "@agenta/oss/src/pages/w/[workspace_id]/p/[project_id]/queries/index" + +export default QueriesPage diff --git a/web/oss/src/components/Filters/Filters.tsx b/web/oss/src/components/Filters/Filters.tsx index d61f056c59..0c5c701bf2 100644 --- a/web/oss/src/components/Filters/Filters.tsx +++ b/web/oss/src/components/Filters/Filters.tsx @@ -1,6 +1,7 @@ import {useMemo, useState} from "react" import {evaluatorsListDataAtom, evaluatorFeedbackSchemasAtom} from "@agenta/entities/workflow" +import {useEnsureEvaluatorEnrichment} from "@agenta/entity-ui/selection" import { ArrowClockwiseIcon, CaretDownIcon, @@ -284,8 +285,14 @@ const Filters: React.FC = ({ onClearFilter, buttonProps, reconcileFilterRows, + inline = false, }) => { const evaluatorPreviews = useAtomValue(evaluatorsListDataAtom) + // The annotation/feedback filter genuinely needs every evaluator's output + // schema to build its options, so activate the shared enrichment gate eagerly + // here (the gate keeps the per-evaluator revision fan-out from running on + // pages that never read this atom, e.g. the playground). + useEnsureEvaluatorEnrichment() const evaluatorFeedbackSchemas = useAtomValue(evaluatorFeedbackSchemasAtom) const annotationEvaluatorOptions = useMemo( @@ -769,7 +776,7 @@ const Filters: React.FC = ({ return {isValid: true} }) - const isApplyDisabled = rowValidations.some(({isValid}) => !isValid) + const hasInvalidRows = rowValidations.some(({isValid}) => !isValid) const nonPermanentFilterCount = useMemo( () => filter.filter((f) => !f.isPermanent).length, @@ -832,6 +839,26 @@ const Filters: React.FC = ({ setIsFilterOpen(false) } + // Whether the draft differs from the applied filter. `mapFilterData` (props → + // internal) is NOT a clean inverse of `sanitizeFilterItems` (it injects + // `key: ""`, labels, toUI-transformed values…), so comparing the sanitized + // draft against the raw `filterData` always reads "changed". Instead, run both + // sides through the same `map → explode → sanitize` pipeline so the + // normalization cancels out and an untouched draft compares equal. + const appliedBaseline = useMemo( + () => + sanitizeFilterItems(explodeAnnotationAnyEvaluatorRows(mapFilterData(filterData ?? []))), + [filterData, columns], + ) + const isDraftUnchanged = isEqual( + sanitizeFilterItems(explodeAnnotationAnyEvaluatorRows(filter)), + appliedBaseline, + ) + // Inline editor (Query Registry drawer): disable Apply when there is nothing to + // apply, so a clean draft can't spuriously fire onApplyFilter and dirty the + // parent. The popover keeps Apply enabled as its primary close affordance. + const isApplyDisabled = hasInvalidRows || (inline && isDraftUnchanged) + const getWithinPopover = (trigger: HTMLElement | null) => (trigger && (trigger.closest(".ant-popover") as HTMLElement)) || document.body @@ -841,832 +868,935 @@ const Filters: React.FC = ({ overflow: "auto", } as const - return ( - { - setIsFilterOpen(open) - if (!open) setActiveFieldDropdown(null) - }} - open={isFilterOpen} - placement="bottomLeft" - autoAdjustOverflow - styles={{body: {maxHeight: "70vh"}, root: {maxWidth: "100vw"}}} - destroyOnHidden - content={ -
+ const filterBody = ( +
+ {!inline && ( + <>
Filter
+ + )} + +
+ {displayedFilter.map((item, idx) => { + const uiKey = item.selectedField || item.field || "" + const baseFieldCfg = getField(uiKey) + const field = effectiveFieldForRow(baseFieldCfg, item) + + const isAnnotationFieldSelected = + field?.baseField?.includes("annotation") ?? false + + const operatorOptions = field + ? (field.operatorOptions ?? operatorOptionsFromIds(field.operatorIds)) + : [] + + const singleOperator = operatorOptions.length === 1 + const operatorValue = + item.operator || (singleOperator ? operatorOptions[0]?.value : undefined) + + const plan = + field && operatorValue ? planInputs(field, operatorValue as any) : undefined + const showKey = Boolean(plan?.needsKey) + const showValue = Boolean(plan?.showValue) + const valueAs = plan?.valueAs + const valueOptions = plan?.valueOptions + const keyPlaceholder = plan?.placeholders?.key ?? "Key" + const valuePlaceholder = plan?.placeholders?.value ?? "Value" + + const rawValue = Array.isArray(item.value) ? "" : (item.value as any) + const displayValue = (field as any)?.valueDisplayText || rawValue + const validation = rowValidations[idx] ?? {isValid: true} + const valueHasError = Boolean(validation.valueInvalid) + + const annotationValue = extractAnnotationValue(item.value) + + const disableHasAnnotationForRow = hasAnnotationIndices.some( + (annotationIdx) => annotationIdx !== idx, + ) + const disabledFieldOptionsForMenu = disableHasAnnotationForRow + ? annotationDisabledOptions + : undefined + + const setAnnotationValue = ( + updater: ( + prev: AnnotationFilterValue | undefined, + ) => AnnotationFilterValue | undefined, + ) => { + const next = updater(annotationValue ? {...annotationValue} : undefined) + if (!next || Object.keys(next).length === 0) { + onFilterChange({columnName: "value", value: [], idx}) + return + } -
- {displayedFilter.map((item, idx) => { - const uiKey = item.selectedField || item.field || "" - const baseFieldCfg = getField(uiKey) - const field = effectiveFieldForRow(baseFieldCfg, item) - - const isAnnotationFieldSelected = - field?.baseField?.includes("annotation") ?? false - - const operatorOptions = field - ? (field.operatorOptions ?? - operatorOptionsFromIds(field.operatorIds)) - : [] - - const singleOperator = operatorOptions.length === 1 - const operatorValue = - item.operator || - (singleOperator ? operatorOptions[0]?.value : undefined) - - const plan = - field && operatorValue - ? planInputs(field, operatorValue as any) - : undefined - const showKey = Boolean(plan?.needsKey) - const showValue = Boolean(plan?.showValue) - const valueAs = plan?.valueAs - const valueOptions = plan?.valueOptions - const keyPlaceholder = plan?.placeholders?.key ?? "Key" - const valuePlaceholder = plan?.placeholders?.value ?? "Value" - - const rawValue = Array.isArray(item.value) ? "" : (item.value as any) - const displayValue = (field as any)?.valueDisplayText || rawValue - const validation = rowValidations[idx] ?? {isValid: true} - const valueHasError = Boolean(validation.valueInvalid) - - const annotationValue = extractAnnotationValue(item.value) - - const disableHasAnnotationForRow = hasAnnotationIndices.some( - (annotationIdx) => annotationIdx !== idx, - ) - const disabledFieldOptionsForMenu = disableHasAnnotationForRow - ? annotationDisabledOptions - : undefined - - const setAnnotationValue = ( - updater: ( - prev: AnnotationFilterValue | undefined, - ) => AnnotationFilterValue | undefined, - ) => { - const next = updater( - annotationValue ? {...annotationValue} : undefined, - ) - if (!next || Object.keys(next).length === 0) { - onFilterChange({columnName: "value", value: [], idx}) - return - } - - const valueToStore: AnnotationFilterValue = {...next} - if (valueToStore.feedback) { - const cleanedFeedback = {...valueToStore.feedback} - if (cleanedFeedback.valueType === undefined) - cleanedFeedback.valueType = "string" - valueToStore.feedback = cleanedFeedback - } + const valueToStore: AnnotationFilterValue = {...next} + if (valueToStore.feedback) { + const cleanedFeedback = {...valueToStore.feedback} + if (cleanedFeedback.valueType === undefined) + cleanedFeedback.valueType = "string" + valueToStore.feedback = cleanedFeedback + } - onFilterChange({columnName: "value", value: [valueToStore], idx}) - } + onFilterChange({columnName: "value", value: [valueToStore], idx}) + } - const currentFeedback = annotationValue?.feedback - - // Build available feedback options - const availableFeedbackOptions = (() => { - if (annotationValue?.evaluator) { - const filtered = annotationFeedbackOptions.filter( - (option) => - option.evaluatorSlug === annotationValue.evaluator, - ) - // Keep currently selected key if it exists and is not part of filtered - const selectedKey = Array.isArray(currentFeedback?.field) - ? currentFeedback?.field[0] - : currentFeedback?.field - const selected = selectedKey - ? annotationFeedbackOptions.find( - (option) => option.value === selectedKey, - ) - : undefined - if ( - selected && - !filtered.some((o) => o.value === selected.value) - ) - return [selected, ...filtered] - return filtered - } - // No evaluator. Show deduped feedback names across all evaluators - return dedupeFeedbackOptions(annotationFeedbackOptions) - })() + const currentFeedback = annotationValue?.feedback - // Pick a type from the first selected key if present - const selectedFeedbackKey = Array.isArray(currentFeedback?.field) + // Build available feedback options + const availableFeedbackOptions = (() => { + if (annotationValue?.evaluator) { + const filtered = annotationFeedbackOptions.filter( + (option) => option.evaluatorSlug === annotationValue.evaluator, + ) + // Keep currently selected key if it exists and is not part of filtered + const selectedKey = Array.isArray(currentFeedback?.field) ? currentFeedback?.field[0] : currentFeedback?.field - const selectedFeedbackOption = selectedFeedbackKey - ? availableFeedbackOptions.find( - (option) => option.value === selectedFeedbackKey, + const selected = selectedKey + ? annotationFeedbackOptions.find( + (option) => option.value === selectedKey, ) : undefined + if (selected && !filtered.some((o) => o.value === selected.value)) + return [selected, ...filtered] + return filtered + } + // No evaluator. Show deduped feedback names across all evaluators + return dedupeFeedbackOptions(annotationFeedbackOptions) + })() + + // Pick a type from the first selected key if present + const selectedFeedbackKey = Array.isArray(currentFeedback?.field) + ? currentFeedback?.field[0] + : currentFeedback?.field + const selectedFeedbackOption = selectedFeedbackKey + ? availableFeedbackOptions.find( + (option) => option.value === selectedFeedbackKey, + ) + : undefined + + const feedbackValueType = + currentFeedback?.valueType ?? selectedFeedbackOption?.type ?? "string" + + const isEvaluatorActive = annotationValue + ? "evaluator" in annotationValue + : false + const isFeedbackActive = annotationValue ? "feedback" in annotationValue : false + + const feedbackOperatorOptions = ALL_FEEDBACK_OPERATOR_OPTIONS + + const coerceNumericFeedbackValue = ( + input: unknown, + ): string | number | undefined => { + if (typeof input === "number") + return Number.isFinite(input) ? input : undefined + if (typeof input === "string") { + const trimmed = input.trim() + if (!trimmed) return "" + const numericPattern = /^-?(?:\d+|\d*\.\d+)$/ + return numericPattern.test(trimmed) ? Number(trimmed) : input + } + return undefined + } - const feedbackValueType = - currentFeedback?.valueType ?? - selectedFeedbackOption?.type ?? - "string" - - const isEvaluatorActive = annotationValue - ? "evaluator" in annotationValue - : false - const isFeedbackActive = annotationValue - ? "feedback" in annotationValue - : false - - const feedbackOperatorOptions = ALL_FEEDBACK_OPERATOR_OPTIONS - - const coerceNumericFeedbackValue = ( - input: unknown, - ): string | number | undefined => { - if (typeof input === "number") - return Number.isFinite(input) ? input : undefined - if (typeof input === "string") { - const trimmed = input.trim() - if (!trimmed) return "" - const numericPattern = /^-?(?:\d+|\d*\.\d+)$/ - return numericPattern.test(trimmed) ? Number(trimmed) : input - } - return undefined - } + const parseFeedbackArrayInput = (input: string): any[] | undefined => { + const trimmed = input.trim() + if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return undefined + try { + const parsed = JSON.parse(trimmed) + return Array.isArray(parsed) ? parsed : undefined + } catch { + return undefined + } + } - const parseFeedbackArrayInput = (input: string): any[] | undefined => { - const trimmed = input.trim() - if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) - return undefined - try { - const parsed = JSON.parse(trimmed) - return Array.isArray(parsed) ? parsed : undefined - } catch { - return undefined - } - } + const ensureFeedbackOperator = ( + type: AnnotationFeedbackValueType, + current?: FilterConditions, + ): FilterConditions => { + if (current && ALL_FEEDBACK_OPERATOR_VALUES.has(current)) return current + if (type === "number") { + return NUM_OPS[0]?.value ?? "" + } + return STRING_EQU_OPS[0]?.value ?? "" + } - const ensureFeedbackOperator = ( - type: AnnotationFeedbackValueType, - current?: FilterConditions, - ): FilterConditions => { - if (current && ALL_FEEDBACK_OPERATOR_VALUES.has(current)) - return current - if (type === "number") { - return NUM_OPS[0]?.value ?? "" - } - return STRING_EQU_OPS[0]?.value ?? "" + const handleEvaluatorChange = (value?: string) => { + setAnnotationValue((prev) => { + const base: AnnotationFilterValue = {...(prev ?? {})} + + if (!value) { + // Removing evaluator. Keep feedback as is. Now it means across any evaluator + delete base.evaluator + return Object.keys(base).length ? base : undefined } - const handleEvaluatorChange = (value?: string) => { - setAnnotationValue((prev) => { - const base: AnnotationFilterValue = {...(prev ?? {})} + base.evaluator = value - if (!value) { - // Removing evaluator. Keep feedback as is. Now it means across any evaluator - delete base.evaluator - return Object.keys(base).length ? base : undefined - } + if (base.feedback?.field) { + const allowed = new Set( + annotationFeedbackOptions + .filter((o) => o.evaluatorSlug === value) + .map((o) => o.value), + ) + if (Array.isArray(base.feedback.field)) { + const kept = base.feedback.field.filter((k) => allowed.has(k)) + base.feedback.field = kept[0] ?? undefined + } else if ( + base.feedback.field && + !allowed.has(base.feedback.field) + ) { + base.feedback.field = undefined + } + } - base.evaluator = value + return base + }) + } - if (base.feedback?.field) { - const allowed = new Set( - annotationFeedbackOptions - .filter((o) => o.evaluatorSlug === value) - .map((o) => o.value), - ) - if (Array.isArray(base.feedback.field)) { - const kept = base.feedback.field.filter((k) => - allowed.has(k), - ) - base.feedback.field = kept[0] ?? undefined - } else if ( - base.feedback.field && - !allowed.has(base.feedback.field) - ) { - base.feedback.field = undefined - } - } + const handleFeedbackFieldChange = (value: string | string[]) => { + setAnnotationValue((prev) => { + const base: AnnotationFilterValue = {...(prev ?? {})} + const feedback = {...(base.feedback ?? {})} - return base - }) - } + const nextField: string | string[] = annotationValue?.evaluator + ? Array.isArray(value) + ? value[0] + : value + : value - const handleFeedbackFieldChange = (value: string | string[]) => { - setAnnotationValue((prev) => { - const base: AnnotationFilterValue = {...(prev ?? {})} - const feedback = {...(base.feedback ?? {})} - - const nextField: string | string[] = annotationValue?.evaluator - ? Array.isArray(value) - ? value[0] - : value - : value - - const sampleKey = Array.isArray(nextField) - ? nextField[0] - : nextField - const option = availableFeedbackOptions.find( - (opt) => opt.value === sampleKey, - ) - const nextType = option - ? option.type - : (feedback.valueType ?? "string") - - feedback.field = nextField - feedback.valueType = nextType - feedback.operator = ensureFeedbackOperator( - nextType, - feedback.operator, - ) - feedback.value = nextType === "boolean" ? true : "" - - base.feedback = feedback - return base - }) - } + const sampleKey = Array.isArray(nextField) ? nextField[0] : nextField + const option = availableFeedbackOptions.find( + (opt) => opt.value === sampleKey, + ) + const nextType = option ? option.type : (feedback.valueType ?? "string") - const handleFeedbackOperatorChange = (operator: FilterConditions) => { - setAnnotationValue((prev) => { - const base: AnnotationFilterValue = {...(prev ?? {})} - const feedback = {...(base.feedback ?? {}), operator} + feedback.field = nextField + feedback.valueType = nextType + feedback.operator = ensureFeedbackOperator(nextType, feedback.operator) + feedback.value = nextType === "boolean" ? true : "" - if (NUMERIC_FEEDBACK_OPERATOR_VALUES.has(operator)) { - feedback.valueType = "number" - const currentValue = feedback.value - const coerced = coerceNumericFeedbackValue(currentValue) - feedback.value = coerced === undefined ? "" : coerced - } + base.feedback = feedback + return base + }) + } - base.feedback = feedback - return base - }) - } + const handleFeedbackOperatorChange = (operator: FilterConditions) => { + setAnnotationValue((prev) => { + const base: AnnotationFilterValue = {...(prev ?? {})} + const feedback = {...(base.feedback ?? {}), operator} - const handleFeedbackTypeChange = ( - type: AnnotationFeedbackValueType, - ) => { - setAnnotationValue((prev) => { - const base: AnnotationFilterValue = {...(prev ?? {})} - const feedback = {...(base.feedback ?? {})} - feedback.valueType = type - feedback.operator = ensureFeedbackOperator( - type, - feedback.operator, - ) - feedback.value = type === "boolean" ? true : "" - base.feedback = feedback - return base - }) + if (NUMERIC_FEEDBACK_OPERATOR_VALUES.has(operator)) { + feedback.valueType = "number" + const currentValue = feedback.value + const coerced = coerceNumericFeedbackValue(currentValue) + feedback.value = coerced === undefined ? "" : coerced } - const handleFeedbackValueChange = (raw: string | number | boolean) => { - setAnnotationValue((prev) => { - const base: AnnotationFilterValue = {...(prev ?? {})} - const fb = {...(base.feedback ?? {})} + base.feedback = feedback + return base + }) + } - const type = fb.valueType ?? "string" - let value: any = raw as any + const handleFeedbackTypeChange = (type: AnnotationFeedbackValueType) => { + setAnnotationValue((prev) => { + const base: AnnotationFilterValue = {...(prev ?? {})} + const feedback = {...(base.feedback ?? {})} + feedback.valueType = type + feedback.operator = ensureFeedbackOperator(type, feedback.operator) + feedback.value = type === "boolean" ? true : "" + base.feedback = feedback + return base + }) + } - if (typeof raw === "string") { - const parsedArray = parseFeedbackArrayInput(raw) - if (parsedArray !== undefined) { - value = parsedArray - } - } + const handleFeedbackValueChange = (raw: string | number | boolean) => { + setAnnotationValue((prev) => { + const base: AnnotationFilterValue = {...(prev ?? {})} + const fb = {...(base.feedback ?? {})} - if (!Array.isArray(value)) { - if (type === "number") { - if (typeof raw === "number") { - value = Number.isFinite(raw) ? raw : fb.value - } else { - const coerced = coerceNumericFeedbackValue(raw) - value = coerced === undefined ? "" : coerced - } - } else if (type === "boolean") { - if (typeof raw === "boolean") { - value = raw - } else { - const s = String(raw).trim().toLowerCase() - value = - s === "true" - ? true - : s === "false" - ? false - : undefined - } - } else if (typeof raw === "string") { - value = raw - } else { - value = String(raw) - } - } + const type = fb.valueType ?? "string" + let value: any = raw as any - base.feedback = {...fb, value} - return base - }) + if (typeof raw === "string") { + const parsedArray = parseFeedbackArrayInput(raw) + if (parsedArray !== undefined) { + value = parsedArray + } } - const removeEvaluator = () => { - setAnnotationValue((prev) => { - if (!prev?.feedback) { - onDeleteFilter(idx) - return undefined + if (!Array.isArray(value)) { + if (type === "number") { + if (typeof raw === "number") { + value = Number.isFinite(raw) ? raw : fb.value + } else { + const coerced = coerceNumericFeedbackValue(raw) + value = coerced === undefined ? "" : coerced } - const next = {...(prev ?? {})} - delete next.evaluator - return Object.keys(next).length ? next : undefined - }) + } else if (type === "boolean") { + if (typeof raw === "boolean") { + value = raw + } else { + const s = String(raw).trim().toLowerCase() + value = + s === "true" ? true : s === "false" ? false : undefined + } + } else if (typeof raw === "string") { + value = raw + } else { + value = String(raw) + } } - const removeFeedback = () => { - setAnnotationValue((prev) => { - if (!prev?.feedback) return prev + base.feedback = {...fb, value} + return base + }) + } - if (!prev.evaluator) { - onDeleteFilter(idx) - return undefined - } - const next = {...(prev ?? {})} - delete next.feedback - return Object.keys(next).length ? next : undefined - }) + const removeEvaluator = () => { + setAnnotationValue((prev) => { + if (!prev?.feedback) { + onDeleteFilter(idx) + return undefined } + const next = {...(prev ?? {})} + delete next.evaluator + return Object.keys(next).length ? next : undefined + }) + } - const feedbackValueRaw = (() => { - const raw = currentFeedback?.value - if (Array.isArray(raw)) { - try { - return JSON.stringify(raw) - } catch { - return "" - } - } - if (raw && typeof raw === "object") { - try { - return JSON.stringify(raw) - } catch { - return "" - } - } - if (raw === undefined || raw === null) return "" - if (typeof raw === "string") return raw - if (typeof raw === "number") return String(raw) - if (typeof raw === "boolean") return raw ? "true" : "false" - return "" - })() - - const renderAddFeedbackButton = () => ( - - ) + const removeFeedback = () => { + setAnnotationValue((prev) => { + if (!prev?.feedback) return prev + + if (!prev.evaluator) { + onDeleteFilter(idx) + return undefined + } + const next = {...(prev ?? {})} + delete next.feedback + return Object.keys(next).length ? next : undefined + }) + } - const feedbackFieldValueForSelect: string | string[] | undefined = - (() => { - const f = currentFeedback?.field - if (Array.isArray(f)) return f - return f ?? undefined - })() - - const feedbackOptionsForSelect = (() => { - const options = availableFeedbackOptions.map((option) => ({ - label: option.label, - value: option.value, + const feedbackValueRaw = (() => { + const raw = currentFeedback?.value + if (Array.isArray(raw)) { + try { + return JSON.stringify(raw) + } catch { + return "" + } + } + if (raw && typeof raw === "object") { + try { + return JSON.stringify(raw) + } catch { + return "" + } + } + if (raw === undefined || raw === null) return "" + if (typeof raw === "string") return raw + if (typeof raw === "number") return String(raw) + if (typeof raw === "boolean") return raw ? "true" : "false" + return "" + })() + + const renderAddFeedbackButton = () => ( + + ) + + const feedbackFieldValueForSelect: string | string[] | undefined = (() => { + const f = currentFeedback?.field + if (Array.isArray(f)) return f + return f ?? undefined + })() + + const feedbackOptionsForSelect = (() => { + const options = availableFeedbackOptions.map((option) => ({ + label: option.label, + value: option.value, + })) + const known = new Set(options.map((o) => o.value)) + + // Keep already-selected custom keys visible with a label. + const selectedFields = Array.isArray(feedbackFieldValueForSelect) + ? feedbackFieldValueForSelect + : feedbackFieldValueForSelect + ? [feedbackFieldValueForSelect] + : [] + for (const selected of selectedFields) { + if (selected && !known.has(selected)) { + options.push({label: selected, value: selected}) + known.add(selected) + } + } - // Surface the text the user is typing as a selectable option, so - // evaluators without an output schema can still be given a - // feedback name. Enter or click commits it. - const typed = (feedbackFieldSearch[idx] ?? "").trim() - if (typed && !known.has(typed)) { - options.unshift({label: `${typed} (custom)`, value: typed}) - } + // Surface the text the user is typing as a selectable option, so + // evaluators without an output schema can still be given a + // feedback name. Enter or click commits it. + const typed = (feedbackFieldSearch[idx] ?? "").trim() + if (typed && !known.has(typed)) { + options.unshift({label: `${typed} (custom)`, value: typed}) + } - return options - })() - - return ( - - - {idx === 0 ? "Where" : "And"} - - - -
- - setActiveFieldDropdown(open ? idx : null) - } - menu={{ - items: buildFieldMenuItems( - columns, - (value, labelFromGroup) => - handleFieldSelection( - value, - idx, - labelFromGroup, - ), - "root", - [], - fieldDropdownSubmenuClass, - disabledFieldOptionsForMenu ?? - EMPTY_DISABLED_OPTIONS, + return options + })() + + return ( + + + {idx === 0 ? "Where" : "And"} + + + +
+ + setActiveFieldDropdown(open ? idx : null) + } + menu={{ + items: buildFieldMenuItems( + columns, + (value, labelFromGroup) => + handleFieldSelection( + value, + idx, + labelFromGroup, ), - onClick: ({key}) => - handleFieldSelection(String(key), idx), - }} - getPopupContainer={(t) => getWithinPopover(t)} - > - - - - {showKey && - (field!.keyInput!.kind === "select" ? ( - (() => { - const options = field!.keyInput! - .options as SelectOption[] - const optionValues = - collectOptionValues(options) - const currentSearch = - keySearchTerms[idx] ?? "" - const normalizedSearch = - normalizeAttributeSearch(currentSearch) - const additionalNodes: NonNullable< - TreeSelectProps["treeData"] - > = [] - const keyValue = - item.key === undefined || - item.key === null - ? undefined - : String(item.key) - if ( - normalizedSearch && - !optionValues.has( - normalizedSearch.value, - ) - ) { - additionalNodes.push( - buildCustomTreeNode( - normalizedSearch.value, - normalizedSearch.pathLabel, - ), - ) - } - if ( - keyValue && - !optionValues.has(keyValue) && - !additionalNodes.some( - (node) => node.value === keyValue, - ) - ) { - additionalNodes.push( - buildCustomTreeNode( - keyValue, - valueToPathLabel(keyValue), - ), - ) + "root", + [], + fieldDropdownSubmenuClass, + disabledFieldOptionsForMenu ?? + EMPTY_DISABLED_OPTIONS, + ), + onClick: ({key}) => + handleFieldSelection(String(key), idx), + }} + getPopupContainer={(t) => getWithinPopover(t)} + > + + + + {showKey && + (field!.keyInput!.kind === "select" ? ( + (() => { + const options = field!.keyInput! + .options as SelectOption[] + const optionValues = collectOptionValues(options) + const currentSearch = keySearchTerms[idx] ?? "" + const normalizedSearch = + normalizeAttributeSearch(currentSearch) + const additionalNodes: NonNullable< + TreeSelectProps["treeData"] + > = [] + const keyValue = + item.key === undefined || item.key === null + ? undefined + : String(item.key) + if ( + normalizedSearch && + !optionValues.has(normalizedSearch.value) + ) { + additionalNodes.push( + buildCustomTreeNode( + normalizedSearch.value, + normalizedSearch.pathLabel, + ), + ) + } + if ( + keyValue && + !optionValues.has(keyValue) && + !additionalNodes.some( + (node) => node.value === keyValue, + ) + ) { + additionalNodes.push( + buildCustomTreeNode( + keyValue, + valueToPathLabel(keyValue), + ), + ) + } + const baseTreeData = mapToTreeData(options) + const treeData = + additionalNodes.length > 0 + ? [...additionalNodes, ...baseTreeData] + : baseTreeData + const expandedKeys = collectTreeKeys(treeData) + return ( + + getWithinPopover(t) } - const baseTreeData = mapToTreeData(options) - const treeData = - additionalNodes.length > 0 - ? [ - ...additionalNodes, - ...baseTreeData, - ] - : baseTreeData - const expandedKeys = - collectTreeKeys(treeData) - return ( - - getWithinPopover(t) - } - value={ - item.key && item.key !== "" - ? (item.key as - | string - | number) - : undefined - } - onChange={(v) => - onFilterChange({ - columnName: "key", - value: v == null ? "" : v, - idx, - }) - } - onSearch={(searchValue) => - setKeySearchTerms((prev) => { - const trimmed = - searchValue.trim() - if (!trimmed) { - if (!(idx in prev)) - return prev - const next = {...prev} - delete next[idx] - return next - } - return { - ...prev, - [idx]: trimmed, - } - }) - } - onDropdownVisibleChange={(open) => { - if (!open) { - setKeySearchTerms( - (prev) => { - if (!(idx in prev)) - return prev - const next = { - ...prev, - } - delete next[idx] - return next - }, - ) - } - }} - placeholder={keyPlaceholder} - showSearch - treeNodeFilterProp="title" - treeDefaultExpandAll - treeExpandedKeys={expandedKeys} - onTreeExpand={noopTreeExpand} - treeLine={{showLeafIcon: false}} - disabled={item.isPermanent} - filterTreeNode={(input, node) => { - const title = - typeof node?.title === - "string" - ? node.title - : String( - node?.title ?? "", - ) - const value = String( - node?.value ?? "", - ) - const pathLabel = - typeof (node as any) - ?.pathLabel === "string" - ? ((node as any) - .pathLabel as string) - : "" - const search = input - .trim() - .toLowerCase() - return ( - title - .toLowerCase() - .includes(search) || - value - .toLowerCase() - .includes(search) || - pathLabel - .toLowerCase() - .includes(search) - ) - }} - /> - ) - })() - ) : ( - + onChange={(v) => onFilterChange({ columnName: "key", - value: e.target.value, + value: v == null ? "" : v, idx, }) } + onSearch={(searchValue) => + setKeySearchTerms((prev) => { + const trimmed = searchValue.trim() + if (!trimmed) { + if (!(idx in prev)) return prev + const next = {...prev} + delete next[idx] + return next + } + return { + ...prev, + [idx]: trimmed, + } + }) + } + onDropdownVisibleChange={(open) => { + if (!open) { + setKeySearchTerms((prev) => { + if (!(idx in prev)) return prev + const next = { + ...prev, + } + delete next[idx] + return next + }) + } + }} + placeholder={keyPlaceholder} + showSearch + treeNodeFilterProp="title" + treeDefaultExpandAll + treeExpandedKeys={expandedKeys} + onTreeExpand={noopTreeExpand} + treeLine={{showLeafIcon: false}} disabled={item.isPermanent} + filterTreeNode={(input, node) => { + const title = + typeof node?.title === "string" + ? node.title + : String(node?.title ?? "") + const value = String(node?.value ?? "") + const pathLabel = + typeof (node as any)?.pathLabel === + "string" + ? ((node as any) + .pathLabel as string) + : "" + const search = input + .trim() + .toLowerCase() + return ( + title + .toLowerCase() + .includes(search) || + value + .toLowerCase() + .includes(search) || + pathLabel + .toLowerCase() + .includes(search) + ) + }} /> - ))} - - {isAnnotationFieldSelected && ( - - That - - )} - - {!singleOperator && ( + ) + })() + ) : ( + + onFilterChange({ + columnName: "key", + value: e.target.value, + idx, + }) + } + disabled={item.isPermanent} + /> + ))} + + {isAnnotationFieldSelected && ( + + That + + )} + + {!singleOperator && ( + - !label.value ? "Condition" : label.label - } - suffixIcon={} + className="w-[220px] flex-1" + showSearch + placeholder="Evaluator" + value={annotationValue?.evaluator} + options={annotationEvaluatorOptions} onChange={(value) => - onFilterChange({ - columnName: "operator", - value, - idx, - }) + handleEvaluatorChange(value) } - className="w-[140px]" - popupMatchSelectWidth={140} - value={operatorValue} - options={operatorOptions} - disabled={item.isPermanent} + allowClear + suffixIcon={} + optionFilterProp="label" getPopupContainer={(t) => getWithinPopover(t)} styles={{ popup: { root: { - ...dropdownPanelStyle, + ...(dropdownPanelStyle || {}), }, }, }} /> - )} - {isAnnotationFieldSelected ? ( - isEvaluatorActive ? ( -
- } + onClick={removeEvaluator} /> - ) : valueAs === "tags" ? ( - + ) : valueAs === "tags" ? ( + + onFilterChange({ + columnName: "value", + value: v, + idx, + }) + } + placeholder={valuePlaceholder} + suffixIcon={} + popupMatchSelectWidth + disabled={item.isPermanent} + status={valueHasError ? "error" : undefined} + getPopupContainer={(t) => getWithinPopover(t)} + styles={{ + popup: { + root: { + ...(dropdownPanelStyle || {}), + }, + }, + }} + /> + ) : valueAs === "range" ? ( + + onFilterChange({ + columnName: "value", + value: e.target.value, + idx, + }) + } + disabled={item.isPermanent} + className="flex-1 min-w-[160px] w-full" + status={valueHasError ? "error" : undefined} + /> + ) : ( + + onFilterChange({ + columnName: "value", + value: e.target.value, + idx, + }) + } + disabled={item.isPermanent} + className="flex-1 min-w-[160px] w-full" + status={valueHasError ? "error" : undefined} + /> + )} + + {field?.optionKey === "custom" && ( + + setFeedbackFieldSearch((prev) => ({ + ...prev, + [idx]: searchValue, + })) + } + onChange={(val) => { + handleFeedbackFieldChange( + val as string | string[], + ) + setFeedbackFieldSearch((prev) => ({ + ...prev, + [idx]: "", + })) + }} + onOpenChange={(open) => { + if (!open) + setFeedbackFieldSearch((prev) => ({ + ...prev, + [idx]: "", + })) + }} + suffixIcon={} + optionFilterProp="label" + getPopupContainer={(t) => getWithinPopover(t)} + styles={{ + popup: { + root: { + ...(dropdownPanelStyle || {}), }, - }} - /> - ) : valueAs === "select" ? ( + }, + }} + /> + - onFilterChange({ - columnName: "value", - value: v, - idx, - }) - } - placeholder={valuePlaceholder} + className="flex-1" + value={currentFeedback?.value ?? true} + options={[ + {label: "true", value: true}, + {label: "false", value: false}, + ]} + onChange={handleFeedbackValueChange} suffixIcon={} - popupMatchSelectWidth - disabled={item.isPermanent} - status={valueHasError ? "error" : undefined} getPopupContainer={(t) => getWithinPopover(t)} styles={{ popup: { @@ -1676,285 +1806,131 @@ const Filters: React.FC = ({ }, }} /> - ) : valueAs === "range" ? ( - - onFilterChange({ - columnName: "value", - value: e.target.value, - idx, - }) - } - disabled={item.isPermanent} - className="flex-1 min-w-[160px] w-full" - status={valueHasError ? "error" : undefined} - /> ) : ( - onFilterChange({ - columnName: "value", - value: e.target.value, - idx, - }) + handleFeedbackValueChange(e.target.value) } - disabled={item.isPermanent} - className="flex-1 min-w-[160px] w-full" - status={valueHasError ? "error" : undefined} /> )} - - {field?.optionKey === "custom" && ( - + handleFeedbackTypeChange( + value as AnnotationFeedbackValueType, + ) + } + suffixIcon={} + getPopupContainer={(t) => getWithinPopover(t)} + styles={{ + popup: { + root: { + ...(dropdownPanelStyle || {}), }, - }} - /> - )} + }, + }} + /> - {!item.isPermanent && - nonPermanentFilterCount > 1 && - !( - isAnnotationFieldSelected && - (isEvaluatorActive || isFeedbackActive) - ) && ( -
- {(isEvaluatorActive || isFeedbackActive) && - (isFeedbackActive ? ( -
- - Feedback - - } - getPopupContainer={(t) => - getWithinPopover(t) - } - styles={{ - popup: { - root: { - ...(dropdownPanelStyle || {}), - }, - }, - }} - /> - {feedbackValueType === "boolean" ? ( - - handleFeedbackValueChange( - e.target.value, - ) - } - /> - )} - + + +
+ {matchLabel ? ( +
+ + {matchLabel} + +
+ ) : ( + + )} + +
+ + {showPreview ? ( +
+ +
+ ) : null} + + {/* Shared entity commit modal — version transition + filtering/windowing + diff + message. Name editing and the save-mode/new-variant flow are + intentionally omitted (the drawer owns the name; queries are + single-variant). */} + setCommitOpen(false)} + onSuccess={onCommitSuccess} + successMessage="Query updated" + /> + + ) +} + +export default QueryRegistryDrawer diff --git a/web/oss/src/components/QueryRegistry/Drawer/QueryTracePreview.tsx b/web/oss/src/components/QueryRegistry/Drawer/QueryTracePreview.tsx new file mode 100644 index 0000000000..e33e4d8536 --- /dev/null +++ b/web/oss/src/components/QueryRegistry/Drawer/QueryTracePreview.tsx @@ -0,0 +1,203 @@ +import {type Key, useEffect, useMemo, useState} from "react" + +import {queryMatchingTraces} from "@agenta/entities/query" +import { + InfiniteVirtualTableFeatureShell, + type TableFeaturePagination, + type TableScopeConfig, +} from "@agenta/ui/table" +import {Alert} from "antd" +import type {ColumnsType} from "antd/es/table" +import {useStore} from "jotai" + +import {getObservabilityColumns} from "@/oss/components/pages/observability/assets/getObservabilityColumns" +import {attachAnnotationsToTraces} from "@/oss/lib/hooks/useAnnotations/assets/helpers" +import {transformApiData} from "@/oss/lib/hooks/useAnnotations/assets/transformer" +import type {AnnotationDto} from "@/oss/lib/hooks/useAnnotations/types" +import {queryAllAnnotations} from "@/oss/services/annotations/api" +import {TraceSpanNode} from "@/oss/services/tracing/types" +import {getOrgValues} from "@/oss/state/org" + +export interface QueryTracePreviewProps { + projectId?: string | null + /** + * Structured tracing filter payload (`toFilteringPayload(filters)`). + * `undefined` matches every trace; the parent decides whether to render the + * preview at all in that case. + */ + filtering: unknown + /** Max traces to fetch for the preview (no pagination — this is a peek). */ + limit?: number +} + +/** + * The InfiniteVirtualTable row constraint (`InfiniteTableRowBase`) requires a + * required `key` and an index signature, which the observability `TraceSpanNode` + * doesn't declare. ObservabilityTable lives with the resulting tsc mismatch; + * here we widen to a constraint-satisfying row so this new file stays clean. + */ +type PreviewRow = TraceSpanNode & {key: Key; [extra: string]: unknown} + +interface TreeNode { + invocationIds?: {trace_id?: string; span_id?: string} | null + aggregatedEvaluatorMetrics?: Record | null + children?: TreeNode[] | null +} + +/** + * Walk the trace tree for the `{trace_id, span_id}` pairs that key annotation + * lookups. Mirrors `collectInvocationLinks` in + * `state/newObservability/atoms/queries.ts`. + */ +const collectInvocationLinks = (nodes: TreeNode[]) => { + const links: {trace_id: string; span_id: string}[] = [] + const seen = new Set() + const visit = (node?: TreeNode) => { + if (!node) return + const ids = node.invocationIds + if (ids?.trace_id && ids?.span_id) { + const key = `${ids.trace_id}:${ids.span_id}` + if (!seen.has(key)) { + seen.add(key) + links.push({trace_id: ids.trace_id, span_id: ids.span_id}) + } + } + node.children?.forEach(visit) + } + nodes.forEach(visit) + return links +} + +/** + * Gather evaluator slugs from each node's `aggregatedEvaluatorMetrics` so + * `getObservabilityColumns` renders the matching annotation columns. Mirrors + * `collectEvaluatorSlugsFromTraces` in `ObservabilityTable`. + */ +const collectEvaluatorSlugs = (nodes: TreeNode[]) => { + const slugs = new Set() + const visit = (node?: TreeNode) => { + if (!node) return + const metrics = node.aggregatedEvaluatorMetrics + if (metrics && typeof metrics === "object") { + Object.keys(metrics).forEach((slug) => slug && slugs.add(slug)) + } + node.children?.forEach(visit) + } + nodes.forEach(visit) + return Array.from(slugs) +} + +/** + * Read-only preview of the traces matching a query's filter, rendered with the + * exact observability table (columns + InfiniteVirtualTable shell) so it stays + * visually identical to the Observability page — including the annotation + * (evaluator-metrics) columns, which are loaded by fetching annotations for the + * matching traces and merging them with the same `attachAnnotationsToTraces` + * helper observability uses. Fetches a single page; this is a peek, not a browser. + */ +const QueryTracePreview = ({projectId, filtering, limit = 50}: QueryTracePreviewProps) => { + const store = useStore() + + const [traces, setTraces] = useState([]) + const [evaluatorSlugs, setEvaluatorSlugs] = useState([]) + const [status, setStatus] = useState<"loading" | "done" | "error">("loading") + + const columns = useMemo( + () => getObservabilityColumns({evaluatorSlugs}) as unknown as ColumnsType, + [evaluatorSlugs], + ) + + useEffect(() => { + if (!projectId) return + let cancelled = false + setStatus("loading") + ;(async () => { + try { + const rawTraces = await queryMatchingTraces({projectId, filtering, limit}) + const links = collectInvocationLinks(rawTraces as unknown as TreeNode[]) + let annotations: AnnotationDto[] = [] + if (links.length) { + const {selectedOrg} = getOrgValues() + const members = selectedOrg?.default_workspace?.members || [] + const res = await queryAllAnnotations({annotation: {links}}) + annotations = + res.annotations?.map((a) => + transformApiData({data: a, members}), + ) ?? [] + } + // Same merge observability uses — attaches `annotations` and + // `aggregatedEvaluatorMetrics` onto each matching node. + const enriched = attachAnnotationsToTraces( + rawTraces as never[], + annotations, + ) as unknown as PreviewRow[] + if (cancelled) return + setTraces(enriched) + setEvaluatorSlugs(collectEvaluatorSlugs(enriched as unknown as TreeNode[])) + setStatus("done") + } catch { + if (cancelled) return + setTraces([]) + setEvaluatorSlugs([]) + setStatus("error") + } + })() + return () => { + cancelled = true + } + }, [projectId, filtering, limit]) + + const tableScope: TableScopeConfig = useMemo( + () => ({scopeId: "query-trace-preview", pageSize: limit, enableInfiniteScroll: false}), + [limit], + ) + + const pagination: TableFeaturePagination = useMemo( + () => ({ + rows: traces, + loadNextPage: () => undefined, + resetPages: () => undefined, + paginationInfo: { + hasMore: false, + nextCursor: null, + nextOffset: null, + isFetching: status === "loading", + totalCount: traces.length, + }, + }), + [traces, status], + ) + + if (status === "error") { + return ( + + ) + } + + return ( + + tableScope={tableScope} + columns={columns} + rowKey={(record) => record.span_id || record.key} + pagination={pagination} + resizableColumns + enableExport={false} + useSettingsDropdown={false} + store={store} + className="flex-1 min-h-0 [&_.ant-table-thead_tr:nth-child(2)]:hidden" + tableProps={{ + bordered: true, + loading: status === "loading", + sticky: true, + size: "small", + }} + /> + ) +} + +export default QueryTracePreview diff --git a/web/oss/src/components/QueryRegistry/Table/QueryRegistryTable.tsx b/web/oss/src/components/QueryRegistry/Table/QueryRegistryTable.tsx new file mode 100644 index 0000000000..0f61fb9a04 --- /dev/null +++ b/web/oss/src/components/QueryRegistry/Table/QueryRegistryTable.tsx @@ -0,0 +1,279 @@ +import type {ReactNode} from "react" +import {useCallback, useEffect, useMemo, useState} from "react" + +import { + querySimpleQueries, + queryRevisionsForQueries, + type QueryRevisionSummary, +} from "@agenta/entities/query" +import {projectIdAtom} from "@agenta/shared/state" +import {InfiniteVirtualTableFeatureShell, useTableManager} from "@agenta/ui/table" +import {useAtomValue} from "jotai" + +import getFilterColumns from "@/oss/components/pages/observability/assets/getFilterColumns" + +import type {QueryRegistryStatus} from "../store/queryRegistryFilterAtoms" +import type {QueryRegistryRow} from "../store/queryRegistryStore" +import { + getQueryRegistryTableState, + queryRegistryRevisionsRefreshAtom, +} from "../store/queryRegistryStore" + +import { + buildFieldLabelMap, + createQueryRegistryColumns, + type QueryColumnActions, +} from "./assets/queryRegistryColumns" + +interface QueryRegistryTableProps { + actions: QueryColumnActions + onRowClick?: (record: QueryRegistryRow) => void + filters?: ReactNode + primaryActions?: ReactNode + /** Rendered by the antd Table when there are no rows (post-load). */ + emptyState?: ReactNode + searchDeps?: unknown[] + /** Active vs archived view — selects the store and the restore-only actions. */ + mode?: QueryRegistryStatus +} + +const isRevisionRow = (row: QueryRegistryRow) => + Boolean(row.__isRevisionChild || row.__isArchivedRevision) + +const QueryRegistryTable = ({ + actions, + onRowClick, + filters, + primaryActions, + emptyState, + searchDeps = [], + mode = "active", +}: QueryRegistryTableProps) => { + const isArchived = mode === "archived" + const projectId = useAtomValue(projectIdAtom) + const datasetStore = getQueryRegistryTableState(mode).store + const revisionsRefresh = useAtomValue(queryRegistryRevisionsRefreshAtom) + + // Active-tab version history per query (batch-fetched, active revisions only): + // drives the head-version badge + the expandable child rows. + const [revisionsByQueryId, setRevisionsByQueryId] = useState< + Record + >({}) + // Archived-tab top-level rows for individually-archived revisions (of queries + // that are themselves still active). + const [archivedRevisionRows, setArchivedRevisionRows] = useState([]) + const [expandedKeys, setExpandedKeys] = useState([]) + + // Drop caches on invalidation (commit/archive/restore) so the relevant fetch re-runs. + useEffect(() => { + if (revisionsRefresh === 0) return + setRevisionsByQueryId({}) + setArchivedRevisionRows([]) + }, [revisionsRefresh]) + + const handleRowClick = useCallback( + (record: QueryRegistryRow) => { + if (isRevisionRow(record)) return + onRowClick?.(record) + }, + [onRowClick], + ) + + const table = useTableManager({ + datasetStore: datasetStore as never, + scopeId: isArchived ? "query-registry-archived" : "query-registry", + pageSize: 50, + onRowClick: handleRowClick, + searchDeps, + columnVisibilityStorageKey: isArchived + ? "agenta:query-registry-archived:column-visibility" + : "agenta:query-registry:column-visibility", + }) + + const rows = table.shellProps.pagination?.rows ?? [] + const headQueryIds = useMemo( + () => rows.filter((row) => !row.__isSkeleton && row.queryId).map((row) => row.queryId), + [rows], + ) + + // ACTIVE tab: batch-fetch each visible query's active revision history. + useEffect(() => { + if (isArchived || !projectId) return + const missing = headQueryIds.filter((id) => !(id in revisionsByQueryId)) + if (!missing.length) return + let cancelled = false + queryRevisionsForQueries({projectId, queryIds: missing}) + .then((revs) => { + if (cancelled) return + const grouped: Record = {} + for (const id of missing) grouped[id] = [] + for (const rev of revs) (grouped[rev.queryId] ??= []).push(rev) + setRevisionsByQueryId((prev) => ({...prev, ...grouped})) + }) + .catch(() => { + if (cancelled) return + setRevisionsByQueryId((prev) => ({ + ...prev, + ...Object.fromEntries(missing.map((id) => [id, prev[id] ?? []])), + })) + }) + return () => { + cancelled = true + } + }, [isArchived, projectId, headQueryIds, revisionsByQueryId]) + + // ARCHIVED tab: surface individually-archived revisions (of still-active queries) + // as their own rows, alongside the archived queries the store already provides. + useEffect(() => { + if (!isArchived || !projectId) return + let cancelled = false + ;(async () => { + try { + const response = await querySimpleQueries({projectId, includeArchived: true}) + const all = response.queries ?? [] + const activeIds = all + .filter((q) => !q.deleted_at && q.id) + .map((q) => q.id as string) + const nameById = new Map(all.map((q) => [q.id ?? "", q.name ?? q.slug ?? ""])) + if (!activeIds.length) { + if (!cancelled) setArchivedRevisionRows([]) + return + } + const revs = await queryRevisionsForQueries({ + projectId, + queryIds: activeIds, + includeArchived: true, + }) + if (cancelled) return + const archivedRows: QueryRegistryRow[] = revs + .filter((rev) => rev.deletedAt && Number(rev.version ?? 0) > 0) + .map((rev) => ({ + key: `arch-rev:${rev.revisionId}`, + queryId: rev.queryId, + variantId: null, + revisionId: rev.revisionId, + name: nameById.get(rev.queryId) || "Query", + slug: null, + filtering: rev.filtering, + windowing: null, + createdAt: rev.createdAt, + createdById: rev.createdById, + version: rev.version, + message: rev.message, + __isArchivedRevision: true, + })) + setArchivedRevisionRows(archivedRows) + } catch { + if (!cancelled) setArchivedRevisionRows([]) + } + })() + return () => { + cancelled = true + } + }, [isArchived, projectId, revisionsRefresh]) + + const handleExpand = useCallback((expanded: boolean, record: QueryRegistryRow) => { + setExpandedKeys((prev) => + expanded ? [...prev, record.key] : prev.filter((k) => k !== record.key), + ) + }, []) + + const expandState = useMemo( + () => ({expandedRowKeys: expandedKeys, handleExpand}), + [expandedKeys, handleExpand], + ) + + const fieldLabels = useMemo(() => buildFieldLabelMap(getFilterColumns()), []) + const columns = useMemo( + // No expand toggle in the archived view — its rows are leaf items. + () => + createQueryRegistryColumns( + actions, + fieldLabels, + isArchived, + isArchived ? undefined : expandState, + ), + [actions, fieldLabels, isArchived, expandState], + ) + + const dataSource = useMemo(() => { + if (isArchived) { + // Archived queries (from the store) + archived revisions, flat. + return [...rows, ...archivedRevisionRows] + } + // Active: enrich each head row with its head version + revision children. + return rows.map((row) => { + if (row.__isSkeleton || !row.queryId) return row + const revs = (revisionsByQueryId[row.queryId] ?? []).filter( + (rev) => Number(rev.version ?? 0) > 0, + ) + if (!revs.length) return row + const head = revs[0] + const children: QueryRegistryRow[] = revs.slice(1).map((rev) => ({ + key: rev.revisionId || `${row.queryId}:${rev.version}`, + queryId: row.queryId, + variantId: row.variantId, + revisionId: rev.revisionId, + name: row.name, + slug: row.slug, + filtering: rev.filtering, + windowing: null, + createdAt: rev.createdAt, + createdById: rev.createdById, + version: rev.version, + message: rev.message, + __isRevisionChild: true, + })) + return { + ...row, + version: head.version ?? null, + message: head.message ?? null, + ...(children.length ? {children} : {}), + } + }) + }, [isArchived, rows, archivedRevisionRows, revisionsByQueryId]) + + const treeExpandable = useMemo( + () => ({ + expandedRowKeys: expandedKeys, + expandIcon: () => null as unknown as null, + rowExpandable: (record: QueryRegistryRow) => + !isArchived && !isRevisionRow(record) && !record.__isSkeleton, + }), + [expandedKeys, isArchived], + ) + + // Revision rows aren't selectable — hide their checkboxes. + const rowSelection = useMemo( + () => + ({ + ...table.shellProps.rowSelection, + getCheckboxProps: (record: QueryRegistryRow) => ({ + disabled: Boolean(record.__isSkeleton || isRevisionRow(record)), + style: isRevisionRow(record) ? {display: "none"} : undefined, + }), + }) as typeof table.shellProps.rowSelection, + [table.shellProps.rowSelection], + ) + + return ( + + {...table.shellProps} + useSettingsDropdown + columns={columns} + filters={filters} + primaryActions={primaryActions} + className="flex-1 min-h-0" + autoHeight + dataSource={dataSource} + rowSelection={rowSelection} + tableProps={{ + ...table.shellProps.tableProps, + expandable: treeExpandable, + ...(emptyState ? {locale: {emptyText: emptyState}} : {}), + }} + /> + ) +} + +export default QueryRegistryTable diff --git a/web/oss/src/components/QueryRegistry/Table/assets/queryRegistryColumns.tsx b/web/oss/src/components/QueryRegistry/Table/assets/queryRegistryColumns.tsx new file mode 100644 index 0000000000..df0086764a --- /dev/null +++ b/web/oss/src/components/QueryRegistry/Table/assets/queryRegistryColumns.tsx @@ -0,0 +1,383 @@ +import {UserAuthorLabel} from "@agenta/entities/shared/user" +import {SkeletonLine, createStandardColumns, formatDateCell} from "@agenta/ui/table" +import { + ArchiveIcon, + ArrowCounterClockwise, + CopySimple, + Eye, + MinusCircle, + PencilSimple, + PlusCircle, +} from "@phosphor-icons/react" +import {Popover, Tag, Typography} from "antd" + +import type {QueryRegistryRow} from "../../store/queryRegistryStore" + +/** Controlled expand state for the version-history rows (mirrors the registry table). */ +export interface QueryExpandState { + expandedRowKeys: string[] + handleExpand: (expanded: boolean, record: QueryRegistryRow) => void +} + +const {Text} = Typography + +interface FilterLeaf { + field: string + key?: string + operator?: string + value?: unknown +} + +/** + * Flatten a (possibly nested AND/OR) filtering tree into its leaf conditions. + * Drives the filter-summary cell (design D1): the first chips render inline, the + * full set in a popover. + */ +export function flattenConditions(filtering: unknown): FilterLeaf[] { + if (!filtering || typeof filtering !== "object") return [] + const node = filtering as { + conditions?: unknown[] + field?: unknown + key?: unknown + operator?: unknown + value?: unknown + } + if (!Array.isArray(node.conditions)) { + if (typeof node.field !== "string") return [] + return [ + { + field: node.field, + key: typeof node.key === "string" ? node.key : undefined, + operator: typeof node.operator === "string" ? node.operator : undefined, + value: node.value, + }, + ] + } + return node.conditions.flatMap(flattenConditions) +} + +/** field → friendly label + value-option labels, derived from the Filters config. */ +export type FieldLabelMap = Map}> + +interface FilterColumnNode { + kind?: string + label?: string + displayLabel?: string + field?: string + value?: string + children?: FilterColumnNode[] + valueInput?: {options?: {label: string; value: unknown}[]} +} + +/** + * Walk the Filters menu tree and build a field → label map so the chips read + * "Trace Type is Invocation" instead of the raw "trace_type is invocation" + * (design D1: reuse the Filters component's labels). + */ +export function buildFieldLabelMap(nodes: readonly unknown[]): FieldLabelMap { + const map: FieldLabelMap = new Map() + const walk = (items?: readonly unknown[]) => { + for (const raw of items ?? []) { + const node = raw as FilterColumnNode + if (node.children?.length) { + walk(node.children) + continue + } + const field = node.field ?? node.value + if (!field) continue + const values = new Map() + for (const option of node.valueInput?.options ?? []) { + values.set(String(option.value), option.label) + } + map.set(field, {label: node.displayLabel ?? node.label ?? field, values}) + } + } + walk(nodes) + return map +} + +/** Compact, human label for one condition (e.g. `Trace Type is Invocation`). */ +function conditionLabel({field, key, operator, value}: FilterLeaf, labels?: FieldLabelMap): string { + const info = labels?.get(field) + const fieldLabel = info?.label ?? field + const lhs = key ? `${fieldLabel}.${key}` : fieldLabel + const op = operator ? ` ${operator}` : "" + let rhs = "" + if (value !== undefined && value !== null) { + const toLabel = (v: unknown) => info?.values.get(String(v)) ?? String(v) + rhs = Array.isArray(value) ? ` ${value.map(toLabel).join(", ")}` : ` ${toLabel(value)}` + } + return `${lhs}${op}${rhs}`.trim() +} + +export interface QueryColumnActions { + handleOpen?: (record: QueryRegistryRow) => void + handleEdit?: (record: QueryRegistryRow) => void + handleDuplicate?: (record: QueryRegistryRow) => void + handleArchive?: (record: QueryRegistryRow) => void + handleRestore?: (record: QueryRegistryRow) => void +} + +/** + * Row actions differ by tab: the Active tab edits/duplicates/archives; the + * Archived tab only restores (editing an archived query is meaningless — it would + * commit a revision on a soft-deleted artifact). + */ +/** Active expand child rows carry no parent-level actions (Open/Edit/Duplicate). */ +const isRevisionRow = (record: QueryRegistryRow) => Boolean(record.__isRevisionChild) + +function buildActionItems(actions: QueryColumnActions, isArchived: boolean) { + if (isArchived) { + return [ + { + key: "restore", + label: "Restore", + icon: , + hidden: isRevisionRow, + onClick: (record: QueryRegistryRow) => actions.handleRestore?.(record), + }, + ] + } + return [ + { + key: "open", + label: "Open details", + icon: , + hidden: isRevisionRow, + onClick: (record: QueryRegistryRow) => actions.handleOpen?.(record), + }, + { + key: "edit", + label: "Edit", + icon: , + hidden: isRevisionRow, + onClick: (record: QueryRegistryRow) => actions.handleEdit?.(record), + }, + { + key: "duplicate", + label: "Duplicate", + icon: , + hidden: isRevisionRow, + onClick: (record: QueryRegistryRow) => actions.handleDuplicate?.(record), + }, + {type: "divider" as const, hidden: isRevisionRow}, + { + // Visible on revision rows too: the parent archives the whole query, a + // revision row archives just that version (which moves it to Archived). + key: "archive", + label: "Archive", + icon: , + danger: true, + onClick: (record: QueryRegistryRow) => actions.handleArchive?.(record), + }, + ] +} + +export function createQueryRegistryColumns( + actions: QueryColumnActions, + labels?: FieldLabelMap, + isArchived = false, + expandState?: QueryExpandState, +) { + return createStandardColumns([ + { + type: "text", + key: "name", + title: "Name", + width: 280, + fixed: "left", + columnVisibilityLocked: true, + render: (_value, record) => { + if (record.__isSkeleton) return + // Revision (child) row in the active expand: indent + version badge. + if (record.__isRevisionChild) { + return ( +
+ {record.name} + {record.version ? ( + v{record.version} + ) : null} +
+ ) + } + // Archived-revision row (archived tab, top-level): name + version + + // an Archived tag, aligned with the non-toggle rows. + if (record.__isArchivedRevision) { + return ( +
+ + + {record.name} + + {record.version ? ( + v{record.version} + ) : null} + Archived +
+ ) + } + // Head (parent) row: it IS the latest revision — show its version + // badge, plus the expand toggle when there are earlier versions. + // A (not
+ } + > + + {shown.map((condition, index) => ( + + {conditionLabel(condition, labels)} + + ))} + {rest > 0 ? ( + + +{rest} more + + ) : null} + + + ) + }, + }, + { + type: "text", + key: "createdAt", + title: "Created on", + width: 160, + render: (_value, record) => { + if (record.__isSkeleton) return + return ( +
+ {formatDateCell(record.createdAt)} +
+ ) + }, + }, + { + type: "text", + key: "createdBy", + title: "Created by", + width: 180, + render: (_value, record) => { + if (record.__isSkeleton) return + if (!record.createdById) { + return ( +
+ + — + +
+ ) + } + return ( +
+ +
+ ) + }, + }, + { + type: "text", + key: "message", + title: "Commit message", + width: 220, + render: (_value, record) => { + if (record.__isSkeleton) return + if (!record.message) { + return ( +
+ + — + +
+ ) + } + return ( +
+ + {record.message} + +
+ ) + }, + }, + { + type: "actions", + width: 48, + maxWidth: 48, + items: buildActionItems(actions, isArchived), + getRecordId: (record: QueryRegistryRow) => record.queryId, + }, + ]) +} diff --git a/web/oss/src/components/QueryRegistry/index.tsx b/web/oss/src/components/QueryRegistry/index.tsx new file mode 100644 index 0000000000..bc27a85100 --- /dev/null +++ b/web/oss/src/components/QueryRegistry/index.tsx @@ -0,0 +1,280 @@ +import {useCallback, useMemo, useState} from "react" + +import { + archiveQueryRevision, + archiveSimpleQuery, + createSimpleQuery, + invalidateQueryCache, + unarchiveQueryRevision, + unarchiveSimpleQuery, + type SimpleQueryCreate, +} from "@agenta/entities/query" +import {projectIdAtom} from "@agenta/shared/state" +import {PageLayout} from "@agenta/ui" +import {message} from "@agenta/ui/app-message" +import {TableEmptyState} from "@agenta/ui/components/presentational" +import {PlusOutlined} from "@ant-design/icons" +import {ArrowLeft, Tray} from "@phosphor-icons/react" +import {Button, Input, Space, Typography} from "antd" +import {useAtomValue, useSetAtom} from "jotai" +import {useRouter} from "next/router" + +import EnhancedModal from "@/oss/components/EnhancedUIs/Modal" +import useURL from "@/oss/hooks/useURL" + +import QueryRegistryDrawer from "./Drawer/QueryRegistryDrawer" +import {querySearchTermAtom, queryRegistryActiveRowAtom} from "./store/queryRegistryFilterAtoms" +import type {QueryRegistryStatus} from "./store/queryRegistryFilterAtoms" +import type {QueryRegistryRow} from "./store/queryRegistryStore" +import {invalidateQueryRegistryStore} from "./store/queryRegistryStore" +import type {QueryColumnActions} from "./Table/assets/queryRegistryColumns" +import QueryRegistryTable from "./Table/QueryRegistryTable" + +const {Text} = Typography + +const EMPTY_CREATE_ROW: QueryRegistryRow = { + key: "new", + queryId: "", + variantId: null, + revisionId: null, + name: "", + slug: null, + filtering: null, + windowing: null, + createdAt: null, + createdById: null, +} + +interface QueryRegistryProps { + /** Active vs archived view — driven by the route (`/queries` vs `/queries/archived`). */ + mode?: QueryRegistryStatus +} + +/** + * Project-scoped Query Registry — lists saved trace-filter queries (SimpleQuery + * rows with head-revision data inlined). Row click / "New query" open the manage + * drawer via the active-row atom; duplicate and archive are wired here. Archive + * uses a generic confirm because the backend exposes no reverse-reference lookup + * (design decision D6). The archive lives at its own route (`/queries/archived`), + * reached via the "Archived" header button — mirroring the Evaluators page. + */ +const QueryRegistry = ({mode = "active"}: QueryRegistryProps) => { + const projectId = useAtomValue(projectIdAtom) + const setSearchTerm = useSetAtom(querySearchTermAtom) + const setActiveRow = useSetAtom(queryRegistryActiveRowAtom) + const router = useRouter() + const {projectURL} = useURL() + const [search, setSearch] = useState("") + const [archiveTarget, setArchiveTarget] = useState(null) + const [archiving, setArchiving] = useState(false) + + const isArchived = mode === "archived" + + const handleSearch = useCallback( + (value: string) => { + setSearch(value) + setSearchTerm(value) + }, + [setSearchTerm], + ) + + const refresh = useCallback(() => { + invalidateQueryRegistryStore() + invalidateQueryCache() + }, []) + + const openDrawer = useCallback( + (record: QueryRegistryRow) => { + setActiveRow(record) + }, + [setActiveRow], + ) + + const handleNewQuery = useCallback(() => { + setActiveRow(EMPTY_CREATE_ROW) + }, [setActiveRow]) + + const handleDuplicate = useCallback( + async (record: QueryRegistryRow) => { + if (!projectId) return + try { + await createSimpleQuery({ + projectId, + query: { + name: `Copy of ${record.name}`, + ...(record.filtering + ? {data: {filtering: record.filtering} as SimpleQueryCreate["data"]} + : {}), + }, + }) + message.success("Query duplicated") + refresh() + } catch { + message.error("Could not duplicate query") + } + }, + [projectId, refresh], + ) + + const handleArchive = useCallback((record: QueryRegistryRow) => { + setArchiveTarget(record) + }, []) + + const archiveTargetIsRevision = Boolean(archiveTarget?.__isRevisionChild) + + const confirmArchive = useCallback(async () => { + if (!projectId || !archiveTarget) return + const isRevision = Boolean(archiveTarget.__isRevisionChild) + setArchiving(true) + try { + if (isRevision && archiveTarget.revisionId) { + // Revision row → archive just that version. + await archiveQueryRevision({projectId, revisionId: archiveTarget.revisionId}) + message.success("Version archived") + } else { + // Parent row → archive the whole query. + await archiveSimpleQuery({projectId, queryId: archiveTarget.queryId}) + message.success("Query archived") + } + setArchiveTarget(null) + refresh() + } catch { + message.error(isRevision ? "Could not archive version" : "Could not archive query") + } finally { + setArchiving(false) + } + }, [projectId, archiveTarget, refresh]) + + const handleRestore = useCallback( + async (record: QueryRegistryRow) => { + if (!projectId) return + const isRevision = Boolean(record.__isArchivedRevision) + try { + if (isRevision && record.revisionId) { + await unarchiveQueryRevision({projectId, revisionId: record.revisionId}) + message.success("Version restored") + } else { + await unarchiveSimpleQuery({projectId, queryId: record.queryId}) + message.success("Query restored") + } + refresh() + } catch { + message.error(isRevision ? "Could not restore version" : "Could not restore query") + } + }, + [projectId, refresh], + ) + + const actions: QueryColumnActions = useMemo( + () => ({ + handleOpen: openDrawer, + handleEdit: openDrawer, + handleDuplicate, + handleArchive, + handleRestore, + }), + [openDrawer, handleDuplicate, handleArchive, handleRestore], + ) + + // Archived view: swap the title for a back-arrow + "Archived Queries", exactly + // like the Evaluators archived route. + const title = isArchived ? ( + + + + + ) + + const emptyState = isArchived ? ( + + ) : ( + } onClick={handleNewQuery}> + New query + + } + /> + ) + + return ( + + + + setArchiveTarget(null)} + okButtonProps={{danger: true, loading: archiving}} + > + + {archiveTargetIsRevision + ? "This removes the version from the query's history. It can be restored later." + : "This query may be in use by a live evaluation. Archived queries can be restored later."} + + + + ) +} + +export default QueryRegistry diff --git a/web/oss/src/components/QueryRegistry/store/queryRegistryFilterAtoms.ts b/web/oss/src/components/QueryRegistry/store/queryRegistryFilterAtoms.ts new file mode 100644 index 0000000000..7812be9008 --- /dev/null +++ b/web/oss/src/components/QueryRegistry/store/queryRegistryFilterAtoms.ts @@ -0,0 +1,21 @@ +import {atom} from "jotai" + +import type {QueryRegistryRow} from "./queryRegistryStore" + +/** Search term for the Query Registry list (client-side filter on the loaded page). */ +export const querySearchTermAtom = atom("") + +/** + * Active vs archived view. Driven by the route (`/queries` vs `/queries/archived`) + * and passed as a `mode` prop — mirrors the Evaluators archived-route pattern — + * so it lives as a type, not a shared atom. + */ +export type QueryRegistryStatus = "active" | "archived" + +/** + * The query the manage drawer is editing, or `null` when closed. A row with an + * empty `queryId` means "create a new query". Set by the registry dashboard, read + * by the drawer — avoids re-fetching, since the SimpleQuery row already carries + * the head filtering. + */ +export const queryRegistryActiveRowAtom = atom(null) diff --git a/web/oss/src/components/QueryRegistry/store/queryRegistryStore.ts b/web/oss/src/components/QueryRegistry/store/queryRegistryStore.ts new file mode 100644 index 0000000000..fa56bc3fa3 --- /dev/null +++ b/web/oss/src/components/QueryRegistry/store/queryRegistryStore.ts @@ -0,0 +1,203 @@ +/** + * Query Registry Paginated Store + * + * Project-scoped list of SimpleQueries (flattened query + head-revision data). + * Each row carries its filtering, variant_id, and revision_id inline, so the + * table needs no per-row revision fetch — the head data comes from the list. + * + * Pagination: SimpleQueriesResponse does not echo a windowing cursor, so we use + * keyset-by-id (descending) — the next cursor is the last row's id when the page + * is full. TODO(verify): confirm the backend honors `windowing.next` for the + * simple-queries list against a project with > limit queries. + */ + +import {querySimpleQueries, type SimpleQuery} from "@agenta/entities/query" +import {createPaginatedEntityStore} from "@agenta/entities/shared" +import type {InfiniteTableFetchResult} from "@agenta/entities/shared" +import {projectIdAtom} from "@agenta/shared/state" +import {atom, getDefaultStore} from "jotai" + +import {emptyFetchResult} from "@/oss/state/entities/shared" + +import {querySearchTermAtom, type QueryRegistryStatus} from "./queryRegistryFilterAtoms" + +// ============================================================================ +// TABLE ROW TYPE +// ============================================================================ + +export interface QueryRegistryRow { + key: string + __isSkeleton?: boolean + queryId: string + variantId: string | null + revisionId: string | null + name: string + slug: string | null + /** Head revision's filtering tree — source for the filter-summary cell. */ + filtering: unknown + /** Head revision's windowing (sampling rate / time bounds) — preserved on edit. */ + windowing: unknown + createdAt: string | null + createdById: string | null + /** Revision version label, shown as a badge on expanded history rows. */ + version?: string | null + /** Git-style commit message for this revision (head row = head revision's). */ + message?: string | null + /** True for a lazily-loaded revision (child) row in the version-history expand. */ + __isRevisionChild?: boolean + /** True when a revision (child) row is archived — shown tagged + restorable. */ + __isArchivedRevision?: boolean + /** Placeholder row shown while a query's revisions are being fetched. */ + __isRevisionLoader?: boolean + /** Injected revision-history rows (antd tree children). */ + children?: QueryRegistryRow[] + [k: string]: unknown +} + +// ============================================================================ +// QUERY META +// ============================================================================ + +interface QueryRegistryMeta { + projectId: string | null + searchTerm?: string + status: QueryRegistryStatus +} + +// One meta atom per mode — `status` is baked in rather than read from a shared +// atom, so the active and archived routes drive independent stores (mirrors the +// Evaluators `getEvaluatorsTableState(mode)` factory and avoids a stale first +// fetch when landing directly on the archived route). +const queryRegistryMetaAtomByStatus = (status: QueryRegistryStatus) => + atom((get) => ({ + projectId: get(projectIdAtom), + searchTerm: get(querySearchTermAtom) || undefined, + status, + })) + +const skeletonDefaults: Partial = { + queryId: "", + variantId: null, + revisionId: null, + name: "", + slug: null, + filtering: null, + windowing: null, + createdAt: null, + createdById: null, + key: "", +} + +const toRow = (query: SimpleQuery): QueryRegistryRow => ({ + key: query.id ?? query.revision_id ?? "", + queryId: query.id ?? "", + variantId: query.variant_id ?? null, + revisionId: query.revision_id ?? null, + name: query.name ?? query.slug ?? query.id ?? "Untitled query", + slug: query.slug ?? null, + filtering: query.data?.filtering ?? null, + windowing: query.data?.windowing ?? null, + createdAt: query.created_at ?? null, + createdById: query.created_by_id ?? null, +}) + +// ============================================================================ +// PAGINATED STORE (per-mode factory) +// ============================================================================ + +const createQueryRegistryStore = (status: QueryRegistryStatus) => + createPaginatedEntityStore({ + entityName: status === "archived" ? "query-registry-archived" : "query-registry", + metaAtom: queryRegistryMetaAtomByStatus(status), + fetchPage: async ({ + meta, + limit, + cursor, + }: { + meta: QueryRegistryMeta + limit?: number + cursor?: string | null + }): Promise> => { + if (!meta.projectId) { + return emptyFetchResult() + } + + const isArchived = meta.status === "archived" + + const response = await querySimpleQueries({ + projectId: meta.projectId, + includeArchived: isArchived, + windowing: { + next: cursor ?? undefined, + limit: limit ?? undefined, + order: "descending", + }, + }) + + const raw = response.queries ?? [] + let queries = raw + + // `include_archived` returns active + archived; split by the soft-delete + // marker so each tab shows only its own rows. + queries = queries.filter((query) => + isArchived ? Boolean(query.deleted_at) : !query.deleted_at, + ) + + // SimpleQueryQuery has no name filter — search is client-side on the page. + if (meta.searchTerm) { + const term = meta.searchTerm.toLowerCase() + queries = queries.filter((query) => + (query.name ?? query.slug ?? "").toLowerCase().includes(term), + ) + } + + // Keyset cursor walks the full backend list, so derive it from the raw + // (pre-filter) page — otherwise client-side filtering would skip rows. + const full = typeof limit === "number" && raw.length >= limit + const lastId = raw.length > 0 ? (raw[raw.length - 1]?.id ?? null) : null + + return { + rows: queries, + totalCount: response.count ?? null, + hasMore: full && Boolean(lastId), + nextCursor: full ? lastId : null, + nextOffset: null, + nextWindowing: null, + } + }, + rowConfig: { + getRowId: (row) => row.id ?? row.revision_id ?? "", + skeletonDefaults, + }, + transformRow: toRow, + isEnabled: (meta) => Boolean(meta?.projectId), + listCountsConfig: { + totalCountMode: "unknown", + }, + }) + +// Lazily-built, cached store per mode. The table reads the store for its current +// mode; both are invalidated together after a create/edit/archive/restore. +const _stores = new Map>() + +export function getQueryRegistryTableState(status: QueryRegistryStatus) { + let store = _stores.get(status) + if (!store) { + store = createQueryRegistryStore(status) + _stores.set(status, store) + } + return store +} + +/** + * Bumped on every registry invalidation. The table's batched revision-history + * cache (parent version badges + child rows) is React state, not part of the + * paginated store, so it watches this signal to refetch after a commit/restore. + */ +export const queryRegistryRevisionsRefreshAtom = atom(0) + +export function invalidateQueryRegistryStore() { + _stores.forEach((store) => store.invalidate()) + const store = getDefaultStore() + store.set(queryRegistryRevisionsRefreshAtom, (v) => v + 1) +} diff --git a/web/oss/src/components/SharedDrawers/AnnotateDrawer/index.tsx b/web/oss/src/components/SharedDrawers/AnnotateDrawer/index.tsx index 789c464a59..a52bdef0f3 100644 --- a/web/oss/src/components/SharedDrawers/AnnotateDrawer/index.tsx +++ b/web/oss/src/components/SharedDrawers/AnnotateDrawer/index.tsx @@ -1,7 +1,7 @@ import {useCallback, useEffect, useMemo, useState} from "react" -import {humanEvaluatorsListDataAtom} from "@agenta/entities/workflow" -import {useAtomValue} from "jotai" +import {humanEvaluatorsListDataAtom, type Workflow} from "@agenta/entities/workflow" +import {atom, useAtomValue} from "jotai" import dynamic from "next/dynamic" import {useLocalStorage} from "usehooks-ts" @@ -14,6 +14,13 @@ import {useEvaluatorSchemas} from "./assets/hooks/useEvaluatorSchemas" import {AnnotateDrawerProps, AnnotateDrawerStepsType, UpdatedMetricsType} from "./assets/types" import {isAnnotationCreatedByCurrentUser} from "./assets/utils" +// `humanEvaluatorsListDataAtom` resolves every evaluator's latest revision (a +// batched per-evaluator fan-out). This drawer is mounted (closed) in shared +// layouts incl. the playground, so reading it unconditionally fired that fan-out +// on every page load. Swap in a stable empty atom while the drawer is closed — +// the list is only needed once it opens. +const EMPTY_EVALUATOR_REFS_ATOM = atom([]) + const Annotate = dynamic(() => import("./assets/Annotate"), {ssr: false}) const SelectEvaluators = dynamic(() => import("./assets/SelectEvaluators"), {ssr: false}) const CreateEvaluator = dynamic(() => import("./assets/CreateEvaluator"), {ssr: false}) @@ -30,7 +37,9 @@ const AnnotateDrawer = ({ ...props }: AnnotateDrawerProps) => { const {projectId} = getProjectValues() - const evaluatorRefs = useAtomValue(humanEvaluatorsListDataAtom) + const evaluatorRefs = useAtomValue( + props.open ? humanEvaluatorsListDataAtom : EMPTY_EVALUATOR_REFS_ATOM, + ) const evaluators = useEvaluatorSchemas(evaluatorRefs as any) const evalLSKey = `${projectId}-evaluator` diff --git a/web/oss/src/components/Sidebar/components/WorkflowEntityCard.tsx b/web/oss/src/components/Sidebar/components/WorkflowEntityCard.tsx index fd265ce7d1..72d81e3701 100644 --- a/web/oss/src/components/Sidebar/components/WorkflowEntityCard.tsx +++ b/web/oss/src/components/Sidebar/components/WorkflowEntityCard.tsx @@ -1,6 +1,7 @@ import {memo, useCallback, useMemo, useState} from "react" import { + activateEvaluatorEnrichmentAtom, nonHumanEvaluatorsAtom, nonArchivedAppWorkflowsAtom, nonArchivedEvaluatorsAtom, @@ -14,7 +15,7 @@ import {WorkflowTypeTag} from "@agenta/entity-ui/workflow" import {ArrowsLeftRight, X} from "@phosphor-icons/react" import {Button, Dropdown, type MenuProps, Tooltip} from "antd" import clsx from "clsx" -import {useAtomValue, useSetAtom} from "jotai" +import {atom, useAtomValue, useSetAtom} from "jotai" import useURL from "@/oss/hooks/useURL" import {recentAppIdAtom, routerAppNavigationAtom} from "@/oss/state/app/atoms/fetcher" @@ -29,7 +30,11 @@ interface WorkflowEntityCardProps { collapsed: boolean } -const EMPTY_WORKFLOWS: readonly Workflow[] = [] +// Stable empty atom read while the switcher is dormant, so swapping it in for +// `nonHumanEvaluatorsAtom` keeps the evaluator latest-revision fan-out unmounted +// until the switcher is first opened. +const EMPTY_EVALUATORS: readonly Workflow[] = [] +const EMPTY_EVALUATORS_ATOM = atom(EMPTY_EVALUATORS) /** * Single row inside the switcher dropdown — name + per-kind type tag. @@ -128,17 +133,24 @@ const WorkflowEntityCard = memo(({collapsed}: WorkflowEntityCardProps) => { // evaluators leaked in (QA 2026-06-05). It drops ONLY human (`is_feedback`) // evaluators; navigation lands on the workflow's current sub-page (Overview/ // Evaluations are valid for every evaluator), so matchers no longer dead-end. - const automaticEvaluators = useAtomValue(nonHumanEvaluatorsAtom) as readonly Workflow[] - // Gated by `EVALUATOR_FULL_PAGE_NAV_ENABLED`: while the flag is off, the - // switcher dropdown hides the "Evaluators" group entirely. - const switcherEvaluators: readonly Workflow[] = useMemo(() => { - if (!EVALUATOR_FULL_PAGE_NAV_ENABLED) return EMPTY_WORKFLOWS - return automaticEvaluators - }, [automaticEvaluators]) + // + // LAZY: that latest-revision resolution fans out one batched + // POST /workflows/revisions/query over EVERY evaluator in the project, and + // it's only needed to populate the switcher dropdown. `nonHumanEvaluatorsAtom` + // sits behind the shared enrichment gate (dormant → empty until activated), + // and we activate it on first switcher-open (see `handleSwitcherOpenChange`), + // so a plain sidebar mount never triggers the fan-out. While the + // `EVALUATOR_FULL_PAGE_NAV_ENABLED` flag is off we read a stable empty atom + // instead, so the "Evaluators" group stays hidden regardless of whether some + // other consumer has activated the gate. + const switcherEvaluators = useAtomValue( + EVALUATOR_FULL_PAGE_NAV_ENABLED ? nonHumanEvaluatorsAtom : EMPTY_EVALUATORS_ATOM, + ) as readonly Workflow[] const recentAppId = useAtomValue(recentAppIdAtom) const recentEvaluatorId = useAtomValue(recentEvaluatorIdAtom) const navigateToWorkflow = useSetAtom(routerAppNavigationAtom) const requestNavigation = useSetAtom(requestNavigationAtom) + const activateEvaluatorEnrichment = useSetAtom(activateEvaluatorEnrichmentAtom) const {baseAppURL} = useURL() const [switcherOpen, setSwitcherOpen] = useState(false) @@ -213,6 +225,17 @@ const WorkflowEntityCard = memo(({collapsed}: WorkflowEntityCardProps) => { [navigateToWorkflow, workflowId], ) + // Opening the switcher activates the shared evaluator-enrichment gate (one-way), + // so the batched latest-revision fetch happens on first open instead of on + // sidebar mount. Idempotent + cached, so reopening is instant. + const handleSwitcherOpenChange = useCallback( + (open: boolean) => { + setSwitcherOpen(open) + if (open && EVALUATOR_FULL_PAGE_NAV_ENABLED) activateEvaluatorEnrichment() + }, + [activateEvaluatorEnrichment], + ) + const handleClose = useCallback(() => { // Exit the entity context — go back to the apps listing. We replace // (not push) so the back button doesn't bring the user straight back @@ -231,7 +254,7 @@ const WorkflowEntityCard = memo(({collapsed}: WorkflowEntityCardProps) => { placement="bottomLeft" destroyOnHidden open={switcherOpen} - onOpenChange={setSwitcherOpen} + onOpenChange={handleSwitcherOpenChange} styles={{root: {zIndex: 2000, minWidth: 280}}} menu={{ items: switcherItems, @@ -270,7 +293,7 @@ const WorkflowEntityCard = memo(({collapsed}: WorkflowEntityCardProps) => { placement="bottomRight" destroyOnHidden open={switcherOpen} - onOpenChange={setSwitcherOpen} + onOpenChange={handleSwitcherOpenChange} styles={{root: {zIndex: 2000, minWidth: 280}}} menu={{ items: switcherItems, diff --git a/web/oss/src/components/Sidebar/hooks/useSidebarConfig/index.tsx b/web/oss/src/components/Sidebar/hooks/useSidebarConfig/index.tsx index eb467d00f8..ccb7326095 100644 --- a/web/oss/src/components/Sidebar/hooks/useSidebarConfig/index.tsx +++ b/web/oss/src/components/Sidebar/hooks/useSidebarConfig/index.tsx @@ -4,6 +4,7 @@ import { DatabaseIcon, DesktopIcon, FlaskIcon, + FunnelIcon, PaperPlaneIcon, PhoneIcon, QuestionIcon, @@ -106,6 +107,13 @@ export const useSidebarConfig = () => { icon: , disabled: !hasProjectURL, }, + { + key: "project-queries-link", + title: "Queries", + link: `${projectURL}/queries`, + icon: , + disabled: !hasProjectURL, + }, { key: "overview-link", title: "Overview", diff --git a/web/oss/src/components/pages/evaluations/onlineEvaluation/OnlineEvaluationDrawer.tsx b/web/oss/src/components/pages/evaluations/onlineEvaluation/OnlineEvaluationDrawer.tsx index 10905313fc..fcb359c964 100644 --- a/web/oss/src/components/pages/evaluations/onlineEvaluation/OnlineEvaluationDrawer.tsx +++ b/web/oss/src/components/pages/evaluations/onlineEvaluation/OnlineEvaluationDrawer.tsx @@ -1,6 +1,11 @@ import {useCallback, useEffect, useMemo, useState} from "react" import type {ReactNode} from "react" +import { + createSimpleQuery as createQueryEntity, + invalidateQueryCache, + type SimpleQueryCreate, +} from "@agenta/entities/query" import { evaluatorConfigRevisionsListDataAtom, evaluatorConfigRevisionsQueryStateAtom, @@ -9,23 +14,19 @@ import { isOnlineCapableEvaluator, } from "@agenta/entities/workflow" import {message} from "@agenta/ui/app-message" -import {Button, Collapse, DatePicker, Form, Input, Select, Switch, Tooltip, Typography} from "antd" +import {Button, Collapse, Form, Input, Select, Tooltip, Typography} from "antd" import dayjs from "dayjs" import type {Dayjs} from "dayjs" import {useAtom, useAtomValue, useSetAtom} from "jotai" import {queryClientAtom} from "jotai-tanstack-query" -import dynamic from "next/dynamic" import {useRouter} from "next/router" import {v4 as uuidv4} from "uuid" import EnhancedDrawer from "@/oss/components/EnhancedUIs/Drawer" import getFilterColumns from "@/oss/components/pages/observability/assets/getFilterColumns" -import type {Filter} from "@/oss/lib/Types" import { createSimpleEvaluation, - createSimpleQuery, - retrieveQueryRevision, type QueryRevisionDataPayload, type SimpleEvaluationCreatePayload, type SimpleQueryCreatePayload, @@ -40,7 +41,7 @@ import { import {onlineEvalFiltersAtom, resetOnlineEvalFiltersAtom} from "./assets/state" import EvaluatorDetailsPreview from "./components/EvaluatorDetailsPreview" import EvaluatorTypeTag from "./components/EvaluatorTypeTag" -import SamplingRateControl from "./components/SamplingRateControl" +import QueryEditor from "./components/QueryEditor" import {useEvaluatorDetails} from "./hooks/useEvaluatorDetails" import {useEvaluatorSelection} from "./hooks/useEvaluatorSelection" import {useEvaluatorTypeFromConfigs} from "./hooks/useEvaluatorTypeFromConfigs" @@ -53,8 +54,6 @@ interface OnlineEvaluationDrawerProps { } const {Text, Link: TypographyLink} = Typography -const {RangePicker} = DatePicker -const Filters = dynamic(() => import("@/oss/components/Filters/Filters"), {ssr: false}) const collapseClass = "[&_.ant-collapse-item]:!border-none [&_.ant-collapse-item]:!rounded-[10px] [&_.ant-collapse-item]:overflow-hidden [&_.ant-collapse-item]:bg-colorBgContainer [&_.ant-collapse-item]:shadow-[0_1px_2px_rgba(15,23,42,0.06)] [&_.ant-collapse-item+.ant-collapse-item]:mt-2 [&_.ant-collapse-header]:!bg-[var(--ag-c-FAFAFB)] [&_.ant-collapse-header]:!border-b [&_.ant-collapse-header]:!border-solid [&_.ant-collapse-header]:!border-[var(--ag-colorSplit)] [&_.ant-collapse-header]:!p-[12px_16px] [&_.ant-collapse-content]:!border-t-0 [&_.ant-collapse-content]:!rounded-[0_0_10px_10px] [&_.ant-collapse-content>.ant-collapse-content-box]:!p-4" @@ -309,19 +308,27 @@ const OnlineEvaluationDrawer = ({open, onClose, onCreate}: OnlineEvaluationDrawe queryPayload.data = queryData } - const queryResponse = await createSimpleQuery({query: queryPayload}) - const queryId = queryResponse.query?.id - if (!queryId) { - throw new Error("Unable to create query for online evaluation.") + if (!projectId) { + throw new Error("Missing project for online evaluation.") } - const revisionResponse = await retrieveQueryRevision({ - query_ref: {id: queryId}, + // Single create path: the entity mutation creates the query and + // resolves its head revision, then fires invalidateQueryCache so the + // new query surfaces in the Query Registry. `data` is cast because the + // live-eval helpers still emit the pre-Fern payload shape (Phase 2 + // migrates toFilteringPayload/toWindowingPayload to Fern types). + const {revisionId: queryRevisionId} = await createQueryEntity({ + projectId, + query: { + slug: querySlug, + name: values.name, + description: values.description, + ...(queryPayload.data + ? {data: queryPayload.data as SimpleQueryCreate["data"]} + : {}), + }, }) - const queryRevisionId = revisionResponse.query_revision?.id - if (!queryRevisionId) { - throw new Error("Unable to resolve query revision for online evaluation.") - } + invalidateQueryCache() const evaluatorRevisionStepId = selectedEvaluatorRevisionId ?? @@ -524,60 +531,11 @@ const OnlineEvaluationDrawer = ({open, onClose, onCreate}: OnlineEvaluationDrawe label: buildPanelHeader("Query", querySummary), style: {marginBottom: 4}, children: ( - <> -
- - - setFilters(newFilters) - } - onClearFilter={(newFilters: Filter[]) => - setFilters(newFilters) - } - buttonProps={{ - size: "middle", - className: "!flex !items-center !gap-2", - }} - /> - - - - -
- -
-
- - - - - Run on historical data - -
- - - -
- + ), }, { diff --git a/web/oss/src/components/pages/evaluations/onlineEvaluation/components/QueryEditor.tsx b/web/oss/src/components/pages/evaluations/onlineEvaluation/components/QueryEditor.tsx new file mode 100644 index 0000000000..38eb81fa68 --- /dev/null +++ b/web/oss/src/components/pages/evaluations/onlineEvaluation/components/QueryEditor.tsx @@ -0,0 +1,94 @@ +import {DatePicker, Form, Switch, Tooltip, Typography} from "antd" +import dynamic from "next/dynamic" + +import getFilterColumns from "@/oss/components/pages/observability/assets/getFilterColumns" +import type {Filter} from "@/oss/lib/Types" + +import SamplingRateControl from "./SamplingRateControl" + +const Filters = dynamic(() => import("@/oss/components/Filters/Filters"), {ssr: false}) +const {Text} = Typography +const {RangePicker} = DatePicker + +export interface QueryEditorProps { + /** Current trace filter conditions. */ + filters: Filter[] + /** Called when the user applies or clears filters. */ + onFiltersChange: (filters: Filter[]) => void + /** Field menu for the filter builder (from `getFilterColumns`). */ + filterColumns: ReturnType + /** + * Render the filter editor inline (always-visible rows) instead of behind the + * funnel button. Used by the Query Registry drawer where editing the filter is + * the primary task; the live-eval drawer keeps the compact button. + */ + inlineFilters?: boolean +} + +/** + * Reusable query editor: trace filter + sampling rate + (coming-soon) historical + * window. Renders inside an antd Form context — `sampling_rate`, `historical`, + * and `historical_range` are form fields, so the parent form collects them + * unchanged. Shared by the live-eval Online Evaluation drawer and the Query + * Registry manage drawer. + */ +const QueryEditor = ({ + filters, + onFiltersChange, + filterColumns, + inlineFilters = false, +}: QueryEditorProps) => { + return ( + <> +
+ + onFiltersChange(newFilters)} + onClearFilter={(newFilters: Filter[]) => onFiltersChange(newFilters)} + buttonProps={{ + size: "middle", + className: "!flex !items-center !gap-2", + }} + /> + + + + +
+ +
+
+ + + + + Run on historical data + +
+ + + +
+ + ) +} + +export default QueryEditor diff --git a/web/oss/src/pages/w/[workspace_id]/p/[project_id]/queries/archived/index.tsx b/web/oss/src/pages/w/[workspace_id]/p/[project_id]/queries/archived/index.tsx new file mode 100644 index 0000000000..3a88822b51 --- /dev/null +++ b/web/oss/src/pages/w/[workspace_id]/p/[project_id]/queries/archived/index.tsx @@ -0,0 +1 @@ +export {default} from "@/oss/components/QueryRegistry/ArchivedQueriesPage" diff --git a/web/oss/src/pages/w/[workspace_id]/p/[project_id]/queries/index.tsx b/web/oss/src/pages/w/[workspace_id]/p/[project_id]/queries/index.tsx new file mode 100644 index 0000000000..7da68f2c57 --- /dev/null +++ b/web/oss/src/pages/w/[workspace_id]/p/[project_id]/queries/index.tsx @@ -0,0 +1,7 @@ +import QueryRegistry from "@/oss/components/QueryRegistry" + +const QueriesPage = () => { + return +} + +export default QueriesPage diff --git a/web/packages/agenta-annotation-ui/src/components/CreateQueueDrawer/index.tsx b/web/packages/agenta-annotation-ui/src/components/CreateQueueDrawer/index.tsx index aa41f8ed18..a6a5390cdb 100644 --- a/web/packages/agenta-annotation-ui/src/components/CreateQueueDrawer/index.tsx +++ b/web/packages/agenta-annotation-ui/src/components/CreateQueueDrawer/index.tsx @@ -6,7 +6,10 @@ import { type CreateSimpleQueuePayload, } from "@agenta/entities/simpleQueue" import {evaluatorWorkflowMetaMapAtom} from "@agenta/entities/workflow" -import {type WorkflowRevisionSelectionResult} from "@agenta/entity-ui/selection" +import { + type WorkflowRevisionSelectionResult, + useEnsureEvaluatorEnrichment, +} from "@agenta/entity-ui/selection" import {projectIdAtom} from "@agenta/shared/state" import {ModalContent, ModalFooter, message} from "@agenta/ui" import {Divider, Drawer, Form, Input, Select, Typography} from "antd" @@ -126,6 +129,10 @@ function CreateQueueDrawerContent({ return map }, [selectedEvaluators]) + // This drawer needs every evaluator's version count, so activate the shared + // enrichment gate (the drawer only mounts when opened, so this never runs on + // a plain page load). + useEnsureEvaluatorEnrichment() const evaluatorWorkflowMetaMap = useAtomValue(evaluatorWorkflowMetaMapAtom) const totalRevisionsByEvaluator = useMemo(() => { const map = new Map() diff --git a/web/packages/agenta-entities/package.json b/web/packages/agenta-entities/package.json index 44f4814aa5..60d5e34a1f 100644 --- a/web/packages/agenta-entities/package.json +++ b/web/packages/agenta-entities/package.json @@ -46,6 +46,7 @@ "./trace/etl": "./src/trace/etl/index.ts", "./testset": "./src/testset/index.ts", "./testcase": "./src/testcase/index.ts", + "./query": "./src/query/index.ts", "./event": "./src/event/index.ts", "./event/state": "./src/event/state/index.ts", "./secret": "./src/secret/index.ts", diff --git a/web/packages/agenta-entities/src/query/api/api.ts b/web/packages/agenta-entities/src/query/api/api.ts new file mode 100644 index 0000000000..3a85277f12 --- /dev/null +++ b/web/packages/agenta-entities/src/query/api/api.ts @@ -0,0 +1,205 @@ +/** + * Query API functions (Fern-backed, pure — no Jotai). + * + * Queries are project-scoped; every call passes project_id via queryParams. + */ + +import {getAgentaSdkClient} from "@agenta/sdk" +import {getAgentaApiUrl} from "@agenta/shared/api" +import type {AgentaApi} from "@agentaai/api-client" + +import {transformTracesResponseToTree, type TraceSpanNode} from "../../trace" + +export interface RetrieveQueryRevisionParams { + projectId: string + queryRef?: AgentaApi.Reference + queryVariantRef?: AgentaApi.Reference + queryRevisionRef?: AgentaApi.Reference + /** Execute the filter and return matching trace ids (used by the drawer match-count). */ + includeTraceIds?: boolean +} + +export interface CountMatchingTracesParams { + projectId: string + /** The in-progress query filtering (structurally the tracing FilteringInput). */ + filtering?: unknown + abortSignal?: AbortSignal +} + +/** + * Count traces matching a filter, for the drawer's live match-count (design D3). + * Executes the filter against the trace store with `limit: 1` and reads the + * window count — no trace payloads are materialized. + */ +export async function countMatchingTraces({ + projectId, + filtering, + abortSignal, +}: CountMatchingTracesParams): Promise { + // Match the trace entity's accessor (getTracesClient = getAgentaSdkClient() + // with no host override). Passing `{host: getAgentaApiUrl()}` here points the + // traces resource at a cross-origin host that rejects the session cookie (401). + const client = getAgentaSdkClient() + const response = await client.traces.queryTraces( + { + ...(filtering ? {filtering: filtering as AgentaApi.FilteringInput} : {}), + windowing: {limit: 1}, + }, + {queryParams: {project_id: projectId}, abortSignal}, + ) + return response.count ?? null +} + +export interface QueryMatchingTracesParams { + projectId: string + filtering?: unknown + limit?: number + abortSignal?: AbortSignal +} + +/** + * Fetch the traces matching a filter, as a tree, for the drawer's "matching + * traces" preview. Reuses the trace resource (`queryTraces`) + the shared + * `transformTracesResponseToTree` adapter so the observability columns render it. + */ +export async function queryMatchingTraces({ + projectId, + filtering, + limit = 50, +}: QueryMatchingTracesParams): Promise { + const client = getAgentaSdkClient() + const response = await client.traces.queryTraces( + { + ...(filtering ? {filtering: filtering as AgentaApi.FilteringInput} : {}), + windowing: {limit, order: "descending"}, + }, + {queryParams: {project_id: projectId}}, + ) + return transformTracesResponseToTree(response as never) +} + +export interface QuerySimpleQueriesParams { + projectId: string + windowing?: AgentaApi.Windowing + /** + * Include soft-deleted (archived) queries. The backend returns both active and + * archived rows when set; the Archived tab filters to `deleted_at != null`. + */ + includeArchived?: boolean +} + +/** + * List project-scoped SimpleQueries (flattened query + head-revision data), + * cursor-paginated via windowing. Each row carries `data` (filtering/windowing), + * `variant_id`, and `revision_id` inline — no per-row revision fetch needed. + */ +export async function querySimpleQueries({ + projectId, + windowing, + includeArchived, +}: QuerySimpleQueriesParams): Promise { + const client = getAgentaSdkClient({host: getAgentaApiUrl()}) + return await client.queries.querySimpleQueries( + { + ...(windowing ? {windowing} : {}), + ...(includeArchived ? {include_archived: true} : {}), + }, + {queryParams: {project_id: projectId}}, + ) +} + +export interface QueryRevisionSummary { + /** Owning query artifact id — lets a batched fetch be grouped per query. */ + queryId: string + revisionId: string + version: string | null + filtering: unknown + createdAt: string | null + createdById: string | null + message: string | null + /** Soft-delete marker — set when the revision has been archived. */ + deletedAt: string | null +} + +const toRevisionSummary = (revision: AgentaApi.QueryRevision): QueryRevisionSummary => ({ + queryId: revision.query_id ?? revision.artifact_id ?? "", + revisionId: revision.id ?? "", + version: revision.version ?? null, + filtering: revision.data?.filtering ?? null, + createdAt: revision.created_at ?? null, + createdById: revision.created_by_id ?? null, + message: revision.message ?? null, + deletedAt: revision.deleted_at ?? null, +}) + +export interface QueryRevisionsByQueryParams { + projectId: string + queryId: string + includeArchived?: boolean +} + +/** + * List the revision history of one query artifact (newest first). Queries by the + * artifact ref (`query_refs`) — simple queries are single-variant, so this is the + * full version history and needs no variant id. + */ +export async function queryQueryRevisions({ + projectId, + queryId, + includeArchived, +}: QueryRevisionsByQueryParams): Promise { + return queryRevisionsForQueries({projectId, queryIds: [queryId], includeArchived}) +} + +export interface QueryRevisionsForQueriesParams { + projectId: string + queryIds: string[] + limit?: number + /** Include archived revisions so they can be shown (tagged) + restored. */ + includeArchived?: boolean +} + +/** + * Batched revision history for several query artifacts in one request (newest + * first), grouped client-side by `queryId`. Used by the registry to surface each + * query's head version + earlier-version rows without an N+1 per row. + */ +export async function queryRevisionsForQueries({ + projectId, + queryIds, + limit = 500, + includeArchived, +}: QueryRevisionsForQueriesParams): Promise { + if (!queryIds.length) return [] + const client = getAgentaSdkClient({host: getAgentaApiUrl()}) + const response = await client.queries.queryQueryRevisions( + { + query_refs: queryIds.map((id) => ({id})), + ...(includeArchived ? {include_archived: true} : {}), + windowing: {limit, order: "descending"}, + }, + {queryParams: {project_id: projectId}}, + ) + return (response.query_revisions ?? []).map(toRevisionSummary) +} + +/** + * Retrieve a query revision (latest by default). When no variant/revision ref is + * given, returns the head revision of the query artifact. + */ +export async function retrieveQueryRevision( + params: RetrieveQueryRevisionParams, +): Promise { + const {projectId, queryRef, queryVariantRef, queryRevisionRef, includeTraceIds} = params + const client = getAgentaSdkClient({host: getAgentaApiUrl()}) + const response = await client.queries.retrieveQueryRevision( + { + ...(queryRef ? {query_ref: queryRef} : {}), + ...(queryVariantRef ? {query_variant_ref: queryVariantRef} : {}), + ...(queryRevisionRef ? {query_revision_ref: queryRevisionRef} : {}), + ...(includeTraceIds ? {include_trace_ids: true} : {}), + }, + {queryParams: {project_id: projectId}}, + ) + return response.query_revision ?? null +} diff --git a/web/packages/agenta-entities/src/query/api/index.ts b/web/packages/agenta-entities/src/query/api/index.ts new file mode 100644 index 0000000000..961ebebd50 --- /dev/null +++ b/web/packages/agenta-entities/src/query/api/index.ts @@ -0,0 +1,29 @@ +export { + retrieveQueryRevision, + type RetrieveQueryRevisionParams, + querySimpleQueries, + type QuerySimpleQueriesParams, + countMatchingTraces, + type CountMatchingTracesParams, + queryMatchingTraces, + type QueryMatchingTracesParams, + queryQueryRevisions, + queryRevisionsForQueries, + type QueryRevisionsForQueriesParams, + type QueryRevisionSummary, + type QueryRevisionsByQueryParams, +} from "./api" +export { + createSimpleQuery, + editSimpleQuery, + type EditSimpleQueryParams, + archiveSimpleQuery, + archiveQueryRevision, + unarchiveQueryRevision, + type ArchiveQueryRevisionParams, + type ArchiveSimpleQueryParams, + commitQueryRevision, + type CommitQueryRevisionParams, + unarchiveSimpleQuery, + type UnarchiveSimpleQueryParams, +} from "./mutations" diff --git a/web/packages/agenta-entities/src/query/api/mutations.ts b/web/packages/agenta-entities/src/query/api/mutations.ts new file mode 100644 index 0000000000..a34a143110 --- /dev/null +++ b/web/packages/agenta-entities/src/query/api/mutations.ts @@ -0,0 +1,189 @@ +/** + * Query mutations (Fern-backed, pure — no Jotai). + * + * `createSimpleQuery` is the single create path. The live-eval drawer is + * repointed at this (T1) so a query created during live-eval setup shares the + * entity cache and shows up in the Query Registry (callers fire + * `invalidateQueryCache` after a successful create). + */ + +import {getAgentaSdkClient} from "@agenta/sdk" +import {getAgentaApiUrl} from "@agenta/shared/api" +import type {AgentaApi} from "@agentaai/api-client" +import {v4 as uuidv4} from "uuid" + +import type { + CreateSimpleQueryParams, + CreateSimpleQueryResult, + QueryRevisionDataInput, + SimpleQueryEdit, +} from "../core/types" + +import {retrieveQueryRevision} from "./api" + +/** + * The backend requires a non-null, project-unique artifact slug — without it + * `create_query` returns None and the create silently no-ops ({count: 0}). Derive + * a stable slug from the name plus a short random suffix. + */ +function makeQuerySlug(name?: string | null): string { + const base = (name ?? "query") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 40) + return `${base || "query"}-${uuidv4().slice(0, 8)}` +} + +export async function createSimpleQuery({ + projectId, + query, +}: CreateSimpleQueryParams): Promise { + const client = getAgentaSdkClient({host: getAgentaApiUrl()}) + const response = await client.queries.createSimpleQuery( + {query: {...query, slug: query.slug || makeQuerySlug(query.name)}}, + {queryParams: {project_id: projectId}}, + ) + + const created = response.query + if (!created?.id) { + throw new Error("Unable to create query.") + } + + const variantId = created.variant_id ?? null + let revisionId = created.revision_id ?? null + + // Fallback preserves the live-eval drawer's original two-call behavior when + // the create response omits the head revision id. + if (!revisionId) { + const revision = await retrieveQueryRevision({projectId, queryRef: {id: created.id}}) + revisionId = revision?.id ?? null + } + + if (!revisionId) { + throw new Error("Unable to resolve query revision after create.") + } + + return {queryId: created.id, variantId, revisionId} +} + +export interface EditSimpleQueryParams { + projectId: string + queryId: string + query: SimpleQueryEdit +} + +/** Edit a query — commits a new head revision with the updated name/filter/window. */ +export async function editSimpleQuery({ + projectId, + queryId, + query, +}: EditSimpleQueryParams): Promise { + const client = getAgentaSdkClient({host: getAgentaApiUrl()}) + await client.queries.editSimpleQuery( + {query_id: queryId, query}, + {queryParams: {project_id: projectId}}, + ) +} + +export interface CommitQueryRevisionParams { + projectId: string + /** The query's head variant to commit a new revision onto. */ + variantId: string + data: QueryRevisionDataInput + name?: string + /** Git-style commit message attached to the new revision. */ + message?: string +} + +/** + * Commit a new revision to a query's variant with an optional commit message — + * the git-style update path (`/queries/revisions/commit`). Unlike + * `editSimpleQuery`, this carries a `message`, so it backs the registry's commit + * modal. Simple queries are single-variant, so the variant id comes from the + * head revision. + */ +export async function commitQueryRevision({ + projectId, + variantId, + data, + name, + message, +}: CommitQueryRevisionParams): Promise { + const client = getAgentaSdkClient({host: getAgentaApiUrl()}) + const queryRevision: AgentaApi.QueryRevisionCommit = { + variant_id: variantId, + data, + ...(name != null ? {name} : {}), + ...(message ? {message} : {}), + } + await client.queries.commitQueryRevision( + {query_revision: queryRevision}, + {queryParams: {project_id: projectId}}, + ) +} + +export interface ArchiveSimpleQueryParams { + projectId: string + queryId: string +} + +/** Archive (soft-delete) a query. Reversible via unarchive; safe-archive confirm + * lives in the UI since the backend exposes no reverse-reference lookup. */ +export async function archiveSimpleQuery({ + projectId, + queryId, +}: ArchiveSimpleQueryParams): Promise { + const client = getAgentaSdkClient({host: getAgentaApiUrl()}) + await client.queries.archiveSimpleQuery( + {query_id: queryId}, + {queryParams: {project_id: projectId}}, + ) +} + +export interface ArchiveQueryRevisionParams { + projectId: string + revisionId: string +} + +/** Archive (soft-delete) a single query revision — distinct from archiving the + * whole query artifact. Used by the registry's per-version archive. */ +export async function archiveQueryRevision({ + projectId, + revisionId, +}: ArchiveQueryRevisionParams): Promise { + const client = getAgentaSdkClient({host: getAgentaApiUrl()}) + await client.queries.archiveQueryRevision( + {query_revision_id: revisionId}, + {queryParams: {project_id: projectId}}, + ) +} + +/** Restore a previously archived query revision. */ +export async function unarchiveQueryRevision({ + projectId, + revisionId, +}: ArchiveQueryRevisionParams): Promise { + const client = getAgentaSdkClient({host: getAgentaApiUrl()}) + await client.queries.unarchiveQueryRevision( + {query_revision_id: revisionId}, + {queryParams: {project_id: projectId}}, + ) +} + +export interface UnarchiveSimpleQueryParams { + projectId: string + queryId: string +} + +/** Restore a previously archived query (clears the soft-delete marker). */ +export async function unarchiveSimpleQuery({ + projectId, + queryId, +}: UnarchiveSimpleQueryParams): Promise { + const client = getAgentaSdkClient({host: getAgentaApiUrl()}) + await client.queries.unarchiveSimpleQuery( + {query_id: queryId}, + {queryParams: {project_id: projectId}}, + ) +} diff --git a/web/packages/agenta-entities/src/query/core/index.ts b/web/packages/agenta-entities/src/query/core/index.ts new file mode 100644 index 0000000000..682b40b1e1 --- /dev/null +++ b/web/packages/agenta-entities/src/query/core/index.ts @@ -0,0 +1,9 @@ +export type { + SimpleQueryCreate, + SimpleQueryEdit, + QueryRevisionDataInput, + SimpleQuery, + QueryRevision, + CreateSimpleQueryParams, + CreateSimpleQueryResult, +} from "./types" diff --git a/web/packages/agenta-entities/src/query/core/types.ts b/web/packages/agenta-entities/src/query/core/types.ts new file mode 100644 index 0000000000..c25cb0f0ec --- /dev/null +++ b/web/packages/agenta-entities/src/query/core/types.ts @@ -0,0 +1,31 @@ +/** + * Query entity — core types. + * + * Queries are project-scoped, git-style saved trace filters + * (Query artifact → QueryVariant → QueryRevision). QueryRevisionData holds the + * filtering + windowing that a live evaluation uses to match traces. + * + * T1 (create-slice) only needs the create/retrieve shapes. The full read-path + * schemas (SimpleQuery list rows, filtering round-trip) land in Phase 2. + */ + +import type {AgentaApi} from "@agentaai/api-client" + +export type SimpleQueryCreate = AgentaApi.SimpleQueryCreate +export type SimpleQueryEdit = AgentaApi.SimpleQueryEdit +export type QueryRevisionDataInput = AgentaApi.QueryRevisionDataInput +export type SimpleQuery = AgentaApi.SimpleQuery +export type QueryRevision = AgentaApi.QueryRevision + +/** Payload for creating a SimpleQuery (name + slug + filtering/windowing data). */ +export interface CreateSimpleQueryParams { + projectId: string + query: SimpleQueryCreate +} + +/** Result of creating a SimpleQuery: the artifact id plus its head variant/revision. */ +export interface CreateSimpleQueryResult { + queryId: string + variantId: string | null + revisionId: string +} diff --git a/web/packages/agenta-entities/src/query/index.ts b/web/packages/agenta-entities/src/query/index.ts new file mode 100644 index 0000000000..a3fa945af8 --- /dev/null +++ b/web/packages/agenta-entities/src/query/index.ts @@ -0,0 +1,57 @@ +/** + * @agenta/entities/query — project-scoped saved trace filters. + * + * T1 ships the create-slice (used to repoint the live-eval drawer at a single + * create path). Phase 2 adds the list/detail atoms, paginated store, molecule, + * and filtering round-trip schemas for the Query Registry page. + */ + +export { + createSimpleQuery, + editSimpleQuery, + type EditSimpleQueryParams, + archiveSimpleQuery, + archiveQueryRevision, + unarchiveQueryRevision, + type ArchiveQueryRevisionParams, + type ArchiveSimpleQueryParams, + commitQueryRevision, + type CommitQueryRevisionParams, + unarchiveSimpleQuery, + type UnarchiveSimpleQueryParams, + retrieveQueryRevision, + type RetrieveQueryRevisionParams, + querySimpleQueries, + type QuerySimpleQueriesParams, + countMatchingTraces, + type CountMatchingTracesParams, + queryMatchingTraces, + type QueryMatchingTracesParams, + queryQueryRevisions, + queryRevisionsForQueries, + type QueryRevisionsForQueriesParams, + type QueryRevisionSummary, + type QueryRevisionsByQueryParams, +} from "./api" + +export { + invalidateQueryCache, + QUERY_LIST_KEY, + QUERY_DETAIL_KEY, + QUERY_HEAD_KEY, + queryHeadQueryAtomFamily, + queryHeadDraftAtomFamily, + queryMolecule, + saveQueryHeadAtom, + type SaveQueryHeadParams, +} from "./state" + +export type { + SimpleQueryCreate, + SimpleQueryEdit, + QueryRevisionDataInput, + SimpleQuery, + QueryRevision, + CreateSimpleQueryParams, + CreateSimpleQueryResult, +} from "./core" diff --git a/web/packages/agenta-entities/src/query/state/index.ts b/web/packages/agenta-entities/src/query/state/index.ts new file mode 100644 index 0000000000..aeaa24445b --- /dev/null +++ b/web/packages/agenta-entities/src/query/state/index.ts @@ -0,0 +1,9 @@ +export { + invalidateQueryCache, + QUERY_LIST_KEY, + QUERY_DETAIL_KEY, + QUERY_HEAD_KEY, + queryHeadQueryAtomFamily, + queryHeadDraftAtomFamily, +} from "./store" +export {queryMolecule, saveQueryHeadAtom, type SaveQueryHeadParams} from "./molecule" diff --git a/web/packages/agenta-entities/src/query/state/molecule.ts b/web/packages/agenta-entities/src/query/state/molecule.ts new file mode 100644 index 0000000000..e06d67fbe7 --- /dev/null +++ b/web/packages/agenta-entities/src/query/state/molecule.ts @@ -0,0 +1,100 @@ +/** + * Query molecule — committed-vs-draft state for a single query's head revision. + * + * Queries are git-style entities (Query → QueryVariant → QueryRevision), so this + * mirrors the testset/workflow molecule pattern: server data is the head + * revision, edits accumulate in a draft, and commit creates a new head revision + * via `editSimpleQuery`. + * + * `isDirty` is a SEMANTIC, order-insensitive diff of the committed fields (name + + * filtering + windowing) — not a `draft !== null` check — so changing a value and + * reverting it reads as clean, matching how the workflow entity tracks dirtiness. + */ + +import deepEqual from "fast-deep-equal" +import {atom} from "jotai" + +import {createMolecule, type AtomFamily, type QueryState} from "../../shared" +import {commitQueryRevision, editSimpleQuery} from "../api" +import type {QueryRevision, QueryRevisionDataInput, SimpleQueryEdit} from "../core" + +import {invalidateQueryCache, queryHeadDraftAtomFamily, queryHeadQueryAtomFamily} from "./store" + +/** The fields a commit persists — what dirtiness is measured against. */ +const dirtySignature = (rev: Partial | null | undefined) => ({ + name: rev?.name ?? null, + filtering: rev?.data?.filtering ?? null, + windowing: rev?.data?.windowing ?? null, +}) + +/** + * Semantic, order-insensitive dirty check for a query head revision: true only + * when the committed fields (name + filtering + windowing) of the draft-merged + * view actually differ from the server. A change-then-revert reads as clean. + */ +export function isQueryHeadDirty( + serverData: QueryRevision | null, + draft: Partial | null, +): boolean { + if (!draft) return false + const merged = {...serverData, ...draft} as Partial + return !deepEqual(dirtySignature(merged), dirtySignature(serverData)) +} + +export const queryMolecule = createMolecule>({ + name: "query", + // jotai-family's atomFamily is structurally compatible but needs the cast. + queryAtomFamily: queryHeadQueryAtomFamily as unknown as AtomFamily>, + draftAtomFamily: queryHeadDraftAtomFamily, + isDirty: isQueryHeadDirty, +}) + +export interface SaveQueryHeadParams { + projectId: string + queryId: string + /** Optional git-style commit message for the new revision. */ + message?: string +} + +/** + * Commit the draft as a new head revision, clear the draft, and refresh caches. + * No-op when there are no unsaved changes. The draft carries the full {name, + * data}, so it wins over (possibly stale) server data on merge. + * + * When the head revision's variant id is known, commits through the git endpoint + * so a commit `message` can be attached; otherwise falls back to the simple edit + * (no message). + */ +export const saveQueryHeadAtom = atom( + null, + async (get, set, {projectId, queryId, message}: SaveQueryHeadParams): Promise => { + const draft = get(queryHeadDraftAtomFamily(queryId)) + if (!draft) return + const serverData = get(queryMolecule.atoms.serverData(queryId)) + const merged = {...serverData, ...draft} as Partial + const data = { + filtering: merged.data?.filtering ?? null, + windowing: merged.data?.windowing ?? null, + } + const name = merged.name ?? undefined + const variantId = serverData?.variant_id ?? serverData?.query_variant_id ?? null + + if (variantId) { + await commitQueryRevision({ + projectId, + variantId, + data: data as QueryRevisionDataInput, + name, + message, + }) + } else { + await editSimpleQuery({ + projectId, + queryId, + query: {...(name != null ? {name} : {}), data: data as SimpleQueryEdit["data"]}, + }) + } + set(queryHeadDraftAtomFamily(queryId), null) + invalidateQueryCache() + }, +) diff --git a/web/packages/agenta-entities/src/query/state/store.ts b/web/packages/agenta-entities/src/query/state/store.ts new file mode 100644 index 0000000000..d0b696f99e --- /dev/null +++ b/web/packages/agenta-entities/src/query/state/store.ts @@ -0,0 +1,59 @@ +/** + * Query entity — server-state atoms + cache invalidation. + * + * Queries are git-style entities (Query artifact → QueryVariant → QueryRevision). + * The molecule (see ./molecule) tracks committed-vs-draft state for a single + * query's head revision; these atoms are its server-data source and its draft + * storage. + */ + +import {projectIdAtom} from "@agenta/shared/state" +import {isValidUUID} from "@agenta/shared/utils" +import {atom, getDefaultStore} from "jotai" +import {atomFamily} from "jotai-family" +import {atomWithQuery, queryClientAtom} from "jotai-tanstack-query" + +import {retrieveQueryRevision} from "../api" +import type {QueryRevision} from "../core" + +/** TanStack Query key prefix for the project-scoped SimpleQuery list. */ +export const QUERY_LIST_KEY = "queries-list" +/** TanStack Query key prefix for a single query / its revisions. */ +export const QUERY_DETAIL_KEY = "query" +/** TanStack Query key prefix for a query's head revision (molecule server data). */ +export const QUERY_HEAD_KEY = "query-head" + +/** + * Server data for the molecule: a single query's head revision (carries `name` + * plus `data.filtering` / `data.windowing`). Keyed by the query artifact id. + */ +export const queryHeadQueryAtomFamily = atomFamily((queryId: string) => + atomWithQuery((get) => { + const projectId = get(projectIdAtom) + const enabled = Boolean(projectId) && isValidUUID(queryId) + return { + queryKey: [QUERY_HEAD_KEY, projectId, queryId], + queryFn: async () => { + if (!projectId || !queryId) return null + return retrieveQueryRevision({projectId, queryRef: {id: queryId}}) + }, + enabled, + staleTime: 60_000, + refetchOnWindowFocus: false, + } + }), +) + +/** Local edit draft for a query's head revision (null = no unsaved changes). */ +export const queryHeadDraftAtomFamily = atomFamily((_queryId: string) => + atom | null>(null), +) + +/** Invalidate the query list + detail caches after a create/commit/archive. */ +export function invalidateQueryCache(): void { + const store = getDefaultStore() + const queryClient = store.get(queryClientAtom) + queryClient.invalidateQueries({queryKey: [QUERY_LIST_KEY]}) + queryClient.invalidateQueries({queryKey: [QUERY_DETAIL_KEY]}) + queryClient.invalidateQueries({queryKey: [QUERY_HEAD_KEY]}) +} diff --git a/web/packages/agenta-entities/src/workflow/index.ts b/web/packages/agenta-entities/src/workflow/index.ts index 01c635ebc3..30ca688417 100644 --- a/web/packages/agenta-entities/src/workflow/index.ts +++ b/web/packages/agenta-entities/src/workflow/index.ts @@ -295,6 +295,9 @@ export { nonArchivedEvaluatorsAtom, fullPagePlaygroundEvaluatorsAtom, nonHumanEvaluatorsAtom, + // Lazy enrichment gate (defers the per-evaluator latest-revision fan-out) + evaluatorEnrichmentActivatedAtom, + activateEvaluatorEnrichmentAtom, // Templates evaluatorTemplatesQueryAtom, evaluatorTemplatesDataAtom, diff --git a/web/packages/agenta-entities/src/workflow/state/evaluatorUtils.ts b/web/packages/agenta-entities/src/workflow/state/evaluatorUtils.ts index e36531a10c..f9cf3b4963 100644 --- a/web/packages/agenta-entities/src/workflow/state/evaluatorUtils.ts +++ b/web/packages/agenta-entities/src/workflow/state/evaluatorUtils.ts @@ -112,6 +112,37 @@ export const nonArchivedEvaluatorsAtom = atom((get) => { return refs.filter((ref) => !ref.deleted_at) as Workflow[] }) +// ============================================================================ +// LAZY ENRICHMENT GATE +// ============================================================================ + +/** + * The aggregate evaluator atoms below — `fullPagePlaygroundEvaluatorsAtom`, + * `nonHumanEvaluatorsAtom`, `evaluatorKeyMapAtom`, `evaluatorWorkflowMetaMapAtom`, + * `evaluatorFeedbackSchemasAtom` — each resolve EVERY evaluator's LATEST REVISION, + * which fans out one batched `POST /workflows/revisions/query` over the whole + * project. That enrichment is only needed to populate evaluator pickers / + * switchers, so the fan-out stays DORMANT until a consumer that genuinely needs + * it activates the gate (one-way, per session). Until then each atom returns a + * cheap, stable empty value and mounts no revision query. + * + * Activate imperatively via `activateEvaluatorEnrichmentAtom` (e.g. from a + * picker/switcher open handler), or eagerly via the `useEnsureEvaluatorEnrichment` + * hook for consumers that must have the data on mount. + */ +export const evaluatorEnrichmentActivatedAtom = atom(false) + +export const activateEvaluatorEnrichmentAtom = atom(null, (get, set) => { + if (!get(evaluatorEnrichmentActivatedAtom)) { + set(evaluatorEnrichmentActivatedAtom, true) + } +}) + +// Stable empty references returned while the gate is dormant (so subscribers +// don't churn on every read). +const EMPTY_EVALUATOR_LIST: Workflow[] = [] +const EMPTY_EVALUATOR_KEY_MAP = new Map() + /** * Non-archived evaluators whose latest revision has the full-page playground * UX (prompt-authored — `auto_ai_critique` / `llm` — or code-authored — @@ -132,6 +163,7 @@ export const nonArchivedEvaluatorsAtom = atom((get) => { * `nonArchivedEvaluatorsAtom`), so callers can use it as a drop-in filter. */ export const fullPagePlaygroundEvaluatorsAtom = atom((get) => { + if (!get(evaluatorEnrichmentActivatedAtom)) return EMPTY_EVALUATOR_LIST const evaluators = get(nonArchivedEvaluatorsAtom) return evaluators.filter((evaluator) => { if (!evaluator.id) return false @@ -162,6 +194,7 @@ export const fullPagePlaygroundEvaluatorsAtom = atom((get) => { * does, so a human evaluator never briefly leaks into the list. */ export const nonHumanEvaluatorsAtom = atom((get) => { + if (!get(evaluatorEnrichmentActivatedAtom)) return EMPTY_EVALUATOR_LIST const evaluators = get(nonArchivedEvaluatorsAtom) return evaluators.filter((evaluator) => { if (!evaluator.id) return false @@ -233,6 +266,7 @@ export function onEvaluatorMutation(listener: () => void): () => void { * extracts `data.uri`, and parses the evaluator key. */ export const evaluatorKeyMapAtom = atom>((get) => { + if (!get(evaluatorEnrichmentActivatedAtom)) return EMPTY_EVALUATOR_KEY_MAP const evaluators = get(nonArchivedEvaluatorsAtom) const map = new Map() @@ -279,7 +313,10 @@ export interface EvaluatorWorkflowMeta { * Reads the same batched + cached latest-revision queries as `evaluatorKeyMapAtom`, * so subscribing to this atom adds no extra requests. */ +const EMPTY_EVALUATOR_META_MAP = new Map() + export const evaluatorWorkflowMetaMapAtom = atom>((get) => { + if (!get(evaluatorEnrichmentActivatedAtom)) return EMPTY_EVALUATOR_META_MAP const evaluators = get(nonArchivedEvaluatorsAtom) const map = new Map() @@ -331,7 +368,10 @@ export interface EvaluatorFeedbackSchema { /** * Derived atom: every non-archived evaluator paired with its output-metric properties. */ +const EMPTY_EVALUATOR_FEEDBACK: EvaluatorFeedbackSchema[] = [] + export const evaluatorFeedbackSchemasAtom = atom((get) => { + if (!get(evaluatorEnrichmentActivatedAtom)) return EMPTY_EVALUATOR_FEEDBACK const evaluators = get(nonArchivedEvaluatorsAtom) const result: EvaluatorFeedbackSchema[] = [] diff --git a/web/packages/agenta-entities/src/workflow/state/index.ts b/web/packages/agenta-entities/src/workflow/state/index.ts index 08e4f50a06..1541e7e8de 100644 --- a/web/packages/agenta-entities/src/workflow/state/index.ts +++ b/web/packages/agenta-entities/src/workflow/state/index.ts @@ -165,6 +165,9 @@ export { nonArchivedEvaluatorsAtom, fullPagePlaygroundEvaluatorsAtom, nonHumanEvaluatorsAtom, + // Lazy enrichment gate (defers the per-evaluator latest-revision fan-out) + evaluatorEnrichmentActivatedAtom, + activateEvaluatorEnrichmentAtom, // Templates evaluatorTemplatesQueryAtom, evaluatorTemplatesDataAtom, diff --git a/web/packages/agenta-entities/tests/integration/helpers/fixtures.ts b/web/packages/agenta-entities/tests/integration/helpers/fixtures.ts index 812913f7a6..17c5bcd1d7 100644 --- a/web/packages/agenta-entities/tests/integration/helpers/fixtures.ts +++ b/web/packages/agenta-entities/tests/integration/helpers/fixtures.ts @@ -9,7 +9,9 @@ */ import {createEnvironment, archiveEnvironment} from "../../../src/environment/api/mutations" +import {createSimpleQuery, archiveSimpleQuery} from "../../../src/query/api/mutations" import {createTestset, archiveTestsets} from "../../../src/testset/api/mutations" + import {TEST_CONFIG} from "./env" function tag(prefix: string) { @@ -92,3 +94,49 @@ export async function makeEnvironmentFixture(): Promise { }, } } + +// ── Query (saved trace filter) ────────────────────────────────────────────────── + +/** A simple, structurally-valid filtering payload for query fixtures. */ +export const QUERY_FIXTURE_FILTERING = { + conditions: [{field: "trace_type", operator: "is", value: "invocation"}], +} + +export interface QueryFixture { + queryId: string + variantId: string | null + revisionId: string + name: string + filtering: unknown + cleanup: () => Promise +} + +export async function makeQueryFixture( + filtering: unknown = QUERY_FIXTURE_FILTERING, +): Promise { + const name = tag("integration-query") + const result = await createSimpleQuery({ + projectId: TEST_CONFIG.projectId, + query: {name, data: {filtering: filtering as never}}, + }) + + return { + queryId: result.queryId, + variantId: result.variantId, + revisionId: result.revisionId, + name, + filtering, + cleanup: async () => { + // Tolerant: a test may have already archived the query (the archive-split + // case), and re-archiving a soft-deleted artifact is a no-op-or-error. + try { + await archiveSimpleQuery({ + projectId: TEST_CONFIG.projectId, + queryId: result.queryId, + }) + } catch { + /* already archived — leave the backend clean either way */ + } + }, + } +} diff --git a/web/packages/agenta-entities/tests/integration/query.integration.test.ts b/web/packages/agenta-entities/tests/integration/query.integration.test.ts new file mode 100644 index 0000000000..780a16e425 --- /dev/null +++ b/web/packages/agenta-entities/tests/integration/query.integration.test.ts @@ -0,0 +1,227 @@ +/** + * Integration tests for the query entity (saved trace filters) — data atoms and + * the registry's read/archive logic, exercised against a REAL running Agenta + * backend. There are NO mocks here: every assertion below round-trips through + * actual HTTP. The whole suite is `describe.skipIf(!hasBackend)`, so when no + * backend is configured it SKIPS (it never passes against a mock). Compare with + * tests/unit/query/*, which mock `getAgentaSdkClient` and assert request shape. + * + * The Fern client used by these api functions is authenticated from the + * ephemeral account credentials provisioned in setup/global.ts + setup/worker.ts + * (AGENTA_API_KEY / AGENTA_HOST). Each `createIntegrationStore()` builds a real + * TanStack QueryClient (staleTime 0), so `queryMolecule.atoms.query` performs a + * live network fetch. + * + * Coverage: + * • queryMolecule.atoms.query / serverData — head revision from the real API + * • queryMolecule.atoms.isDirty + reducers — update/discard round-trip + * • querySimpleQueries — created query appears in the list + * • queryRevisionsForQueries — head + history after a commit + * • archive/unarchive a single revision — includeArchived split (the + * Archived-tab logic we ship) + * • archive/unarchive the whole query — active vs archived list split + */ + +import {describe, it, expect, beforeEach, afterEach} from "vitest" + +import { + queryMolecule, + commitQueryRevision, + querySimpleQueries, + queryRevisionsForQueries, + archiveQueryRevision, + unarchiveQueryRevision, + archiveSimpleQuery, +} from "../../src/query" + +import {hasBackend} from "./helpers/env" +import {makeQueryFixture, type QueryFixture} from "./helpers/fixtures" +import {createIntegrationStore, waitForAtom} from "./helpers/store" + +const PROJECT_ID = process.env.AGENTA_TEST_PROJECT_ID || "" + +const firstConditionField = (filtering: unknown): string | undefined => { + const conditions = (filtering as {conditions?: {field?: string}[]} | null)?.conditions + return conditions?.[0]?.field +} + +describe.skipIf(!hasBackend)("query entity integration (real API)", () => { + let fixture: QueryFixture + + beforeEach(async () => { + fixture = await makeQueryFixture() + }) + + afterEach(async () => { + await fixture.cleanup() + }) + + // ── Molecule data atoms ───────────────────────────────────────────────────── + + it("atoms.query resolves from pending to settled against the live backend", async () => { + const {store} = createIntegrationStore() + + const queryAtom = queryMolecule.atoms.query(fixture.queryId) + const settled = await waitForAtom<{isPending: boolean}>( + store, + queryAtom, + (q) => !q.isPending, + ) + + expect(settled.isPending).toBe(false) + }) + + it("atoms.serverData returns the created head revision with its filtering round-tripped", async () => { + const {store} = createIntegrationStore() + + const queryAtom = queryMolecule.atoms.query(fixture.queryId) + await waitForAtom<{isPending: boolean}>(store, queryAtom, (q) => !q.isPending) + + const serverData = store.get(queryMolecule.atoms.serverData(fixture.queryId)) + expect(serverData).not.toBeNull() + expect(serverData?.name).toBe(fixture.name) + // Filtering persisted to the backend and came back on the head revision. + expect(firstConditionField(serverData?.data?.filtering)).toBe("trace_type") + }) + + it("atoms.isDirty is false on a freshly fetched query; update/discard round-trips", async () => { + const {store} = createIntegrationStore() + + const queryAtom = queryMolecule.atoms.query(fixture.queryId) + await waitForAtom<{isPending: boolean}>(store, queryAtom, (q) => !q.isPending) + + expect(store.get(queryMolecule.atoms.isDirty(fixture.queryId))).toBe(false) + + store.set(queryMolecule.reducers.update, fixture.queryId, {name: "Updated Name"}) + expect(store.get(queryMolecule.atoms.isDirty(fixture.queryId))).toBe(true) + expect(store.get(queryMolecule.atoms.data(fixture.queryId))?.name).toBe("Updated Name") + + store.set(queryMolecule.reducers.discard, fixture.queryId) + expect(store.get(queryMolecule.atoms.isDirty(fixture.queryId))).toBe(false) + expect(store.get(queryMolecule.atoms.data(fixture.queryId))?.name).toBe(fixture.name) + }) + + // ── List + revision-history fetchers (back the registry table) ────────────── + + it("querySimpleQueries lists the created query among the project's active queries", async () => { + const response = await querySimpleQueries({projectId: PROJECT_ID}) + const ids = (response.queries ?? []).map((q) => q.id) + expect(ids).toContain(fixture.queryId) + }) + + it("queryRevisionsForQueries returns head + history newest-first after a commit", async () => { + // Need the variant to commit onto; resolve from the head revision if the + // create response did not inline it. + const {store} = createIntegrationStore() + const queryAtom = queryMolecule.atoms.query(fixture.queryId) + await waitForAtom<{isPending: boolean}>(store, queryAtom, (q) => !q.isPending) + const head = store.get(queryMolecule.atoms.serverData(fixture.queryId)) + const variantId = fixture.variantId ?? head?.variant_id ?? head?.query_variant_id ?? null + expect(variantId).toBeTruthy() + + await commitQueryRevision({ + projectId: PROJECT_ID, + variantId: variantId as string, + data: { + filtering: { + conditions: [{field: "trace_type", operator: "is", value: "completion"}], + } as never, + }, + name: fixture.name, + message: "integration: second revision", + }) + + const revs = await queryRevisionsForQueries({ + projectId: PROJECT_ID, + queryIds: [fixture.queryId], + }) + // Two revisions now exist: the new head plus the original. + expect(revs.length).toBeGreaterThanOrEqual(2) + expect(revs.every((r) => r.queryId === fixture.queryId)).toBe(true) + // Distinct revision ids, none archived. + expect(new Set(revs.map((r) => r.revisionId)).size).toBe(revs.length) + expect(revs.every((r) => r.deletedAt === null)).toBe(true) + }) + + // ── Per-revision archive / restore (the Archived-tab split we ship) ───────── + + it("archiving one revision removes it from the active history and surfaces it under includeArchived", async () => { + const {store} = createIntegrationStore() + const queryAtom = queryMolecule.atoms.query(fixture.queryId) + await waitForAtom<{isPending: boolean}>(store, queryAtom, (q) => !q.isPending) + const head = store.get(queryMolecule.atoms.serverData(fixture.queryId)) + const variantId = fixture.variantId ?? head?.variant_id ?? head?.query_variant_id ?? null + expect(variantId).toBeTruthy() + + // Commit a second revision so there's a non-head revision to archive. + await commitQueryRevision({ + projectId: PROJECT_ID, + variantId: variantId as string, + data: {filtering: fixture.filtering as never}, + name: fixture.name, + message: "integration: revision to archive", + }) + + const before = await queryRevisionsForQueries({ + projectId: PROJECT_ID, + queryIds: [fixture.queryId], + }) + // Archive the oldest revision (the original head before the commit). + const target = before[before.length - 1] + expect(target?.revisionId).toBeTruthy() + + await archiveQueryRevision({projectId: PROJECT_ID, revisionId: target.revisionId}) + + // Active history (no includeArchived) no longer contains the archived revision. + const active = await queryRevisionsForQueries({ + projectId: PROJECT_ID, + queryIds: [fixture.queryId], + }) + expect(active.map((r) => r.revisionId)).not.toContain(target.revisionId) + + // includeArchived surfaces it, marked with a deletedAt — exactly what the + // Archived tab filters on (deletedAt && version > 0). + const withArchived = await queryRevisionsForQueries({ + projectId: PROJECT_ID, + queryIds: [fixture.queryId], + includeArchived: true, + }) + const archivedRev = withArchived.find((r) => r.revisionId === target.revisionId) + expect(archivedRev).toBeDefined() + expect(archivedRev?.deletedAt).not.toBeNull() + + // Restore returns it to the active history. + await unarchiveQueryRevision({projectId: PROJECT_ID, revisionId: target.revisionId}) + const restored = await queryRevisionsForQueries({ + projectId: PROJECT_ID, + queryIds: [fixture.queryId], + }) + expect(restored.map((r) => r.revisionId)).toContain(target.revisionId) + }) + + // ── Whole-query archive / restore (active vs archived list split) ─────────── + + it("archiving the query moves it from the active list to the archived list", async () => { + // Sanity: starts active, not archived. + const activeBefore = await querySimpleQueries({projectId: PROJECT_ID}) + expect((activeBefore.queries ?? []).map((q) => q.id)).toContain(fixture.queryId) + + await archiveSimpleQuery({projectId: PROJECT_ID, queryId: fixture.queryId}) + + // Active list (no includeArchived) excludes the archived query. + const activeAfter = await querySimpleQueries({projectId: PROJECT_ID}) + const activeAfterRow = (activeAfter.queries ?? []).find( + (q) => q.id === fixture.queryId && !q.deleted_at, + ) + expect(activeAfterRow).toBeUndefined() + + // includeArchived surfaces it with a deleted_at marker (the Archived tab). + const withArchived = await querySimpleQueries({ + projectId: PROJECT_ID, + includeArchived: true, + }) + const archivedRow = (withArchived.queries ?? []).find((q) => q.id === fixture.queryId) + expect(archivedRow).toBeDefined() + expect(archivedRow?.deleted_at).toBeTruthy() + }) +}) diff --git a/web/packages/agenta-entities/tests/unit/query/archiveTabs.test.ts b/web/packages/agenta-entities/tests/unit/query/archiveTabs.test.ts new file mode 100644 index 0000000000..e596d9cfa0 --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/query/archiveTabs.test.ts @@ -0,0 +1,65 @@ +import {describe, it, expect, vi, beforeEach} from "vitest" + +// Regression guard for the Active/Archived tabs: the Archived tab depends on the +// list passing `include_archived` and on the unarchive (restore) mutation sending +// the right request shape. + +const querySimpleQueriesMock = vi.fn() +const unarchiveSimpleQueryMock = vi.fn() + +vi.mock("@agenta/sdk", () => ({ + getAgentaSdkClient: () => ({ + queries: { + querySimpleQueries: querySimpleQueriesMock, + unarchiveSimpleQuery: unarchiveSimpleQueryMock, + }, + }), +})) + +vi.mock("@agenta/shared/api", () => ({ + getAgentaApiUrl: () => "http://test", +})) + +import {querySimpleQueries} from "../../../src/query/api/api" +import {unarchiveSimpleQuery} from "../../../src/query/api/mutations" + +describe("query archived-tab contracts", () => { + beforeEach(() => { + querySimpleQueriesMock.mockReset() + unarchiveSimpleQueryMock.mockReset() + }) + + it("omits include_archived for the active list", async () => { + querySimpleQueriesMock.mockResolvedValue({queries: [], count: 0}) + + await querySimpleQueries({projectId: "p1"}) + + expect(querySimpleQueriesMock).toHaveBeenCalledWith({}, {queryParams: {project_id: "p1"}}) + }) + + it("sends include_archived when archived rows are requested", async () => { + querySimpleQueriesMock.mockResolvedValue({queries: [], count: 0}) + + await querySimpleQueries({ + projectId: "p1", + includeArchived: true, + windowing: {limit: 50, order: "descending"}, + }) + + expect(querySimpleQueriesMock).toHaveBeenCalledWith( + {include_archived: true, windowing: {limit: 50, order: "descending"}}, + {queryParams: {project_id: "p1"}}, + ) + }) + + it("restores a query via unarchive with the project_id queryParam", async () => { + unarchiveSimpleQueryMock.mockResolvedValue(undefined) + + await unarchiveSimpleQuery({projectId: "p1", queryId: "q9"}) + + expect(unarchiveSimpleQueryMock).toHaveBeenCalledWith( + {query_id: "q9"}, + {queryParams: {project_id: "p1"}}, + ) + }) +}) diff --git a/web/packages/agenta-entities/tests/unit/query/commitQueryRevision.test.ts b/web/packages/agenta-entities/tests/unit/query/commitQueryRevision.test.ts new file mode 100644 index 0000000000..d31edf604c --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/query/commitQueryRevision.test.ts @@ -0,0 +1,55 @@ +import {describe, it, expect, vi, beforeEach} from "vitest" + +// Regression guard for the registry commit modal: edits commit a new revision via +// the git endpoint with a commit message + the head variant id. + +const commitQueryRevisionMock = vi.fn() + +vi.mock("@agenta/sdk", () => ({ + getAgentaSdkClient: () => ({ + queries: {commitQueryRevision: commitQueryRevisionMock}, + }), +})) + +vi.mock("@agenta/shared/api", () => ({ + getAgentaApiUrl: () => "http://test", +})) + +import {commitQueryRevision} from "../../../src/query/api/mutations" + +describe("commitQueryRevision", () => { + beforeEach(() => { + commitQueryRevisionMock.mockReset() + commitQueryRevisionMock.mockResolvedValue({}) + }) + + it("commits to the head variant with data, name, and message", async () => { + await commitQueryRevision({ + projectId: "p1", + variantId: "v1", + data: {filtering: {conditions: []}}, + name: "test-3", + message: "tightened the filter", + }) + + expect(commitQueryRevisionMock).toHaveBeenCalledWith( + { + query_revision: { + variant_id: "v1", + data: {filtering: {conditions: []}}, + name: "test-3", + message: "tightened the filter", + }, + }, + {queryParams: {project_id: "p1"}}, + ) + }) + + it("omits the message when none is given", async () => { + await commitQueryRevision({projectId: "p1", variantId: "v1", data: {}}) + + const [payload] = commitQueryRevisionMock.mock.calls[0] + expect(payload.query_revision).not.toHaveProperty("message") + expect(payload.query_revision.variant_id).toBe("v1") + }) +}) diff --git a/web/packages/agenta-entities/tests/unit/query/createSimpleQuery.test.ts b/web/packages/agenta-entities/tests/unit/query/createSimpleQuery.test.ts new file mode 100644 index 0000000000..efefa795a9 --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/query/createSimpleQuery.test.ts @@ -0,0 +1,75 @@ +import {describe, it, expect, vi, beforeEach} from "vitest" + +// Regression guard for the live-eval create-path repoint (T1): the Online +// Evaluation drawer now creates queries through this mutation, so its request +// shape and revision resolution must stay stable. + +const createSimpleQueryMock = vi.fn() +const retrieveQueryRevisionMock = vi.fn() + +vi.mock("@agenta/sdk", () => ({ + getAgentaSdkClient: () => ({ + queries: { + createSimpleQuery: createSimpleQueryMock, + retrieveQueryRevision: retrieveQueryRevisionMock, + }, + }), +})) + +vi.mock("@agenta/shared/api", () => ({ + getAgentaApiUrl: () => "http://test", +})) + +import {createSimpleQuery} from "../../../src/query/api/mutations" + +describe("createSimpleQuery (live-eval repoint regression)", () => { + beforeEach(() => { + createSimpleQueryMock.mockReset() + retrieveQueryRevisionMock.mockReset() + }) + + it("sends {query} with the project_id queryParam and returns ids from the create response", async () => { + createSimpleQueryMock.mockResolvedValue({ + query: {id: "q1", variant_id: "v1", revision_id: "r1"}, + }) + + const query = {name: "n", slug: "s", data: {filtering: {conditions: []}}} + const result = await createSimpleQuery({projectId: "p1", query}) + + expect(createSimpleQueryMock).toHaveBeenCalledWith( + {query}, + {queryParams: {project_id: "p1"}}, + ) + // Head revision came inlined on the create response — no extra round-trip. + expect(retrieveQueryRevisionMock).not.toHaveBeenCalled() + expect(result).toEqual({queryId: "q1", variantId: "v1", revisionId: "r1"}) + }) + + it("falls back to retrieveQueryRevision when the create response omits revision_id", async () => { + createSimpleQueryMock.mockResolvedValue({query: {id: "q2", variant_id: "v2"}}) + retrieveQueryRevisionMock.mockResolvedValue({query_revision: {id: "r2"}}) + + const result = await createSimpleQuery({projectId: "p1", query: {name: "n"}}) + + expect(retrieveQueryRevisionMock).toHaveBeenCalledWith( + {query_ref: {id: "q2"}}, + {queryParams: {project_id: "p1"}}, + ) + expect(result).toEqual({queryId: "q2", variantId: "v2", revisionId: "r2"}) + }) + + it("throws when no query id is returned", async () => { + createSimpleQueryMock.mockResolvedValue({query: null}) + + await expect(createSimpleQuery({projectId: "p1", query: {}})).rejects.toThrow( + /create query/i, + ) + }) + + it("throws when no revision can be resolved", async () => { + createSimpleQueryMock.mockResolvedValue({query: {id: "q3"}}) + retrieveQueryRevisionMock.mockResolvedValue({query_revision: null}) + + await expect(createSimpleQuery({projectId: "p1", query: {}})).rejects.toThrow(/revision/i) + }) +}) diff --git a/web/packages/agenta-entities/tests/unit/query/isQueryHeadDirty.test.ts b/web/packages/agenta-entities/tests/unit/query/isQueryHeadDirty.test.ts new file mode 100644 index 0000000000..cf8780ae2c --- /dev/null +++ b/web/packages/agenta-entities/tests/unit/query/isQueryHeadDirty.test.ts @@ -0,0 +1,56 @@ +import {describe, it, expect} from "vitest" + +import {isQueryHeadDirty} from "../../../src/query/state/molecule" + +const server = { + id: "rev1", + name: "test-3", + data: {filtering: {conditions: [{field: "trace_type", operator: "is", value: "invocation"}]}}, +} as never + +describe("isQueryHeadDirty (semantic draft diff)", () => { + it("is not dirty when there is no draft", () => { + expect(isQueryHeadDirty(server, null)).toBe(false) + }) + + it("is not dirty when the draft equals the server values", () => { + const draft = { + name: "test-3", + data: {filtering: {conditions: server.data.filtering.conditions}}, + } + expect(isQueryHeadDirty(server, draft as never)).toBe(false) + }) + + it("is not dirty when filtering matches but key order differs (order-insensitive)", () => { + const draft = { + data: { + filtering: { + conditions: [{value: "invocation", operator: "is", field: "trace_type"}], + }, + }, + } + expect(isQueryHeadDirty(server, draft as never)).toBe(false) + }) + + it("is dirty when the name changes", () => { + expect(isQueryHeadDirty(server, {name: "renamed"} as never)).toBe(true) + }) + + it("is dirty when the filter changes", () => { + const draft = { + data: { + filtering: { + conditions: [{field: "trace_type", operator: "is", value: "completion"}], + }, + }, + } + expect(isQueryHeadDirty(server, draft as never)).toBe(true) + }) + + it("is clean again when a changed value is reverted", () => { + // Draft round-tripped back to the server values must read as not dirty, + // unlike a `draft !== null` check. + const reverted = {name: server.name, data: {filtering: server.data.filtering}} + expect(isQueryHeadDirty(server, reverted as never)).toBe(false) + }) +}) diff --git a/web/packages/agenta-entity-ui/src/adapters/index.ts b/web/packages/agenta-entity-ui/src/adapters/index.ts index ab6580bef3..12b127a09e 100644 --- a/web/packages/agenta-entity-ui/src/adapters/index.ts +++ b/web/packages/agenta-entity-ui/src/adapters/index.ts @@ -9,3 +9,4 @@ export {testsetModalAdapter, revisionModalAdapter} from "./testsetAdapters" export {simpleQueueModalAdapter} from "./simpleQueueAdapters" export {variantModalAdapter} from "./variantAdapters" +export {queryModalAdapter} from "./queryAdapters" diff --git a/web/packages/agenta-entity-ui/src/adapters/queryAdapters.ts b/web/packages/agenta-entity-ui/src/adapters/queryAdapters.ts new file mode 100644 index 0000000000..aa09d8ba4d --- /dev/null +++ b/web/packages/agenta-entity-ui/src/adapters/queryAdapters.ts @@ -0,0 +1,76 @@ +/** + * Query Modal Adapter + * + * Registers the query entity for the unified commit modal, so query edits use the + * same EntityCommitModal as other git-style entities — version transition (vN → + * vN+1), a filtering/windowing JSON diff, and a commit message. + * + * Name editing and the save-mode / new-variant flow are NOT wired here: the + * registry drawer already owns the name field, and simple queries are + * single-variant, so the consuming modal omits those props. + */ + +import { + archiveSimpleQuery, + invalidateQueryCache, + queryMolecule, + saveQueryHeadAtom, + type QueryRevision, +} from "@agenta/entities/query" +import {projectIdAtom} from "@agenta/shared/state" +import {atom} from "jotai" + +import {createAndRegisterEntityAdapter, type CommitContext, type CommitParams} from "../modals" + +// Archive (soft-delete). Required by the adapter contract; the registry archives +// through its own confirm flow, so this is a fallback, not the primary path. +const queryDeleteAtom = atom(null, async (get, _set, ids: string[]): Promise => { + const projectId = get(projectIdAtom) + if (!projectId) throw new Error("No project id for query archive") + await Promise.all(ids.map((queryId) => archiveSimpleQuery({projectId, queryId}))) + invalidateQueryCache() +}) + +// Commit a new head revision with the modal's message (via the molecule). +const queryCommitAtom = atom(null, async (get, set, params: CommitParams): Promise => { + const projectId = get(projectIdAtom) + if (!projectId) throw new Error("No project id for query commit") + await set(saveQueryHeadAtom, {projectId, queryId: params.id, message: params.message}) +}) + +// What the diff preview compares — the committed fields only. +const diffShape = (rev: QueryRevision | null) => ({ + name: rev?.name ?? null, + filtering: rev?.data?.filtering ?? null, + windowing: rev?.data?.windowing ?? null, +}) + +const queryCommitContextAtom = (id: string) => + atom((get): CommitContext | null => { + const serverData = get(queryMolecule.atoms.serverData(id)) ?? null + const merged = get(queryMolecule.atoms.data(id)) ?? null + const currentVersion = Number(serverData?.version ?? 0) || 0 + const original = JSON.stringify(diffShape(serverData), null, 2) + const modified = JSON.stringify(diffShape(merged), null, 2) + return { + versionInfo: { + currentVersion, + targetVersion: currentVersion + 1, + latestVersion: currentVersion, + }, + diffData: {original, modified, language: "json"}, + ...(original !== modified + ? {changesSummary: {description: "Filter / sampling updated"}} + : {}), + } + }) + +export const queryModalAdapter = createAndRegisterEntityAdapter({ + type: "query", + getDisplayName: (q) => q?.name ?? "Untitled query", + getDisplayLabel: (count) => (count === 1 ? "Query" : "Queries"), + deleteAtom: queryDeleteAtom, + commitAtom: queryCommitAtom, + dataAtom: (id) => queryMolecule.atoms.data(id), + commitContextAtom: queryCommitContextAtom, +}) diff --git a/web/packages/agenta-entity-ui/src/modals/commit/components/EntityCommitModal.tsx b/web/packages/agenta-entity-ui/src/modals/commit/components/EntityCommitModal.tsx index 115ca8cfd4..33dc5c7881 100644 --- a/web/packages/agenta-entity-ui/src/modals/commit/components/EntityCommitModal.tsx +++ b/web/packages/agenta-entity-ui/src/modals/commit/components/EntityCommitModal.tsx @@ -12,7 +12,12 @@ import {message} from "@agenta/ui/app-message" import {EnhancedModal} from "@agenta/ui/components/modal" import {useAtomValue, useSetAtom} from "jotai" -import {revisionModalAdapter, testsetModalAdapter, variantModalAdapter} from "../../../adapters" +import { + queryModalAdapter, + revisionModalAdapter, + testsetModalAdapter, + variantModalAdapter, +} from "../../../adapters" import type { EntityReference, CommitSubmitResult, @@ -48,6 +53,7 @@ import {EntityCommitTitle} from "./EntityCommitTitle" void testsetModalAdapter void revisionModalAdapter void variantModalAdapter +void queryModalAdapter export type {CommitSubmitResult, CommitSubmitParams, CommitCreateFieldsConfig} diff --git a/web/packages/agenta-entity-ui/src/modals/types.ts b/web/packages/agenta-entity-ui/src/modals/types.ts index 809a2823a4..171769318c 100644 --- a/web/packages/agenta-entity-ui/src/modals/types.ts +++ b/web/packages/agenta-entity-ui/src/modals/types.ts @@ -23,6 +23,7 @@ export type EntityType = | "evaluator" | "application" | "simpleQueue" + | "query" /** * Reference to an entity for modal operations @@ -439,6 +440,7 @@ export function getEntityTypeLabel( evaluator: ["Evaluator", "Evaluators"], application: ["Application", "Applications"], simpleQueue: ["Annotation queue", "Annotation queues"], + query: ["Query", "Queries"], } const [singular, plural] = labels[type] diff --git a/web/packages/agenta-entity-ui/src/selection/adapters/index.ts b/web/packages/agenta-entity-ui/src/selection/adapters/index.ts index 1ea2f30946..2987e91628 100644 --- a/web/packages/agenta-entity-ui/src/selection/adapters/index.ts +++ b/web/packages/agenta-entity-ui/src/selection/adapters/index.ts @@ -99,6 +99,7 @@ export {renderEvaluatorPickerLabelNode, buildEvaluatorPickerLabelNode} from "./e // Enriched adapter hooks with auto-fetching evaluator template data export { + useEnsureEvaluatorEnrichment, useEvaluatorEnrichedData, useEnrichedEvaluatorBrowseAdapter, useEnrichedEvaluatorOnlyAdapter, diff --git a/web/packages/agenta-entity-ui/src/selection/adapters/useEnrichedEvaluatorAdapter.ts b/web/packages/agenta-entity-ui/src/selection/adapters/useEnrichedEvaluatorAdapter.ts index f84893cf29..99ccb7614e 100644 --- a/web/packages/agenta-entity-ui/src/selection/adapters/useEnrichedEvaluatorAdapter.ts +++ b/web/packages/agenta-entity-ui/src/selection/adapters/useEnrichedEvaluatorAdapter.ts @@ -13,19 +13,22 @@ */ import type React from "react" -import {useMemo, useRef} from "react" +import {useEffect, useMemo, useRef} from "react" import { + activateEvaluatorEnrichmentAtom, + evaluatorEnrichmentActivatedAtom, evaluatorKeyMapAtom, evaluatorTemplatesMapAtom, evaluatorTemplatesDataAtom, + type EvaluatorCatalogTemplate, evaluatorConfigsQueryStateAtom, evaluatorWorkflowMetaMapAtom, humanEvaluatorsListQueryAtom, workflowAppTypeAtomFamily, workflowsListDataAtom, } from "@agenta/entities/workflow" -import {atom, getDefaultStore, useAtomValue} from "jotai" +import {atom, getDefaultStore, useAtomValue, useSetAtom} from "jotai" import { renderEvaluatorPickerLabelNode, @@ -42,15 +45,48 @@ import { // SHARED ENRICHMENT HOOK // ============================================================================ +/** + * Activate the evaluator-enrichment gate (`evaluatorEnrichmentActivatedAtom`). + * + * The aggregate evaluator atoms (key map, meta map, non-human list, feedback + * schemas) stay dormant — and mount no per-evaluator latest-revision fan-out — + * until something activates them. Pickers/switchers call this: eagerly (on mount, + * the default) or lazily (`enabled` gated on first open) so the batched + * `POST /workflows/revisions/query` only runs when the data is actually needed. + * Idempotent and one-way. + */ +export function useEnsureEvaluatorEnrichment(enabled = true) { + const activate = useSetAtom(activateEvaluatorEnrichmentAtom) + useEffect(() => { + if (enabled) activate() + }, [enabled, activate]) +} + +// Stable empties read while a lazy adapter is dormant, so the evaluator template +// catalog (a separate GET /evaluators/catalog/templates fetch) isn't requested +// until the gate opens. +const EMPTY_TEMPLATES_MAP_ATOM = atom>(new Map()) +const EMPTY_TEMPLATES_DATA_ATOM = atom([]) + /** * Hook that provides the evaluator key map and template definitions map. * * Uses package-level atoms (auto-fetching) instead of legacy SWR hooks, * so it works on any page without manual data population. + * + * Activates the enrichment gate on mount unless `lazy` is set — lazy callers + * (e.g. the playground header) defer activation to first picker-open so a plain + * playground load doesn't trigger the evaluator revision fan-out (and holds the + * template catalog read until the gate opens too). */ -export function useEvaluatorEnrichedData() { +export function useEvaluatorEnrichedData(options?: {lazy?: boolean}) { + useEnsureEvaluatorEnrichment(!options?.lazy) + const activated = useAtomValue(evaluatorEnrichmentActivatedAtom) + const wantData = !options?.lazy || activated const evaluatorKeyMap = useAtomValue(evaluatorKeyMapAtom) - const evaluatorDefsByKey = useAtomValue(evaluatorTemplatesMapAtom) + const evaluatorDefsByKey = useAtomValue( + wantData ? evaluatorTemplatesMapAtom : EMPTY_TEMPLATES_MAP_ATOM, + ) return {evaluatorKeyMap, evaluatorDefsByKey} } @@ -137,10 +173,14 @@ export function useEnrichedEvaluatorBrowseAdapter() { */ export function useEnrichedEvaluatorOnlyAdapter( revisionLabelOverride?: (entity: unknown) => React.ReactNode, - options?: {showWorkflowMeta?: boolean; splitTypeTag?: boolean}, + options?: {showWorkflowMeta?: boolean; splitTypeTag?: boolean; lazy?: boolean}, ) { - const {evaluatorKeyMap, evaluatorDefsByKey} = useEvaluatorEnrichedData() - const templates = useAtomValue(evaluatorTemplatesDataAtom) + const {evaluatorKeyMap, evaluatorDefsByKey} = useEvaluatorEnrichedData({lazy: options?.lazy}) + const activated = useAtomValue(evaluatorEnrichmentActivatedAtom) + const wantData = !options?.lazy || activated + const templates = useAtomValue( + wantData ? evaluatorTemplatesDataAtom : EMPTY_TEMPLATES_DATA_ATOM, + ) const workflowMetaMap = useAtomValue(evaluatorWorkflowMetaMapAtom) const evaluatorKeyMapRef = useRef(evaluatorKeyMap) const evaluatorDefsByKeyRef = useRef(evaluatorDefsByKey) @@ -155,6 +195,13 @@ export function useEnrichedEvaluatorOnlyAdapter( const hasRevisionLabelOverride = Boolean(revisionLabelOverride) const showWorkflowMeta = Boolean(options?.showWorkflowMeta) const splitTypeTag = Boolean(options?.splitTypeTag) + // The EntityPicker subscribes to the list atom below on MOUNT (even while + // closed), and that list flows through evaluatorConfigsQueryStateAtom → + // evaluatorRevisionFlagsMapAtom, which fans out a latest-revision query per + // evaluator. For lazy callers we hold that list empty until the shared + // enrichment gate opens, so a closed picker mounts no fan-out. + const lazyRef = useRef(Boolean(options?.lazy)) + lazyRef.current = Boolean(options?.lazy) // Build a stable Map from template data const templateCategoryMap = useMemo(() => { @@ -174,6 +221,10 @@ export function useEnrichedEvaluatorOnlyAdapter( const autoEvaluatorsListAtom = useMemo( () => atom((get) => { + // Lazy + gate-closed → don't subscribe to the fan-out list yet. + if (lazyRef.current && !get(evaluatorEnrichmentActivatedAtom)) { + return {data: [] as unknown[], isPending: true, isError: false, error: null} + } const state = get(evaluatorConfigsQueryStateAtom) return { data: state.data as unknown[], diff --git a/web/packages/agenta-entity-ui/src/selection/index.ts b/web/packages/agenta-entity-ui/src/selection/index.ts index 6f46018939..e2ef92d2a0 100644 --- a/web/packages/agenta-entity-ui/src/selection/index.ts +++ b/web/packages/agenta-entity-ui/src/selection/index.ts @@ -211,6 +211,7 @@ export type { export { renderEvaluatorPickerLabelNode, buildEvaluatorPickerLabelNode, + useEnsureEvaluatorEnrichment, useEvaluatorEnrichedData, useEnrichedEvaluatorBrowseAdapter, useEnrichedEvaluatorOnlyAdapter, diff --git a/web/packages/agenta-ui/src/components/presentational/table-states/TableEmptyState.tsx b/web/packages/agenta-ui/src/components/presentational/table-states/TableEmptyState.tsx index 3062b8a542..fc4dbe8d8d 100644 --- a/web/packages/agenta-ui/src/components/presentational/table-states/TableEmptyState.tsx +++ b/web/packages/agenta-ui/src/components/presentational/table-states/TableEmptyState.tsx @@ -14,13 +14,21 @@ * ``` */ -import {Empty} from "antd" +import type {ReactNode} from "react" + +import {Empty, Typography} from "antd" import {cn, flexLayouts, justifyClasses} from "../../../utils/styles" +const {Text} = Typography + export interface TableEmptyStateProps { /** Message to display (default: "No data found") */ message?: string + /** Secondary explanatory line shown under the message (e.g. what this list is). */ + description?: ReactNode + /** Primary action shown below the text (e.g. a "New …" CTA button). */ + action?: ReactNode /** Additional CSS class names */ className?: string /** Use simple image variant (default: true) */ @@ -29,6 +37,8 @@ export interface TableEmptyStateProps { export function TableEmptyState({ message = "No data found", + description, + action, className, simple = true, }: TableEmptyStateProps) { @@ -36,8 +46,21 @@ export function TableEmptyState({
+ description={ + description ? ( +
+ {message} + + {description} + +
+ ) : ( + message + ) + } + > + {action} +
) }