Skip to content

Commit 3124a4c

Browse files
authored
Merge pull request #1768 from yjlu666/bugfix/superteam
fix(console): restore migration UI and protect resource references
2 parents 48c0c33 + 92fcb06 commit 3124a4c

19 files changed

Lines changed: 1394 additions & 489 deletions

File tree

‎console/backend/astron_console/knowledge/tests/test_resources.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from sqlalchemy import text as sql_text
1111

1212
from astron_console.accounts.models import User, UserProfile
13+
from astron_console.agents.models import Agent, AgentState, AgentVersion
1314
from astron_console.knowledge import clients, databases, files, storage
1415
from astron_console.knowledge.models import (
1516
Artifact,
@@ -24,6 +25,7 @@
2425
Repo,
2526
)
2627
from astron_console.spaces.models import GroupVisibility
28+
from astron_console.workflows.models import Workflow, WorkflowVersion
2729

2830

2931
class ResourceTests(TestCase):
@@ -93,6 +95,45 @@ def test_shared_read_does_not_grant_write(self):
9395
self.post("/repo/update-repo", {"id": self.repo.pk, "name": "attack"}).status_code, 403
9496
)
9597

98+
def test_repo_delete_and_use_status_preserve_python_references(self):
99+
for source in ("draft", "version", "workflow-version"):
100+
with self.subTest(source=source):
101+
repo = Repo.objects.create(
102+
name=source,
103+
user_id=self.user.pk,
104+
core_repo_id=f"core-{source}",
105+
tag="AIUI-RAG2",
106+
)
107+
agent = Agent.objects.create(uid=self.user.pk)
108+
if source == "draft":
109+
AgentState.objects.create(agent_id=agent.pk, config={"repoIds": [repo.pk]})
110+
elif source == "version":
111+
AgentVersion.objects.create(
112+
agent_id=agent.pk,
113+
definition={"repoIds": [repo.pk]},
114+
created_by=self.user.pk,
115+
)
116+
else:
117+
workflow = Workflow.objects.create(
118+
uid=self.user.pk, app_id="app", flow_id="repo-flow", name="Saved", data="{}"
119+
)
120+
WorkflowVersion.objects.create(
121+
flow_id=workflow.flow_id,
122+
version_num="v1",
123+
data=json.dumps(
124+
{"nodes": [{"data": {"nodeParam": {"repoIds": [repo.pk]}}}]}
125+
),
126+
)
127+
usage = self.client.get("/repo/get-repo-use-status", {"id": repo.pk})
128+
self.assertEqual(usage.status_code, 200, usage.content)
129+
self.assertTrue(usage.json()["data"])
130+
with patch("astron_console.knowledge.views.delete_document") as remove:
131+
result = self.client.delete(f"/repo/delete-repo?id={repo.pk}")
132+
self.assertEqual(result.status_code, 400, result.content)
133+
remove.assert_not_called()
134+
repo.refresh_from_db()
135+
self.assertFalse(repo.deleted)
136+
96137
@patch(
97138
"astron_console.knowledge.clients.rag",
98139
side_effect=clients.ResourceServiceError("资源服务暂时不可用"),

‎console/backend/astron_console/knowledge/views.py‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,14 @@ def check_references(table, column, identifier):
163163
raise ValidationError("资源仍被引用,不能删除")
164164

165165

166+
def check_repo_references(repo):
167+
from astron_console.resources.references import ensure_definition_unreferenced
168+
169+
check_references("flow_repo_rel", "repo_id", repo.core_repo_id)
170+
check_references("bot_repo_rel", "repo_id", repo.pk)
171+
ensure_definition_unreferenced("repo", {repo.pk, repo.core_repo_id})
172+
173+
166174
def rag_params(repo, file):
167175
return {
168176
"ragType": repo.tag,
@@ -258,8 +266,7 @@ def repos(request, action):
258266
return [file_dump(x) for x in Document.objects.filter(repo_id=row.pk)]
259267
if action == "get-repo-use-status":
260268
try:
261-
check_references("flow_repo_rel", "repo_id", row.core_repo_id)
262-
check_references("bot_repo_rel", "repo_id", row.pk)
269+
check_repo_references(row)
263270
except ValidationError:
264271
return True
265272
return False
@@ -313,8 +320,7 @@ def repos(request, action):
313320
elif action == "set-top":
314321
row.is_top = not row.is_top
315322
elif action == "delete-repo":
316-
check_references("flow_repo_rel", "repo_id", row.core_repo_id)
317-
check_references("bot_repo_rel", "repo_id", row.pk)
323+
check_repo_references(row)
318324
for file in Document.objects.filter(repo_id=row.pk):
319325
delete_document(file, row)
320326
Directory.objects.filter(app_id=str(row.pk)).delete()

‎console/backend/astron_console/resources/model_views.py‎

Lines changed: 43 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828
ModelCategory,
2929
ModelCategoryRelation,
3030
OfficialModel,
31-
WorkflowScope,
3231
)
3332

3433
logger = logging.getLogger(__name__)
@@ -280,25 +279,52 @@ def _java_llm_id(pk):
280279

281280

282281
def ensure_unreferenced(row):
282+
from astron_console.agents.models import Agent, AgentVersion
283+
from astron_console.workflows.models import Workflow, WorkflowVersion
284+
283285
tables = connection.introspection.table_names()
286+
if "chat_bot_base" in tables:
287+
agents = Agent.objects.filter(is_delete=0, version=1)
288+
if agents.filter(model_id=row.pk).exists():
289+
raise ValidationError("模型仍被 Agent 引用,无法删除")
290+
if (
291+
"console_agent_version" in tables
292+
and AgentVersion.objects.filter(
293+
agent_id__in=agents.values("pk"), definition__modelId__in=[row.pk, str(row.pk)]
294+
).exists()
295+
):
296+
raise ValidationError("模型仍被 Agent 版本引用,无法删除")
284297
if "workflow" in tables:
285-
refs = {row.id, _java_llm_id(row.id), (row.id + 123456) ^ 654321}
286-
for item in WorkflowScope.objects.filter(deleted=False):
287-
data = parsed(item.data)
288-
289-
def references(value):
290-
if isinstance(value, dict):
291-
for key, v in value.items():
292-
if key in {"modelId", "llmId"} and str(v) in {str(r) for r in refs}:
293-
return True
294-
if references(v):
295-
return True
296-
if isinstance(value, list):
297-
return any(references(v) for v in value)
298-
return False
299-
300-
if references(data):
298+
refs = {str(value) for value in (row.id, _java_llm_id(row.id), (row.id + 123456) ^ 654321)}
299+
300+
def references(value):
301+
if isinstance(value, dict):
302+
return any(
303+
(key in {"modelId", "llmId"} and str(item) in refs) or references(item)
304+
for key, item in value.items()
305+
)
306+
if isinstance(value, list):
307+
return any(references(item) for item in value)
308+
return False
309+
310+
for item in (
311+
Workflow.objects.filter(deleted=False)
312+
.only("flow_id", "data", "published_data")
313+
.iterator()
314+
):
315+
if references(parsed(item.data)) or references(parsed(item.published_data)):
301316
raise ValidationError("模型仍被工作流引用,无法删除")
317+
# Versions remain executable after the editable draft changes. Match each
318+
# retained flow ID as a value, avoiding cross-table collation assumptions.
319+
if "workflow_version" in tables and item.flow_id:
320+
versions = (
321+
WorkflowVersion.objects.filter(flow_id=item.flow_id, is_deleted=0)
322+
.exclude(deleted=2)
323+
.values_list("data", flat=True)
324+
.iterator()
325+
)
326+
if any(references(parsed(data)) for data in versions):
327+
raise ValidationError("模型仍被工作流版本引用,无法删除")
302328
if "bot_model_bind" in tables:
303329
with connection.cursor() as cursor:
304330
cursor.execute("SELECT 1 FROM bot_model_bind WHERE domain = %s LIMIT 1", [row.domain])
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"""Deletion guards for references stored in Python drafts and immutable versions."""
2+
3+
from django.core.exceptions import ValidationError
4+
5+
from .common import parsed
6+
7+
8+
def ensure_definition_unreferenced(kind, identifiers):
9+
from astron_console.agents.definitions import definition
10+
from astron_console.agents.models import Agent, AgentVersion
11+
from astron_console.workflows.models import Workflow, WorkflowVersion
12+
13+
identifiers = {str(value) for value in identifiers if value not in (None, "")}
14+
fields = {
15+
"tool": {"tools", "pluginId", "toolId", "tool_id"},
16+
"repo": {"repoIds", "repoId", "knowledge"},
17+
}[kind]
18+
message = {"tool": "工具", "repo": "知识库"}[kind] + "仍被 Agent 或工作流引用,无法删除"
19+
20+
def matches(value):
21+
if isinstance(value, list):
22+
return any(matches(item) for item in value)
23+
if isinstance(value, dict):
24+
return any(matches(value[key]) for key in fields | {"id"} if key in value)
25+
return not isinstance(value, bool) and str(value) in identifiers
26+
27+
def check(value):
28+
if isinstance(value, dict):
29+
for key, item in value.items():
30+
if key in fields and matches(item):
31+
raise ValidationError(message)
32+
check(item)
33+
elif isinstance(value, list):
34+
for item in value:
35+
check(item)
36+
37+
agents = Agent.objects.filter(is_delete=0, version=1)
38+
for agent in agents.iterator():
39+
# Use the same retained MAAS dataset and Python state precedence as execution.
40+
check(definition(agent))
41+
for config in (
42+
AgentVersion.objects.filter(agent_id__in=agents.values("pk"))
43+
.values_list("definition", flat=True)
44+
.iterator()
45+
):
46+
check(config)
47+
for workflow in (
48+
Workflow.objects.filter(deleted=False).only("flow_id", "data", "published_data").iterator()
49+
):
50+
check(parsed(workflow.data))
51+
check(parsed(workflow.published_data))
52+
if workflow.flow_id:
53+
# Compare IDs to values, not across retained tables with different collations.
54+
for data in (
55+
WorkflowVersion.objects.filter(flow_id=workflow.flow_id, is_deleted=0)
56+
.exclude(deleted=2)
57+
.values_list("data", flat=True)
58+
.iterator()
59+
):
60+
check(parsed(data))

‎console/backend/astron_console/resources/tests/test_resources.py‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from django.test import Client, TransactionTestCase, override_settings
66

77
from astron_console.accounts.models import User, UserProfile
8+
from astron_console.agents.models import Agent, AgentVersion
89
from astron_console.resources.common import UpstreamError
910
from astron_console.resources.models import (
1011
ConfigInfo,
@@ -16,6 +17,7 @@
1617
WorkflowScope,
1718
)
1819
from astron_console.spaces.models import Space, SpaceMember, SpacePermission
20+
from astron_console.workflows.models import Workflow, WorkflowVersion
1921

2022
SCHEMA = {
2123
"openapi": "3.0.3",
@@ -246,6 +248,45 @@ def test_model_delete_requires_write_verb(self):
246248
self.model.refresh_from_db()
247249
self.assertTrue(self.model.is_deleted)
248250

251+
def test_model_delete_preserves_agent_draft_reference(self):
252+
Agent.objects.create(uid=self.user.pk, model_id=self.model.pk)
253+
result = self.client.delete(f"/api/model/delete?modelId={self.model.pk}")
254+
self.assertEqual(result.status_code, 400, result.content)
255+
self.model.refresh_from_db()
256+
self.assertFalse(self.model.is_deleted)
257+
258+
def test_model_delete_preserves_agent_version_after_draft_changes(self):
259+
agent = Agent.objects.create(uid=self.user.pk, model_id=None)
260+
AgentVersion.objects.create(
261+
agent_id=agent.pk,
262+
definition={"modelId": self.model.pk},
263+
created_by=self.user.pk,
264+
)
265+
result = self.client.delete(f"/api/model/delete?modelId={self.model.pk}")
266+
self.assertEqual(result.status_code, 400, result.content)
267+
self.model.refresh_from_db()
268+
self.assertFalse(self.model.is_deleted)
269+
Agent.objects.filter(pk=agent.pk).update(is_delete=1)
270+
result = self.client.delete(f"/api/model/delete?modelId={self.model.pk}")
271+
self.assertEqual(result.status_code, 200, result.content)
272+
273+
def test_model_delete_preserves_workflow_version_after_draft_changes(self):
274+
workflow = Workflow.objects.create(
275+
uid=self.user.pk, app_id="app", flow_id="version-only-model", name="Saved", data="{}"
276+
)
277+
version = WorkflowVersion.objects.create(
278+
flow_id=workflow.flow_id,
279+
version_num="v1",
280+
data=json.dumps({"nodes": [{"data": {"nodeParam": {"modelId": self.model.pk}}}]}),
281+
)
282+
result = self.client.delete(f"/api/model/delete?modelId={self.model.pk}")
283+
self.assertEqual(result.status_code, 400, result.content)
284+
self.model.refresh_from_db()
285+
self.assertFalse(self.model.is_deleted)
286+
WorkflowVersion.objects.filter(pk=version.pk).update(deleted=2)
287+
result = self.client.delete(f"/api/model/delete?modelId={self.model.pk}")
288+
self.assertEqual(result.status_code, 200, result.content)
289+
249290
def test_internal_brokers_require_credentials(self):
250291
for url in [
251292
"/model/internal-runtime-config?flowId=flow&modelId=1",
@@ -365,6 +406,45 @@ def test_tool_upstream_failure_does_not_create_local_success(self):
365406
self.assertEqual(result.status_code, 502)
366407
self.assertFalse(Tool.objects.exists())
367408

409+
def test_tool_delete_preserves_new_agent_references_before_remote_delete(self):
410+
for source in ("draft", "version", "workflow-version"):
411+
with self.subTest(source=source):
412+
tool = Tool.objects.create(
413+
user_id=self.user.pk, tool_id=f"remote-{source}", version="V1", deleted=0
414+
)
415+
# Deleting another card of the same tool also deletes this version upstream.
416+
selected = Tool.objects.create(
417+
user_id=self.user.pk, tool_id=tool.tool_id, version="V2", deleted=0
418+
)
419+
agent = Agent.objects.create(
420+
uid=self.user.pk, tools=json.dumps([tool.pk] if source == "draft" else [])
421+
)
422+
if source == "version":
423+
AgentVersion.objects.create(
424+
agent_id=agent.pk,
425+
definition={"tools": [{"toolId": tool.tool_id, "version": "V1"}]},
426+
created_by=self.user.pk,
427+
)
428+
elif source == "workflow-version":
429+
workflow = Workflow.objects.create(
430+
uid=self.user.pk, app_id="app", flow_id="tool-flow", name="Saved", data="{}"
431+
)
432+
WorkflowVersion.objects.create(
433+
flow_id=workflow.flow_id,
434+
version_num="v1",
435+
data=json.dumps(
436+
{"nodes": [{"data": {"nodeParam": {"pluginId": tool.tool_id}}}]}
437+
),
438+
)
439+
with patch("astron_console.resources.clients.call") as remote:
440+
result = self.client.delete(f"/resources/tools/{selected.pk}")
441+
self.assertEqual(result.status_code, 400, result.content)
442+
remote.assert_not_called()
443+
tool.refresh_from_db()
444+
selected.refresh_from_db()
445+
self.assertFalse(tool.deleted)
446+
self.assertFalse(selected.deleted)
447+
368448
def test_tool_registration_and_secret_mask(self):
369449
with patch(
370450
"astron_console.resources.clients.call",

‎console/backend/astron_console/resources/tools.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,14 @@ def item(request, pk):
316316
uid_field="user_id",
317317
deleted="deleted",
318318
)
319+
from .references import ensure_definition_unreferenced
320+
321+
# The upstream DELETE removes all versions of this remote tool, so include
322+
# every local version ID rather than only the selected card.
323+
identifiers = {row.pk, row.tool_id}
324+
if row.tool_id:
325+
identifiers.update(Tool.objects.filter(tool_id=row.tool_id).values_list("pk", flat=True))
326+
ensure_definition_unreferenced("tool", identifiers)
319327
if row.tool_id:
320328
tables = connection.introspection.table_names()
321329
for table in ("flow_tool_rel", "bot_tool_rel"):

‎console/frontend/_tests_/workflow-config-persistence.test.js‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,7 @@ function workflowListUI(createTalkAgent) {
391391
const dependencies = {
392392
react: {
393393
useEffect: noop,
394+
useRef: initial => ({ current: initial }),
394395
useState: initial => {
395396
const index = cursor++;
396397
if (!(index in states)) states[index] = initial;
@@ -408,8 +409,15 @@ function workflowListUI(createTalkAgent) {
408409
useNavigate: () => path => routes.push(path),
409410
},
410411
antd: {
412+
Alert: 'Alert',
413+
Dropdown: 'Dropdown',
414+
Empty: 'Empty',
415+
Spin: 'Spin',
411416
Button: 'Button',
412-
Input: { Search: 'Search', TextArea: 'TextArea' },
417+
Input: Object.assign(() => null, {
418+
Search: 'Search',
419+
TextArea: 'TextArea',
420+
}),
413421
Modal: 'Modal',
414422
Pagination: 'Pagination',
415423
message: {},
@@ -422,7 +430,14 @@ function workflowListUI(createTalkAgent) {
422430
},
423431
'@/services/spark-common': { createTalkAgent },
424432
'@/components/workflow/utils': {},
425-
'@/pages/agents/style.css': {},
433+
'@/store/space-store': { default: () => ({}), __esModule: true },
434+
'@/assets/imgs/workflow/flow-icon.png': 'workflow-icon',
435+
'@/pages/agents/catalog.module.scss': {},
436+
'@ant-design/icons': {
437+
EllipsisOutlined: 'EllipsisOutlined',
438+
PlusOutlined: 'PlusOutlined',
439+
UploadOutlined: 'UploadOutlined',
440+
},
426441
};
427442
const require = createRequire(import.meta.url);
428443
const code = ts.transpileModule(

0 commit comments

Comments
 (0)