Compare commits

..
Author SHA1 Message Date
fatelei 2bb01984ed fix: fix conflict 2026-07-28 19:03:11 +08:00
fatelei 77e41a37ee feat: agent can op file and fix version check 2026-07-28 18:36:06 +08:00
zxhlyh 79229b7ede fix(web): preserve IME composition in skill builder 2026-07-28 17:46:12 +08:00
zxhlyh 36ae648a63 fix(web): protect skill bindings in version view 2026-07-28 16:21:43 +08:00
zxhlyh c409d531da fix(web): paginate workspace skills list 2026-07-28 15:49:44 +08:00
autofix-ci[bot]andGitHub 511ca69e4e [autofix.ci] apply automated fixes 2026-07-28 07:17:11 +00:00
fatelei 4c7e60d7e7 fix: fix db version 2026-07-28 15:12:58 +08:00
fatelei a9e163403d fix: fix model not change 2026-07-28 14:52:16 +08:00
zxhlyhandfatelei bc136b89f9 fix(web): prevent duplicate skill autosaves 2026-07-28 14:52:16 +08:00
fatelei b1bd4f9a8b fix: fix model not update 2026-07-28 14:52:16 +08:00
fatelei 06d3927f05 fix: fix failed test 2026-07-28 14:52:16 +08:00
fatelei 5c3516cae8 fix: fix skill not in agent 2026-07-28 14:52:16 +08:00
fatelei 1c18d8ddbd fix: fix frontend issue and let draft check relax 2026-07-28 14:52:16 +08:00
fatelei 253dfe9351 fix: fix ci failed 2026-07-28 14:52:15 +08:00
fatelei 2d3999e984 fix: fix ci failed 2026-07-28 14:52:15 +08:00
fatelei a431cc726d fix: fix scroll and live mode can not enter 2026-07-28 14:52:15 +08:00
fatelei 08493d2429 fix: fix some bugs 2026-07-28 14:52:15 +08:00
fatelei 8dd0969006 chore: adjust skill entrance 2026-07-28 14:52:15 +08:00
fatelei 2d80d3c35c fix: fix delete skill ref 2026-07-28 14:52:14 +08:00
fatelei 4da764904c feat: support agent skill 2026-07-28 14:46:34 +08:00
fatelei 5c4f4fd1ef Merge branch 'skill' into deploy/dev 2026-07-28 14:44:57 +08:00
fatelei 54843971ac fix: fix model not change 2026-07-28 14:43:05 +08:00
zxhlyh 5ec5d3aeb9 fix(web): prevent duplicate skill autosaves 2026-07-28 14:30:17 +08:00
github-actions[bot]GitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>yyh
137d4f3f60 chore(i18n): sync translations with en-US (#39671)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-07-28 06:11:36 +00:00
fatelei d661b53e49 fix: fix model not update 2026-07-28 14:10:49 +08:00
zxhlyh fd1e777f85 Merge branch 'codex/fix-skill-autosave-conflict' into deploy/dev 2026-07-28 14:04:25 +08:00
zxhlyh e5f77ce185 fix(web): prevent duplicate skill autosaves 2026-07-28 14:02:18 +08:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
e723b348cf test: use SQLite sessions in core app apps (#39103)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-28 05:54:17 +00:00
59fb603ec6 fix: reject trailing newlines in alphanumeric() validator (#39666) (#39667)
Co-authored-by: Harsh Kashyap <Harsh23Kashyap@users.noreply.github.com>
2026-07-28 05:49:19 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
63f072ebfb test: use SQLite sessions in core rag (#39105)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-28 05:47:22 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
0913d04d33 test: use SQLite sessions in services retention (#39074)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-28 05:47:01 +00:00
Asuka MinatoandGitHub 80ff108fc0 test: use SQLite in Aliyun trace utilities (#38983) 2026-07-28 05:40:27 +00:00
Asuka MinatoandGitHub ea58129ebe test: move workflow app service coverage to unit tests (#38946) 2026-07-28 05:34:59 +00:00
-LAN-andGitHub 698869460c chore(docker): remove redundant feature preview env (#39035) 2026-07-28 05:34:35 +00:00
Asuka MinatoandGitHub f3f2f63110 test: use SQLite in LangSmith traces (#38986) 2026-07-28 05:26:52 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
b100cdc382 test: use SQLite sessions in core app (#39099)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-28 05:22:47 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
9e90b32991 test: use SQLite sessions in core datasource (#39104)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-28 05:22:18 +00:00
非法操作andGitHub b2d54cb2e9 chore: improve archived logs hint in logs list page (#39659) 2026-07-28 05:15:35 +00:00
fatelei 99929d1c16 fix: fix conflict 2026-07-27 08:12:58 +08:00
fatelei c699aa11db fix: fix failed test 2026-07-26 23:42:50 +08:00
fatelei 06cd0b56ac fix: fix skill not in agent 2026-07-26 21:00:38 +08:00
fatelei 241a9e1fec fix: fix frontend issue and let draft check relax 2026-07-26 21:00:38 +08:00
fatelei 3d79689fb5 fix: fix ci failed 2026-07-26 20:59:56 +08:00
fatelei 9f050f7957 fix: fix ci failed 2026-07-26 20:59:56 +08:00
fatelei cb691d47d3 fix: fix scroll and live mode can not enter 2026-07-26 20:57:29 +08:00
fatelei 04f5ee58d0 fix: fix some bugs 2026-07-26 20:57:29 +08:00
fatelei f5cff724f4 chore: adjust skill entrance 2026-07-26 20:57:29 +08:00
fatelei 1f4ccafdea fix: fix delete skill ref 2026-07-26 20:57:29 +08:00
fatelei 163049f6ca feat: support agent skill 2026-07-26 20:54:32 +08:00
fatelei 65e7507ca3 Merge branch 'skill' into deploy/dev 2026-07-24 13:38:15 +08:00
fatelei ba157f9604 fix: fix skill not in agent 2026-07-24 13:37:19 +08:00
fatelei 0dc913630e fix: fix conflict 2026-07-24 13:18:31 +08:00
fatelei 875cd30b1f fix: fix frontend issue and let draft check relax 2026-07-24 13:11:18 +08:00
fatelei aa4a32ae84 fix: fix ci failed 2026-07-24 11:13:44 +08:00
fatelei 8573e14777 fix: fix ci failed 2026-07-24 11:13:44 +08:00
fatelei 1855be234c fix: fix scroll and live mode can not enter 2026-07-24 11:13:44 +08:00
fatelei 9bb960ff12 fix: fix some bugs 2026-07-24 11:13:44 +08:00
fatelei 1c14c7d467 chore: adjust skill entrance 2026-07-24 11:13:44 +08:00
fatelei 61faec16ca fix: fix delete skill ref 2026-07-24 11:13:44 +08:00
fatelei 57c836e692 feat: support agent skill 2026-07-24 11:13:43 +08:00
samzong 626cc282b1 fix(web): echo OAuth state on authorize redirect (#39459)
Signed-off-by: samzong <samzong.lu@gmail.com>
2026-07-23 06:36:58 -04:00
fatelei 52624d54e3 fix: fix db migration 2026-07-23 16:28:27 +08:00
fatelei 5ce038ef92 fix: fix db migration 2026-07-23 16:25:33 +08:00
fatelei 30f4d4c0c6 fix: fix db migration 2026-07-23 16:04:09 +08:00
fatelei 510679a7d1 fix: fix db migration 2026-07-23 15:38:05 +08:00
fatelei 9237f2a14a fix: fix conflict 2026-07-23 15:23:09 +08:00
fatelei bd178c7b29 Merge branch 'skill' into deploy/dev 2026-07-23 14:38:04 +08:00
fatelei d80947aa72 fix: fix scroll and live mode can not enter 2026-07-23 14:36:44 +08:00
fatelei 1618c37d26 Merge branch 'skill' into deploy/dev 2026-07-23 14:17:52 +08:00
fatelei 701ab64462 fix: fix some bugs 2026-07-23 14:14:28 +08:00
fatelei b3298800e9 chore: adjust skill entrance 2026-07-22 16:30:38 +08:00
fatelei 0f1c6b3f78 chore: remove agent v2 feature 2026-07-22 16:26:00 +08:00
林玮 (Jade Lin) 9b4b246aad refactor(api): simplify batch cleanup refresh state
(cherry picked from commit 1ded32d949)
2026-07-22 14:49:11 +08:00
林玮 (Jade Lin) 1bd654a289 refactor(api): simplify vector cache refresh metric
(cherry picked from commit fa585cf134)
2026-07-22 14:49:11 +08:00
林玮 (Jade Lin) fc70329bdb refactor(api): derive cleanup tenant from dataset
(cherry picked from commit b124ec86fc)
2026-07-22 14:49:11 +08:00
林玮 (Jade Lin) cd8a82fbd4 fix(api): invalidate vector space cache after cleanup
(cherry picked from commit a07e5c6eb4)
2026-07-22 14:49:11 +08:00
fatelei 550cb7eff5 fix: fix db migration 2026-07-22 14:42:11 +08:00
fatelei 2e748c16e9 Merge branch 'skill' into deploy/dev 2026-07-22 14:17:08 +08:00
fatelei 577012b66d fix: fix delete skill ref 2026-07-22 14:11:55 +08:00
fatelei 251c324180 Merge branch 'skill' into deploy/dev 2026-07-22 12:59:22 +08:00
fatelei 865a618fd5 Revert "feat: support agent skill"
This reverts commit 991116990a.
2026-07-22 12:48:29 +08:00
fatelei 991116990a feat: support agent skill 2026-07-22 11:45:50 +08:00
fatelei eb5d1da0e8 feat: support agent skill 2026-07-22 10:25:25 +08:00
fatelei dce3b7a7fc feat: use celery task 2026-07-10 15:20:06 +08:00
fatelei be386aba3b chore: add switch 2026-07-10 15:19:56 +08:00
JzoNg 02e51e7d7c Merge branch 'main' into deploy/dev 2026-07-10 10:28:07 +08:00
Xiyuan ChenandGitHub 96b6d4f2c0 Merge branch 'fix/socketio-user-connect-app-context' into deploy/dev 2026-07-08 22:02:43 -07:00
GareArc 4c84c5957d fix: run user_connect authorization inside Flask app context
Socket.IO event handlers run in plain gevent greenlets without a Flask
application context, so the db.session() call added in #38227 raises
RuntimeError: Working outside of application context, and every
user_connect is rejected as unauthorized. Wrap the authorization call in
sio.app.app_context(), matching the existing connect handler.
2026-07-08 21:57:07 -07:00
CodingOnStar 34613ecdc5 fix(app): update app creation logic to use database session and ensure RBAC access initialization 2026-07-09 12:00:16 +08:00
CodingOnStar 6a14245401 Merge remote-tracking branch 'origin/main' into deploy/dev 2026-07-09 11:49:16 +08:00
CodingOnStar a758ca2aef fix(amplitude): update API key for amplitude initialization 2026-07-09 11:37:28 +08:00
CodingOnStar 9e60d4e213 Merge branch 'fix/report-slug' into deploy/dev 2026-07-09 11:29:04 +08:00
CodingOnStar ef29c8442c refactor(amplitude): comment out amplitude initialization logic for future implementation 2026-07-09 11:26:59 +08:00
CodingOnStar a1b45415ac fix(web): preserve attribution from auth redirect 2026-07-09 11:00:37 +08:00
fatelei 7fc46d75bd chore: add switch 2026-07-08 18:08:02 +08:00
fatelei 953a4ef0ca feat: create app sync scope and workspace user 2026-07-08 16:12:40 +08:00
248 changed files with 25903 additions and 3581 deletions
+6 -5
View File
@@ -3,7 +3,6 @@ CLI command modules extracted from `commands.py`.
"""
from .account import create_tenant, reset_email, reset_password
from .app_maintenance import convert_to_agent_apps, fix_app_site_missing
from .data_migrate import data_migrate, legacy_model_types
from .data_migration import (
export_migration_data,
@@ -11,7 +10,6 @@ from .data_migration import (
import_migration_data,
migration_data_wizard,
)
from .database import upgrade_db
from .plugin import (
backfill_plugin_auto_upgrade,
extract_plugins,
@@ -38,6 +36,12 @@ from .retention import (
restore_workflow_runs,
)
from .storage import clear_orphaned_file_records, file_usage, migrate_oss, remove_orphaned_files_on_storage
from .system import (
convert_to_agent_apps,
fix_app_site_missing,
reset_encrypt_key_pair,
upgrade_db,
)
from .vector import (
add_qdrant_index,
migrate_annotation_vector_database,
@@ -45,8 +49,6 @@ from .vector import (
old_metadata_migration,
vdb_migrate,
)
from .workflow_migration import migrate_legacy_sys_files_workflows
from .workspace import reset_encrypt_key_pair
__all__ = [
"add_qdrant_index",
@@ -78,7 +80,6 @@ __all__ = [
"migrate_data_for_plugin",
"migrate_dataset_permissions_to_rbac",
"migrate_knowledge_vector_database",
"migrate_legacy_sys_files_workflows",
"migrate_member_roles_to_rbac",
"migrate_oss",
"migration_data_wizard",
-45
View File
@@ -1,45 +0,0 @@
"""Database schema migration CLI commands."""
import logging
import click
from extensions.ext_redis import redis_client
from libs.db_migration_lock import DbMigrationAutoRenewLock
logger = logging.getLogger(__name__)
DB_UPGRADE_LOCK_TTL_SECONDS = 60
@click.command("upgrade-db", help="Upgrade the database")
def upgrade_db() -> None:
click.echo("Preparing database migration...")
lock = DbMigrationAutoRenewLock(
redis_client=redis_client,
name="db_upgrade_lock",
ttl_seconds=DB_UPGRADE_LOCK_TTL_SECONDS,
logger=logger,
log_context="db_migration",
)
if lock.acquire(blocking=False):
migration_succeeded = False
try:
click.echo(click.style("Starting database migration.", fg="green"))
import flask_migrate
flask_migrate.upgrade()
migration_succeeded = True
click.echo(click.style("Database migration successful!", fg="green"))
except Exception as e:
logger.exception("Failed to execute database migration")
click.echo(click.style(f"Database migration failed: {e}", fg="red"))
raise SystemExit(1)
finally:
status = "successful" if migration_succeeded else "failed"
lock.release_safely(status=status)
else:
click.echo("Database migration skipped")
@@ -1,27 +1,86 @@
"""App data maintenance CLI commands."""
import logging
import click
import sqlalchemy as sa
from sqlalchemy import select, update
from sqlalchemy import delete, select, update
from sqlalchemy.orm import sessionmaker
from configs import dify_config
from enums.deployment_edition import DeploymentEdition
from events.app_event import app_was_created
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from libs.db_migration_lock import DbMigrationAutoRenewLock
from libs.rsa import generate_key_pair
from models import Tenant
from models.model import App, AppMode, Conversation
from models.provider import Provider, ProviderModel
from models.tools import ApiToolProvider, BuiltinToolProvider, MCPToolProvider
logger = logging.getLogger(__name__)
DB_UPGRADE_LOCK_TTL_SECONDS = 60
@click.command(
"reset-encrypt-key-pair",
help="Reset the asymmetric key pair of workspace for encrypt LLM credentials. "
"After the reset, all LLM credentials and tool provider credentials "
"(builtin / API / MCP) will be purged, requiring re-entry. "
"Only support SELF_HOSTED mode.",
)
@click.confirmation_option(
prompt=click.style(
"Are you sure you want to reset encrypt key pair? "
"This will also purge builtin / API / MCP tool provider records for every tenant. "
"This operation cannot be rolled back!",
fg="red",
)
)
def reset_encrypt_key_pair():
"""
Reset the encrypted key pair of workspace for encrypt LLM credentials.
After the reset, all LLM credentials will become invalid, requiring re-entry.
Only support SELF_HOSTED mode.
"""
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
click.echo(click.style("This command is only for SELF_HOSTED installations.", fg="red"))
return
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
tenants = session.scalars(select(Tenant)).all()
for tenant in tenants:
if not tenant:
click.echo(click.style("No workspaces found. Run /install first.", fg="red"))
return
tenant.encrypt_public_key = generate_key_pair(tenant.id)
session.execute(delete(Provider).where(Provider.provider_type == "custom", Provider.tenant_id == tenant.id))
session.execute(delete(ProviderModel).where(ProviderModel.tenant_id == tenant.id))
# Purge tool provider records that hold credentials encrypted under the
# tenant key. Leaving them in place causes /console/api/workspaces/current/
# tool-providers to 500 because decryption fails on stale ciphertext (#35396).
session.execute(delete(BuiltinToolProvider).where(BuiltinToolProvider.tenant_id == tenant.id))
session.execute(delete(ApiToolProvider).where(ApiToolProvider.tenant_id == tenant.id))
session.execute(delete(MCPToolProvider).where(MCPToolProvider.tenant_id == tenant.id))
click.echo(
click.style(
f"Congratulations! The asymmetric key pair of workspace {tenant.id} has been reset.",
fg="green",
)
)
@click.command("convert-to-agent-apps", help="Convert Agent Assistant to Agent App.")
def convert_to_agent_apps() -> None:
def convert_to_agent_apps():
"""
Convert Agent Assistant to Agent App.
"""
click.echo(click.style("Starting convert to agent apps.", fg="green"))
proceeded_app_ids: list[str] = []
proceeded_app_ids = []
while True:
# fetch first 1000 apps
@@ -74,14 +133,48 @@ def convert_to_agent_apps() -> None:
click.echo(click.style(f"Conversion complete. Converted {len(proceeded_app_ids)} agent apps.", fg="green"))
@click.command("upgrade-db", help="Upgrade the database")
def upgrade_db():
click.echo("Preparing database migration...")
lock = DbMigrationAutoRenewLock(
redis_client=redis_client,
name="db_upgrade_lock",
ttl_seconds=DB_UPGRADE_LOCK_TTL_SECONDS,
logger=logger,
log_context="db_migration",
)
if lock.acquire(blocking=False):
migration_succeeded = False
try:
click.echo(click.style("Starting database migration.", fg="green"))
# run db migration
import flask_migrate
flask_migrate.upgrade()
migration_succeeded = True
click.echo(click.style("Database migration successful!", fg="green"))
except Exception as e:
logger.exception("Failed to execute database migration")
click.echo(click.style(f"Database migration failed: {e}", fg="red"))
raise SystemExit(1)
finally:
status = "successful" if migration_succeeded else "failed"
lock.release_safely(status=status)
else:
click.echo("Database migration skipped")
@click.command("fix-app-site-missing", help="Fix app related site missing issue.")
def fix_app_site_missing() -> None:
def fix_app_site_missing():
"""
Fix app related site missing issue.
"""
click.echo(click.style("Starting fix for missing app-related sites.", fg="green"))
failed_app_ids: list[str] = []
failed_app_ids = []
while True:
sql = """select apps.id as id from apps left join sites on sites.app_id=apps.id
where sites.id is null limit 1000"""
-173
View File
@@ -1,173 +0,0 @@
"""Workflow data migration CLI commands.
TODO: Remove the legacy system file workflow migration command after the production migration is complete.
"""
import logging
from dataclasses import dataclass
import click
from sqlalchemy import select
from sqlalchemy.orm import Session, load_only, sessionmaker
from extensions.ext_database import db
from models.workflow import Workflow, WorkflowType
logger = logging.getLogger(__name__)
@dataclass
class LegacySysFilesWorkflowMigrationStats:
scanned: int = 0
migrated: int = 0
failed: int = 0
batches: int = 0
last_id: str | None = None
def _build_legacy_sys_files_workflow_query(
*,
start_after_id: str | None,
batch_size: int,
tenant_id: str | None,
app_id: str | None,
):
# Workflow IDs are UUID4, so this is not chronological pagination. The migration only needs a stable total
# order that matches the resume cursor; ordering by the same primary-key column used in the `id > cursor`
# predicate lets each batch continue deterministically without offset scans.
stmt = (
select(Workflow)
.options(load_only(Workflow.id, Workflow.type, Workflow.graph))
.where(Workflow.type.in_((WorkflowType.WORKFLOW, WorkflowType.CHAT)))
.order_by(Workflow.id)
.limit(batch_size)
)
if start_after_id:
stmt = stmt.where(Workflow.id > start_after_id)
if tenant_id:
stmt = stmt.where(Workflow.tenant_id == tenant_id)
if app_id:
stmt = stmt.where(Workflow.app_id == app_id)
return stmt
def _migrate_legacy_sys_files_workflow_batch(
*,
session: Session,
start_after_id: str | None,
batch_size: int,
tenant_id: str | None,
app_id: str | None,
dry_run: bool,
) -> LegacySysFilesWorkflowMigrationStats:
stats = LegacySysFilesWorkflowMigrationStats()
workflows = session.scalars(
_build_legacy_sys_files_workflow_query(
start_after_id=start_after_id,
batch_size=batch_size,
tenant_id=tenant_id,
app_id=app_id,
)
).all()
for workflow in workflows:
stats.scanned += 1
stats.last_id = workflow.id
try:
if workflow.migrate_legacy_sys_files_graph_in_place():
stats.migrated += 1
except Exception:
stats.failed += 1
logger.exception("Failed to migrate legacy sys.files workflow, workflow_id=%s", workflow.id)
if dry_run:
session.rollback()
else:
session.commit()
return stats
def run_legacy_sys_files_workflow_migration(
*,
batch_size: int,
limit: int | None,
start_after_id: str | None,
tenant_id: str | None,
app_id: str | None,
dry_run: bool,
) -> LegacySysFilesWorkflowMigrationStats:
"""Scan Workflow and Advanced Chat graphs in keyset-paginated batches."""
if batch_size <= 0:
raise click.UsageError("--batch-size must be greater than 0")
if limit is not None and limit <= 0:
raise click.UsageError("--limit must be greater than 0 when provided")
session_maker = sessionmaker(db.engine, expire_on_commit=False)
total = LegacySysFilesWorkflowMigrationStats(last_id=start_after_id)
next_start_after_id = start_after_id
while limit is None or total.scanned < limit:
remaining = None if limit is None else limit - total.scanned
current_batch_size = batch_size if remaining is None else min(batch_size, remaining)
if current_batch_size <= 0:
break
with session_maker() as session:
batch_stats = _migrate_legacy_sys_files_workflow_batch(
session=session,
start_after_id=next_start_after_id,
batch_size=current_batch_size,
tenant_id=tenant_id,
app_id=app_id,
dry_run=dry_run,
)
if batch_stats.scanned == 0:
break
total.scanned += batch_stats.scanned
total.migrated += batch_stats.migrated
total.failed += batch_stats.failed
total.batches += 1
total.last_id = batch_stats.last_id
next_start_after_id = batch_stats.last_id
if batch_stats.scanned < current_batch_size:
break
return total
@click.command(
"migrate-legacy-sys-files-workflows",
help="Migrate Workflow and Advanced Chat graphs that still reference deprecated sys.files.",
)
@click.option("--batch-size", default=1000, show_default=True, type=int, help="Number of workflows to scan per batch.")
@click.option("--limit", default=None, type=int, help="Maximum number of workflows to scan in this run.")
@click.option("--start-after-id", default=None, help="Resume scanning after this workflow ID.")
@click.option("--tenant-id", default=None, help="Limit migration to one tenant.")
@click.option("--app-id", default=None, help="Limit migration to one app.")
@click.option("--dry-run", is_flag=True, default=False, help="Scan and report without saving changes.")
def migrate_legacy_sys_files_workflows(
batch_size: int,
limit: int | None,
start_after_id: str | None,
tenant_id: str | None,
app_id: str | None,
dry_run: bool,
) -> None:
stats = run_legacy_sys_files_workflow_migration(
batch_size=batch_size,
limit=limit,
start_after_id=start_after_id,
tenant_id=tenant_id,
app_id=app_id,
dry_run=dry_run,
)
click.echo(
"Legacy sys.files workflow migration finished: "
f"scanned={stats.scanned} migrated={stats.migrated} failed={stats.failed} "
f"batches={stats.batches} last_id={stats.last_id or ''}"
)
if dry_run:
click.echo("Dry run only: no workflow graph changes were saved.")
-64
View File
@@ -1,64 +0,0 @@
"""Workspace maintenance CLI commands."""
import click
from sqlalchemy import delete, select
from sqlalchemy.orm import sessionmaker
from configs import dify_config
from enums.deployment_edition import DeploymentEdition
from extensions.ext_database import db
from libs.rsa import generate_key_pair
from models import Tenant
from models.provider import Provider, ProviderModel
from models.tools import ApiToolProvider, BuiltinToolProvider, MCPToolProvider
@click.command(
"reset-encrypt-key-pair",
help="Reset the asymmetric key pair of workspace for encrypt LLM credentials. "
"After the reset, all LLM credentials and tool provider credentials "
"(builtin / API / MCP) will be purged, requiring re-entry. "
"Only support SELF_HOSTED mode.",
)
@click.confirmation_option(
prompt=click.style(
"Are you sure you want to reset encrypt key pair? "
"This will also purge builtin / API / MCP tool provider records for every tenant. "
"This operation cannot be rolled back!",
fg="red",
)
)
def reset_encrypt_key_pair() -> None:
"""
Reset the encrypted key pair of workspace for encrypt LLM credentials.
After the reset, all LLM credentials will become invalid, requiring re-entry.
Only support SELF_HOSTED mode.
"""
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
click.echo(click.style("This command is only for SELF_HOSTED installations.", fg="red"))
return
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
tenants = session.scalars(select(Tenant)).all()
for tenant in tenants:
if not tenant:
click.echo(click.style("No workspaces found. Run /install first.", fg="red"))
return
tenant.encrypt_public_key = generate_key_pair(tenant.id)
session.execute(delete(Provider).where(Provider.provider_type == "custom", Provider.tenant_id == tenant.id))
session.execute(delete(ProviderModel).where(ProviderModel.tenant_id == tenant.id))
# Purge tool provider records that hold credentials encrypted under the
# tenant key. Leaving them in place causes /console/api/workspaces/current/
# tool-providers to 500 because decryption fails on stale ciphertext (#35396).
session.execute(delete(BuiltinToolProvider).where(BuiltinToolProvider.tenant_id == tenant.id))
session.execute(delete(ApiToolProvider).where(ApiToolProvider.tenant_id == tenant.id))
session.execute(delete(MCPToolProvider).where(MCPToolProvider.tenant_id == tenant.id))
click.echo(
click.style(
f"Congratulations! The asymmetric key pair of workspace {tenant.id} has been reset.",
fg="green",
)
)
+2
View File
@@ -144,6 +144,7 @@ from .workspace import (
models,
plugin,
rbac,
skills,
snippets,
tool_providers,
trigger_providers,
@@ -225,6 +226,7 @@ __all__ = [
"saved_message",
"setup",
"site",
"skills",
"snippet_workflow",
"snippet_workflow_draft_variable",
"snippets",
+1 -1
View File
@@ -59,7 +59,7 @@ class TagBindingRemovePayload(BaseModel):
class TagListQueryParam(BaseModel):
type: Literal["knowledge", "app", "snippet", ""] = Field("", description="Tag type filter")
type: TagType | Literal[""] = Field("", description="Tag type filter")
keyword: str | None = Field(None, description="Search keyword")
+834
View File
@@ -0,0 +1,834 @@
"""Console API for workspace-level Skill Management."""
from __future__ import annotations
import io
from flask import request, send_file
from flask_restx import Resource
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from controllers.common.fields import BinaryFileResponse
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.wraps import (
account_initialization_required,
edit_permission_required,
setup_required,
with_current_tenant_id,
with_current_user,
)
from fields.base import ResponseModel
from libs import helper
from libs.helper import dump_response
from libs.login import login_required
from models.account import Account
from services.skill_management_service import (
SkillAssistMessagePayload,
SkillCreatePayload,
SkillDraftFileOperationPayload,
SkillDraftTreePayload,
SkillImportPayload,
SkillManagementService,
SkillManagementServiceError,
SkillMetadataPayload,
SkillPublishPayload,
SkillRestorePayload,
SkillVersionUpdatePayload,
)
_FILE_UPLOAD_PARAMS = {
"file": {
"description": "Skill draft file payload",
"in": "formData",
"type": "file",
"required": True,
},
}
class WorkspaceSkillsQuery(BaseModel):
keyword: str | None = Field(default=None, description="Search keyword matching skill name or description.")
page: int = Field(default=1, ge=1, le=99999, description="Page number.")
limit: int = Field(default=20, ge=1, le=100, description="Number of items per page.")
tag: list[str] = Field(
default_factory=list,
description="Skill tag filters. Repeat the parameter for multiple tags.",
)
class SkillDeletePayload(BaseModel):
model_config = ConfigDict(extra="forbid")
confirmation_name: str | None = Field(
default=None,
description="Required when deleting a referenced Skill. Must match the Skill name.",
)
class AgentSkillBindingsPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
skill_ids: list[str] = Field(default_factory=list, description="Ordered Skill IDs bound to the Agent.")
class SkillFileQuery(BaseModel):
path: str = Field(description="Skill file path relative to the Skill root.")
version_id: str | None = Field(default=None, description="Optional published version ID. Omit for current draft.")
class SkillResponse(ResponseModel):
id: str
name: str
display_name: str
icon: str
description: str
tags: list[str] = Field(default_factory=list)
name_manually_edited: bool = False
visibility: str
latest_published_version_id: str | None = None
reference_count: int = 0
created_by: str | None = None
created_by_name: str | None = None
updated_by: str | None = None
updated_by_name: str | None = None
created_at: int
updated_at: int
class SkillFileResponse(ResponseModel):
id: str | None = None
path: str
kind: str
storage: str | None = None
mime_type: str | None = None
content: str | None = None
tool_file_id: str | None = None
size: int | None = None
hash: str | None = None
class SkillFilePreviewResponse(ResponseModel):
path: str
mime_type: str
content: str
size: int
hash: str
class SkillFileUploadResponse(ResponseModel):
id: str
name: str
mime_type: str
size: int
hash: str
class SkillDetailResponse(SkillResponse):
files: list[SkillFileResponse] = Field(default_factory=list)
class SkillListResponse(ResponseModel):
data: list[SkillResponse] = Field(default_factory=list)
has_more: bool = False
limit: int = 20
page: int = 1
total: int = 0
class SkillTagResponse(ResponseModel):
tag: str
count: int
class SkillTagListResponse(ResponseModel):
data: list[SkillTagResponse] = Field(default_factory=list)
class SkillVersionResponse(ResponseModel):
id: str
skill_id: str
version_number: int
version_name: str
publish_note: str
hash_code: str
archive_size: int
published_by: str | None = None
published_by_name: str | None = None
is_latest: bool = False
created_at: int
class SkillVersionListResponse(ResponseModel):
data: list[SkillVersionResponse] = Field(default_factory=list)
class SkillVersionDetailResponse(SkillVersionResponse):
files: list[SkillFileResponse] = Field(default_factory=list)
class SkillVersionDeleteResponse(ResponseModel):
id: str
deleted: bool
latest_published_version_id: str | None = None
class SkillReferenceResponse(ResponseModel):
type: str
agent_id: str
agent_icon: str | None = None
agent_icon_background: str | None = None
agent_icon_type: str | None = None
app_id: str | None = None
name: str
display_name: str
workflow_id: str | None = None
workflow_name: str | None = None
workflow_icon: str | None = None
workflow_icon_background: str | None = None
workflow_icon_type: str | None = None
workflow_version: str | None = None
node_id: str | None = None
node_name: str | None = None
class SkillReferenceListResponse(ResponseModel):
data: list[SkillReferenceResponse] = Field(default_factory=list)
class SkillDeleteResponse(ResponseModel):
id: str
deleted: bool
class AgentSkillBindingItemResponse(ResponseModel):
id: str
priority: int
name: str
display_name: str
icon: str
description: str
tags: list[str] = Field(default_factory=list)
status: str
file_count: int
latest_published_version_id: str | None = None
latest_published_at: int | None = None
updated_at: int
class AgentSkillBindingsResponse(ResponseModel):
agent_id: str
skill_ids: list[str] = Field(default_factory=list)
data: list[AgentSkillBindingItemResponse] = Field(default_factory=list)
register_schema_models(
console_ns,
WorkspaceSkillsQuery,
SkillCreatePayload,
SkillAssistMessagePayload,
SkillMetadataPayload,
SkillDraftFileOperationPayload,
SkillDraftTreePayload,
SkillPublishPayload,
SkillRestorePayload,
SkillVersionUpdatePayload,
SkillDeletePayload,
SkillFileQuery,
AgentSkillBindingsPayload,
)
register_response_schema_models(
console_ns,
SkillResponse,
SkillFileResponse,
SkillFilePreviewResponse,
SkillFileUploadResponse,
SkillDetailResponse,
SkillListResponse,
SkillTagResponse,
SkillTagListResponse,
SkillVersionResponse,
SkillVersionListResponse,
SkillVersionDetailResponse,
SkillVersionDeleteResponse,
SkillReferenceResponse,
SkillReferenceListResponse,
SkillDeleteResponse,
AgentSkillBindingItemResponse,
AgentSkillBindingsResponse,
BinaryFileResponse,
)
def _error_response(exc: SkillManagementServiceError) -> tuple[dict[str, object], int]:
body: dict[str, object] = {"code": exc.code, "message": exc.message}
if exc.details:
body["details"] = exc.details
return body, exc.status_code
@console_ns.route("/workspaces/current/skills")
class WorkspaceSkillsApi(Resource):
@console_ns.doc(params=query_params_from_model(WorkspaceSkillsQuery))
@console_ns.response(200, "Workspace skills", console_ns.models[SkillListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str):
query_input: dict[str, object] = {
"keyword": request.args.get("keyword"),
"tag": request.args.getlist("tag"),
}
if "limit" in request.args:
query_input["limit"] = request.args.get("limit")
if "page" in request.args:
query_input["page"] = request.args.get("page")
query = WorkspaceSkillsQuery.model_validate(query_input)
result = SkillManagementService().list_skills(
tenant_id=current_tenant_id,
keyword=query.keyword,
page=query.page,
limit=query.limit,
tags=[tag for tag in query.tag if tag],
)
return dump_response(SkillListResponse, result)
@console_ns.expect(console_ns.models[SkillCreatePayload.__name__])
@console_ns.response(201, "Skill created", console_ns.models[SkillDetailResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account):
try:
payload = SkillCreatePayload.model_validate(console_ns.payload or {})
result = SkillManagementService().create_skill(
tenant_id=current_tenant_id,
user_id=current_user.id,
payload=payload,
)
return dump_response(SkillDetailResponse, result), 201
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except ValueError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/files/upload")
class WorkspaceSkillFileUploadApi(Resource):
@console_ns.doc(consumes=["multipart/form-data"], params=_FILE_UPLOAD_PARAMS)
@console_ns.response(201, "Skill draft file uploaded", console_ns.models[SkillFileUploadResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account):
if "file" not in request.files:
return {"code": "no_file_uploaded", "message": "no file uploaded"}, 400
file = request.files["file"]
if not file.filename:
return {"code": "filename_missing", "message": "filename is required"}, 400
result = SkillManagementService().upload_file(
tenant_id=current_tenant_id,
user_id=current_user.id,
filename=file.filename,
content=file.stream.read(),
mime_type=file.mimetype,
)
return dump_response(SkillFileUploadResponse, result), 201
@console_ns.route("/workspaces/current/skills/tags")
class WorkspaceSkillTagsApi(Resource):
@console_ns.response(200, "Workspace Skill tags", console_ns.models[SkillTagListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str):
result = SkillManagementService().list_tags(tenant_id=current_tenant_id)
return dump_response(SkillTagListResponse, result)
@console_ns.route("/workspaces/current/skills/import")
class WorkspaceSkillImportApi(Resource):
@console_ns.doc(description="Import a Skill zip package from multipart form field `file`.")
@console_ns.response(201, "Skill imported", console_ns.models[SkillDetailResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account):
upload = request.files.get("file")
if upload is None:
return {"code": "invalid_request", "message": "file is required"}, 400
try:
payload = SkillImportPayload(content=upload.read(), filename=upload.filename or "skill.zip")
result = SkillManagementService().import_skill(
tenant_id=current_tenant_id,
user_id=current_user.id,
payload=payload,
)
return dump_response(SkillDetailResponse, result), 201
except (ValidationError, ValueError) as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>")
class WorkspaceSkillApi(Resource):
@console_ns.response(200, "Skill detail", console_ns.models[SkillDetailResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str, skill_id: str):
try:
result = SkillManagementService().get_skill(tenant_id=current_tenant_id, skill_id=skill_id)
return dump_response(SkillDetailResponse, result)
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.expect(console_ns.models[SkillMetadataPayload.__name__])
@console_ns.response(200, "Skill updated", console_ns.models[SkillResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def patch(self, current_tenant_id: str, current_user: Account, skill_id: str):
try:
payload = SkillMetadataPayload.model_validate(console_ns.payload or {})
result = SkillManagementService().update_metadata(
tenant_id=current_tenant_id,
user_id=current_user.id,
skill_id=skill_id,
payload=payload,
)
return dump_response(SkillResponse, result)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except ValueError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.expect(console_ns.models[SkillDeletePayload.__name__])
@console_ns.response(200, "Skill deleted", console_ns.models[SkillDeleteResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_tenant_id
def delete(self, current_tenant_id: str, skill_id: str):
try:
payload = SkillDeletePayload.model_validate(console_ns.payload or {})
result = SkillManagementService().delete_skill(
tenant_id=current_tenant_id,
skill_id=skill_id,
confirmation_name=payload.confirmation_name,
)
return dump_response(SkillDeleteResponse, result)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/duplicate")
class WorkspaceSkillDuplicateApi(Resource):
@console_ns.response(201, "Skill duplicated", console_ns.models[SkillDetailResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account, skill_id: str):
try:
result = SkillManagementService().duplicate_skill(
tenant_id=current_tenant_id,
user_id=current_user.id,
skill_id=skill_id,
)
return dump_response(SkillDetailResponse, result), 201
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/export")
class WorkspaceSkillExportApi(Resource):
@console_ns.response(200, "Published Skill zip archive")
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str, skill_id: str):
try:
result = SkillManagementService().pull_published_archive(tenant_id=current_tenant_id, skill_id=skill_id)
return send_file(
io.BytesIO(result.payload),
mimetype=result.mime_type,
as_attachment=True,
download_name=result.filename,
)
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/assist/messages")
class WorkspaceSkillAssistMessageApi(Resource):
"""Stream read-only Skill Authoring suggestions from the default workspace model."""
@console_ns.expect(console_ns.models[SkillAssistMessagePayload.__name__])
@console_ns.response(200, "Skill Authoring assistant event stream")
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account, skill_id: str):
try:
payload = SkillAssistMessagePayload.model_validate(console_ns.payload or {})
response = SkillManagementService().create_assistant_action_stream(
tenant_id=current_tenant_id,
skill_id=skill_id,
user_id=current_user.id,
message=payload.message,
attachments=payload.attachments,
model_payload=payload.model,
target_path=payload.target_path,
)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
return helper.compact_generate_response(response)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/files")
class WorkspaceSkillFilesApi(Resource):
@console_ns.expect(console_ns.models[SkillDraftFileOperationPayload.__name__])
@console_ns.response(200, "Draft file operation applied", console_ns.models[SkillDetailResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def patch(self, current_tenant_id: str, current_user: Account, skill_id: str):
try:
payload = SkillDraftFileOperationPayload.model_validate(console_ns.payload or {})
result = SkillManagementService().apply_draft_file_operation(
tenant_id=current_tenant_id,
user_id=current_user.id,
skill_id=skill_id,
payload=payload,
)
return dump_response(SkillDetailResponse, result)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except ValueError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.expect(console_ns.models[SkillDraftTreePayload.__name__])
@console_ns.response(200, "Draft files replaced", console_ns.models[SkillDetailResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def put(self, current_tenant_id: str, current_user: Account, skill_id: str):
try:
payload = SkillDraftTreePayload.model_validate(console_ns.payload or {})
result = SkillManagementService().replace_draft_tree(
tenant_id=current_tenant_id,
user_id=current_user.id,
skill_id=skill_id,
payload=payload,
)
return dump_response(SkillDetailResponse, result)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except ValueError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/files/preview")
class WorkspaceSkillFilePreviewApi(Resource):
@console_ns.doc(params=query_params_from_model(SkillFileQuery))
@console_ns.response(200, "Skill file text preview", console_ns.models[SkillFilePreviewResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str, skill_id: str):
try:
query = SkillFileQuery.model_validate(
{
"path": request.args.get("path"),
"version_id": request.args.get("version_id"),
}
)
result = SkillManagementService().preview_file(
tenant_id=current_tenant_id,
skill_id=skill_id,
path=query.path,
version_id=query.version_id,
)
return dump_response(SkillFilePreviewResponse, result)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except ValueError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/files/content")
class WorkspaceSkillFileContentApi(Resource):
@console_ns.doc(params={**query_params_from_model(SkillFileQuery), "download": "Return as an attachment when 1."})
@console_ns.response(200, "Skill file content", console_ns.models[BinaryFileResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str, skill_id: str):
try:
query = SkillFileQuery.model_validate(
{
"path": request.args.get("path"),
"version_id": request.args.get("version_id"),
}
)
result = SkillManagementService().pull_file(
tenant_id=current_tenant_id,
skill_id=skill_id,
path=query.path,
version_id=query.version_id,
)
return send_file(
io.BytesIO(result.payload),
mimetype=result.mime_type,
as_attachment=request.args.get("download") == "1",
download_name=result.filename,
)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except ValueError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/publish")
class WorkspaceSkillPublishApi(Resource):
@console_ns.expect(console_ns.models[SkillPublishPayload.__name__])
@console_ns.response(200, "Skill published", console_ns.models[SkillVersionResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account, skill_id: str):
try:
payload = SkillPublishPayload.model_validate(console_ns.payload or {})
result = SkillManagementService().publish_skill(
tenant_id=current_tenant_id,
user_id=current_user.id,
skill_id=skill_id,
payload=payload,
)
return dump_response(SkillVersionResponse, result)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/restore")
class WorkspaceSkillRestoreApi(Resource):
@console_ns.expect(console_ns.models[SkillRestorePayload.__name__])
@console_ns.response(200, "Skill version restored", console_ns.models[SkillVersionResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account, skill_id: str):
try:
payload = SkillRestorePayload.model_validate(console_ns.payload or {})
result = SkillManagementService().restore_version(
tenant_id=current_tenant_id,
user_id=current_user.id,
skill_id=skill_id,
payload=payload,
)
return dump_response(SkillVersionResponse, result)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/references")
class WorkspaceSkillReferencesApi(Resource):
@console_ns.response(200, "Skill references", console_ns.models[SkillReferenceListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str, skill_id: str):
try:
result = SkillManagementService().list_skill_references(tenant_id=current_tenant_id, skill_id=skill_id)
return dump_response(SkillReferenceListResponse, result)
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/versions")
class WorkspaceSkillVersionsApi(Resource):
@console_ns.response(200, "Skill versions", console_ns.models[SkillVersionListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str, skill_id: str):
try:
result = SkillManagementService().list_versions(tenant_id=current_tenant_id, skill_id=skill_id)
return dump_response(SkillVersionListResponse, result)
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/skills/<string:skill_id>/versions/<string:version_id>")
class WorkspaceSkillVersionApi(Resource):
@console_ns.response(200, "Skill version detail", console_ns.models[SkillVersionDetailResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str, skill_id: str, version_id: str):
try:
result = SkillManagementService().get_version(
tenant_id=current_tenant_id,
skill_id=skill_id,
version_id=version_id,
)
return dump_response(SkillVersionDetailResponse, result)
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.expect(console_ns.models[SkillVersionUpdatePayload.__name__])
@console_ns.response(200, "Skill version updated", console_ns.models[SkillVersionResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_tenant_id
def patch(self, current_tenant_id: str, skill_id: str, version_id: str):
try:
payload = SkillVersionUpdatePayload.model_validate(console_ns.payload or {})
result = SkillManagementService().update_version(
tenant_id=current_tenant_id,
skill_id=skill_id,
version_id=version_id,
payload=payload,
)
return dump_response(SkillVersionResponse, result)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.response(200, "Skill version deleted", console_ns.models[SkillVersionDeleteResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def delete(self, current_tenant_id: str, current_user: Account, skill_id: str, version_id: str):
try:
result = SkillManagementService().delete_version(
tenant_id=current_tenant_id,
user_id=current_user.id,
skill_id=skill_id,
version_id=version_id,
)
return dump_response(SkillVersionDeleteResponse, result)
except SkillManagementServiceError as exc:
return _error_response(exc)
@console_ns.route("/workspaces/current/agents/<string:agent_id>/skills")
class WorkspaceAgentSkillBindingsApi(Resource):
@console_ns.response(200, "Agent Skill bindings", console_ns.models[AgentSkillBindingsResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str, agent_id: str):
result = SkillManagementService().list_agent_bindings(tenant_id=current_tenant_id, agent_id=agent_id)
return dump_response(AgentSkillBindingsResponse, result)
@console_ns.expect(console_ns.models[AgentSkillBindingsPayload.__name__])
@console_ns.response(200, "Agent Skill bindings replaced", console_ns.models[AgentSkillBindingsResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def put(self, current_tenant_id: str, current_user: Account, agent_id: str):
try:
payload = AgentSkillBindingsPayload.model_validate(console_ns.payload or {})
result = SkillManagementService().replace_agent_bindings(
tenant_id=current_tenant_id,
user_id=current_user.id,
agent_id=agent_id,
skill_ids=payload.skill_ids,
)
return dump_response(AgentSkillBindingsResponse, result)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
__all__ = [
"WorkspaceAgentSkillBindingsApi",
"WorkspaceSkillApi",
"WorkspaceSkillDuplicateApi",
"WorkspaceSkillExportApi",
"WorkspaceSkillFilesApi",
"WorkspaceSkillImportApi",
"WorkspaceSkillPublishApi",
"WorkspaceSkillReferencesApi",
"WorkspaceSkillRestoreApi",
"WorkspaceSkillTagsApi",
"WorkspaceSkillVersionApi",
"WorkspaceSkillVersionsApi",
"WorkspaceSkillsApi",
]
+2
View File
@@ -23,6 +23,7 @@ from .knowledge import retrieval as _knowledge_retrieval
from .plugin import agent_config as _agent_config
from .plugin import agent_drive as _agent_drive
from .plugin import plugin as _plugin
from .plugin import skills as _skills
from .workspace import workspace as _workspace
api.add_namespace(inner_api_ns)
@@ -36,6 +37,7 @@ __all__ = [
"_mail",
"_plugin",
"_runtime_credentials",
"_skills",
"_workspace",
"api",
"bp",
@@ -0,0 +1,54 @@
"""Inner API for published workspace Skills.
These endpoints are called by trusted runtime services. They expose only
published Skill artifacts, never draft files or editable metadata.
"""
from __future__ import annotations
import io
from flask import request, send_file
from flask_restx import Resource
from pydantic import BaseModel, ValidationError
from controllers.console.wraps import setup_required
from controllers.inner_api import inner_api_ns
from controllers.inner_api.wraps import plugin_inner_api_only
from services.skill_management_service import SkillManagementService, SkillManagementServiceError
class _SkillTargetQuery(BaseModel):
tenant_id: str
def _target_query_from_request() -> _SkillTargetQuery:
return _SkillTargetQuery.model_validate({"tenant_id": request.args.get("tenant_id")})
def _error_response(exc: SkillManagementServiceError) -> tuple[dict[str, str], int]:
return {"code": exc.code, "message": exc.message}, exc.status_code
@inner_api_ns.route("/skills/<string:skill_id>/pull")
class PublishedSkillPullApi(Resource):
@setup_required
@plugin_inner_api_only
@inner_api_ns.doc("published_skill_pull")
def get(self, skill_id: str):
try:
query = _target_query_from_request()
result = SkillManagementService().pull_published_archive(tenant_id=query.tenant_id, skill_id=skill_id)
return send_file(
io.BytesIO(result.payload),
mimetype=result.mime_type,
as_attachment=True,
download_name=result.filename,
)
except ValidationError as exc:
return {"code": "invalid_request", "message": str(exc)}, 400
except SkillManagementServiceError as exc:
return _error_response(exc)
__all__ = ["PublishedSkillPullApi"]
@@ -26,10 +26,6 @@ from controllers.service_api.app.error import (
ProviderQuotaExceededError,
WorkflowVersionExecutionNotAllowedError,
)
from controllers.service_api.app.legacy_system_files import (
attach_legacy_system_file_warning_for_service_api,
normalize_legacy_system_file_args_for_service_api,
)
from controllers.service_api.schema import (
InputFileList,
expect_user_json,
@@ -394,7 +390,6 @@ class ChatApi(Resource):
args["external_trace_id"] = external_trace_id
streaming = _resolve_agent_app_streaming(app_mode=app_mode, response_mode=payload.response_mode)
legacy_system_file_compat = None
try:
# Eagerly validate conversation to avoid hanging on invalid conversation_id
@@ -406,14 +401,6 @@ class ChatApi(Resource):
session=session,
)
if app_mode == AppMode.ADVANCED_CHAT:
args, legacy_system_file_compat = normalize_legacy_system_file_args_for_service_api(
session=session,
app_model=app_model,
args=args,
raw_payload=service_api_ns.payload,
workflow_id=args.get("workflow_id"),
)
response = AppGenerateService.generate(
session=session,
app_model=app_model,
@@ -422,7 +409,6 @@ class ChatApi(Resource):
invoke_from=InvokeFrom.SERVICE_API,
streaming=streaming,
)
response = attach_legacy_system_file_warning_for_service_api(response, legacy_system_file_compat)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
@@ -1,72 +0,0 @@
"""Temporary Service API adapter for the deprecated workflow file input."""
from collections.abc import Generator, Mapping
from typing import Any
from sqlalchemy.orm import Session
from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.features.rate_limiting.rate_limit import RateLimitGenerator
from core.workflow.legacy_system_files import (
LegacySysFilesCompatVariable,
attach_legacy_sys_files_warning,
normalize_legacy_sys_files_args,
)
from models.model import App
from services.app_generate_service import AppGenerateService
type ServiceAPIGenerateResponse = Mapping[str, Any] | Generator[str, None, None] | RateLimitGenerator
def normalize_legacy_system_file_args_for_service_api(
*,
session: Session,
app_model: App,
args: dict[str, Any],
raw_payload: Mapping[str, Any] | None,
workflow_id: str | None = None,
) -> tuple[dict[str, Any], LegacySysFilesCompatVariable | None]:
# TODO: Remove this hidden Service API compatibility path after all persisted workflows are migrated.
args_with_hidden_system = _copy_hidden_system_files_arg(args=args, raw_payload=raw_payload)
if not _has_legacy_file_arg(args_with_hidden_system):
return args, None
workflow = AppGenerateService.get_workflow(
app_model,
InvokeFrom.SERVICE_API,
workflow_id,
session=session,
)
return normalize_legacy_sys_files_args(graph=workflow.graph_dict, args=args_with_hidden_system)
def attach_legacy_system_file_warning_for_service_api(
response: ServiceAPIGenerateResponse,
compat_variable: LegacySysFilesCompatVariable | None,
) -> ServiceAPIGenerateResponse:
# TODO: Remove this warning once Service API clients no longer need the legacy migration notice.
if compat_variable is None:
return response
return attach_legacy_sys_files_warning(response, compat_variable)
def _copy_hidden_system_files_arg(
*,
args: dict[str, Any],
raw_payload: Mapping[str, Any] | None,
) -> dict[str, Any]:
system = raw_payload.get("system") if isinstance(raw_payload, Mapping) else None
if not isinstance(system, Mapping) or "files" not in system or system["files"] is None:
return args
copied_args = dict(args)
copied_args["system"] = {"files": system["files"]}
return copied_args
def _has_legacy_file_arg(args: Mapping[str, Any]) -> bool:
if args.get("files") is not None:
return True
system = args.get("system")
return isinstance(system, Mapping) and system.get("files") is not None
@@ -30,10 +30,6 @@ from controllers.service_api.app.error import (
ProviderQuotaExceededError,
WorkflowVersionExecutionNotAllowedError,
)
from controllers.service_api.app.legacy_system_files import (
attach_legacy_system_file_warning_for_service_api,
normalize_legacy_system_file_args_for_service_api,
)
from controllers.service_api.schema import (
expect_user_json,
expect_with_user,
@@ -348,12 +344,6 @@ class WorkflowRunApi(Resource):
streaming = payload.response_mode == "streaming"
try:
args, legacy_system_file_compat = normalize_legacy_system_file_args_for_service_api(
session=session,
app_model=app_model,
args=args,
raw_payload=service_api_ns.payload,
)
response = AppGenerateService.generate(
session=session,
app_model=app_model,
@@ -362,7 +352,6 @@ class WorkflowRunApi(Resource):
invoke_from=InvokeFrom.SERVICE_API,
streaming=streaming,
)
response = attach_legacy_system_file_warning_for_service_api(response, legacy_system_file_compat)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
@@ -482,13 +471,6 @@ class WorkflowRunByIdApi(Resource):
streaming = payload.response_mode == "streaming"
try:
args, legacy_system_file_compat = normalize_legacy_system_file_args_for_service_api(
session=session,
app_model=app_model,
args=args,
raw_payload=service_api_ns.payload,
workflow_id=workflow_id,
)
response = AppGenerateService.generate(
session=session,
app_model=app_model,
@@ -497,7 +479,6 @@ class WorkflowRunByIdApi(Resource):
invoke_from=InvokeFrom.SERVICE_API,
streaming=streaming,
)
response = attach_legacy_system_file_warning_for_service_api(response, legacy_system_file_compat)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
@@ -46,7 +46,6 @@ from core.ops.ops_trace_manager import TraceQueueManager
from core.prompt.utils.get_thread_messages_length import get_thread_messages_length
from core.repositories import DifyCoreRepositoryFactory
from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository
from core.workflow.legacy_system_files import normalize_legacy_sys_files_args
from extensions.ext_database import db
from factories import file_factory
from graphon.filters import ResponseStreamFilter
@@ -148,8 +147,6 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
if not args.get("query"):
raise ValueError("query is required")
# TODO: Remove this compatibility normalization after all persisted workflows are migrated.
args, _ = normalize_legacy_sys_files_args(graph=workflow.graph_dict, args=args)
query = args["query"]
if not isinstance(query, str):
raise ValueError("query must be a string")
@@ -43,6 +43,7 @@ from core.workflow.nodes.agent_v2.runtime_request_builder import (
build_config_layer_config,
build_knowledge_layer_config,
build_shell_layer_config,
load_runtime_agent_skill_configs,
)
from models.agent_config_entities import AgentSoulConfig, AgentSoulToolsConfig
from models.provider_ids import ModelProviderID
@@ -125,14 +126,22 @@ class AgentAppRuntimeRequestBuilder:
"cli_tool_count": len(agent_soul.tools.cli_tools),
}
runtime_config_skills = load_runtime_agent_skill_configs(
tenant_id=context.dify_context.tenant_id,
agent_id=context.agent_id,
)
config_layer_config, config_warnings = build_config_layer_config(
agent_soul,
agent_id=context.agent_id,
config_version_id=context.agent_config_snapshot_id,
config_version_kind=context.agent_config_version_kind,
runtime_config_skills=runtime_config_skills,
)
append_runtime_warnings(metadata, config_warnings)
soul_prompt_resolver = build_config_aware_soul_mention_resolver(agent_soul)
soul_prompt_resolver = build_config_aware_soul_mention_resolver(
agent_soul,
runtime_config_skills=runtime_config_skills,
)
knowledge_config = build_knowledge_layer_config(agent_soul)
request = self._request_builder.build_for_agent_app(
@@ -155,10 +155,8 @@ class WorkflowResponseConverter:
# TODO(@future-refactor): store system variables separately from user inputs so we don't
# need to flatten `sys.*` entries into the input payload just for rerun/export tooling.
if field_name == SystemVariableKey.CONVERSATION_ID:
# Conversation IDs are session-scoped; omitting them keeps workflow inputs reusable.
continue
if field_name == SystemVariableKey.FILES:
# When files are exposed as an input, application inputs use the canonical `userinput.files` key.
# Conversation IDs are session-scoped; omitting them keeps workflow inputs
# reusable without pinning new runs to a prior conversation.
continue
inputs[f"sys.{field_name}"] = value
handled = WorkflowEntry.handle_special_values(inputs)
@@ -41,7 +41,6 @@ from core.helper.trace_id_helper import (
from core.ops.ops_trace_manager import TraceQueueManager
from core.repositories import DifyCoreRepositoryFactory
from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository
from core.workflow.legacy_system_files import normalize_legacy_sys_files_args
from extensions.ext_database import db
from factories import file_factory
from graphon.filters import ResponseStreamFilter
@@ -165,8 +164,6 @@ class WorkflowAppGenerator(BaseAppGenerator):
pause_state_config: PauseStateLayerConfig | None = None,
) -> Mapping[str, Any] | Generator[Mapping[str, Any] | str, None, None]:
with self._bind_file_access_scope(tenant_id=app_model.tenant_id, user=user, invoke_from=invoke_from):
# TODO: Remove this compatibility normalization after all persisted workflows are migrated.
args, _ = normalize_legacy_sys_files_args(graph=workflow.graph_dict, args=args)
files: Sequence[Mapping[str, Any]] = args.get("files") or []
# parse files
@@ -42,9 +42,9 @@ _NODE_SNIPPETS: dict[str, str] = {
["local_file", "remote_url"]. Only when you include "custom" must you
also set ``allowed_file_extensions`` to a non-empty list like
[".epub", ".rtf"]; otherwise leave it [].
In Advanced-Chat mode ``sys.query`` is automatic. ``userinput.files`` is
the automatic file-upload variable in both app modes. Downstream nodes
may reference these variables; do NOT add them to ``variables``.""",
In Advanced-Chat mode ``sys.query`` and ``sys.files`` are automatic
system variables — downstream nodes may reference them; do NOT add
them to ``variables``.""",
"end": """\
- end (Workflow mode only):
{"outputs": [
@@ -168,8 +168,8 @@ _NODE_SNIPPETS: dict[str, str] = {
Single output variable ``text``: a string when ``is_array_file`` is false,
an array of strings (one per file) when it is true. ``variable_selector``
MUST point at a ``start`` variable declared with type "file" / "file-list"
(or the automatic ``userinput.files`` variable). A declared start variable
MUST set a non-empty ``allowed_file_types`` (use ["document"] for document text).""",
(or ``sys.files`` in Advanced-Chat mode). That start variable MUST set a
non-empty ``allowed_file_types`` (use ["document"] for document text).""",
"variable-aggregator": """\
- variable-aggregator (merge mutually-exclusive branches into one output):
{"output_type": "string", # VarType of the merged value — one of
@@ -91,20 +91,21 @@ def format_parallel_plan(
def format_mode_section(mode: str) -> str:
"""Tell each builder which app mode it is configuring for.
``sys.query`` is available in advanced-chat, while ``userinput.files`` is
available in both app modes. Without this guidance the model invents
start-node variables that postprocess then materializes as spurious inputs.
Matters most in advanced-chat, where ``sys.query`` / ``sys.files`` are the
sanctioned way to reference the user's message — without this the model
invents start-node variables that postprocess then materializes as
spurious form inputs.
"""
if mode == "advanced-chat":
return (
"# App mode\n\n"
"advanced-chat: the user's chat message is available as sys.query and uploaded files "
"as userinput.files. Use placeholder {{#sys.query#}} or selector "
'["userinput", "files"] directly; do NOT invent start-node variables for them.\n\n'
'as sys.files placeholder {{#sys.query#}}, selector ["sys", "query"]. Reference them '
"directly; do NOT invent start-node variables for the chat message.\n\n"
)
return (
"# App mode\n\n"
"workflow: uploaded files are available as userinput.files; all other user input must use "
"workflow: there are NO automatic system variables; reference user input only through "
"the start node's declared variables.\n\n"
)
@@ -96,10 +96,10 @@ minimum set of Dify workflow nodes needed to fulfil it, in execution order.
- "text-input" for short single-line values (URLs, names),
- "paragraph" for free-form multi-line text (descriptions, queries),
- "number" / "select" / "file" / "file-list" for the obvious cases.
In Advanced-Chat mode ``sys.query`` is automatic. ``userinput.files`` is
automatic in both app modes. Downstream nodes may reference these values
without a ``start_inputs`` entry; every other user-supplied Workflow value
must be declared in ``start_inputs``.
In Advanced-Chat mode the ``sys.query`` / ``sys.files`` system
variables are automatic — downstream nodes may reference them without
a ``start_inputs`` entry. In Workflow mode there is NO automatic
variable; everything the user supplies must be in ``start_inputs``.
11. Give every node a unique runtime-safe ``id`` using only letters, digits,
and underscores. In create mode use ``node1``, ``node2``, ... in node-list
order. In refine mode preserve the existing id for every retained node.
+4 -7
View File
@@ -1382,8 +1382,8 @@ class WorkflowGenerator:
multiple outputs remain untouched so validation fails closed instead
of guessing which value the workflow should consume.
``sys.query`` in Advanced-Chat mode and ``userinput.files`` in either mode
are treated as resolved without declarations. Tool nodes' parameter
For Advanced-Chat mode, ``sys.query`` and ``sys.files`` are always
treated as resolved without any declaration. Tool nodes' parameter
references aren't validated here because we don't know each tool's
schema — the run time validates those.
"""
@@ -1398,12 +1398,9 @@ class WorkflowGenerator:
for node in nodes:
cls._collect_refs_in_data(node.get("data") or {}, refs)
automatic_refs = {("userinput", "files")}
if mode == "advanced-chat":
automatic_refs.add(("sys", "query"))
for node_id, var in refs:
if (node_id, var) in automatic_refs:
# Advanced-Chat system variables are always resolved.
if mode == "advanced-chat" and node_id == "sys":
continue
target = nodes_by_id.get(node_id)
if target is None:
-218
View File
@@ -1,218 +0,0 @@
"""Compatibility helpers for workflows that still reference deprecated `sys.files`.
TODO: Remove this module after all persisted Workflow and Advanced Chat graphs
have been migrated from the deprecated system file variable to `userinput.files`.
"""
from __future__ import annotations
import json
from collections.abc import Generator, Iterable, Mapping
from dataclasses import dataclass
from typing import Any
_LEGACY_SYSTEM_NODE_ID = "sys"
_USER_INPUT_NODE_ID = "userinput"
_LEGACY_FILES_VARIABLE = "files"
_USER_INPUT_FILE_SELECTOR = [_USER_INPUT_NODE_ID, _LEGACY_FILES_VARIABLE]
_USER_INPUT_FILE_INPUT_KEY = ".".join(_USER_INPUT_FILE_SELECTOR)
_LEGACY_FILES_TEMPLATE = "{{#sys.files#}}"
_USER_INPUT_FILES_TEMPLATE = "{{#userinput.files#}}"
@dataclass(frozen=True)
class LegacySysFilesCompatVariable:
node_id: str
variable_name: str
@dataclass(frozen=True)
class LegacySysFilesGraphMigrationResult:
graph: dict[str, Any]
changed: bool
def migrate_legacy_sys_files_graph_with_result(
graph: Mapping[str, Any],
) -> LegacySysFilesGraphMigrationResult:
"""Return the migrated graph and whether any legacy reference was rewritten."""
graph_copy = dict(graph)
nodes = graph_copy.get("nodes")
if not isinstance(nodes, list):
return LegacySysFilesGraphMigrationResult(graph=graph_copy, changed=False)
# Legacy references are stored in node data. Restricting both search and replacement to `nodes`
# avoids recursively scanning graph-level metadata and edges for every workflow load.
if not _contains_legacy_sys_files_reference(nodes):
return LegacySysFilesGraphMigrationResult(graph=graph_copy, changed=False)
graph_copy["nodes"] = _replace_legacy_sys_files_references(nodes)
return LegacySysFilesGraphMigrationResult(graph=graph_copy, changed=True)
def resolve_legacy_sys_files_compat_variable(graph: Mapping[str, Any]) -> LegacySysFilesCompatVariable | None:
"""Resolve the target variable used by the `sys.files` compatibility layer."""
nodes = graph.get("nodes")
if not isinstance(nodes, list):
return None
if not _contains_file_input_reference(nodes):
return None
return LegacySysFilesCompatVariable(node_id=_USER_INPUT_NODE_ID, variable_name=_LEGACY_FILES_VARIABLE)
def normalize_legacy_sys_files_args(
*,
graph: Mapping[str, Any],
args: Mapping[str, Any],
) -> tuple[dict[str, Any], LegacySysFilesCompatVariable | None]:
"""Map Service/Web API file arguments onto the `userinput.files` system alias.
The top-level `files` argument and hidden `system.files` payload both feed
the same runtime file collection. After graph references are migrated, the
file collection is exposed in the variable pool as `userinput.files`.
"""
normalized_args = dict(args)
files_from_input, input_files_used = _extract_userinput_files(args)
if input_files_used:
normalized_args["files"] = files_from_input
return normalized_args, None
compat_variable = resolve_legacy_sys_files_compat_variable(graph)
if compat_variable is None:
return normalized_args, None
files, legacy_files_used = _extract_legacy_files(args)
if not legacy_files_used:
return normalized_args, None
if normalized_args.get("files") is None:
normalized_args["files"] = files
raw_inputs = normalized_args.get("inputs")
inputs = dict(raw_inputs) if isinstance(raw_inputs, Mapping) else {}
inputs.setdefault(_USER_INPUT_FILE_INPUT_KEY, files)
normalized_args["inputs"] = inputs
return normalized_args, compat_variable
def attach_legacy_sys_files_warning(
response: Mapping[str, Any] | Iterable[Any],
compat_variable: LegacySysFilesCompatVariable,
) -> Mapping[str, Any] | Generator[str, None, None]:
warning = build_legacy_sys_files_warning(compat_variable)
if isinstance(response, Mapping):
response_with_warning = dict(response)
existing_warnings = response_with_warning.get("warnings")
warnings = list(existing_warnings) if isinstance(existing_warnings, list) else []
warnings.append(warning)
response_with_warning["warnings"] = warnings
return response_with_warning
def _with_warning() -> Generator[str, None, None]:
try:
yield f"data: {json.dumps({'event': 'warning', 'warning': warning})}\n\n"
yield from response
finally:
close = getattr(response, "close", None)
if callable(close):
close()
return _with_warning()
def build_legacy_sys_files_warning(compat_variable: LegacySysFilesCompatVariable) -> str:
variable_selector = ".".join((compat_variable.node_id, compat_variable.variable_name))
return (
"sys.files is deprecated. This workflow now reads files from "
f"`{variable_selector}`; update Service API calls to pass files in "
f"`inputs.{variable_selector}` instead of `system.files` or top-level `files`."
)
def _contains_legacy_sys_files_reference(value: Any) -> bool:
if _is_legacy_sys_files_selector(value):
return True
if isinstance(value, str):
return _LEGACY_FILES_TEMPLATE in value
if isinstance(value, Mapping):
return any(_contains_legacy_sys_files_reference(item) for item in value.values())
if isinstance(value, list):
return any(_contains_legacy_sys_files_reference(item) for item in value)
return False
def _contains_file_input_reference(value: Any) -> bool:
if _is_legacy_sys_files_selector(value) or _is_userinput_files_selector(value):
return True
if isinstance(value, str):
return _LEGACY_FILES_TEMPLATE in value or _USER_INPUT_FILES_TEMPLATE in value
if isinstance(value, Mapping):
return any(_contains_file_input_reference(item) for item in value.values())
if isinstance(value, list):
return any(_contains_file_input_reference(item) for item in value)
return False
def _replace_legacy_sys_files_references(value: Any) -> Any:
if _is_legacy_sys_files_selector(value):
return list(_USER_INPUT_FILE_SELECTOR)
if isinstance(value, str):
return value.replace(_LEGACY_FILES_TEMPLATE, _USER_INPUT_FILES_TEMPLATE)
if isinstance(value, Mapping):
return {key: _replace_legacy_sys_files_references(item) for key, item in value.items()}
if isinstance(value, list):
return [_replace_legacy_sys_files_references(item) for item in value]
return value
def _is_legacy_sys_files_selector(value: Any) -> bool:
return (
isinstance(value, list)
and len(value) == 2
and value[0] == _LEGACY_SYSTEM_NODE_ID
and value[1] == _LEGACY_FILES_VARIABLE
)
def _is_userinput_files_selector(value: Any) -> bool:
return isinstance(value, list) and value == _USER_INPUT_FILE_SELECTOR
def serialized_graph_may_contain_legacy_sys_files(serialized_graph: str) -> bool:
"""Cheaply reject stored graphs that cannot contain a legacy file reference."""
return _LEGACY_FILES_TEMPLATE in serialized_graph or ('"sys"' in serialized_graph and '"files"' in serialized_graph)
def _extract_legacy_files(args: Mapping[str, Any]) -> tuple[Any, bool]:
if "files" in args and args["files"] is not None:
return args["files"], True
system = args.get("system")
if isinstance(system, Mapping) and "files" in system and system["files"] is not None:
return system["files"], True
return None, False
def _extract_userinput_files(args: Mapping[str, Any]) -> tuple[Any, bool]:
inputs = args.get("inputs")
if isinstance(inputs, Mapping) and inputs.get(_USER_INPUT_FILE_INPUT_KEY) is not None:
return inputs[_USER_INPUT_FILE_INPUT_KEY], True
return None, False
@@ -38,6 +38,7 @@ from dify_agent.layers.shell import (
)
from dify_agent.protocol import CreateRunRequest, DeferredToolResultsPayload
from pydantic import BaseModel, ValidationError
from sqlalchemy.exc import OperationalError
from clients.agent_backend import (
AgentBackendModelConfig,
@@ -206,14 +207,22 @@ class WorkflowAgentRuntimeRequestBuilder:
"cli_tool_count": len(agent_soul.tools.cli_tools),
}
runtime_config_skills = load_runtime_agent_skill_configs(
tenant_id=context.dify_context.tenant_id,
agent_id=context.agent.id,
)
config_layer_config, config_warnings = build_config_layer_config(
agent_soul,
agent_id=context.agent.id,
config_version_id=context.snapshot.id,
config_version_kind="snapshot",
runtime_config_skills=runtime_config_skills,
)
append_runtime_warnings(metadata, config_warnings)
soul_prompt_resolver = build_config_aware_soul_mention_resolver(agent_soul)
soul_prompt_resolver = build_config_aware_soul_mention_resolver(
agent_soul,
runtime_config_skills=runtime_config_skills,
)
soul_prompt = expand_prompt_mentions(agent_soul.prompt.system_prompt, soul_prompt_resolver).strip()
knowledge_config = build_knowledge_layer_config(agent_soul)
@@ -368,7 +377,7 @@ class WorkflowAgentRuntimeRequestBuilder:
if uploaded_files is not None:
lines.append("- Uploaded workflow files:")
lines.append(f" - userinput.files: {uploaded_files}")
lines.append(f" - sys.files: {uploaded_files}")
if resolved_outputs:
lines.append("- Previous node outputs:")
@@ -883,11 +892,16 @@ def append_runtime_warnings(metadata: dict[str, Any], warnings: list[dict[str, s
existing.extend(warnings)
def build_config_aware_soul_mention_resolver(agent_soul: AgentSoulConfig):
def build_config_aware_soul_mention_resolver(
agent_soul: AgentSoulConfig,
*,
runtime_config_skills: Sequence[DifyConfigSkillConfig] = (),
):
"""Resolve config skill/file mentions and delegate the rest to Agent Soul."""
base_resolver = build_soul_mention_resolver(agent_soul)
skill_names = {item.name for item in agent_soul.config_skills if not item.is_missing}
skill_names.update(item.name for item in runtime_config_skills)
file_names = {item.name for item in agent_soul.config_files if not item.is_missing}
def _resolve(mention: object) -> str | None:
@@ -905,12 +919,34 @@ def build_config_aware_soul_mention_resolver(agent_soul: AgentSoulConfig):
return _resolve
def load_runtime_agent_skill_configs(*, tenant_id: str, agent_id: str) -> list[DifyConfigSkillConfig]:
"""Return workspace-bound Skills as prompt-safe runtime config skills."""
from services.skill_management_service import SkillManagementService
try:
runtime_skills = SkillManagementService().list_runtime_agent_skills(tenant_id=tenant_id, agent_id=agent_id)
except OperationalError as exc:
if "no such table: agent_skill_bindings" not in str(exc.orig):
raise
runtime_skills = []
return [
DifyConfigSkillConfig(
name=str(item["name"]),
description=str(item.get("description") or ""),
size=cast(int | None, item.get("size")),
mime_type=cast(str | None, item.get("mime_type")),
)
for item in runtime_skills
]
def build_config_layer_config(
agent_soul: AgentSoulConfig,
*,
agent_id: str | None = None,
config_version_id: str | None = None,
config_version_kind: Literal["snapshot", "draft", "build_draft"] = "snapshot",
runtime_config_skills: Sequence[DifyConfigSkillConfig] = (),
) -> tuple[DifyConfigLayerConfig, list[dict[str, str]]]:
"""Build the always-present Agent config layer from Agent Soul state.
@@ -927,8 +963,23 @@ def build_config_layer_config(
)
)
available_skills = [skill for skill in agent_soul.config_skills if not skill.is_missing]
skill_configs = [
DifyConfigSkillConfig(
name=skill.name,
description=skill.description,
size=skill.size,
mime_type=skill.mime_type,
)
for skill in available_skills
]
seen_skill_names = {skill.name for skill in skill_configs}
for skill in runtime_config_skills:
if skill.name in seen_skill_names:
continue
seen_skill_names.add(skill.name)
skill_configs.append(skill)
available_files = [file_ref for file_ref in agent_soul.config_files if not file_ref.is_missing]
skill_names = {skill.name for skill in available_skills}
skill_names = {skill.name for skill in skill_configs}
file_names = {file_ref.name for file_ref in available_files}
warnings: list[dict[str, str]] = [
{
@@ -965,15 +1016,7 @@ def build_config_layer_config(
kind=config_version_kind,
writable=config_version_kind == "build_draft",
),
skills=[
DifyConfigSkillConfig(
name=skill.name,
description=skill.description,
size=skill.size,
mime_type=skill.mime_type,
)
for skill in available_skills
],
skills=skill_configs,
files=[
DifyConfigFileConfig(
name=file_ref.name,
-7
View File
@@ -16,7 +16,6 @@ from .variable_prefixes import (
ENVIRONMENT_VARIABLE_NODE_ID,
RAG_PIPELINE_VARIABLE_NODE_ID,
SYSTEM_VARIABLE_NODE_ID,
USER_INPUT_VARIABLE_NODE_ID,
)
@@ -119,12 +118,6 @@ def build_bootstrap_variables(
*(_with_selector(variable, ENVIRONMENT_VARIABLE_NODE_ID) for variable in environment_variables),
*(_with_selector(variable, CONVERSATION_VARIABLE_NODE_ID) for variable in conversation_variables),
]
# TODO: Stop emitting the legacy `sys.files` selector after stored graphs and Service API callers are migrated.
# `userinput.files` remains the canonical file-upload variable.
for variable in system_variables:
if variable.name == SystemVariableKey.FILES.value:
variables.append(_with_selector(variable, USER_INPUT_VARIABLE_NODE_ID))
break
rag_pipeline_variables_map: defaultdict[str, dict[str, Any]] = defaultdict(dict)
for rag_var in rag_pipeline_variables:
-1
View File
@@ -1,5 +1,4 @@
SYSTEM_VARIABLE_NODE_ID = "sys"
USER_INPUT_VARIABLE_NODE_ID = "userinput"
ENVIRONMENT_VARIABLE_NODE_ID = "env"
CONVERSATION_VARIABLE_NODE_ID = "conversation"
RAG_PIPELINE_VARIABLE_NODE_ID = "rag"
-2
View File
@@ -29,7 +29,6 @@ def init_app(app: DifyApp):
install_rag_pipeline_plugins,
migrate_data_for_plugin,
migrate_dataset_permissions_to_rbac,
migrate_legacy_sys_files_workflows,
migrate_member_roles_to_rbac,
migrate_oss,
migration_data_wizard,
@@ -58,7 +57,6 @@ def init_app(app: DifyApp):
data_migrate,
upgrade_db,
fix_app_site_missing,
migrate_legacy_sys_files_workflows,
migrate_data_for_plugin,
migrate_dataset_permissions_to_rbac,
migrate_member_roles_to_rbac,
+5 -1
View File
@@ -289,7 +289,11 @@ UUIDStr = Annotated[str, AfterValidator(_strict_uuid)]
def alphanumeric(value: str):
# check if the value is alphanumeric and underlined
if re.match(r"^[a-zA-Z0-9_]+$", value):
# Use re.fullmatch instead of re.match to reject trailing newlines.
# In Python, '$' matches at end-of-string OR just before a trailing newline,
# so re.match accepts "tool_name\n". re.fullmatch requires the entire
# string to match. Regression for #39666 (sibling of #39234 / #39548).
if re.fullmatch(r"^[a-zA-Z0-9_]+$", value):
return value
raise ValueError(f"{value} is not a valid alphanumeric value")
@@ -0,0 +1,114 @@
"""add workspace skill management
Revision ID: a4f8d2c9e1b0
Revises: 6f5a9c2d8e1b
Create Date: 2026-07-09 12:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import mysql
from models.types import StringUUID
# revision identifiers, used by Alembic.
revision = "a4f8d2c9e1b0"
down_revision = "6f5a9c2d8e1b"
branch_labels = None
depends_on = None
def _uuid_column(name: str, *, nullable: bool = False) -> sa.Column:
return sa.Column(name, StringUUID(), nullable=nullable)
def _long_text() -> sa.types.TypeEngine:
return sa.Text().with_variant(mysql.LONGTEXT(), "mysql")
def upgrade() -> None:
op.create_table(
"skills",
_uuid_column("id"),
_uuid_column("tenant_id"),
sa.Column("name", sa.String(length=64), nullable=False),
sa.Column("display_name", sa.String(length=128), nullable=False),
sa.Column("icon", sa.String(length=16), nullable=False, server_default="📄"),
sa.Column("description", sa.String(length=1024), nullable=False, server_default=""),
sa.Column("name_manually_edited", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("visibility", sa.String(length=32), nullable=False, server_default="workspace"),
_uuid_column("latest_published_version_id", nullable=True),
_uuid_column("created_by", nullable=True),
_uuid_column("updated_by", nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
sa.PrimaryKeyConstraint("id", name="skill_pkey"),
sa.UniqueConstraint("tenant_id", "name", name="skill_tenant_name_unique"),
)
op.create_index("skills_tenant_updated_at_idx", "skills", ["tenant_id", "updated_at"])
op.create_table(
"skill_draft_files",
_uuid_column("id"),
_uuid_column("skill_id"),
sa.Column("path", sa.String(length=512), nullable=False),
sa.Column("kind", sa.String(length=32), nullable=False),
sa.Column("storage", sa.String(length=32), nullable=True),
sa.Column("mime_type", sa.String(length=255), nullable=True),
sa.Column("content_text", _long_text(), nullable=True),
_uuid_column("tool_file_id", nullable=True),
sa.Column("size", sa.BigInteger(), nullable=True),
sa.Column("hash", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
sa.PrimaryKeyConstraint("id", name="skill_draft_file_pkey"),
sa.UniqueConstraint("skill_id", "path", name="skill_draft_file_skill_path_unique"),
)
op.create_index("skill_draft_files_skill_path_idx", "skill_draft_files", ["skill_id", "path"])
op.create_table(
"skill_versions",
_uuid_column("id"),
_uuid_column("skill_id"),
sa.Column("version_number", sa.Integer(), nullable=False),
sa.Column("version_name", sa.String(length=128), nullable=False, server_default=""),
sa.Column("publish_note", sa.String(length=1024), nullable=False, server_default=""),
sa.Column("manifest", _long_text(), nullable=False),
_uuid_column("archive_tool_file_id"),
sa.Column("hash_code", sa.String(length=255), nullable=False),
sa.Column("archive_size", sa.BigInteger(), nullable=False),
_uuid_column("published_by", nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
sa.PrimaryKeyConstraint("id", name="skill_version_pkey"),
sa.UniqueConstraint("skill_id", "version_number", name="skill_version_skill_number_unique"),
)
op.create_index("skill_versions_skill_created_at_idx", "skill_versions", ["skill_id", "created_at"])
op.create_table(
"agent_skill_bindings",
_uuid_column("id"),
_uuid_column("tenant_id"),
_uuid_column("agent_id"),
_uuid_column("skill_id"),
sa.Column("priority", sa.Integer(), nullable=False),
_uuid_column("created_by", nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
sa.PrimaryKeyConstraint("id", name="agent_skill_binding_pkey"),
sa.UniqueConstraint("tenant_id", "agent_id", "skill_id", name="agent_skill_binding_unique"),
sa.UniqueConstraint("tenant_id", "agent_id", "priority", name="agent_skill_binding_priority_unique"),
)
op.create_index("agent_skill_bindings_skill_idx", "agent_skill_bindings", ["tenant_id", "skill_id"])
def downgrade() -> None:
op.drop_index("agent_skill_bindings_skill_idx", table_name="agent_skill_bindings")
op.drop_table("agent_skill_bindings")
op.drop_index("skill_versions_skill_created_at_idx", table_name="skill_versions")
op.drop_table("skill_versions")
op.drop_index("skill_draft_files_skill_path_idx", table_name="skill_draft_files")
op.drop_table("skill_draft_files")
op.drop_index("skills_tenant_updated_at_idx", table_name="skills")
op.drop_table("skills")
@@ -18,31 +18,109 @@ branch_labels = None
depends_on = None
def _is_pg(conn) -> bool:
return conn.dialect.name == "postgresql"
def _uuid_column(name: str, *, nullable: bool = False, primary_key: bool = False) -> sa.Column:
kwargs = {"nullable": nullable, "primary_key": primary_key}
if primary_key and _is_pg(op.get_bind()):
kwargs["server_default"] = sa.text("uuidv7()")
return sa.Column(name, models.types.StringUUID(), **kwargs)
def _has_table(table_name: str) -> bool:
return sa.inspect(op.get_bind()).has_table(table_name)
def _has_column(table_name: str, column_name: str) -> bool:
return any(
column["name"] == column_name for column in sa.inspect(op.get_bind()).get_columns(table_name)
)
def _has_unique_constraint(table_name: str, constraint_name: str) -> bool:
return any(
constraint["name"] == constraint_name
for constraint in sa.inspect(op.get_bind()).get_unique_constraints(table_name)
)
def upgrade():
if not _has_table("agent_debug_conversations"):
op.create_table(
"agent_debug_conversations",
_uuid_column("id", primary_key=True),
sa.Column("tenant_id", models.types.StringUUID(), nullable=False),
sa.Column("agent_id", models.types.StringUUID(), nullable=False),
sa.Column("app_id", models.types.StringUUID(), nullable=False),
sa.Column("account_id", models.types.StringUUID(), nullable=False),
sa.Column("conversation_id", models.types.StringUUID(), nullable=False),
sa.Column(
"draft_type",
sa.String(length=32),
nullable=False,
server_default=sa.text("'debug_build'"),
),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("agent_debug_conversation_pkey")),
sa.UniqueConstraint(
"tenant_id",
"agent_id",
"account_id",
"draft_type",
name=op.f("agent_debug_conversation_agent_account_draft_type_unique"),
),
)
op.create_index(
"agent_debug_conversation_conversation_idx",
"agent_debug_conversations",
["conversation_id"],
)
op.create_index(
"agent_debug_conversation_account_idx",
"agent_debug_conversations",
["tenant_id", "account_id"],
)
return
# Existing pointers have always represented Build chat because the Agent
# detail API exposes them as ``debug_conversation_id`` for that surface.
op.add_column(
if not _has_column("agent_debug_conversations", "draft_type"):
op.add_column(
"agent_debug_conversations",
sa.Column(
"draft_type",
sa.String(length=32),
nullable=False,
server_default=sa.text("'debug_build'"),
),
)
if _has_unique_constraint(
"agent_debug_conversations",
sa.Column(
"draft_type",
sa.String(length=32),
nullable=False,
server_default=sa.text("'debug_build'"),
),
)
op.drop_constraint(
"agent_debug_conversation_agent_account_unique",
):
op.drop_constraint(
"agent_debug_conversation_agent_account_unique",
"agent_debug_conversations",
type_="unique",
)
if not _has_unique_constraint(
"agent_debug_conversations",
type_="unique",
)
op.create_unique_constraint(
"agent_debug_conversation_agent_account_draft_type_unique",
"agent_debug_conversations",
["tenant_id", "agent_id", "account_id", "draft_type"],
)
):
op.create_unique_constraint(
"agent_debug_conversation_agent_account_draft_type_unique",
"agent_debug_conversations",
["tenant_id", "agent_id", "account_id", "draft_type"],
)
def downgrade():
if not _has_table("agent_debug_conversations"):
return
debug_conversations = sa.table(
"agent_debug_conversations",
sa.column("tenant_id", models.types.StringUUID()),
@@ -64,14 +142,23 @@ def downgrade():
),
)
)
op.drop_constraint(
if _has_unique_constraint(
"agent_debug_conversations",
"agent_debug_conversation_agent_account_draft_type_unique",
):
op.drop_constraint(
"agent_debug_conversation_agent_account_draft_type_unique",
"agent_debug_conversations",
type_="unique",
)
if not _has_unique_constraint(
"agent_debug_conversations",
type_="unique",
)
op.create_unique_constraint(
"agent_debug_conversation_agent_account_unique",
"agent_debug_conversations",
["tenant_id", "agent_id", "account_id"],
)
op.drop_column("agent_debug_conversations", "draft_type")
):
op.create_unique_constraint(
"agent_debug_conversation_agent_account_unique",
"agent_debug_conversations",
["tenant_id", "agent_id", "account_id"],
)
if _has_column("agent_debug_conversations", "draft_type"):
op.drop_column("agent_debug_conversations", "draft_type")
@@ -0,0 +1,21 @@
"""merge skill and agent debug conversation heads
Revision ID: e9f4a1b2c3d5
Revises: a4f8d2c9e1b0, d2825e7b9c10
Create Date: 2026-07-23 15:00:00.000000
"""
# revision identifiers, used by Alembic.
revision = "e9f4a1b2c3d5"
down_revision = ("a4f8d2c9e1b0", "d2825e7b9c10")
branch_labels = None
depends_on = None
def upgrade():
pass
def downgrade():
pass
+7
View File
@@ -113,6 +113,7 @@ from .provider import (
TenantDefaultModel,
TenantPreferredModelProvider,
)
from .skill import AgentSkillBinding, Skill, SkillDraftFile, SkillFileKind, SkillFileStorage, SkillVersion
from .snippet import CustomizedSnippet, SnippetType
from .source import DataSourceApiKeyAuthBinding, DataSourceOauthBinding
from .task import CeleryTask, CeleryTaskSet
@@ -173,6 +174,7 @@ __all__ = [
"AgentRuntimeSessionOwnerType",
"AgentRuntimeSessionStatus",
"AgentScope",
"AgentSkillBinding",
"AgentSource",
"AgentStatus",
"ApiRequest",
@@ -246,6 +248,11 @@ __all__ = [
"RecommendedApp",
"SavedMessage",
"Site",
"Skill",
"SkillDraftFile",
"SkillFileKind",
"SkillFileStorage",
"SkillVersion",
"SnippetType",
"Tag",
"TagBinding",
+1
View File
@@ -249,6 +249,7 @@ class TagType(StrEnum):
KNOWLEDGE = "knowledge"
APP = "app"
SNIPPET = "snippet"
SKILL = "skill"
class DatasetMetadataType(StrEnum):
+1 -1
View File
@@ -2667,7 +2667,7 @@ class Tag(TypeBase):
sa.Index("tag_name_idx", "name"),
)
TAG_TYPE_LIST = ["knowledge", "app", "snippet"]
TAG_TYPE_LIST = ["knowledge", "app", "snippet", "skill"]
id: Mapped[str] = mapped_column(
StringUUID, insert_default=lambda: str(uuid4()), default_factory=lambda: str(uuid4()), init=False
+164
View File
@@ -0,0 +1,164 @@
"""Workspace-level Skill Management models.
These tables are the source of truth for reusable workspace Skills. Agent Soul
``config_skills`` and Agent Drive skill rows remain per-agent runtime/config
assets; they may consume a published Skill snapshot but do not own the Skill's
draft, metadata, version history, or Agent binding priority.
"""
from enum import StrEnum
import sqlalchemy as sa
from pydantic import BaseModel, ConfigDict
from sqlalchemy import Index, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from models.base import Base, DefaultFieldsMixin
from models.types import EnumText, JSONModelColumn, LongText, StringUUID
class SkillFileKind(StrEnum):
"""Draft file entry kind."""
FILE = "file"
DIRECTORY = "directory"
class SkillFileStorage(StrEnum):
"""How a draft file's content is stored."""
TEXT = "text"
TOOL_FILE = "tool_file"
class SkillVersionManifestFile(BaseModel):
"""One file entry captured in a published Skill snapshot manifest."""
path: str
mime_type: str | None = None
size: int
hash: str
model_config = ConfigDict(extra="forbid")
class SkillVersionManifest(BaseModel):
"""Published Skill snapshot file index."""
files: list[SkillVersionManifestFile]
model_config = ConfigDict(extra="forbid")
class Skill(DefaultFieldsMixin, Base):
"""Workspace-level reusable Skill metadata and draft status."""
__tablename__ = "skills"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="skill_pkey"),
UniqueConstraint("tenant_id", "name", name="skill_tenant_name_unique"),
Index("skills_tenant_updated_at_idx", "tenant_id", "updated_at"),
)
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
name: Mapped[str] = mapped_column(sa.String(64), nullable=False)
display_name: Mapped[str] = mapped_column(sa.String(128), nullable=False)
icon: Mapped[str] = mapped_column(sa.String(16), nullable=False, default="📄", server_default="📄")
description: Mapped[str] = mapped_column(sa.String(1024), nullable=False, default="", server_default="")
name_manually_edited: Mapped[bool] = mapped_column(
sa.Boolean,
nullable=False,
default=False,
server_default=sa.false(),
)
visibility: Mapped[str] = mapped_column(
sa.String(32),
nullable=False,
default="workspace",
server_default="workspace",
)
latest_published_version_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
updated_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
class SkillDraftFile(DefaultFieldsMixin, Base):
"""One draft file or directory in a workspace Skill."""
__tablename__ = "skill_draft_files"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="skill_draft_file_pkey"),
UniqueConstraint("skill_id", "path", name="skill_draft_file_skill_path_unique"),
Index("skill_draft_files_skill_path_idx", "skill_id", "path"),
)
skill_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
path: Mapped[str] = mapped_column(sa.String(512), nullable=False)
kind: Mapped[SkillFileKind] = mapped_column(EnumText(SkillFileKind, length=32), nullable=False)
storage: Mapped[SkillFileStorage | None] = mapped_column(EnumText(SkillFileStorage, length=32), nullable=True)
mime_type: Mapped[str | None] = mapped_column(sa.String(255), nullable=True)
content_text: Mapped[str | None] = mapped_column(LongText, nullable=True)
tool_file_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
size: Mapped[int | None] = mapped_column(sa.BigInteger, nullable=True)
hash: Mapped[str | None] = mapped_column(sa.String(255), nullable=True)
class SkillVersion(DefaultFieldsMixin, Base):
"""Immutable published Skill snapshot.
``hash_code`` uniquely identifies a published version for downstream
execution audit. It includes Skill identity, version number, and archive
content digest instead of being only the archive content hash.
"""
__tablename__ = "skill_versions"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="skill_version_pkey"),
UniqueConstraint("skill_id", "version_number", name="skill_version_skill_number_unique"),
Index("skill_versions_skill_created_at_idx", "skill_id", "created_at"),
)
skill_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
version_number: Mapped[int] = mapped_column(sa.Integer, nullable=False)
version_name: Mapped[str] = mapped_column(sa.String(128), nullable=False, default="", server_default="")
publish_note: Mapped[str] = mapped_column(sa.String(1024), nullable=False, default="", server_default="")
manifest: Mapped[SkillVersionManifest] = mapped_column(JSONModelColumn(SkillVersionManifest), nullable=False)
archive_tool_file_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
hash_code: Mapped[str] = mapped_column(sa.String(255), nullable=False)
archive_size: Mapped[int] = mapped_column(sa.BigInteger, nullable=False)
published_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
class AgentSkillBinding(DefaultFieldsMixin, Base):
"""Direct Agent-to-workspace-Skill binding.
``priority`` is retained as an internal ordering column for the current
schema constraints. Runtime Skill selection is Agent-driven and must not
treat it as a matching priority.
"""
__tablename__ = "agent_skill_bindings"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="agent_skill_binding_pkey"),
UniqueConstraint("tenant_id", "agent_id", "skill_id", name="agent_skill_binding_unique"),
UniqueConstraint("tenant_id", "agent_id", "priority", name="agent_skill_binding_priority_unique"),
Index("agent_skill_bindings_skill_idx", "tenant_id", "skill_id"),
)
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
skill_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
priority: Mapped[int] = mapped_column(sa.Integer, nullable=False)
created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
__all__ = [
"AgentSkillBinding",
"Skill",
"SkillDraftFile",
"SkillFileKind",
"SkillFileStorage",
"SkillVersion",
"SkillVersionManifest",
"SkillVersionManifestFile",
]
+3 -39
View File
@@ -25,10 +25,6 @@ from typing_extensions import deprecated
from core.trigger.constants import TRIGGER_PLUGIN_NODE_TYPE
from core.workflow.human_input_adapter import adapt_node_config_for_graph
from core.workflow.legacy_system_files import (
migrate_legacy_sys_files_graph_with_result,
serialized_graph_may_contain_legacy_sys_files,
)
from core.workflow.nodes.human_input.pause_reason import (
HumanInputRequired,
)
@@ -329,39 +325,7 @@ class Workflow(Base): # bug
# Currently, the following functions / methods would mutate the returned dict:
#
# - `_get_graph_and_variable_pool_for_single_node_run`.
if not self.graph:
return {}
graph = json.loads(self.graph)
if not self._supports_legacy_sys_files_compatibility() or not serialized_graph_may_contain_legacy_sys_files(
self.graph
):
return graph
# TODO: Remove this load-time compatibility rewrite after all persisted workflows are migrated.
return migrate_legacy_sys_files_graph_with_result(graph).graph
def migrate_legacy_sys_files_graph_in_place(self) -> bool:
if (
not self.graph
or not self._supports_legacy_sys_files_compatibility()
or not serialized_graph_may_contain_legacy_sys_files(self.graph)
):
return False
# TODO: Remove this in-place compatibility rewrite after all persisted workflows are migrated.
migration_result = migrate_legacy_sys_files_graph_with_result(json.loads(self.graph))
if migration_result.changed:
self.graph = json.dumps(migration_result.graph)
return migration_result.changed
def _supports_legacy_sys_files_compatibility(self) -> bool:
return self.type in {
WorkflowType.WORKFLOW,
WorkflowType.CHAT,
WorkflowType.WORKFLOW.value,
WorkflowType.CHAT.value,
}
return json.loads(self.graph) if self.graph else {}
def get_node_config_by_id(self, node_id: str) -> NodeConfigDict:
"""Extract a node configuration from the workflow graph by node ID.
@@ -523,7 +487,7 @@ class Workflow(Base): # bug
"memory":
{
"window": { "enabled": false, "size": 10 },
"query_prompt_template": "{{#sys.query#}}\n\n{{#userinput.files#}}",
"query_prompt_template": "{{#sys.query#}}\n\n{{#sys.files#}}",
"role_prefix": { "user": "", "assistant": "" },
},
"selected": false,
@@ -1556,7 +1520,7 @@ class ConversationVariable(TypeBase):
return variable_factory.build_conversation_variable_from_mapping(mapping)
# TODO: Remove file-system-variable editability after all persisted workflows are migrated.
# Only `sys.query` and `sys.files` could be modified.
_EDITABLE_SYSTEM_VARIABLE = frozenset(("query", "files"))
+728 -2
View File
@@ -9551,7 +9551,7 @@ Remove one or more tag bindings from a target.
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| keyword | query | Search keyword | No | string |
| type | query | Tag type filter | No | string, <br>**Available values:** "", "app", "knowledge", "snippet" |
| type | query | Tag type filter | No | string |
#### Responses
@@ -10087,6 +10087,38 @@ Get list of available agent providers
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [AgentProviderListResponse](#agentproviderlistresponse)<br> |
### [GET] /workspaces/current/agents/{agent_id}/skills
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent Skill bindings | **application/json**: [AgentSkillBindingsResponse](#agentskillbindingsresponse)<br> |
### [PUT] /workspaces/current/agents/{agent_id}/skills
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | | Yes | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [AgentSkillBindingsPayload](#agentskillbindingspayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent Skill bindings replaced | **application/json**: [AgentSkillBindingsResponse](#agentskillbindingsresponse)<br> |
### [GET] /workspaces/current/customized-snippets
**List customized snippets with pagination and search**
@@ -11988,6 +12020,341 @@ Returns permission flags that control workspace features like member invitations
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [WorkspaceAccessMatrix](#workspaceaccessmatrix)<br> |
### [GET] /workspaces/current/skills
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| keyword | query | Search keyword matching skill name or description. | No | string |
| limit | query | Number of items per page. | No | integer, <br>**Default:** 20 |
| page | query | Page number. | No | integer, <br>**Default:** 1 |
| tag | query | Skill tag filters. Repeat the parameter for multiple tags. | No | [ string ] |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workspace skills | **application/json**: [SkillListResponse](#skilllistresponse)<br> |
### [POST] /workspaces/current/skills
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [SkillCreatePayload](#skillcreatepayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | Skill created | **application/json**: [SkillDetailResponse](#skilldetailresponse)<br> |
### [POST] /workspaces/current/skills/files/upload
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **multipart/form-data**: { **"file"**: binary }<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | Skill draft file uploaded | **application/json**: [SkillFileUploadResponse](#skillfileuploadresponse)<br> |
### [POST] /workspaces/current/skills/import
Import a Skill zip package from multipart form field `file`.
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | Skill imported | **application/json**: [SkillDetailResponse](#skilldetailresponse)<br> |
### [GET] /workspaces/current/skills/tags
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workspace Skill tags | **application/json**: [SkillTagListResponse](#skilltaglistresponse)<br> |
### [DELETE] /workspaces/current/skills/{skill_id}
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [SkillDeletePayload](#skilldeletepayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill deleted | **application/json**: [SkillDeleteResponse](#skilldeleteresponse)<br> |
### [GET] /workspaces/current/skills/{skill_id}
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill detail | **application/json**: [SkillDetailResponse](#skilldetailresponse)<br> |
### [PATCH] /workspaces/current/skills/{skill_id}
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [SkillMetadataPayload](#skillmetadatapayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill updated | **application/json**: [SkillResponse](#skillresponse)<br> |
### [POST] /workspaces/current/skills/{skill_id}/assist/messages
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [SkillAssistMessagePayload](#skillassistmessagepayload)<br> |
#### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Skill Authoring assistant event stream |
### [POST] /workspaces/current/skills/{skill_id}/duplicate
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | Skill duplicated | **application/json**: [SkillDetailResponse](#skilldetailresponse)<br> |
### [GET] /workspaces/current/skills/{skill_id}/export
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
#### Responses
| Code | Description |
| ---- | ----------- |
| 200 | Published Skill zip archive |
### [PATCH] /workspaces/current/skills/{skill_id}/files
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [SkillDraftFileOperationPayload](#skilldraftfileoperationpayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Draft file operation applied | **application/json**: [SkillDetailResponse](#skilldetailresponse)<br> |
### [PUT] /workspaces/current/skills/{skill_id}/files
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [SkillDraftTreePayload](#skilldrafttreepayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Draft files replaced | **application/json**: [SkillDetailResponse](#skilldetailresponse)<br> |
### [GET] /workspaces/current/skills/{skill_id}/files/content
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| download | query | Return as an attachment when 1. | No | string |
| path | query | Skill file path relative to the Skill root. | Yes | string |
| version_id | query | Optional published version ID. Omit for current draft. | No | string |
| skill_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill file content | **application/json**: [BinaryFileResponse](#binaryfileresponse)<br> |
### [GET] /workspaces/current/skills/{skill_id}/files/preview
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| path | query | Skill file path relative to the Skill root. | Yes | string |
| version_id | query | Optional published version ID. Omit for current draft. | No | string |
| skill_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill file text preview | **application/json**: [SkillFilePreviewResponse](#skillfilepreviewresponse)<br> |
### [POST] /workspaces/current/skills/{skill_id}/publish
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [SkillPublishPayload](#skillpublishpayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill published | **application/json**: [SkillVersionResponse](#skillversionresponse)<br> |
### [GET] /workspaces/current/skills/{skill_id}/references
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill references | **application/json**: [SkillReferenceListResponse](#skillreferencelistresponse)<br> |
### [POST] /workspaces/current/skills/{skill_id}/restore
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [SkillRestorePayload](#skillrestorepayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill version restored | **application/json**: [SkillVersionResponse](#skillversionresponse)<br> |
### [GET] /workspaces/current/skills/{skill_id}/versions
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill versions | **application/json**: [SkillVersionListResponse](#skillversionlistresponse)<br> |
### [DELETE] /workspaces/current/skills/{skill_id}/versions/{version_id}
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
| version_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill version deleted | **application/json**: [SkillVersionDeleteResponse](#skillversiondeleteresponse)<br> |
### [GET] /workspaces/current/skills/{skill_id}/versions/{version_id}
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
| version_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill version detail | **application/json**: [SkillVersionDetailResponse](#skillversiondetailresponse)<br> |
### [PATCH] /workspaces/current/skills/{skill_id}/versions/{version_id}
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| skill_id | path | | Yes | string |
| version_id | path | | Yes | string |
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **application/json**: [SkillVersionUpdatePayload](#skillversionupdatepayload)<br> |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Skill version updated | **application/json**: [SkillVersionResponse](#skillversionresponse)<br> |
### [GET] /workspaces/current/tool-labels
#### Responses
@@ -14709,6 +15076,37 @@ Visibility and lifecycle scope of an Agent record.
| ---- | ---- | ----------- | -------- |
| result | string | | Yes |
#### AgentSkillBindingItemResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| description | string | | Yes |
| display_name | string | | Yes |
| file_count | integer | | Yes |
| icon | string | | Yes |
| id | string | | Yes |
| latest_published_at | integer | | No |
| latest_published_version_id | string | | No |
| name | string | | Yes |
| priority | integer | | Yes |
| status | string | | Yes |
| tags | [ string ] | | No |
| updated_at | integer | | Yes |
#### AgentSkillBindingsPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| skill_ids | [ string ] | Ordered Skill IDs bound to the Agent. | No |
#### AgentSkillBindingsResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| agent_id | string | | Yes |
| data | [ [AgentSkillBindingItemResponse](#agentskillbindingitemresponse) ] | | No |
| skill_ids | [ string ] | | No |
#### AgentSkillRefConfig
| Name | Type | Description | Required |
@@ -21665,6 +22063,186 @@ Simple provider entity response.
| title | string | | Yes |
| use_icon_as_answer_icon | boolean | | Yes |
#### SkillAssistAttachmentPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| mime_type | string | | No |
| name | string | | Yes |
| size | integer | | No |
| tool_file_id | string | | Yes |
#### SkillAssistMessagePayload
One user message and optional uploaded context for the read-only Skill Authoring assistant.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| attachments | [ [SkillAssistAttachmentPayload](#skillassistattachmentpayload) ] | | No |
| message | string | | Yes |
| model | [SkillAssistModelPayload](#skillassistmodelpayload) | | No |
#### SkillAssistModelPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| model | string | | Yes |
| model_settings | object | | No |
| plugin_id | string | | No |
| provider | string | | Yes |
#### SkillCreatePayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| description | string | | No |
| display_name | string | | No |
| icon | string, <br>**Default:** 📄 | | No |
| name | string | | No |
| tags | [ string ] | | No |
#### SkillDeletePayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| confirmation_name | string | Required when deleting a referenced Skill. Must match the Skill name. | No |
#### SkillDeleteResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| deleted | boolean | | Yes |
| id | string | | Yes |
#### SkillDetailResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| created_at | integer | | Yes |
| created_by | string | | No |
| created_by_name | string | | No |
| description | string | | Yes |
| display_name | string | | Yes |
| files | [ [SkillFileResponse](#skillfileresponse) ] | | No |
| icon | string | | Yes |
| id | string | | Yes |
| latest_published_version_id | string | | No |
| name | string | | Yes |
| name_manually_edited | boolean | | No |
| reference_count | integer | | No |
| tags | [ string ] | | No |
| updated_at | integer | | Yes |
| updated_by | string | | No |
| updated_by_name | string | | No |
| visibility | string | | Yes |
#### SkillDraftFileOperation
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| SkillDraftFileOperation | string | | |
#### SkillDraftFileOperationPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| content | string | | No |
| expected_updated_at | integer | | No |
| hash | string | | No |
| mime_type | string | | No |
| operation | [SkillDraftFileOperation](#skilldraftfileoperation) | | Yes |
| path | string | | Yes |
| size | integer | | No |
| target_path | string | | No |
| tool_file_id | string | | No |
#### SkillDraftTreeItemPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| content | string | | No |
| hash | string | | No |
| kind | [SkillFileKind](#skillfilekind) | | No |
| mime_type | string | | No |
| path | string | | Yes |
| size | integer | | No |
| storage | [SkillFileStorage](#skillfilestorage) | | No |
| tool_file_id | string | | No |
#### SkillDraftTreePayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| expected_updated_at | integer | | No |
| files | [ [SkillDraftTreeItemPayload](#skilldrafttreeitempayload) ] | | No |
#### SkillFileKind
Draft file entry kind.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| SkillFileKind | string | Draft file entry kind. | |
#### SkillFilePreviewResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| content | string | | Yes |
| hash | string | | Yes |
| mime_type | string | | Yes |
| path | string | | Yes |
| size | integer | | Yes |
#### SkillFileQuery
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| path | string | Skill file path relative to the Skill root. | Yes |
| version_id | string | Optional published version ID. Omit for current draft. | No |
#### SkillFileResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| content | string | | No |
| hash | string | | No |
| id | string | | No |
| kind | string | | Yes |
| mime_type | string | | No |
| path | string | | Yes |
| size | integer | | No |
| storage | string | | No |
| tool_file_id | string | | No |
#### SkillFileStorage
How a draft file's content is stored.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| SkillFileStorage | string | How a draft file's content is stored. | |
#### SkillFileUploadResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| hash | string | | Yes |
| id | string | | Yes |
| mime_type | string | | Yes |
| name | string | | Yes |
| size | integer | | Yes |
#### SkillListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| data | [ [SkillResponse](#skillresponse) ] | | No |
| has_more | boolean | | No |
| limit | integer, <br>**Default:** 20 | | No |
| page | integer, <br>**Default:** 1 | | No |
| total | integer | | No |
#### SkillManifest
Validated metadata extracted from a Skill package.
@@ -21678,6 +22256,91 @@ Validated metadata extracted from a Skill package.
| name | string | | Yes |
| size | integer | | Yes |
#### SkillMetadataPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| display_name | string | | No |
| expected_updated_at | integer | | No |
| icon | string | | No |
| tags | [ string ] | | No |
#### SkillPublishPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| publish_note | string | | No |
| version_name | string | | No |
#### SkillReferenceListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| data | [ [SkillReferenceResponse](#skillreferenceresponse) ] | | No |
#### SkillReferenceResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| agent_icon | string | | No |
| agent_icon_background | string | | No |
| agent_icon_type | string | | No |
| agent_id | string | | Yes |
| app_id | string | | No |
| display_name | string | | Yes |
| name | string | | Yes |
| node_id | string | | No |
| node_name | string | | No |
| type | string | | Yes |
| workflow_icon | string | | No |
| workflow_icon_background | string | | No |
| workflow_icon_type | string | | No |
| workflow_id | string | | No |
| workflow_name | string | | No |
| workflow_version | string | | No |
#### SkillResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| created_at | integer | | Yes |
| created_by | string | | No |
| created_by_name | string | | No |
| description | string | | Yes |
| display_name | string | | Yes |
| icon | string | | Yes |
| id | string | | Yes |
| latest_published_version_id | string | | No |
| name | string | | Yes |
| name_manually_edited | boolean | | No |
| reference_count | integer | | No |
| tags | [ string ] | | No |
| updated_at | integer | | Yes |
| updated_by | string | | No |
| updated_by_name | string | | No |
| visibility | string | | Yes |
#### SkillRestorePayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| publish_note | string | | No |
| version_id | string | | Yes |
| version_name | string | | No |
#### SkillTagListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| data | [ [SkillTagResponse](#skilltagresponse) ] | | No |
#### SkillTagResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| count | integer | | Yes |
| tag | string | | Yes |
#### SkillToolInferenceResult
| Name | Type | Description | Required |
@@ -21686,6 +22349,60 @@ Validated metadata extracted from a Skill package.
| inferable | boolean | | Yes |
| reason | string | | No |
#### SkillVersionDeleteResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| deleted | boolean | | Yes |
| id | string | | Yes |
| latest_published_version_id | string | | No |
#### SkillVersionDetailResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| archive_size | integer | | Yes |
| created_at | integer | | Yes |
| files | [ [SkillFileResponse](#skillfileresponse) ] | | No |
| hash_code | string | | Yes |
| id | string | | Yes |
| is_latest | boolean | | No |
| publish_note | string | | Yes |
| published_by | string | | No |
| published_by_name | string | | No |
| skill_id | string | | Yes |
| version_name | string | | Yes |
| version_number | integer | | Yes |
#### SkillVersionListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| data | [ [SkillVersionResponse](#skillversionresponse) ] | | No |
#### SkillVersionResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| archive_size | integer | | Yes |
| created_at | integer | | Yes |
| hash_code | string | | Yes |
| id | string | | Yes |
| is_latest | boolean | | No |
| publish_note | string | | Yes |
| published_by | string | | No |
| published_by_name | string | | No |
| skill_id | string | | Yes |
| version_name | string | | Yes |
| version_number | integer | | Yes |
#### SkillVersionUpdatePayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| publish_note | string | | No |
| version_name | string | | No |
#### SnippetDependencyCheckResponse
| Name | Type | Description | Required |
@@ -22153,7 +22870,7 @@ Non-sensitive bootstrap snapshot exposed before Console or Web authentication.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| keyword | string | Search keyword | No |
| type | string, <br>**Available values:** "", "app", "knowledge", "snippet" | Tag type filter<br>*Enum:* `""`, `"app"`, `"knowledge"`, `"snippet"` | No |
| type | [TagType](#tagtype)<br>string | Tag type filter | No |
#### TagListResponse
@@ -24339,6 +25056,15 @@ Workflow tool configuration
| ---- | ---- | ----------- | -------- |
| permission_keys | [ string ] | | No |
#### WorkspaceSkillsQuery
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| keyword | string | Search keyword matching skill name or description. | No |
| limit | integer, <br>**Default:** 20 | Number of items per page. | No |
| page | integer, <br>**Default:** 1 | Page number. | No |
| tag | [ string ] | Skill tag filters. Repeat the parameter for multiple tags. | No |
#### WorkspaceTenantResultResponse
| Name | Type | Description | Required |
@@ -1,3 +1,5 @@
"""Unit tests for Aliyun trace utility transformations and database lookups."""
import json
from collections.abc import Mapping
from typing import Any, cast
@@ -25,11 +27,13 @@ from dify_trace_aliyun.utils import (
serialize_json_data,
)
from opentelemetry.trace import Link, StatusCode
from sqlalchemy.orm import Session
from core.rag.models.document import Document
from graphon.entities import WorkflowNodeExecution
from graphon.enums import WorkflowNodeExecutionStatus
from models import EndUser
from models.enums import EndUserType
def test_get_user_id_from_message_data_no_end_user(monkeypatch: pytest.MonkeyPatch):
@@ -40,35 +44,40 @@ def test_get_user_id_from_message_data_no_end_user(monkeypatch: pytest.MonkeyPat
assert get_user_id_from_message_data(message_data) == "account_id"
def test_get_user_id_from_message_data_with_end_user(monkeypatch: pytest.MonkeyPatch):
@pytest.mark.parametrize("sqlite3_session", [(EndUser,)], indirect=True)
def test_get_user_id_from_message_data_with_end_user(monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session) -> None:
message_data = MagicMock()
message_data.from_account_id = "account_id"
message_data.from_end_user_id = "end_user_id"
end_user_data = MagicMock(spec=EndUser)
end_user_data.session_id = "session_id"
mock_session = MagicMock()
mock_session.get.return_value = end_user_data
end_user_data = EndUser(
id="end_user_id",
tenant_id="tenant_id",
app_id="app_id",
type=EndUserType.BROWSER,
session_id="session_id",
)
sqlite3_session.add(end_user_data)
sqlite3_session.commit()
from dify_trace_aliyun.utils import db
monkeypatch.setattr(db, "session", mock_session)
monkeypatch.setattr(db, "session", sqlite3_session)
assert get_user_id_from_message_data(message_data) == "session_id"
def test_get_user_id_from_message_data_end_user_not_found(monkeypatch: pytest.MonkeyPatch):
@pytest.mark.parametrize("sqlite3_session", [(EndUser,)], indirect=True)
def test_get_user_id_from_message_data_end_user_not_found(
monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session
) -> None:
message_data = MagicMock()
message_data.from_account_id = "account_id"
message_data.from_end_user_id = "end_user_id"
mock_session = MagicMock()
mock_session.get.return_value = None
from dify_trace_aliyun.utils import db
monkeypatch.setattr(db, "session", mock_session)
monkeypatch.setattr(db, "session", sqlite3_session)
assert get_user_id_from_message_data(message_data) == "account_id"
@@ -1,5 +1,8 @@
"""Unit tests for LangSmith trace translation with SQLite-backed lookups."""
import collections
from datetime import datetime, timedelta
from types import SimpleNamespace
from typing import override
from unittest.mock import MagicMock
@@ -11,6 +14,7 @@ from dify_trace_langsmith.entities.langsmith_trace_entity import (
LangSmithRunUpdateModel,
)
from dify_trace_langsmith.langsmith_trace import LangSmithDataTrace
from sqlalchemy.orm import Session
from core.ops.entities.trace_entity import (
DatasetRetrievalTraceInfo,
@@ -24,6 +28,7 @@ from core.ops.entities.trace_entity import (
)
from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey
from models import EndUser
from models.enums import EndUserType
def _dt() -> datetime:
@@ -108,7 +113,8 @@ def test_trace_dispatch(trace_instance, monkeypatch: pytest.MonkeyPatch):
mocks["generate_name_trace"].assert_called_once_with(info)
def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch):
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session) -> None:
# Setup trace info
workflow_data = MagicMock()
workflow_data.created_at = _dt()
@@ -137,10 +143,10 @@ def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch):
workflow_data=workflow_data,
)
# Mock dependencies
mock_session = MagicMock()
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: mock_session)
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine"))
monkeypatch.setattr(
"dify_trace_langsmith.langsmith_trace.db",
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
)
# Mock node executions
node_llm = MagicMock()
@@ -228,7 +234,10 @@ def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch):
assert call_args[4].run_type == LangSmithRunType.retriever
def test_workflow_trace_no_start_time(trace_instance, monkeypatch: pytest.MonkeyPatch):
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
def test_workflow_trace_no_start_time(
trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session
) -> None:
workflow_data = MagicMock()
workflow_data.created_at = _dt()
workflow_data.finished_at = _dt() + timedelta(seconds=1)
@@ -256,9 +265,10 @@ def test_workflow_trace_no_start_time(trace_instance, monkeypatch: pytest.Monkey
workflow_data=workflow_data,
)
mock_session = MagicMock()
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: mock_session)
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine"))
monkeypatch.setattr(
"dify_trace_langsmith.langsmith_trace.db",
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
)
repo = MagicMock()
repo.get_by_workflow_execution.return_value = []
mock_factory = MagicMock()
@@ -271,7 +281,10 @@ def test_workflow_trace_no_start_time(trace_instance, monkeypatch: pytest.Monkey
assert trace_instance.add_run.called
def test_workflow_trace_missing_app_id(trace_instance, monkeypatch: pytest.MonkeyPatch):
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
def test_workflow_trace_missing_app_id(
trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session
) -> None:
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info.trace_id = "trace-1"
trace_info.message_id = None
@@ -287,15 +300,17 @@ def test_workflow_trace_missing_app_id(trace_instance, monkeypatch: pytest.Monke
trace_info.workflow_run_outputs = {}
trace_info.error = ""
mock_session = MagicMock()
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: mock_session)
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine"))
monkeypatch.setattr(
"dify_trace_langsmith.langsmith_trace.db",
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
)
with pytest.raises(ValueError, match="No app_id found in trace_info metadata"):
trace_instance.workflow_trace(trace_info)
def test_message_trace(trace_instance, monkeypatch: pytest.MonkeyPatch):
@pytest.mark.parametrize("sqlite3_session", [(EndUser,)], indirect=True)
def test_message_trace(trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session) -> None:
message_data = MagicMock()
message_data.id = "msg-1"
message_data.from_account_id = "acc-1"
@@ -321,10 +336,19 @@ def test_message_trace(trace_instance, monkeypatch: pytest.MonkeyPatch):
message_file_data=MagicMock(url="file-url"),
)
# Mock EndUser lookup
mock_end_user = MagicMock(spec=EndUser)
mock_end_user.session_id = "session-id-123"
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db.session.get", lambda model, pk: mock_end_user)
end_user = EndUser(
id="end-user-1",
tenant_id="tenant-1",
app_id="app-1",
type=EndUserType.BROWSER,
session_id="session-id-123",
)
sqlite3_session.add(end_user)
sqlite3_session.commit()
monkeypatch.setattr(
"dify_trace_langsmith.langsmith_trace.db",
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
)
trace_instance.add_run = MagicMock()
@@ -521,9 +545,13 @@ def test_update_run_error(trace_instance):
trace_instance.update_run(update_data)
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
def test_workflow_trace_usage_extraction_error(
trace_instance, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
):
trace_instance,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
sqlite3_session: Session,
) -> None:
workflow_data = MagicMock()
workflow_data.created_at = _dt()
workflow_data.finished_at = _dt() + timedelta(seconds=1)
@@ -576,8 +604,10 @@ def test_workflow_trace_usage_extraction_error(
mock_factory = MagicMock()
mock_factory.create_workflow_node_execution_repository.return_value = repo
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.DifyCoreRepositoryFactory", mock_factory)
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: MagicMock())
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine"))
monkeypatch.setattr(
"dify_trace_langsmith.langsmith_trace.db",
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
)
monkeypatch.setattr(trace_instance, "get_service_account_with_tenant", lambda app_id: MagicMock())
trace_instance.add_run = MagicMock()
@@ -644,9 +674,11 @@ def _make_workflow_trace_info(
)
def _patch_workflow_trace_deps(monkeypatch, trace_instance):
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: MagicMock())
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine"))
def _patch_workflow_trace_deps(monkeypatch, trace_instance, sqlite3_session: Session) -> None:
monkeypatch.setattr(
"dify_trace_langsmith.langsmith_trace.db",
SimpleNamespace(engine=sqlite3_session.get_bind(), session=sqlite3_session),
)
repo = MagicMock()
repo.get_by_workflow_execution.return_value = []
factory = MagicMock()
@@ -656,14 +688,17 @@ def _patch_workflow_trace_deps(monkeypatch, trace_instance):
trace_instance.add_run = MagicMock()
def test_workflow_trace_id_uses_message_id_not_external(trace_instance, monkeypatch: pytest.MonkeyPatch):
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
def test_workflow_trace_id_uses_message_id_not_external(
trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session
) -> None:
"""Chatflow with external trace_id: LangSmith trace_id must be message_id, not external."""
trace_info = _make_workflow_trace_info(
message_id="msg-abc",
workflow_run_id="run-xyz",
trace_id="external-999",
)
_patch_workflow_trace_deps(monkeypatch, trace_instance)
_patch_workflow_trace_deps(monkeypatch, trace_instance, sqlite3_session)
trace_instance.workflow_trace(trace_info)
@@ -677,14 +712,17 @@ def test_workflow_trace_id_uses_message_id_not_external(trace_instance, monkeypa
assert trace_info.metadata.get("external_trace_id") == "external-999"
def test_workflow_trace_id_pure_workflow_uses_run_id(trace_instance, monkeypatch: pytest.MonkeyPatch):
@pytest.mark.parametrize("sqlite3_session", [()], indirect=True)
def test_workflow_trace_id_pure_workflow_uses_run_id(
trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session
) -> None:
"""Pure workflow (no message_id) with external trace_id: trace_id must be workflow_run_id."""
trace_info = _make_workflow_trace_info(
message_id=None,
workflow_run_id="run-xyz",
trace_id="external-999",
)
_patch_workflow_trace_deps(monkeypatch, trace_instance)
_patch_workflow_trace_deps(monkeypatch, trace_instance, sqlite3_session)
trace_instance.workflow_trace(trace_info)
+3 -1
View File
@@ -1599,7 +1599,9 @@ class TenantService:
return updated_accounts
@staticmethod
def iter_member_account_id_batches(tenant_id: str, batch_size: int, *, session: Session) -> Iterator[list[str]]:
def iter_member_account_id_batches(
tenant_id: str, batch_size: int, *, session: Session
) -> Iterator[list[str]]:
"""Yield workspace member account ids in bounded, ordered batches."""
offset = 0
while True:
+46 -20
View File
@@ -19,12 +19,11 @@ from __future__ import annotations
import hashlib
import io
import posixpath
import re
import zipfile
import zlib
import yaml
from pydantic import BaseModel
from pydantic import BaseModel, Field, ValidationError, field_validator
# Bounds — generous but finite so a hostile upload can't exhaust memory/disk.
_MAX_ARCHIVE_BYTES = 50 * 1024 * 1024
@@ -33,7 +32,8 @@ _MAX_SKILL_MD_BYTES = 1 * 1024 * 1024
_MAX_ENTRIES = 5000
_ALLOWED_EXTENSIONS = (".zip", ".skill")
_SKILL_MD_NAME = "SKILL.md"
_HEADING_RE = re.compile(r"^\s*#\s+(.+?)\s*$", re.MULTILINE)
_SKILL_NAME_PATTERN = r"^[a-z0-9]+(?:-[a-z0-9]+)*$"
_MAX_SKILL_DESCRIPTION_LENGTH = 1024
class SkillPackageError(Exception):
@@ -53,13 +53,18 @@ class SkillPackageError(Exception):
class SkillManifest(BaseModel):
"""Validated metadata extracted from a Skill package."""
name: str
description: str
name: str = Field(min_length=1, max_length=64, pattern=_SKILL_NAME_PATTERN)
description: str = Field(min_length=1, max_length=_MAX_SKILL_DESCRIPTION_LENGTH)
entry_path: str # path of SKILL.md inside the archive
files: list[str] # all (safe) file paths inside the archive
size: int # total uncompressed bytes
hash: str # sha256 of the archive bytes
@field_validator("name", "description", mode="before")
@classmethod
def _strip_required_string(cls, value: object) -> object:
return value.strip() if isinstance(value, str) else value
class NormalizedSkillPackage(BaseModel):
"""Canonical skill package bytes and metadata ready to store in agent drive."""
@@ -108,14 +113,17 @@ class SkillPackageService:
normalized_size = sum(max(info.file_size, 0) for info in normalized_members.values())
name, description = self._parse_skill_md(skill_md)
manifest = SkillManifest(
name=name,
description=description,
entry_path=_SKILL_MD_NAME,
files=sorted(normalized_members),
size=normalized_size,
hash=hashlib.sha256(normalized_archive_bytes).hexdigest(),
)
try:
manifest = SkillManifest(
name=name,
description=description,
entry_path=_SKILL_MD_NAME,
files=sorted(normalized_members),
size=normalized_size,
hash=hashlib.sha256(normalized_archive_bytes).hexdigest(),
)
except ValidationError as exc:
raise self._manifest_validation_error(exc) from exc
return NormalizedSkillPackage(
manifest=manifest,
archive_bytes=normalized_archive_bytes,
@@ -123,6 +131,31 @@ class SkillPackageService:
strip_prefix=strip_prefix,
)
@staticmethod
def _manifest_validation_error(exc: ValidationError) -> SkillPackageError:
first_error = exc.errors()[0]
loc = first_error["loc"]
field = loc[0] if loc else "manifest"
error_type = first_error["type"]
if field == "name":
code = "missing_skill_name" if error_type == "string_too_short" else "invalid_skill_name"
message = (
"SKILL.md frontmatter name is required"
if code == "missing_skill_name"
else "SKILL.md frontmatter name must be lowercase letters, numbers, and hyphens only, "
"must not start or end with a hyphen, and must be at most 64 characters"
)
return SkillPackageError(code, message, status_code=400)
if field == "description":
code = "missing_skill_description" if error_type == "string_too_short" else "invalid_skill_description"
message = (
"SKILL.md frontmatter description is required"
if code == "missing_skill_description"
else f"SKILL.md frontmatter description must be at most {_MAX_SKILL_DESCRIPTION_LENGTH} characters"
)
return SkillPackageError(code, message, status_code=400)
return SkillPackageError("invalid_skill_manifest", "SKILL.md frontmatter is invalid", status_code=400)
def _open_archive(self, *, content: bytes, filename: str) -> zipfile.ZipFile:
self._check_extension(filename)
if not content:
@@ -280,13 +313,6 @@ class SkillPackageService:
frontmatter = cls._parse_frontmatter(content)
name = str(frontmatter.get("name") or "").strip()
description = str(frontmatter.get("description") or "").strip()
if not name:
heading = _HEADING_RE.search(content)
name = heading.group(1).strip() if heading else ""
if not name:
raise SkillPackageError(
"missing_skill_name", "SKILL.md must declare a name (frontmatter or top heading)", status_code=400
)
return name, description
@staticmethod
+81 -12
View File
@@ -46,6 +46,7 @@ from models.tools import ToolFile
from services.agent.config_skill_normalize_service import ConfigSkillNormalizeService
from services.agent.skill_package_service import SkillPackageError
from services.agent_drive_service import DriveFileRef
from services.skill_management_service import SkillManagementService, SkillManagementServiceError
class AgentConfigVersionKind(StrEnum):
@@ -98,6 +99,7 @@ class ConfigPushPayload(BaseModel):
@dataclass(slots=True)
class AgentConfigTarget:
tenant_id: str
agent_id: str
version_id: str
kind: AgentConfigVersionKind
@@ -146,6 +148,7 @@ class AgentConfigService:
user_id=user_id,
)
return AgentConfigTarget(
tenant_id=tenant_id,
agent_id=target.agent_id,
version_id=target.version_id,
kind=target.kind,
@@ -191,7 +194,7 @@ class AgentConfigService:
return {
"agent_id": target.agent_id,
"config_version": self._config_version_payload(target),
"items": [self._serialize_skill_item(skill) for skill in target.agent_soul.config_skills],
"items": self._skill_items_for_target(target),
}
def list_files(
@@ -233,10 +236,27 @@ class AgentConfigService:
config_version_kind=config_version_kind,
user_id=user_id,
)
skill = self._require_skill(target.agent_soul, name=name)
file_id = self._available_skill_file_id(skill)
payload, mime_type = self._load_tool_file_bytes(tenant_id=tenant_id, file_id=file_id)
return ConfigDownload(filename=f"{skill.name}.zip", mime_type=mime_type or "application/zip", payload=payload)
try:
skill = self._require_skill(target.agent_soul, name=name)
file_id = self._available_skill_file_id(skill)
payload, mime_type = self._load_tool_file_bytes(tenant_id=tenant_id, file_id=file_id)
return ConfigDownload(
filename=f"{skill.name}.zip",
mime_type=mime_type or "application/zip",
payload=payload,
)
except AgentConfigServiceError as exc:
if exc.code != "config_skill_not_found":
raise
try:
result = SkillManagementService().pull_runtime_agent_skill(
tenant_id=tenant_id,
agent_id=agent_id,
name=name,
)
return ConfigDownload(filename=result.filename, mime_type=result.mime_type, payload=result.payload)
except SkillManagementServiceError as exc:
raise AgentConfigServiceError("config_skill_not_found", "config skill not found", status_code=404) from exc
def download_skill_url(
self,
@@ -279,9 +299,45 @@ class AgentConfigService:
config_version_kind=config_version_kind,
user_id=user_id,
)
skill = self._require_skill(target.agent_soul, name=name)
file_id = self._available_skill_file_id(skill)
archive_bytes, _mime_type = self._load_tool_file_bytes(tenant_id=tenant_id, file_id=file_id)
try:
skill = self._require_skill(target.agent_soul, name=name)
file_id = self._available_skill_file_id(skill)
archive_bytes, _mime_type = self._load_tool_file_bytes(tenant_id=tenant_id, file_id=file_id)
skill_item = self._serialize_skill_item(skill)
except AgentConfigServiceError as exc:
if exc.code != "config_skill_not_found":
raise
try:
workspace_archive = SkillManagementService().pull_runtime_agent_skill(
tenant_id=tenant_id,
agent_id=agent_id,
name=name,
)
except SkillManagementServiceError as skill_exc:
raise AgentConfigServiceError(
"config_skill_not_found",
"config skill not found",
status_code=404,
) from skill_exc
archive_bytes = workspace_archive.payload
skill_item = next(
(
item
for item in SkillManagementService().list_runtime_agent_skills(
tenant_id=tenant_id,
agent_id=agent_id,
)
if item["name"] == name
),
{
"id": name,
"name": name,
"description": "",
"size": None,
"hash": None,
"mime_type": "application/zip",
},
)
try:
archive_items, skill_md = self._inspect_skill_archive(archive_bytes)
except (OSError, ValueError, zipfile.BadZipFile) as exc:
@@ -291,7 +347,7 @@ class AgentConfigService:
status_code=500,
) from exc
return {
**self._serialize_skill_item(skill),
**skill_item,
"source": "config_skill_zip",
"files": archive_items,
"skill_md": skill_md,
@@ -839,6 +895,7 @@ class AgentConfigService:
status_code=404,
)
return AgentConfigTarget(
tenant_id=tenant_id,
agent_id=agent_id,
version_id=version.id,
kind=config_version_kind,
@@ -1133,9 +1190,7 @@ class AgentConfigService:
return {
"agent_id": target.agent_id,
"config_version": AgentConfigService._config_version_payload(target),
"skills": {
"items": [AgentConfigService._serialize_skill_item(skill) for skill in target.agent_soul.config_skills]
},
"skills": {"items": AgentConfigService._skill_items_for_target(target)},
"files": {
"items": [
AgentConfigService._serialize_file_item(file_ref) for file_ref in target.agent_soul.config_files
@@ -1145,6 +1200,20 @@ class AgentConfigService:
"note": target.agent_soul.config_note,
}
@staticmethod
def _skill_items_for_target(target: AgentConfigTarget) -> list[dict[str, object]]:
items = [AgentConfigService._serialize_skill_item(skill) for skill in target.agent_soul.config_skills]
seen_names = {str(item["name"]) for item in items}
for item in SkillManagementService().list_runtime_agent_skills(
tenant_id=target.tenant_id,
agent_id=target.agent_id,
):
if item["name"] in seen_names:
continue
seen_names.add(str(item["name"]))
items.append(item)
return items
@staticmethod
def _config_version_payload(target: AgentConfigTarget) -> dict[str, object]:
return {
+1 -12
View File
@@ -488,7 +488,7 @@ class AppGenerateService:
)
@classmethod
def get_workflow(
def _get_workflow(
cls,
app_model: App,
invoke_from: InvokeFrom,
@@ -533,17 +533,6 @@ class AppGenerateService:
return workflow
@classmethod
def _get_workflow(
cls,
app_model: App,
invoke_from: InvokeFrom,
workflow_id: str | None = None,
*,
session: Session,
) -> Workflow:
return cls.get_workflow(app_model, invoke_from, workflow_id, session=session)
@classmethod
def get_response_generator(
cls,
File diff suppressed because it is too large Load Diff
+9
View File
@@ -12,6 +12,7 @@ from werkzeug.exceptions import NotFound
from models.dataset import Dataset
from models.enums import TagType
from models.model import App, Tag, TagBinding
from models.skill import Skill
from models.snippet import CustomizedSnippet
type _TagTypeLike = TagType | str
@@ -282,5 +283,13 @@ class TagService:
)
if not snippet:
raise NotFound("Snippet not found")
elif type == "skill":
skill = session.scalar(
select(Skill)
.where(Skill.tenant_id == current_user.current_tenant_id, Skill.id == target_id)
.limit(1)
)
if not skill:
raise NotFound("Skill not found")
else:
raise NotFound("Invalid binding type")
+1 -2
View File
@@ -18,7 +18,6 @@ from core.app.apps.completion.app_config_manager import CompletionAppConfigManag
from core.helper import encrypter
from core.prompt.simple_prompt_transform import SimplePromptTransform
from core.prompt.utils.prompt_template_parser import PromptTemplateParser
from core.workflow.variable_prefixes import USER_INPUT_VARIABLE_NODE_ID
from events.app_event import app_was_created
from graphon.file import FileUploadConfig
from graphon.model_runtime.entities.llm_entities import LLMMode
@@ -598,7 +597,7 @@ class WorkflowConverter:
},
"vision": {
"enabled": file_upload is not None,
"variable_selector": [USER_INPUT_VARIABLE_NODE_ID, "files"] if file_upload is not None else None,
"variable_selector": ["sys", "files"] if file_upload is not None else None,
"configs": {"detail": file_upload.image_config.detail}
if file_upload is not None and file_upload.image_config is not None
else None,
+2 -55
View File
@@ -6,10 +6,8 @@ from collections.abc import Callable, Generator, Mapping, Sequence
from dataclasses import dataclass
from typing import Any, cast
from sqlalchemy import exists, inspect, select, update
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy import exists, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.orm.attributes import set_committed_value
from configs import dify_config
from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager
@@ -145,8 +143,6 @@ from .human_input_delivery_test_service import (
from .workflow_draft_variable_service import DraftVariableSaver, DraftVarLoader, WorkflowDraftVariableService
from .workflow_restore import apply_published_workflow_snapshot_to_draft
logger = logging.getLogger(__name__)
_file_access_controller = DatabaseFileAccessController()
@@ -159,7 +155,6 @@ class WorkflowService:
"""Initialize WorkflowService with repository dependencies."""
if session_maker is None:
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
self._session_maker = session_maker
self._node_execution_service_repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository(
session_maker
)
@@ -216,7 +211,7 @@ class WorkflowService:
)
# return draft workflow
return self._persist_legacy_sys_files_migration_on_load(workflow)
return workflow
def get_published_workflow_by_id(self, app_model: App, workflow_id: str, *, session: Session) -> Workflow | None:
"""
@@ -241,7 +236,6 @@ class WorkflowService:
f"Cannot use draft workflow version. Workflow ID: {workflow_id}. "
f"Please use a published workflow version or leave workflow_id empty."
)
self._persist_legacy_sys_files_migration_on_load(workflow)
return workflow
def get_published_workflow(self, app_model: App, *, session: Session) -> Workflow | None:
@@ -265,53 +259,6 @@ class WorkflowService:
.limit(1)
)
return self._persist_legacy_sys_files_migration_on_load(workflow)
def _persist_legacy_sys_files_migration_on_load(self, workflow: Workflow | None) -> Workflow | None:
"""Persist a load-time graph rewrite without joining or dirtying the caller's transaction."""
if workflow is None:
return None
if inspect(workflow, raiseerr=False) is None:
return workflow
# TODO: Remove this load-time persistence path after the historical workflow migration is complete.
original_graph = workflow.graph
if not workflow.migrate_legacy_sys_files_graph_in_place():
return workflow
migrated_graph = workflow.graph
try:
with self._session_maker.begin() as session:
result = session.execute(
update(Workflow)
.where(
Workflow.id == workflow.id,
Workflow.tenant_id == workflow.tenant_id,
Workflow.graph == original_graph,
)
.values(graph=migrated_graph)
)
if getattr(result, "rowcount", None) == 0:
logger.warning(
"Skipped persisting legacy sys.files workflow migration because the workflow changed "
"concurrently, "
"workflow_id=%s tenant_id=%s",
workflow.id,
workflow.tenant_id,
)
except SQLAlchemyError:
logger.warning(
"Failed to persist legacy sys.files workflow migration, workflow_id=%s tenant_id=%s",
workflow.id,
workflow.tenant_id,
exc_info=True,
)
finally:
# The conditional update owns persistence. Mark the caller's instance clean so its later flush cannot
# overwrite a concurrent workflow edit with the compatibility rewrite.
set_committed_value(workflow, "graph", migrated_graph)
return workflow
def get_accessible_app_ids(self, app_ids: Sequence[str], tenant_id: str, *, session: Session) -> set[str]:
+1 -1
View File
@@ -61,7 +61,7 @@ workflow:
query_prompt_template: '{{#sys.query#}}
{{#userinput.files#}}'
{{#sys.files#}}'
window:
enabled: false
size: 10
@@ -162,7 +162,7 @@ workflow:
query_prompt_template: '{{#sys.query#}}
{{#userinput.files#}}'
{{#sys.files#}}'
role_prefix:
assistant: ''
user: ''
@@ -207,7 +207,7 @@ workflow:
query_prompt_template: '{{#sys.query#}}
{{#userinput.files#}}'
{{#sys.files#}}'
role_prefix:
assistant: ''
user: ''
+1 -1
View File
@@ -178,7 +178,7 @@ workflow:
query_prompt_template: '{{#sys.query#}}
{{#userinput.files#}}'
{{#sys.files#}}'
role_prefix:
assistant: ''
user: ''
@@ -3,7 +3,6 @@ from __future__ import annotations
import json
import uuid
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from unittest.mock import patch
import pytest
@@ -12,13 +11,13 @@ from sqlalchemy.orm import Session
from graphon.enums import WorkflowExecutionStatus
from models import EndUser, Workflow, WorkflowAppLog, WorkflowArchiveLog, WorkflowRun
from models.enums import AppTriggerType, CreatorUserRole, EndUserType, WorkflowRunTriggeredFrom
from models.enums import CreatorUserRole, EndUserType, WorkflowRunTriggeredFrom
from models.workflow import WorkflowAppLogCreatedFrom
from services.account_service import AccountService, TenantService
# Delay import of AppService to avoid circular dependency
# from services.app_service import AppService, CreateAppParams
from services.workflow_app_service import LogView, WorkflowAppService
from services.workflow_app_service import WorkflowAppService
from tests.test_containers_integration_tests.helpers import generate_valid_password
@@ -1627,73 +1626,3 @@ class TestWorkflowAppService:
end_user_item = next(d for d in result["data"] if d["created_by_end_user"] is not None)
assert account_item["created_by_account"].id == account.id
assert end_user_item["created_by_end_user"].id == end_user.id
class TestLogView:
def test_details_and_proxy_attributes(self):
log = SimpleNamespace(id="log-1", status="succeeded")
view = LogView(log=log, details={"trigger_metadata": {"type": "plugin"}})
assert view.details == {"trigger_metadata": {"type": "plugin"}}
assert view.status == "succeeded"
class TestHandleTriggerMetadata:
def test_returns_empty_dict_when_metadata_missing(self):
service = WorkflowAppService()
assert service.handle_trigger_metadata("tenant-1", None) == {}
def test_enriches_plugin_icons(self):
service = WorkflowAppService()
meta = {
"type": AppTriggerType.TRIGGER_PLUGIN.value,
"icon_filename": "light.png",
"icon_dark_filename": "dark.png",
}
with patch(
"services.workflow_app_service.PluginService.get_plugin_icon_url",
side_effect=["https://cdn/light.png", "https://cdn/dark.png"],
) as mock_icon:
result = service.handle_trigger_metadata("tenant-1", json.dumps(meta))
assert result["icon"] == "https://cdn/light.png"
assert result["icon_dark"] == "https://cdn/dark.png"
assert mock_icon.call_count == 2
def test_non_plugin_metadata_without_icon_lookup(self):
service = WorkflowAppService()
meta = {"type": AppTriggerType.TRIGGER_WEBHOOK.value}
with patch("services.workflow_app_service.PluginService.get_plugin_icon_url") as mock_icon:
result = service.handle_trigger_metadata("tenant-1", json.dumps(meta))
assert result["type"] == AppTriggerType.TRIGGER_WEBHOOK.value
mock_icon.assert_not_called()
class TestSafeJsonLoads:
@pytest.mark.parametrize(
("value", "expected"),
[
(None, None),
("", None),
('{"k":"v"}', {"k": "v"}),
("not-json", None),
({"raw": True}, {"raw": True}),
],
)
def test_handles_various_inputs(self, value, expected):
assert WorkflowAppService._safe_json_loads(value) == expected
class TestSafeParseUuid:
def test_returns_none_for_short_or_invalid_values(self):
service = WorkflowAppService()
assert service._safe_parse_uuid("short") is None
assert service._safe_parse_uuid("x" * 40) is None
def test_returns_uuid_for_valid_string(self):
service = WorkflowAppService()
raw = str(uuid.uuid4())
result = service._safe_parse_uuid(raw)
assert result is not None
assert str(result) == raw
@@ -4,7 +4,7 @@ from unittest.mock import MagicMock
import pytest
from sqlalchemy.orm import Session
from commands import app_maintenance as app_maintenance_commands
from commands import system as system_commands
def test_fix_app_site_missing_passes_loaded_session_to_signal(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -30,15 +30,15 @@ def test_fix_app_site_missing_passes_loaded_session_to_signal(monkeypatch: pytes
engine = MagicMock()
engine.begin.return_value.__enter__.return_value = connection
monkeypatch.setattr(app_maintenance_commands, "db", SimpleNamespace(engine=engine, session=scoped_session))
monkeypatch.setattr(system_commands, "db", SimpleNamespace(engine=engine, session=scoped_session))
send = MagicMock(side_effect=lambda *_args, **_kwargs: phase_events.append("signal"))
monkeypatch.setattr(app_maintenance_commands.app_was_created, "send", send)
monkeypatch.setattr(system_commands.app_was_created, "send", send)
app_maintenance_commands.fix_app_site_missing.callback()
system_commands.fix_app_site_missing.callback()
scoped_session.assert_called_once_with()
scalar.assert_called_once()
get.assert_called_once_with(app_maintenance_commands.Tenant, app.tenant_id)
get.assert_called_once_with(system_commands.Tenant, app.tenant_id)
tenant.get_accounts.assert_called_once_with(session=session)
send.assert_called_once_with(app, account=account, session=session)
commit.assert_called_once_with()
@@ -62,19 +62,15 @@ def test_fix_app_site_missing_rolls_back_when_signal_fails(monkeypatch: pytest.M
engine = MagicMock()
engine.begin.return_value.__enter__.return_value = connection
monkeypatch.setattr(
app_maintenance_commands,
"db",
SimpleNamespace(engine=engine, session=MagicMock(return_value=session)),
)
monkeypatch.setattr(system_commands, "db", SimpleNamespace(engine=engine, session=MagicMock(return_value=session)))
def fail_signal(*_args, **_kwargs) -> None:
phase_events.append("signal")
raise RuntimeError("failed")
monkeypatch.setattr(app_maintenance_commands.app_was_created, "send", MagicMock(side_effect=fail_signal))
monkeypatch.setattr(system_commands.app_was_created, "send", MagicMock(side_effect=fail_signal))
app_maintenance_commands.fix_app_site_missing.callback()
system_commands.fix_app_site_missing.callback()
session.rollback.assert_called_once_with()
session.commit.assert_not_called()
@@ -1,236 +0,0 @@
from types import SimpleNamespace
from unittest.mock import MagicMock
import click
import pytest
from commands import migrate_legacy_sys_files_workflows
from commands import workflow_migration as workflow_migration_commands
def test_migrate_legacy_sys_files_workflows_command_passes_batch_options(mocker, capsys):
runner = mocker.patch.object(
workflow_migration_commands,
"run_legacy_sys_files_workflow_migration",
return_value=workflow_migration_commands.LegacySysFilesWorkflowMigrationStats(
scanned=10,
migrated=2,
failed=0,
batches=1,
last_id="workflow-10",
),
)
migrate_legacy_sys_files_workflows.callback(
batch_size=200,
limit=500,
start_after_id="workflow-1",
tenant_id="tenant-1",
app_id="app-1",
dry_run=True,
)
runner.assert_called_once_with(
batch_size=200,
limit=500,
start_after_id="workflow-1",
tenant_id="tenant-1",
app_id="app-1",
dry_run=True,
)
captured = capsys.readouterr()
assert "scanned=10" in captured.out
assert "migrated=2" in captured.out
assert "last_id=workflow-10" in captured.out
def test_migrate_legacy_sys_files_workflows_rejects_non_positive_batch_size():
with pytest.raises(click.UsageError, match="batch-size"):
migrate_legacy_sys_files_workflows.callback(
batch_size=0,
limit=None,
start_after_id=None,
tenant_id=None,
app_id=None,
dry_run=False,
)
def test_migrate_legacy_sys_files_workflows_rejects_non_positive_limit():
with pytest.raises(click.UsageError, match="limit"):
migrate_legacy_sys_files_workflows.callback(
batch_size=100,
limit=0,
start_after_id=None,
tenant_id=None,
app_id=None,
dry_run=False,
)
def test_build_legacy_sys_files_workflow_query_uses_keyset_pagination():
stmt = workflow_migration_commands._build_legacy_sys_files_workflow_query(
start_after_id="workflow-1",
batch_size=200,
tenant_id="tenant-1",
app_id="app-1",
)
compiled = str(stmt.compile(compile_kwargs={"literal_binds": True}))
assert "workflows.id > 'workflow-1'" in compiled
assert "workflows.tenant_id = 'tenant-1'" in compiled
assert "workflows.app_id = 'app-1'" in compiled
assert "ORDER BY workflows.id" in compiled
assert "LIMIT 200" in compiled
assert "workflows.environment_variables" not in compiled
def test_migrate_legacy_sys_files_workflow_batch_dry_run_rolls_back():
migrated_workflow = MagicMock()
migrated_workflow.id = "workflow-1"
migrated_workflow.migrate_legacy_sys_files_graph_in_place.return_value = True
untouched_workflow = MagicMock()
untouched_workflow.id = "workflow-2"
untouched_workflow.migrate_legacy_sys_files_graph_in_place.return_value = False
session = MagicMock()
session.scalars.return_value.all.return_value = [migrated_workflow, untouched_workflow]
stats = workflow_migration_commands._migrate_legacy_sys_files_workflow_batch(
session=session,
start_after_id=None,
batch_size=200,
tenant_id=None,
app_id=None,
dry_run=True,
)
assert stats.scanned == 2
assert stats.migrated == 1
assert stats.failed == 0
assert stats.last_id == "workflow-2"
session.rollback.assert_called_once()
session.commit.assert_not_called()
def test_migrate_legacy_sys_files_workflow_batch_commits_and_counts_failures(caplog):
migrated_workflow = MagicMock()
migrated_workflow.id = "workflow-1"
migrated_workflow.migrate_legacy_sys_files_graph_in_place.return_value = True
failing_workflow = MagicMock()
failing_workflow.id = "workflow-2"
failing_workflow.migrate_legacy_sys_files_graph_in_place.side_effect = RuntimeError("boom")
session = MagicMock()
session.scalars.return_value.all.return_value = [migrated_workflow, failing_workflow]
stats = workflow_migration_commands._migrate_legacy_sys_files_workflow_batch(
session=session,
start_after_id=None,
batch_size=200,
tenant_id=None,
app_id=None,
dry_run=False,
)
assert stats.scanned == 2
assert stats.migrated == 1
assert stats.failed == 1
assert stats.last_id == "workflow-2"
assert "Failed to migrate legacy" in caplog.text
session.commit.assert_called_once()
session.rollback.assert_not_called()
def test_run_legacy_sys_files_workflow_migration_uses_keyset_batches(mocker):
session_maker = MagicMock()
sessions = [MagicMock(), MagicMock()]
session_maker.side_effect = sessions
mocker.patch.object(workflow_migration_commands, "sessionmaker", return_value=session_maker)
mocker.patch.object(workflow_migration_commands, "db", SimpleNamespace(engine=object()))
migrate_batch = mocker.patch.object(
workflow_migration_commands,
"_migrate_legacy_sys_files_workflow_batch",
side_effect=[
workflow_migration_commands.LegacySysFilesWorkflowMigrationStats(
scanned=2,
migrated=1,
failed=0,
last_id="workflow-2",
),
workflow_migration_commands.LegacySysFilesWorkflowMigrationStats(
scanned=1,
migrated=1,
failed=0,
last_id="workflow-3",
),
],
)
stats = workflow_migration_commands.run_legacy_sys_files_workflow_migration(
batch_size=2,
limit=3,
start_after_id="workflow-0",
tenant_id="tenant-1",
app_id="app-1",
dry_run=True,
)
assert stats.scanned == 3
assert stats.migrated == 2
assert stats.batches == 2
assert stats.last_id == "workflow-3"
assert migrate_batch.call_args_list[0].kwargs["start_after_id"] == "workflow-0"
assert migrate_batch.call_args_list[0].kwargs["batch_size"] == 2
assert migrate_batch.call_args_list[1].kwargs["start_after_id"] == "workflow-2"
assert migrate_batch.call_args_list[1].kwargs["batch_size"] == 1
def test_run_legacy_sys_files_workflow_migration_stops_on_empty_batch(mocker):
session_maker = MagicMock(return_value=MagicMock())
mocker.patch.object(workflow_migration_commands, "sessionmaker", return_value=session_maker)
mocker.patch.object(workflow_migration_commands, "db", SimpleNamespace(engine=object()))
mocker.patch.object(
workflow_migration_commands,
"_migrate_legacy_sys_files_workflow_batch",
return_value=workflow_migration_commands.LegacySysFilesWorkflowMigrationStats(scanned=0),
)
stats = workflow_migration_commands.run_legacy_sys_files_workflow_migration(
batch_size=2,
limit=None,
start_after_id=None,
tenant_id=None,
app_id=None,
dry_run=False,
)
assert stats.scanned == 0
assert stats.batches == 0
def test_run_legacy_sys_files_workflow_migration_stops_on_short_batch(mocker):
session_maker = MagicMock(return_value=MagicMock())
mocker.patch.object(workflow_migration_commands, "sessionmaker", return_value=session_maker)
mocker.patch.object(workflow_migration_commands, "db", SimpleNamespace(engine=object()))
migrate_batch = mocker.patch.object(
workflow_migration_commands,
"_migrate_legacy_sys_files_workflow_batch",
return_value=workflow_migration_commands.LegacySysFilesWorkflowMigrationStats(
scanned=1,
migrated=1,
failed=0,
last_id="workflow-1",
),
)
stats = workflow_migration_commands.run_legacy_sys_files_workflow_migration(
batch_size=2,
limit=None,
start_after_id=None,
tenant_id=None,
app_id=None,
dry_run=False,
)
assert stats.scanned == 1
assert stats.batches == 1
migrate_batch.assert_called_once()
@@ -14,7 +14,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
import commands
from commands import workspace as workspace_commands
from commands import system as system_commands
from core.tools.entities.tool_entities import ApiProviderSchemaType
from graphon.model_runtime.entities.model_entities import ModelType
from models import Tenant
@@ -83,11 +83,11 @@ def _encrypted_rows(tenant_id: str, *, suffix: str = "1") -> tuple[object, ...]:
def _bind_command_to_sqlite(monkeypatch: pytest.MonkeyPatch, session: Session) -> None:
monkeypatch.setattr(workspace_commands, "db", SimpleNamespace(engine=session.get_bind()))
monkeypatch.setattr(system_commands, "db", SimpleNamespace(engine=session.get_bind()))
def test_reset_aborts_when_not_self_hosted(monkeypatch, capsys):
monkeypatch.setattr(workspace_commands.dify_config, "EDITION", "CLOUD")
monkeypatch.setattr(system_commands.dify_config, "EDITION", "CLOUD")
exit_code = _invoke_reset()
captured = capsys.readouterr()
@@ -106,8 +106,8 @@ def test_reset_purges_provider_and_tool_tables_for_each_tenant(
) -> None:
"""The command must purge LLM provider rows AND every tool provider table
that stores ciphertext encrypted under the tenant key (#35396)."""
monkeypatch.setattr(workspace_commands.dify_config, "EDITION", "SELF_HOSTED")
monkeypatch.setattr(workspace_commands, "generate_key_pair", lambda tenant_id: f"new-key-{tenant_id}")
monkeypatch.setattr(system_commands.dify_config, "EDITION", "SELF_HOSTED")
monkeypatch.setattr(system_commands, "generate_key_pair", lambda tenant_id: f"new-key-{tenant_id}")
_bind_command_to_sqlite(monkeypatch, sqlite_session)
tenant = _tenant(TENANT_ID)
@@ -146,8 +146,8 @@ def test_reset_purges_provider_and_tool_tables_for_each_tenant(
)
def test_reset_iterates_all_tenants(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None:
"""Multi-tenant deployments must purge every tenant, not just the first."""
monkeypatch.setattr(workspace_commands.dify_config, "EDITION", "SELF_HOSTED")
monkeypatch.setattr(workspace_commands, "generate_key_pair", lambda tenant_id: f"new-key-{tenant_id}")
monkeypatch.setattr(system_commands.dify_config, "EDITION", "SELF_HOSTED")
monkeypatch.setattr(system_commands, "generate_key_pair", lambda tenant_id: f"new-key-{tenant_id}")
_bind_command_to_sqlite(monkeypatch, sqlite_session)
tenant_ids = [f"11111111-1111-1111-1111-{index:012d}" for index in range(3)]
@@ -4,7 +4,7 @@ import types
from unittest.mock import MagicMock
import commands
from commands import database as database_commands
from commands import system as system_commands
from libs.db_migration_lock import LockNotOwnedError, RedisError
HEARTBEAT_WAIT_TIMEOUT_SECONDS = 5.0
@@ -25,11 +25,11 @@ def _invoke_upgrade_db() -> int:
def test_upgrade_db_skips_when_lock_not_acquired(monkeypatch, capsys):
monkeypatch.setattr(database_commands, "DB_UPGRADE_LOCK_TTL_SECONDS", 1234)
monkeypatch.setattr(system_commands, "DB_UPGRADE_LOCK_TTL_SECONDS", 1234)
lock = MagicMock()
lock.acquire.return_value = False
database_commands.redis_client.lock.return_value = lock
system_commands.redis_client.lock.return_value = lock
exit_code = _invoke_upgrade_db()
captured = capsys.readouterr()
@@ -37,20 +37,18 @@ def test_upgrade_db_skips_when_lock_not_acquired(monkeypatch, capsys):
assert exit_code == 0
assert "Database migration skipped" in captured.out
database_commands.redis_client.lock.assert_called_once_with(
name="db_upgrade_lock", timeout=1234, thread_local=False
)
system_commands.redis_client.lock.assert_called_once_with(name="db_upgrade_lock", timeout=1234, thread_local=False)
lock.acquire.assert_called_once_with(blocking=False)
lock.release.assert_not_called()
def test_upgrade_db_failure_not_masked_by_lock_release(monkeypatch, capsys):
monkeypatch.setattr(database_commands, "DB_UPGRADE_LOCK_TTL_SECONDS", 321)
monkeypatch.setattr(system_commands, "DB_UPGRADE_LOCK_TTL_SECONDS", 321)
lock = MagicMock()
lock.acquire.return_value = True
lock.release.side_effect = LockNotOwnedError("simulated")
database_commands.redis_client.lock.return_value = lock
system_commands.redis_client.lock.return_value = lock
def _upgrade():
raise RuntimeError("boom")
@@ -63,18 +61,18 @@ def test_upgrade_db_failure_not_masked_by_lock_release(monkeypatch, capsys):
assert exit_code == 1
assert "Database migration failed: boom" in captured.out
database_commands.redis_client.lock.assert_called_once_with(name="db_upgrade_lock", timeout=321, thread_local=False)
system_commands.redis_client.lock.assert_called_once_with(name="db_upgrade_lock", timeout=321, thread_local=False)
lock.acquire.assert_called_once_with(blocking=False)
lock.release.assert_called_once()
def test_upgrade_db_success_ignores_lock_not_owned_on_release(monkeypatch, capsys):
monkeypatch.setattr(database_commands, "DB_UPGRADE_LOCK_TTL_SECONDS", 999)
monkeypatch.setattr(system_commands, "DB_UPGRADE_LOCK_TTL_SECONDS", 999)
lock = MagicMock()
lock.acquire.return_value = True
lock.release.side_effect = LockNotOwnedError("simulated")
database_commands.redis_client.lock.return_value = lock
system_commands.redis_client.lock.return_value = lock
_install_fake_flask_migrate(monkeypatch, lambda: None)
@@ -84,7 +82,7 @@ def test_upgrade_db_success_ignores_lock_not_owned_on_release(monkeypatch, capsy
assert exit_code == 0
assert "Database migration successful!" in captured.out
database_commands.redis_client.lock.assert_called_once_with(name="db_upgrade_lock", timeout=999, thread_local=False)
system_commands.redis_client.lock.assert_called_once_with(name="db_upgrade_lock", timeout=999, thread_local=False)
lock.acquire.assert_called_once_with(blocking=False)
lock.release.assert_called_once()
@@ -95,11 +93,11 @@ def test_upgrade_db_renews_lock_during_migration(monkeypatch, capsys):
"""
# Use a small TTL so the heartbeat interval triggers quickly.
monkeypatch.setattr(database_commands, "DB_UPGRADE_LOCK_TTL_SECONDS", 0.3)
monkeypatch.setattr(system_commands, "DB_UPGRADE_LOCK_TTL_SECONDS", 0.3)
lock = MagicMock()
lock.acquire.return_value = True
database_commands.redis_client.lock.return_value = lock
system_commands.redis_client.lock.return_value = lock
renewed = threading.Event()
@@ -123,11 +121,11 @@ def test_upgrade_db_renews_lock_during_migration(monkeypatch, capsys):
def test_upgrade_db_ignores_reacquire_errors(monkeypatch, capsys):
# Use a small TTL so heartbeat runs during the upgrade call.
monkeypatch.setattr(database_commands, "DB_UPGRADE_LOCK_TTL_SECONDS", 0.3)
monkeypatch.setattr(system_commands, "DB_UPGRADE_LOCK_TTL_SECONDS", 0.3)
lock = MagicMock()
lock.acquire.return_value = True
database_commands.redis_client.lock.return_value = lock
system_commands.redis_client.lock.return_value = lock
attempted = threading.Event()
@@ -1,49 +0,0 @@
from unittest.mock import MagicMock
from commands import reset_encrypt_key_pair
from commands import workspace as workspace_commands
def test_reset_encrypt_key_pair_skips_non_self_hosted(monkeypatch, capsys):
monkeypatch.setattr(workspace_commands.dify_config, "EDITION", "CLOUD")
reset_encrypt_key_pair.callback()
captured = capsys.readouterr()
assert "only for SELF_HOSTED" in captured.out
def test_reset_encrypt_key_pair_rotates_keys_and_removes_custom_provider_data(monkeypatch, capsys):
monkeypatch.setattr(workspace_commands.dify_config, "EDITION", "SELF_HOSTED")
monkeypatch.setattr(workspace_commands, "generate_key_pair", lambda tenant_id: f"public-key-{tenant_id}")
tenant = MagicMock()
tenant.id = "tenant-1"
session = MagicMock()
session.scalars.return_value.all.return_value = [tenant]
session_manager = MagicMock()
session_manager.begin.return_value.__enter__.return_value = session
monkeypatch.setattr(workspace_commands, "sessionmaker", lambda *args, **kwargs: session_manager)
monkeypatch.setattr(workspace_commands, "db", MagicMock(engine=object()))
reset_encrypt_key_pair.callback()
assert tenant.encrypt_public_key == "public-key-tenant-1"
assert session.execute.call_count == 5
captured = capsys.readouterr()
assert "tenant-1 has been reset" in captured.out
def test_reset_encrypt_key_pair_stops_when_workspace_record_is_missing(monkeypatch, capsys):
monkeypatch.setattr(workspace_commands.dify_config, "EDITION", "SELF_HOSTED")
session = MagicMock()
session.scalars.return_value.all.return_value = [None]
session_manager = MagicMock()
session_manager.begin.return_value.__enter__.return_value = session
monkeypatch.setattr(workspace_commands, "sessionmaker", lambda *args, **kwargs: session_manager)
monkeypatch.setattr(workspace_commands, "db", MagicMock(engine=object()))
reset_encrypt_key_pair.callback()
session.execute.assert_not_called()
captured = capsys.readouterr()
assert "No workspaces found" in captured.out
@@ -0,0 +1,394 @@
from __future__ import annotations
from inspect import unwrap
from unittest.mock import MagicMock, PropertyMock, patch
import pytest
from flask import Flask
from controllers.console import console_ns
from controllers.console.workspace.skills import (
WorkspaceAgentSkillBindingsApi,
WorkspaceSkillAssistMessageApi,
WorkspaceSkillFilesApi,
WorkspaceSkillsApi,
WorkspaceSkillTagsApi,
WorkspaceSkillVersionApi,
)
from models.account import Account
from services.skill_management_service import SkillAssistAttachmentPayload, SkillManagementServiceError
@pytest.fixture
def app() -> Flask:
flask_app = Flask("test_workspace_skills")
flask_app.config["TESTING"] = True
return flask_app
@pytest.fixture
def current_user() -> Account:
user = Account(name="Test User", email="test@example.com")
user.id = "user-1"
return user
def _skill_detail() -> dict:
return {
"id": "skill-1",
"name": "finance-sop",
"display_name": "Finance SOP",
"icon": "📄",
"description": "",
"tags": [],
"name_manually_edited": False,
"visibility": "workspace",
"latest_published_version_id": None,
"reference_count": 0,
"created_by": "user-1",
"created_by_name": "Test User",
"updated_by": "user-1",
"updated_by_name": "Test User",
"created_at": 1,
"updated_at": 1,
"files": [
{
"id": "file-1",
"path": "SKILL.md",
"kind": "file",
"storage": "text",
"mime_type": "text/markdown",
"content": "---\nname: finance-sop\n---\n# Body",
"tool_file_id": None,
"size": 32,
"hash": "hash",
}
],
}
def test_create_skill_validates_payload_and_returns_detail(app: Flask, current_user: Account) -> None:
api = WorkspaceSkillsApi()
method = unwrap(api.post)
service = MagicMock()
service.create_skill.return_value = _skill_detail()
with (
app.test_request_context("/", method="POST"),
patch.object(type(console_ns), "payload", new_callable=PropertyMock, return_value={}),
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
):
payload, status = method(api, "tenant-1", current_user)
assert status == 201
assert payload["id"] == "skill-1"
assert payload["files"][0]["path"] == "SKILL.md"
service.create_skill.assert_called_once()
assert service.create_skill.call_args.kwargs["tenant_id"] == "tenant-1"
assert service.create_skill.call_args.kwargs["user_id"] == "user-1"
def test_list_skills_uses_default_pagination_when_query_omits_page_and_limit(app: Flask) -> None:
api = WorkspaceSkillsApi()
method = unwrap(api.get)
service = MagicMock()
service.list_skills.return_value = {
"data": [],
"has_more": False,
"limit": 20,
"page": 1,
"total": 0,
}
with (
app.test_request_context("/?keyword=finance&tag=ops&tag=", method="GET"),
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
):
payload = method(api, "tenant-1")
assert payload == {
"data": [],
"has_more": False,
"limit": 20,
"page": 1,
"total": 0,
}
service.list_skills.assert_called_once_with(
tenant_id="tenant-1",
keyword="finance",
page=1,
limit=20,
tags=["ops"],
)
def test_get_agent_skill_bindings_returns_card_data(app: Flask) -> None:
api = WorkspaceAgentSkillBindingsApi()
method = unwrap(api.get)
service = MagicMock()
service.list_agent_bindings.return_value = {
"agent_id": "agent-1",
"skill_ids": ["skill-1"],
"data": [
{
"id": "skill-1",
"priority": 0,
"name": "finance-sop",
"display_name": "Finance SOP",
"icon": "📄",
"description": "Handle finance.",
"tags": ["Finance"],
"status": "published",
"file_count": 2,
"latest_published_version_id": "version-1",
"latest_published_at": 123,
"updated_at": 124,
}
],
}
with (
app.test_request_context("/", method="GET"),
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
):
payload = method(api, "tenant-1", "agent-1")
assert payload["skill_ids"] == ["skill-1"]
assert payload["data"][0]["display_name"] == "Finance SOP"
assert payload["data"][0]["file_count"] == 2
service.list_agent_bindings.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1")
def test_patch_skill_file_operation_validates_payload_and_returns_detail(app: Flask, current_user: Account) -> None:
api = WorkspaceSkillFilesApi()
method = unwrap(api.patch)
service = MagicMock()
service.apply_draft_file_operation.return_value = _skill_detail()
request_payload = {
"operation": "upsert_text",
"path": "references/policy.md",
"content": "Policy",
}
with (
app.test_request_context("/", method="PATCH"),
patch.object(type(console_ns), "payload", new_callable=PropertyMock, return_value=request_payload),
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
):
payload = method(api, "tenant-1", current_user, "skill-1")
assert payload["id"] == "skill-1"
service.apply_draft_file_operation.assert_called_once()
call = service.apply_draft_file_operation.call_args.kwargs
assert call["tenant_id"] == "tenant-1"
assert call["user_id"] == "user-1"
assert call["skill_id"] == "skill-1"
assert call["payload"].operation == "upsert_text"
def test_patch_skill_file_operation_returns_error_details(app: Flask, current_user: Account) -> None:
api = WorkspaceSkillFilesApi()
method = unwrap(api.patch)
service = MagicMock()
service.apply_draft_file_operation.side_effect = SkillManagementServiceError(
"missing_skill_name",
"SKILL.md frontmatter name is required",
details={"path": "SKILL.md", "field": "name", "line": 2},
)
with (
app.test_request_context("/", method="PATCH"),
patch.object(
type(console_ns),
"payload",
new_callable=PropertyMock,
return_value={"operation": "delete", "path": "SKILL.md"},
),
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
):
payload, status = method(api, "tenant-1", current_user, "skill-1")
assert status == 400
assert payload == {
"code": "missing_skill_name",
"message": "SKILL.md frontmatter name is required",
"details": {"path": "SKILL.md", "field": "name", "line": 2},
}
def test_list_skill_tags_returns_filter_options(app: Flask) -> None:
api = WorkspaceSkillTagsApi()
method = unwrap(api.get)
service = MagicMock()
service.list_tags.return_value = {"data": [{"tag": "finance", "count": 2}]}
with (
app.test_request_context("/", method="GET"),
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
):
payload = method(api, "tenant-1")
assert payload == {"data": [{"tag": "finance", "count": 2}]}
service.list_tags.assert_called_once_with(tenant_id="tenant-1")
def test_get_skill_version_returns_version_detail(app: Flask) -> None:
api = WorkspaceSkillVersionApi()
method = unwrap(api.get)
service = MagicMock()
service.get_version.return_value = {
"id": "version-1",
"skill_id": "skill-1",
"version_number": 1,
"version_name": "Initial finance policy",
"publish_note": "Initial finance policy",
"hash_code": "hash-code",
"archive_size": 123,
"published_by": "user-1",
"published_by_name": "Li Wei",
"is_latest": True,
"created_at": 1,
"files": [
{
"id": None,
"path": "SKILL.md",
"kind": "file",
"storage": "text",
"mime_type": "text/markdown",
"content": "# Version",
"tool_file_id": None,
"size": 9,
"hash": "file-hash",
}
],
}
with (
app.test_request_context("/", method="GET"),
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
):
payload = method(api, "tenant-1", "skill-1", "version-1")
assert payload["files"][0]["content"] == "# Version"
service.get_version.assert_called_once_with(
tenant_id="tenant-1",
skill_id="skill-1",
version_id="version-1",
)
def test_patch_skill_version_renames_version(app: Flask) -> None:
api = WorkspaceSkillVersionApi()
method = unwrap(api.patch)
service = MagicMock()
service.update_version.return_value = {
"id": "version-1",
"skill_id": "skill-1",
"version_number": 1,
"version_name": "Approval threshold",
"publish_note": "",
"hash_code": "hash-code",
"archive_size": 123,
"published_by": "user-1",
"published_by_name": "Li Wei",
"is_latest": True,
"created_at": 1,
}
with (
app.test_request_context("/", method="PATCH"),
patch.object(
type(console_ns),
"payload",
new_callable=PropertyMock,
return_value={"version_name": "Approval threshold"},
),
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
):
payload = method(api, "tenant-1", "skill-1", "version-1")
assert payload["version_name"] == "Approval threshold"
service.update_version.assert_called_once()
assert service.update_version.call_args.kwargs["payload"].version_name == "Approval threshold"
def test_delete_skill_version_returns_new_latest(app: Flask, current_user: Account) -> None:
api = WorkspaceSkillVersionApi()
method = unwrap(api.delete)
service = MagicMock()
service.delete_version.return_value = {
"id": "version-2",
"deleted": True,
"latest_published_version_id": "version-1",
}
with (
app.test_request_context("/", method="DELETE"),
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
):
payload = method(api, "tenant-1", current_user, "skill-1", "version-2")
assert payload == {"id": "version-2", "deleted": True, "latest_published_version_id": "version-1"}
service.delete_version.assert_called_once_with(
tenant_id="tenant-1",
user_id="user-1",
skill_id="skill-1",
version_id="version-2",
)
def test_skill_assistant_runs_agent_app_stream(app: Flask, current_user: Account) -> None:
api = WorkspaceSkillAssistMessageApi()
method = unwrap(api.post)
service = MagicMock()
assistant_app = MagicMock()
assistant_app.id = "assistant-app-1"
service.get_or_create_assistant_app.return_value = (assistant_app, "<skill_draft>draft</skill_draft>")
app_model = MagicMock()
app_response = MagicMock()
compact_response = MagicMock()
with (
app.test_request_context("/", method="POST"),
patch.object(
type(console_ns),
"payload",
new_callable=PropertyMock,
return_value={
"attachments": [
{
"tool_file_id": "tool-file-1",
"name": "requirements.md",
"mime_type": "text/markdown",
"size": 128,
}
],
"message": "Create an approval checklist.",
},
),
patch("controllers.console.workspace.skills.SkillManagementService", return_value=service),
patch(
"controllers.console.workspace.skills.db.session",
return_value=MagicMock(get=MagicMock(return_value=app_model)),
),
patch("controllers.console.workspace.skills.AppGenerateService.generate", return_value=app_response),
patch("controllers.console.workspace.skills.helper.compact_generate_response", return_value=compact_response),
):
response = method(api, "tenant-1", current_user, "skill-1")
assert response is compact_response
service.get_or_create_assistant_app.assert_called_once_with(
tenant_id="tenant-1",
skill_id="skill-1",
user_id="user-1",
attachments=[
SkillAssistAttachmentPayload(
tool_file_id="tool-file-1",
name="requirements.md",
mime_type="text/markdown",
size=128,
)
],
message="Create an approval checklist.",
model_payload=None,
)
@@ -1,89 +0,0 @@
from unittest.mock import MagicMock
from controllers.service_api.app.legacy_system_files import (
attach_legacy_system_file_warning_for_service_api,
normalize_legacy_system_file_args_for_service_api,
)
from core.app.entities.app_invoke_entities import InvokeFrom
from services.app_generate_service import AppGenerateService
_LEGACY_FILE_TEMPLATE = "{{#" + ".".join(("sys", "files")) + "#}}"
_USER_INPUT_FILE_INPUT_KEY = ".".join(("userinput", "files"))
def _legacy_file_graph() -> dict:
return {
"nodes": [
{"id": "start", "data": {"type": "start", "variables": []}},
{"id": "answer", "data": {"type": "answer", "answer": _LEGACY_FILE_TEMPLATE}},
],
"edges": [],
}
def test_hidden_service_api_file_payload_maps_to_userinput_files(mocker):
workflow = MagicMock()
workflow.graph_dict = _legacy_file_graph()
get_workflow = mocker.patch.object(AppGenerateService, "get_workflow", return_value=workflow)
app_model = MagicMock()
session = MagicMock()
files = [{"transfer_method": "remote_url", "url": "https://example.com/a.png"}]
args, compat_variable = normalize_legacy_system_file_args_for_service_api(
session=session,
app_model=app_model,
args={"inputs": {}, "files": None},
raw_payload={"system": {"files": files}},
)
get_workflow.assert_called_once_with(app_model, InvokeFrom.SERVICE_API, None, session=session)
assert compat_variable is not None
assert args["files"] == files
assert args["inputs"][_USER_INPUT_FILE_INPUT_KEY] == files
def test_service_api_file_payload_is_ignored_when_absent(mocker):
get_workflow = mocker.patch.object(AppGenerateService, "get_workflow")
app_model = MagicMock()
original_args = {"inputs": {}}
args, compat_variable = normalize_legacy_system_file_args_for_service_api(
session=MagicMock(),
app_model=app_model,
args=original_args,
raw_payload={},
)
assert args is original_args
assert compat_variable is None
get_workflow.assert_not_called()
def test_top_level_service_api_file_payload_still_checks_workflow_graph(mocker):
workflow = MagicMock()
workflow.graph_dict = {"nodes": []}
get_workflow = mocker.patch.object(AppGenerateService, "get_workflow", return_value=workflow)
app_model = MagicMock()
session = MagicMock()
files = [{"id": "file-1"}]
args, compat_variable = normalize_legacy_system_file_args_for_service_api(
session=session,
app_model=app_model,
args={"inputs": {}, "files": files},
raw_payload={},
)
get_workflow.assert_called_once_with(app_model, InvokeFrom.SERVICE_API, None, session=session)
assert args["files"] == files
assert compat_variable is None
def test_service_api_warning_is_attached_only_when_compatibility_was_used():
compat_variable = MagicMock(node_id="userinput", variable_name="files")
response = attach_legacy_system_file_warning_for_service_api({"answer": "ok"}, compat_variable)
response_without_warning = attach_legacy_system_file_warning_for_service_api({"answer": "ok"}, None)
assert response["warnings"]
assert response_without_warning == {"answer": "ok"}
@@ -46,7 +46,7 @@ class TestAdvancedChatAppGeneratorValidation:
with pytest.raises(ValueError, match="query must be a string"):
generator.generate(
app_model=SimpleNamespace(),
workflow=SimpleNamespace(graph_dict={"nodes": []}),
workflow=SimpleNamespace(),
user=SimpleNamespace(),
args={"inputs": {}, "query": 123},
invoke_from=InvokeFrom.WEB_APP,
@@ -186,7 +186,7 @@ class TestAdvancedChatAppGeneratorInternals:
result = generator.generate(
app_model=SimpleNamespace(id="app", tenant_id="tenant"),
workflow=SimpleNamespace(features_dict={}, graph_dict={"nodes": []}),
workflow=SimpleNamespace(features_dict={}),
user=user,
args={
"query": "hello",
@@ -1209,7 +1209,7 @@ class TestAdvancedChatAppGeneratorInternals:
monkeypatch.setattr(generator, "_generate", _fake_generate)
app_model = SimpleNamespace(id="app", tenant_id="tenant")
workflow = SimpleNamespace(features_dict={}, graph_dict={"nodes": []})
workflow = SimpleNamespace(features_dict={})
from models import Account
user = Account(name="Tester", email="tester@example.com")
@@ -1289,7 +1289,7 @@ class TestAdvancedChatAppGeneratorInternals:
monkeypatch.setattr(generator, "_generate", _fake_generate)
app_model = SimpleNamespace(id="app", tenant_id="tenant")
workflow = SimpleNamespace(features_dict={}, graph_dict={"nodes": []})
workflow = SimpleNamespace(features_dict={})
from models.model import EndUser
user = EndUser(tenant_id="tenant", type="session", name="tester", session_id="session")
@@ -7,6 +7,7 @@ from types import SimpleNamespace
from typing import Any
import pytest
from dify_agent.layers.config import DifyConfigSkillConfig
from dify_agent.layers.dify_core_tools import DifyCoreToolConfig, DifyCoreToolsLayerConfig
from dify_agent.layers.dify_plugin import DifyPluginToolConfig, DifyPluginToolsLayerConfig
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
@@ -29,6 +30,14 @@ from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
from models.agent_config_entities import AgentSoulConfig
@pytest.fixture(autouse=True)
def _no_runtime_agent_skills(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.load_runtime_agent_skill_configs",
lambda *, tenant_id, agent_id: [],
)
def _exec_ctx() -> DifyExecutionContextLayerConfig:
return DifyExecutionContextLayerConfig(
tenant_id="tenant-1",
@@ -514,6 +523,33 @@ class TestAgentAppConfigLayer:
"mentioned_file_names": [],
}
def test_config_layer_includes_bound_workspace_skills(self, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"core.app.apps.agent_app.runtime_request_builder.load_runtime_agent_skill_configs",
lambda *, tenant_id, agent_id: [
DifyConfigSkillConfig(
name="workspace-skill",
description="Bound workspace skill.",
size=123,
mime_type="application/zip",
)
],
)
soul = _soul_with_model()
soul.prompt.system_prompt = "Use [§skill:workspace-skill:Workspace Skill§]."
builder = AgentAppRuntimeRequestBuilder(
credentials_provider=_FakeCredentialsProvider(),
dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type]
)
result = builder.build(_ctx(soul))
config = next(layer for layer in result.request.composition.layers if layer.name == DIFY_CONFIG_LAYER_ID)
assert [skill.name for skill in config.config.skills] == ["workspace-skill"]
assert config.config.mentioned_skill_names == ["workspace-skill"]
prompt_layer = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt")
assert prompt_layer.config.prefix == "Use workspace-skill."
@pytest.mark.parametrize(
("system_prompt", "expected_prefix"),
[
@@ -22,7 +22,7 @@ def _build_converter() -> WorkflowResponseConverter:
app_config=SimpleNamespace(app_id="app-1", tenant_id="tenant-1"),
invoke_from=InvokeFrom.EXPLORE,
files=[],
inputs={"userinput.files": []},
inputs={},
workflow_execution_id="run-1",
call_depth=0,
)
@@ -54,17 +54,3 @@ def test_workflow_start_stream_response_carries_initial_reason():
reason=WorkflowStartReason.INITIAL,
)
assert resp.data.reason is WorkflowStartReason.INITIAL
def test_workflow_start_stream_response_exposes_only_canonical_file_input():
converter = _build_converter()
resp = converter.workflow_start_to_stream_response(
task_id="task-1",
workflow_run_id="run-1",
workflow_id="wf-1",
reason=WorkflowStartReason.INITIAL,
)
assert resp.data.inputs["userinput.files"] == []
assert "sys.files" not in resp.data.inputs
@@ -111,7 +111,7 @@ def test_generate_includes_parent_trace_context_in_extras(monkeypatch):
result = generator.generate(
app_model=SimpleNamespace(tenant_id="tenant-1", id="app-1"),
workflow=SimpleNamespace(features_dict={}, graph_dict={"nodes": []}),
workflow=SimpleNamespace(features_dict={}),
user=SimpleNamespace(id="user-1", session_id="session-1"),
args={
"inputs": {"query": "hello"},
@@ -377,7 +377,7 @@ class TestWorkflowAppGeneratorGenerate:
result = generator.generate(
app_model=SimpleNamespace(id="app", tenant_id="tenant"),
workflow=SimpleNamespace(features_dict={}, graph_dict={"nodes": []}),
workflow=SimpleNamespace(features_dict={}),
user=SimpleNamespace(id="user", session_id="session"),
args={"inputs": {}, SKIP_PREPARE_USER_INPUTS_KEY: True},
invoke_from=InvokeFrom.WEB_APP,
@@ -1,11 +1,11 @@
from __future__ import annotations
import logging
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from sqlalchemy import select
from sqlalchemy.orm import Session
from core.app.app_config.entities import AppAdditionalFeatures, WorkflowUIBasedAppConfig
from core.app.apps.workflow.generate_task_pipeline import WorkflowAppGenerateTaskPipeline
@@ -54,6 +54,7 @@ from graphon.runtime import GraphRuntimeState, VariablePool
from libs.datetime_utils import naive_utc_now
from models.enums import CreatorUserRole
from models.model import AppMode, EndUser
from models.workflow import WorkflowAppLog
from tests.workflow_test_utils import build_test_variable_pool
@@ -193,7 +194,7 @@ class TestWorkflowGenerateTaskPipeline:
assert isinstance(responses[0], ValueError)
def test_handle_workflow_started_event_sets_run_id(self, monkeypatch: pytest.MonkeyPatch):
def test_handle_workflow_started_event_sets_run_id(self, monkeypatch: pytest.MonkeyPatch, sqlite_engine):
pipeline = _make_pipeline()
pipeline._graph_runtime_state = GraphRuntimeState(
variable_pool=build_test_variable_pool(variables=build_system_variables(workflow_execution_id="run-id")),
@@ -201,11 +202,10 @@ class TestWorkflowGenerateTaskPipeline:
)
pipeline._workflow_response_converter.workflow_start_to_stream_response = lambda **kwargs: "started"
@contextmanager
def _fake_session():
yield SimpleNamespace()
monkeypatch.setattr(pipeline, "_database_session", _fake_session)
monkeypatch.setattr(
"core.app.apps.workflow.generate_task_pipeline.db",
SimpleNamespace(engine=sqlite_engine),
)
monkeypatch.setattr(pipeline, "_save_workflow_app_log", lambda **kwargs: None)
responses = list(pipeline._handle_workflow_started_event(QueueWorkflowStartedEvent()))
@@ -339,19 +339,18 @@ class TestWorkflowGenerateTaskPipeline:
assert responses == ["finish"]
def test_save_workflow_app_log_created_from(self):
@pytest.mark.parametrize("sqlite_session", [(WorkflowAppLog,)], indirect=True)
def test_save_workflow_app_log_created_from(self, sqlite_session: Session):
pipeline = _make_pipeline()
pipeline._application_generate_entity.invoke_from = InvokeFrom.SERVICE_API
pipeline._user_id = "user"
added: list[object] = []
pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id="run-id")
sqlite_session.flush()
class _Session:
def add(self, item):
added.append(item)
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id="run-id")
assert added
saved_log = sqlite_session.scalar(select(WorkflowAppLog))
assert saved_log is not None
assert saved_log.workflow_run_id == "run-id"
assert saved_log.created_from == "service-api"
def test_iteration_loop_and_human_input_handlers(self):
pipeline = _make_pipeline()
@@ -674,35 +673,29 @@ class TestWorkflowGenerateTaskPipeline:
assert "Fails to get audio trunk, task_id: task" in caplog.messages
assert any(isinstance(item, MessageAudioEndStreamResponse) for item in responses)
def test_database_session_rolls_back_on_error(self, monkeypatch: pytest.MonkeyPatch):
@pytest.mark.parametrize("sqlite_session", [(WorkflowAppLog,)], indirect=True)
def test_database_session_rolls_back_on_error(
self, monkeypatch: pytest.MonkeyPatch, sqlite_engine, sqlite_session: Session
):
pipeline = _make_pipeline()
calls = {"enter": 0, "exit_exc": None}
pipeline._application_generate_entity.invoke_from = InvokeFrom.SERVICE_API
pipeline._user_id = "user"
monkeypatch.setattr(
"core.app.apps.workflow.generate_task_pipeline.db",
SimpleNamespace(engine=sqlite_engine),
)
class _BeginContext:
def __enter__(self):
calls["enter"] += 1
return MagicMock()
def __exit__(self, exc_type, exc, tb):
calls["exit_exc"] = exc_type
return False
class _Sessionmaker:
def __init__(self, *args, **kwargs):
pass
def begin(self):
return _BeginContext()
monkeypatch.setattr("core.app.apps.workflow.generate_task_pipeline.sessionmaker", _Sessionmaker)
monkeypatch.setattr("core.app.apps.workflow.generate_task_pipeline.db", SimpleNamespace(engine=object()))
with pytest.raises(RuntimeError, match="db error"):
with pipeline._database_session():
def persist_then_fail() -> None:
with pipeline._database_session() as session:
pipeline._save_workflow_app_log(session=session, workflow_run_id="run-id")
session.flush()
raise RuntimeError("db error")
assert calls["enter"] == 1
assert calls["exit_exc"] is RuntimeError
with pytest.raises(RuntimeError, match="db error"):
persist_then_fail()
sqlite_session.expire_all()
assert sqlite_session.scalar(select(WorkflowAppLog)) is None
def test_node_retry_and_started_handlers_cover_none_and_value(self):
pipeline = _make_pipeline()
@@ -862,31 +855,30 @@ class TestWorkflowGenerateTaskPipeline:
pipeline._handle_workflow_failed_and_stop_events = lambda event, **kwargs: iter(["stopped"])
assert list(pipeline._process_stream_response()) == ["stopped"]
def test_save_workflow_app_log_covers_invoke_from_variants(self):
@pytest.mark.parametrize("sqlite_session", [(WorkflowAppLog,)], indirect=True)
def test_save_workflow_app_log_covers_invoke_from_variants(self, sqlite_session: Session):
pipeline = _make_pipeline()
pipeline._user_id = "user-id"
added: list[object] = []
class _Session:
def add(self, item):
added.append(item)
pipeline._application_generate_entity.invoke_from = InvokeFrom.EXPLORE
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id="run-id")
assert added[-1].created_from == "installed-app"
pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id="run-id")
pipeline._application_generate_entity.invoke_from = InvokeFrom.WEB_APP
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id="run-id")
assert added[-1].created_from == "web-app"
pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id="run-id-2")
sqlite_session.flush()
saved_logs = sqlite_session.scalars(select(WorkflowAppLog).order_by(WorkflowAppLog.workflow_run_id)).all()
assert [log.created_from for log in saved_logs] == ["installed-app", "web-app"]
count_before = len(added)
count_before = len(saved_logs)
pipeline._application_generate_entity.invoke_from = InvokeFrom.DEBUGGER
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id="run-id")
assert len(added) == count_before
pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id="run-id-3")
sqlite_session.flush()
assert len(sqlite_session.scalars(select(WorkflowAppLog)).all()) == count_before
pipeline._application_generate_entity.invoke_from = InvokeFrom.WEB_APP
pipeline._save_workflow_app_log(session=_Session(), workflow_run_id=None)
assert len(added) == count_before
pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id=None)
sqlite_session.flush()
assert len(sqlite_session.scalars(select(WorkflowAppLog)).all()) == count_before
def test_save_output_for_event_writes_draft_variables(self):
pipeline = _make_pipeline()
@@ -1,9 +1,13 @@
import logging
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from unittest.mock import Mock, patch
import pytest
from sqlalchemy import Engine, event
from sqlalchemy.orm import Session, sessionmaker
from core.app.layers.trigger_post_layer import TriggerPostLayer
from core.workflow.system_variables import build_system_variables
@@ -13,19 +17,63 @@ from graphon.graph_events import (
GraphRunSucceededEvent,
)
from graphon.runtime import VariablePool
from models.enums import WorkflowTriggerStatus
from models.enums import AppTriggerType, CreatorUserRole, WorkflowTriggerStatus
from models.trigger import WorkflowTriggerLog
@dataclass(frozen=True)
class TriggerDatabase:
session: Session
statements: list[str]
@pytest.fixture(autouse=True)
def trigger_database(monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> Iterator[TriggerDatabase]:
"""Create the trigger-log table and bind layer-owned sessions to SQLite."""
WorkflowTriggerLog.metadata.create_all(sqlite_engine, tables=[WorkflowTriggerLog.__table__])
sqlite_session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
monkeypatch.setattr("core.db.session_factory._session_maker", sqlite_session_maker)
statements: list[str] = []
def record_statement(_connection, _cursor, statement, _parameters, _context, _executemany) -> None:
statements.append(statement)
event.listen(sqlite_engine, "before_cursor_execute", record_statement)
with sqlite_session_maker() as session:
try:
yield TriggerDatabase(session=session, statements=statements)
finally:
event.remove(sqlite_engine, "before_cursor_execute", record_statement)
def _persist_trigger_log(database: TriggerDatabase, *, trigger_log_id: str = "log-1") -> WorkflowTriggerLog:
trigger_log = WorkflowTriggerLog(
tenant_id="tenant-1",
app_id="app-1",
workflow_id="workflow-1",
workflow_run_id=None,
root_node_id=None,
trigger_metadata="{}",
trigger_type=AppTriggerType.TRIGGER_WEBHOOK,
trigger_data="{}",
inputs="{}",
outputs=None,
status=WorkflowTriggerStatus.RUNNING,
error=None,
queue_name="workflow",
celery_task_id=None,
created_by_role=CreatorUserRole.ACCOUNT,
created_by="account-1",
)
trigger_log.id = trigger_log_id
database.session.add(trigger_log)
database.session.commit()
return trigger_log
class TestTriggerPostLayer:
def test_on_event_updates_trigger_log(self):
trigger_log = SimpleNamespace(
status=None,
workflow_run_id=None,
outputs=None,
elapsed_time=None,
total_tokens=None,
finished_at=None,
)
def test_on_event_updates_trigger_log(self, trigger_database: TriggerDatabase):
trigger_log = _persist_trigger_log(trigger_database)
runtime_state = SimpleNamespace(
outputs={"answer": "ok"},
variable_pool=VariablePool.from_bootstrap(
@@ -35,19 +83,10 @@ class TestTriggerPostLayer:
)
with (
patch("core.app.layers.trigger_post_layer.session_factory") as mock_session_factory,
patch("core.app.layers.trigger_post_layer.SQLAlchemyWorkflowTriggerLogRepository") as mock_repo_cls,
patch("core.app.layers.trigger_post_layer.datetime") as mock_datetime,
):
mock_datetime.now.return_value = datetime(2026, 2, 20, tzinfo=UTC)
session = Mock()
mock_session_factory.create_session.return_value.__enter__.return_value = session
repo = Mock()
repo.get_by_id.return_value = trigger_log
mock_repo_cls.return_value = repo
layer = TriggerPostLayer(
cfs_plan_scheduler_entity=Mock(),
start_time=datetime(2026, 2, 20, tzinfo=UTC) - timedelta(seconds=10),
@@ -57,25 +96,18 @@ class TestTriggerPostLayer:
layer.on_event(GraphRunSucceededEvent())
assert trigger_log.status == WorkflowTriggerStatus.SUCCEEDED
assert trigger_log.workflow_run_id == "run-1"
assert trigger_log.outputs is not None
assert trigger_log.elapsed_time is not None
assert trigger_log.total_tokens == 12
assert trigger_log.finished_at is not None
repo.update.assert_called_once_with(trigger_log)
session.commit.assert_called_once()
trigger_database.session.expire_all()
persisted_log = trigger_database.session.get(WorkflowTriggerLog, trigger_log.id)
assert persisted_log is not None
assert persisted_log.status == WorkflowTriggerStatus.SUCCEEDED
assert persisted_log.workflow_run_id == "run-1"
assert persisted_log.outputs == '{"answer":"ok"}'
assert persisted_log.elapsed_time == 10
assert persisted_log.total_tokens == 12
assert persisted_log.finished_at is not None
def test_on_event_updates_trigger_log_for_aborted_event(self):
trigger_log = SimpleNamespace(
status=None,
workflow_run_id=None,
outputs=None,
error=None,
elapsed_time=None,
total_tokens=None,
finished_at=None,
)
def test_on_event_updates_trigger_log_for_aborted_event(self, trigger_database: TriggerDatabase):
trigger_log = _persist_trigger_log(trigger_database)
runtime_state = SimpleNamespace(
outputs={"partial": "ok"},
variable_pool=VariablePool.from_bootstrap(
@@ -85,19 +117,10 @@ class TestTriggerPostLayer:
)
with (
patch("core.app.layers.trigger_post_layer.session_factory") as mock_session_factory,
patch("core.app.layers.trigger_post_layer.SQLAlchemyWorkflowTriggerLogRepository") as mock_repo_cls,
patch("core.app.layers.trigger_post_layer.datetime") as mock_datetime,
):
mock_datetime.now.return_value = datetime(2026, 2, 20, tzinfo=UTC)
session = Mock()
mock_session_factory.create_session.return_value.__enter__.return_value = session
repo = Mock()
repo.get_by_id.return_value = trigger_log
mock_repo_cls.return_value = repo
layer = TriggerPostLayer(
cfs_plan_scheduler_entity=Mock(),
start_time=datetime(2026, 2, 20, tzinfo=UTC) - timedelta(seconds=10),
@@ -107,17 +130,22 @@ class TestTriggerPostLayer:
layer.on_event(GraphRunAbortedEvent(reason="timeout"))
assert trigger_log.status == WorkflowTriggerStatus.FAILED
assert trigger_log.workflow_run_id == "run-1"
assert trigger_log.outputs is not None
assert trigger_log.error == "timeout"
assert trigger_log.elapsed_time is not None
assert trigger_log.total_tokens == 7
assert trigger_log.finished_at is not None
repo.update.assert_called_once_with(trigger_log)
session.commit.assert_called_once()
trigger_database.session.expire_all()
persisted_log = trigger_database.session.get(WorkflowTriggerLog, trigger_log.id)
assert persisted_log is not None
assert persisted_log.status == WorkflowTriggerStatus.FAILED
assert persisted_log.workflow_run_id == "run-1"
assert persisted_log.outputs == '{"partial":"ok"}'
assert persisted_log.error == "timeout"
assert persisted_log.elapsed_time == 10
assert persisted_log.total_tokens == 7
assert persisted_log.finished_at is not None
def test_on_event_handles_missing_trigger_log(self, caplog: pytest.LogCaptureFixture):
def test_on_event_handles_missing_trigger_log(
self,
caplog: pytest.LogCaptureFixture,
trigger_database: TriggerDatabase,
):
runtime_state = SimpleNamespace(
outputs={},
variable_pool=VariablePool.from_bootstrap(
@@ -126,31 +154,20 @@ class TestTriggerPostLayer:
total_tokens=0,
)
with (
patch("core.app.layers.trigger_post_layer.session_factory") as mock_session_factory,
patch("core.app.layers.trigger_post_layer.SQLAlchemyWorkflowTriggerLogRepository") as mock_repo_cls,
):
session = Mock()
mock_session_factory.create_session.return_value.__enter__.return_value = session
layer = TriggerPostLayer(
cfs_plan_scheduler_entity=Mock(),
start_time=datetime(2026, 2, 20, tzinfo=UTC),
trigger_log_id="missing",
)
layer.initialize(runtime_state, Mock())
repo = Mock()
repo.get_by_id.return_value = None
mock_repo_cls.return_value = repo
layer = TriggerPostLayer(
cfs_plan_scheduler_entity=Mock(),
start_time=datetime(2026, 2, 20, tzinfo=UTC),
trigger_log_id="missing",
)
layer.initialize(runtime_state, Mock())
with caplog.at_level(logging.ERROR, logger="core.app.layers.trigger_post_layer"):
layer.on_event(GraphRunFailedEvent(error="boom"))
with caplog.at_level(logging.ERROR, logger="core.app.layers.trigger_post_layer"):
layer.on_event(GraphRunFailedEvent(error="boom"))
assert any(record.levelno == logging.ERROR for record in caplog.records)
session.commit.assert_not_called()
assert trigger_database.session.get(WorkflowTriggerLog, "missing") is None
def test_on_event_ignores_non_status_events(self):
def test_on_event_ignores_non_status_events(self, trigger_database: TriggerDatabase):
runtime_state = SimpleNamespace(
outputs={},
variable_pool=VariablePool.from_bootstrap(
@@ -159,14 +176,14 @@ class TestTriggerPostLayer:
total_tokens=0,
)
with patch("core.app.layers.trigger_post_layer.session_factory") as mock_session_factory:
layer = TriggerPostLayer(
cfs_plan_scheduler_entity=Mock(),
start_time=datetime(2026, 2, 20, tzinfo=UTC),
trigger_log_id="log-1",
)
layer.initialize(runtime_state, Mock())
layer = TriggerPostLayer(
cfs_plan_scheduler_entity=Mock(),
start_time=datetime(2026, 2, 20, tzinfo=UTC),
trigger_log_id="log-1",
)
layer.initialize(runtime_state, Mock())
layer.on_event(Mock())
trigger_database.statements.clear()
layer.on_event(Mock())
mock_session_factory.create_session.assert_not_called()
assert trigger_database.statements == []
@@ -1,10 +1,13 @@
import types
from collections.abc import Generator
from collections.abc import Generator, Iterator
import pytest
from pytest_mock import MockerFixture
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker
from contexts.wrapper import RecyclableContextVar
from core.datasource import datasource_manager as datasource_manager_module
from core.datasource.datasource_manager import DatasourceManager
from core.datasource.entities.datasource_entities import DatasourceMessage, DatasourceProviderType
from core.datasource.errors import DatasourceProviderNotFoundError
@@ -12,6 +15,34 @@ from core.workflow.file_reference import parse_file_reference
from graphon.enums import WorkflowNodeExecutionStatus
from graphon.file import File, FileTransferMethod, FileType
from graphon.node_events import StreamChunkEvent, StreamCompletedEvent
from models.base import TypeBase
from models.tools import ToolFile
@pytest.fixture
def tool_file_session(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator[Session]:
"""Bind datasource-owned lookups to a SQLite ToolFile table."""
TypeBase.metadata.create_all(sqlite_engine, tables=[TypeBase.metadata.tables[ToolFile.__tablename__]])
session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
monkeypatch.setattr(datasource_manager_module.session_factory, "create_session", session_maker)
with session_maker() as session:
yield session
def _persist_tool_file(session: Session, *, file_id: str, tenant_id: str) -> ToolFile:
tool_file = ToolFile(
user_id="user-1",
tenant_id=tenant_id,
conversation_id=None,
file_key="files/image.png",
mimetype="image/png",
name="image.png",
size=10,
)
tool_file.id = file_id
session.add(tool_file)
session.commit()
return tool_file
def _gen_messages_text_only(text: str) -> Generator[DatasourceMessage, None, None]:
@@ -373,7 +404,8 @@ def test_stream_node_events_emits_events_online_document(mocker: MockerFixture):
assert events[-1].node_run_result.status == WorkflowNodeExecutionStatus.SUCCEEDED
def test_stream_node_events_builds_file_and_variables_from_messages(mocker: MockerFixture):
def test_stream_node_events_builds_file_and_variables_from_messages(mocker: MockerFixture, tool_file_session: Session):
_persist_tool_file(tool_file_session, file_id="tool_file_1", tenant_id="t1")
mocker.patch.object(DatasourceManager, "stream_online_results", return_value=_gen_messages_text_only("ignored"))
def _transformed(**_kwargs):
@@ -418,19 +450,6 @@ def test_stream_node_events_builds_file_and_variables_from_messages(mocker: Mock
side_effect=_transformed,
)
fake_tool_file = types.SimpleNamespace(mimetype="image/png")
class _Session:
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def scalar(self, _stmt):
return fake_tool_file
mocker.patch("core.datasource.datasource_manager.session_factory.create_session", return_value=_Session())
mocker.patch("core.datasource.datasource_manager.get_file_type_by_mime_type", return_value=FileType.IMAGE)
built = File(
file_type=FileType.IMAGE,
@@ -481,7 +500,8 @@ def test_stream_node_events_builds_file_and_variables_from_messages(mocker: Mock
assert events[-1].node_run_result.outputs["x"] == 1
def test_stream_node_events_raises_when_toolfile_missing(mocker: MockerFixture):
def test_stream_node_events_raises_when_toolfile_missing(mocker: MockerFixture, tool_file_session: Session):
_persist_tool_file(tool_file_session, file_id="missing", tenant_id="other-tenant")
mocker.patch.object(DatasourceManager, "stream_online_results", return_value=_gen_messages_text_only("ignored"))
def _transformed(**_kwargs):
@@ -496,18 +516,6 @@ def test_stream_node_events_raises_when_toolfile_missing(mocker: MockerFixture):
side_effect=_transformed,
)
class _Session:
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def scalar(self, _stmt):
return None
mocker.patch("core.datasource.datasource_manager.session_factory.create_session", return_value=_Session())
with pytest.raises(ValueError, match="ToolFile not found for file_id=missing, tenant_id=t1"):
list(
DatasourceManager.stream_node_events(
@@ -14,18 +14,64 @@ Tests follow the Arrange-Act-Assert pattern for clarity.
"""
import json
from collections.abc import Iterator
from dataclasses import dataclass
from typing import Any
from unittest.mock import Mock, patch
from uuid import uuid4
import httpx
import pytest
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session
from core.datasource.entities.datasource_entities import DatasourceProviderType
from core.datasource.online_document.online_document_provider import (
OnlineDocumentDatasourcePluginProviderController,
)
from core.rag.extractor import notion_extractor as notion_extractor_module
from core.rag.extractor.notion_extractor import NotionExtractor
from core.rag.models.document import Document
from models.base import TypeBase
from models.dataset import Document as DocumentModel
from models.enums import DataSourceType, DocumentCreatedFrom
@dataclass(frozen=True)
class _Database:
"""Expose the real SQLite session used by the extractor update."""
session: Session
@pytest.fixture
def database(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator[_Database]:
"""Bind a real session for Notion document metadata persistence."""
TypeBase.metadata.create_all(sqlite_engine, tables=[DocumentModel.__table__])
with Session(sqlite_engine, expire_on_commit=False) as session:
database = _Database(session)
monkeypatch.setattr(notion_extractor_module, "db", database)
yield database
@pytest.fixture
def persisted_document(database: _Database) -> DocumentModel:
document = DocumentModel(
id=str(uuid4()),
tenant_id=str(uuid4()),
dataset_id=str(uuid4()),
position=1,
data_source_type=DataSourceType.NOTION_IMPORT,
data_source_info=json.dumps({"last_edited_time": "2024-01-01T00:00:00.000Z"}),
batch="batch",
name="Notion page",
created_from=DocumentCreatedFrom.WEB,
created_by=str(uuid4()),
)
database.session.add(document)
database.session.commit()
return document
class TestNotionExtractorAuthentication:
@@ -763,9 +809,14 @@ class TestNotionExtractorLastEditedTime:
call_args = mock_request.call_args
assert "databases/database-789" in call_args[0][1]
@patch("core.rag.extractor.notion_extractor.db")
@patch("httpx.request")
def test_update_last_edited_time(self, mock_request, mock_db, extractor_page, mock_document_model):
def test_update_last_edited_time(
self,
mock_request: Mock,
extractor_page: NotionExtractor,
database: _Database,
persisted_document: DocumentModel,
):
"""Test updating document model with last edited time."""
# Arrange
mock_response = Mock()
@@ -777,11 +828,11 @@ class TestNotionExtractorLastEditedTime:
mock_request.return_value = mock_response
# Act
extractor_page.update_last_edited_time(mock_document_model)
extractor_page.update_last_edited_time(persisted_document)
# Assert
assert mock_document_model.data_source_info_dict["last_edited_time"] == "2024-11-27T18:00:00.000Z"
mock_db.session.commit.assert_called_once()
database.session.expire(persisted_document)
assert persisted_document.data_source_info_dict["last_edited_time"] == "2024-11-27T18:00:00.000Z"
def test_update_last_edited_time_no_document(self, extractor_page):
"""Test update_last_edited_time with None document model."""
@@ -807,9 +858,10 @@ class TestNotionExtractorIntegration:
mock_doc.data_source_info_dict = {"last_edited_time": "2024-01-01T00:00:00.000Z"}
return mock_doc
@patch("core.rag.extractor.notion_extractor.db")
@patch("httpx.request")
def test_extract_page_complete_workflow(self, mock_request, mock_db, mock_document_model):
def test_extract_page_complete_workflow(
self, mock_request: Mock, database: _Database, persisted_document: DocumentModel
):
"""Test complete page extraction workflow."""
# Arrange
extractor = NotionExtractor(
@@ -818,7 +870,7 @@ class TestNotionExtractorIntegration:
notion_page_type="page",
tenant_id="tenant-789",
notion_access_token="test-token",
document_model=mock_document_model,
document_model=persisted_document,
)
# Mock last edited time request
@@ -869,11 +921,18 @@ class TestNotionExtractorIntegration:
assert isinstance(documents[0], Document)
assert "# Test Page" in documents[0].page_content
assert "Test content" in documents[0].page_content
database.session.expire(persisted_document)
assert persisted_document.data_source_info_dict["last_edited_time"] == "2024-11-27T20:00:00.000Z"
@patch("core.rag.extractor.notion_extractor.db")
@patch("httpx.post")
@patch("httpx.request")
def test_extract_database_complete_workflow(self, mock_request, mock_post, mock_db, mock_document_model):
def test_extract_database_complete_workflow(
self,
mock_request: Mock,
mock_post: Mock,
database: _Database,
persisted_document: DocumentModel,
):
"""Test complete database extraction workflow."""
# Arrange
extractor = NotionExtractor(
@@ -882,7 +941,7 @@ class TestNotionExtractorIntegration:
notion_page_type="database",
tenant_id="tenant-789",
notion_access_token="test-token",
document_model=mock_document_model,
document_model=persisted_document,
)
# Mock last edited time request
@@ -921,6 +980,8 @@ class TestNotionExtractorIntegration:
assert isinstance(documents[0], Document)
assert "Name:Item 1" in documents[0].page_content
assert "Status:Active" in documents[0].page_content
database.session.expire(persisted_document)
assert persisted_document.data_source_info_dict["last_edited_time"] == "2024-11-27T20:00:00.000Z"
def test_extract_invalid_page_type(self):
"""Test extract with invalid page type."""
@@ -2,9 +2,21 @@ from types import SimpleNamespace
import pandas as pd
import pytest
from sqlalchemy import Engine, select
from sqlalchemy.orm import Session, sessionmaker
import core.rag.extractor.excel_extractor as excel_module
from core.rag.extractor.excel_extractor import ExcelExtractor
from models.base import TypeBase
from models.model import UploadFile
@pytest.fixture
def database_session_maker(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> sessionmaker[Session]:
TypeBase.metadata.create_all(sqlite_engine, tables=[UploadFile.__table__])
session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
monkeypatch.setattr(excel_module.session_factory, "create_session", session_maker)
return session_maker
class _FakeCell:
@@ -58,82 +70,22 @@ class _FakeImage:
return self._raw_data
class _FieldExpression:
def __eq__(self, other):
return ("eq", other)
def in_(self, values):
return ("in", tuple(values))
class _SelectStub:
def where(self, *args, **kwargs):
return self
class _FakeUploadFile:
tenant_id = _FieldExpression()
key = _FieldExpression()
_i = 0
def __init__(self, **kwargs):
type(self)._i += 1
self.id = f"u{self._i}"
self.key = kwargs["key"]
class _PersistentSession:
def __init__(self, persisted):
self._persisted = persisted
self.added = []
self.commit_count = 0
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def scalars(self, _stmt):
return SimpleNamespace(all=lambda: list(self._persisted.values()))
def add_all(self, objects) -> None:
self.added.extend(objects)
def commit(self) -> None:
self.commit_count += 1
for upload_file in self.added:
self._persisted[upload_file.key] = upload_file
self.added.clear()
class _PersistentSessionFactory:
def __init__(self):
self.persisted = {}
self.sessions = []
def create_session(self):
session = _PersistentSession(self.persisted)
self.sessions.append(session)
return session
def _patch_image_persistence(monkeypatch: pytest.MonkeyPatch):
saves: list[tuple[str, bytes]] = []
session_factory = _PersistentSessionFactory()
def save(key: str, data: bytes) -> None:
saves.append((key, data))
_FakeUploadFile._i = 0
monkeypatch.setattr(excel_module, "storage", SimpleNamespace(save=save))
monkeypatch.setattr(excel_module, "session_factory", session_factory)
monkeypatch.setattr(excel_module, "select", lambda *args, **kwargs: _SelectStub())
monkeypatch.setattr(excel_module, "UploadFile", _FakeUploadFile)
monkeypatch.setattr(excel_module.storage, "save", save)
monkeypatch.setattr(excel_module.dify_config, "FILES_URL", "http://files.local", raising=False)
monkeypatch.setattr(excel_module.dify_config, "STORAGE_TYPE", "local", raising=False)
return saves, session_factory
return saves
def _get_upload_files(session_maker: sessionmaker[Session]) -> list[UploadFile]:
with session_maker() as session:
return list(session.scalars(select(UploadFile)).all())
class TestExcelExtractor:
@@ -160,7 +112,11 @@ class TestExcelExtractor:
assert docs[1].page_content == '"Name":"";"Link":"123"'
assert all(doc.metadata["source"] == "/tmp/sample.xlsx" for doc in docs)
def test_extract_xlsx_turns_embedded_images_into_markdown_links(self, monkeypatch: pytest.MonkeyPatch):
def test_extract_xlsx_turns_embedded_images_into_markdown_links(
self,
monkeypatch: pytest.MonkeyPatch,
database_session_maker: sessionmaker[Session],
):
image_bytes = b"\x89PNG\r\n\x1a\nexcel-image"
sheet = _FakeSheet(
header_rows=[("Question", "Answer", "Image")],
@@ -175,7 +131,7 @@ class TestExcelExtractor:
)
workbook = _FakeWorkbook({"Data": sheet})
monkeypatch.setattr(excel_module, "load_workbook", lambda *args, **kwargs: workbook)
saves, session_factory = _patch_image_persistence(monkeypatch)
saves = _patch_image_persistence(monkeypatch)
extractor = ExcelExtractor(
"/tmp/sample.xlsx",
@@ -184,23 +140,30 @@ class TestExcelExtractor:
source_file_id="source-file-1",
)
docs = extractor.extract()
upload_files = _get_upload_files(database_session_maker)
assert workbook.closed is True
assert len(docs) == 2
assert len(upload_files) == 1
assert docs[0].page_content == (
'"Question":"Q1";"Answer":"A1";'
'"Image":"![image](http://files.local/files/u1/file-preview) '
'![image](http://files.local/files/u1/file-preview)"'
f'"Image":"![image](http://files.local/files/{upload_files[0].id}/file-preview) '
f'![image](http://files.local/files/{upload_files[0].id}/file-preview)"'
)
assert docs[1].page_content == '"Question":"Q2";"Answer":"A2";"Image":""'
assert len(saves) == 1
assert saves[0][0].startswith("image_files/tenant-1/source-file-1/")
assert saves[0][0].endswith(".png")
assert saves[0][1] == image_bytes
assert len(session_factory.persisted) == 1
assert [session.commit_count for session in session_factory.sessions] == [1]
assert upload_files[0].tenant_id == "tenant-1"
assert upload_files[0].key == saves[0][0]
assert upload_files[0].used is True
def test_extract_xlsx_keeps_rows_with_only_embedded_images(self, monkeypatch: pytest.MonkeyPatch):
def test_extract_xlsx_keeps_rows_with_only_embedded_images(
self,
monkeypatch: pytest.MonkeyPatch,
database_session_maker: sessionmaker[Session],
):
image_bytes = b"\x89PNG\r\n\x1a\nimage-only-row"
sheet = _FakeSheet(
header_rows=[("Question", "Answer", "Image")],
@@ -212,7 +175,7 @@ class TestExcelExtractor:
)
workbook = _FakeWorkbook({"Data": sheet})
monkeypatch.setattr(excel_module, "load_workbook", lambda *args, **kwargs: workbook)
saves, session_factory = _patch_image_persistence(monkeypatch)
saves = _patch_image_persistence(monkeypatch)
extractor = ExcelExtractor(
"/tmp/sample.xlsx",
@@ -221,17 +184,21 @@ class TestExcelExtractor:
source_file_id="source-file-1",
)
docs = extractor.extract()
upload_files = _get_upload_files(database_session_maker)
assert workbook.closed is True
assert len(docs) == 1
assert len(upload_files) == 1
assert docs[0].page_content == (
'"Question":"";"Answer":"";"Image":"![image](http://files.local/files/u1/file-preview)"'
f'"Question":"";"Answer":"";"Image":"![image](http://files.local/files/{upload_files[0].id}/file-preview)"'
)
assert len(saves) == 1
assert len(session_factory.persisted) == 1
assert [session.commit_count for session in session_factory.sessions] == [1]
def test_extract_xlsx_reuses_existing_embedded_image_uploads_on_retry(self, monkeypatch: pytest.MonkeyPatch):
def test_extract_xlsx_reuses_existing_embedded_image_uploads_on_retry(
self,
monkeypatch: pytest.MonkeyPatch,
database_session_maker: sessionmaker[Session],
):
image_bytes = b"\x89PNG\r\n\x1a\nretry-safe-image"
workbooks = [
_FakeWorkbook(
@@ -254,7 +221,7 @@ class TestExcelExtractor:
),
]
monkeypatch.setattr(excel_module, "load_workbook", lambda *args, **kwargs: workbooks.pop(0))
saves, session_factory = _patch_image_persistence(monkeypatch)
saves = _patch_image_persistence(monkeypatch)
extractor = ExcelExtractor(
"/tmp/sample.xlsx",
@@ -264,16 +231,17 @@ class TestExcelExtractor:
)
first_docs = extractor.extract()
second_docs = extractor.extract()
upload_files = _get_upload_files(database_session_maker)
assert len(upload_files) == 1
expected_page_content = (
'"Question":"Q1";"Answer":"A1";"Image":"![image](http://files.local/files/u1/file-preview)"'
'"Question":"Q1";"Answer":"A1";'
f'"Image":"![image](http://files.local/files/{upload_files[0].id}/file-preview)"'
)
assert first_docs[0].page_content == expected_page_content
assert second_docs[0].page_content == expected_page_content
assert len(saves) == 1
assert len(session_factory.persisted) == 1
assert [session.commit_count for session in session_factory.sessions] == [1, 0]
def test_extract_xls_path(self, monkeypatch: pytest.MonkeyPatch):
class FakeExcelFile:
@@ -26,7 +26,6 @@ class TestPlannerSystemPrompt:
"""Auto-mode resolution rides on the planner echoing its mode choice."""
assert '"mode": "workflow | advanced-chat"' in PLANNER_SYSTEM_PROMPT
assert "When the ``# Mode`` section says auto, YOU decide" in PLANNER_SYSTEM_PROMPT
assert "userinput.files" in PLANNER_SYSTEM_PROMPT
class TestFormatIdealOutputSection:
@@ -76,9 +75,6 @@ class TestNodeBuilderPrompt:
assert '"viewport":' not in prompt
assert '"positionAbsolute":' not in prompt
def test_start_uses_user_input_files(self):
assert "userinput.files" in get_node_builder_system_prompt("start")
def test_supports_main_human_input_and_assigner_contracts(self):
human_input = get_node_builder_system_prompt("human-input")
assigner = get_node_builder_system_prompt("assigner")
@@ -134,19 +130,17 @@ class TestNodeBuilderUserSections:
class TestModeSection:
def test_advanced_chat_documents_built_in_variables(self):
def test_advanced_chat_documents_system_variables(self):
out = format_mode_section("advanced-chat")
assert "sys.query" in out
assert "userinput.files" in out
assert '["userinput", "files"]' in out
assert '["sys", "query"]' in out
assert "do NOT invent start-node variables" in out
def test_workflow_mode_documents_file_input(self):
def test_workflow_mode_forbids_system_variables(self):
out = format_mode_section("workflow")
assert "userinput.files" in out
assert "start node's declared variables" in out
assert "NO automatic system variables" in out
class TestExistingGraphSection:
@@ -5,6 +5,7 @@ from typing import cast
import pytest
from agenton.compositor import CompositorSessionSnapshot
from dify_agent.layers.config import DifyConfigSkillConfig
from dify_agent.layers.dify_core_tools import DifyCoreToolConfig, DifyCoreToolsLayerConfig
from dify_agent.layers.dify_plugin import DifyPluginToolConfig, DifyPluginToolsLayerConfig
from dify_agent.protocol import DIFY_AGENT_HISTORY_LAYER_ID, DIFY_AGENT_MODEL_LAYER_ID
@@ -40,6 +41,14 @@ from models.agent_config_entities import (
)
@pytest.fixture(autouse=True)
def _no_runtime_agent_skills(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.load_runtime_agent_skill_configs",
lambda *, tenant_id, agent_id: [],
)
class FakeCredentialsProvider:
def fetch(self, provider_name: str, model_name: str) -> dict[str, object]:
assert provider_name == "openai"
@@ -228,12 +237,12 @@ def _previous_node_prompt_payload(result, selector: str) -> object:
def _uploaded_workflow_files_prompt_payload(result) -> object:
prefix = " - userinput.files: "
prefix = " - sys.files: "
user_prompt = _workflow_user_prompt(result)
for line in user_prompt.splitlines():
if line.startswith(prefix):
return json.loads(line.removeprefix(prefix))
raise AssertionError("missing prompt payload for userinput.files")
raise AssertionError("missing prompt payload for sys.files")
def test_builds_create_run_request_from_agent_soul_and_node_job():
@@ -1420,6 +1429,30 @@ def test_build_config_layer_config_includes_soul_context_and_mentions():
assert warnings == []
def test_build_config_layer_config_includes_runtime_agent_skills():
from core.workflow.nodes.agent_v2.runtime_request_builder import build_config_layer_config
soul = AgentSoulConfig(
prompt={"system_prompt": "Use [§skill:workspace-skill:Workspace Skill§]."},
model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"),
)
config, warnings = build_config_layer_config(
soul,
runtime_config_skills=[
DifyConfigSkillConfig(
name="workspace-skill",
description="Bound workspace skill.",
size=123,
mime_type="application/zip",
)
],
)
assert [skill.name for skill in config.skills] == ["workspace-skill"]
assert config.mentioned_skill_names == ["workspace-skill"]
assert warnings == []
def test_build_config_layer_config_returns_empty_config_for_empty_agent_soul():
from core.workflow.nodes.agent_v2.runtime_request_builder import build_config_layer_config
@@ -1505,6 +1538,33 @@ def test_workflow_run_request_contains_config_layer():
assert any(spec.name == DIFY_CONFIG_LAYER_ID and spec.type == "dify.config" for spec in specs)
def test_workflow_run_request_includes_bound_workspace_skills(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
"core.workflow.nodes.agent_v2.runtime_request_builder.load_runtime_agent_skill_configs",
lambda *, tenant_id, agent_id: [
DifyConfigSkillConfig(
name="workspace-skill",
description="Bound workspace skill.",
size=123,
mime_type="application/zip",
)
],
)
context = _context()
context.snapshot.config_snapshot = AgentSoulConfig(
prompt={"system_prompt": "Use [§skill:workspace-skill:Workspace Skill§]."},
model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"),
)
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
config = next(layer for layer in result.request.composition.layers if layer.name == DIFY_CONFIG_LAYER_ID)
assert [skill.name for skill in config.config.skills] == ["workspace-skill"]
assert config.config.mentioned_skill_names == ["workspace-skill"]
soul_prompt = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt")
assert soul_prompt.config.prefix == "Use workspace-skill."
def test_workflow_runtime_expands_config_mentions_in_agent_soul_prompt():
context = _context()
context.snapshot.config_snapshot = _soul_with_config_assets()
@@ -1,161 +0,0 @@
from core.workflow.legacy_system_files import (
LegacySysFilesCompatVariable,
attach_legacy_sys_files_warning,
migrate_legacy_sys_files_graph_with_result,
normalize_legacy_sys_files_args,
resolve_legacy_sys_files_compat_variable,
)
_LEGACY_NODE_ID = "sys"
_LEGACY_ALIAS_NODE_ID = "userinput"
_LEGACY_VARIABLE_NAME = "files"
_LEGACY_SELECTOR = [_LEGACY_NODE_ID, _LEGACY_VARIABLE_NAME]
_LEGACY_TEMPLATE = "{{#" + ".".join((_LEGACY_NODE_ID, _LEGACY_VARIABLE_NAME)) + "#}}"
_LEGACY_ALIAS_SELECTOR = [_LEGACY_ALIAS_NODE_ID, _LEGACY_VARIABLE_NAME]
_LEGACY_ALIAS_TEMPLATE = "{{#" + ".".join((_LEGACY_ALIAS_NODE_ID, _LEGACY_VARIABLE_NAME)) + "#}}"
_LEGACY_ALIAS_INPUT_KEY = ".".join((_LEGACY_ALIAS_NODE_ID, _LEGACY_VARIABLE_NAME))
def test_migrate_legacy_sys_files_graph_ignores_invalid_or_unrelated_graphs():
assert not migrate_legacy_sys_files_graph_with_result({}).changed
assert not migrate_legacy_sys_files_graph_with_result({"nodes": [], "edges": [_LEGACY_SELECTOR]}).changed
assert not migrate_legacy_sys_files_graph_with_result({"nodes": [{"data": {"value": ["sys", "query"]}}]}).changed
def test_migrate_legacy_sys_files_graph_rewrites_sys_files_to_userinput_files_without_start_variable():
graph = {
"nodes": [
{"id": "start", "data": {"type": "start", "variables": [{"variable": "sys_files"}]}},
{
"id": "answer",
"data": {
"type": "answer",
"answer": _LEGACY_SELECTOR,
"template": _LEGACY_TEMPLATE,
},
},
],
}
result = migrate_legacy_sys_files_graph_with_result(graph)
assert result.changed
start_data = result.graph["nodes"][0]["data"]
assert start_data["variables"] == [{"variable": "sys_files"}]
assert result.graph["nodes"][1]["data"]["answer"] == _LEGACY_ALIAS_SELECTOR
assert result.graph["nodes"][1]["data"]["template"] == _LEGACY_ALIAS_TEMPLATE
assert graph["nodes"][1]["data"]["answer"] == _LEGACY_SELECTOR
assert graph["nodes"][1]["data"]["template"] == _LEGACY_TEMPLATE
def test_migrate_legacy_sys_files_graph_leaves_userinput_files_target_unchanged():
graph = {
"nodes": [
{"id": "start", "data": {"type": "start", "variables": []}},
{
"id": "answer",
"data": {
"type": "answer",
"answer": _LEGACY_ALIAS_SELECTOR,
"template": _LEGACY_ALIAS_TEMPLATE,
},
},
],
}
result = migrate_legacy_sys_files_graph_with_result(graph)
assert not result.changed
assert result.graph == graph
def test_resolve_legacy_sys_files_compat_variable_returns_userinput_files_target():
assert resolve_legacy_sys_files_compat_variable({}) is None
assert resolve_legacy_sys_files_compat_variable({"nodes": [{"data": {"value": ["sys", "query"]}}]}) is None
compat_variable = resolve_legacy_sys_files_compat_variable({"nodes": [{"data": {"value": _LEGACY_SELECTOR}}]})
assert compat_variable == LegacySysFilesCompatVariable(
node_id=_LEGACY_ALIAS_NODE_ID,
variable_name=_LEGACY_VARIABLE_NAME,
)
assert (
resolve_legacy_sys_files_compat_variable({"nodes": [{"data": {"value": _LEGACY_ALIAS_SELECTOR}}]})
== compat_variable
)
def test_normalize_legacy_sys_files_args_handles_no_compat_and_top_level_files():
args_without_legacy, compat_without_legacy = normalize_legacy_sys_files_args(
graph={"nodes": []},
args={"inputs": {}},
)
assert args_without_legacy == {"inputs": {}}
assert compat_without_legacy is None
files = [{"id": "file-1"}]
graph = {
"nodes": [
{"id": "start", "data": {"type": "start", "variables": []}},
{"id": "answer", "data": {"type": "answer", "answer": _LEGACY_TEMPLATE}},
],
}
normalized_args, compat_variable = normalize_legacy_sys_files_args(
graph=graph,
args={"inputs": {}, "files": files},
)
assert compat_variable is not None
assert normalized_args["files"] == files
assert normalized_args["inputs"][".".join((compat_variable.node_id, compat_variable.variable_name))] == files
def test_normalize_legacy_sys_files_args_maps_userinput_files_to_top_level_files_without_warning():
files = [{"id": "file-1"}]
normalized_args, compat_variable = normalize_legacy_sys_files_args(
graph={"nodes": []},
args={"inputs": {_LEGACY_ALIAS_INPUT_KEY: files}},
)
assert compat_variable is None
assert normalized_args["files"] == files
assert normalized_args["inputs"] == {_LEGACY_ALIAS_INPUT_KEY: files}
def test_normalize_legacy_sys_files_args_prefers_userinput_files_over_legacy_files():
legacy_files = [{"id": "legacy-file"}]
userinput_files = [{"id": "userinput-file"}]
normalized_args, compat_variable = normalize_legacy_sys_files_args(
graph={"nodes": [{"data": {"type": "answer", "answer": _LEGACY_ALIAS_TEMPLATE}}]},
args={
"inputs": {_LEGACY_ALIAS_INPUT_KEY: userinput_files},
"files": legacy_files,
},
)
assert compat_variable is None
assert normalized_args["files"] == userinput_files
def test_attach_legacy_sys_files_warning_wraps_stream_and_closes_source():
class CloseableStream:
closed = False
def __iter__(self):
yield "data: payload\n\n"
def close(self):
self.closed = True
stream = CloseableStream()
wrapped = attach_legacy_sys_files_warning(
stream,
LegacySysFilesCompatVariable(node_id=_LEGACY_ALIAS_NODE_ID, variable_name=_LEGACY_VARIABLE_NAME),
)
chunks = list(wrapped)
assert "warning" in chunks[0]
assert chunks[1] == "data: payload\n\n"
assert stream.closed
@@ -1,7 +1,6 @@
from types import SimpleNamespace
from core.workflow.system_variables import (
build_bootstrap_variables,
build_system_variables,
default_system_variables,
get_node_creation_preload_selectors,
@@ -57,25 +56,6 @@ def test_build_system_variables_preserves_file_values():
assert system_values["files"] == [file]
def test_build_bootstrap_variables_adds_userinput_files_alias():
file = File(
file_type=FileType.DOCUMENT,
transfer_method=FileTransferMethod.LOCAL_FILE,
related_id="file-id",
filename="test.txt",
extension=".txt",
mime_type="text/plain",
size=1,
storage_key="storage-key",
)
bootstrap_variables = build_bootstrap_variables(system_variables=build_system_variables(files=[file]))
file_variables_by_selector = {tuple(variable.selector): variable for variable in bootstrap_variables}
assert file_variables_by_selector[("sys", "files")].value == [file]
assert file_variables_by_selector[("userinput", "files")].value == [file]
def test_default_system_variables_generates_workflow_run_id():
system_variables = default_system_variables()
system_values = system_variables_to_mapping(system_variables)
+45 -1
View File
@@ -2,7 +2,7 @@ from datetime import datetime
import pytest
from libs.helper import OptionalTimestampField, email, escape_like_pattern, extract_tenant_id
from libs.helper import OptionalTimestampField, alphanumeric, email, escape_like_pattern, extract_tenant_id
from models.account import Account
from models.model import EndUser
@@ -153,3 +153,47 @@ class TestEmailValidator:
def test_invalid_email_rejected(self):
with pytest.raises(ValueError, match="not a valid email"):
email("not-an-email")
class TestAlphanumericValidator:
"""Tests for the alphanumeric() validator — regression for #39666."""
def test_valid_alphanumeric_accepted(self):
assert alphanumeric("tool_name") == "tool_name"
assert alphanumeric("Tool123") == "Tool123"
assert alphanumeric("_underscore_start") == "_underscore_start"
assert alphanumeric("a") == "a"
def test_trailing_newline_rejected(self):
# re.match with $ accepts a trailing \n in Python; re.fullmatch does not.
# This was the pre-fix behaviour: alphanumeric("tool\n") returned "tool\n".
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
alphanumeric("tool_name\n")
def test_trailing_carriage_return_rejected(self):
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
alphanumeric("tool_name\r")
def test_trailing_crlf_rejected(self):
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
alphanumeric("tool_name\r\n")
def test_leading_newline_rejected(self):
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
alphanumeric("\ntool_name")
def test_embedded_whitespace_rejected(self):
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
alphanumeric("tool name")
def test_empty_string_rejected(self):
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
alphanumeric("")
def test_special_characters_rejected(self):
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
alphanumeric("tool-name")
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
alphanumeric("tool.name")
with pytest.raises(ValueError, match="not a valid alphanumeric value"):
alphanumeric("tool/name")
@@ -18,11 +18,6 @@ from models.workflow import (
is_system_variable_editable,
)
_LEGACY_FILE_TEMPLATE = "{{#" + ".".join(("sys", "files")) + "#}}"
_LEGACY_FILE_SELECTOR = ["sys", "files"]
_USER_INPUT_FILE_TEMPLATE = "{{#" + ".".join(("userinput", "files")) + "#}}"
_USER_INPUT_FILE_SELECTOR = ["userinput", "files"]
def test_environment_variables():
# tenant_id context variable removed - using current_user.current_tenant_id directly
@@ -250,144 +245,6 @@ class TestIsSystemVariableEditable:
assert is_system_variable_editable("invalid_or_new_system_variable") == False
class TestWorkflowLegacySysFilesCompatibility:
def _make_workflow(self, graph: dict, *, features: dict | None = None) -> Workflow:
return Workflow(
tenant_id="tenant_id",
app_id="app_id",
type="workflow",
version="draft",
graph=json.dumps(graph),
features=json.dumps(features or {}),
created_by="account_id",
environment_variables=[],
conversation_variables=[],
)
def test_graph_dict_rewrites_legacy_sys_files_references_to_userinput_files(self):
workflow = self._make_workflow(
{
"nodes": [
{
"id": "start",
"data": {
"type": "start",
"title": "Start",
"variables": [],
},
},
{
"id": "llm",
"data": {
"type": "llm",
"prompt_template": [{"role": "user", "text": f"files: {_LEGACY_FILE_TEMPLATE}"}],
"context": {"variable_selector": _LEGACY_FILE_SELECTOR},
},
},
],
"edges": [],
}
)
stored_graph_before_read = workflow.graph
graph = workflow.graph_dict
start_node = next(node for node in graph["nodes"] if node["id"] == "start")
llm_node = next(node for node in graph["nodes"] if node["id"] == "llm")
assert start_node["data"]["variables"] == []
assert llm_node["data"]["prompt_template"][0]["text"] == f"files: {_USER_INPUT_FILE_TEMPLATE}"
assert llm_node["data"]["context"]["variable_selector"] == _USER_INPUT_FILE_SELECTOR
assert workflow.graph == stored_graph_before_read
def test_migrate_legacy_sys_files_graph_in_place_updates_stored_graph(self):
workflow = self._make_workflow(
{
"nodes": [
{"id": "answer", "data": {"type": "answer", "answer": _LEGACY_FILE_TEMPLATE}},
],
"edges": [],
}
)
assert workflow.migrate_legacy_sys_files_graph_in_place()
assert _LEGACY_FILE_TEMPLATE not in workflow.graph
assert _USER_INPUT_FILE_TEMPLATE in workflow.graph
def test_graph_dict_preserves_existing_start_variables_when_migrating_legacy_sys_files(self):
workflow = self._make_workflow(
{
"nodes": [
{
"id": "start",
"data": {
"type": "start",
"title": "Start",
"variables": [
{"variable": "sys_files", "label": "Existing", "type": "text-input"},
],
},
},
{
"id": "answer",
"data": {
"type": "answer",
"answer": _LEGACY_FILE_TEMPLATE,
},
},
],
"edges": [],
}
)
graph = workflow.graph_dict
start_node = next(node for node in graph["nodes"] if node["id"] == "start")
answer_node = next(node for node in graph["nodes"] if node["id"] == "answer")
assert [variable["variable"] for variable in start_node["data"]["variables"]] == ["sys_files"]
assert answer_node["data"]["answer"] == _USER_INPUT_FILE_TEMPLATE
def test_graph_dict_leaves_userinput_files_references_unchanged(self):
workflow = self._make_workflow(
{
"nodes": [
{
"id": "start",
"data": {
"type": "start",
"title": "Start",
"variables": [],
},
},
{
"id": "answer",
"data": {
"type": "answer",
"answer": _USER_INPUT_FILE_TEMPLATE,
},
},
],
"edges": [],
},
features={
"file_upload": {
"enabled": True,
"allowed_file_upload_methods": ["remote_url"],
"allowed_file_types": ["document", "custom"],
"allowed_file_extensions": [".pdf"],
"number_limits": 8,
}
},
)
graph = workflow.graph_dict
start_node = next(node for node in graph["nodes"] if node["id"] == "start")
assert start_node["data"]["variables"] == []
assert json.loads(workflow.graph) == graph
class TestWorkflowDraftVariableGetValue:
def test_get_value_by_case(self):
@dataclasses.dataclass
@@ -13,7 +13,7 @@ from services.agent import skill_package_service as skill_package_service_module
from services.agent.skill_package_service import NormalizedSkillPackage, SkillPackageError, SkillPackageService
_SKILL_MD = """---
name: PDF Toolkit
name: pdf-toolkit
description: Tools for working with PDF files.
---
@@ -43,7 +43,7 @@ def _archive_members(content: bytes) -> list[str]:
def test_valid_skill_normalizes_manifest():
manifest = _normalize({"SKILL.md": _SKILL_MD.encode(), "scripts/run.py": b"print('hi')\n"}).manifest
assert manifest.name == "PDF Toolkit"
assert manifest.name == "pdf-toolkit"
assert manifest.description == "Tools for working with PDF files."
assert manifest.entry_path == "SKILL.md"
assert set(manifest.files) == {"SKILL.md", "scripts/run.py"}
@@ -51,10 +51,10 @@ def test_valid_skill_normalizes_manifest():
assert len(manifest.hash) == 64
def test_name_falls_back_to_heading_without_frontmatter():
manifest = _normalize({"SKILL.md": b"# Heading Name\n\nbody"}).manifest
assert manifest.name == "Heading Name"
assert manifest.description == ""
def test_name_and_description_are_required_in_frontmatter():
with pytest.raises(SkillPackageError) as exc_info:
_normalize({"SKILL.md": b"# heading-name\n\nbody"})
assert exc_info.value.code == "missing_skill_name"
def test_shallowest_skill_md_preferred_during_normalization():
@@ -155,7 +155,18 @@ def test_validate_and_normalize_strips_deeper_selected_skill_root():
({"README.md": b"x"}, "skill.zip", "missing_skill_md"),
({"SKILL.md": _SKILL_MD.encode()}, "skill.tar", "unsupported_extension"),
({"SKILL.md": b""}, "skill.zip", "empty_skill_md"),
({"SKILL.md": b"no name here"}, "skill.zip", "missing_skill_name"),
({"SKILL.md": b"---\ndescription: valid\n---\n# no name here"}, "skill.zip", "missing_skill_name"),
({"SKILL.md": b"---\nname: pdf-toolkit\n---\n# no description"}, "skill.zip", "missing_skill_description"),
(
{"SKILL.md": b"---\nname: PDF Toolkit\ndescription: valid\n---\n# invalid name"},
"skill.zip",
"invalid_skill_name",
),
(
{"SKILL.md": f"---\nname: pdf-toolkit\ndescription: {'x' * 1025}\n---\n# long".encode()},
"skill.zip",
"invalid_skill_description",
),
({"SKILL.md": b"\xff\xfenot utf8"}, "skill.zip", "skill_md_not_utf8"),
],
)
@@ -224,10 +235,10 @@ def test_bad_frontmatter_yaml_rejected():
assert exc_info.value.code == "invalid_frontmatter"
def test_unterminated_frontmatter_falls_back_to_heading():
# leading '---' with no closing fence -> no frontmatter, use the heading
manifest = _normalize({"SKILL.md": b"---\n# Heading Wins\nbody"}).manifest
assert manifest.name == "Heading Wins"
def test_unterminated_frontmatter_rejected():
with pytest.raises(SkillPackageError) as exc_info:
_normalize({"SKILL.md": b"---\n# heading-wins\nbody"})
assert exc_info.value.code == "missing_skill_name"
def test_validate_and_normalize_rejects_files_outside_selected_skill_root():
@@ -20,7 +20,7 @@ _AGENT_ID = "22222222-2222-2222-2222-222222222222"
_USER_ID = "33333333-3333-3333-3333-333333333333"
_SKILL_MD = b"""---
name: PDF Toolkit
name: pdf-toolkit
description: Work with PDFs.
---
@@ -121,7 +121,7 @@ def test_standardize_creates_drive_owned_toolfiles_and_commits_archive_manifest(
assert skill_row.is_skill is True
assert skill_row.skill_metadata is not None
skill_metadata = DriveSkillMetadata.model_validate_json(skill_row.skill_metadata)
assert skill_metadata.name == "PDF Toolkit"
assert skill_metadata.name == "pdf-toolkit"
assert skill_metadata.manifest_files == ["SKILL.md", "scripts/run.py"]
assert archive_row.file_kind == AgentDriveFileKind.TOOL_FILE
assert archive_row.file_id == archive_tool_file.id
@@ -132,7 +132,7 @@ def test_standardize_creates_drive_owned_toolfiles_and_commits_archive_manifest(
# The returned upload response carries only the drive-derived fields the UI needs.
skill = result["skill"]
assert skill["path"] == "pdf-toolkit"
assert skill["name"] == "PDF Toolkit"
assert skill["name"] == "pdf-toolkit"
assert skill["archive_key"] == "pdf-toolkit/.DIFY-SKILL-FULL.zip"
assert skill["skill_md_key"] == "pdf-toolkit/SKILL.md"
assert result["manifest"]["entry_path"] == "SKILL.md"
@@ -10,14 +10,19 @@ import io
import json
import logging
import zipfile
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import datetime
from unittest.mock import Mock, create_autospec, patch
from unittest.mock import Mock, patch
import pytest
from pydantic import ValidationError
from sqlalchemy import Column, Integer, MetaData, String, Table
from sqlalchemy import Column, Engine, Integer, MetaData, String, Table, delete, event, func, select
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from sqlalchemy.orm import Session, sessionmaker
from libs.archive_storage import ArchiveStorageNotConfiguredError
from models.enums import CreatorUserRole
from models.trigger import WorkflowTriggerLog
from models.workflow import (
WorkflowAppLog,
@@ -28,6 +33,7 @@ from models.workflow import (
WorkflowPauseReason,
WorkflowRun,
)
from services.retention.workflow_run import restore_archived_workflow_run as restore_module
from services.retention.workflow_run.restore_archived_workflow_run import (
SCHEMA_MAPPERS,
TABLE_MODELS,
@@ -36,24 +42,49 @@ from services.retention.workflow_run.restore_archived_workflow_run import (
)
@dataclass(frozen=True)
class Database:
"""Explicit SQLite engine, caller session, and real service-owned session factory."""
engine: Engine
session: Session
session_maker: sessionmaker[Session]
@pytest.fixture
def database(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator[Database]:
WorkflowRun.metadata.create_all(
sqlite_engine,
tables=[WorkflowRun.__table__, WorkflowAppLog.__table__, WorkflowArchiveLog.__table__],
)
session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
with session_maker() as session:
database = Database(engine=sqlite_engine, session=session, session_maker=session_maker)
monkeypatch.setattr(restore_module, "db", database)
# Production constructs PostgreSQL's equivalent statement; SQLite's
# dialect keeps the conflict behavior executable in these tests.
monkeypatch.setattr(restore_module, "pg_insert", sqlite_insert)
yield database
class WorkflowRunRestoreTestDataFactory:
"""
Factory for creating test data and mock objects.
Factory for creating persisted-model-compatible test data.
Provides reusable methods to create consistent mock objects for testing
workflow run restore operations.
"""
@staticmethod
def create_workflow_run_mock(
def create_workflow_run(
run_id: str = "run-123",
tenant_id: str = "tenant-123",
app_id: str = "app-123",
created_at: datetime | None = None,
**kwargs,
) -> Mock:
) -> WorkflowRun:
"""
Create a mock WorkflowRun object.
Create a concrete WorkflowRun object.
Args:
run_id: Unique identifier for the workflow run
@@ -63,27 +94,44 @@ class WorkflowRunRestoreTestDataFactory:
**kwargs: Additional attributes to set on the mock
Returns:
Mock WorkflowRun object with specified attributes
WorkflowRun object with specified attributes
"""
run = create_autospec(WorkflowRun, instance=True)
run.id = run_id
run.tenant_id = tenant_id
run.app_id = app_id
run.created_at = created_at or datetime(2024, 1, 1, 12, 0, 0)
for key, value in kwargs.items():
setattr(run, key, value)
attrs = {
"id": run_id,
"tenant_id": tenant_id,
"app_id": app_id,
"workflow_id": "workflow-123",
"type": "workflow",
"triggered_from": "app-run",
"version": "1",
"graph": None,
"inputs": None,
"status": "succeeded",
"outputs": "{}",
"error": None,
"elapsed_time": 0,
"total_tokens": 0,
"total_steps": 0,
"created_by_role": CreatorUserRole.ACCOUNT,
"created_by": "user-123",
"created_at": created_at or datetime(2024, 1, 1, 12, 0, 0),
"finished_at": None,
"exceptions_count": 0,
}
attrs.update(kwargs)
run = WorkflowRun(**attrs)
return run
@staticmethod
def create_workflow_archive_log_mock(
def create_workflow_archive_log(
run_id: str = "run-123",
tenant_id: str = "tenant-123",
app_id: str = "app-123",
created_at: datetime | None = None,
**kwargs,
) -> Mock:
) -> WorkflowArchiveLog:
"""
Create a mock WorkflowArchiveLog object.
Create a concrete WorkflowArchiveLog object.
Args:
run_id: Unique identifier for the workflow run
@@ -93,16 +141,32 @@ class WorkflowRunRestoreTestDataFactory:
**kwargs: Additional attributes to set on the mock
Returns:
Mock WorkflowArchiveLog object with specified attributes
WorkflowArchiveLog object with specified attributes
"""
archive_log = create_autospec(WorkflowArchiveLog, instance=True)
archive_log.workflow_run_id = run_id
archive_log.tenant_id = tenant_id
archive_log.app_id = app_id
archive_log.run_created_at = created_at or datetime(2024, 1, 1, 12, 0, 0)
for key, value in kwargs.items():
setattr(archive_log, key, value)
return archive_log
attrs = {
"tenant_id": tenant_id,
"app_id": app_id,
"workflow_id": "workflow-123",
"workflow_run_id": run_id,
"created_by_role": CreatorUserRole.ACCOUNT,
"created_by": "user-123",
"log_id": None,
"log_created_at": None,
"log_created_from": None,
"run_version": "1",
"run_status": "succeeded",
"run_triggered_from": "app-run",
"run_error": None,
"run_elapsed_time": 0,
"run_total_tokens": 0,
"run_total_steps": 0,
"run_created_at": created_at or datetime(2024, 1, 1, 12, 0, 0),
"run_finished_at": None,
"run_exceptions_count": 0,
"trigger_metadata": None,
}
attrs.update(kwargs)
return WorkflowArchiveLog(**attrs)
@staticmethod
def create_archive_zip_mock(
@@ -137,7 +201,7 @@ class WorkflowRunRestoreTestDataFactory:
"app_id": "app-123",
"workflow_id": "workflow-123",
"type": "workflow",
"triggered_from": "app",
"triggered_from": "app-run",
"version": "1",
"status": "succeeded",
"created_by_role": "account",
@@ -151,7 +215,7 @@ class WorkflowRunRestoreTestDataFactory:
"app_id": "app-123",
"workflow_id": "workflow-123",
"workflow_run_id": "run-123",
"created_from": "app",
"created_from": "service-api",
"created_by_role": "account",
"created_by": "user-123",
},
@@ -161,7 +225,7 @@ class WorkflowRunRestoreTestDataFactory:
"app_id": "app-123",
"workflow_id": "workflow-123",
"workflow_run_id": "run-123",
"created_from": "app",
"created_from": "service-api",
"created_by_role": "account",
"created_by": "user-123",
},
@@ -225,14 +289,10 @@ class TestGetWorkflowRunRepo:
"""Tests for WorkflowRunRestore._get_workflow_run_repo method."""
@patch("services.retention.workflow_run.restore_archived_workflow_run.DifyAPIRepositoryFactory")
@patch("services.retention.workflow_run.restore_archived_workflow_run.sessionmaker")
@patch("services.retention.workflow_run.restore_archived_workflow_run.db")
def test_first_call_creates_repo(self, mock_db, mock_sessionmaker, mock_factory):
def test_first_call_creates_repo(self, mock_factory, database: Database):
"""First call should create and cache repository."""
restore = WorkflowRunRestore()
mock_session = Mock()
mock_sessionmaker.return_value = mock_session
mock_repo = Mock()
mock_factory.create_api_workflow_run_repository.return_value = mock_repo
@@ -240,8 +300,9 @@ class TestGetWorkflowRunRepo:
assert result is mock_repo
assert restore.workflow_run_repo is mock_repo
mock_sessionmaker.assert_called_once_with(bind=mock_db.engine, expire_on_commit=False)
mock_factory.create_api_workflow_run_repository.assert_called_once_with(mock_session)
session_maker = mock_factory.create_api_workflow_run_repository.call_args.args[0]
assert isinstance(session_maker, sessionmaker)
assert session_maker.kw["bind"] is database.engine
def test_cached_repo_returned(self):
"""Subsequent calls should return cached repository."""
@@ -492,47 +553,27 @@ class TestGetModelColumnInfo:
class TestRestoreTableRecords:
"""Tests for WorkflowRunRestore._restore_table_records method."""
@patch("services.retention.workflow_run.restore_archived_workflow_run.TABLE_MODELS")
def test_unknown_table_returns_zero(self, mock_table_models, caplog: pytest.LogCaptureFixture):
def test_unknown_table_returns_zero(self, database: Database, caplog: pytest.LogCaptureFixture):
"""Should return 0 for unknown table."""
restore = WorkflowRunRestore()
mock_table_models.get.return_value = None
mock_session = Mock()
records = [{"id": "test"}]
caplog.set_level(logging.WARNING, logger="services.retention.workflow_run.restore_archived_workflow_run")
result = restore._restore_table_records(mock_session, "unknown_table", records, schema_version="1.0")
result = restore._restore_table_records(database.session, "unknown_table", records, schema_version="1.0")
assert result == 0
assert "Unknown table: unknown_table" in caplog.messages
def test_empty_records_returns_zero(self):
def test_empty_records_returns_zero(self, database: Database):
"""Should return 0 for empty records list."""
restore = WorkflowRunRestore()
mock_session = Mock()
result = restore._restore_table_records(mock_session, "workflow_runs", [], schema_version="1.0")
result = restore._restore_table_records(database.session, "workflow_runs", [], schema_version="1.0")
assert result == 0
@patch("services.retention.workflow_run.restore_archived_workflow_run.pg_insert")
@patch("services.retention.workflow_run.restore_archived_workflow_run.cast")
def test_successful_restore(self, mock_cast, mock_pg_insert):
def test_successful_restore(self, database: Database):
"""Should successfully restore records."""
restore = WorkflowRunRestore()
# Mock session and execution
mock_session = Mock()
mock_result = Mock()
mock_result.rowcount = 2
mock_session.execute.return_value = mock_result
mock_cast.return_value = mock_result
# Mock insert statement
mock_stmt = Mock()
mock_stmt.on_conflict_do_nothing.return_value = mock_stmt
mock_pg_insert.return_value = mock_stmt
records = [
{
"id": "test1",
@@ -540,7 +581,7 @@ class TestRestoreTableRecords:
"app_id": "app-123",
"workflow_id": "workflow-123",
"type": "workflow",
"triggered_from": "app",
"triggered_from": "app-run",
"version": "1",
"status": "succeeded",
"created_by_role": "account",
@@ -552,7 +593,7 @@ class TestRestoreTableRecords:
"app_id": "app-123",
"workflow_id": "workflow-123",
"type": "workflow",
"triggered_from": "app",
"triggered_from": "app-run",
"version": "1",
"status": "succeeded",
"created_by_role": "account",
@@ -560,38 +601,20 @@ class TestRestoreTableRecords:
},
]
result = restore._restore_table_records(mock_session, "workflow_runs", records, schema_version="1.0")
result = restore._restore_table_records(database.session, "workflow_runs", records, schema_version="1.0")
assert result == 2
mock_session.execute.assert_called_once()
assert database.session.scalar(select(func.count(WorkflowRun.id))) == 2
assert restore._restore_table_records(database.session, "workflow_runs", records, schema_version="1.0") == 0
def test_missing_required_columns_raises_error(self):
def test_missing_required_columns_raises_error(self, database: Database):
"""Should raise ValueError for missing required columns."""
restore = WorkflowRunRestore()
mock_session = Mock()
# Use a dedicated mock model to isolate required-column validation behavior.
mock_model = Mock()
records = [{"id": "test"}]
# Mock a required column
required_column = Mock()
required_column.key = "required_field"
required_column.nullable = False
required_column.default = None
required_column.server_default = None
required_column.autoincrement = False
required_column.type = Mock()
# Mock the __table__ attribute properly
mock_table = Mock()
mock_table.columns = [required_column]
mock_model.__table__ = mock_table
records = [{"name": "test"}] # Missing required 'required_field'
with patch.dict(TABLE_MODELS, {"test_table": mock_model}):
with pytest.raises(ValueError, match="Missing required columns for test_table"):
restore._restore_table_records(mock_session, "test_table", records, schema_version="1.0")
with pytest.raises(ValueError, match="Missing required columns for workflow_runs"):
restore._restore_table_records(database.session, "workflow_runs", records, schema_version="1.0")
# ---------------------------------------------------------------------------
@@ -603,38 +626,38 @@ class TestRestoreFromRun:
"""Tests for WorkflowRunRestore._restore_from_run method."""
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
def test_archive_storage_not_configured(self, mock_get_storage):
def test_archive_storage_not_configured(self, mock_get_storage, database: Database):
"""Should handle ArchiveStorageNotConfiguredError."""
restore = WorkflowRunRestore()
mock_get_storage.side_effect = ArchiveStorageNotConfiguredError("Storage not configured")
run = WorkflowRunRestoreTestDataFactory.create_workflow_run_mock()
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
result = restore._restore_from_run(run, session_maker=lambda: Mock())
result = restore._restore_from_run(run, session_maker=database.session_maker)
assert result.success is False
assert "Storage not configured" in result.error
assert result.elapsed_time > 0
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
def test_archive_bundle_not_found(self, mock_get_storage):
def test_archive_bundle_not_found(self, mock_get_storage, database: Database):
"""Should handle FileNotFoundError when archive bundle is missing."""
restore = WorkflowRunRestore()
mock_storage = Mock()
mock_storage.get_object.side_effect = FileNotFoundError("Bundle not found")
mock_get_storage.return_value = mock_storage
run = WorkflowRunRestoreTestDataFactory.create_workflow_run_mock()
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
result = restore._restore_from_run(run, session_maker=lambda: Mock())
result = restore._restore_from_run(run, session_maker=database.session_maker)
assert result.success is False
assert "Archive bundle not found" in result.error
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
def test_dry_run_mode(self, mock_get_storage):
def test_dry_run_mode(self, mock_get_storage, database: Database):
"""Should handle dry run mode correctly."""
restore = WorkflowRunRestore(dry_run=True)
@@ -644,23 +667,16 @@ class TestRestoreFromRun:
mock_storage.get_object.return_value = archive_data
mock_get_storage.return_value = mock_storage
run = WorkflowRunRestoreTestDataFactory.create_workflow_run_mock()
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
# Create a proper mock session with context manager support
mock_session = Mock()
mock_session.__enter__ = Mock(return_value=mock_session)
mock_session.__exit__ = Mock(return_value=None)
result = restore._restore_from_run(run, session_maker=lambda: mock_session)
result = restore._restore_from_run(run, session_maker=database.session_maker)
assert result.success is True
assert result.restored_counts["workflow_runs"] == 1
assert result.restored_counts["workflow_app_logs"] == 2
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
@patch("services.retention.workflow_run.restore_archived_workflow_run.pg_insert")
@patch("services.retention.workflow_run.restore_archived_workflow_run.cast")
def test_successful_restore(self, mock_cast, mock_pg_insert, mock_get_storage):
def test_successful_restore(self, mock_get_storage, database: Database):
"""Should successfully restore from archive."""
restore = WorkflowRunRestore()
@@ -670,53 +686,57 @@ class TestRestoreFromRun:
mock_storage.get_object.return_value = archive_data
mock_get_storage.return_value = mock_storage
# Mock session with context manager support
mock_session = Mock()
mock_session.__enter__ = Mock(return_value=mock_session)
mock_session.__exit__ = Mock(return_value=None)
def session_maker():
return mock_session
# Mock database execution to return integer counts
mock_result_workflow_runs = Mock()
mock_result_workflow_runs.rowcount = 1
mock_result_app_logs = Mock()
mock_result_app_logs.rowcount = 2
# Configure session.execute to return different results based on the table
def mock_execute(stmt):
if "workflow_runs" in str(stmt):
return mock_result_workflow_runs
else:
return mock_result_app_logs
mock_session.execute.side_effect = mock_execute
mock_cast.return_value = mock_result_workflow_runs
# Mock insert statement
mock_stmt = Mock()
mock_stmt.on_conflict_do_nothing.return_value = mock_stmt
mock_pg_insert.return_value = mock_stmt
run = WorkflowRunRestoreTestDataFactory.create_workflow_run_mock()
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log()
database.session.add(archive_log)
database.session.commit()
# Mock repository methods
with patch.object(restore, "_get_workflow_run_repo") as mock_get_repo:
mock_repo = Mock()
mock_repo.delete_archive_log_by_run_id.side_effect = lambda session, run_id: session.execute(
delete(WorkflowArchiveLog).where(WorkflowArchiveLog.workflow_run_id == run_id)
)
mock_get_repo.return_value = mock_repo
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
result = restore._restore_from_run(run, session_maker=session_maker)
result = restore._restore_from_run(run, session_maker=database.session_maker)
assert result.success is True
assert result.restored_counts["workflow_runs"] == 1
assert result.restored_counts["workflow_app_logs"] >= 1 # Just check it's restored
mock_session.commit.assert_called_once()
mock_repo.delete_archive_log_by_run_id.assert_called_once_with(mock_session, run.id)
assert result.restored_counts["workflow_app_logs"] == 2
database.session.expire_all()
assert database.session.scalar(select(func.count(WorkflowRun.id))) == 1
assert database.session.scalar(select(func.count(WorkflowAppLog.id))) == 2
assert database.session.scalar(select(func.count(WorkflowArchiveLog.id))) == 0
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
def test_invalid_archive_bundle(self, mock_get_storage):
def test_insert_failure_rolls_back_all_tables(self, mock_get_storage, database: Database):
"""A later table failure must roll back earlier restored rows."""
restore = WorkflowRunRestore()
mock_storage = Mock()
mock_storage.get_object.return_value = WorkflowRunRestoreTestDataFactory.create_archive_zip_mock()
mock_get_storage.return_value = mock_storage
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
def fail_app_log_insert(_connection, _cursor, statement, _parameters, _context, _executemany):
if statement.startswith("INSERT INTO workflow_app_logs"):
raise RuntimeError("forced app-log insert failure")
event.listen(database.engine, "before_cursor_execute", fail_app_log_insert)
try:
with patch("services.retention.workflow_run.restore_archived_workflow_run.click"):
result = restore._restore_from_run(run, session_maker=database.session_maker)
finally:
event.remove(database.engine, "before_cursor_execute", fail_app_log_insert)
assert result.success is False
assert result.error == "forced app-log insert failure"
assert database.session.scalar(select(func.count(WorkflowRun.id))) == 0
assert database.session.scalar(select(func.count(WorkflowAppLog.id))) == 0
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
def test_invalid_archive_bundle(self, mock_get_storage, database: Database):
"""Should handle invalid archive bundle."""
restore = WorkflowRunRestore()
@@ -725,22 +745,17 @@ class TestRestoreFromRun:
mock_storage.get_object.return_value = b"invalid zip data"
mock_get_storage.return_value = mock_storage
run = WorkflowRunRestoreTestDataFactory.create_workflow_run_mock()
# Create proper mock session
mock_session = Mock()
mock_session.__enter__ = Mock(return_value=mock_session)
mock_session.__exit__ = Mock(return_value=None)
run = WorkflowRunRestoreTestDataFactory.create_workflow_run()
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
result = restore._restore_from_run(run, session_maker=lambda: mock_session)
result = restore._restore_from_run(run, session_maker=database.session_maker)
assert result.success is False
# The error message comes from zipfile.BadZipFile which says "File is not a zip file"
assert "File is not a zip file" in result.error
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
def test_workflow_archive_log_input(self, mock_get_storage):
def test_workflow_archive_log_input(self, mock_get_storage, database: Database):
"""Should handle WorkflowArchiveLog input correctly."""
restore = WorkflowRunRestore(dry_run=True)
@@ -750,14 +765,11 @@ class TestRestoreFromRun:
mock_storage.get_object.return_value = archive_data
mock_get_storage.return_value = mock_storage
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock()
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log()
database.session.add(archive_log)
database.session.commit()
# Create proper mock session
mock_session = Mock()
mock_session.__enter__ = Mock(return_value=mock_session)
mock_session.__exit__ = Mock(return_value=None)
result = restore._restore_from_run(archive_log, session_maker=lambda: mock_session)
result = restore._restore_from_run(archive_log, session_maker=database.session_maker)
assert result.success is True
assert result.run_id == archive_log.workflow_run_id
@@ -772,39 +784,29 @@ class TestRestoreFromRun:
class TestRestoreBatch:
"""Tests for WorkflowRunRestore.restore_batch method."""
@patch("services.retention.workflow_run.restore_archived_workflow_run.sessionmaker")
def test_empty_tenant_ids_returns_empty(self, mock_sessionmaker):
def test_empty_tenant_ids_returns_empty(self, database: Database):
"""Should return empty list when tenant_ids is empty list."""
restore = WorkflowRunRestore()
# Mock db.engine to avoid SQLAlchemy issues
with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db:
mock_db.engine = Mock()
result = restore.restore_batch(
tenant_ids=[],
start_date=datetime(2024, 1, 1),
end_date=datetime(2024, 1, 2),
)
result = restore.restore_batch(
tenant_ids=[],
start_date=datetime(2024, 1, 1),
end_date=datetime(2024, 1, 2),
)
assert result == []
@patch("services.retention.workflow_run.restore_archived_workflow_run.ThreadPoolExecutor")
def test_successful_batch_restore(self, mock_executor):
def test_successful_batch_restore(self, mock_executor, database: Database):
"""Should successfully restore batch of workflow runs."""
restore = WorkflowRunRestore(workers=2)
# Mock session that supports context manager protocol
mock_session = Mock()
mock_session.__enter__ = Mock(return_value=mock_session)
mock_session.__exit__ = Mock(return_value=None)
# Mock session factory that returns context manager sessions
mock_session_factory = Mock(return_value=mock_session)
# Mock repository and archive logs
mock_repo = Mock()
archive_log1 = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock("run-1")
archive_log2 = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock("run-2")
archive_log1 = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log("run-1")
archive_log2 = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log("run-2")
database.session.add_all([archive_log1, archive_log2])
database.session.commit()
mock_repo.get_archived_logs_by_time_range.return_value = [archive_log1, archive_log2]
# Mock restore results
@@ -821,38 +823,25 @@ class TestRestoreBatch:
with patch.object(restore, "_get_workflow_run_repo", return_value=mock_repo):
with patch.object(restore, "_restore_from_run", side_effect=[result1, result2]):
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
# Mock sessionmaker and db.engine to avoid SQLAlchemy issues
with patch(
"services.retention.workflow_run.restore_archived_workflow_run.sessionmaker"
) as mock_sessionmaker:
mock_sessionmaker.return_value = mock_session_factory
with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db:
mock_db.engine = Mock()
results = restore.restore_batch(
tenant_ids=["tenant-1"],
start_date=datetime(2024, 1, 1),
end_date=datetime(2024, 1, 2),
)
results = restore.restore_batch(
tenant_ids=["tenant-1"],
start_date=datetime(2024, 1, 1),
end_date=datetime(2024, 1, 2),
)
assert len(results) == 2
assert results[0].run_id == "run-1"
assert results[1].run_id == "run-2"
@patch("services.retention.workflow_run.restore_archived_workflow_run.ThreadPoolExecutor")
def test_dry_run_batch_restore(self, mock_executor):
def test_dry_run_batch_restore(self, mock_executor, database: Database):
"""Should handle dry run mode for batch restore."""
restore = WorkflowRunRestore(dry_run=True)
# Mock session that supports context manager protocol
mock_session = Mock()
mock_session.__enter__ = Mock(return_value=mock_session)
mock_session.__exit__ = Mock(return_value=None)
# Mock session factory that returns context manager sessions
mock_session_factory = Mock(return_value=mock_session)
mock_repo = Mock()
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock()
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log()
database.session.add(archive_log)
database.session.commit()
mock_repo.get_archived_logs_by_time_range.return_value = [archive_log]
result = RestoreResult(run_id="run-1", tenant_id="tenant-1", success=True, restored_counts={"workflow_runs": 1})
@@ -867,18 +856,11 @@ class TestRestoreBatch:
with patch.object(restore, "_get_workflow_run_repo", return_value=mock_repo):
with patch.object(restore, "_restore_from_run", return_value=result):
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
# Mock sessionmaker and db.engine to avoid SQLAlchemy issues
with patch(
"services.retention.workflow_run.restore_archived_workflow_run.sessionmaker"
) as mock_sessionmaker:
mock_sessionmaker.return_value = mock_session_factory
with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db:
mock_db.engine = Mock()
results = restore.restore_batch(
tenant_ids=["tenant-1"],
start_date=datetime(2024, 1, 1),
end_date=datetime(2024, 1, 2),
)
results = restore.restore_batch(
tenant_ids=["tenant-1"],
start_date=datetime(2024, 1, 1),
end_date=datetime(2024, 1, 2),
)
assert len(results) == 1
assert results[0].success is True
@@ -907,16 +889,14 @@ class TestRestoreByRunId:
assert "not found" in result.error
assert result.run_id == "nonexistent-run"
@patch("services.retention.workflow_run.restore_archived_workflow_run.sessionmaker")
def test_successful_restore_by_id(self, mock_sessionmaker):
def test_successful_restore_by_id(self, database: Database):
"""Should successfully restore by run ID."""
restore = WorkflowRunRestore()
mock_session = Mock()
mock_sessionmaker.return_value = mock_session
mock_repo = Mock()
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock()
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log()
database.session.add(archive_log)
database.session.commit()
mock_repo.get_archived_log_by_run_id.return_value = archive_log
result = RestoreResult(run_id="run-1", tenant_id="tenant-1", success=True, restored_counts={})
@@ -924,24 +904,19 @@ class TestRestoreByRunId:
with patch.object(restore, "_get_workflow_run_repo", return_value=mock_repo):
with patch.object(restore, "_restore_from_run", return_value=result):
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
# Mock db.engine to avoid SQLAlchemy issues
with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db:
mock_db.engine = Mock()
actual_result = restore.restore_by_run_id("run-1")
actual_result = restore.restore_by_run_id("run-1")
assert actual_result.success is True
assert actual_result.run_id == "run-1"
@patch("services.retention.workflow_run.restore_archived_workflow_run.sessionmaker")
def test_dry_run_restore_by_id(self, mock_sessionmaker):
def test_dry_run_restore_by_id(self, database: Database):
"""Should handle dry run mode for restore by ID."""
restore = WorkflowRunRestore(dry_run=True)
mock_session = Mock()
mock_sessionmaker.return_value = mock_session
mock_repo = Mock()
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock()
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log()
database.session.add(archive_log)
database.session.commit()
mock_repo.get_archived_log_by_run_id.return_value = archive_log
result = RestoreResult(run_id="run-1", tenant_id="tenant-1", success=True, restored_counts={"workflow_runs": 1})
@@ -949,10 +924,7 @@ class TestRestoreByRunId:
with patch.object(restore, "_get_workflow_run_repo", return_value=mock_repo):
with patch.object(restore, "_restore_from_run", return_value=result):
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
# Mock db.engine to avoid SQLAlchemy issues
with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db:
mock_db.engine = Mock()
actual_result = restore.restore_by_run_id("run-1")
actual_result = restore.restore_by_run_id("run-1")
assert actual_result.success is True
assert actual_result.run_id == "run-1"
@@ -1038,8 +1010,7 @@ class TestIntegration:
"""Integration tests combining multiple components."""
@patch("services.retention.workflow_run.restore_archived_workflow_run.get_archive_storage")
@patch("services.retention.workflow_run.restore_archived_workflow_run.ThreadPoolExecutor")
def test_full_restore_flow(self, mock_executor, mock_get_storage):
def test_full_restore_flow(self, mock_get_storage, database: Database):
"""Test complete restore flow with all components."""
restore = WorkflowRunRestore(workers=1)
@@ -1059,7 +1030,7 @@ class TestIntegration:
"app_id": "app-123",
"workflow_id": "workflow-123",
"type": "workflow",
"triggered_from": "app",
"triggered_from": "app-run",
"version": "1",
"status": "succeeded",
"created_by_role": "account",
@@ -1072,48 +1043,20 @@ class TestIntegration:
mock_storage.get_object.return_value = archive_data
mock_get_storage.return_value = mock_storage
# Mock session that supports context manager protocol
mock_session = Mock()
mock_session.__enter__ = Mock(return_value=mock_session)
mock_session.__exit__ = Mock(return_value=None)
# Mock session factory that returns context manager sessions
mock_session_factory = Mock(return_value=mock_session)
mock_result = Mock()
mock_result.rowcount = 1
mock_session.execute.return_value = mock_result
# Mock repository
mock_repo = Mock()
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log_mock()
archive_log = WorkflowRunRestoreTestDataFactory.create_workflow_archive_log()
database.session.add(archive_log)
database.session.commit()
mock_repo.get_archived_log_by_run_id.return_value = archive_log
# Mock ThreadPoolExecutor (not actually used in restore_by_run_id but needed for patch)
mock_executor_instance = Mock()
mock_executor_instance.__enter__ = Mock(return_value=mock_executor_instance)
mock_executor_instance.__exit__ = Mock(return_value=None)
mock_executor_instance.map = Mock(return_value=[])
mock_executor.return_value = mock_executor_instance
mock_repo.delete_archive_log_by_run_id.side_effect = lambda session, run_id: session.execute(
delete(WorkflowArchiveLog).where(WorkflowArchiveLog.workflow_run_id == run_id)
)
with patch.object(restore, "_get_workflow_run_repo", return_value=mock_repo):
with patch("services.retention.workflow_run.restore_archived_workflow_run.pg_insert") as mock_insert:
mock_stmt = Mock()
mock_stmt.on_conflict_do_nothing.return_value = mock_stmt
mock_insert.return_value = mock_stmt
with patch("services.retention.workflow_run.restore_archived_workflow_run.cast") as mock_cast:
mock_cast.return_value = mock_result
with patch("services.retention.workflow_run.restore_archived_workflow_run.click") as mock_click:
# Mock sessionmaker and db.engine to avoid SQLAlchemy issues
with patch(
"services.retention.workflow_run.restore_archived_workflow_run.sessionmaker"
) as mock_sessionmaker:
mock_sessionmaker.return_value = mock_session_factory
with patch("services.retention.workflow_run.restore_archived_workflow_run.db") as mock_db:
mock_db.engine = Mock()
result = restore.restore_by_run_id("run-123")
with patch("services.retention.workflow_run.restore_archived_workflow_run.click"):
result = restore.restore_by_run_id("run-123")
assert result.success is True
assert result.restored_counts.get("workflow_runs") == 1
assert database.session.scalar(select(func.count(WorkflowRun.id))) == 1
@@ -63,6 +63,7 @@ def _target(
) -> AgentConfigTarget:
agent_soul = soul or _soul()
return AgentConfigTarget(
tenant_id=TENANT,
agent_id=AGENT,
version_id=version_id,
kind=kind,
@@ -508,7 +509,9 @@ def test_manifest_uses_items_shape_without_download_urls() -> None:
),
)
manifest = AgentConfigService._manifest_for_target(target)
with patch(f"{MODULE}.SkillManagementService") as skill_management_service:
skill_management_service.return_value.list_runtime_agent_skills.return_value = []
manifest = AgentConfigService._manifest_for_target(target)
assert manifest == {
"agent_id": AGENT,
@@ -557,7 +560,9 @@ def test_manifest_preserves_missing_config_assets_and_pull_rejects_them() -> Non
target = _target(kind=AgentConfigVersionKind.DRAFT, writable=False, soul=soul)
service = AgentConfigService()
manifest = service._manifest_for_target(target)
with patch(f"{MODULE}.SkillManagementService") as skill_management_service:
skill_management_service.return_value.list_runtime_agent_skills.return_value = []
manifest = service._manifest_for_target(target)
assert manifest["skills"]["items"][0]["is_missing"] is True # type: ignore[index]
assert manifest["files"]["items"][0]["is_missing"] is True # type: ignore[index]
@@ -606,6 +611,44 @@ def test_config_asset_refs_require_file_id_unless_marked_missing() -> None:
)
def test_manifest_appends_published_workspace_skills() -> None:
target = _target(
kind=AgentConfigVersionKind.DRAFT,
writable=False,
soul=_soul(
config_skills=[
AgentConfigSkillRefConfig(name="alpha", description="Alpha skill", file_id="tool-file-1")
]
),
)
with patch(f"{MODULE}.SkillManagementService") as skill_management_service:
skill_management_service.return_value.list_runtime_agent_skills.return_value = [
{
"id": "workspace-skill-id",
"name": "beta",
"file_id": "tool-file-2",
"description": "Beta workspace skill",
"size": 123,
"hash": "sha256:beta",
"mime_type": "application/zip",
},
{
"id": "duplicate",
"name": "alpha",
"file_id": "tool-file-ignored",
"description": "Duplicate workspace skill",
"size": 456,
"hash": "sha256:ignored",
"mime_type": "application/zip",
},
]
manifest = AgentConfigService._manifest_for_target(target)
assert [item["name"] for item in manifest["skills"]["items"]] == ["alpha", "beta"]
assert manifest["skills"]["items"][1]["file_id"] == "tool-file-2"
def test_preview_skill_file_returns_text_preview() -> None:
service = AgentConfigService()
target = _target(
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,88 @@
"""Unit tests for workflow app log views and trigger metadata helpers."""
import json
import uuid
from unittest.mock import patch
import pytest
from models.enums import AppTriggerType, CreatorUserRole
from models.workflow import WorkflowAppLog, WorkflowAppLogCreatedFrom
from services.workflow_app_service import LogView, WorkflowAppService
class TestLogView:
def test_details_and_proxy_attributes(self) -> None:
log = WorkflowAppLog(
tenant_id="tenant-1",
app_id="app-1",
workflow_id="workflow-1",
workflow_run_id="run-1",
created_from=WorkflowAppLogCreatedFrom.WEB_APP,
created_by_role=CreatorUserRole.ACCOUNT,
created_by="account-1",
)
log.id = "log-1"
view = LogView(log=log, details={"trigger_metadata": {"type": "plugin"}})
assert view.details == {"trigger_metadata": {"type": "plugin"}}
assert view.id == "log-1"
class TestHandleTriggerMetadata:
def test_returns_empty_dict_when_metadata_missing(self) -> None:
assert WorkflowAppService().handle_trigger_metadata("tenant-1", None) == {}
def test_enriches_plugin_icons(self) -> None:
metadata = {
"type": AppTriggerType.TRIGGER_PLUGIN.value,
"icon_filename": "light.png",
"icon_dark_filename": "dark.png",
}
with patch(
"services.workflow_app_service.PluginService.get_plugin_icon_url",
side_effect=["https://cdn/light.png", "https://cdn/dark.png"],
) as mock_icon:
result = WorkflowAppService().handle_trigger_metadata("tenant-1", json.dumps(metadata))
assert result["icon"] == "https://cdn/light.png"
assert result["icon_dark"] == "https://cdn/dark.png"
assert mock_icon.call_count == 2
def test_non_plugin_metadata_without_icon_lookup(self) -> None:
metadata = {"type": AppTriggerType.TRIGGER_WEBHOOK.value}
with patch("services.workflow_app_service.PluginService.get_plugin_icon_url") as mock_icon:
result = WorkflowAppService().handle_trigger_metadata("tenant-1", json.dumps(metadata))
assert result["type"] == AppTriggerType.TRIGGER_WEBHOOK.value
mock_icon.assert_not_called()
class TestSafeJsonLoads:
@pytest.mark.parametrize(
("value", "expected"),
[
(None, None),
("", None),
('{"k":"v"}', {"k": "v"}),
("not-json", None),
({"raw": True}, {"raw": True}),
],
)
def test_handles_various_inputs(self, value, expected) -> None:
assert WorkflowAppService._safe_json_loads(value) == expected
class TestSafeParseUuid:
def test_returns_none_for_short_or_invalid_values(self) -> None:
assert WorkflowAppService._safe_parse_uuid("short") is None
assert WorkflowAppService._safe_parse_uuid("x" * 40) is None
def test_returns_uuid_for_valid_string(self) -> None:
raw = str(uuid.uuid4())
result = WorkflowAppService._safe_parse_uuid(raw)
assert result is not None
assert str(result) == raw
@@ -50,9 +50,6 @@ from services.workflow_service import (
_setup_variable_pool,
)
_LEGACY_FILE_TEMPLATE = "{{#" + ".".join(("sys", "files")) + "#}}"
_USER_INPUT_FILE_TEMPLATE = "{{#" + ".".join(("userinput", "files")) + "#}}"
class TestWorkflowAssociatedDataFactory:
"""
@@ -280,31 +277,6 @@ class TestWorkflowService:
assert result is workflow
def test_get_draft_workflow_persists_legacy_system_files_migration(
self, workflow_service: WorkflowService, sqlite_session: Session
):
# TODO: Remove this compatibility test after the historical workflow migration is complete.
app = TestWorkflowAssociatedDataFactory.create_app()
workflow = TestWorkflowAssociatedDataFactory.create_workflow(
graph={
"nodes": [
{"id": "start", "data": {"type": "start", "variables": []}},
{"id": "answer", "data": {"type": "answer", "answer": _LEGACY_FILE_TEMPLATE}},
],
"edges": [],
}
)
sqlite_session.add(workflow)
sqlite_session.commit()
result = workflow_service.get_draft_workflow(app, session=sqlite_session)
assert result is workflow
assert _LEGACY_FILE_TEMPLATE not in workflow.graph
assert _USER_INPUT_FILE_TEMPLATE in workflow.graph
sqlite_session.expire_all()
assert _USER_INPUT_FILE_TEMPLATE in sqlite_session.get(Workflow, workflow.id).graph
def test_get_draft_workflow_returns_none(self, workflow_service: WorkflowService, sqlite_session: Session):
"""Test get_draft_workflow returns None when no draft exists."""
app = TestWorkflowAssociatedDataFactory.create_app()
@@ -239,29 +239,6 @@ def test__convert_to_llm_node_for_chatbot_simple_chat_model(default_variables: l
assert "{{#start.text_input#}}" in node["data"]["prompt_template"][0]["text"]
def test__convert_to_llm_node_uses_userinput_files_for_vision(default_variables: list[VariableEntity]) -> None:
workflow_converter = WorkflowConverter()
graph = {"nodes": [workflow_converter._convert_to_start_node(default_variables)], "edges": []}
model_config = ModelConfigEntity(provider="openai", model="gpt-4", mode=LLMMode.CHAT.value, parameters={}, stop=[])
prompt_template = PromptTemplateEntity(
prompt_type=PromptTemplateEntity.PromptType.SIMPLE,
simple_prompt_template="Describe the upload",
)
file_upload = MagicMock()
file_upload.image_config = None
node = workflow_converter._convert_to_llm_node(
original_app_mode=AppMode.CHAT,
new_app_mode=AppMode.ADVANCED_CHAT,
model_config=model_config,
graph=graph,
prompt_template=prompt_template,
file_upload=file_upload,
)
assert node["data"]["vision"]["variable_selector"] == ["userinput", "files"]
def test__convert_to_llm_node_for_chatbot_simple_chat_model_with_empty_template(
default_variables: list[VariableEntity],
monkeypatch: pytest.MonkeyPatch,
@@ -34,9 +34,6 @@ from services.workflow_draft_variable_service import (
_model_to_insertion_dict,
)
# TODO: Remove this compatibility key after the historical workflow migration is complete.
_SYSTEM_FILE_OUTPUT_KEY = ".".join((SYSTEM_VARIABLE_NODE_ID, "files"))
SQLITE_MODELS = (Workflow, WorkflowDraftVariable, WorkflowDraftVariableFile, WorkflowNodeExecutionModel)
pytestmark = [
pytest.mark.usefixtures("sqlite_session"),
@@ -130,7 +127,7 @@ class TestDraftVariableSaver:
assert node_id == c.expected_node_id, fail_msg
assert name == c.expected_name, fail_msg
def test_build_variables_from_start_mapping_rebuilds_system_file_variable(self, sqlite_session: Session):
def test_build_variables_from_start_mapping_rebuilds_system_files(self, sqlite_session: Session):
mock_user = MagicMock(spec=Account)
mock_user.id = str(uuid.uuid4())
saver = DraftVariableSaver(
@@ -162,7 +159,7 @@ class TestDraftVariableSaver:
"services.workflow_draft_variable_service.build_file_from_stored_mapping",
return_value=rebuilt_file,
) as rebuild_file:
draft_vars = saver._build_variables_from_start_mapping({_SYSTEM_FILE_OUTPUT_KEY: [raw_file]})
draft_vars = saver._build_variables_from_start_mapping({"sys.files": [raw_file]})
sys_var = draft_vars[0]
assert sys_var.get_value().value[0] == rebuilt_file
@@ -270,7 +267,7 @@ class TestDraftVariableSaver:
def test_start_node_save_persists_sys_timestamp_and_workflow_run_id(
self, mock_batch_upsert, sqlite_session: Session
):
"""Start node should persist common system variables."""
"""Start node should persist common `sys.*` variables, not only `sys.files`."""
mock_user = MagicMock(spec=Account)
mock_user.id = "test-user-id"
mock_user.tenant_id = "test-tenant-id"
@@ -554,7 +551,7 @@ class TestWorkflowDraftVariableService:
# Create mock execution record
mock_execution = Mock(spec=WorkflowNodeExecutionModel)
mock_execution.load_full_outputs.return_value = {_SYSTEM_FILE_OUTPUT_KEY: "[]"}
mock_execution.load_full_outputs.return_value = {"sys.files": "[]"}
# Mock the repository to return the execution record
service._api_node_execution_repo = Mock()
-3
View File
@@ -157,9 +157,6 @@ ENABLE_WEBSITE_JINAREADER=true
ENABLE_WEBSITE_FIRECRAWL=true
ENABLE_WEBSITE_WATERCRAWL=true
NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX=false
# Enable preview features still in development (currently the /create and
# /refine slash commands in the "Go to Anything" command palette).
NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW=true
NEXT_PUBLIC_ENABLE_AGENT_V2=true
EXPERIMENTAL_ENABLE_VINEXT=false
+18
View File
@@ -3883,6 +3883,11 @@
"count": 2
}
},
"web/app/components/workflow-app/hooks/use-workflow-template.ts": {
"typescript/no-explicit-any": {
"count": 2
}
},
"web/app/components/workflow-app/store/workflow/workflow-slice.ts": {
"typescript/no-explicit-any": {
"count": 2
@@ -4212,6 +4217,9 @@
"web/app/components/workflow/nodes/_base/components/memory-config.tsx": {
"no-restricted-imports": {
"count": 1
},
"unicorn/prefer-number-properties": {
"count": 1
}
},
"web/app/components/workflow/nodes/_base/components/next-step/operator.tsx": {
@@ -5063,11 +5071,21 @@
"count": 8
}
},
"web/app/components/workflow/nodes/start/panel.tsx": {
"typescript/no-explicit-any": {
"count": 2
}
},
"web/app/components/workflow/nodes/start/use-config.ts": {
"typescript/no-explicit-any": {
"count": 1
}
},
"web/app/components/workflow/nodes/start/use-single-run-form-params.ts": {
"typescript/no-explicit-any": {
"count": 3
}
},
"web/app/components/workflow/nodes/template-transform/use-config.ts": {
"typescript/no-explicit-any": {
"count": 4
@@ -997,11 +997,15 @@ export const zSandboxListResponse = z.object({
* Validated metadata extracted from a Skill package.
*/
export const zSkillManifest = z.object({
description: z.string(),
description: z.string().min(1).max(1024),
entry_path: z.string(),
files: z.array(z.string()),
hash: z.string(),
name: z.string(),
name: z
.string()
.min(1)
.max(64)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
size: z.int(),
})
@@ -1373,11 +1373,15 @@ export const zAgentLogMetaResponse = z.object({
* Validated metadata extracted from a Skill package.
*/
export const zSkillManifest = z.object({
description: z.string(),
description: z.string().min(1).max(1024),
entry_path: z.string(),
files: z.array(z.string()),
hash: z.string(),
name: z.string(),
name: z
.string()
.min(1)
.max(64)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
size: z.int(),
})
@@ -20,7 +20,7 @@ export type TagBindingRemovePayload = {
type: TagType
}
export type TagType = 'app' | 'knowledge' | 'snippet'
export type TagType = 'app' | 'knowledge' | 'skill' | 'snippet'
export type PostTagBindingsData = {
body: TagBindingPayload
@@ -14,7 +14,7 @@ export const zSimpleResultResponse = z.object({
*
* Tag type
*/
export const zTagType = z.enum(['app', 'knowledge', 'snippet'])
export const zTagType = z.enum(['app', 'knowledge', 'skill', 'snippet'])
/**
* TagBindingPayload
@@ -22,14 +22,14 @@ export type TagUpdateRequestPayload = {
name: string
}
export type TagType = 'app' | 'knowledge' | 'snippet'
export type TagType = 'app' | 'knowledge' | 'skill' | 'snippet'
export type GetTagsData = {
body?: never
path?: never
query?: {
keyword?: string
type?: '' | 'app' | 'knowledge' | 'snippet'
type?: '' | 'app' | 'knowledge' | 'skill' | 'snippet'
}
url: '/tags'
}
@@ -29,7 +29,7 @@ export const zTagUpdateRequestPayload = z.object({
*
* Tag type
*/
export const zTagType = z.enum(['app', 'knowledge', 'snippet'])
export const zTagType = z.enum(['app', 'knowledge', 'skill', 'snippet'])
/**
* TagBasePayload
@@ -41,7 +41,7 @@ export const zTagBasePayload = z.object({
export const zGetTagsQuery = z.object({
keyword: z.string().optional(),
type: z.enum(['', 'app', 'knowledge', 'snippet']).optional().default(''),
type: z.enum(['', 'app', 'knowledge', 'skill', 'snippet']).optional().default(''),
})
/**
File diff suppressed because it is too large Load Diff
@@ -32,6 +32,16 @@ export type AgentProviderListResponse = Array<{
[key: string]: unknown
}>
export type AgentSkillBindingsResponse = {
agent_id: string
data?: Array<AgentSkillBindingItemResponse>
skill_ids?: Array<string>
}
export type AgentSkillBindingsPayload = {
skill_ids?: Array<string>
}
export type SnippetPaginationResponse = {
data: Array<SnippetListItemResponse>
has_more: boolean
@@ -635,6 +645,194 @@ export type WorkspaceAccessMatrix = {
pagination?: Pagination | null
}
export type SkillListResponse = {
data?: Array<SkillResponse>
has_more?: boolean
limit?: number
page?: number
total?: number
}
export type SkillCreatePayload = {
description?: string
display_name?: string | null
icon?: string
name?: string | null
tags?: Array<string>
}
export type SkillDetailResponse = {
created_at: number
created_by?: string | null
created_by_name?: string | null
description: string
display_name: string
files?: Array<SkillFileResponse>
icon: string
id: string
latest_published_version_id?: string | null
name: string
name_manually_edited?: boolean
reference_count?: number
tags?: Array<string>
updated_at: number
updated_by?: string | null
updated_by_name?: string | null
visibility: string
}
export type SkillFileUploadResponse = {
hash: string
id: string
mime_type: string
name: string
size: number
}
export type SkillTagListResponse = {
data?: Array<SkillTagResponse>
}
export type SkillDeletePayload = {
confirmation_name?: string | null
}
export type SkillDeleteResponse = {
deleted: boolean
id: string
}
export type SkillMetadataPayload = {
display_name?: string | null
expected_updated_at?: number | null
icon?: string | null
tags?: Array<string> | null
}
export type SkillResponse = {
created_at: number
created_by?: string | null
created_by_name?: string | null
description: string
display_name: string
icon: string
id: string
latest_published_version_id?: string | null
name: string
name_manually_edited?: boolean
reference_count?: number
tags?: Array<string>
updated_at: number
updated_by?: string | null
updated_by_name?: string | null
visibility: string
}
export type SkillAssistModelPayload = {
model: string
model_settings?: { [key: string]: unknown } | null
plugin_id?: string | null
provider: string
}
export type SkillAssistAttachmentPayload = {
mime_type?: string | null
name: string
size?: number | null
tool_file_id: string
}
export type SkillAssistMessagePayload = {
attachments?: Array<SkillAssistAttachmentPayload>
message: string
model?: SkillAssistModelPayload | null
target_path?: string | null
}
export type SkillDraftFileOperationPayload = {
content?: string | null
expected_updated_at?: number | null
hash?: string | null
mime_type?: string | null
operation: SkillDraftFileOperation
path: string
size?: number | null
target_path?: string | null
tool_file_id?: string | null
}
export type SkillDraftTreePayload = {
expected_updated_at?: number | null
files?: Array<SkillDraftTreeItemPayload>
}
export type SkillFilePreviewResponse = {
content: string
hash: string
mime_type: string
path: string
size: number
}
export type SkillPublishPayload = {
publish_note?: string
version_name?: string | null
}
export type SkillVersionResponse = {
archive_size: number
created_at: number
hash_code: string
id: string
is_latest?: boolean
publish_note: string
published_by?: string | null
published_by_name?: string | null
skill_id: string
version_name: string
version_number: number
}
export type SkillReferenceListResponse = {
data?: Array<SkillReferenceResponse>
}
export type SkillRestorePayload = {
publish_note?: string
version_id: string
version_name?: string | null
}
export type SkillVersionListResponse = {
data?: Array<SkillVersionResponse>
}
export type SkillVersionDeleteResponse = {
deleted: boolean
id: string
latest_published_version_id?: string | null
}
export type SkillVersionDetailResponse = {
archive_size: number
created_at: number
files?: Array<SkillFileResponse>
hash_code: string
id: string
is_latest?: boolean
publish_note: string
published_by?: string | null
published_by_name?: string | null
skill_id: string
version_name: string
version_number: number
}
export type SkillVersionUpdatePayload = {
publish_note?: string
version_name?: string | null
}
export type ToolLabelListResponse = Array<ToolLabel>
export type ApiToolProviderAddPayload = {
@@ -1042,6 +1240,21 @@ export type WorkspaceCustomConfigResponse = {
replace_webapp_logo?: string | null
}
export type AgentSkillBindingItemResponse = {
description: string
display_name: string
file_count: number
icon: string
id: string
latest_published_at?: number | null
latest_published_version_id?: string | null
name: string
priority: number
status: string
tags?: Array<string>
updated_at: number
}
export type SnippetListItemResponse = {
author_name: string | null
created_at: number
@@ -1502,6 +1715,60 @@ export type AccessPolicyRole = {
role_tag?: string
}
export type SkillFileResponse = {
content?: string | null
hash?: string | null
id?: string | null
kind: string
mime_type?: string | null
path: string
size?: number | null
storage?: string | null
tool_file_id?: string | null
}
export type SkillTagResponse = {
count: number
tag: string
}
export type SkillDraftFileOperation =
| 'delete'
| 'mkdir'
| 'rename'
| 'upsert_text'
| 'upsert_tool_file'
export type SkillDraftTreeItemPayload = {
content?: string | null
hash?: string | null
kind?: SkillFileKind
mime_type?: string | null
path: string
size?: number | null
storage?: SkillFileStorage | null
tool_file_id?: string | null
}
export type SkillReferenceResponse = {
agent_id: string
agent_icon?: string | null
agent_icon_background?: string | null
agent_icon_type?: string | null
app_id?: string | null
display_name: string
name: string
node_id?: string | null
node_name?: string | null
type: string
workflow_icon?: string | null
workflow_icon_background?: string | null
workflow_icon_type?: string | null
workflow_id?: string | null
workflow_name?: string | null
workflow_version?: string | null
}
export type ToolLabel = {
icon: string
label: I18nObject
@@ -1978,6 +2245,10 @@ export type PermissionCatalogItem = {
name: string
}
export type SkillFileKind = 'directory' | 'file'
export type SkillFileStorage = 'text' | 'tool_file'
export type ToolParameter = {
auto_generate?: PluginParameterAutoGenerate | null
default?:
@@ -2473,6 +2744,38 @@ export type GetWorkspacesCurrentAgentProvidersResponses = {
export type GetWorkspacesCurrentAgentProvidersResponse =
GetWorkspacesCurrentAgentProvidersResponses[keyof GetWorkspacesCurrentAgentProvidersResponses]
export type GetWorkspacesCurrentAgentsByAgentIdSkillsData = {
body?: never
path: {
agent_id: string
}
query?: never
url: '/workspaces/current/agents/{agent_id}/skills'
}
export type GetWorkspacesCurrentAgentsByAgentIdSkillsResponses = {
200: AgentSkillBindingsResponse
}
export type GetWorkspacesCurrentAgentsByAgentIdSkillsResponse =
GetWorkspacesCurrentAgentsByAgentIdSkillsResponses[keyof GetWorkspacesCurrentAgentsByAgentIdSkillsResponses]
export type PutWorkspacesCurrentAgentsByAgentIdSkillsData = {
body: AgentSkillBindingsPayload
path: {
agent_id: string
}
query?: never
url: '/workspaces/current/agents/{agent_id}/skills'
}
export type PutWorkspacesCurrentAgentsByAgentIdSkillsResponses = {
200: AgentSkillBindingsResponse
}
export type PutWorkspacesCurrentAgentsByAgentIdSkillsResponse =
PutWorkspacesCurrentAgentsByAgentIdSkillsResponses[keyof PutWorkspacesCurrentAgentsByAgentIdSkillsResponses]
export type GetWorkspacesCurrentCustomizedSnippetsData = {
body?: never
path?: never
@@ -4625,6 +4928,369 @@ export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponses = {
export type GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponse =
GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponses[keyof GetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponses]
export type GetWorkspacesCurrentSkillsData = {
body?: never
path?: never
query?: {
keyword?: string
limit?: number
page?: number
tag?: Array<string>
}
url: '/workspaces/current/skills'
}
export type GetWorkspacesCurrentSkillsResponses = {
200: SkillListResponse
}
export type GetWorkspacesCurrentSkillsResponse =
GetWorkspacesCurrentSkillsResponses[keyof GetWorkspacesCurrentSkillsResponses]
export type PostWorkspacesCurrentSkillsData = {
body: SkillCreatePayload
path?: never
query?: never
url: '/workspaces/current/skills'
}
export type PostWorkspacesCurrentSkillsResponses = {
201: SkillDetailResponse
}
export type PostWorkspacesCurrentSkillsResponse =
PostWorkspacesCurrentSkillsResponses[keyof PostWorkspacesCurrentSkillsResponses]
export type PostWorkspacesCurrentSkillsFilesUploadData = {
body: {
file: Blob | File
}
path?: never
query?: never
url: '/workspaces/current/skills/files/upload'
}
export type PostWorkspacesCurrentSkillsFilesUploadResponses = {
201: SkillFileUploadResponse
}
export type PostWorkspacesCurrentSkillsFilesUploadResponse =
PostWorkspacesCurrentSkillsFilesUploadResponses[keyof PostWorkspacesCurrentSkillsFilesUploadResponses]
export type PostWorkspacesCurrentSkillsImportData = {
body?: never
path?: never
query?: never
url: '/workspaces/current/skills/import'
}
export type PostWorkspacesCurrentSkillsImportResponses = {
201: SkillDetailResponse
}
export type PostWorkspacesCurrentSkillsImportResponse =
PostWorkspacesCurrentSkillsImportResponses[keyof PostWorkspacesCurrentSkillsImportResponses]
export type GetWorkspacesCurrentSkillsTagsData = {
body?: never
path?: never
query?: never
url: '/workspaces/current/skills/tags'
}
export type GetWorkspacesCurrentSkillsTagsResponses = {
200: SkillTagListResponse
}
export type GetWorkspacesCurrentSkillsTagsResponse =
GetWorkspacesCurrentSkillsTagsResponses[keyof GetWorkspacesCurrentSkillsTagsResponses]
export type DeleteWorkspacesCurrentSkillsBySkillIdData = {
body: SkillDeletePayload
path: {
skill_id: string
}
query?: never
url: '/workspaces/current/skills/{skill_id}'
}
export type DeleteWorkspacesCurrentSkillsBySkillIdResponses = {
200: SkillDeleteResponse
}
export type DeleteWorkspacesCurrentSkillsBySkillIdResponse =
DeleteWorkspacesCurrentSkillsBySkillIdResponses[keyof DeleteWorkspacesCurrentSkillsBySkillIdResponses]
export type GetWorkspacesCurrentSkillsBySkillIdData = {
body?: never
path: {
skill_id: string
}
query?: never
url: '/workspaces/current/skills/{skill_id}'
}
export type GetWorkspacesCurrentSkillsBySkillIdResponses = {
200: SkillDetailResponse
}
export type GetWorkspacesCurrentSkillsBySkillIdResponse =
GetWorkspacesCurrentSkillsBySkillIdResponses[keyof GetWorkspacesCurrentSkillsBySkillIdResponses]
export type PatchWorkspacesCurrentSkillsBySkillIdData = {
body: SkillMetadataPayload
path: {
skill_id: string
}
query?: never
url: '/workspaces/current/skills/{skill_id}'
}
export type PatchWorkspacesCurrentSkillsBySkillIdResponses = {
200: SkillResponse
}
export type PatchWorkspacesCurrentSkillsBySkillIdResponse =
PatchWorkspacesCurrentSkillsBySkillIdResponses[keyof PatchWorkspacesCurrentSkillsBySkillIdResponses]
export type PostWorkspacesCurrentSkillsBySkillIdAssistMessagesData = {
body: SkillAssistMessagePayload
path: {
skill_id: string
}
query?: never
url: '/workspaces/current/skills/{skill_id}/assist/messages'
}
export type PostWorkspacesCurrentSkillsBySkillIdAssistMessagesResponses = {
200: {
[key: string]: unknown
}
}
export type PostWorkspacesCurrentSkillsBySkillIdAssistMessagesResponse =
PostWorkspacesCurrentSkillsBySkillIdAssistMessagesResponses[keyof PostWorkspacesCurrentSkillsBySkillIdAssistMessagesResponses]
export type PostWorkspacesCurrentSkillsBySkillIdDuplicateData = {
body?: never
path: {
skill_id: string
}
query?: never
url: '/workspaces/current/skills/{skill_id}/duplicate'
}
export type PostWorkspacesCurrentSkillsBySkillIdDuplicateResponses = {
201: SkillDetailResponse
}
export type PostWorkspacesCurrentSkillsBySkillIdDuplicateResponse =
PostWorkspacesCurrentSkillsBySkillIdDuplicateResponses[keyof PostWorkspacesCurrentSkillsBySkillIdDuplicateResponses]
export type GetWorkspacesCurrentSkillsBySkillIdExportData = {
body?: never
path: {
skill_id: string
}
query?: never
url: '/workspaces/current/skills/{skill_id}/export'
}
export type GetWorkspacesCurrentSkillsBySkillIdExportResponses = {
200: {
[key: string]: unknown
}
}
export type GetWorkspacesCurrentSkillsBySkillIdExportResponse =
GetWorkspacesCurrentSkillsBySkillIdExportResponses[keyof GetWorkspacesCurrentSkillsBySkillIdExportResponses]
export type PatchWorkspacesCurrentSkillsBySkillIdFilesData = {
body: SkillDraftFileOperationPayload
path: {
skill_id: string
}
query?: never
url: '/workspaces/current/skills/{skill_id}/files'
}
export type PatchWorkspacesCurrentSkillsBySkillIdFilesResponses = {
200: SkillDetailResponse
}
export type PatchWorkspacesCurrentSkillsBySkillIdFilesResponse =
PatchWorkspacesCurrentSkillsBySkillIdFilesResponses[keyof PatchWorkspacesCurrentSkillsBySkillIdFilesResponses]
export type PutWorkspacesCurrentSkillsBySkillIdFilesData = {
body: SkillDraftTreePayload
path: {
skill_id: string
}
query?: never
url: '/workspaces/current/skills/{skill_id}/files'
}
export type PutWorkspacesCurrentSkillsBySkillIdFilesResponses = {
200: SkillDetailResponse
}
export type PutWorkspacesCurrentSkillsBySkillIdFilesResponse =
PutWorkspacesCurrentSkillsBySkillIdFilesResponses[keyof PutWorkspacesCurrentSkillsBySkillIdFilesResponses]
export type GetWorkspacesCurrentSkillsBySkillIdFilesContentData = {
body?: never
path: {
skill_id: string
}
query: {
download?: string
path: string
version_id?: string
}
url: '/workspaces/current/skills/{skill_id}/files/content'
}
export type GetWorkspacesCurrentSkillsBySkillIdFilesContentResponses = {
200: BinaryFileResponse
}
export type GetWorkspacesCurrentSkillsBySkillIdFilesContentResponse =
GetWorkspacesCurrentSkillsBySkillIdFilesContentResponses[keyof GetWorkspacesCurrentSkillsBySkillIdFilesContentResponses]
export type GetWorkspacesCurrentSkillsBySkillIdFilesPreviewData = {
body?: never
path: {
skill_id: string
}
query: {
path: string
version_id?: string
}
url: '/workspaces/current/skills/{skill_id}/files/preview'
}
export type GetWorkspacesCurrentSkillsBySkillIdFilesPreviewResponses = {
200: SkillFilePreviewResponse
}
export type GetWorkspacesCurrentSkillsBySkillIdFilesPreviewResponse =
GetWorkspacesCurrentSkillsBySkillIdFilesPreviewResponses[keyof GetWorkspacesCurrentSkillsBySkillIdFilesPreviewResponses]
export type PostWorkspacesCurrentSkillsBySkillIdPublishData = {
body: SkillPublishPayload
path: {
skill_id: string
}
query?: never
url: '/workspaces/current/skills/{skill_id}/publish'
}
export type PostWorkspacesCurrentSkillsBySkillIdPublishResponses = {
200: SkillVersionResponse
}
export type PostWorkspacesCurrentSkillsBySkillIdPublishResponse =
PostWorkspacesCurrentSkillsBySkillIdPublishResponses[keyof PostWorkspacesCurrentSkillsBySkillIdPublishResponses]
export type GetWorkspacesCurrentSkillsBySkillIdReferencesData = {
body?: never
path: {
skill_id: string
}
query?: never
url: '/workspaces/current/skills/{skill_id}/references'
}
export type GetWorkspacesCurrentSkillsBySkillIdReferencesResponses = {
200: SkillReferenceListResponse
}
export type GetWorkspacesCurrentSkillsBySkillIdReferencesResponse =
GetWorkspacesCurrentSkillsBySkillIdReferencesResponses[keyof GetWorkspacesCurrentSkillsBySkillIdReferencesResponses]
export type PostWorkspacesCurrentSkillsBySkillIdRestoreData = {
body: SkillRestorePayload
path: {
skill_id: string
}
query?: never
url: '/workspaces/current/skills/{skill_id}/restore'
}
export type PostWorkspacesCurrentSkillsBySkillIdRestoreResponses = {
200: SkillVersionResponse
}
export type PostWorkspacesCurrentSkillsBySkillIdRestoreResponse =
PostWorkspacesCurrentSkillsBySkillIdRestoreResponses[keyof PostWorkspacesCurrentSkillsBySkillIdRestoreResponses]
export type GetWorkspacesCurrentSkillsBySkillIdVersionsData = {
body?: never
path: {
skill_id: string
}
query?: never
url: '/workspaces/current/skills/{skill_id}/versions'
}
export type GetWorkspacesCurrentSkillsBySkillIdVersionsResponses = {
200: SkillVersionListResponse
}
export type GetWorkspacesCurrentSkillsBySkillIdVersionsResponse =
GetWorkspacesCurrentSkillsBySkillIdVersionsResponses[keyof GetWorkspacesCurrentSkillsBySkillIdVersionsResponses]
export type DeleteWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdData = {
body?: never
path: {
skill_id: string
version_id: string
}
query?: never
url: '/workspaces/current/skills/{skill_id}/versions/{version_id}'
}
export type DeleteWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponses = {
200: SkillVersionDeleteResponse
}
export type DeleteWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponse =
DeleteWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponses[keyof DeleteWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponses]
export type GetWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdData = {
body?: never
path: {
skill_id: string
version_id: string
}
query?: never
url: '/workspaces/current/skills/{skill_id}/versions/{version_id}'
}
export type GetWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponses = {
200: SkillVersionDetailResponse
}
export type GetWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponse =
GetWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponses[keyof GetWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponses]
export type PatchWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdData = {
body: SkillVersionUpdatePayload
path: {
skill_id: string
version_id: string
}
query?: never
url: '/workspaces/current/skills/{skill_id}/versions/{version_id}'
}
export type PatchWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponses = {
200: SkillVersionResponse
}
export type PatchWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponse =
PatchWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponses[keyof PatchWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponses]
export type GetWorkspacesCurrentToolLabelsData = {
body?: never
path?: never
@@ -12,6 +12,13 @@ export const zAgentProviderResponse = z.record(z.string(), z.unknown())
*/
export const zAgentProviderListResponse = z.array(z.record(z.string(), z.unknown()))
/**
* AgentSkillBindingsPayload
*/
export const zAgentSkillBindingsPayload = z.object({
skill_ids: z.array(z.string()).optional(),
})
/**
* SnippetImportPayload
*
@@ -436,6 +443,155 @@ export const zReplaceBindingsRequest = z.object({
role_ids: z.array(z.string()).optional(),
})
/**
* SkillCreatePayload
*/
export const zSkillCreatePayload = z.object({
description: z.string().optional().default(''),
display_name: z.string().nullish(),
icon: z.string().optional().default('📄'),
name: z.string().nullish(),
tags: z.array(z.string()).optional(),
})
/**
* SkillFileUploadResponse
*/
export const zSkillFileUploadResponse = z.object({
hash: z.string(),
id: z.string(),
mime_type: z.string(),
name: z.string(),
size: z.int(),
})
/**
* SkillDeletePayload
*/
export const zSkillDeletePayload = z.object({
confirmation_name: z.string().nullish(),
})
/**
* SkillDeleteResponse
*/
export const zSkillDeleteResponse = z.object({
deleted: z.boolean(),
id: z.string(),
})
/**
* SkillMetadataPayload
*/
export const zSkillMetadataPayload = z.object({
display_name: z.string().nullish(),
expected_updated_at: z.int().nullish(),
icon: z.string().nullish(),
tags: z.array(z.string()).nullish(),
})
/**
* SkillResponse
*/
export const zSkillResponse = z.object({
created_at: z.int(),
created_by: z.string().nullish(),
created_by_name: z.string().nullish(),
description: z.string(),
display_name: z.string(),
icon: z.string(),
id: z.string(),
latest_published_version_id: z.string().nullish(),
name: z.string(),
name_manually_edited: z.boolean().optional().default(false),
reference_count: z.int().optional().default(0),
tags: z.array(z.string()).optional(),
updated_at: z.int(),
updated_by: z.string().nullish(),
updated_by_name: z.string().nullish(),
visibility: z.string(),
})
/**
* SkillListResponse
*/
export const zSkillListResponse = z.object({
data: z.array(zSkillResponse).optional(),
has_more: z.boolean().optional().default(false),
limit: z.int().optional().default(20),
page: z.int().optional().default(1),
total: z.int().optional().default(0),
})
/**
* SkillFilePreviewResponse
*/
export const zSkillFilePreviewResponse = z.object({
content: z.string(),
hash: z.string(),
mime_type: z.string(),
path: z.string(),
size: z.int(),
})
/**
* SkillPublishPayload
*/
export const zSkillPublishPayload = z.object({
publish_note: z.string().max(1024).optional().default(''),
version_name: z.string().max(128).nullish(),
})
/**
* SkillVersionResponse
*/
export const zSkillVersionResponse = z.object({
archive_size: z.int(),
created_at: z.int(),
hash_code: z.string(),
id: z.string(),
is_latest: z.boolean().optional().default(false),
publish_note: z.string(),
published_by: z.string().nullish(),
published_by_name: z.string().nullish(),
skill_id: z.string(),
version_name: z.string(),
version_number: z.int(),
})
/**
* SkillRestorePayload
*/
export const zSkillRestorePayload = z.object({
publish_note: z.string().max(1024).optional().default(''),
version_id: z.string(),
version_name: z.string().max(128).nullish(),
})
/**
* SkillVersionListResponse
*/
export const zSkillVersionListResponse = z.object({
data: z.array(zSkillVersionResponse).optional(),
})
/**
* SkillVersionDeleteResponse
*/
export const zSkillVersionDeleteResponse = z.object({
deleted: z.boolean(),
id: z.string(),
latest_published_version_id: z.string().nullish(),
})
/**
* SkillVersionUpdatePayload
*/
export const zSkillVersionUpdatePayload = z.object({
publish_note: z.string().max(1024).optional().default(''),
version_name: z.string().max(128).nullish(),
})
/**
* ApiToolProviderDeletePayload
*/
@@ -656,6 +812,33 @@ export const zSwitchWorkspaceResponse = z.object({
result: z.string(),
})
/**
* AgentSkillBindingItemResponse
*/
export const zAgentSkillBindingItemResponse = z.object({
description: z.string(),
display_name: z.string(),
file_count: z.int(),
icon: z.string(),
id: z.string(),
latest_published_at: z.int().nullish(),
latest_published_version_id: z.string().nullish(),
name: z.string(),
priority: z.int(),
status: z.string(),
tags: z.array(z.string()).optional(),
updated_at: z.int(),
})
/**
* AgentSkillBindingsResponse
*/
export const zAgentSkillBindingsResponse = z.object({
agent_id: z.string(),
data: z.array(zAgentSkillBindingItemResponse).optional(),
skill_ids: z.array(z.string()).optional(),
})
/**
* IconInfo
*
@@ -1244,6 +1427,164 @@ export const zWorkspaceAccessMatrix = z.object({
pagination: zPagination.nullish(),
})
/**
* SkillFileResponse
*/
export const zSkillFileResponse = z.object({
content: z.string().nullish(),
hash: z.string().nullish(),
id: z.string().nullish(),
kind: z.string(),
mime_type: z.string().nullish(),
path: z.string(),
size: z.int().nullish(),
storage: z.string().nullish(),
tool_file_id: z.string().nullish(),
})
/**
* SkillDetailResponse
*/
export const zSkillDetailResponse = z.object({
created_at: z.int(),
created_by: z.string().nullish(),
created_by_name: z.string().nullish(),
description: z.string(),
display_name: z.string(),
files: z.array(zSkillFileResponse).optional(),
icon: z.string(),
id: z.string(),
latest_published_version_id: z.string().nullish(),
name: z.string(),
name_manually_edited: z.boolean().optional().default(false),
reference_count: z.int().optional().default(0),
tags: z.array(z.string()).optional(),
updated_at: z.int(),
updated_by: z.string().nullish(),
updated_by_name: z.string().nullish(),
visibility: z.string(),
})
/**
* SkillVersionDetailResponse
*/
export const zSkillVersionDetailResponse = z.object({
archive_size: z.int(),
created_at: z.int(),
files: z.array(zSkillFileResponse).optional(),
hash_code: z.string(),
id: z.string(),
is_latest: z.boolean().optional().default(false),
publish_note: z.string(),
published_by: z.string().nullish(),
published_by_name: z.string().nullish(),
skill_id: z.string(),
version_name: z.string(),
version_number: z.int(),
})
/**
* SkillTagResponse
*/
export const zSkillTagResponse = z.object({
count: z.int(),
tag: z.string(),
})
/**
* SkillTagListResponse
*/
export const zSkillTagListResponse = z.object({
data: z.array(zSkillTagResponse).optional(),
})
/**
* SkillAssistAttachmentPayload
*/
export const zSkillAssistAttachmentPayload = z.object({
mime_type: z.string().min(1).max(255).nullish(),
name: z.string().min(1).max(255),
size: z.int().gte(0).nullish(),
tool_file_id: z.string().min(1),
})
/**
* SkillAssistModelPayload
*/
export const zSkillAssistModelPayload = z.object({
model: z.string().min(1).max(255),
model_settings: z.record(z.string(), z.unknown()).nullish(),
plugin_id: z.string().min(1).max(255).nullish(),
provider: z.string().min(1).max(255),
})
/**
* SkillAssistMessagePayload
*
* One user message and optional uploaded context for the read-only Skill Authoring assistant.
*/
export const zSkillAssistMessagePayload = z.object({
attachments: z.array(zSkillAssistAttachmentPayload).max(10).optional(),
message: z.string().min(1).max(8000),
model: zSkillAssistModelPayload.nullish(),
target_path: z.string().nullish(),
})
/**
* SkillDraftFileOperation
*/
export const zSkillDraftFileOperation = z.enum([
'delete',
'mkdir',
'rename',
'upsert_text',
'upsert_tool_file',
])
/**
* SkillDraftFileOperationPayload
*/
export const zSkillDraftFileOperationPayload = z.object({
content: z.string().nullish(),
expected_updated_at: z.int().nullish(),
hash: z.string().nullish(),
mime_type: z.string().nullish(),
operation: zSkillDraftFileOperation,
path: z.string(),
size: z.int().gte(0).nullish(),
target_path: z.string().nullish(),
tool_file_id: z.string().nullish(),
})
/**
* SkillReferenceResponse
*/
export const zSkillReferenceResponse = z.object({
agent_id: z.string(),
agent_icon: z.string().nullish(),
agent_icon_background: z.string().nullish(),
agent_icon_type: z.string().nullish(),
app_id: z.string().nullish(),
display_name: z.string(),
name: z.string(),
node_id: z.string().nullish(),
node_name: z.string().nullish(),
type: z.string(),
workflow_icon: z.string().nullish(),
workflow_icon_background: z.string().nullish(),
workflow_icon_type: z.string().nullish(),
workflow_id: z.string().nullish(),
workflow_name: z.string().nullish(),
workflow_version: z.string().nullish(),
})
/**
* SkillReferenceListResponse
*/
export const zSkillReferenceListResponse = z.object({
data: z.array(zSkillReferenceResponse).optional(),
})
/**
* ToolEmojiIcon
*/
@@ -2096,6 +2437,42 @@ export const zPermissionCatalogResponse = z.object({
groups: z.array(zPermissionCatalogGroup).optional(),
})
/**
* SkillFileKind
*
* Draft file entry kind.
*/
export const zSkillFileKind = z.enum(['directory', 'file'])
/**
* SkillFileStorage
*
* How a draft file's content is stored.
*/
export const zSkillFileStorage = z.enum(['text', 'tool_file'])
/**
* SkillDraftTreeItemPayload
*/
export const zSkillDraftTreeItemPayload = z.object({
content: z.string().nullish(),
hash: z.string().nullish(),
kind: zSkillFileKind.optional().default('file'),
mime_type: z.string().nullish(),
path: z.string(),
size: z.int().gte(0).nullish(),
storage: zSkillFileStorage.nullish(),
tool_file_id: z.string().nullish(),
})
/**
* SkillDraftTreePayload
*/
export const zSkillDraftTreePayload = z.object({
expected_updated_at: z.int().nullish(),
files: z.array(zSkillDraftTreeItemPayload).optional(),
})
/**
* Option
*/
@@ -3421,6 +3798,26 @@ export const zGetWorkspacesCurrentAgentProviderByProviderNameResponse = zAgentPr
*/
export const zGetWorkspacesCurrentAgentProvidersResponse = zAgentProviderListResponse
export const zGetWorkspacesCurrentAgentsByAgentIdSkillsPath = z.object({
agent_id: z.string(),
})
/**
* Agent Skill bindings
*/
export const zGetWorkspacesCurrentAgentsByAgentIdSkillsResponse = zAgentSkillBindingsResponse
export const zPutWorkspacesCurrentAgentsByAgentIdSkillsBody = zAgentSkillBindingsPayload
export const zPutWorkspacesCurrentAgentsByAgentIdSkillsPath = z.object({
agent_id: z.string(),
})
/**
* Agent Skill bindings replaced
*/
export const zPutWorkspacesCurrentAgentsByAgentIdSkillsResponse = zAgentSkillBindingsResponse
export const zGetWorkspacesCurrentCustomizedSnippetsQuery = z.object({
creators: z.array(z.string()).optional(),
is_published: z.boolean().optional(),
@@ -4694,6 +5091,234 @@ export const zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPoliciesByPolicyIdR
*/
export const zGetWorkspacesCurrentRbacWorkspaceDatasetsAccessPolicyResponse = zWorkspaceAccessMatrix
export const zGetWorkspacesCurrentSkillsQuery = z.object({
keyword: z.string().optional(),
limit: z.int().gte(1).lte(100).optional().default(20),
page: z.int().gte(1).lte(99999).optional().default(1),
tag: z.array(z.string()).optional(),
})
/**
* Workspace skills
*/
export const zGetWorkspacesCurrentSkillsResponse = zSkillListResponse
export const zPostWorkspacesCurrentSkillsBody = zSkillCreatePayload
/**
* Skill created
*/
export const zPostWorkspacesCurrentSkillsResponse = zSkillDetailResponse
export const zPostWorkspacesCurrentSkillsFilesUploadBody = z.object({
file: z.custom<Blob | File>((value) => value instanceof Blob || value instanceof File),
})
/**
* Skill draft file uploaded
*/
export const zPostWorkspacesCurrentSkillsFilesUploadResponse = zSkillFileUploadResponse
/**
* Skill imported
*/
export const zPostWorkspacesCurrentSkillsImportResponse = zSkillDetailResponse
/**
* Workspace Skill tags
*/
export const zGetWorkspacesCurrentSkillsTagsResponse = zSkillTagListResponse
export const zDeleteWorkspacesCurrentSkillsBySkillIdBody = zSkillDeletePayload
export const zDeleteWorkspacesCurrentSkillsBySkillIdPath = z.object({
skill_id: z.string(),
})
/**
* Skill deleted
*/
export const zDeleteWorkspacesCurrentSkillsBySkillIdResponse = zSkillDeleteResponse
export const zGetWorkspacesCurrentSkillsBySkillIdPath = z.object({
skill_id: z.string(),
})
/**
* Skill detail
*/
export const zGetWorkspacesCurrentSkillsBySkillIdResponse = zSkillDetailResponse
export const zPatchWorkspacesCurrentSkillsBySkillIdBody = zSkillMetadataPayload
export const zPatchWorkspacesCurrentSkillsBySkillIdPath = z.object({
skill_id: z.string(),
})
/**
* Skill updated
*/
export const zPatchWorkspacesCurrentSkillsBySkillIdResponse = zSkillResponse
export const zPostWorkspacesCurrentSkillsBySkillIdAssistMessagesBody = zSkillAssistMessagePayload
export const zPostWorkspacesCurrentSkillsBySkillIdAssistMessagesPath = z.object({
skill_id: z.string(),
})
/**
* Skill Authoring assistant event stream
*/
export const zPostWorkspacesCurrentSkillsBySkillIdAssistMessagesResponse = z.record(
z.string(),
z.unknown(),
)
export const zPostWorkspacesCurrentSkillsBySkillIdDuplicatePath = z.object({
skill_id: z.string(),
})
/**
* Skill duplicated
*/
export const zPostWorkspacesCurrentSkillsBySkillIdDuplicateResponse = zSkillDetailResponse
export const zGetWorkspacesCurrentSkillsBySkillIdExportPath = z.object({
skill_id: z.string(),
})
/**
* Published Skill zip archive
*/
export const zGetWorkspacesCurrentSkillsBySkillIdExportResponse = z.record(z.string(), z.unknown())
export const zPatchWorkspacesCurrentSkillsBySkillIdFilesBody = zSkillDraftFileOperationPayload
export const zPatchWorkspacesCurrentSkillsBySkillIdFilesPath = z.object({
skill_id: z.string(),
})
/**
* Draft file operation applied
*/
export const zPatchWorkspacesCurrentSkillsBySkillIdFilesResponse = zSkillDetailResponse
export const zPutWorkspacesCurrentSkillsBySkillIdFilesBody = zSkillDraftTreePayload
export const zPutWorkspacesCurrentSkillsBySkillIdFilesPath = z.object({
skill_id: z.string(),
})
/**
* Draft files replaced
*/
export const zPutWorkspacesCurrentSkillsBySkillIdFilesResponse = zSkillDetailResponse
export const zGetWorkspacesCurrentSkillsBySkillIdFilesContentPath = z.object({
skill_id: z.string(),
})
export const zGetWorkspacesCurrentSkillsBySkillIdFilesContentQuery = z.object({
download: z.string().optional(),
path: z.string(),
version_id: z.string().optional(),
})
/**
* Skill file content
*/
export const zGetWorkspacesCurrentSkillsBySkillIdFilesContentResponse = zBinaryFileResponse
export const zGetWorkspacesCurrentSkillsBySkillIdFilesPreviewPath = z.object({
skill_id: z.string(),
})
export const zGetWorkspacesCurrentSkillsBySkillIdFilesPreviewQuery = z.object({
path: z.string(),
version_id: z.string().optional(),
})
/**
* Skill file text preview
*/
export const zGetWorkspacesCurrentSkillsBySkillIdFilesPreviewResponse = zSkillFilePreviewResponse
export const zPostWorkspacesCurrentSkillsBySkillIdPublishBody = zSkillPublishPayload
export const zPostWorkspacesCurrentSkillsBySkillIdPublishPath = z.object({
skill_id: z.string(),
})
/**
* Skill published
*/
export const zPostWorkspacesCurrentSkillsBySkillIdPublishResponse = zSkillVersionResponse
export const zGetWorkspacesCurrentSkillsBySkillIdReferencesPath = z.object({
skill_id: z.string(),
})
/**
* Skill references
*/
export const zGetWorkspacesCurrentSkillsBySkillIdReferencesResponse = zSkillReferenceListResponse
export const zPostWorkspacesCurrentSkillsBySkillIdRestoreBody = zSkillRestorePayload
export const zPostWorkspacesCurrentSkillsBySkillIdRestorePath = z.object({
skill_id: z.string(),
})
/**
* Skill version restored
*/
export const zPostWorkspacesCurrentSkillsBySkillIdRestoreResponse = zSkillVersionResponse
export const zGetWorkspacesCurrentSkillsBySkillIdVersionsPath = z.object({
skill_id: z.string(),
})
/**
* Skill versions
*/
export const zGetWorkspacesCurrentSkillsBySkillIdVersionsResponse = zSkillVersionListResponse
export const zDeleteWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdPath = z.object({
skill_id: z.string(),
version_id: z.string(),
})
/**
* Skill version deleted
*/
export const zDeleteWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponse =
zSkillVersionDeleteResponse
export const zGetWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdPath = z.object({
skill_id: z.string(),
version_id: z.string(),
})
/**
* Skill version detail
*/
export const zGetWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponse =
zSkillVersionDetailResponse
export const zPatchWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdBody =
zSkillVersionUpdatePayload
export const zPatchWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdPath = z.object({
skill_id: z.string(),
version_id: z.string(),
})
/**
* Skill version updated
*/
export const zPatchWorkspacesCurrentSkillsBySkillIdVersionsByVersionIdResponse =
zSkillVersionResponse
/**
* Tool labels retrieved successfully
*/
@@ -18,6 +18,7 @@ The current Flask-RESTX generator still emits these response entries under `appl
| service | GET | `/files/{file_id}/preview` | Original file MIME type, optionally attachment | `BinaryFileResponse` |
| console | GET | `/workspaces/current/plugin/icon` | Plugin asset MIME type | `BinaryFileResponse` |
| console | GET | `/workspaces/current/plugin/asset` | `application/octet-stream` | `BinaryFileResponse` |
| console | GET | `/workspaces/current/skills/{skill_id}/files/content` | Skill file MIME type, optionally attachment | `BinaryFileResponse` |
| console | GET | `/workspaces/current/tool-provider/builtin/{provider}/icon` | Tool icon MIME type | `BinaryFileResponse` |
| console | GET | `/workspaces/current/trigger-provider/{provider}/icon` | Trigger icon response | `BinaryFileResponse` |
| console | GET | `/workspaces/{tenant_id}/model-providers/{provider}/{icon_type}/{lang}` | Model provider icon MIME type | `BinaryFileResponse` |
@@ -0,0 +1,5 @@
import SkillDetailPage from '@/features/skills/detail-page'
export default function Page() {
return <SkillDetailPage />
}
+5
View File
@@ -0,0 +1,5 @@
import SkillsPage from '@/features/skills/page'
export default function Page() {
return <SkillsPage />
}
@@ -1,4 +1,5 @@
import { fireEvent, screen } from '@testing-library/react'
import { screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
import { defaultPlan } from '@/app/components/billing/config'
import { Plan } from '@/app/components/billing/type'
@@ -67,11 +68,16 @@ describe('ArchivedLogsNotice', () => {
)
})
it('should show notice for paid workspace managers', () => {
it('should show an accessible notice for paid workspace managers', async () => {
const user = userEvent.setup()
renderNotice()
expect(screen.getByText('appLog.archives.notice.description')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'appLog.archives.notice.action' }))
const notice = screen.getByRole('status')
expect(notice).toHaveAttribute('aria-live', 'polite')
expect(notice).toHaveAttribute('aria-atomic', 'true')
expect(within(notice).getByText('appLog.archives.notice.description')).toBeInTheDocument()
await user.click(within(notice).getByRole('button', { name: 'appLog.archives.notice.action' }))
expect(setShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.WORKFLOW_LOG_ARCHIVES,
})
@@ -1,9 +1,37 @@
import type { QueryParam } from '../index'
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, within } from '@testing-library/react'
import Filter, { TIME_PERIOD_MAPPING } from '../filter'
let mockAnnotationsCountLoading = false
let mockAnnotationsCountData: { count: number } | null = { count: 10 }
const mockRuntime = vi.hoisted(() => ({
deploymentEdition: 'CLOUD',
enableBilling: true,
isFetchedPlan: true,
isFetchedPlanInfo: true,
planType: 'professional',
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useSuspenseQuery: () => ({ data: mockRuntime.deploymentEdition }),
}
})
vi.mock('@/context/provider-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/provider-context')>()
return {
...actual,
useProviderContext: () => ({
enableBilling: mockRuntime.enableBilling,
isFetchedPlan: mockRuntime.isFetchedPlan,
isFetchedPlanInfo: mockRuntime.isFetchedPlanInfo,
plan: { type: mockRuntime.planType },
}),
}
})
vi.mock('@/service/use-log', () => ({
useAnnotationsCount: () => ({
@@ -12,28 +40,43 @@ vi.mock('@/service/use-log', () => ({
}),
}))
vi.mock('@/app/components/base/chip', () => ({
default: ({
items,
value,
onSelect,
onClear,
}: {
items: Array<{ value: string; name: string }>
value?: string
onSelect: (item: { value: string; name: string }) => void
onClear: () => void
}) => {
const currentItem = items.find((item) => item.value === value) ?? items[0]
return (
<div>
<div>{currentItem?.name}</div>
<button onClick={() => onSelect(items.at(-1)!)}>{`select-${items.at(-1)?.value}`}</button>
<button onClick={onClear}>clear-chip</button>
</div>
)
},
}))
vi.mock('@/app/components/base/chip', async () => {
const { useState } = await import('react')
return {
default: function MockChip({
items,
value,
onSelect,
onClear,
}: {
items: Array<{ value: string; name: string }>
value?: string
onSelect: (item: { value: string; name: string }) => void
onClear: () => void
}) {
const [isOpen, setIsOpen] = useState(false)
const currentItem = items.find((item) => item.value === value) ?? items[0]
return (
<div>
<div>{currentItem?.name}</div>
<button aria-label={`open-options-${items[0]?.value}`} onClick={() => setIsOpen(true)}>
open-chip
</button>
{isOpen && (
<ul aria-label={`options-${items[0]?.value}`}>
{items.map((item) => (
<li key={item.value}>{item.name}</li>
))}
</ul>
)}
<button onClick={() => onSelect(items.at(-1)!)}>{`select-${items.at(-1)?.value}`}</button>
<button onClick={onClear}>clear-chip</button>
</div>
)
},
}
})
vi.mock('@/app/components/base/sort', () => ({
default: ({ onSelect }: { onSelect: (value: string) => void }) => (
@@ -59,6 +102,11 @@ describe('Filter', () => {
vi.clearAllMocks()
mockAnnotationsCountLoading = false
mockAnnotationsCountData = { count: 10 }
mockRuntime.deploymentEdition = 'CLOUD'
mockRuntime.enableBilling = true
mockRuntime.isFetchedPlan = true
mockRuntime.isFetchedPlanInfo = true
mockRuntime.planType = 'professional'
})
describe('Rendering', () => {
@@ -124,6 +172,77 @@ describe('Filter', () => {
})
describe('User Interactions', () => {
it('should only show supported periods for Cloud sandbox workspaces', () => {
mockRuntime.deploymentEdition = 'CLOUD'
mockRuntime.planType = 'sandbox'
render(<Filter {...defaultProps} queryParams={{ ...defaultQueryParams, period: '2' }} />)
fireEvent.click(screen.getByRole('button', { name: 'open-options-1' }))
const periodOptions = within(screen.getByRole('list', { name: 'options-1' }))
expect(periodOptions.getAllByRole('listitem').map((item) => item.textContent)).toEqual([
expect.stringMatching(/(?:^|\.)filter\.period\.today(?=$|:)/),
expect.stringMatching(/(?:^|\.)filter\.period\.last7days(?=$|:)/),
expect.stringMatching(/(?:^|\.)filter\.period\.last30days(?=$|:)/),
])
})
it('should only show supported periods while the Cloud plan is pending', () => {
mockRuntime.isFetchedPlan = false
mockRuntime.isFetchedPlanInfo = false
render(<Filter {...defaultProps} queryParams={{ ...defaultQueryParams, period: '2' }} />)
fireEvent.click(screen.getByRole('button', { name: 'open-options-1' }))
const periodOptions = within(screen.getByRole('list', { name: 'options-1' }))
expect(periodOptions.getAllByRole('listitem').map((item) => item.textContent)).toEqual([
expect.stringMatching(/(?:^|\.)filter\.period\.today(?=$|:)/),
expect.stringMatching(/(?:^|\.)filter\.period\.last7days(?=$|:)/),
expect.stringMatching(/(?:^|\.)filter\.period\.last30days(?=$|:)/),
])
})
it('should keep all periods when Cloud billing is known to be disabled', () => {
mockRuntime.enableBilling = false
mockRuntime.isFetchedPlan = false
mockRuntime.isFetchedPlanInfo = true
render(<Filter {...defaultProps} queryParams={{ ...defaultQueryParams, period: '2' }} />)
fireEvent.click(screen.getByRole('button', { name: 'open-options-1' }))
const periodOptions = within(screen.getByRole('list', { name: 'options-1' }))
expect(periodOptions.getAllByRole('listitem')).toHaveLength(9)
})
it('should keep all periods for sandbox workspaces outside Cloud', () => {
mockRuntime.deploymentEdition = 'COMMUNITY'
mockRuntime.planType = 'sandbox'
render(<Filter {...defaultProps} queryParams={{ ...defaultQueryParams, period: '2' }} />)
fireEvent.click(screen.getByRole('button', { name: 'open-options-1' }))
const periodOptions = within(screen.getByRole('list', { name: 'options-1' }))
expect(periodOptions.getAllByRole('listitem')).toHaveLength(9)
})
it('should reset the Cloud sandbox period to today when cleared', () => {
mockRuntime.deploymentEdition = 'CLOUD'
mockRuntime.planType = 'sandbox'
render(<Filter {...defaultProps} queryParams={{ ...defaultQueryParams, period: '2' }} />)
fireEvent.click(screen.getAllByText('clear-chip')[0]!)
expect(mockSetQueryParams).toHaveBeenCalledWith({
...defaultQueryParams,
period: '1',
})
})
it('should update keyword when typing in search input', () => {
render(<Filter {...defaultProps} />)
@@ -1,5 +1,8 @@
/* oxlint-disable typescript/no-explicit-any */
import type { CloudSandboxPlanState } from '../cloud-sandbox-retention'
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import dayjs from 'dayjs'
import { APP_PAGE_LIMIT } from '@/config'
import { AppModeEnum } from '@/types/app'
import Logs from '../index'
@@ -11,11 +14,27 @@ vi.mock('@/context/i18n', () => ({
const mockReplace = vi.fn()
const mockUseChatConversations = vi.fn()
const mockUseCompletionConversations = vi.fn()
const mockPlanState = vi.hoisted(() => ({
value: 'unrestricted' as CloudSandboxPlanState,
}))
const mockDebouncedPeriod = vi.hoisted(() => ({
value: null as string | null,
}))
let mockSearchParams = new URLSearchParams()
vi.mock('ahooks', async () => {
return {
useDebounce: <T,>(value: T) => value,
useDebounce: <T,>(value: T) => {
if (
mockDebouncedPeriod.value === null ||
typeof value !== 'object' ||
value === null ||
!('period' in value)
)
return value
return { ...value, period: mockDebouncedPeriod.value }
},
}
})
@@ -33,28 +52,19 @@ vi.mock('@/next/navigation', () => ({
vi.mock('@/service/use-log', () => ({
useChatConversations: (...args: unknown[]) => mockUseChatConversations(...args),
useCompletionConversations: (...args: unknown[]) => mockUseCompletionConversations(...args),
useAnnotationsCount: () => ({
data: { count: 0 },
isLoading: false,
}),
}))
vi.mock('../filter', () => ({
TIME_PERIOD_MAPPING: {
2: { value: 7 },
9: { value: 0 },
},
default: ({ setQueryParams }: { setQueryParams: (next: Record<string, string>) => void }) => (
<button
onClick={() =>
setQueryParams({
period: '9',
annotation_status: 'all',
sort_by: '-created_at',
keyword: 'hello',
})
}
>
filter-controls
</button>
),
}))
vi.mock('../cloud-sandbox-retention', async (importOriginal) => {
const actual = await importOriginal<typeof import('../cloud-sandbox-retention')>()
return {
...actual,
useCloudSandboxPlanStatus: () => mockPlanState.value,
}
})
vi.mock('../list', () => ({
default: ({ logs }: { logs: { total?: number } }) => (
@@ -69,6 +79,10 @@ vi.mock('../empty-element', () => ({
default: () => <div>empty-logs</div>,
}))
vi.mock('../retention-upgrade-notice', () => ({
RetentionUpgradeNotice: () => <div>retention-upgrade-notice</div>,
}))
vi.mock('@/app/components/base/loading', () => ({
default: () => <div>loading-logs</div>,
}))
@@ -85,6 +99,8 @@ describe('Logs', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSearchParams = new URLSearchParams()
mockPlanState.value = 'unrestricted'
mockDebouncedPeriod.value = null
mockUseChatConversations.mockReturnValue({
data: undefined,
refetch: vi.fn(),
@@ -117,6 +133,7 @@ describe('Logs', () => {
expect(
screen.getByRole('link', { name: /(?:^|\.)operation\.learnMore(?=$|:)/ }),
).toHaveAttribute('href', 'https://docs.example.com/use-dify/monitor/logs')
expect(screen.getByText('retention-upgrade-notice')).toBeInTheDocument()
expect(screen.getByText('loading-logs')).toBeInTheDocument()
})
@@ -166,4 +183,101 @@ describe('Logs', () => {
expect(mockReplace).toHaveBeenCalledWith('/apps/app-1/logs?page=2', { scroll: false })
})
it('should query the last 30 days when a Sandbox user selects the longest period', async () => {
const user = userEvent.setup()
mockPlanState.value = 'sandbox'
mockUseChatConversations.mockReturnValue({
data: { total: 0 },
refetch: vi.fn(),
})
render(
<Logs
appDetail={
{
id: 'app-sandbox-last-30-days',
mode: AppModeEnum.CHAT,
} as any
}
/>,
)
await user.click(screen.getByRole('combobox', { name: /appLog\.filter\.period\.last7days/ }))
await user.click(await screen.findByText(/appLog\.filter\.period\.last30days/))
expect(
screen.getByRole('combobox', { name: /appLog\.filter\.period\.last30days/ }),
).toBeInTheDocument()
expect(mockUseChatConversations.mock.calls.at(-1)?.[0]).toEqual(
expect.objectContaining({
params: expect.objectContaining({
start: dayjs().subtract(30, 'day').startOf('day').format('YYYY-MM-DD HH:mm'),
end: dayjs().endOf('day').format('YYYY-MM-DD HH:mm'),
}),
}),
)
})
it('should use a valid period for the real Chip and request when a cached period settles to Sandbox', async () => {
const user = userEvent.setup()
const appDetail = {
id: 'app-period-transition',
mode: AppModeEnum.CHAT,
} as any
mockUseChatConversations.mockReturnValue({
data: { total: 0 },
refetch: vi.fn(),
})
const unrestrictedRender = render(<Logs appDetail={appDetail} />)
await user.click(screen.getByRole('combobox', { name: /appLog\.filter\.period\.last7days/ }))
await user.click(await screen.findByText(/appLog\.filter\.period\.allTime/))
expect(mockUseChatConversations.mock.calls.at(-1)?.[0]).toEqual(
expect.objectContaining({
params: expect.not.objectContaining({
start: expect.anything(),
end: expect.anything(),
}),
}),
)
unrestrictedRender.unmount()
mockPlanState.value = 'pending'
mockDebouncedPeriod.value = '9'
const pendingRender = render(<Logs appDetail={appDetail} />)
expect(
screen.getByRole('combobox', { name: /appLog\.filter\.period\.today/ }),
).toBeInTheDocument()
expect(
screen.getByRole('button', {
name: /common\.operation\.clear appLog\.filter\.period\.today/,
}),
).toBeInTheDocument()
expect(mockUseChatConversations.mock.calls.at(-1)?.[0]).toEqual(
expect.objectContaining({
params: expect.objectContaining({
start: dayjs().startOf('day').format('YYYY-MM-DD HH:mm'),
end: expect.any(String),
}),
}),
)
mockPlanState.value = 'sandbox'
pendingRender.rerender(<Logs appDetail={appDetail} />)
expect(
screen.getByRole('combobox', { name: /appLog\.filter\.period\.today/ }),
).toBeInTheDocument()
expect(mockUseChatConversations.mock.calls.at(-1)?.[0]).toEqual(
expect.objectContaining({
params: expect.objectContaining({
start: dayjs().startOf('day').format('YYYY-MM-DD HH:mm'),
end: expect.any(String),
}),
}),
)
})
})
@@ -0,0 +1,117 @@
import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen'
import { screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
import { defaultPlan } from '@/app/components/billing/config'
import { Plan } from '@/app/components/billing/type'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { createConsoleQueryWrapper } from '@/test/console/query-data'
import { render } from '@/test/console/render'
import { RetentionUpgradeNotice } from '../retention-upgrade-notice'
vi.mock('@/context/provider-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/provider-context')>()
return {
...actual,
useProviderContext: vi.fn(),
}
})
vi.mock('@/context/modal-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/modal-context')>()
return {
...actual,
useModalContext: vi.fn(),
}
})
const mockUseProviderContext = vi.mocked(useProviderContext)
const mockUseModalContext = vi.mocked(useModalContext)
describe('RetentionUpgradeNotice', () => {
const setShowPricingModal = vi.fn()
function mockProvider({
enableBilling = true,
isFetchedPlan = true,
isFetchedPlanInfo = true,
planType = Plan.sandbox,
}: {
enableBilling?: boolean
isFetchedPlan?: boolean
isFetchedPlanInfo?: boolean
planType?: Plan
} = {}) {
mockUseProviderContext.mockReturnValue(
createMockProviderContextValue({
enableBilling,
isFetchedPlan,
isFetchedPlanInfo,
plan: {
...defaultPlan,
type: planType,
},
}),
)
}
function renderNotice(deploymentEdition: DeploymentEdition = 'CLOUD') {
const { wrapper } = createConsoleQueryWrapper({
systemFeatures: { deployment_edition: deploymentEdition },
})
return render(<RetentionUpgradeNotice />, { wrapper })
}
beforeEach(() => {
vi.clearAllMocks()
mockProvider()
mockUseModalContext.mockReturnValue({
setShowPricingModal,
} as unknown as ReturnType<typeof useModalContext>)
})
it('should show accessible upgrade guidance for Cloud sandbox workspaces', async () => {
const user = userEvent.setup()
renderNotice()
const notice = screen.getByRole('status')
expect(notice).toHaveAttribute('aria-live', 'polite')
expect(notice).toHaveAttribute('aria-atomic', 'true')
expect(within(notice).getByText('appLog.retention.upgradeTip.description')).toBeInTheDocument()
await user.click(
within(notice).getByRole('button', { name: 'billing.upgradeBtn.encourageShort' }),
)
expect(setShowPricingModal).toHaveBeenCalledOnce()
})
it.each([
{
name: 'paid Cloud workspaces',
provider: { planType: Plan.professional },
deploymentEdition: 'CLOUD',
},
{
name: 'self-hosted sandbox workspaces',
provider: { planType: Plan.sandbox },
deploymentEdition: 'COMMUNITY',
},
{
name: 'workspaces without billing',
provider: { enableBilling: false },
deploymentEdition: 'CLOUD',
},
{
name: 'workspaces before plan loading completes',
provider: { isFetchedPlan: false, isFetchedPlanInfo: false },
deploymentEdition: 'CLOUD',
},
] as const)('should not show guidance for $name', ({ provider, deploymentEdition }) => {
mockProvider(provider)
renderNotice(deploymentEdition)
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
})

Some files were not shown because too many files have changed in this diff Show More