数据目录 F3 / B1 界面:运行横幅、时间线和版本历史把 Agent 可能查询的表和直接引用分开说——直接引用写「有变化」,可能涉及写「Agent 可能查询的表有变化」并另起一组;版本历史按新的发布记录两组列出,发布之后才建目录的表也列;补发布记录两种写法的单测和三个检查脚本的断言

This commit is contained in:
ThearchyHelios
2026-10-04 01:24:50 +08:00
parent 52aaaaf945
commit 3ece89872b
9 changed files with 222 additions and 69 deletions
+15
View File
@@ -205,3 +205,18 @@ def test_assistant_seeds_read_tool_nodes_the_same_way():
assert graph_tables(graph, [shop]) == {"shop": ["customers", "orders", "stores"]}
graph["nodes"] = [n for n in graph["nodes"] if n["id"] != "q2"]
assert graph_tables(graph, [shop]) == {"shop": ["customers", "orders"]}
def test_recorded_versions_reads_both_shapes():
"""发布记录两种写法:现在的 {direct, possible},和可能涉及上线之前只记直接引用的 {源名: {表名: 版本}}。
源名可以就叫 direct / possible,靠第二层是不是版本号分开。"""
from app.data.catalog_impact import recorded_versions
now = {"direct": {"shop": {"orders": 2}}, "possible": {"shop": {"customers": 1}, "crm": {}}}
assert recorded_versions(now) == now
assert recorded_versions({"direct": {}, "possible": {}}) == {"direct": {}, "possible": {}}
assert recorded_versions({"shop": {"orders": 2}}) == {"direct": {"shop": {"orders": 2}}, "possible": {}}
# 旧写法里一个叫 direct 的源
assert recorded_versions({"direct": {"orders": 2}}) == {"direct": {"direct": {"orders": 2}}, "possible": {}}
assert recorded_versions({}) == {"direct": {}, "possible": {}}
assert recorded_versions(None) is None and recorded_versions("x") is None and recorded_versions({"shop": 3}) is None
+55 -28
View File
@@ -10,7 +10,7 @@ import { formatDateTime, formatTime } from '../lib/format'
import { CATALOG_DRIFT_TEXT, WORKFLOW_STATUS_LABEL } from '../lib/terms'
import { hintOf } from './shortcuts'
import type { Edge } from '@xyflow/react'
import type { CatalogDriftTable, GraphSpec, Workflow, WorkflowVersion } from '../types'
import type { CatalogDriftTable, CatalogVersions as CatalogVersionsRecord, GraphSpec, Workflow, WorkflowVersion } from '../types'
/**
* 版本历史。盖在右栏上的一层,和属性面板同一种「临时造访」。
@@ -212,38 +212,65 @@ function VersionPreview({ version, target, diff, lock, onRestore }: {
const DIFF_COLOR = { add: 'var(--ok)', del: 'var(--err)', mod: 'var(--warn)' } as const
/**
* 发布时的目录版本:这一版 SQL 用到的表在发布时是数据目录的第几版,之后改过的标出来——下次从这一版发起正式
* 运行时会提醒「数据目录有变化」,这里先让人看到是哪几张表
* 发布时的目录版本:这一版用到的表在发布时是数据目录的第几版,之后改过的标出来——下次从这一版发起正式运行时会
* 提醒「数据目录有变化」,这里先让人看到是哪几张表。
*
* 两组分开列:SQL 里写着的表在前;Agent 可能查询的表(Agent 绑定了数据源的查询工具,发布时记下数据源中所有有
* 目录的表)在小标题下面。可能涉及的数据源里发布之后新建了目录的表,记录里没有它,按变化里给的补进来(发布时
* 「尚无目录」)
*/
function CatalogVersions({ versions, changes }: {
versions: Record<string, Record<string, number>>
changes: CatalogDriftTable[]
}) {
const rows = Object.entries(versions).flatMap(([source, tables]) =>
Object.entries(tables).map(([table, v]) => ({ source, table, v })))
const changed = new Map(changes.map((c) => [`${c.source}/${c.table}`, c]))
function CatalogVersions({ versions, changes }: { versions: CatalogVersionsRecord; changes: CatalogDriftTable[] }) {
// 老后端给的变化没有 impact:按直接引用
const impactOf = (c: CatalogDriftTable) => (c.impact === 'possible' ? 'possible' : 'direct')
const changed = new Map(changes.map((c) => [`${impactOf(c)}/${c.source}/${c.table}`, c]))
const rowsOf = (impact: CatalogDriftTable['impact']) => {
const recorded = Object.entries(versions[impact] ?? {}).flatMap(([source, tables]) =>
Object.entries(tables).map(([table, v]) => ({ source, table, v: v as number | null })))
const known = new Set(recorded.map((r) => `${r.source}/${r.table}`))
const added = changes.filter((c) => impactOf(c) === impact && !known.has(`${c.source}/${c.table}`))
.map((c) => ({ source: c.source, table: c.table, v: c.published }))
return [...recorded, ...added]
}
const direct = rowsOf('direct')
const possible = rowsOf('possible')
const bare = Object.entries(versions.possible ?? {})
.filter(([source, tables]) => !Object.keys(tables).length && !possible.some((r) => r.source === source))
.map(([source]) => source)
const list = (impact: CatalogDriftTable['impact'], rows: typeof direct) => (
<ul className="mt-1 space-y-0.5 text-2xs">
{rows.map(({ source, table, v }) => {
const c = changed.get(`${impact}/${source}/${table}`)
return (
<li key={`${source}/${table}`} className="flex min-w-0 flex-wrap items-baseline gap-x-1.5"
data-version-catalog-table={table} data-impact={impact} data-changed={c ? 'true' : undefined}>
<span className="mono min-w-0 truncate" title={`${source} · ${table}`}>{table}</span>
<span className="shrink-0 text-faint">{CATALOG_DRIFT_TEXT.version(v)}</span>
{c && (
<span className="shrink-0" style={{ color: 'var(--st-waiting)' }}>
{CATALOG_DRIFT_TEXT.changedSince(CATALOG_DRIFT_TEXT.version(c.current))}
</span>
)}
</li>
)
})}
</ul>
)
const empty = !direct.length && !possible.length && !bare.length
return (
<div className="mt-3 border-t pt-2" data-version-catalog={changes.length}>
<div className="text-2xs font-medium text-dim" title={CATALOG_DRIFT_TEXT.sectionHint}>{CATALOG_DRIFT_TEXT.section}</div>
{!rows.length ? <div className="mt-1 text-2xs text-faint">{CATALOG_DRIFT_TEXT.empty}</div> : (
{empty ? <div className="mt-1 text-2xs text-faint">{CATALOG_DRIFT_TEXT.empty}</div> : (
<>
<ul className="mt-1 space-y-0.5 text-2xs">
{rows.map(({ source, table, v }) => {
const c = changed.get(`${source}/${table}`)
return (
<li key={`${source}/${table}`} className="flex min-w-0 flex-wrap items-baseline gap-x-1.5" data-version-catalog-table={table}
data-changed={c ? 'true' : undefined}>
<span className="mono min-w-0 truncate" title={`${source} · ${table}`}>{table}</span>
<span className="shrink-0 text-faint">{CATALOG_DRIFT_TEXT.version(v)}</span>
{c && (
<span className="shrink-0" style={{ color: 'var(--st-waiting)' }}>
{CATALOG_DRIFT_TEXT.changedSince(CATALOG_DRIFT_TEXT.version(c.current))}
</span>
)}
</li>
)
})}
</ul>
{direct.length > 0 && list('direct', direct)}
{(possible.length > 0 || bare.length > 0) && (
<div className="mt-1.5" data-version-catalog-possible={possible.length}>
<div className="text-2xs text-dim" title={CATALOG_DRIFT_TEXT.possibleHint}>{CATALOG_DRIFT_TEXT.possibleHead}</div>
{possible.length > 0 && list('possible', possible)}
{bare.map((source) => (
<div key={source} className="mt-0.5 text-2xs text-faint">{CATALOG_DRIFT_TEXT.possibleNone(source)}</div>
))}
</div>
)}
<div className="mt-1 text-2xs" style={{ color: changes.length ? 'var(--st-waiting)' : undefined }}>
<span className={changes.length ? undefined : 'text-faint'}>
{changes.length ? CATALOG_DRIFT_TEXT.changedCount(changes.length) : CATALOG_DRIFT_TEXT.unchanged}
+16 -2
View File
@@ -2224,26 +2224,40 @@ export const CATALOG_IMPACT_TEXT = {
}
/**
* 发布时的目录版本:发布时记下这一版 SQL 用到的表的数据目录版本;从这一版发起正式运行时目录有变化,提醒而不拦。
* 发布时的目录版本:发布时记下这一版用到的表的数据目录版本;从这一版发起正式运行时目录有变化,提醒而不拦。
* 版本号写「第 N 版」,还没有目录的表写「尚无目录」。
*
* 和影响面(CATALOG_IMPACT_TEXT)同一个分法:SQL 里写着的表是直接引用,说「有变化」;Agent 绑定了数据源的查询工具、
* SQL 运行时才写的,记的是这个数据源中所有有目录的表,说「Agent 可能查询的表有变化」——两组分开写,不混成一句。
*/
export const CATALOG_DRIFT_TEXT = {
/** 运行里的提醒:「自发布以来,数据目录中「入园记录」等 2 张表有变化」 */
title: (first: string, n: number) => (n > 1
? `自发布以来,数据目录中「${first}」等 ${formatNumber(n)} 张表有变化`
: `自发布以来,数据目录中「${first}」有变化`),
/** 只有 Agent 可能查询的表变了:「自发布以来,Agent 可能查询的表有变化:「入园记录」等 3 张」 */
possibleTitle: (first: string, n: number) => (n > 1
? `自发布以来,Agent 可能查询的表有变化:「${first}」等 ${formatNumber(n)} 张`
: `自发布以来,Agent 可能查询的表有变化:「${first}」`),
/** 两组都有:直接引用的标题后面补一句 */
alsoPossible: (n: number) => `;另有 ${formatNumber(n)} 张 Agent 可能查询的表有变化`,
sub: '运行未被拦截。请核对这些表的目录修改是否影响本次结果',
table: (label: string | null, table: string) => (label ? `${label}(${table})` : table),
version: (v: number | null) => (v ? `第 ${formatNumber(v)} 版` : '尚无目录'),
line: (source: string, table: string, from: string, to: string) => `「${source}」${table}:发布时${from},现为${to}`,
open: '查看目录',
/** 可能涉及那一组的小标题和说明(运行横幅、时间线展开、版本历史共用) */
possibleHead: 'Agent 可能查询的表',
possibleHint: 'Agent 绑定了这些数据源的查询工具,SQL 在运行时生成:发布时记下了数据源中所有有目录的表,不一定都会用到',
// ---- 版本历史里的「发布时的目录版本」
section: '发布时的目录版本',
sectionHint: '这一版 SQL 用到的表在发布时的数据目录版本',
sectionHint: '这一版 SQL 用到的表,以及 Agent 可能查询的表,在发布时的数据目录版本',
changedSince: (now: string) => `之后有变化,现为${now}`,
changedCount: (n: number) => `${formatNumber(n)} 张表在发布之后有变化,下次正式运行时会提醒`,
unchanged: '发布之后这些表的目录没有变化',
empty: '这一版的 SQL 没有用到数据库表',
/** 可能涉及的数据源在发布时一张有目录的表都没有 */
possibleNone: (source: string) => `「${source}」发布时还没有目录`,
}
// ===========================================================================
+36 -21
View File
@@ -5,6 +5,7 @@ import { CopyButton, Spinner, StatusBadge } from '../../components/ui'
import { formatDateTime, formatSpan, formatTime } from '../../lib/format'
import type { Approval, CatalogDriftTable } from '../../types'
import { CATALOG_DRIFT_TEXT, CATALOG_IMPACT_TEXT } from '../../lib/terms'
import { catalogDriftLine } from '../../run/decode'
import type { RunErrorExplain } from '../../lib/explain'
import { LONG_WAIT_MS, ageMs } from './model'
import { copyText } from './parts'
@@ -28,12 +29,14 @@ function Shell({ color, children, data }: { color: string; children: ReactNode;
// -------------------------------------------------------------------------
/**
* 从发布版本发起的正式运行,开始时发现这一版 SQL 用到的表的数据目录在发布之后改过。运行照常(目录是说明,
* 从发布版本发起的正式运行,开始时发现这一版用到的表的数据目录在发布之后改过。运行照常(目录是说明,
* 不是运行的输入),但看结果的人要知道:「在园人数」在发布之后被改成了存量,这一版的 SQL 可能还在跨天相加。
* 每张表给到数据目录的入口;表多时只列前几张
* SQL 里写着的表在前;Agent 可能查询的表(Agent 运行时自己写 SQL)另起一组、带小标题,和直接引用分开。
* 每张表给到数据目录的入口;每组表多时只列前几张
*/
export function CatalogDriftBanner({ drift }: { drift: { title: string; tables: CatalogDriftTable[] } }) {
const shown = drift.tables.slice(0, DRIFT_SHOWN)
export function CatalogDriftBanner({ drift }: {
drift: { title: string; direct: CatalogDriftTable[]; possible: CatalogDriftTable[] }
}) {
return (
<Shell color="var(--st-waiting)" data="catalog-drift">
<div className="flex items-start gap-2.5">
@@ -41,29 +44,41 @@ export function CatalogDriftBanner({ drift }: { drift: { title: string; tables:
<div className="min-w-0 flex-1 text-xs">
<p className="font-semibold" data-drift-title="">{drift.title}</p>
<p className="mt-0.5 text-2xs text-dim">{CATALOG_DRIFT_TEXT.sub}</p>
<ul className="mt-1 space-y-0.5 text-2xs text-dim">
{shown.map((t) => (
<li key={`${t.source}/${t.table}`} className="flex min-w-0 flex-wrap items-baseline gap-x-2" data-drift-table={t.table}>
<span className="min-w-0 break-words">
{CATALOG_DRIFT_TEXT.line(t.source, CATALOG_DRIFT_TEXT.table(t.label, t.table),
CATALOG_DRIFT_TEXT.version(t.published), CATALOG_DRIFT_TEXT.version(t.current))}
</span>
{t.source_id && (
<Link className="shrink-0 text-[var(--accent)] hover:underline" data-drift-open={t.table}
to={`/data/catalog/${encodeURIComponent(t.source_id)}/${encodeURIComponent(t.table)}`}>
{CATALOG_DRIFT_TEXT.open}
</Link>
)}
</li>
))}
{drift.tables.length > shown.length && <li className="text-faint">{CATALOG_IMPACT_TEXT.more(drift.tables.length - shown.length)}</li>}
</ul>
{drift.direct.length > 0 && <DriftTables tables={drift.direct} />}
{drift.possible.length > 0 && (
<div className="mt-1.5" data-drift-possible={drift.possible.length}>
<p className="text-2xs font-medium text-dim">{CATALOG_DRIFT_TEXT.possibleHead}</p>
<p className="text-2xs text-faint">{CATALOG_DRIFT_TEXT.possibleHint}</p>
<DriftTables tables={drift.possible} />
</div>
)}
</div>
</div>
</Shell>
)
}
function DriftTables({ tables }: { tables: CatalogDriftTable[] }) {
const shown = tables.slice(0, DRIFT_SHOWN)
return (
<ul className="mt-1 space-y-0.5 text-2xs text-dim">
{shown.map((t) => (
<li key={`${t.source}/${t.table}`} className="flex min-w-0 flex-wrap items-baseline gap-x-2" data-drift-table={t.table}
data-drift-impact={t.impact}>
<span className="min-w-0 break-words">{catalogDriftLine(t)}</span>
{t.source_id && (
<Link className="shrink-0 text-[var(--accent)] hover:underline" data-drift-open={t.table}
to={`/data/catalog/${encodeURIComponent(t.source_id)}/${encodeURIComponent(t.table)}`}>
{CATALOG_DRIFT_TEXT.open}
</Link>
)}
</li>
))}
{tables.length > shown.length && <li className="text-faint">{CATALOG_IMPACT_TEXT.more(tables.length - shown.length)}</li>}
</ul>
)
}
const DRIFT_SHOWN = 6
// -------------------------------------------------------------------------
+28 -8
View File
@@ -2556,25 +2556,45 @@ function labelsOf(ops: CopilotOp[], fallback?: (id: string) => string | undefine
return (id) => map.get(id) ?? fallback?.(id)
}
/** 一张表的那一行:「scenic」入园记录(visits):发布时第 3 版,现为第 5 版 */
export function catalogDriftLine(t: CatalogDriftTable): string {
return CATALOG_DRIFT_TEXT.line(t.source, CATALOG_DRIFT_TEXT.table(t.label, t.table),
CATALOG_DRIFT_TEXT.version(t.published), CATALOG_DRIFT_TEXT.version(t.current))
}
/**
* catalog.drift 事件 → 提醒的标题和逐表明细。标题按第一张表的中文名说(「入园记录」等 2 张表),明细每张表一行:
* 发布时第几版、现在第几版(0 写「尚无目录」)。认不出的形状返回 null
* catalog.drift 事件 → 提醒的标题和逐表明细。每张表带 impact:direct 是 SQL 里写着的表,possible 是 Agent 可能
* 查询的表(Agent 绑定了数据源的查询工具,SQL 运行时才生成),两组分开说:
* - 标题按第一张直接引用的表说(「入园记录」等 2 张表有变化),还有可能涉及的补一句「另有 N 张 Agent 可能查询的表
* 有变化」;只有可能涉及的,标题直说「Agent 可能查询的表有变化」
* - 明细每张表一行:发布时第几版、现在第几版(0 写「尚无目录」);可能涉及的放在小标题下面
* 老事件的表没有 impact,按直接引用。认不出的形状返回 null
*/
export function catalogDrift(d: Record<string, any>): { title: string; detail: string; tables: CatalogDriftTable[] } | null {
export function catalogDrift(d: Record<string, any>): {
title: string; detail: string; tables: CatalogDriftTable[]; direct: CatalogDriftTable[]; possible: CatalogDriftTable[]
} | null {
const tables: CatalogDriftTable[] = (Array.isArray(d?.tables) ? d.tables : [])
.filter((t: any) => t && typeof t === 'object' && typeof t.table === 'string' && typeof t.source === 'string')
.map((t: any) => ({
source: t.source, source_id: String(t.source_id ?? ''), table: t.table,
label: typeof t.label === 'string' && t.label ? t.label : null,
published: num(t.published) ?? null, current: num(t.current) ?? 0,
impact: t.impact === 'possible' ? 'possible' : 'direct',
}))
if (!tables.length) return null
const first = tables[0].label ?? tables[0].table
const direct = tables.filter((t) => t.impact === 'direct')
const possible = tables.filter((t) => t.impact === 'possible')
const name = (t: CatalogDriftTable) => t.label ?? t.table
const title = direct.length
? CATALOG_DRIFT_TEXT.title(name(direct[0]), direct.length) + (possible.length ? CATALOG_DRIFT_TEXT.alsoPossible(possible.length) : '')
: CATALOG_DRIFT_TEXT.possibleTitle(name(possible[0]), possible.length)
return {
title: CATALOG_DRIFT_TEXT.title(first, tables.length),
detail: tables.map((t) => CATALOG_DRIFT_TEXT.line(t.source, CATALOG_DRIFT_TEXT.table(t.label, t.table),
CATALOG_DRIFT_TEXT.version(t.published), CATALOG_DRIFT_TEXT.version(t.current))).join('\n'),
tables,
title,
detail: [
...direct.map(catalogDriftLine),
...(possible.length ? [`${CATALOG_DRIFT_TEXT.possibleHead}:`, ...possible.map(catalogDriftLine)] : []),
].join('\n'),
tables, direct, possible,
}
}
+15 -2
View File
@@ -56,12 +56,23 @@ export interface WorkflowVersion {
input_fields?: { name: string; required?: boolean; [key: string]: any }[]
/** 是不是当前的已发布版本 */
published?: boolean
/** 发布时这一版 SQL 用到的表的数据目录版本 {源名: {表名: 版本}},0 表示当时还没有目录;没记过为 null */
catalog_versions?: Record<string, Record<string, number>> | null
/** 发布时记下的数据目录版本,没记过为 null */
catalog_versions?: CatalogVersions | null
/** 发布之后目录有变化的表 */
catalog_changes?: CatalogDriftTable[]
}
/**
* 发布时记下的数据目录版本(服务端 catalog_impact.recorded_versions),两组都是 {源名: {表名: 版本}}:
* - direct:SQL 里写着的表,0 表示当时还没有目录
* - possible:Agent 绑定了查询工具的数据源中所有有目录的表(SQL 运行时才生成);源下为空表示当时一张有目录的
* 表都没有。只记了直接引用的老版本这一组为空
*/
export interface CatalogVersions {
direct: Record<string, Record<string, number>>
possible: Record<string, Record<string, number>>
}
/** 发布之后数据目录有变化的一张表(服务端 catalog_impact.catalog_drift;运行里的 catalog.drift 事件同形) */
export interface CatalogDriftTable {
source: string
@@ -72,6 +83,8 @@ export interface CatalogDriftTable {
/** 发布时的版本,0 表示当时还没有目录 */
published: number | null
current: number
/** direct:SQL 里写着这张表;possible:Agent 可能查询的表。老事件没有这一项,按 direct */
impact: 'direct' | 'possible'
}
/**
+20
View File
@@ -737,6 +737,26 @@ await section('正式运行:发布之后数据目录有变化(catalog.drift
&& row.detail.includes('「scenic」parks:发布时尚无目录,现为第 1 版'), row?.detail)
const one = mod.catalogDrift({ tables: [{ source: 's', table: 't', label: null, published: 1, current: 2 }] })
check('只有一张表:标题不写「等」', one?.title === '自发布以来,数据目录中「t」有变化', one?.title)
check('老事件的表没有 impact:按直接引用', one?.direct.length === 1 && one?.possible.length === 0 && one?.tables[0].impact === 'direct')
// Agent 可能查询的表(SQL 运行时才写):和直接引用分开说
const mixed = mod.catalogDrift({ tables: [
{ source: 'scenic', source_id: 'src-scenic', table: 'visits', label: '入园记录', published: 3, current: 5, impact: 'direct' },
{ source: 'scenic', source_id: 'src-scenic', table: 'orders', label: '订单', published: 2, current: 4, impact: 'possible' },
{ source: 'scenic', source_id: 'src-scenic', table: 'gates', label: null, published: 0, current: 1, impact: 'possible' },
] })
check('两组都有:标题说直接引用的,再补一句 Agent 可能查询的表有变化',
mixed?.title === '自发布以来,数据目录中「入园记录」有变化;另有 2 张 Agent 可能查询的表有变化', mixed?.title)
check('……展开:直接引用在前,可能涉及的在小标题下面', mixed?.detail === [
'「scenic」入园记录(visits):发布时第 3 版,现为第 5 版', 'Agent 可能查询的表:',
'「scenic」订单(orders):发布时第 2 版,现为第 4 版', '「scenic」gates:发布时尚无目录,现为第 1 版'].join('\n'), mixed?.detail)
const agentOnly = mod.catalogDrift({ tables: [
{ source: 'scenic', source_id: 'src-scenic', table: 'orders', label: '订单', published: 2, current: 4, impact: 'possible' },
] })
check('只有可能涉及的:标题直说「Agent 可能查询的表有变化」', agentOnly?.title === '自发布以来,Agent 可能查询的表有变化:「订单」', agentOnly?.title)
const run = mod.decodeRun([{ seq: 1, type: 'catalog.drift', node_id: null, ts: 1, data: { version: 2, count: 1, tables: [
{ source: 'scenic', source_id: 'src-scenic', table: 'orders', label: '订单', published: 2, current: 4, impact: 'possible' }] } }])
.find((s) => s.code === 'catalog_drift')
check('……时间线里那一行同样是警告', run?.level === 'warn' && run?.title === agentOnly?.title, run?.title)
check('形状不对不出行', mod.decodeRun([{ seq: 1, type: 'catalog.drift', node_id: null, ts: 1, data: { tables: 'x' } }])
.every((s) => s.code !== 'catalog_drift'))
})
+15 -4
View File
@@ -706,9 +706,11 @@ await section('正式运行:发布之后数据目录有变化,提醒但不
], edges: [{ source: 'a', target: 'q' }] }
const events = [
{ seq: 1, type: 'run.started', node_id: null, ts: t, data: { nodes: 2 } },
{ seq: 2, type: 'catalog.drift', node_id: null, ts: t + 0.01, data: { version: 3, count: 2, tables: [
{ source: 'scenic', source_id: 'src-scenic', table: 'visits', label: '入园记录', published: 3, current: 5 },
{ source: 'scenic', source_id: 'src-scenic', table: 'parks', label: null, published: 0, current: 1 },
{ seq: 2, type: 'catalog.drift', node_id: null, ts: t + 0.01, data: { version: 3, count: 3, tables: [
{ source: 'scenic', source_id: 'src-scenic', table: 'visits', label: '入园记录', published: 3, current: 5, impact: 'direct' },
{ source: 'scenic', source_id: 'src-scenic', table: 'parks', label: null, published: 0, current: 1, impact: 'direct' },
// Agent 绑定了这个源的查询工具:SQL 运行时才写,记的是源里有目录的表
{ source: 'scenic', source_id: 'src-scenic', table: 'orders', label: '订单', published: 2, current: 4, impact: 'possible' },
] } },
{ seq: 3, type: 'node.started', node_id: 'a', ts: t + 0.1, data: {} },
{ seq: 4, type: 'node.finished', node_id: 'a', ts: t + 0.2, data: { duration_ms: 100 } },
@@ -723,11 +725,20 @@ await section('正式运行:发布之后数据目录有变化,提醒但不
const banner = page.locator('[data-run-banner="catalog-drift"]')
check('详情显示目录变化的横幅', await banner.waitFor({ timeout: 5000 }).then(() => true, () => false))
const text = await banner.innerText().catch(() => '')
check('横幅标题:「入园记录」等 2 张表有变化', text.includes('自发布以来,数据目录中「入园记录」等 2 张表有变化'), text.split('\n')[0])
check('横幅标题:「入园记录」等 2 张表有变化,另有 1 张 Agent 可能查询的表有变化',
text.includes('自发布以来,数据目录中「入园记录」等 2 张表有变化;另有 1 张 Agent 可能查询的表有变化'), text.split('\n')[0])
check('写明运行未被拦截', text.includes('运行未被拦截'))
check('逐表写发布时和现在的版本,没有目录写「尚无目录」', text.includes('「scenic」入园记录(visits):发布时第 3 版,现为第 5 版')
&& text.includes('「scenic」parks:发布时尚无目录,现为第 1 版'), text.replace(/\s+/g, ' '))
check('每张表给到数据目录的入口', (await banner.locator('[data-drift-open="visits"]').getAttribute('href')) === '/data/catalog/src-scenic/visits')
const possible = banner.locator('[data-drift-possible]')
const possibleText = await possible.innerText().catch(() => '')
check('Agent 可能查询的表另起一组:小标题、说明、逐表一行', await possible.count() === 1
&& possibleText.includes('Agent 可能查询的表') && possibleText.includes('SQL 在运行时生成')
&& possibleText.includes('「scenic」订单(orders):发布时第 2 版,现为第 4 版'), possibleText.replace(/\s+/g, ' '))
check('……直接引用的表不在这一组里', await possible.locator('[data-drift-table="visits"]').count() === 0
&& (await banner.locator('[data-drift-table="orders"]').getAttribute('data-drift-impact')) === 'possible')
check('……也给到数据目录的入口', (await banner.locator('[data-drift-open="orders"]').getAttribute('href')) === '/data/catalog/src-scenic/orders')
check('运行照常完成:没有失败横幅', await page.locator('[data-run-banner=failed]').count() === 0)
const row = page.locator('[data-step-code="catalog_drift"]')
check('时间线里也有一行提醒', await row.count() === 1 && (await row.innerText()).includes('等 2 张表有变化'))
+22 -4
View File
@@ -263,11 +263,17 @@ async function open({ width = 1440, height = 900, path = '/studio/st-main', pref
if (sub === 'versions' && !v) return json(route, VERSIONS)
if (sub === 'versions' && v) {
const n = Number(v)
// 已发布的 v2 记着发布时的目录版本:入园记录之后改过(第 3 版 → 第 5 版),景区表没变
// 已发布的 v2 记着发布时的目录版本:入园记录之后改过(第 3 版 → 第 5 版),景区表没变;
// Agent 绑定了 shop 的查询工具,可能查询的表里订单改过、闸机是发布之后才建的目录,另一个源发布时还没有目录
return json(route, { ...VERSIONS.find((x) => x.version === n), workflow_id: id,
graph: n === 1 ? V1 : GRAPH, graph_hash: 'x', input_fields: [{ name: 'goal' }], published: n === 2,
catalog_versions: n === 2 ? { shop: { parks: 1, visits: 3 } } : null,
catalog_changes: n === 2 ? [{ source: 'shop', source_id: 'src-shop', table: 'visits', label: '入园记录', published: 3, current: 5 }] : [] })
catalog_versions: n === 2
? { direct: { shop: { parks: 1, visits: 3 } }, possible: { shop: { orders: 2, channels: 1 }, crm: {} } } : null,
catalog_changes: n === 2 ? [
{ source: 'shop', source_id: 'src-shop', table: 'visits', label: '入园记录', published: 3, current: 5, impact: 'direct' },
{ source: 'shop', source_id: 'src-shop', table: 'orders', label: '订单', published: 2, current: 4, impact: 'possible' },
{ source: 'shop', source_id: 'src-shop', table: 'gates', label: null, published: 0, current: 1, impact: 'possible' },
] : [] })
}
if (method === 'GET' && !sub) return state.deleted.has(id) ? json(route, { detail: '工作流不存在,可能已被删除' }, 404) : json(route, FAKES[id])
if (method === 'DELETE' && !sub) {
@@ -1011,8 +1017,20 @@ await section('版本历史:预览、恢复成一次可撤销的改动', async
const catText = await cat.innerText().catch(() => '')
check('已发布的版本写「发布时的目录版本」', catText.includes('发布时的目录版本') && catText.includes('parks') && catText.includes('第 1 版'), catText)
check('之后改过的表标出来', (await page.locator('[data-version-catalog-table="visits"]').getAttribute('data-changed')) === 'true'
&& catText.includes('之后有变化,现为第 5 版') && catText.includes('1 张表在发布之后有变化'), catText.replace(/\s+/g, ' '))
&& catText.includes('之后有变化,现为第 5 版') && catText.includes('3 张表在发布之后有变化'), catText.replace(/\s+/g, ' '))
check('没改过的表不标', (await page.locator('[data-version-catalog-table="parks"]').getAttribute('data-changed')) === null)
// Agent 可能查询的表:另起一组,和 SQL 里写着的表分开
const possible = cat.locator('[data-version-catalog-possible]')
const possibleText = await possible.innerText().catch(() => '')
check('Agent 可能查询的表另起一组', await possible.count() === 1 && possibleText.includes('Agent 可能查询的表')
&& await possible.locator('[data-version-catalog-table="visits"]').count() === 0, possibleText.replace(/\s+/g, ' '))
check('……改过的标出来,没改过的不标', (await possible.locator('[data-version-catalog-table="orders"]').getAttribute('data-changed')) === 'true'
&& (await possible.locator('[data-version-catalog-table="channels"]').getAttribute('data-changed')) === null
&& possibleText.includes('之后有变化,现为第 4 版'), possibleText.replace(/\s+/g, ' '))
check('……发布之后才建目录的表也列出来,发布时写「尚无目录」',
(await possible.locator('[data-version-catalog-table="gates"]').getAttribute('data-changed')) === 'true'
&& (await possible.locator('[data-version-catalog-table="gates"]').innerText()).includes('尚无目录'))
check('……发布时一张有目录的表都没有的源写一句', possibleText.includes('「crm」发布时还没有目录'), possibleText.replace(/\s+/g, ' '))
await page.locator('ol[aria-label="版本"] > li').nth(2).locator('button').first().click()
await page.waitForTimeout(300)
check('没发布过的版本没有目录版本这一栏', await count(page, '[data-version-catalog]') === 0)