Compare commits
26 Commits
codex/dial
...
deploy/ope
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c27bbde168 | ||
|
|
ce8836f30e | ||
|
|
8976e91bbc | ||
|
|
6021f0e2d2 | ||
|
|
ab49e9eaae | ||
|
|
01a4939731 | ||
|
|
5095851c4d | ||
|
|
ff0283417e | ||
|
|
4f5c73f3c6 | ||
|
|
651daa39be | ||
|
|
6179a6a0b7 | ||
|
|
a44dae75c2 | ||
|
|
021ac2b7e1 | ||
|
|
100fff4ba3 | ||
|
|
4349c67a79 | ||
|
|
2f0923be28 | ||
|
|
b2516d5baa | ||
|
|
623cd905e3 | ||
|
|
02ba22b6ed | ||
|
|
96b10658c9 | ||
|
|
da4e0a9af3 | ||
|
|
8c639df933 | ||
|
|
ddecdb6257 | ||
|
|
263080e907 | ||
|
|
4c96259fec | ||
|
|
6b7d38f18c |
3
.gitattributes
vendored
3
.gitattributes
vendored
@@ -9,3 +9,6 @@
|
||||
# Codegen output must stay byte-identical across platforms so
|
||||
# `pnpm tree:check` in CI does not trip on CRLF rewrites.
|
||||
*.generated.ts text eol=lf
|
||||
|
||||
# Skill scripts run on every platform, so keep LF line endings.
|
||||
/skills/** text=auto eol=lf
|
||||
|
||||
1
.github/workflows/cli-edge.yml
vendored
1
.github/workflows/cli-edge.yml
vendored
@@ -5,6 +5,7 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'cli/**'
|
||||
- 'skills/**'
|
||||
- 'packages/contracts/generated/api/openapi/**'
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
5
.github/workflows/cli-release.yml
vendored
5
.github/workflows/cli-release.yml
vendored
@@ -77,11 +77,6 @@ jobs:
|
||||
echo "dify_tag=${tag}" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::target Dify release ${tag}"
|
||||
|
||||
- name: Compatibility check
|
||||
env:
|
||||
DIFY_TAG: ${{ steps.resolve.outputs.dify_tag }}
|
||||
run: node scripts/release-naming.mjs compat-check "$DIFY_TAG"
|
||||
|
||||
release:
|
||||
name: build + attach standalone binaries (all targets)
|
||||
needs: validate
|
||||
|
||||
2
.github/workflows/cli-smoke.yml
vendored
2
.github/workflows/cli-smoke.yml
vendored
@@ -51,7 +51,7 @@ jobs:
|
||||
|
||||
- name: Run smoke against live dify
|
||||
working-directory: ./cli
|
||||
run: pnpm exec node scripts/run-smoke.ts --base-url http://localhost:5001
|
||||
run: DIFY_SERVER=http://localhost:5001 bun scripts/run-smoke.ts
|
||||
|
||||
- name: Dump dify logs on failure
|
||||
if: failure()
|
||||
|
||||
2
.github/workflows/main-ci.yml
vendored
2
.github/workflows/main-ci.yml
vendored
@@ -78,6 +78,7 @@ jobs:
|
||||
- 'docker/volumes/sandbox/conf/**'
|
||||
cli:
|
||||
- 'cli/**'
|
||||
- 'skills/**'
|
||||
- 'packages/contracts/**'
|
||||
- 'packages/tsconfig/**'
|
||||
- 'package.json'
|
||||
@@ -187,6 +188,7 @@ jobs:
|
||||
ts-common-style:
|
||||
- 'web/**'
|
||||
- 'cli/**'
|
||||
- 'skills/**'
|
||||
- 'e2e/**'
|
||||
- 'sdks/nodejs-client/**'
|
||||
- 'packages/**'
|
||||
|
||||
@@ -87,7 +87,6 @@ from graphon.model_runtime.utils.encoders import jsonable_encoder
|
||||
from graphon.variables import SecretVariable, SegmentType, VariableBase
|
||||
from graphon.variables.exc import VariableError
|
||||
from libs import helper
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from libs.helper import TimestampField, dump_response, to_timestamp, uuid_value
|
||||
from libs.login import login_required
|
||||
from models import Account, App
|
||||
@@ -101,10 +100,7 @@ from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError,
|
||||
from services.errors.llm import InvokeRateLimitError
|
||||
from services.workflow_ref_service import WorkflowRefService
|
||||
from services.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError, WorkflowService
|
||||
from services.workflow_variable_reference_validator import (
|
||||
format_variable_reference_errors,
|
||||
validate_variable_references,
|
||||
)
|
||||
from services.workflow_variable_reference_validator import advisory_variable_reference_warning
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1274,21 +1270,6 @@ class DraftWorkflowNodeRunApi(Resource):
|
||||
).model_dump(mode="json")
|
||||
|
||||
|
||||
def _advisory_variable_reference_warning(graph_text: str | None) -> str | None:
|
||||
"""Return a non-blocking publish warning. A checker failure must not fail publish."""
|
||||
if not graph_text:
|
||||
return None
|
||||
try:
|
||||
graph = json.loads(graph_text)
|
||||
if not isinstance(graph, dict):
|
||||
return None
|
||||
issues = validate_variable_references(graph)
|
||||
return format_variable_reference_errors(issues) if issues else None
|
||||
except Exception:
|
||||
logger.warning("Skipped advisory variable reference check", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/workflows/publish")
|
||||
class PublishedWorkflowApi(Resource):
|
||||
@console_ns.doc("get_published_workflow")
|
||||
@@ -1337,7 +1318,7 @@ class PublishedWorkflowApi(Resource):
|
||||
|
||||
workflow_service = WorkflowService()
|
||||
with sessionmaker(db.engine).begin() as session:
|
||||
workflow = workflow_service.publish_workflow(
|
||||
workflow = workflow_service.publish_app_workflow(
|
||||
session=session,
|
||||
app_model=app_model,
|
||||
account=current_user,
|
||||
@@ -1345,17 +1326,10 @@ class PublishedWorkflowApi(Resource):
|
||||
marked_comment=args.marked_comment or "",
|
||||
)
|
||||
|
||||
# Update app_model within the same session to ensure atomicity
|
||||
app_model_in_session = session.get(App, app_model.id)
|
||||
if app_model_in_session:
|
||||
app_model_in_session.workflow_id = workflow.id
|
||||
app_model_in_session.updated_by = current_user.id
|
||||
app_model_in_session.updated_at = naive_utc_now()
|
||||
|
||||
workflow_created_at = TimestampField().format(workflow.created_at)
|
||||
graph_text = workflow.graph
|
||||
|
||||
warning = _advisory_variable_reference_warning(graph_text)
|
||||
warning = advisory_variable_reference_warning(graph_text)
|
||||
payload: dict[str, object] = {
|
||||
"result": "success",
|
||||
"created_at": workflow_created_at,
|
||||
|
||||
@@ -61,6 +61,9 @@ from controllers.openapi._models import (
|
||||
DeviceMutateResponse,
|
||||
DevicePollRequest,
|
||||
DeviceTokenResponse,
|
||||
DraftWorkflowRunPayload,
|
||||
EnvVariableListResponse,
|
||||
EnvVariableSetPayload,
|
||||
FileUploadPayload,
|
||||
FormSubmitResponse,
|
||||
HealthResponse,
|
||||
@@ -77,13 +80,20 @@ from controllers.openapi._models import (
|
||||
OpenApiFormSubmitPayload,
|
||||
PermittedExternalAppsListQuery,
|
||||
PermittedExternalAppsListResponse,
|
||||
PublishPayload,
|
||||
PublishResponse,
|
||||
RestoreResponse,
|
||||
RevokeResponse,
|
||||
RunListQuery,
|
||||
RunListResponse,
|
||||
ServerVersionResponse,
|
||||
SessionListQuery,
|
||||
SessionListResponse,
|
||||
SessionRow,
|
||||
TaskStopResponse,
|
||||
UsageInfo,
|
||||
VersionListQuery,
|
||||
VersionListResponse,
|
||||
WorkflowRunData,
|
||||
WorkflowRunPayload,
|
||||
WorkspaceDetailResponse,
|
||||
@@ -93,6 +103,7 @@ from controllers.openapi._models import (
|
||||
WorkspaceSummaryResponse,
|
||||
)
|
||||
from fields.file_fields import FileResponse
|
||||
from fields.workflow_run_fields import WorkflowRunDetailResponse, WorkflowRunNodeExecutionListResponse
|
||||
from services.entities.dsl_entities import CheckDependenciesResult, Import
|
||||
|
||||
register_schema_models(
|
||||
@@ -108,13 +119,18 @@ register_schema_models(
|
||||
DevicePollRequest,
|
||||
DeviceLookupQuery,
|
||||
DeviceMutateRequest,
|
||||
DraftWorkflowRunPayload,
|
||||
FileUploadPayload,
|
||||
MemberInvitePayload,
|
||||
MemberListQuery,
|
||||
MemberRoleUpdatePayload,
|
||||
OpenApiFormSubmitPayload,
|
||||
EnvVariableSetPayload,
|
||||
PermittedExternalAppsListQuery,
|
||||
PublishPayload,
|
||||
RunListQuery,
|
||||
SessionListQuery,
|
||||
VersionListQuery,
|
||||
WorkflowRunPayload,
|
||||
WorkspaceListQuery,
|
||||
)
|
||||
@@ -160,6 +176,13 @@ register_response_schema_models(
|
||||
FileResponse,
|
||||
ServerVersionResponse,
|
||||
HealthResponse,
|
||||
RunListResponse,
|
||||
PublishResponse,
|
||||
VersionListResponse,
|
||||
RestoreResponse,
|
||||
EnvVariableListResponse,
|
||||
WorkflowRunDetailResponse,
|
||||
WorkflowRunNodeExecutionListResponse,
|
||||
)
|
||||
# Standalone definition for contract codegen; ErrorBody.code stays an open
|
||||
# string on the wire so old clients keep parsing future codes.
|
||||
@@ -169,6 +192,7 @@ from . import (
|
||||
account,
|
||||
app_dsl,
|
||||
app_run,
|
||||
app_workflow,
|
||||
apps,
|
||||
apps_permitted_external,
|
||||
files,
|
||||
@@ -186,6 +210,7 @@ __all__ = [
|
||||
"account",
|
||||
"app_dsl",
|
||||
"app_run",
|
||||
"app_workflow",
|
||||
"apps",
|
||||
"apps_permitted_external",
|
||||
"files",
|
||||
|
||||
@@ -68,6 +68,12 @@ class OpenApiErrorCode(StrEnum):
|
||||
RECIPIENT_SURFACE_MISMATCH = "recipient_surface_mismatch"
|
||||
CATALOG_STALE = "catalog_stale"
|
||||
TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE = "trigger_workflow_service_mode_unavailable"
|
||||
RUN_NOT_FOUND = "run_not_found"
|
||||
VERSION_NOT_FOUND = "version_not_found"
|
||||
VERSION_NOT_RESTORABLE = "version_not_restorable"
|
||||
DRAFT_NOT_FOUND = "draft_not_found"
|
||||
SECRET_MASK_UNKNOWN_ID = "secret_mask_unknown_id"
|
||||
SECRET_MASK_NOT_SECRET = "secret_mask_not_secret"
|
||||
|
||||
|
||||
class ErrorDetail(BaseModel):
|
||||
@@ -270,3 +276,39 @@ class InvalidFilePart(OpenApiError): # noqa: N818
|
||||
code = 422
|
||||
error_code = OpenApiErrorCode.INVALID_PARAM
|
||||
description = "A request part could not be used"
|
||||
|
||||
|
||||
class RunNotFound(OpenApiError): # noqa: N818
|
||||
code = 404
|
||||
error_code = OpenApiErrorCode.RUN_NOT_FOUND
|
||||
description = "No run with this id exists for this app."
|
||||
|
||||
|
||||
class VersionNotFound(OpenApiError): # noqa: N818
|
||||
code = 404
|
||||
error_code = OpenApiErrorCode.VERSION_NOT_FOUND
|
||||
description = "No version with this id exists for this app."
|
||||
|
||||
|
||||
class VersionNotRestorable(OpenApiError): # noqa: N818
|
||||
code = 400
|
||||
error_code = OpenApiErrorCode.VERSION_NOT_RESTORABLE
|
||||
description = "Only a published version can be restored."
|
||||
|
||||
|
||||
class DraftNotFound(OpenApiError): # noqa: N818
|
||||
code = 404
|
||||
error_code = OpenApiErrorCode.DRAFT_NOT_FOUND
|
||||
description = "This app has no draft."
|
||||
|
||||
|
||||
class SecretMaskUnknownId(OpenApiError): # noqa: N818
|
||||
code = 400
|
||||
error_code = OpenApiErrorCode.SECRET_MASK_UNKNOWN_ID
|
||||
description = "The value is the masked secret placeholder, but env_id is not an existing secret."
|
||||
|
||||
|
||||
class SecretMaskNotSecret(OpenApiError): # noqa: N818
|
||||
code = 400
|
||||
error_code = OpenApiErrorCode.SECRET_MASK_NOT_SECRET
|
||||
description = "The value is the masked secret placeholder, which only a secret value_type can keep."
|
||||
|
||||
@@ -12,7 +12,9 @@ from constants.oauth_bearer import SubjectType
|
||||
from controllers.common.human_input import HumanInputFormSubmitPayload
|
||||
from controllers.openapi._upload import UploadPart, UploadParts
|
||||
from enums import DeploymentEdition
|
||||
from libs.helper import EmailStr, UUIDStr, UUIDStrOrEmpty, uuid_value
|
||||
from fields.workflow_run_fields import WorkflowRunPaginationResponse
|
||||
from graphon.variables import SegmentType
|
||||
from libs.helper import EmailStr, UUIDStr, UUIDStrOrEmpty, to_timestamp, uuid_value
|
||||
from models.model import AppMode
|
||||
from services.app_dsl_service import Import
|
||||
|
||||
@@ -345,7 +347,7 @@ class RunPayloadBase(BaseModel):
|
||||
inputs: dict[str, Any] = Field(
|
||||
description=(
|
||||
"Variables declared by the app. The exact shape is per app: read `input_schema` from "
|
||||
"console_app.describe. A file variable takes a Dify file mapping (remote url or upload id) here, "
|
||||
"describe.console_app. A file variable takes a Dify file mapping (remote url or upload id) here, "
|
||||
"or a local file in `files`, not both."
|
||||
)
|
||||
)
|
||||
@@ -366,6 +368,10 @@ class WorkflowRunPayload(RunPayloadBase, _WorkflowVersionFields):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class DraftWorkflowRunPayload(RunPayloadBase):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class ChatRunPayload(RunPayloadBase, _ConversationFields):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -498,6 +504,11 @@ class AppDslImportPayload(BaseModel):
|
||||
icon: str | None = Field(None)
|
||||
icon_background: str | None = Field(None)
|
||||
app_id: str | None = Field(None, description="Existing app ID to overwrite (workflow/advanced-chat apps only)")
|
||||
draft_hash: str | None = Field(
|
||||
None,
|
||||
description="draft_hash from the export or restore this import is based on. The import fails if the "
|
||||
"draft's graph, features, environment variables or conversation variables changed since. Requires app_id",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_source_by_mode(self) -> AppDslImportPayload:
|
||||
@@ -521,6 +532,11 @@ class AppDslExportResponse(BaseModel):
|
||||
"""Export DSL response."""
|
||||
|
||||
data: str = Field(..., description="DSL YAML string")
|
||||
draft_hash: str | None = Field(
|
||||
None,
|
||||
description="Hash of the draft's graph, features, environment variables and conversation variables; "
|
||||
"pass it to the import to refuse overwriting newer edits",
|
||||
)
|
||||
|
||||
|
||||
class AppDslImportResponse(Import, Hinted):
|
||||
@@ -550,3 +566,99 @@ class HumanInputFormDefinitionResponse(BaseModel):
|
||||
resolved_default_values: dict[str, str]
|
||||
user_actions: list[dict[str, Any]] = Field(default_factory=list)
|
||||
expiration_time: int | None = None
|
||||
|
||||
|
||||
class RunListQuery(BaseModel):
|
||||
last_id: UUIDStr | None = Field(None, description="Cursor: id of the last run on the previous page")
|
||||
limit: int = Field(20, ge=1, le=MAX_PAGE_LIMIT)
|
||||
status: Literal["running", "succeeded", "failed", "stopped", "partial-succeeded"] | None = None
|
||||
triggered_from: Literal["debugging", "app-run"] | None = Field(
|
||||
None, description="debugging: draft test runs; app-run: real use. Omitted: debugging, as in the console"
|
||||
)
|
||||
|
||||
|
||||
class RunListResponse(WorkflowRunPaginationResponse, Hinted):
|
||||
"""Cursor page of runs; `hints` carries the next page."""
|
||||
|
||||
|
||||
class PublishPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
marked_name: str = Field("", max_length=20, description="Version name")
|
||||
marked_comment: str = Field("", max_length=100, description="Version note")
|
||||
|
||||
|
||||
class PublishResponse(BaseModel):
|
||||
version_id: str
|
||||
created_at: int
|
||||
warning: str | None = Field(None, description="Variable references that may read a skipped branch")
|
||||
|
||||
|
||||
class VersionListQuery(PageQuery):
|
||||
named_only: bool = Field(False, description="Only versions that have a name")
|
||||
|
||||
|
||||
class VersionRow(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
marked_name: str
|
||||
marked_comment: str
|
||||
created_by: str | None = None
|
||||
created_at: int
|
||||
|
||||
@field_validator("created_at", mode="before")
|
||||
@classmethod
|
||||
def _timestamp(cls, value: datetime | int) -> int | None:
|
||||
return to_timestamp(value)
|
||||
|
||||
|
||||
class VersionListResponse(Hinted):
|
||||
"""Page of published versions, newest first; there is no total, `hints` carries the next page."""
|
||||
|
||||
page: int
|
||||
limit: int
|
||||
has_more: bool
|
||||
data: list[VersionRow]
|
||||
|
||||
|
||||
class EnvVariableRow(BaseModel):
|
||||
id: str = Field(description="What set and delete take to address this variable")
|
||||
name: str = Field(description="What nodes use to refer to this variable")
|
||||
description: str = ""
|
||||
value_type: str
|
||||
value: Any = Field(description="The value; a secret with a value is masked, an empty one reads as empty")
|
||||
|
||||
|
||||
class EnvVariableListResponse(BaseModel):
|
||||
data: list[EnvVariableRow]
|
||||
|
||||
|
||||
class EnvVariableValueType(StrEnum):
|
||||
"""Value types the draft environment-variable ``set`` op accepts.
|
||||
|
||||
A curated subset of ``SegmentType``: what the console's environment-variable editor
|
||||
allows (``ENVIRONMENT_VARIABLE_SUPPORTED_TYPES`` in controllers/console/app/workflow.py).
|
||||
Members reference ``SegmentType.*.value`` so the subset relationship is type-checked.
|
||||
"""
|
||||
|
||||
STRING = SegmentType.STRING.value
|
||||
NUMBER = SegmentType.NUMBER.value
|
||||
SECRET = SegmentType.SECRET.value
|
||||
|
||||
|
||||
class EnvVariableSetPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str = Field(description="Variable name")
|
||||
value_type: EnvVariableValueType = Field(description="string, number or secret")
|
||||
value: Any = Field(description="The value; sending the masked value of an existing secret keeps the stored one")
|
||||
description: str = Field("", description="What the variable is for")
|
||||
|
||||
|
||||
class RestoreResponse(BaseModel):
|
||||
result: Literal["success"]
|
||||
draft_hash: str = Field(
|
||||
description="Hash of the restored draft's graph, features, environment variables and conversation "
|
||||
"variables; pass it to a DSL import as draft_hash"
|
||||
)
|
||||
|
||||
@@ -34,7 +34,7 @@ from services.entities.account_entities import AccountSnapshot
|
||||
@openapi_ns.route("/account")
|
||||
class AccountApi(Resource):
|
||||
@endpoint(
|
||||
op="account.get",
|
||||
op="describe.account",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Current account",
|
||||
examples=(Example(title="Show the logged-in account and its workspaces", input={}),),
|
||||
@@ -61,7 +61,7 @@ class AccountApi(Resource):
|
||||
@openapi_ns.route("/account/sessions/self")
|
||||
class AccountSessionsSelfApi(Resource):
|
||||
@endpoint(
|
||||
op="account.sessions.revoke_current",
|
||||
op="delete.account.current_session",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Revoke the session behind this token",
|
||||
examples=(Example(title="Log out the session behind this token", input={}),),
|
||||
@@ -76,7 +76,7 @@ class AccountSessionsSelfApi(Resource):
|
||||
@openapi_ns.route("/account/sessions")
|
||||
class AccountSessionsApi(Resource):
|
||||
@endpoint(
|
||||
op="account.sessions.list",
|
||||
op="get.account.session",
|
||||
kind=Kind.LIST,
|
||||
summary="List login sessions of the current account",
|
||||
examples=(Example(title="List login sessions, first page", input={"page": 1, "limit": 20}),),
|
||||
@@ -102,7 +102,7 @@ class AccountSessionsApi(Resource):
|
||||
@openapi_ns.route("/account/sessions/<string:session_id>")
|
||||
class AccountSessionByIdApi(Resource):
|
||||
@endpoint(
|
||||
op="account.sessions.revoke",
|
||||
op="delete.account.session",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Revoke one login session by id",
|
||||
examples=(Example(title="Log out one device by session id", input={"session_id": "<session_id>"}),),
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
from typing import Final
|
||||
from uuid import UUID
|
||||
|
||||
from flask_restx import Resource
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
from constants.oauth_bearer import Scope
|
||||
from controllers.common.rbac import PlainApp, RBACCheck, RBACPermission, Workspace
|
||||
from controllers.common.rbac import RBACCheck, RBACPermission, Workspace
|
||||
from controllers.openapi import openapi_ns
|
||||
from controllers.openapi._contract import Example, Kind, endpoint, op_of
|
||||
from controllers.openapi._models import (
|
||||
@@ -19,22 +20,25 @@ from controllers.openapi._models import (
|
||||
)
|
||||
from controllers.openapi.auth.context import Context
|
||||
from controllers.openapi.auth.requirements import (
|
||||
CheckAppApiEnabled,
|
||||
EDITOR_ROLES,
|
||||
CheckRBACPermission,
|
||||
CheckScope,
|
||||
CheckSubject,
|
||||
CheckWorkspaceMember,
|
||||
CheckWorkspaceRole,
|
||||
account_app_guards,
|
||||
)
|
||||
from controllers.openapi.auth.subjects import AccountSubject
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from machinery.context import RequestContext
|
||||
from models.account import TenantAccountRole
|
||||
from services.app_dsl_service import AppDslService
|
||||
from services.entities.dsl_entities import AppImportParams, CheckDependenciesResult, Import, ImportStatus
|
||||
from services.errors.account import NoPermissionError
|
||||
from services.errors.app import WorkflowNotFoundError
|
||||
from services.workflow_service import WorkflowService
|
||||
|
||||
_DSL_READ_GUARDS: Final = account_app_guards(RBACPermission.APP_IMPORT_EXPORT_DSL, scope=Scope.APPS_READ, editor=True)
|
||||
|
||||
|
||||
def _import_response(result: Import, *, workspace_id: str) -> AppDslImportResponse:
|
||||
@@ -66,7 +70,7 @@ class AppDslImportApi(Resource):
|
||||
|
||||
@endpoint(
|
||||
account_context=True,
|
||||
op="console_app.dsl.import",
|
||||
op="import.console_app.dsl",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Import an app from DSL text or URL",
|
||||
examples=(
|
||||
@@ -88,7 +92,7 @@ class AppDslImportApi(Resource):
|
||||
CheckScope(Scope.WORKSPACE_WRITE),
|
||||
CheckWorkspaceMember(),
|
||||
CheckRBACPermission(RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, Workspace())),
|
||||
CheckWorkspaceRole(frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER})),
|
||||
CheckWorkspaceRole(EDITOR_ROLES),
|
||||
),
|
||||
body=AppDslImportPayload,
|
||||
returns=(
|
||||
@@ -129,7 +133,7 @@ class AppDslImportConfirmApi(Resource):
|
||||
|
||||
@endpoint(
|
||||
account_context=True,
|
||||
op="console_app.dsl.import_confirm",
|
||||
op="confirm.console_app.dsl_import",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Confirm a pending DSL import",
|
||||
examples=(Example(title="Confirm an import that is pending confirmation", input={"import_id": "<import_id>"}),),
|
||||
@@ -138,7 +142,7 @@ class AppDslImportConfirmApi(Resource):
|
||||
CheckScope(Scope.WORKSPACE_WRITE),
|
||||
CheckWorkspaceMember(),
|
||||
CheckRBACPermission(RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, Workspace())),
|
||||
CheckWorkspaceRole(frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER})),
|
||||
CheckWorkspaceRole(EDITOR_ROLES),
|
||||
),
|
||||
returns=((HTTPStatus.OK, Import, "Import confirmed"), (HTTPStatus.BAD_REQUEST, Import, "Import failed")),
|
||||
)
|
||||
@@ -167,7 +171,7 @@ class AppDslExportApi(Resource):
|
||||
"""
|
||||
|
||||
@endpoint(
|
||||
op="console_app.dsl.export",
|
||||
op="export.console_app.dsl",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Export app DSL as YAML text inside a JSON object",
|
||||
examples=(
|
||||
@@ -177,18 +181,15 @@ class AppDslExportApi(Resource):
|
||||
input={"app_id": "<app_id>", "workflow_id": "<workflow_id>", "include_secret": True},
|
||||
),
|
||||
),
|
||||
requirements=(
|
||||
CheckSubject(allowed=(AccountSubject,)),
|
||||
CheckAppApiEnabled(),
|
||||
CheckWorkspaceMember(),
|
||||
CheckScope(Scope.APPS_READ),
|
||||
CheckRBACPermission(RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, PlainApp())),
|
||||
CheckWorkspaceRole(frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER})),
|
||||
),
|
||||
requirements=_DSL_READ_GUARDS,
|
||||
query=AppDslExportQuery,
|
||||
returns=(200, AppDslExportResponse, "Export successful"),
|
||||
)
|
||||
def get(self, ctx: Context, app_id: str, *, query: AppDslExportQuery):
|
||||
# Capture the draft hash before building the DSL: an edit that commits in between would
|
||||
# otherwise be described by a hash newer than the exported YAML, defeating the guard
|
||||
# draft_hash exists for (a later import silently overwriting that edit).
|
||||
draft = WorkflowService().get_draft_workflow(app_model=ctx.app, session=db.session())
|
||||
try:
|
||||
data = AppDslService.export_dsl(
|
||||
app_model=ctx.app,
|
||||
@@ -198,7 +199,7 @@ class AppDslExportApi(Resource):
|
||||
)
|
||||
except WorkflowNotFoundError as exc:
|
||||
return str(exc), 404
|
||||
return AppDslExportResponse(data=data), 200
|
||||
return AppDslExportResponse(data=data, draft_hash=draft.content_hash if draft else None), 200
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/dependencies:check")
|
||||
@@ -213,18 +214,11 @@ class AppDslCheckDependenciesApi(Resource):
|
||||
|
||||
@endpoint(
|
||||
account_context=True,
|
||||
op="console_app.dependencies.check",
|
||||
op="check.console_app.dependency",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Check plugin dependencies of an app",
|
||||
examples=(Example(title="Check which plugins an app needs", input={"app_id": "<app_id>"}),),
|
||||
requirements=(
|
||||
CheckSubject(allowed=(AccountSubject,)),
|
||||
CheckAppApiEnabled(),
|
||||
CheckWorkspaceMember(),
|
||||
CheckScope(Scope.APPS_READ),
|
||||
CheckRBACPermission(RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, PlainApp())),
|
||||
CheckWorkspaceRole(frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER})),
|
||||
),
|
||||
requirements=_DSL_READ_GUARDS,
|
||||
returns=(HTTPStatus.OK, CheckDependenciesResult, "Dependencies checked"),
|
||||
)
|
||||
def get(self, ctx: RequestContext, app_id: str):
|
||||
|
||||
@@ -32,6 +32,7 @@ from controllers.openapi._models import (
|
||||
AdvancedChatRunPayload,
|
||||
ChatRunPayload,
|
||||
CompletionRunPayload,
|
||||
DraftWorkflowRunPayload,
|
||||
Hint,
|
||||
RunPayloadBase,
|
||||
TaskStopResponse,
|
||||
@@ -45,6 +46,8 @@ from controllers.openapi.auth.requirements import (
|
||||
CheckScope,
|
||||
CheckSubject,
|
||||
CheckWorkspaceMember,
|
||||
Requirement,
|
||||
account_app_guards,
|
||||
)
|
||||
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject
|
||||
from controllers.openapi.human_input_form import with_form_hints
|
||||
@@ -140,16 +143,19 @@ _RUN_GUARDS: Final = (
|
||||
CheckRBACPermission(RBACCheck(RBACPermission.APP_TEST_AND_RUN, PlainApp())),
|
||||
CheckAppAccess(),
|
||||
)
|
||||
_DRAFT_RUN_GUARDS: Final = account_app_guards(RBACPermission.APP_TEST_AND_RUN, scope=Scope.APPS_RUN, editor=True)
|
||||
# Draft runs raise debugger forms, which the form submit op does not accept.
|
||||
_DRAFT_FORM_NOTE: Final = "A human-input pause in a draft run cannot be resumed over openapi."
|
||||
_STREAM_RESULT: Final = (200, EventStreamResponse, "Run result (SSE stream)")
|
||||
|
||||
|
||||
def _generate(app: App, caller: Any, args: dict[str, Any], session: Session):
|
||||
def _generate(app: App, caller: Any, args: dict[str, Any], session: Session, invoke_from: InvokeFrom):
|
||||
return AppGenerateService.generate(
|
||||
session=session,
|
||||
app_model=app,
|
||||
user=caller,
|
||||
args=args,
|
||||
invoke_from=InvokeFrom.OPENAPI,
|
||||
invoke_from=invoke_from,
|
||||
streaming=True,
|
||||
)
|
||||
|
||||
@@ -164,12 +170,12 @@ def _generate_args(ctx: Context, payload: RunPayloadBase, *, exclude: Collection
|
||||
return args
|
||||
|
||||
|
||||
def _stream(ctx: Context, args: dict[str, Any]):
|
||||
def _stream(ctx: Context, args: dict[str, Any], invoke_from: InvokeFrom):
|
||||
with _translate_service_errors():
|
||||
return _generate(ctx.app, ctx.caller, args, ctx.session)
|
||||
return _generate(ctx.app, ctx.caller, args, ctx.session, invoke_from)
|
||||
|
||||
|
||||
def _require_mode(app: App, *modes: AppMode) -> None:
|
||||
def require_mode(app: App, *modes: AppMode) -> None:
|
||||
if app.mode not in modes:
|
||||
raise UnprocessableEntity("app_mode_mismatch")
|
||||
|
||||
@@ -231,13 +237,15 @@ class _RunRoute:
|
||||
modes: tuple[AppMode, ...]
|
||||
hints: tuple[HintLayer, ...] = ()
|
||||
examples: tuple[Example, ...] = ()
|
||||
invoke_from: InvokeFrom = InvokeFrom.OPENAPI
|
||||
requirements: tuple[Requirement, ...] = _RUN_GUARDS
|
||||
|
||||
|
||||
_RUN_ROUTES: Final = (
|
||||
_RunRoute(
|
||||
resource="WorkflowRunApi",
|
||||
segment="workflow",
|
||||
op="console_app.workflow.run",
|
||||
op="run.console_app.workflow",
|
||||
summary="Run a workflow app; streams workflow events",
|
||||
payload=WorkflowRunPayload,
|
||||
modes=(AppMode.WORKFLOW,),
|
||||
@@ -260,7 +268,7 @@ _RUN_ROUTES: Final = (
|
||||
_RunRoute(
|
||||
resource="ChatRunApi",
|
||||
segment="chat",
|
||||
op="console_app.chat.run",
|
||||
op="run.console_app.chat",
|
||||
summary="Run a chat or agent app; streams message events",
|
||||
payload=ChatRunPayload,
|
||||
modes=(AppMode.CHAT, AppMode.AGENT_CHAT),
|
||||
@@ -293,7 +301,7 @@ _RUN_ROUTES: Final = (
|
||||
_RunRoute(
|
||||
resource="AdvancedChatRunApi",
|
||||
segment="advanced-chat",
|
||||
op="console_app.advanced_chat.run",
|
||||
op="run.console_app.advanced_chat",
|
||||
summary="Run an advanced-chat (chatflow) app; streams message and workflow events",
|
||||
payload=AdvancedChatRunPayload,
|
||||
modes=(AppMode.ADVANCED_CHAT,),
|
||||
@@ -317,7 +325,7 @@ _RUN_ROUTES: Final = (
|
||||
_RunRoute(
|
||||
resource="CompletionRunApi",
|
||||
segment="completion",
|
||||
op="console_app.completion.run",
|
||||
op="run.console_app.completion",
|
||||
summary="Run a completion app; streams message events",
|
||||
payload=CompletionRunPayload,
|
||||
modes=(AppMode.COMPLETION,),
|
||||
@@ -332,6 +340,35 @@ _RUN_ROUTES: Final = (
|
||||
),
|
||||
),
|
||||
),
|
||||
_RunRoute(
|
||||
resource="WorkflowDraftRunApi",
|
||||
segment="draft/workflow",
|
||||
op="run.console_app.draft.workflow",
|
||||
summary="Test-run the draft of a workflow app; streams workflow events. " + _DRAFT_FORM_NOTE,
|
||||
payload=DraftWorkflowRunPayload,
|
||||
modes=(AppMode.WORKFLOW,),
|
||||
examples=(
|
||||
Example(title="Test the draft with one input", input={"app_id": "<app_id>", "inputs": {"topic": "test"}}),
|
||||
),
|
||||
invoke_from=InvokeFrom.DEBUGGER,
|
||||
requirements=_DRAFT_RUN_GUARDS,
|
||||
),
|
||||
_RunRoute(
|
||||
resource="AdvancedChatDraftRunApi",
|
||||
segment="draft/advanced-chat",
|
||||
op="run.console_app.draft.advanced_chat",
|
||||
summary="Test-run the draft of an advanced-chat app; streams message and workflow events. " + _DRAFT_FORM_NOTE,
|
||||
payload=ChatRunPayload,
|
||||
modes=(AppMode.ADVANCED_CHAT,),
|
||||
hints=(_reply_layer,),
|
||||
examples=(
|
||||
Example(
|
||||
title="Test the draft with one message", input={"app_id": "<app_id>", "query": "hello", "inputs": {}}
|
||||
),
|
||||
),
|
||||
invoke_from=InvokeFrom.DEBUGGER,
|
||||
requirements=_DRAFT_RUN_GUARDS,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -340,14 +377,14 @@ def _run_api(route: _RunRoute) -> type[Resource]:
|
||||
op=route.op,
|
||||
kind=Kind.SSE,
|
||||
summary=route.summary,
|
||||
requirements=_RUN_GUARDS,
|
||||
requirements=route.requirements,
|
||||
body=route.payload,
|
||||
examples=route.examples,
|
||||
returns=_STREAM_RESULT,
|
||||
)
|
||||
def post(self: Resource, ctx: Context, app_id: str, *, body: RunPayloadBase):
|
||||
_require_mode(ctx.app, *route.modes)
|
||||
stream = _stream(ctx, _generate_args(ctx, body))
|
||||
require_mode(ctx.app, *route.modes)
|
||||
stream = _stream(ctx, _generate_args(ctx, body), route.invoke_from)
|
||||
for layer in route.hints:
|
||||
stream = layer(stream, route.op, ctx.app.id)
|
||||
return _respond(ctx, stream)
|
||||
@@ -356,13 +393,20 @@ def _run_api(route: _RunRoute) -> type[Resource]:
|
||||
return openapi_ns.route(f"/apps/<string:app_id>/{route.segment}:run")(resource)
|
||||
|
||||
|
||||
WorkflowRunApi, ChatRunApi, AdvancedChatRunApi, CompletionRunApi = (_run_api(route) for route in _RUN_ROUTES)
|
||||
(
|
||||
WorkflowRunApi,
|
||||
ChatRunApi,
|
||||
AdvancedChatRunApi,
|
||||
CompletionRunApi,
|
||||
WorkflowDraftRunApi,
|
||||
AdvancedChatDraftRunApi,
|
||||
) = (_run_api(route) for route in _RUN_ROUTES)
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/tasks/<string:task_id>:stop")
|
||||
class AppRunTaskStopApi(Resource):
|
||||
@endpoint(
|
||||
op="run.stop",
|
||||
op="stop.run",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Stop a running task",
|
||||
examples=(Example(title="Stop a running task", input={"app_id": "<app_id>", "task_id": "<task_id>"}),),
|
||||
|
||||
394
api/controllers/openapi/app_workflow.py
Normal file
394
api/controllers/openapi/app_workflow.py
Normal file
@@ -0,0 +1,394 @@
|
||||
"""Build routes on /openapi/v1 for workflow and advanced-chat apps: run history,
|
||||
publishing and versions, and draft environment variables."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from http import HTTPStatus
|
||||
from typing import Final
|
||||
from uuid import UUID
|
||||
|
||||
from flask_restx import Resource
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from werkzeug.exceptions import BadRequest
|
||||
|
||||
from constants import HIDDEN_VALUE
|
||||
from constants.oauth_bearer import Scope
|
||||
from controllers.common.fields import SimpleResultResponse
|
||||
from controllers.common.rbac import RBACPermission
|
||||
from controllers.openapi import openapi_ns
|
||||
from controllers.openapi._contract import Example, Kind, endpoint
|
||||
from controllers.openapi._errors import (
|
||||
DraftNotFound,
|
||||
OpenApiError,
|
||||
RunNotFound,
|
||||
SecretMaskNotSecret,
|
||||
SecretMaskUnknownId,
|
||||
VersionNotFound,
|
||||
VersionNotRestorable,
|
||||
)
|
||||
from controllers.openapi._models import (
|
||||
EnvVariableListResponse,
|
||||
EnvVariableRow,
|
||||
EnvVariableSetPayload,
|
||||
EnvVariableValueType,
|
||||
Hint,
|
||||
PublishPayload,
|
||||
PublishResponse,
|
||||
RestoreResponse,
|
||||
RunListQuery,
|
||||
RunListResponse,
|
||||
VersionListQuery,
|
||||
VersionListResponse,
|
||||
VersionRow,
|
||||
)
|
||||
from controllers.openapi.app_run import require_mode
|
||||
from controllers.openapi.auth.context import Context
|
||||
from controllers.openapi.auth.requirements import account_app_guards
|
||||
from core.helper import encrypter
|
||||
from core.logging.context import get_request_id, get_trace_id
|
||||
from core.workflow.llm_environment_variable import environment_variable_value_type
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from factories import variable_factory
|
||||
from fields.workflow_run_fields import (
|
||||
WorkflowRunDetailResponse,
|
||||
WorkflowRunNodeExecutionListResponse,
|
||||
workflow_run_pagination_response_source,
|
||||
workflow_run_response_source,
|
||||
)
|
||||
from graphon.variables import SecretVariable, VariableBase
|
||||
from graphon.variables.exc import VariableError
|
||||
from libs.helper import to_timestamp
|
||||
from machinery.context import RequestContext
|
||||
from models import AppMode, WorkflowRunTriggeredFrom
|
||||
from models.workflow import Workflow, WorkflowRun
|
||||
from services.errors.app import IsDraftWorkflowError, WorkflowNotFoundError
|
||||
from services.workflow_run_service import WorkflowRunListArgs
|
||||
from services.workflow_service import WorkflowService
|
||||
from services.workflow_variable_reference_validator import advisory_variable_reference_warning
|
||||
|
||||
GRAPH_MODES: Final = (AppMode.WORKFLOW, AppMode.ADVANCED_CHAT)
|
||||
_RUN_LIST_OP: Final = "get.console_app.run"
|
||||
_VERSION_LIST_OP: Final = "get.console_app.version"
|
||||
|
||||
_RUN_READ_GUARDS: Final = account_app_guards(
|
||||
RBACPermission.APP_CREATE_AND_MANAGEMENT, scope=Scope.APPS_READ, editor=False
|
||||
)
|
||||
_VERSION_READ_GUARDS: Final = account_app_guards(RBACPermission.APP_VIEW_LAYOUT, scope=Scope.APPS_READ, editor=True)
|
||||
_RELEASE_GUARDS: Final = account_app_guards(
|
||||
RBACPermission.APP_RELEASE_AND_VERSION, scope=Scope.WORKSPACE_WRITE, editor=True
|
||||
)
|
||||
_ENV_READ_GUARDS: Final = account_app_guards(RBACPermission.APP_VIEW_LAYOUT, scope=Scope.APPS_READ, editor=True)
|
||||
_ENV_WRITE_GUARDS: Final = account_app_guards(RBACPermission.APP_EDIT, scope=Scope.WORKSPACE_WRITE, editor=True)
|
||||
|
||||
|
||||
def _request_context(ctx: Context) -> RequestContext:
|
||||
return RequestContext(get_request_id(), get_trace_id(), ctx.account.id, ctx.workspace.id)
|
||||
|
||||
|
||||
def _require_uuid(value: str, *, not_found: type[OpenApiError]) -> str:
|
||||
"""Parse a path id as a UUID or raise 404; an unparsed id would otherwise reach
|
||||
a UUID-typed database column and fail as a 500."""
|
||||
try:
|
||||
return str(UUID(value))
|
||||
except ValueError:
|
||||
raise not_found()
|
||||
|
||||
|
||||
def _require_run(ctx: Context, run_id: str) -> WorkflowRun:
|
||||
run = application_services().workflow_runs.get_workflow_run(
|
||||
_request_context(ctx), app_id=ctx.app.id, run_id=_require_uuid(run_id, not_found=RunNotFound)
|
||||
)
|
||||
if run is None:
|
||||
raise RunNotFound()
|
||||
return run
|
||||
|
||||
|
||||
def with_next_cursor(page: RunListResponse, *, op: str, app_id: str, query: RunListQuery) -> RunListResponse:
|
||||
if page.has_more and page.data:
|
||||
next_input = {"app_id": app_id, **query.model_dump(exclude_none=True), "last_id": page.data[-1].id}
|
||||
page.hints = [Hint(summary="Next page", op=op, input=next_input)]
|
||||
return page
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/runs")
|
||||
class AppRunListApi(Resource):
|
||||
@endpoint(
|
||||
op=_RUN_LIST_OP,
|
||||
kind=Kind.OBJECT,
|
||||
summary="List runs of a workflow or advanced-chat app, newest first",
|
||||
examples=(
|
||||
Example(
|
||||
title="Latest failed runs from real use",
|
||||
input={"app_id": "<app_id>", "status": "failed", "triggered_from": "app-run"},
|
||||
),
|
||||
Example(
|
||||
title="My latest draft test run",
|
||||
input={"app_id": "<app_id>", "triggered_from": "debugging", "limit": 1},
|
||||
),
|
||||
),
|
||||
requirements=_RUN_READ_GUARDS,
|
||||
query=RunListQuery,
|
||||
returns=(HTTPStatus.OK, RunListResponse, "Run list"),
|
||||
)
|
||||
def get(self, ctx: Context, app_id: str, *, query: RunListQuery):
|
||||
require_mode(ctx.app, *GRAPH_MODES)
|
||||
args: WorkflowRunListArgs = {"limit": query.limit}
|
||||
if query.last_id is not None:
|
||||
args["last_id"] = query.last_id
|
||||
if query.status is not None:
|
||||
args["status"] = query.status
|
||||
pagination = application_services().workflow_runs.get_paginate_workflow_runs(
|
||||
_request_context(ctx),
|
||||
app_id=ctx.app.id,
|
||||
args=args,
|
||||
triggered_from=WorkflowRunTriggeredFrom(query.triggered_from or WorkflowRunTriggeredFrom.DEBUGGING),
|
||||
)
|
||||
page = RunListResponse.model_validate(workflow_run_pagination_response_source(pagination, session=ctx.session))
|
||||
return with_next_cursor(page, op=_RUN_LIST_OP, app_id=ctx.app.id, query=query)
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/runs/<string:run_id>")
|
||||
class AppRunDescribeApi(Resource):
|
||||
@endpoint(
|
||||
op="describe.console_app.run",
|
||||
kind=Kind.OBJECT,
|
||||
summary="One run: inputs, outputs, error, tokens and timing",
|
||||
examples=(Example(title="Read one run", input={"app_id": "<app_id>", "run_id": "<run_id>"}),),
|
||||
requirements=_RUN_READ_GUARDS,
|
||||
returns=(HTTPStatus.OK, WorkflowRunDetailResponse, "Run detail"),
|
||||
)
|
||||
def get(self, ctx: Context, app_id: str, run_id: str):
|
||||
require_mode(ctx.app, *GRAPH_MODES)
|
||||
run = _require_run(ctx, run_id)
|
||||
return WorkflowRunDetailResponse.model_validate(workflow_run_response_source(run, session=ctx.session))
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/runs/<string:run_id>/nodes")
|
||||
class AppRunNodeListApi(Resource):
|
||||
@endpoint(
|
||||
op="get.console_app.run.node",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Each node step of a run, to find the one that failed",
|
||||
examples=(Example(title="Node steps of one run", input={"app_id": "<app_id>", "run_id": "<run_id>"}),),
|
||||
requirements=_RUN_READ_GUARDS,
|
||||
returns=(HTTPStatus.OK, WorkflowRunNodeExecutionListResponse, "Node steps"),
|
||||
)
|
||||
def get(self, ctx: Context, app_id: str, run_id: str):
|
||||
require_mode(ctx.app, *GRAPH_MODES)
|
||||
run = _require_run(ctx, run_id)
|
||||
steps = application_services().workflow_runs.get_workflow_run_node_executions(
|
||||
_request_context(ctx), app_id=ctx.app.id, run_id=run.id
|
||||
)
|
||||
return WorkflowRunNodeExecutionListResponse.model_validate({"data": steps})
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>:publish")
|
||||
class AppPublishApi(Resource):
|
||||
@endpoint(
|
||||
op="publish.console_app",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Publish the draft as a new version and make the app use it",
|
||||
examples=(
|
||||
Example(title="Publish the draft", input={"app_id": "<app_id>"}),
|
||||
Example(
|
||||
title="Publish a named version",
|
||||
input={"app_id": "<app_id>", "marked_name": "v2", "marked_comment": "Adds a retry"},
|
||||
),
|
||||
),
|
||||
requirements=_RELEASE_GUARDS,
|
||||
body=PublishPayload,
|
||||
returns=(HTTPStatus.OK, PublishResponse, "Published"),
|
||||
)
|
||||
def post(self, ctx: Context, app_id: str, *, body: PublishPayload):
|
||||
require_mode(ctx.app, *GRAPH_MODES)
|
||||
try:
|
||||
with sessionmaker(db.engine).begin() as session:
|
||||
workflow = WorkflowService().publish_app_workflow(
|
||||
session=session,
|
||||
app_model=ctx.app,
|
||||
account=ctx.account,
|
||||
marked_name=body.marked_name,
|
||||
marked_comment=body.marked_comment,
|
||||
)
|
||||
published = PublishResponse(
|
||||
version_id=workflow.id,
|
||||
created_at=to_timestamp(workflow.created_at),
|
||||
warning=advisory_variable_reference_warning(workflow.graph),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise BadRequest(str(exc)) from exc
|
||||
return published
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/versions")
|
||||
class AppVersionListApi(Resource):
|
||||
@endpoint(
|
||||
op=_VERSION_LIST_OP,
|
||||
kind=Kind.OBJECT,
|
||||
summary="List published versions, newest first",
|
||||
examples=(
|
||||
Example(title="Latest versions", input={"app_id": "<app_id>"}),
|
||||
Example(title="Only named versions", input={"app_id": "<app_id>", "named_only": True}),
|
||||
),
|
||||
requirements=_VERSION_READ_GUARDS,
|
||||
query=VersionListQuery,
|
||||
returns=(HTTPStatus.OK, VersionListResponse, "Version list"),
|
||||
)
|
||||
def get(self, ctx: Context, app_id: str, *, query: VersionListQuery):
|
||||
require_mode(ctx.app, *GRAPH_MODES)
|
||||
workflows, has_more = WorkflowService().get_all_published_workflow(
|
||||
session=ctx.session,
|
||||
app_model=ctx.app,
|
||||
page=query.page,
|
||||
limit=query.limit,
|
||||
user_id=None,
|
||||
named_only=query.named_only,
|
||||
include_draft=False,
|
||||
)
|
||||
page = VersionListResponse(
|
||||
page=query.page,
|
||||
limit=query.limit,
|
||||
has_more=has_more,
|
||||
data=[VersionRow.model_validate(workflow) for workflow in workflows],
|
||||
)
|
||||
if has_more:
|
||||
next_input = {"app_id": ctx.app.id, **query.model_dump(), "page": query.page + 1}
|
||||
page.hints = [Hint(summary="Next page", op=_VERSION_LIST_OP, input=next_input)]
|
||||
return page
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/versions/<string:version_id>:restore")
|
||||
class AppVersionRestoreApi(Resource):
|
||||
@endpoint(
|
||||
op="restore.console_app.version",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Copy a published version into the draft; publish again to make it live",
|
||||
examples=(
|
||||
Example(title="Restore an older version", input={"app_id": "<app_id>", "version_id": "<version_id>"}),
|
||||
),
|
||||
requirements=_RELEASE_GUARDS,
|
||||
returns=(HTTPStatus.OK, RestoreResponse, "Restored into the draft"),
|
||||
)
|
||||
def post(self, ctx: Context, app_id: str, version_id: str):
|
||||
require_mode(ctx.app, *GRAPH_MODES)
|
||||
version_id = _require_uuid(version_id, not_found=VersionNotFound)
|
||||
try:
|
||||
draft = WorkflowService().restore_published_workflow_to_draft(
|
||||
app_model=ctx.app, workflow_id=version_id, account=ctx.account, session=db.session()
|
||||
)
|
||||
except IsDraftWorkflowError as exc:
|
||||
raise VersionNotRestorable() from exc
|
||||
except WorkflowNotFoundError as exc:
|
||||
raise VersionNotFound() from exc
|
||||
except ValueError as exc:
|
||||
raise BadRequest(str(exc)) from exc
|
||||
return RestoreResponse(result="success", draft_hash=draft.content_hash)
|
||||
|
||||
|
||||
def env_variable_rows(variables: Iterable[VariableBase]) -> list[EnvVariableRow]:
|
||||
return [
|
||||
EnvVariableRow(
|
||||
id=variable.id,
|
||||
name=variable.name,
|
||||
description=variable.description,
|
||||
value_type=environment_variable_value_type(variable),
|
||||
value=encrypter.full_mask_token()
|
||||
if isinstance(variable, SecretVariable) and variable.value
|
||||
else variable.value,
|
||||
)
|
||||
for variable in variables
|
||||
]
|
||||
|
||||
|
||||
def _stored_secret_ids(draft: Workflow | None) -> set[str]:
|
||||
if draft is None:
|
||||
return set()
|
||||
return {variable.id for variable in draft.environment_variables if isinstance(variable, SecretVariable)}
|
||||
|
||||
|
||||
def _patch_env(ctx: Context, *, upserts: list[VariableBase], deletions: list[str]) -> None:
|
||||
try:
|
||||
WorkflowService().patch_draft_workflow_environment_variables(
|
||||
app_model=ctx.app,
|
||||
environment_variables=upserts,
|
||||
deleted_environment_variable_ids=deletions,
|
||||
account=ctx.account,
|
||||
session=db.session(),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise BadRequest(str(exc)) from exc
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/env")
|
||||
class AppEnvListApi(Resource):
|
||||
@endpoint(
|
||||
op="get.console_app.env",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Environment variables of the draft. Each has an id, which set and delete take, "
|
||||
"and a name, which nodes use. Secrets with a value are masked; empty secrets read as empty",
|
||||
examples=(Example(title="List environment variables", input={"app_id": "<app_id>"}),),
|
||||
requirements=_ENV_READ_GUARDS,
|
||||
returns=(HTTPStatus.OK, EnvVariableListResponse, "Environment variables"),
|
||||
)
|
||||
def get(self, ctx: Context, app_id: str):
|
||||
require_mode(ctx.app, *GRAPH_MODES)
|
||||
draft = WorkflowService().get_draft_workflow(app_model=ctx.app, session=ctx.session)
|
||||
if draft is None:
|
||||
raise DraftNotFound()
|
||||
return EnvVariableListResponse(data=env_variable_rows(draft.environment_variables))
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/env/<string:env_id>")
|
||||
class AppEnvItemApi(Resource):
|
||||
@endpoint(
|
||||
op="set.console_app.env",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Add or replace one environment variable of the draft, keyed by its id from get console_app env, "
|
||||
"not its name. A new id (e.g. a fresh UUID) adds a variable. Others are left alone",
|
||||
examples=(
|
||||
Example(
|
||||
title="Set an API key as a secret",
|
||||
input={
|
||||
"app_id": "<app_id>",
|
||||
"env_id": "<env_id>",
|
||||
"name": "API_KEY",
|
||||
"value_type": "secret",
|
||||
"value": "sk-...",
|
||||
},
|
||||
),
|
||||
),
|
||||
requirements=_ENV_WRITE_GUARDS,
|
||||
body=EnvVariableSetPayload,
|
||||
returns=(HTTPStatus.OK, SimpleResultResponse, "Variable set"),
|
||||
)
|
||||
def put(self, ctx: Context, app_id: str, env_id: str, *, body: EnvVariableSetPayload):
|
||||
require_mode(ctx.app, *GRAPH_MODES)
|
||||
if body.value == encrypter.full_mask_token() and body.value_type != EnvVariableValueType.SECRET:
|
||||
raise SecretMaskNotSecret()
|
||||
[mapping] = Workflow.normalize_environment_variable_mappings([{**body.model_dump(mode="json"), "id": env_id}])
|
||||
if mapping["value"] == HIDDEN_VALUE:
|
||||
draft = WorkflowService().get_draft_workflow(app_model=ctx.app, session=ctx.session)
|
||||
if env_id not in _stored_secret_ids(draft):
|
||||
raise SecretMaskUnknownId()
|
||||
try:
|
||||
variable = variable_factory.build_environment_variable_from_mapping(mapping)
|
||||
except VariableError as exc:
|
||||
raise BadRequest(str(exc)) from exc
|
||||
_patch_env(ctx, upserts=[variable], deletions=[])
|
||||
return SimpleResultResponse(result="success")
|
||||
|
||||
@endpoint(
|
||||
op="delete.console_app.env",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Remove one environment variable from the draft, keyed by its id from get console_app env, "
|
||||
"not its name. Others are left alone",
|
||||
examples=(Example(title="Remove a variable", input={"app_id": "<app_id>", "env_id": "<env_id>"}),),
|
||||
requirements=_ENV_WRITE_GUARDS,
|
||||
returns=(HTTPStatus.OK, SimpleResultResponse, "Variable removed"),
|
||||
)
|
||||
def delete(self, ctx: Context, app_id: str, env_id: str):
|
||||
require_mode(ctx.app, *GRAPH_MODES)
|
||||
_patch_env(ctx, upserts=[], deletions=[env_id])
|
||||
return SimpleResultResponse(result="success")
|
||||
@@ -105,7 +105,7 @@ def build_app_describe_response(app: App, fields: set[str] | None, *, session: S
|
||||
@openapi_ns.route("/apps/<string:app_id>")
|
||||
class AppDescribeApi(Resource):
|
||||
@endpoint(
|
||||
op="console_app.describe",
|
||||
op="describe.console_app",
|
||||
kind=Kind.OBJECT,
|
||||
summary="App detail, parameters and runtime input_schema",
|
||||
examples=(
|
||||
@@ -133,7 +133,7 @@ class AppDescribeApi(Resource):
|
||||
@openapi_ns.route("/apps")
|
||||
class AppListApi(Resource):
|
||||
@endpoint(
|
||||
op="console_app.list",
|
||||
op="get.console_app",
|
||||
kind=Kind.LIST,
|
||||
summary="List apps in a workspace",
|
||||
examples=(
|
||||
|
||||
@@ -43,7 +43,7 @@ _ENTERPRISE_ONLY = frozenset({DeploymentEdition.ENTERPRISE})
|
||||
@openapi_ns.route("/permitted-external-apps")
|
||||
class PermittedExternalAppsListApi(Resource):
|
||||
@endpoint(
|
||||
op="console_app.external.list",
|
||||
op="get.console_app.external",
|
||||
kind=Kind.LIST,
|
||||
summary="List apps an external SSO subject may run",
|
||||
examples=(Example(title="List the apps this SSO subject may run, first page", input={"page": 1, "limit": 20}),),
|
||||
@@ -101,7 +101,7 @@ class PermittedExternalAppsListApi(Resource):
|
||||
@openapi_ns.route("/permitted-external-apps/<string:app_id>")
|
||||
class PermittedExternalAppDescribeApi(Resource):
|
||||
@endpoint(
|
||||
op="console_app.external.describe",
|
||||
op="describe.console_app.external",
|
||||
kind=Kind.OBJECT,
|
||||
summary="External-subject app detail",
|
||||
examples=(Example(title="Describe a permitted app with its input_schema", input={"app_id": "<app_id>"}),),
|
||||
|
||||
@@ -11,7 +11,7 @@ from __future__ import annotations
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Sequence
|
||||
from enum import IntEnum
|
||||
from typing import ClassVar, override
|
||||
from typing import ClassVar, Final, override
|
||||
|
||||
from flask import request
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -19,11 +19,11 @@ from werkzeug.exceptions import Forbidden
|
||||
|
||||
from configs import dify_config
|
||||
from constants.oauth_bearer import Scope
|
||||
from controllers.common.rbac import RBACCheck, enforce_rbac_checks
|
||||
from controllers.common.rbac import PlainApp, RBACCheck, RBACPermission, enforce_rbac_checks
|
||||
from controllers.openapi._audit import emit_wrong_surface
|
||||
from controllers.openapi.auth.context import Context
|
||||
from controllers.openapi.auth.loaders import load_app, load_caller, load_workspace, load_workspace_role
|
||||
from controllers.openapi.auth.subjects import Subject
|
||||
from controllers.openapi.auth.subjects import AccountSubject, Subject
|
||||
from enums import DeploymentEdition
|
||||
from models.account import TenantAccountRole
|
||||
from models.enums import CreatorUserRole
|
||||
@@ -141,6 +141,9 @@ class CheckRBACPermission(Requirement):
|
||||
)
|
||||
|
||||
|
||||
EDITOR_ROLES: Final = frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER})
|
||||
|
||||
|
||||
class CheckWorkspaceRole(Requirement):
|
||||
"""The workspace-role gate that predates RBAC. Inert wherever RBAC is on;
|
||||
a route that needs a check there declares a `CheckRBACPermission` beside this.
|
||||
@@ -206,3 +209,15 @@ class ResolveCaller(Requirement):
|
||||
if not subject.mounts_caller(ctx):
|
||||
return
|
||||
load_caller(ctx)
|
||||
|
||||
|
||||
def account_app_guards(permission: RBACPermission, *, scope: Scope, editor: bool) -> tuple[Requirement, ...]:
|
||||
"""Guards for an account-only op on one app; `editor` adds the pre-RBAC editor-role gate."""
|
||||
guards: tuple[Requirement, ...] = (
|
||||
CheckSubject(allowed=(AccountSubject,)),
|
||||
CheckAppApiEnabled(),
|
||||
CheckWorkspaceMember(),
|
||||
CheckScope(scope),
|
||||
CheckRBACPermission(RBACCheck(permission, PlainApp())),
|
||||
)
|
||||
return (*guards, CheckWorkspaceRole(EDITOR_ROLES)) if editor else guards
|
||||
|
||||
@@ -35,7 +35,7 @@ class AppFileUploadApi(Resource):
|
||||
}
|
||||
)
|
||||
@endpoint(
|
||||
op="console_app.file.upload",
|
||||
op="upload.console_app.file",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Upload a file and get a file id for later runs",
|
||||
examples=(
|
||||
|
||||
@@ -92,7 +92,7 @@ def _ensure_form_belongs_to_app(form, app_model: App) -> None:
|
||||
@openapi_ns.route("/apps/<string:app_id>/human-input-forms/<string:form_token>")
|
||||
class OpenApiWorkflowHumanInputFormApi(Resource):
|
||||
@endpoint(
|
||||
op="run.form.get",
|
||||
op="describe.run.form",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Read a human-input form",
|
||||
examples=(
|
||||
@@ -126,7 +126,7 @@ class OpenApiWorkflowHumanInputFormApi(Resource):
|
||||
@openapi_ns.route("/apps/<string:app_id>/human-input-forms/<string:form_token>:submit")
|
||||
class OpenApiWorkflowHumanInputFormSubmitApi(Resource):
|
||||
@endpoint(
|
||||
op="run.form.submit",
|
||||
op="submit.run.form",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Submit a human-input form",
|
||||
examples=(
|
||||
|
||||
@@ -53,7 +53,7 @@ class WorkflowEventsQuery(BaseModel):
|
||||
@openapi_ns.route("/apps/<string:app_id>/tasks/<string:task_id>/events")
|
||||
class OpenApiWorkflowEventsApi(Resource):
|
||||
@endpoint(
|
||||
op="run.events",
|
||||
op="get.run.event",
|
||||
kind=Kind.SSE,
|
||||
summary="Stream the events of a workflow run",
|
||||
examples=(
|
||||
|
||||
@@ -89,7 +89,7 @@ def _check_member_invite_quota(tenant_id: str) -> None:
|
||||
@openapi_ns.route("/workspaces")
|
||||
class WorkspacesApi(Resource):
|
||||
@endpoint(
|
||||
op="workspace.list",
|
||||
op="get.workspace",
|
||||
kind=Kind.LIST,
|
||||
summary="List workspaces of the current account",
|
||||
examples=(Example(title="List my workspaces, first page", input={"page": 1, "limit": 20}),),
|
||||
@@ -105,7 +105,7 @@ class WorkspacesApi(Resource):
|
||||
@openapi_ns.route("/workspaces/<string:workspace_id>")
|
||||
class WorkspaceByIdApi(Resource):
|
||||
@endpoint(
|
||||
op="workspace.get",
|
||||
op="describe.workspace",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Workspace detail",
|
||||
examples=(
|
||||
@@ -134,7 +134,7 @@ class WorkspaceSwitchApi(Resource):
|
||||
"""
|
||||
|
||||
@endpoint(
|
||||
op="workspace.switch",
|
||||
op="switch.workspace",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Server-side current workspace switch (shared with the web console)",
|
||||
examples=(Example(title="Make a workspace current on the server", input={"workspace_id": "<workspace_id>"}),),
|
||||
@@ -168,7 +168,7 @@ class WorkspaceMembersApi(Resource):
|
||||
"""
|
||||
|
||||
@endpoint(
|
||||
op="workspace.members.list",
|
||||
op="get.workspace.member",
|
||||
kind=Kind.LIST,
|
||||
summary="List workspace members",
|
||||
examples=(Example(title="List members of the pinned workspace, first page", input={"page": 1, "limit": 20}),),
|
||||
@@ -185,7 +185,7 @@ class WorkspaceMembersApi(Resource):
|
||||
return MemberListResponse.page_of([_member_response(m) for m in members], query=query)
|
||||
|
||||
@endpoint(
|
||||
op="workspace.members.invite",
|
||||
op="invite.workspace.member",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Invite a member by email",
|
||||
examples=(
|
||||
@@ -252,7 +252,7 @@ class WorkspaceMemberApi(Resource):
|
||||
"""
|
||||
|
||||
@endpoint(
|
||||
op="workspace.members.remove",
|
||||
op="delete.workspace.member",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Remove a member",
|
||||
examples=(Example(title="Remove a member by account id", input={"member_id": "<member_id>"}),),
|
||||
@@ -282,7 +282,7 @@ class WorkspaceMemberApi(Resource):
|
||||
return MemberActionResponse()
|
||||
|
||||
@endpoint(
|
||||
op="workspace.members.set_role",
|
||||
op="set.workspace.member_role",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Change a member's role",
|
||||
examples=(
|
||||
|
||||
@@ -25,7 +25,7 @@ class AppGenerateResponseConverter[TBlockingResponse: AppBlockingResponse](ABC):
|
||||
def convert(
|
||||
cls, response: Union[AppBlockingResponse, Generator[AppStreamResponse, Any, None]], invoke_from: InvokeFrom
|
||||
) -> Mapping[str, Any] | Generator[str | Mapping[str, Any], Any, None]:
|
||||
if invoke_from in {InvokeFrom.DEBUGGER, InvokeFrom.SERVICE_API}:
|
||||
if invoke_from in {InvokeFrom.DEBUGGER, InvokeFrom.SERVICE_API, InvokeFrom.OPENAPI}:
|
||||
if isinstance(response, AppBlockingResponse):
|
||||
return cls.convert_blocking_full_response(cls._cast_blocking_response(response))
|
||||
else:
|
||||
|
||||
@@ -560,6 +560,18 @@ class Workflow(Base): # bug
|
||||
|
||||
return helper.generate_text_hash(json.dumps(entity, sort_keys=True))
|
||||
|
||||
@property
|
||||
def content_hash(self) -> str:
|
||||
"""Hash of everything a DSL import replaces: graph, features, environment and conversation
|
||||
variables. Hashes the stored column text, so encrypted secrets never leave the row."""
|
||||
entity = {
|
||||
"graph": self.graph,
|
||||
"features": self.features,
|
||||
"environment_variables": self._environment_variables,
|
||||
"conversation_variables": self._conversation_variables,
|
||||
}
|
||||
return helper.generate_text_hash(json.dumps(entity, sort_keys=True))
|
||||
|
||||
@deprecated(
|
||||
"This method is not accurate for determining if a workflow is published as a tool."
|
||||
"It only checks if there's a WorkflowToolProvider for the app, "
|
||||
|
||||
@@ -16232,6 +16232,7 @@ This class is used to store the schema information of an api based tool.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| app_id | string | | No |
|
||||
| description | string | | No |
|
||||
| draft_hash | string | | No |
|
||||
| icon | string | | No |
|
||||
| icon_background | string | | No |
|
||||
| icon_type | string | | No |
|
||||
|
||||
@@ -195,6 +195,48 @@ Machine-readable catalog of every op on this surface
|
||||
| 200 | Dependencies checked | **application/json**: [CheckDependenciesResult](#checkdependenciesresult)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [POST] /apps/{app_id}/draft/advanced-chat:run
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | | Yes | string |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [ChatRunPayload](#chatrunpayload)<br>**multipart/form-data**: [ChatRunPayload](#chatrunpayload)<br> | **application/json**: [ChatRunPayload](#chatrunpayload)<br>**multipart/form-data**: [ChatRunPayload](#chatrunpayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Run result (SSE stream) | **application/json**: [EventStreamResponse](#eventstreamresponse)<br> |
|
||||
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [POST] /apps/{app_id}/draft/workflow:run
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | | Yes | string |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [DraftWorkflowRunPayload](#draftworkflowrunpayload)<br>**multipart/form-data**: [DraftWorkflowRunPayload](#draftworkflowrunpayload)<br> | **application/json**: [DraftWorkflowRunPayload](#draftworkflowrunpayload)<br>**multipart/form-data**: [DraftWorkflowRunPayload](#draftworkflowrunpayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Run result (SSE stream) | **application/json**: [EventStreamResponse](#eventstreamresponse)<br> |
|
||||
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [GET] /apps/{app_id}/dsl
|
||||
#### Parameters
|
||||
|
||||
@@ -212,6 +254,57 @@ Machine-readable catalog of every op on this surface
|
||||
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [GET] /apps/{app_id}/env
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Environment variables | **application/json**: [EnvVariableListResponse](#envvariablelistresponse)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [DELETE] /apps/{app_id}/env/{env_id}
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | | Yes | string |
|
||||
| env_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Variable removed | **application/json**: [SimpleResultResponse](#simpleresultresponse)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [PUT] /apps/{app_id}/env/{env_id}
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | | Yes | string |
|
||||
| env_id | path | | Yes | string |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [EnvVariableSetPayload](#envvariablesetpayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Variable set | **application/json**: [SimpleResultResponse](#simpleresultresponse)<br> |
|
||||
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [POST] /apps/{app_id}/files
|
||||
Upload a file to use as an input variable when running the app
|
||||
|
||||
@@ -276,6 +369,55 @@ Upload a file to use as an input variable when running the app
|
||||
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [GET] /apps/{app_id}/runs
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| last_id | query | Cursor: id of the last run on the previous page | No | string (uuid) |
|
||||
| limit | query | | No | integer, <br>**Default:** 20 |
|
||||
| status | query | | No | string, <br>**Available values:** "failed", "partial-succeeded", "running", "stopped", "succeeded" |
|
||||
| triggered_from | query | debugging: draft test runs; app-run: real use. Omitted: debugging, as in the console | No | string, <br>**Available values:** "app-run", "debugging" |
|
||||
| app_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Run list | **application/json**: [RunListResponse](#runlistresponse)<br> |
|
||||
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [GET] /apps/{app_id}/runs/{run_id}
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | | Yes | string |
|
||||
| run_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Run detail | **application/json**: [WorkflowRunDetailResponse](#workflowrundetailresponse)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [GET] /apps/{app_id}/runs/{run_id}/nodes
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | | Yes | string |
|
||||
| run_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Node steps | **application/json**: [WorkflowRunNodeExecutionListResponse](#workflowrunnodeexecutionlistresponse)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [GET] /apps/{app_id}/tasks/{task_id}/events
|
||||
#### Parameters
|
||||
|
||||
@@ -309,6 +451,39 @@ Upload a file to use as an input variable when running the app
|
||||
| 200 | Task stopped | **application/json**: [TaskStopResponse](#taskstopresponse)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [GET] /apps/{app_id}/versions
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| limit | query | | No | integer, <br>**Default:** 20 |
|
||||
| named_only | query | Only versions that have a name | No | boolean |
|
||||
| page | query | | No | integer, <br>**Default:** 1 |
|
||||
| app_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Version list | **application/json**: [VersionListResponse](#versionlistresponse)<br> |
|
||||
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [POST] /apps/{app_id}/versions/{version_id}:restore
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | | Yes | string |
|
||||
| version_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Restored into the draft | **application/json**: [RestoreResponse](#restoreresponse)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [POST] /apps/{app_id}/workflow:run
|
||||
#### Parameters
|
||||
|
||||
@@ -330,6 +505,27 @@ Upload a file to use as an input variable when running the app
|
||||
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [POST] /apps/{app_id}:publish
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | | Yes | string |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [PublishPayload](#publishpayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Published | **application/json**: [PublishResponse](#publishresponse)<br> |
|
||||
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [POST] /oauth/device/approve
|
||||
#### Request Body
|
||||
|
||||
@@ -619,7 +815,7 @@ A chat run against an advanced-chat (chatflow) app, which can also pin a workflo
|
||||
| auto_generate_name | boolean, <br>**Default:** true | Let the server name a new conversation | No |
|
||||
| conversation_id | string | Continue an existing conversation | No |
|
||||
| files | object | Local files keyed by the app's file variable name; the server uploads each one and sets `inputs[<name>]`. Send a list (part name `files[<name>][]`) for a file-list variable | No |
|
||||
| inputs | object | Variables declared by the app. The exact shape is per app: read `input_schema` from console_app.describe. A file variable takes a Dify file mapping (remote url or upload id) here, or a local file in `files`, not both. | Yes |
|
||||
| inputs | object | Variables declared by the app. The exact shape is per app: read `input_schema` from describe.console_app. A file variable takes a Dify file mapping (remote url or upload id) here, or a local file in `files`, not both. | Yes |
|
||||
| query | string | User message | Yes |
|
||||
| workflow_id | string | Pin a published workflow version | No |
|
||||
| workspace_id | string | Workspace that owns the app | No |
|
||||
@@ -670,6 +866,7 @@ Export DSL response.
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| data | string | DSL YAML string | Yes |
|
||||
| draft_hash | string | Hash of the draft's graph, features, environment variables and conversation variables; pass it to the import to refuse overwriting newer edits | No |
|
||||
|
||||
#### AppDslImportPayload
|
||||
|
||||
@@ -679,6 +876,7 @@ Request body for POST /workspaces/<workspace_id>/apps/imports.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| app_id | string | Existing app ID to overwrite (workflow/advanced-chat apps only) | No |
|
||||
| description | string | Override the app description from the DSL | No |
|
||||
| draft_hash | string | draft_hash from the export or restore this import is based on. The import fails if the draft's graph, features, environment variables or conversation variables changed since. Requires app_id | No |
|
||||
| icon | string | | No |
|
||||
| icon_background | string | | No |
|
||||
| icon_type | string | | No |
|
||||
@@ -762,7 +960,7 @@ mode is a closed enum of listable app types.
|
||||
| auto_generate_name | boolean, <br>**Default:** true | Let the server name a new conversation | No |
|
||||
| conversation_id | string | Continue an existing conversation | No |
|
||||
| files | object | Local files keyed by the app's file variable name; the server uploads each one and sets `inputs[<name>]`. Send a list (part name `files[<name>][]`) for a file-list variable | No |
|
||||
| inputs | object | Variables declared by the app. The exact shape is per app: read `input_schema` from console_app.describe. A file variable takes a Dify file mapping (remote url or upload id) here, or a local file in `files`, not both. | Yes |
|
||||
| inputs | object | Variables declared by the app. The exact shape is per app: read `input_schema` from describe.console_app. A file variable takes a Dify file mapping (remote url or upload id) here, or a local file in `files`, not both. | Yes |
|
||||
| query | string | User message | Yes |
|
||||
| workspace_id | string | Workspace that owns the app | No |
|
||||
|
||||
@@ -778,7 +976,7 @@ mode is a closed enum of listable app types.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| attachments | [ string ] | Local files attached to the run itself (the app's `sys.files`), not to a variable | No |
|
||||
| files | object | Local files keyed by the app's file variable name; the server uploads each one and sets `inputs[<name>]`. Send a list (part name `files[<name>][]`) for a file-list variable | No |
|
||||
| inputs | object | Variables declared by the app. The exact shape is per app: read `input_schema` from console_app.describe. A file variable takes a Dify file mapping (remote url or upload id) here, or a local file in `files`, not both. | Yes |
|
||||
| inputs | object | Variables declared by the app. The exact shape is per app: read `input_schema` from describe.console_app. A file variable takes a Dify file mapping (remote url or upload id) here, or a local file in `files`, not both. | Yes |
|
||||
| query | string | Prompt text; most completion apps take their input through `inputs` | No |
|
||||
| workspace_id | string | Workspace that owns the app | No |
|
||||
|
||||
@@ -854,6 +1052,15 @@ Enum representing the deployment edition of the platform.
|
||||
| token_id | string | | Yes |
|
||||
| workspaces | [ [WorkspacePayload](#workspacepayload) ], <br>**Default:** | | No |
|
||||
|
||||
#### DraftWorkflowRunPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| attachments | [ string ] | Local files attached to the run itself (the app's `sys.files`), not to a variable | No |
|
||||
| files | object | Local files keyed by the app's file variable name; the server uploads each one and sets `inputs[<name>]`. Send a list (part name `files[<name>][]`) for a file-list variable | No |
|
||||
| inputs | object | Variables declared by the app. The exact shape is per app: read `input_schema` from describe.console_app. A file variable takes a Dify file mapping (remote url or upload id) here, or a local file in `files`, not both. | Yes |
|
||||
| workspace_id | string | Workspace that owns the app | No |
|
||||
|
||||
#### DslImportWarning
|
||||
|
||||
Portable DSL reference that could not be restored in the target workspace.
|
||||
@@ -865,6 +1072,43 @@ Portable DSL reference that could not be restored in the target workspace.
|
||||
| message | string | | Yes |
|
||||
| path | string | | Yes |
|
||||
|
||||
#### EnvVariableListResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| data | [ [EnvVariableRow](#envvariablerow) ] | | Yes |
|
||||
|
||||
#### EnvVariableRow
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| description | string | | No |
|
||||
| id | string | What set and delete take to address this variable | Yes |
|
||||
| name | string | What nodes use to refer to this variable | Yes |
|
||||
| value | | The value; a secret with a value is masked, an empty one reads as empty | Yes |
|
||||
| value_type | string | | Yes |
|
||||
|
||||
#### EnvVariableSetPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| description | string | What the variable is for | No |
|
||||
| name | string | Variable name | Yes |
|
||||
| value | | The value; sending the masked value of an existing secret keeps the stored one | Yes |
|
||||
| value_type | [EnvVariableValueType](#envvariablevaluetype) | string, number or secret | Yes |
|
||||
|
||||
#### EnvVariableValueType
|
||||
|
||||
Value types the draft environment-variable ``set`` op accepts.
|
||||
|
||||
A curated subset of ``SegmentType``: what the console's environment-variable editor
|
||||
allows (``ENVIRONMENT_VARIABLE_SUPPORTED_TYPES`` in controllers/console/app/workflow.py).
|
||||
Members reference ``SegmentType.*.value`` so the subset relationship is type-checked.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| EnvVariableValueType | string | Value types the draft environment-variable ``set`` op accepts. A curated subset of ``SegmentType``: what the console's environment-variable editor allows (``ENVIRONMENT_VARIABLE_SUPPORTED_TYPES`` in controllers/console/app/workflow.py). Members reference ``SegmentType.*.value`` so the subset relationship is type-checked. | |
|
||||
|
||||
#### ErrorBody
|
||||
|
||||
Canonical non-2xx body. ``code`` is typed ``str`` (not the enum) so the
|
||||
@@ -1126,12 +1370,54 @@ Strict (extra='forbid').
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| PluginDependencyType | string | | |
|
||||
|
||||
#### PublishPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| marked_comment | string | Version note | No |
|
||||
| marked_name | string | Version name | No |
|
||||
|
||||
#### PublishResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| created_at | integer | | Yes |
|
||||
| version_id | string | | Yes |
|
||||
| warning | string | Variable references that may read a skipped branch | No |
|
||||
|
||||
#### RestoreResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| draft_hash | string | Hash of the restored draft's graph, features, environment variables and conversation variables; pass it to a DSL import as draft_hash | Yes |
|
||||
| result | string | | Yes |
|
||||
|
||||
#### RevokeResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| status | string | | Yes |
|
||||
|
||||
#### RunListQuery
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| last_id | string | Cursor: id of the last run on the previous page | No |
|
||||
| limit | integer, <br>**Default:** 20 | | No |
|
||||
| status | string, <br>**Available values:** "failed", "partial-succeeded", "running", "stopped", "succeeded" | | No |
|
||||
| triggered_from | string, <br>**Available values:** "app-run", "debugging" | debugging: draft test runs; app-run: real use. Omitted: debugging, as in the console | No |
|
||||
|
||||
#### RunListResponse
|
||||
|
||||
Cursor page of runs; `hints` carries the next page.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| data | [ [WorkflowRunForListResponse](#workflowrunforlistresponse) ] | | Yes |
|
||||
| has_more | boolean | | Yes |
|
||||
| hints | [ [Hint](#hint) ] | Next steps the caller can take | No |
|
||||
| limit | integer | | Yes |
|
||||
|
||||
#### ServerVersionResponse
|
||||
|
||||
Meta endpoint payload for `GET /openapi/v1/_version` — no auth required.
|
||||
@@ -1173,6 +1459,23 @@ Pagination for GET /account/sessions. Strict (extra='forbid').
|
||||
| last_used_at | string | | No |
|
||||
| prefix | string | | Yes |
|
||||
|
||||
#### SimpleAccountResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| email | string | | Yes |
|
||||
| id | string | | Yes |
|
||||
| name | string | | Yes |
|
||||
|
||||
#### SimpleEndUser
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| id | string | | Yes |
|
||||
| is_anonymous | boolean | | Yes |
|
||||
| session_id | string | | No |
|
||||
| type | string | | Yes |
|
||||
|
||||
#### SimpleResultResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -1221,6 +1524,36 @@ types it as a required `'success'` rather than an optional field.
|
||||
| prompt_tokens | integer | | No |
|
||||
| total_tokens | integer | | No |
|
||||
|
||||
#### VersionListQuery
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| limit | integer, <br>**Default:** 20 | | No |
|
||||
| named_only | boolean | Only versions that have a name | No |
|
||||
| page | integer, <br>**Default:** 1 | | No |
|
||||
|
||||
#### VersionListResponse
|
||||
|
||||
Page of published versions, newest first; there is no total, `hints` carries the next page.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| data | [ [VersionRow](#versionrow) ] | | Yes |
|
||||
| has_more | boolean | | Yes |
|
||||
| hints | [ [Hint](#hint) ] | Next steps the caller can take | No |
|
||||
| limit | integer | | Yes |
|
||||
| page | integer | | Yes |
|
||||
|
||||
#### VersionRow
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| created_at | integer | | Yes |
|
||||
| created_by | string | | No |
|
||||
| id | string | | Yes |
|
||||
| marked_comment | string | | Yes |
|
||||
| marked_name | string | | Yes |
|
||||
|
||||
#### WorkflowRunData
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -1236,13 +1569,84 @@ types it as a required `'success'` rather than an optional field.
|
||||
| total_tokens | integer | | No |
|
||||
| workflow_id | string | | Yes |
|
||||
|
||||
#### WorkflowRunDetailResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| created_at | integer | | No |
|
||||
| created_by_account | [SimpleAccountResponse](#simpleaccountresponse) | | No |
|
||||
| created_by_end_user | [SimpleEndUser](#simpleenduser) | | No |
|
||||
| created_by_role | string | | No |
|
||||
| elapsed_time | number | | No |
|
||||
| error | string | | No |
|
||||
| exceptions_count | integer | | No |
|
||||
| finished_at | integer | | No |
|
||||
| graph | | | Yes |
|
||||
| id | string | | Yes |
|
||||
| inputs | | | Yes |
|
||||
| outputs | | | Yes |
|
||||
| status | string | | No |
|
||||
| total_steps | integer | | No |
|
||||
| total_tokens | integer | | No |
|
||||
| version | string | | No |
|
||||
|
||||
#### WorkflowRunForListResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| created_at | integer | | No |
|
||||
| created_by_account | [SimpleAccountResponse](#simpleaccountresponse) | | No |
|
||||
| elapsed_time | number | | No |
|
||||
| exceptions_count | integer | | No |
|
||||
| finished_at | integer | | No |
|
||||
| id | string | | Yes |
|
||||
| retry_index | integer | | No |
|
||||
| status | string | | No |
|
||||
| total_steps | integer | | No |
|
||||
| total_tokens | integer | | No |
|
||||
| version | string | | No |
|
||||
|
||||
#### WorkflowRunNodeExecutionListResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| data | [ [WorkflowRunNodeExecutionResponse](#workflowrunnodeexecutionresponse) ] | | Yes |
|
||||
|
||||
#### WorkflowRunNodeExecutionResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| created_at | integer | | No |
|
||||
| created_by_account | [SimpleAccountResponse](#simpleaccountresponse) | | No |
|
||||
| created_by_end_user | [SimpleEndUser](#simpleenduser) | | No |
|
||||
| created_by_role | string | | No |
|
||||
| elapsed_time | number | | No |
|
||||
| error | string | | No |
|
||||
| execution_metadata | | | No |
|
||||
| extras | | | No |
|
||||
| finished_at | integer | | No |
|
||||
| id | string | | Yes |
|
||||
| index | integer | | No |
|
||||
| inputs | | | No |
|
||||
| inputs_truncated | boolean | | No |
|
||||
| node_id | string | | No |
|
||||
| node_type | string | | No |
|
||||
| outputs | | | No |
|
||||
| outputs_truncated | boolean | | No |
|
||||
| predecessor_node_id | string | | No |
|
||||
| process_data | | | No |
|
||||
| process_data_truncated | boolean | | No |
|
||||
| retry_index | integer | | No |
|
||||
| status | string | | No |
|
||||
| title | string | | No |
|
||||
|
||||
#### WorkflowRunPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| attachments | [ string ] | Local files attached to the run itself (the app's `sys.files`), not to a variable | No |
|
||||
| files | object | Local files keyed by the app's file variable name; the server uploads each one and sets `inputs[<name>]`. Send a list (part name `files[<name>][]`) for a file-list variable | No |
|
||||
| inputs | object | Variables declared by the app. The exact shape is per app: read `input_schema` from console_app.describe. A file variable takes a Dify file mapping (remote url or upload id) here, or a local file in `files`, not both. | Yes |
|
||||
| inputs | object | Variables declared by the app. The exact shape is per app: read `input_schema` from describe.console_app. A file variable takes a Dify file mapping (remote url or upload id) here, or a local file in `files`, not both. | Yes |
|
||||
| workflow_id | string | Pin a published workflow version | No |
|
||||
| workspace_id | string | Workspace that owns the app | No |
|
||||
|
||||
|
||||
@@ -252,6 +252,7 @@ class AppTransferGateway(AppTransfers, AppDefinitionImports):
|
||||
icon=params.icon,
|
||||
icon_background=params.icon_background,
|
||||
app_id=params.app_id,
|
||||
draft_hash=params.draft_hash,
|
||||
package=package,
|
||||
)
|
||||
if result.status == ImportStatus.FAILED or (as_copy and result.status == ImportStatus.PENDING):
|
||||
|
||||
@@ -2,7 +2,7 @@ import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any, cast
|
||||
from urllib.parse import urlparse
|
||||
from uuid import uuid4
|
||||
@@ -16,6 +16,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
from constants import HIDDEN_VALUE
|
||||
from constants.dsl_version import CURRENT_APP_DSL_VERSION
|
||||
from core.app.app_config.features.suggested_questions_after_answer.manager import (
|
||||
SuggestedQuestionsAfterAnswerConfigManager,
|
||||
@@ -46,6 +47,7 @@ from graphon.nodes.llm.entities import LLMNodeData, ModelConfig
|
||||
from graphon.nodes.parameter_extractor.entities import ParameterExtractorNodeData
|
||||
from graphon.nodes.question_classifier.entities import QuestionClassifierNodeData
|
||||
from graphon.nodes.tool.entities import ToolNodeData
|
||||
from graphon.variables import SecretVariable, SegmentType
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models import Account, App, AppMode
|
||||
from models.agent import AgentScope
|
||||
@@ -71,7 +73,7 @@ from services.entities.dsl_entities import (
|
||||
make_app_dsl,
|
||||
)
|
||||
from services.errors.account import NoPermissionError
|
||||
from services.errors.app import WorkflowNotFoundError
|
||||
from services.errors.app import WorkflowHashNotEqualError, WorkflowNotFoundError
|
||||
from services.icon_configuration import (
|
||||
DEFAULT_ICON,
|
||||
DEFAULT_ICON_TYPE,
|
||||
@@ -86,6 +88,8 @@ logger = logging.getLogger(__name__)
|
||||
IMPORT_INFO_REDIS_KEY_PREFIX = "app_import_info:"
|
||||
CHECK_DEPENDENCIES_REDIS_KEY_PREFIX = "app_check_dependencies:"
|
||||
IMPORT_INFO_REDIS_EXPIRY = 10 * 60 # 10 minutes
|
||||
DRAFT_CHANGED_ERROR = "The draft changed after it was exported. Export it again and reapply your edits."
|
||||
DRAFT_HASH_NEEDS_APP_ID_ERROR = "draft_hash is only valid when app_id names the app to overwrite."
|
||||
CURRENT_DSL_VERSION = CURRENT_APP_DSL_VERSION
|
||||
|
||||
|
||||
@@ -105,6 +109,18 @@ def missing_app_section_error(top_level_keys: list[str]) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _keep_stored_secrets(mappings: Sequence[Mapping[str, Any]], stored_secret_ids: set[str]) -> list[Mapping[str, Any]]:
|
||||
# An export without include_secret blanks secrets; on overwrite, keep the stored value instead.
|
||||
return [
|
||||
{**mapping, "value": HIDDEN_VALUE}
|
||||
if mapping.get("value_type") == SegmentType.SECRET
|
||||
and mapping.get("value") == ""
|
||||
and mapping.get("id") in stored_secret_ids
|
||||
else mapping
|
||||
for mapping in mappings
|
||||
]
|
||||
|
||||
|
||||
class PendingData(PendingImportOwner):
|
||||
import_mode: str
|
||||
yaml_content: str
|
||||
@@ -114,6 +130,7 @@ class PendingData(PendingImportOwner):
|
||||
icon: str | None = None
|
||||
icon_background: str | None = None
|
||||
app_id: str | None = None
|
||||
draft_hash: str | None = None
|
||||
warnings: list[DslImportWarning] = Field(default_factory=list)
|
||||
|
||||
|
||||
@@ -142,6 +159,7 @@ class AppDslService:
|
||||
icon: str | None = None,
|
||||
icon_background: str | None = None,
|
||||
app_id: str | None = None,
|
||||
draft_hash: str | None = None,
|
||||
import_app_id: str | None = None,
|
||||
package: AppImportPackage | None = None,
|
||||
) -> Import:
|
||||
@@ -155,6 +173,9 @@ class AppDslService:
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid import_mode: {import_mode}")
|
||||
|
||||
if draft_hash is not None and not app_id:
|
||||
return Import(id=import_id, status=ImportStatus.FAILED, error=DRAFT_HASH_NEEDS_APP_ID_ERROR)
|
||||
|
||||
# Get YAML content
|
||||
content: str = ""
|
||||
if mode == ImportMode.YAML_URL:
|
||||
@@ -301,6 +322,7 @@ class AppDslService:
|
||||
icon=icon,
|
||||
icon_background=icon_background,
|
||||
app_id=app_id,
|
||||
draft_hash=draft_hash,
|
||||
warnings=self._warnings,
|
||||
)
|
||||
redis_client.setex(
|
||||
@@ -344,6 +366,7 @@ class AppDslService:
|
||||
icon_background=icon_background,
|
||||
dependencies=check_dependencies_pending_data,
|
||||
import_app_id=import_app_id,
|
||||
draft_hash=draft_hash,
|
||||
)
|
||||
|
||||
draft_var_srv = WorkflowDraftVariableService(session=self._session)
|
||||
@@ -365,6 +388,9 @@ class AppDslService:
|
||||
error=f"Invalid YAML format: {str(e)}",
|
||||
)
|
||||
|
||||
except WorkflowHashNotEqualError:
|
||||
return Import(id=import_id, status=ImportStatus.FAILED, error=DRAFT_CHANGED_ERROR)
|
||||
|
||||
except NoPermissionError:
|
||||
raise
|
||||
|
||||
@@ -434,6 +460,7 @@ class AppDslService:
|
||||
icon_type=pending_data.icon_type,
|
||||
icon=pending_data.icon,
|
||||
icon_background=pending_data.icon_background,
|
||||
draft_hash=pending_data.draft_hash,
|
||||
)
|
||||
|
||||
# Delete import info from Redis
|
||||
@@ -449,6 +476,9 @@ class AppDslService:
|
||||
warnings=self._warnings,
|
||||
)
|
||||
|
||||
except WorkflowHashNotEqualError:
|
||||
return Import(id=import_id, status=ImportStatus.FAILED, error=DRAFT_CHANGED_ERROR)
|
||||
|
||||
except NoPermissionError:
|
||||
raise
|
||||
|
||||
@@ -558,8 +588,23 @@ class AppDslService:
|
||||
else {BuiltinNodeTypes.ANSWER}
|
||||
)
|
||||
nodes = data.get("workflow", {}).get("graph", {}).get("nodes", [])
|
||||
if any(node.get("data", {}).get("type") in invalid_types for node in nodes):
|
||||
raise ValueError("Workflow contains node types incompatible with the target App")
|
||||
incompatible = [
|
||||
f"{node['id']} ({node_type})" if "id" in node else node_type
|
||||
for node in nodes
|
||||
if (node_type := node.get("data", {}).get("type")) in invalid_types
|
||||
]
|
||||
if incompatible:
|
||||
raise ValueError(
|
||||
f"Workflow contains node types incompatible with the target App: {', '.join(incompatible)}"
|
||||
)
|
||||
|
||||
def _lock_draft_at_hash(self, app: App | None, draft_hash: str) -> Workflow:
|
||||
"""Row-lock the draft and require it to be the one the caller exported. The lock holds until
|
||||
the import's transaction ends, so no edit can land between this check and the overwrite."""
|
||||
draft = WorkflowService().get_draft_workflow_for_update(app_model=app, session=self._session) if app else None
|
||||
if draft is None or draft.content_hash != draft_hash:
|
||||
raise WorkflowHashNotEqualError()
|
||||
return draft
|
||||
|
||||
def _create_or_update_app(
|
||||
self,
|
||||
@@ -574,6 +619,7 @@ class AppDslService:
|
||||
icon_background: str | None = None,
|
||||
dependencies: list[PluginDependency] | None = None,
|
||||
import_app_id: str | None = None,
|
||||
draft_hash: str | None = None,
|
||||
) -> App:
|
||||
"""Create a new app or update an existing one."""
|
||||
app_data = data.get("app", {})
|
||||
@@ -588,6 +634,8 @@ class AppDslService:
|
||||
if target_tenant_id is None:
|
||||
raise ValueError("Current tenant is not set")
|
||||
|
||||
locked_draft = self._lock_draft_at_hash(app, draft_hash) if draft_hash is not None else None
|
||||
|
||||
# Set icon type
|
||||
icon_type_value = icon_type or app_data.get("icon_type")
|
||||
resolved_icon_type: IconType
|
||||
@@ -647,7 +695,18 @@ class AppDslService:
|
||||
if not workflow_data or not isinstance(workflow_data, dict):
|
||||
raise ValueError("Missing workflow data for workflow/advanced chat app")
|
||||
|
||||
environment_variables_list = workflow_data.get("environment_variables", [])
|
||||
workflow_service = WorkflowService()
|
||||
current_draft_workflow = locked_draft or workflow_service.get_draft_workflow(
|
||||
app_model=app, session=self._session
|
||||
)
|
||||
stored_secret_ids = (
|
||||
{v.id for v in current_draft_workflow.environment_variables if isinstance(v, SecretVariable)}
|
||||
if current_draft_workflow
|
||||
else set()
|
||||
)
|
||||
environment_variables_list = _keep_stored_secrets(
|
||||
workflow_data.get("environment_variables", []), stored_secret_ids
|
||||
)
|
||||
environment_variables = [
|
||||
variable_factory.build_environment_variable_from_mapping(obj) for obj in environment_variables_list
|
||||
]
|
||||
@@ -657,8 +716,6 @@ class AppDslService:
|
||||
for obj in conversation_variables_list
|
||||
]
|
||||
|
||||
workflow_service = WorkflowService()
|
||||
current_draft_workflow = workflow_service.get_draft_workflow(app_model=app, session=self._session)
|
||||
if current_draft_workflow:
|
||||
unique_hash = current_draft_workflow.unique_hash
|
||||
else:
|
||||
|
||||
@@ -31,6 +31,7 @@ class AppImportParams(BaseModel):
|
||||
icon: str | None = None
|
||||
icon_background: str | None = None
|
||||
app_id: str | None = None
|
||||
draft_hash: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
||||
@@ -158,6 +158,7 @@ from .workflow_draft_variable_service import DraftVariableSaver, DraftVarLoader,
|
||||
from .workflow_restore import apply_published_workflow_snapshot_to_draft
|
||||
|
||||
_file_access_controller = DatabaseFileAccessController()
|
||||
ENVIRONMENT_VARIABLE_NAME_TAKEN_ERROR = "Environment variable names must be unique."
|
||||
|
||||
|
||||
def _merge_environment_variable_patch(
|
||||
@@ -198,7 +199,7 @@ def _merge_environment_variable_patch(
|
||||
)
|
||||
names = [variable.name for variable in merged_variables]
|
||||
if len(set(names)) != len(names):
|
||||
raise ValueError("Environment variable names must be unique.")
|
||||
raise ValueError(ENVIRONMENT_VARIABLE_NAME_TAKEN_ERROR)
|
||||
return merged_variables
|
||||
|
||||
|
||||
@@ -269,7 +270,7 @@ class WorkflowService:
|
||||
# return draft workflow
|
||||
return workflow
|
||||
|
||||
def _get_draft_workflow_for_update(self, app_model: App, *, session: Session) -> Workflow | None:
|
||||
def get_draft_workflow_for_update(self, app_model: App, *, session: Session) -> Workflow | None:
|
||||
"""Return the app draft while holding its row lock for the caller's transaction."""
|
||||
return session.scalar(
|
||||
select(Workflow)
|
||||
@@ -361,9 +362,14 @@ class WorkflowService:
|
||||
limit: int,
|
||||
user_id: str | None,
|
||||
named_only: bool = False,
|
||||
include_draft: bool = True,
|
||||
) -> tuple[Sequence[Workflow], bool]:
|
||||
"""
|
||||
Get published workflow with pagination
|
||||
|
||||
The console lists the draft alongside published versions (``include_draft=True``,
|
||||
the default); callers that only want published versions, where the draft would
|
||||
break restore or ordering, pass ``include_draft=False``.
|
||||
"""
|
||||
if not app_model.workflow_id:
|
||||
return [], False
|
||||
@@ -391,6 +397,9 @@ class WorkflowService:
|
||||
if named_only:
|
||||
stmt = stmt.where(Workflow.marked_name != "")
|
||||
|
||||
if not include_draft:
|
||||
stmt = stmt.where(Workflow.version != Workflow.VERSION_DRAFT)
|
||||
|
||||
workflows = session.scalars(stmt).all()
|
||||
|
||||
has_more = len(workflows) > limit
|
||||
@@ -434,7 +443,7 @@ class WorkflowService:
|
||||
raise ValueError("Deleted environment variable ids require an environment variable patch.")
|
||||
|
||||
# fetch draft workflow by app_model
|
||||
workflow = self._get_draft_workflow_for_update(app_model=app_model, session=session)
|
||||
workflow = self.get_draft_workflow_for_update(app_model=app_model, session=session)
|
||||
|
||||
if workflow and workflow.unique_hash != unique_hash:
|
||||
raise WorkflowHashNotEqualError()
|
||||
@@ -555,7 +564,7 @@ class WorkflowService:
|
||||
variables they do not touch. Existing variables keep their order and new variables are appended.
|
||||
The transaction is committed before this method returns.
|
||||
"""
|
||||
workflow = self._get_draft_workflow_for_update(app_model=app_model, session=session)
|
||||
workflow = self.get_draft_workflow_for_update(app_model=app_model, session=session)
|
||||
if not workflow:
|
||||
raise ValueError("No draft workflow found.")
|
||||
|
||||
@@ -766,6 +775,29 @@ class WorkflowService:
|
||||
# return new workflow
|
||||
return workflow
|
||||
|
||||
def publish_app_workflow(
|
||||
self,
|
||||
*,
|
||||
session: Session,
|
||||
app_model: App,
|
||||
account: Account,
|
||||
marked_name: str = "",
|
||||
marked_comment: str = "",
|
||||
) -> Workflow:
|
||||
workflow = self.publish_workflow(
|
||||
session=session,
|
||||
app_model=app_model,
|
||||
account=account,
|
||||
marked_name=marked_name,
|
||||
marked_comment=marked_comment,
|
||||
)
|
||||
app_in_session = session.get(App, app_model.id)
|
||||
if app_in_session:
|
||||
app_in_session.workflow_id = workflow.id
|
||||
app_in_session.updated_by = account.id
|
||||
app_in_session.updated_at = naive_utc_now()
|
||||
return workflow
|
||||
|
||||
def _validate_workflow_credentials(self, workflow: Workflow, *, session: Session) -> None:
|
||||
"""
|
||||
Validate all credentials in workflow nodes before publishing.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from collections import defaultdict, deque
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
@@ -10,6 +12,8 @@ from core.trigger.constants import TRIGGER_NODE_TYPES
|
||||
from core.workflow.nodes.human_input.constants import TIMEOUT_HANDLE
|
||||
from graphon.enums import BuiltinNodeTypes, ErrorStrategy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_RESERVED_SELECTOR_HEADS: frozenset[str] = frozenset({"sys", "env", "conversation", "start"})
|
||||
|
||||
_REFERENCE_EXEMPT_NODE_TYPES: frozenset[str] = frozenset(
|
||||
@@ -169,6 +173,21 @@ def format_variable_reference_errors(issues: Sequence[VariableReferenceIssue]) -
|
||||
)
|
||||
|
||||
|
||||
def advisory_variable_reference_warning(graph_text: str | None) -> str | None:
|
||||
"""Return a non-blocking publish warning. A checker failure must not fail publish."""
|
||||
if not graph_text:
|
||||
return None
|
||||
try:
|
||||
graph = json.loads(graph_text)
|
||||
if not isinstance(graph, dict):
|
||||
return None
|
||||
issues = validate_variable_references(graph)
|
||||
return format_variable_reference_errors(issues) if issues else None
|
||||
except Exception:
|
||||
logger.warning("Skipped advisory variable reference check", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _reachable_from(starts: Sequence[str], successors: Mapping[str, list[str]]) -> set[str]:
|
||||
visited: set[str] = set()
|
||||
queue: deque[str] = deque(starts)
|
||||
|
||||
@@ -4,6 +4,7 @@ import inspect
|
||||
import json
|
||||
from contextlib import contextmanager, nullcontext
|
||||
from datetime import datetime
|
||||
from functools import partial
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
@@ -23,6 +24,7 @@ from graphon.variables.variables import RAGPipelineVariable
|
||||
from models.account import Account
|
||||
from models.model import App, AppMode
|
||||
from models.workflow import Workflow, WorkflowType
|
||||
from services import workflow_variable_reference_validator as validator_module
|
||||
from tests.unit_tests.config_override import apply_config_overrides
|
||||
|
||||
|
||||
@@ -166,18 +168,16 @@ def test_publish_workflow_returns_success(
|
||||
graph={"clean": "{}", "empty": None, "non-object": "[]", "invalid-json": "{"}.get(advisory, json.dumps(graph)),
|
||||
)
|
||||
if advisory == "checker-error":
|
||||
monkeypatch.setattr(workflow_module, "validate_variable_references", Mock(side_effect=RuntimeError("checker")))
|
||||
monkeypatch.setattr(validator_module, "validate_variable_references", Mock(side_effect=RuntimeError("checker")))
|
||||
elif advisory == "formatter-error":
|
||||
monkeypatch.setattr(
|
||||
workflow_module, "format_variable_reference_errors", Mock(side_effect=RuntimeError("format"))
|
||||
validator_module, "format_variable_reference_errors", Mock(side_effect=RuntimeError("format"))
|
||||
)
|
||||
session = Mock()
|
||||
session.get.return_value = app_model
|
||||
monkeypatch.setattr(
|
||||
workflow_module,
|
||||
"WorkflowService",
|
||||
Mock(return_value=SimpleNamespace(publish_workflow=Mock(return_value=workflow))),
|
||||
)
|
||||
service = SimpleNamespace(publish_workflow=Mock(return_value=workflow))
|
||||
service.publish_app_workflow = partial(workflow_module.WorkflowService.publish_app_workflow, service)
|
||||
monkeypatch.setattr(workflow_module, "WorkflowService", Mock(return_value=service))
|
||||
monkeypatch.setattr(
|
||||
workflow_module,
|
||||
"sessionmaker",
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
from collections.abc import Callable
|
||||
from types import SimpleNamespace
|
||||
from typing import Protocol, cast
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
from controllers.openapi import app_dsl
|
||||
from controllers.openapi._contract import op_of
|
||||
from controllers.openapi._models import AppDslImportPayload, AppDslImportResponse, Hint
|
||||
from controllers.openapi.app_dsl import AppDslImportApi, AppDslImportConfirmApi
|
||||
from controllers.openapi._models import (
|
||||
AppDslExportQuery,
|
||||
AppDslExportResponse,
|
||||
AppDslImportPayload,
|
||||
AppDslImportResponse,
|
||||
Hint,
|
||||
)
|
||||
from controllers.openapi.app_dsl import AppDslExportApi, AppDslImportApi, AppDslImportConfirmApi
|
||||
from controllers.openapi.auth.context import Context
|
||||
from controllers.openapi.auth.spec import EndpointSpec
|
||||
from controllers.openapi.auth.subjects import Subject
|
||||
from extensions.ext_database import db
|
||||
from machinery.context import RequestContext
|
||||
from models.model import App, AppMode
|
||||
from services.app_dsl_service import AppDslService
|
||||
from services.entities.dsl_entities import Import, ImportStatus
|
||||
from services.errors.account import NoPermissionError
|
||||
from tests.unit_tests.controllers.conftest import ControllerTestServices
|
||||
@@ -22,6 +36,12 @@ class _EndpointView(Protocol):
|
||||
__handler__: Callable[..., tuple[AppDslImportResponse, int]]
|
||||
|
||||
|
||||
class _ExportView(Protocol):
|
||||
"""Same stand-in, typed for the export endpoint's response."""
|
||||
|
||||
__handler__: Callable[..., tuple[AppDslExportResponse, int]]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("api", "kwargs"),
|
||||
[
|
||||
@@ -97,3 +117,37 @@ def test_import_hints_a_confirm_only_while_pending(
|
||||
)
|
||||
|
||||
assert (code, response.hints) == (status, hints)
|
||||
|
||||
|
||||
def test_export_reads_draft_hash_before_building_dsl(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The hash must describe the draft as of before the export, not after: an edit that
|
||||
commits in between would otherwise make a later import silently overwrite it."""
|
||||
call_order: list[str] = []
|
||||
|
||||
def fake_export_dsl(**_kwargs):
|
||||
call_order.append("export")
|
||||
return "app: {}"
|
||||
|
||||
def fake_get_draft_workflow(**_kwargs):
|
||||
call_order.append("draft_hash")
|
||||
return SimpleNamespace(unique_hash="graph-only-hash", content_hash="hash-before-edit")
|
||||
|
||||
# `WorkflowService()` needs a live db.engine; stand in for the whole service so the
|
||||
# bare Flask app these tests run under does not need one.
|
||||
service = Mock()
|
||||
service.get_draft_workflow.side_effect = fake_get_draft_workflow
|
||||
|
||||
monkeypatch.setattr(AppDslService, "export_dsl", fake_export_dsl)
|
||||
monkeypatch.setattr(app_dsl, "WorkflowService", lambda: service)
|
||||
monkeypatch.setattr(db, "session", lambda: None)
|
||||
|
||||
ctx = Context(cast(Subject, SimpleNamespace()), Mock(), {"app_id": "app-1"})
|
||||
ctx._app = App(id="app-1", tenant_id="tenant-1", name="a", mode=AppMode.WORKFLOW, enable_site=True, enable_api=True)
|
||||
|
||||
api = AppDslExportApi()
|
||||
with app.test_request_context("/openapi/v1/apps/app-1/dsl"):
|
||||
response, status = cast(_ExportView, api.get).__handler__(api, ctx, "app-1", query=AppDslExportQuery())
|
||||
|
||||
assert call_order == ["draft_hash", "export"]
|
||||
assert response.draft_hash == "hash-before-edit"
|
||||
assert status == 200
|
||||
|
||||
@@ -15,6 +15,7 @@ from pydantic import BaseModel, ValidationError
|
||||
from werkzeug.datastructures import FileStorage
|
||||
from werkzeug.exceptions import UnprocessableEntity
|
||||
|
||||
from controllers.openapi import app_run
|
||||
from controllers.openapi._models import (
|
||||
AdvancedChatRunPayload,
|
||||
ChatRunPayload,
|
||||
@@ -23,6 +24,7 @@ from controllers.openapi._models import (
|
||||
WorkflowRunPayload,
|
||||
)
|
||||
from controllers.openapi.app_run import AdvancedChatRunApi, AppRunTaskStopApi, ChatRunApi, CompletionRunApi
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from graphon.file import FileType
|
||||
from models import Account
|
||||
from models.enums import CreatorUserRole
|
||||
@@ -208,7 +210,7 @@ def test_chat_route_hints_the_reply_on_message_end(app: Flask, monkeypatch: pyte
|
||||
assert json.loads(chunks[1][len("data: ") :])["hints"] == [
|
||||
{
|
||||
"summary": "Reply in this conversation",
|
||||
"op": "console_app.chat.run",
|
||||
"op": "run.console_app.chat",
|
||||
"input": {"app_id": _TEST_APP_ID, "conversation_id": "c1", "query": None, "inputs": {}},
|
||||
}
|
||||
]
|
||||
@@ -222,3 +224,13 @@ def test_chat_route_passes_a_message_end_without_conversation_through(app: Flask
|
||||
with app.test_request_context(f"/openapi/v1/apps/{_TEST_APP_ID}/chat:run", method="POST"):
|
||||
response = api.post.__handler__(api, _ctx(AppMode.CHAT), app_id=_TEST_APP_ID, body=body)
|
||||
assert list(response.response) == [end]
|
||||
|
||||
|
||||
def test_draft_runs_do_not_hint_the_form_submit_op() -> None:
|
||||
"""A draft run's human-input form is a debugger form; the submit op only takes web-app
|
||||
forms, so hinting it would send the agent straight into a 403."""
|
||||
draft_routes = [route for route in app_run._RUN_ROUTES if route.invoke_from == InvokeFrom.DEBUGGER]
|
||||
assert draft_routes
|
||||
for route in draft_routes:
|
||||
assert app_run._form_layer not in route.hints
|
||||
assert "cannot be resumed over openapi" in route.summary
|
||||
|
||||
174
api/tests/unit_tests/controllers/openapi/test_app_workflow.py
Normal file
174
api/tests/unit_tests/controllers/openapi/test_app_workflow.py
Normal file
@@ -0,0 +1,174 @@
|
||||
from collections.abc import Callable
|
||||
from types import SimpleNamespace
|
||||
from typing import Protocol, cast
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from pydantic import ValidationError
|
||||
|
||||
from controllers.openapi import app_workflow
|
||||
from controllers.openapi._errors import RunNotFound, SecretMaskNotSecret, SecretMaskUnknownId, VersionNotFound
|
||||
from controllers.openapi._models import (
|
||||
EnvVariableSetPayload,
|
||||
RestoreResponse,
|
||||
RunListQuery,
|
||||
RunListResponse,
|
||||
VersionListQuery,
|
||||
)
|
||||
from controllers.openapi.app_workflow import (
|
||||
AppEnvItemApi,
|
||||
AppRunNodeListApi,
|
||||
AppVersionListApi,
|
||||
AppVersionRestoreApi,
|
||||
env_variable_rows,
|
||||
with_next_cursor,
|
||||
)
|
||||
from controllers.openapi.auth.context import Context
|
||||
from controllers.openapi.auth.subjects import Subject
|
||||
from core.helper import encrypter
|
||||
from graphon.variables import SecretVariable, StringVariable
|
||||
from models.account import Account
|
||||
from models.model import App, AppMode
|
||||
|
||||
|
||||
class _EndpointView(Protocol):
|
||||
"""Structural stand-in for a `view` carrying the attributes `@endpoint` attaches."""
|
||||
|
||||
__handler__: Callable[..., object]
|
||||
|
||||
|
||||
def _app_context(app_id: str = "app-1", **view_args: str) -> Context:
|
||||
ctx = Context(cast(Subject, SimpleNamespace()), Mock(), {"app_id": app_id, **view_args})
|
||||
ctx._app = App(id=app_id, tenant_id="tenant-1", name="a", mode=AppMode.WORKFLOW, enable_site=True, enable_api=True)
|
||||
account = Account(name="tester", email="tester@example.com")
|
||||
account.id = "account-1"
|
||||
ctx._caller = account
|
||||
return ctx
|
||||
|
||||
|
||||
def _fake_workflow_service(monkeypatch: pytest.MonkeyPatch) -> Mock:
|
||||
"""Stand in for `WorkflowService()`: the real constructor needs a live db.engine,
|
||||
which the bare Flask app these tests run under does not have."""
|
||||
service = Mock()
|
||||
monkeypatch.setattr(app_workflow, "WorkflowService", lambda: service)
|
||||
return service
|
||||
|
||||
|
||||
def test_env_variable_view_masks_only_secrets_that_have_a_value() -> None:
|
||||
"""An empty secret reads back empty, not masked: masking it would claim a value that is not there."""
|
||||
secret = SecretVariable(id="s", name="KEY", value="plain", selector=["env", "KEY"])
|
||||
empty_secret = SecretVariable(id="e", name="BLANK", value="", selector=["env", "BLANK"])
|
||||
plain = StringVariable(id="p", name="URL", value="https://x", selector=["env", "URL"])
|
||||
rows = env_variable_rows([secret, empty_secret, plain])
|
||||
assert [row.value for row in rows] == [encrypter.full_mask_token(), "", "https://x"]
|
||||
|
||||
|
||||
def test_run_list_hints_the_next_cursor() -> None:
|
||||
page = RunListResponse.model_validate({"limit": 1, "has_more": True, "data": [{"id": "run-1"}]})
|
||||
hinted = with_next_cursor(page, op="get.console_app.run", app_id="app-1", query=RunListQuery(limit=1))
|
||||
assert hinted.hints[0].input == {"app_id": "app-1", "limit": 1, "last_id": "run-1"}
|
||||
|
||||
|
||||
def test_set_env_rejects_mask_for_unknown_id(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Sending the mask for an id that is not a stored secret must not encrypt the
|
||||
literal mask into the draft (the bug: it silently succeeded, storing `[__HIDDEN__]`)."""
|
||||
service = _fake_workflow_service(monkeypatch)
|
||||
service.get_draft_workflow.return_value = SimpleNamespace(environment_variables=[])
|
||||
|
||||
body = EnvVariableSetPayload(name="KEY", value_type="secret", value=encrypter.full_mask_token())
|
||||
api = AppEnvItemApi()
|
||||
ctx = _app_context(env_id="unknown-id")
|
||||
with app.test_request_context("/openapi/v1/apps/app-1/env/unknown-id", method="PUT"):
|
||||
with pytest.raises(SecretMaskUnknownId):
|
||||
cast(_EndpointView, api.put).__handler__(api, ctx, "app-1", "unknown-id", body=body)
|
||||
|
||||
service.patch_draft_workflow_environment_variables.assert_not_called()
|
||||
|
||||
|
||||
def test_set_env_accepts_mask_for_a_stored_secret(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The same mask for an id that *is* a stored secret keeps the existing flow working."""
|
||||
stored = SecretVariable(id="s-1", name="KEY", value="plain", selector=["env", "KEY"])
|
||||
service = _fake_workflow_service(monkeypatch)
|
||||
service.get_draft_workflow.return_value = SimpleNamespace(environment_variables=[stored])
|
||||
|
||||
body = EnvVariableSetPayload(name="KEY", value_type="secret", value=encrypter.full_mask_token())
|
||||
api = AppEnvItemApi()
|
||||
ctx = _app_context(env_id="s-1")
|
||||
with app.test_request_context("/openapi/v1/apps/app-1/env/s-1", method="PUT"):
|
||||
cast(_EndpointView, api.put).__handler__(api, ctx, "app-1", "s-1", body=body)
|
||||
|
||||
service.patch_draft_workflow_environment_variables.assert_called_once()
|
||||
|
||||
|
||||
def test_set_env_rejects_the_mask_as_a_plain_value(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Re-typing a stored secret as a string while sending the mask would store the mask text
|
||||
and destroy the secret."""
|
||||
stored = SecretVariable(id="s-1", name="KEY", value="plain", selector=["env", "KEY"])
|
||||
service = _fake_workflow_service(monkeypatch)
|
||||
service.get_draft_workflow.return_value = SimpleNamespace(environment_variables=[stored])
|
||||
|
||||
body = EnvVariableSetPayload(name="KEY", value_type="string", value=encrypter.full_mask_token())
|
||||
api = AppEnvItemApi()
|
||||
with app.test_request_context("/openapi/v1/apps/app-1/env/s-1", method="PUT"):
|
||||
with pytest.raises(SecretMaskNotSecret):
|
||||
cast(_EndpointView, api.put).__handler__(api, _app_context(env_id="s-1"), "app-1", "s-1", body=body)
|
||||
|
||||
service.patch_draft_workflow_environment_variables.assert_not_called()
|
||||
|
||||
|
||||
def test_set_env_payload_rejects_a_value_type_the_console_does_not_allow() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
EnvVariableSetPayload(name="KEY", value_type="boolean", value=True)
|
||||
|
||||
|
||||
def test_restore_rejects_a_garbage_version_id(app: Flask) -> None:
|
||||
api = AppVersionRestoreApi()
|
||||
ctx = _app_context(version_id="not-a-uuid")
|
||||
with app.test_request_context("/openapi/v1/apps/app-1/versions/not-a-uuid:restore", method="POST"):
|
||||
with pytest.raises(VersionNotFound):
|
||||
cast(_EndpointView, api.post).__handler__(api, ctx, "app-1", "not-a-uuid")
|
||||
|
||||
|
||||
def test_version_list_excludes_the_draft(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
service = _fake_workflow_service(monkeypatch)
|
||||
service.get_all_published_workflow.return_value = ([], False)
|
||||
|
||||
api = AppVersionListApi()
|
||||
with app.test_request_context("/openapi/v1/apps/app-1/versions"):
|
||||
cast(_EndpointView, api.get).__handler__(api, _app_context(), "app-1", query=VersionListQuery())
|
||||
|
||||
assert service.get_all_published_workflow.call_args.kwargs["include_draft"] is False
|
||||
|
||||
|
||||
def test_restore_returns_the_hash_an_import_checks(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
service = _fake_workflow_service(monkeypatch)
|
||||
service.restore_published_workflow_to_draft.return_value = SimpleNamespace(
|
||||
unique_hash="graph-only-hash", content_hash="whole-draft-hash"
|
||||
)
|
||||
monkeypatch.setattr(app_workflow.db, "session", lambda: None)
|
||||
version_id = "11111111-1111-4111-8111-111111111111"
|
||||
|
||||
api = AppVersionRestoreApi()
|
||||
with app.test_request_context(f"/openapi/v1/apps/app-1/versions/{version_id}:restore", method="POST"):
|
||||
response = cast(_EndpointView, api.post).__handler__(
|
||||
api, _app_context(version_id=version_id), "app-1", version_id
|
||||
)
|
||||
|
||||
assert cast(RestoreResponse, response).draft_hash == "whole-draft-hash"
|
||||
|
||||
|
||||
def test_run_node_list_is_not_found_for_a_run_of_another_app(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Match describe: an unknown run is a 404, not an empty step list."""
|
||||
runs = Mock()
|
||||
runs.get_workflow_run.return_value = None
|
||||
monkeypatch.setattr(app_workflow, "application_services", lambda: SimpleNamespace(workflow_runs=runs))
|
||||
monkeypatch.setattr(app_workflow, "_request_context", lambda _ctx: None)
|
||||
run_id = "11111111-1111-4111-8111-111111111111"
|
||||
|
||||
api = AppRunNodeListApi()
|
||||
with app.test_request_context(f"/openapi/v1/apps/app-1/runs/{run_id}/nodes"):
|
||||
with pytest.raises(RunNotFound):
|
||||
cast(_EndpointView, api.get).__handler__(api, _app_context(run_id=run_id), "app-1", run_id)
|
||||
|
||||
runs.get_workflow_run_node_executions.assert_not_called()
|
||||
@@ -243,9 +243,9 @@ ADMIT_NO_MOUNT = Expect(
|
||||
_RUN_TRAITS = frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED, Trait.EXTERNAL_REACHABLE})
|
||||
|
||||
ROUTES: tuple[Route, ...] = (
|
||||
Route("account.get", "GET", "/account", frozenset({Trait.ACCOUNT_PRIMARY})),
|
||||
Route("describe.account", "GET", "/account", frozenset({Trait.ACCOUNT_PRIMARY})),
|
||||
Route("account.sessions.revoke_self", "DELETE", "/account/sessions/self", frozenset({Trait.ACCOUNT_PRIMARY})),
|
||||
Route("account.sessions.list", "GET", "/account/sessions", frozenset({Trait.ACCOUNT_PRIMARY})),
|
||||
Route("get.account.session", "GET", "/account/sessions", frozenset({Trait.ACCOUNT_PRIMARY})),
|
||||
Route(
|
||||
"account.sessions.revoke_one",
|
||||
"DELETE",
|
||||
@@ -309,6 +309,59 @@ ROUTES: tuple[Route, ...] = (
|
||||
Route("app_run.chat", "POST", "/apps/{app_id}/chat:run", _RUN_TRAITS),
|
||||
Route("app_run.advanced_chat", "POST", "/apps/{app_id}/advanced-chat:run", _RUN_TRAITS),
|
||||
Route("app_run.completion", "POST", "/apps/{app_id}/completion:run", _RUN_TRAITS),
|
||||
Route(
|
||||
"app_run.draft.workflow",
|
||||
"POST",
|
||||
"/apps/{app_id}/draft/workflow:run",
|
||||
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
||||
),
|
||||
Route(
|
||||
"app_run.draft.advanced_chat",
|
||||
"POST",
|
||||
"/apps/{app_id}/draft/advanced-chat:run",
|
||||
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
||||
),
|
||||
Route("app_workflow.run.list", "GET", "/apps/{app_id}/runs", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED})),
|
||||
Route(
|
||||
"app_workflow.run.describe",
|
||||
"GET",
|
||||
"/apps/{app_id}/runs/{run_id}",
|
||||
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
||||
),
|
||||
Route(
|
||||
"app_workflow.run.nodes",
|
||||
"GET",
|
||||
"/apps/{app_id}/runs/{run_id}/nodes",
|
||||
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
||||
),
|
||||
Route(
|
||||
"app_workflow.publish", "POST", "/apps/{app_id}:publish", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED})
|
||||
),
|
||||
Route(
|
||||
"app_workflow.version.list",
|
||||
"GET",
|
||||
"/apps/{app_id}/versions",
|
||||
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
||||
),
|
||||
Route(
|
||||
"app_workflow.version.restore",
|
||||
"POST",
|
||||
"/apps/{app_id}/versions/{version_id}:restore",
|
||||
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
||||
),
|
||||
Route("app_workflow.env.list", "GET", "/apps/{app_id}/env", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED})),
|
||||
Route(
|
||||
"app_workflow.env.set",
|
||||
"PUT",
|
||||
"/apps/{app_id}/env/{env_id}",
|
||||
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
||||
),
|
||||
Route(
|
||||
"app_workflow.env.delete",
|
||||
"DELETE",
|
||||
"/apps/{app_id}/env/{env_id}",
|
||||
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
||||
),
|
||||
Route(
|
||||
"app_run.stop",
|
||||
"POST",
|
||||
@@ -491,7 +544,7 @@ SCENARIOS: dict[Case, Scenario] = {
|
||||
}
|
||||
|
||||
|
||||
ROUTER_CASE_ROUTE = "account.get"
|
||||
ROUTER_CASE_ROUTE = "describe.account"
|
||||
ROUTER_CASES: dict[Case, Expect] = {
|
||||
Case.NO_BEARER: DENY_NO_BEARER,
|
||||
Case.LICENSE_INVALID: ADMIT_NO_LICENCE_GATE,
|
||||
@@ -563,8 +616,36 @@ _DUAL_SUBJECT_RUN: dict[Case, Expect] = {
|
||||
}
|
||||
|
||||
|
||||
_ACCOUNT_READER_APP: dict[Case, Expect] = {
|
||||
Case.MEMBER: ADMIT,
|
||||
Case.WRONG_SUBJECT: DENY_WRONG_SUBJECT,
|
||||
Case.INSUFFICIENT_SCOPE: DENY_SCOPE,
|
||||
Case.NON_MEMBER: DENY_NON_MEMBER,
|
||||
Case.NON_MEMBER_AND_INSUFFICIENT_SCOPE: DENY_NON_MEMBER,
|
||||
Case.LOW_ROLE: ADMIT,
|
||||
Case.APP_API_DISABLED: DENY_API_DISABLED,
|
||||
Case.UNKNOWN_APP: DENY_UNKNOWN_APP,
|
||||
Case.FOREIGN_WORKSPACE_QUERY: ADMIT,
|
||||
Case.EE_ACCOUNT_PUBLIC: ADMIT,
|
||||
Case.EE_ACCOUNT_SSO_VERIFIED: ADMIT,
|
||||
Case.EE_ACCOUNT_PRIVATE_ALL: ADMIT,
|
||||
Case.EE_ACCOUNT_PRIVATE_PERMITTED: ADMIT,
|
||||
Case.EE_ACCOUNT_PRIVATE_REFUSED: ADMIT,
|
||||
Case.EE_ACCOUNT_PRIVATE_REFUSED_WEBAPP_AUTH_OFF: ADMIT,
|
||||
Case.EE_ACCOUNT_MODE_UNRESOLVED: ADMIT,
|
||||
Case.RBAC_ON_DENIED: DENY_RBAC,
|
||||
Case.RBAC_ON_LOW_ROLE: ADMIT_NO_WORKSPACE_ROLE,
|
||||
}
|
||||
|
||||
_ACCOUNT_EDITOR_APP: dict[Case, Expect] = {
|
||||
**_ACCOUNT_READER_APP,
|
||||
Case.LOW_ROLE: DENY_ROLE,
|
||||
Case.RBAC_ON_LOW_ROLE: ADMIT,
|
||||
}
|
||||
|
||||
|
||||
MATRIX: dict[str, dict[Case, Expect]] = {
|
||||
"account.get": dict(_ACCOUNT_ONLY_NO_WORKSPACE),
|
||||
"describe.account": dict(_ACCOUNT_ONLY_NO_WORKSPACE),
|
||||
"workspaces.list": dict(_ACCOUNT_ONLY_NO_WORKSPACE),
|
||||
"apps.list": dict(_ACCOUNT_MEMBER_NO_ROLE),
|
||||
"workspaces.switch": dict(_ACCOUNT_MEMBER_NO_ROLE),
|
||||
@@ -573,46 +654,19 @@ MATRIX: dict[str, dict[Case, Expect]] = {
|
||||
"workspaces.members.invite": dict(_ACCOUNT_MEMBER_WITH_ROLE),
|
||||
"workspaces.members.update_role": dict(_ACCOUNT_MEMBER_WITH_ROLE),
|
||||
"app_dsl.import": dict(_ACCOUNT_MEMBER_WITH_ROLE),
|
||||
"apps.describe": {
|
||||
Case.MEMBER: ADMIT,
|
||||
Case.WRONG_SUBJECT: DENY_WRONG_SUBJECT,
|
||||
Case.INSUFFICIENT_SCOPE: DENY_SCOPE,
|
||||
Case.NON_MEMBER: DENY_NON_MEMBER,
|
||||
Case.NON_MEMBER_AND_INSUFFICIENT_SCOPE: DENY_NON_MEMBER,
|
||||
Case.LOW_ROLE: ADMIT,
|
||||
Case.APP_API_DISABLED: DENY_API_DISABLED,
|
||||
Case.UNKNOWN_APP: DENY_UNKNOWN_APP,
|
||||
Case.FOREIGN_WORKSPACE_QUERY: ADMIT,
|
||||
Case.EE_ACCOUNT_PUBLIC: ADMIT,
|
||||
Case.EE_ACCOUNT_SSO_VERIFIED: ADMIT,
|
||||
Case.EE_ACCOUNT_PRIVATE_ALL: ADMIT,
|
||||
Case.EE_ACCOUNT_PRIVATE_PERMITTED: ADMIT,
|
||||
Case.EE_ACCOUNT_PRIVATE_REFUSED: ADMIT,
|
||||
Case.EE_ACCOUNT_PRIVATE_REFUSED_WEBAPP_AUTH_OFF: ADMIT,
|
||||
Case.EE_ACCOUNT_MODE_UNRESOLVED: ADMIT,
|
||||
Case.RBAC_ON_DENIED: DENY_RBAC,
|
||||
Case.RBAC_ON_LOW_ROLE: ADMIT_NO_WORKSPACE_ROLE,
|
||||
},
|
||||
"app_dsl.export": {
|
||||
Case.MEMBER: ADMIT,
|
||||
Case.WRONG_SUBJECT: DENY_WRONG_SUBJECT,
|
||||
Case.INSUFFICIENT_SCOPE: DENY_SCOPE,
|
||||
Case.NON_MEMBER: DENY_NON_MEMBER,
|
||||
Case.NON_MEMBER_AND_INSUFFICIENT_SCOPE: DENY_NON_MEMBER,
|
||||
Case.LOW_ROLE: DENY_ROLE,
|
||||
Case.APP_API_DISABLED: DENY_API_DISABLED,
|
||||
Case.UNKNOWN_APP: DENY_UNKNOWN_APP,
|
||||
Case.FOREIGN_WORKSPACE_QUERY: ADMIT,
|
||||
Case.EE_ACCOUNT_PUBLIC: ADMIT,
|
||||
Case.EE_ACCOUNT_SSO_VERIFIED: ADMIT,
|
||||
Case.EE_ACCOUNT_PRIVATE_ALL: ADMIT,
|
||||
Case.EE_ACCOUNT_PRIVATE_PERMITTED: ADMIT,
|
||||
Case.EE_ACCOUNT_PRIVATE_REFUSED: ADMIT,
|
||||
Case.EE_ACCOUNT_PRIVATE_REFUSED_WEBAPP_AUTH_OFF: ADMIT,
|
||||
Case.EE_ACCOUNT_MODE_UNRESOLVED: ADMIT,
|
||||
Case.RBAC_ON_LOW_ROLE: ADMIT,
|
||||
Case.RBAC_ON_DENIED: DENY_RBAC,
|
||||
},
|
||||
"apps.describe": dict(_ACCOUNT_READER_APP),
|
||||
"app_dsl.export": dict(_ACCOUNT_EDITOR_APP),
|
||||
"app_run.draft.workflow": dict(_ACCOUNT_EDITOR_APP),
|
||||
"app_run.draft.advanced_chat": dict(_ACCOUNT_EDITOR_APP),
|
||||
"app_workflow.run.list": dict(_ACCOUNT_READER_APP),
|
||||
"app_workflow.run.describe": dict(_ACCOUNT_READER_APP),
|
||||
"app_workflow.run.nodes": dict(_ACCOUNT_READER_APP),
|
||||
"app_workflow.publish": dict(_ACCOUNT_EDITOR_APP),
|
||||
"app_workflow.version.list": dict(_ACCOUNT_EDITOR_APP),
|
||||
"app_workflow.version.restore": dict(_ACCOUNT_EDITOR_APP),
|
||||
"app_workflow.env.list": dict(_ACCOUNT_EDITOR_APP),
|
||||
"app_workflow.env.set": dict(_ACCOUNT_EDITOR_APP),
|
||||
"app_workflow.env.delete": dict(_ACCOUNT_EDITOR_APP),
|
||||
"app_run.workflow": dict(_DUAL_SUBJECT_RUN),
|
||||
"app_run.chat": dict(_DUAL_SUBJECT_RUN),
|
||||
"app_run.advanced_chat": dict(_DUAL_SUBJECT_RUN),
|
||||
@@ -716,6 +770,45 @@ _REQ_RUN = (
|
||||
CheckAppAccess(),
|
||||
)
|
||||
_REQ_RUN_FORM = (*_REQ_RUN, CheckFormSurface())
|
||||
_REQ_RELEASE = (
|
||||
CheckSubject(allowed=_ACCOUNT),
|
||||
CheckAppApiEnabled(),
|
||||
CheckWorkspaceMember(),
|
||||
CheckScope(Scope.WORKSPACE_WRITE),
|
||||
CheckRBACPermission(RBACCheck(RBACPermission.APP_RELEASE_AND_VERSION, PlainApp())),
|
||||
CheckWorkspaceRole(_EDITOR_UP),
|
||||
)
|
||||
_REQ_VERSION_READ = (
|
||||
CheckSubject(allowed=_ACCOUNT),
|
||||
CheckAppApiEnabled(),
|
||||
CheckWorkspaceMember(),
|
||||
CheckScope(Scope.APPS_READ),
|
||||
CheckRBACPermission(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp())),
|
||||
CheckWorkspaceRole(_EDITOR_UP),
|
||||
)
|
||||
_REQ_ENV_WRITE = (
|
||||
CheckSubject(allowed=_ACCOUNT),
|
||||
CheckAppApiEnabled(),
|
||||
CheckWorkspaceMember(),
|
||||
CheckScope(Scope.WORKSPACE_WRITE),
|
||||
CheckRBACPermission(RBACCheck(RBACPermission.APP_EDIT, PlainApp())),
|
||||
CheckWorkspaceRole(_EDITOR_UP),
|
||||
)
|
||||
_REQ_RUN_HISTORY = (
|
||||
CheckSubject(allowed=_ACCOUNT),
|
||||
CheckAppApiEnabled(),
|
||||
CheckWorkspaceMember(),
|
||||
CheckScope(Scope.APPS_READ),
|
||||
CheckRBACPermission(RBACCheck(RBACPermission.APP_CREATE_AND_MANAGEMENT, PlainApp())),
|
||||
)
|
||||
_REQ_DRAFT_RUN = (
|
||||
CheckSubject(allowed=_ACCOUNT),
|
||||
CheckAppApiEnabled(),
|
||||
CheckWorkspaceMember(),
|
||||
CheckScope(Scope.APPS_RUN),
|
||||
CheckRBACPermission(RBACCheck(RBACPermission.APP_TEST_AND_RUN, PlainApp())),
|
||||
CheckWorkspaceRole(_EDITOR_UP),
|
||||
)
|
||||
_REQ_FILES = (
|
||||
CheckSubject(allowed=_ACCOUNT_OR_EXTERNAL),
|
||||
CheckAppApiEnabled(),
|
||||
@@ -735,9 +828,9 @@ _REQ_EXTERNAL_DESCRIBE = (
|
||||
)
|
||||
|
||||
DECLARED: dict[str, tuple[Requirement, ...]] = {
|
||||
"account.get": _REQ_ACCOUNT_FULL,
|
||||
"describe.account": _REQ_ACCOUNT_FULL,
|
||||
"account.sessions.revoke_self": _REQ_ACCOUNT_FULL,
|
||||
"account.sessions.list": _REQ_ACCOUNT_FULL,
|
||||
"get.account.session": _REQ_ACCOUNT_FULL,
|
||||
"account.sessions.revoke_one": _REQ_ACCOUNT_FULL,
|
||||
"apps.describe": _REQ_APP_DESCRIBE,
|
||||
"apps.list": _REQ_ACCOUNT_APPS_READ_MEMBER,
|
||||
@@ -756,6 +849,17 @@ DECLARED: dict[str, tuple[Requirement, ...]] = {
|
||||
"app_run.chat": _REQ_RUN,
|
||||
"app_run.advanced_chat": _REQ_RUN,
|
||||
"app_run.completion": _REQ_RUN,
|
||||
"app_run.draft.workflow": _REQ_DRAFT_RUN,
|
||||
"app_run.draft.advanced_chat": _REQ_DRAFT_RUN,
|
||||
"app_workflow.run.list": _REQ_RUN_HISTORY,
|
||||
"app_workflow.run.describe": _REQ_RUN_HISTORY,
|
||||
"app_workflow.run.nodes": _REQ_RUN_HISTORY,
|
||||
"app_workflow.publish": _REQ_RELEASE,
|
||||
"app_workflow.version.list": _REQ_VERSION_READ,
|
||||
"app_workflow.version.restore": _REQ_RELEASE,
|
||||
"app_workflow.env.list": _REQ_VERSION_READ,
|
||||
"app_workflow.env.set": _REQ_ENV_WRITE,
|
||||
"app_workflow.env.delete": _REQ_ENV_WRITE,
|
||||
"app_run.stop": _REQ_RUN,
|
||||
"files.upload": _REQ_FILES,
|
||||
"human_input_form.get": _REQ_RUN_FORM,
|
||||
@@ -1041,6 +1145,9 @@ def _url(route: Route, world: World, scenario: Scenario, bearer: Bearer | None)
|
||||
"import_id": str(uuid.uuid4()),
|
||||
"task_id": str(uuid.uuid4()),
|
||||
"form_token": uuid.uuid4().hex,
|
||||
"run_id": str(uuid.uuid4()),
|
||||
"version_id": str(uuid.uuid4()),
|
||||
"env_id": str(uuid.uuid4()),
|
||||
}
|
||||
query = route.query.format(**ids)
|
||||
if scenario.foreign_workspace_query:
|
||||
@@ -1268,6 +1375,17 @@ EXPECTED_RESPONSE_CODES: dict[tuple[str, str], frozenset[str]] = {
|
||||
("post", "/apps/{app_id}/chat:run"): frozenset({"200", "422", "default"}),
|
||||
("post", "/apps/{app_id}/advanced-chat:run"): frozenset({"200", "422", "default"}),
|
||||
("post", "/apps/{app_id}/completion:run"): frozenset({"200", "422", "default"}),
|
||||
("post", "/apps/{app_id}/draft/workflow:run"): frozenset({"200", "422", "default"}),
|
||||
("post", "/apps/{app_id}/draft/advanced-chat:run"): frozenset({"200", "422", "default"}),
|
||||
("get", "/apps/{app_id}/runs"): frozenset({"200", "422", "default"}),
|
||||
("get", "/apps/{app_id}/runs/{run_id}"): frozenset({"200", "default"}),
|
||||
("get", "/apps/{app_id}/runs/{run_id}/nodes"): frozenset({"200", "default"}),
|
||||
("post", "/apps/{app_id}:publish"): frozenset({"200", "422", "default"}),
|
||||
("get", "/apps/{app_id}/versions"): frozenset({"200", "422", "default"}),
|
||||
("post", "/apps/{app_id}/versions/{version_id}:restore"): frozenset({"200", "default"}),
|
||||
("get", "/apps/{app_id}/env"): frozenset({"200", "default"}),
|
||||
("put", "/apps/{app_id}/env/{env_id}"): frozenset({"200", "422", "default"}),
|
||||
("delete", "/apps/{app_id}/env/{env_id}"): frozenset({"200", "default"}),
|
||||
# The five device-flow rows are the only operations with no `default`: they
|
||||
# document their 200 with a raw `openapi_ns.response` rather than `@returns`,
|
||||
# so no `ErrorBody` schema is registered for them.
|
||||
|
||||
@@ -92,12 +92,13 @@ def test_every_guarded_route_declares_catalog_meta_once(app: Flask, ops: dict[st
|
||||
streaming.add(meta.op)
|
||||
assert meta.kind is Kind.SSE, meta.op
|
||||
assert set(ops) <= set(seen)
|
||||
per_mode = {f"console_app.{mode}.run" for mode in ("workflow", "chat", "advanced_chat", "completion")}
|
||||
assert streaming == per_mode | {"run.events"}
|
||||
per_mode = {f"run.console_app.{mode}" for mode in ("workflow", "chat", "advanced_chat", "completion")}
|
||||
draft = {f"run.console_app.draft.{mode}" for mode in ("workflow", "advanced_chat")}
|
||||
assert streaming == per_mode | draft | {"get.run.event"}
|
||||
|
||||
|
||||
def test_run_entries_carry_path_bind_kind_and_flags(ops: dict[str, CatalogOp]) -> None:
|
||||
chat = ops["console_app.chat.run"]
|
||||
chat = ops["run.console_app.chat"]
|
||||
assert set(chat) == {"summary", "method", "path", "kind", "input", "bind", "internal", "deprecated", "examples"}
|
||||
assert (chat["method"], chat["path"], chat["kind"]) == (
|
||||
"POST",
|
||||
@@ -113,10 +114,10 @@ def test_run_entries_carry_path_bind_kind_and_flags(ops: dict[str, CatalogOp]) -
|
||||
"attachments": "file",
|
||||
}
|
||||
assert chat["deprecated"] is False
|
||||
assert ops["console_app.file.upload"]["bind"]["file"] == "file"
|
||||
assert ops["console_app.list"]["bind"]["page"] == "query"
|
||||
assert ops["run.events"]["bind"]["continue_on_pause"] == "query"
|
||||
assert ops["workspace.switch"]["internal"] is True
|
||||
assert ops["upload.console_app.file"]["bind"]["file"] == "file"
|
||||
assert ops["get.console_app"]["bind"]["page"] == "query"
|
||||
assert ops["get.run.event"]["bind"]["continue_on_pause"] == "query"
|
||||
assert ops["switch.workspace"]["internal"] is True
|
||||
|
||||
|
||||
def test_input_schemas_are_flat_shallow_and_described(ops: dict[str, CatalogOp]) -> None:
|
||||
@@ -140,8 +141,8 @@ def test_input_schemas_are_flat_shallow_and_described(ops: dict[str, CatalogOp])
|
||||
op for op, e in ops.items() if e["kind"] == "list" and not {"page", "limit"} <= set(e["input"]["properties"])
|
||||
] == []
|
||||
assert [op for op, e in ops.items() if set(Hinted.model_fields) & set(e["input"]["properties"])] == []
|
||||
desc = ops["console_app.chat.run"]["input"]["properties"]["inputs"]["description"]
|
||||
assert "console_app.describe" in desc
|
||||
desc = ops["run.console_app.chat"]["input"]["properties"]["inputs"]["description"]
|
||||
assert "describe.console_app" in desc
|
||||
assert "input_schema" in desc
|
||||
|
||||
|
||||
|
||||
@@ -281,7 +281,7 @@ class TestFormHints:
|
||||
[out] = list(with_form_hints(iter([f"data: {json.dumps(event)}\n\n"]), app_id="a1"))
|
||||
body = json.loads(out[len("data: ") :])
|
||||
op = op_of(OpenApiWorkflowHumanInputFormSubmitApi.post)
|
||||
assert op == "run.form.submit"
|
||||
assert op == "submit.run.form"
|
||||
assert body["hints"] == [
|
||||
{
|
||||
"summary": "批准",
|
||||
|
||||
@@ -383,7 +383,7 @@ def test_members_list_next_page_hint_reaches_the_wire(admitted_bearer: AdmittedW
|
||||
assert body["hints"] == [
|
||||
{
|
||||
"summary": "Next page",
|
||||
"op": "workspace.members.list",
|
||||
"op": "get.workspace.member",
|
||||
"input": {"workspace_id": admitted_bearer.workspace_id, "page": 2, "limit": 2},
|
||||
"form": None,
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ This test suite covers:
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -19,6 +20,7 @@ from graphon.enums import (
|
||||
WorkflowExecutionStatus,
|
||||
WorkflowNodeExecutionStatus,
|
||||
)
|
||||
from graphon.variables import StringVariable
|
||||
from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom
|
||||
from models.workflow import (
|
||||
Workflow,
|
||||
@@ -27,6 +29,7 @@ from models.workflow import (
|
||||
WorkflowRun,
|
||||
WorkflowType,
|
||||
)
|
||||
from tests.unit_tests.model_factories import make_workflow
|
||||
|
||||
|
||||
class TestWorkflowModelValidation:
|
||||
@@ -1042,3 +1045,43 @@ class TestGraphConfigurationValidation:
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
|
||||
|
||||
def _add_env_variable(workflow: Workflow) -> None:
|
||||
workflow.environment_variables = [
|
||||
*workflow.environment_variables,
|
||||
StringVariable(id="env-2", name="ADDED", value="x", selector=["env", "ADDED"]),
|
||||
]
|
||||
|
||||
|
||||
def _change_features(workflow: Workflow) -> None:
|
||||
workflow.features = json.dumps({"opening_statement": "hi"})
|
||||
|
||||
|
||||
def _add_conversation_variable(workflow: Workflow) -> None:
|
||||
workflow.conversation_variables = [
|
||||
StringVariable(id="conv-1", name="topic", value="", selector=["conversation", "topic"])
|
||||
]
|
||||
|
||||
|
||||
def _change_graph(workflow: Workflow) -> None:
|
||||
workflow.graph = json.dumps({"nodes": [{"id": "start"}], "edges": []})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"edit", [_add_env_variable, _change_features, _add_conversation_variable, _change_graph], ids=lambda f: f.__name__
|
||||
)
|
||||
def test_content_hash_covers_everything_a_dsl_import_replaces(edit: Callable[[Workflow], None]) -> None:
|
||||
workflow = make_workflow(
|
||||
environment_variables=[StringVariable(id="env-1", name="URL", value="https://x", selector=["env", "URL"])]
|
||||
)
|
||||
before = workflow.content_hash
|
||||
edit(workflow)
|
||||
assert workflow.content_hash != before
|
||||
|
||||
|
||||
def test_unique_hash_still_covers_only_the_graph() -> None:
|
||||
workflow = make_workflow()
|
||||
before = workflow.unique_hash
|
||||
_add_env_variable(workflow)
|
||||
assert workflow.unique_hash == before
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
@@ -8,14 +9,16 @@ import yaml
|
||||
from sqlalchemy import event, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from constants import HIDDEN_VALUE
|
||||
from constants.dsl_version import CURRENT_APP_DSL_VERSION
|
||||
from core.rbac import RBACPermission, RBACResourceScope
|
||||
from core.workflow.llm_environment_variable import LLMEnvironmentVariable
|
||||
from graphon.variables import StringVariable
|
||||
from models import Account, App, AppMode
|
||||
from models.model import AppModelConfig, AppModelConfigDict, IconType
|
||||
from models.workflow import Workflow
|
||||
from services.agent.dsl_entities import AgentPackage
|
||||
from services.app_dsl_service import AppDslService, PendingData
|
||||
from services.app_dsl_service import DRAFT_CHANGED_ERROR, AppDslService, PendingData, _keep_stored_secrets
|
||||
from services.entities.dsl_entities import ImportStatus
|
||||
from services.errors.account import NoPermissionError
|
||||
from services.errors.app import WorkflowNotFoundError
|
||||
@@ -726,6 +729,149 @@ def test_create_or_update_app_gates_agent_mode_before_creation(
|
||||
assert not unbound_session.in_transaction()
|
||||
|
||||
|
||||
_OVERWRITE_DSL = yaml.safe_dump(
|
||||
{
|
||||
"version": CURRENT_APP_DSL_VERSION,
|
||||
"kind": "app",
|
||||
"app": {"mode": AppMode.WORKFLOW.value, "name": "Changed"},
|
||||
"workflow": {"graph": {"nodes": []}},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _exported_draft() -> Workflow:
|
||||
return make_workflow(
|
||||
tenant_id="tenant",
|
||||
app_id="target",
|
||||
environment_variables=[StringVariable(id="env-1", name="URL", value="https://x", selector=["env", "URL"])],
|
||||
)
|
||||
|
||||
|
||||
def _add_env_variable_after_export(draft: Workflow) -> None:
|
||||
draft.environment_variables = [
|
||||
*draft.environment_variables,
|
||||
StringVariable(id="env-2", name="ADDED", value="y", selector=["env", "ADDED"]),
|
||||
]
|
||||
|
||||
|
||||
def _change_graph_after_export(draft: Workflow) -> None:
|
||||
draft.graph = json.dumps({"nodes": [{"id": "start"}], "edges": []})
|
||||
|
||||
|
||||
def _overwrite_target_service(monkeypatch: pytest.MonkeyPatch, draft: Workflow | None) -> tuple[AppDslService, Mock]:
|
||||
workflow_service = Mock()
|
||||
workflow_service.get_draft_workflow_for_update.return_value = draft
|
||||
workflow_service.get_draft_workflow.return_value = draft
|
||||
workflow_service.sync_draft_workflow.return_value = SimpleNamespace(id="workflow-1")
|
||||
monkeypatch.setattr("services.app_dsl_service.WorkflowService", Mock(return_value=workflow_service))
|
||||
return AppDslService(Mock()), workflow_service
|
||||
|
||||
|
||||
@pytest.mark.parametrize("edit", [_add_env_variable_after_export, _change_graph_after_export], ids=lambda f: f.__name__)
|
||||
def test_overwrite_refuses_a_draft_changed_since_export(
|
||||
monkeypatch: pytest.MonkeyPatch, edit: Callable[[Workflow], None]
|
||||
) -> None:
|
||||
draft = _exported_draft()
|
||||
export_hash = draft.content_hash
|
||||
edit(draft)
|
||||
target = App(id="target", tenant_id="tenant", mode=AppMode.WORKFLOW, name="Original")
|
||||
service, workflow_service = _overwrite_target_service(monkeypatch, draft)
|
||||
service._load_app_for_overwrite = Mock(return_value=target)
|
||||
|
||||
result = service.import_app(
|
||||
account=_account(),
|
||||
import_mode="yaml-content",
|
||||
app_id=target.id,
|
||||
draft_hash=export_hash,
|
||||
yaml_content=_OVERWRITE_DSL,
|
||||
)
|
||||
|
||||
assert result.status == ImportStatus.FAILED
|
||||
assert result.error == DRAFT_CHANGED_ERROR
|
||||
workflow_service.sync_draft_workflow.assert_not_called()
|
||||
assert target.name == "Original"
|
||||
|
||||
|
||||
def test_overwrite_refuses_a_draft_hash_when_the_app_has_no_draft(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
target = App(id="target", tenant_id="tenant", mode=AppMode.WORKFLOW, name="Original")
|
||||
service, workflow_service = _overwrite_target_service(monkeypatch, None)
|
||||
service._load_app_for_overwrite = Mock(return_value=target)
|
||||
|
||||
result = service.import_app(
|
||||
account=_account(), import_mode="yaml-content", app_id=target.id, draft_hash="any", yaml_content=_OVERWRITE_DSL
|
||||
)
|
||||
|
||||
assert result.error == DRAFT_CHANGED_ERROR
|
||||
workflow_service.sync_draft_workflow.assert_not_called()
|
||||
|
||||
|
||||
def test_overwrite_with_the_current_draft_hash_syncs_against_the_locked_draft(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
draft = _exported_draft()
|
||||
target = App(id="target", tenant_id="tenant", mode=AppMode.WORKFLOW, name="Original")
|
||||
service, workflow_service = _overwrite_target_service(monkeypatch, draft)
|
||||
|
||||
service._create_or_update_app(
|
||||
app=target, data=yaml.safe_load(_OVERWRITE_DSL), account=_account(), draft_hash=draft.content_hash
|
||||
)
|
||||
|
||||
workflow_service.get_draft_workflow_for_update.assert_called_once()
|
||||
assert workflow_service.sync_draft_workflow.call_args.kwargs["unique_hash"] == draft.unique_hash
|
||||
|
||||
|
||||
def test_overwrite_without_draft_hash_does_not_lock_the_draft_early(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
draft = _exported_draft()
|
||||
target = App(id="target", tenant_id="tenant", mode=AppMode.WORKFLOW, name="Original")
|
||||
service, workflow_service = _overwrite_target_service(monkeypatch, draft)
|
||||
|
||||
service._create_or_update_app(app=target, data=yaml.safe_load(_OVERWRITE_DSL), account=_account())
|
||||
|
||||
workflow_service.get_draft_workflow_for_update.assert_not_called()
|
||||
assert workflow_service.sync_draft_workflow.call_args.kwargs["unique_hash"] == draft.unique_hash
|
||||
|
||||
|
||||
def test_confirm_import_with_a_stale_draft_hash_fails_and_keeps_the_pending_import(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
draft = _exported_draft()
|
||||
export_hash = draft.content_hash
|
||||
_add_env_variable_after_export(draft)
|
||||
account = _account(account_id=_CALLER_ID, tenant_id=_TENANT_ID)
|
||||
pending = PendingData(
|
||||
tenant_id=_TENANT_ID,
|
||||
account_id=_CALLER_ID,
|
||||
import_mode="yaml-content",
|
||||
yaml_content=_OVERWRITE_DSL,
|
||||
app_id=_OVERWRITE_APP_ID,
|
||||
draft_hash=export_hash,
|
||||
)
|
||||
monkeypatch.setattr("services.app_dsl_service.redis_client.get", Mock(return_value=pending.model_dump_json()))
|
||||
redis_delete = Mock()
|
||||
monkeypatch.setattr("services.app_dsl_service.redis_client.delete", redis_delete)
|
||||
target = App(id=_OVERWRITE_APP_ID, tenant_id=_TENANT_ID, mode=AppMode.WORKFLOW, name="Original")
|
||||
service, workflow_service = _overwrite_target_service(monkeypatch, draft)
|
||||
service._load_app_for_overwrite = Mock(return_value=target)
|
||||
|
||||
result = service.confirm_import(import_id="import-1", account=account)
|
||||
|
||||
assert result.status == ImportStatus.FAILED
|
||||
assert result.error == DRAFT_CHANGED_ERROR
|
||||
workflow_service.sync_draft_workflow.assert_not_called()
|
||||
redis_delete.assert_not_called()
|
||||
assert target.name == "Original"
|
||||
|
||||
|
||||
def test_blank_secret_keeps_the_stored_value_only_when_it_is_stored() -> None:
|
||||
mappings = [
|
||||
{"id": "stored", "name": "KEY", "value_type": "secret", "value": ""},
|
||||
{"id": "new", "name": "NEW_KEY", "value_type": "secret", "value": ""},
|
||||
{"id": "plain", "name": "URL", "value_type": "string", "value": ""},
|
||||
{"id": "stored", "name": "KEY", "value_type": "secret", "value": "rotated"},
|
||||
]
|
||||
assert [m["value"] for m in _keep_stored_secrets(mappings, {"stored"})] == [HIDDEN_VALUE, "", "", "rotated"]
|
||||
|
||||
|
||||
def test_import_app_reraises_permission_denial_instead_of_failed_result(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
unbound_session: Session,
|
||||
@@ -843,13 +989,14 @@ def test_overwrite_rejects_incompatible_nodes_before_mutation(mode: AppMode, nod
|
||||
"version": CURRENT_APP_DSL_VERSION,
|
||||
"kind": "app",
|
||||
"app": {"mode": mode.value, "name": "Changed"},
|
||||
"workflow": {"graph": {"nodes": [{"data": {"type": node_type}}]}},
|
||||
"workflow": {"graph": {"nodes": [{"id": "n1", "data": {"type": node_type}}]}},
|
||||
}
|
||||
),
|
||||
)
|
||||
assert result.status == ImportStatus.FAILED
|
||||
assert result.error is not None
|
||||
assert "incompatible" in result.error
|
||||
assert f"n1 ({node_type})" in result.error
|
||||
assert target.name == "Original"
|
||||
session.add.assert_not_called()
|
||||
|
||||
|
||||
@@ -60,7 +60,9 @@ from services.errors.app import IsDraftWorkflowError, TriggerNodeLimitExceededEr
|
||||
from services.errors.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError
|
||||
from services.workflow_ref_service import WorkflowRef
|
||||
from services.workflow_service import (
|
||||
ENVIRONMENT_VARIABLE_NAME_TAKEN_ERROR,
|
||||
WorkflowService,
|
||||
_merge_environment_variable_patch,
|
||||
_rebuild_file_for_user_inputs_in_start_node,
|
||||
_rebuild_single_file,
|
||||
_setup_variable_pool,
|
||||
@@ -1582,6 +1584,39 @@ class TestWorkflowService:
|
||||
|
||||
assert [workflow.id for workflow in workflows] == ["workflow-draft", "workflow-3"]
|
||||
|
||||
def test_get_all_published_workflow_excludes_draft_when_requested(
|
||||
self, workflow_service: WorkflowService, sqlite_session: Session
|
||||
):
|
||||
"""`include_draft=False` filters the draft out at query level so callers that
|
||||
only understand published versions (e.g. the openapi version-list op, where the
|
||||
draft breaks restore and ordering) get an exact page.
|
||||
"""
|
||||
app = TestWorkflowAssociatedDataFactory.create_app(workflow_id="workflow-3")
|
||||
app_created_at = datetime(2026, 1, 1)
|
||||
|
||||
sqlite_session.add(
|
||||
TestWorkflowAssociatedDataFactory.create_workflow(
|
||||
workflow_id="workflow-draft",
|
||||
version=Workflow.VERSION_DRAFT,
|
||||
created_at=app_created_at,
|
||||
)
|
||||
)
|
||||
sqlite_session.add(
|
||||
TestWorkflowAssociatedDataFactory.create_workflow(
|
||||
workflow_id="workflow-3",
|
||||
version="2026-02-01 00:00:00",
|
||||
created_at=app_created_at + timedelta(days=1),
|
||||
)
|
||||
)
|
||||
sqlite_session.commit()
|
||||
|
||||
workflows, has_more = workflow_service.get_all_published_workflow(
|
||||
session=sqlite_session, app_model=app, page=1, limit=10, user_id=None, include_draft=False
|
||||
)
|
||||
|
||||
assert [workflow.id for workflow in workflows] == ["workflow-3"]
|
||||
assert has_more is False
|
||||
|
||||
def test_get_all_published_workflow_has_more(self, workflow_service: WorkflowService, sqlite_session: Session):
|
||||
"""
|
||||
Test get_all_published_workflow indicates has_more when results exceed limit.
|
||||
@@ -3686,3 +3721,18 @@ class TestWorkflowServiceFreeNodeExecution:
|
||||
assert node.title == "Human Input"
|
||||
assert node.node_data is node_data
|
||||
assert node.variable_pool is variable_pool
|
||||
|
||||
|
||||
def _env(variable_id: str, name: str) -> StringVariable:
|
||||
return StringVariable(id=variable_id, name=name, value="v", selector=["env", name])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"upsert",
|
||||
[_env("new-id", "TAKEN"), _env("other-id", "TAKEN")],
|
||||
ids=["new id reuses a name", "rename onto another variable's name"],
|
||||
)
|
||||
def test_environment_patch_rejects_a_name_another_id_uses(upsert: StringVariable) -> None:
|
||||
current = [_env("taken-id", "TAKEN"), _env("other-id", "OTHER")]
|
||||
with pytest.raises(ValueError, match=ENVIRONMENT_VARIABLE_NAME_TAKEN_ERROR):
|
||||
_merge_environment_variable_patch(current, [upsert], [])
|
||||
|
||||
@@ -4,11 +4,11 @@ This package is the Node 24+, ESM TypeScript implementation of `difyctl`. Develo
|
||||
|
||||
## Architecture Boundaries
|
||||
|
||||
- Every leaf command extends `DifyCommand`; command classes own framework parsing and delegate behavior to domain modules.
|
||||
- Each command folder keeps its framework shell in `index.ts`. Extract behavior into sibling modules such as `run.ts` and `handlers.ts` when it needs an independently testable owner; those modules receive typed dependencies and do not import `src/framework/`.
|
||||
- `src/http/` owns ky middleware and client construction; `src/api/` owns resource clients; `src/sys/io/` owns process streams and progress UI; `src/types/` remains a pure data and schema leaf.
|
||||
- Preserve flags, output, and exit codes during refactors. Do not add dependencies or compatibility shims unless the task explicitly requires them.
|
||||
- `ARD.md` owns CLI code structure. Keep wire behavior aligned with typed API clients and the real mock-server behavior tests.
|
||||
- Every command extends `Command` (`src/plugins/commands/command.ts`) and declares no `needs` of its own; it reads whatever plugin services it uses directly through `ctx.get(...)` inside `run(input, ctx)`.
|
||||
- Plugins under `src/plugins/` (`argv global-flags env config session token catalog http ops io commands`) own state. A plugin's `build()` closes over its own cache or loaded document; nothing outside a plugin reaches into another plugin's internals. Outside a plugin folder, import only `@/plugins/<name>` and use the service from `ctx.get(...)`; the command framework (`plugins/commands/command.ts`, `cancel.ts`, `registry.ts`) and the argv parser (`plugins/argv/parse.ts`) are the only modules imported directly.
|
||||
- `src/protocol/` and `src/call/` are pure modules: catalog-shape logic (kinds, bind, pins, fold) and the `call` flag table, request-building and rendering, with no op-specific branching anywhere under `src/call/` — kind and bind dispatch go through the tables in `src/protocol/` and `src/call/render/`, keyed by the catalog's own `kind`/`bind` values, never by operation id.
|
||||
- Preserve exit codes and the JSON output contract during refactors. Do not add dependencies or compatibility shims unless the task explicitly requires them.
|
||||
- `ARD.md` owns CLI code structure. Keep behavior aligned with the real mock-server (`test/fixtures/dify-mock/`) behavior tests.
|
||||
|
||||
## Commands
|
||||
|
||||
|
||||
282
cli/ARD.md
282
cli/ARD.md
@@ -4,90 +4,62 @@ Onboarding ref for `dify/cli/` contributors. Cover canonical patterns, layer con
|
||||
|
||||
---
|
||||
|
||||
## Project layout
|
||||
## Layout
|
||||
|
||||
```
|
||||
src/
|
||||
commands/ one folder per command leaf
|
||||
api/ HTTP client wrappers (one file per resource)
|
||||
auth/ hosts.yml read/write
|
||||
cache/ app-info cache
|
||||
config/ config.yml read/write
|
||||
errors/ BaseError, ErrorCode, exit codes
|
||||
http/ ky client factory + middleware
|
||||
sys/io/ IOStreams, prompts, spinner, output rendering
|
||||
limit/ --limit flag parsing
|
||||
types/ shared TypeScript types
|
||||
util/ small pure helpers
|
||||
workspace/ workspace ID resolution
|
||||
kernel/ plugin + Context primitives — no CLI knowledge
|
||||
plugins/ argv global-flags env config session token catalog http ops output io commands — the 12 services, one folder each; index.ts is the plugin and the only file anything outside the folder imports
|
||||
argv/ the invocation's tokens as a context input, plus the argv parser (parse.ts, framework shared by global-flags and commands)
|
||||
global-flags/ flags any command accepts (`--verbose`, `--json`), parsed off argv before the command is resolved
|
||||
catalog/ the cached catalog document: types, parseCatalog, op lookup
|
||||
http/ the authenticated fetch wrapper with the catalog fingerprint header; refetches on 412, and on 404 when the catalog moved
|
||||
ops/ the catalog as a command tree (refetched on request); command.ts adapts one op to a Command, tree.ts builds their command tree
|
||||
output/ output mode per stream: text on a terminal, JSON in a pipe; `--json` or `DIFY_OUTPUT` forces one
|
||||
io/ the one way anything is printed
|
||||
commands/ command pipeline plus the command framework (command.ts, cancel.ts, registry.ts) every command imports; help.ts shapes the help views, text.ts renders them for a terminal
|
||||
protocol/ pure catalog-shape logic: kinds, bind, pins, fold, shape (a JSON Schema as one of ten shapes), op-id (dotted id and its spaced words) — no I/O
|
||||
discovery/ pure id tree and ranked search over commands and ops — no I/O
|
||||
call/ the `call` flag table; JSON and SSE response decoding; builds a request from a resolved op + validated input; renders the response by kind
|
||||
commands/ one folder per command leaf; index.ts is the only file the registry discovers
|
||||
net/ fetch init and proxy dispatcher, shared by the http plugin and the pre-login device flow
|
||||
store/ config/session/token file and keychain persistence
|
||||
sys/ the process edges — env, argv, exit, platform paths; io/ holds streams.ts, color.ts and view.ts (a JSON value paired with its text form, the mode picks one)
|
||||
skills/ reads skills from a GitHub folder or a local folder, and installs them
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Kernel
|
||||
|
||||
A plugin is `{ name, needs, build(ctx) }`. `needs` is a list of the other plugin objects it reads through `ctx.get(...)` — an array of the actual plugin values, not strings or classes, so a plugin literally names its dependencies and TypeScript checks `build`'s `ctx` against that list (`src/kernel/plugin.ts`).
|
||||
|
||||
`Context` (`src/kernel/context.ts`) builds services lazily: `ctx.get(plugin)` runs `plugin.build(ctx)` the first time that plugin is requested, caches the result, and returns the cached service on every later call in the same invocation. A plugin never builds if nothing asks for it. `needs` is only the compile-time allow-list for that plugin's `ctx.get`; the kernel never walks it, so declaring `needs: BASE_PLUGINS` on the `commands` plugin costs nothing until a command actually calls `ctx.get` on one of them (`version` logged out builds six plugins, never `http`).
|
||||
|
||||
State stays with its owner. A plugin's `build()` closes over its own state (an in-memory cache, a loaded config document); nothing outside reaches into it — code that needs it calls `ctx.get(thatPlugin)` again and gets the same cached instance.
|
||||
|
||||
`ctx.defer(fn)` queues a cleanup to run after the command finishes (for example, aborting a streaming call's `AbortController` on `SIGINT`), instead of scattering `try`/`finally` through command bodies.
|
||||
|
||||
There is no event bus and no listener registration. Plugins that need to react to something call `ctx.get` on the plugin that has it; there is no subscribe/publish path to keep in sync.
|
||||
|
||||
The one `commands` plugin (`src/plugins/commands/index.ts`) needs `BASE_PLUGINS` (`global-flags env config session token catalog http ops io`) and its `build()` returns `{ run }`. `runPipeline` takes the argv left over by `global-flags`, resolves the typed `Command` from the generated tree, parses those tokens against that command's Zod `input` schema, and calls `run(input, ctx)` — the pipeline never branches on which command it is.
|
||||
|
||||
A plugin's folder is private. Code outside it imports only the plugin's `index.ts` (`@/plugins/<name>`) and reaches the plugin's behaviour through `ctx.get(plugin)`; a helper two plugins share is exposed on a service, and a helper only commands use lives outside `src/plugins/`. The two exceptions are contracts rather than services: the command framework in `plugins/commands/` (`Command`, `Outcome`, `runSignal`, the tree types) and the argv parser in `plugins/argv/parse.ts`.
|
||||
|
||||
Global flags are one Zod object in `src/plugins/global-flags/index.ts`. The plugin pulls the flags it knows out of the `argv` input, exposes their values as `flags` and the remaining tokens as `rest`; any plugin or command reads a global flag through `ctx.get(globalFlags)`, never by scanning argv. Which flag wins over an env var or a config key is decided in the plugin that owns the setting, not here.
|
||||
|
||||
Help is the row: `commandRow(Ctor, path)` (`src/plugins/commands/describe.ts`) is the one function that renders a command's `--help` output and its entry in `help --full` — there is no separate help-text template. Catalog operations are commands in the same tree, so `entriesOf(tree)` in `plugins/commands/help.ts` feeds the map, namespace listings and search (`src/discovery/`) from one list; words no command claims go to `discover` in the pipeline. `plugins/commands/text.ts` renders the same values for a terminal through a section table.
|
||||
|
||||
Tests substitute one plugin's service directly: `new Context([[plugin, mockService]])` seeds the cache so `ctx.get(plugin)` returns the mock without building the real one.
|
||||
|
||||
---
|
||||
|
||||
## New command scaffold
|
||||
|
||||
Recipe for adding command leaf. Follow order.
|
||||
|
||||
**1. Create folder**
|
||||
|
||||
```
|
||||
src/commands/<topic>/<verb>/
|
||||
```
|
||||
|
||||
Examples: `get/app/`, `auth/devices/revoke/`, `describe/app/`.
|
||||
|
||||
**2. Mandatory file**
|
||||
|
||||
| File | Responsibility |
|
||||
| ---------- | ------------------------------------------------------------------------------------ |
|
||||
| `index.ts` | `DifyCommand` subclass. Owns flag/arg parsing, framework output, and command wiring. |
|
||||
|
||||
**3. Optional files — add as needed**
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------ | ------------------------------------------------------------------ |
|
||||
| `run.ts` | Typed behavior owner when logic merits independent tests or reuse |
|
||||
| `handlers.ts` | Output types implementing `FormattedPrintable` or `TablePrintable` |
|
||||
| `payload-shape.ts` | Response type narrowing/transformation |
|
||||
| `run.test.ts` | Behavior tests against `run.ts` |
|
||||
| `guide.ts` | Agent onboarding text — exports `agentGuide` string |
|
||||
|
||||
**4. Checklist**
|
||||
|
||||
- [ ] `index.ts` extends `DifyCommand`
|
||||
- [ ] Authed command calls `this.authedCtx()`; non-authed skips
|
||||
- [ ] Let the command boundary handle `BaseError`; catch only when the command owns recovery
|
||||
- [ ] Keep framework parsing and output construction in `index.ts`
|
||||
- [ ] When present, `run.ts` returns typed behavior data or owns explicit streaming/interactive I/O and does not import `src/framework/`
|
||||
- [ ] HTTP client via factory dep, not direct
|
||||
- [ ] Add focused behavior tests when the command changes an observable contract
|
||||
- [ ] `pnpm tree:gen` run after adding command (updates `src/commands/tree.generated.ts`)
|
||||
- [ ] README command table updated by hand
|
||||
|
||||
---
|
||||
|
||||
## DifyCommand base class
|
||||
|
||||
All commands extend `DifyCommand`, not `Command`.
|
||||
|
||||
```typescript
|
||||
export default class MyCommand extends DifyCommand {
|
||||
async run(argv: string[]) {
|
||||
const { args, flags } = this.parse(MyCommand, argv)
|
||||
|
||||
const ctx = await this.authedCtx({ retryFlag: undefined, format: flags.output })
|
||||
const result = await runMyThing(
|
||||
{ id: args.id },
|
||||
{ active: ctx.active, http: ctx.http, io: ctx.io },
|
||||
)
|
||||
return formatted({ format: flags.output, data: result.data })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`authedCtx(opts)`** — wraps `buildAuthedContext` and returns the authenticated registry, account, HTTP, I/O, and optional cache dependencies. Pass the selected output format so authentication failures use the same serialization contract. Required for commands that need a bearer token.
|
||||
|
||||
The framework runner in `src/framework/run.ts` catches command errors, normalizes unknown failures, and serializes `BaseError` according to the selected output format. Catch inside a command only when that command owns a real recovery path.
|
||||
1. Create the folder: `src/commands/<topic>/<verb>/` (or `src/commands/<verb>/` for a top-level command like `login`).
|
||||
2. Write `index.ts`: a default-exported class extending `Command<typeof INPUT>` with static `summary`, `effect` (`'read' | 'write' | 'destructive'`, default `'read'`), `input` (a Zod object), optional `positional` and `examples`, and an instance `run(input, ctx)` that returns the value to print — or an `Outcome` when the command needs a non-zero exit or has already written its own output.
|
||||
3. Run `pnpm tree:gen` to add the command to `src/commands/tree.generated.ts`.
|
||||
4. Run `pnpm tree:check` (also gated in CI) to confirm the generated tree matches the command folders.
|
||||
|
||||
---
|
||||
|
||||
@@ -106,127 +78,48 @@ throw new BaseError({
|
||||
})
|
||||
```
|
||||
|
||||
`ErrorCode` is the exhaustive error-code object; do not scatter raw code strings. `exitFor(code)` maps it to a process exit code, and the framework runner calls `formatErrorForCli` so JSON/YAML consumers receive machine-readable errors.
|
||||
`ErrorCode` is the exhaustive error-code object; do not scatter raw code strings. `exitFor(code)` maps it to a process exit code, and `BaseError.toEnvelope()` serializes it as the one JSON error line on stderr.
|
||||
|
||||
| Exit | Meaning |
|
||||
| ---- | ----------------------------------------- |
|
||||
| 0 | Success |
|
||||
| 1 | Generic error |
|
||||
| 2 | Usage error (bad flag, missing arg) |
|
||||
| 4 | Auth error (not logged in, token expired) |
|
||||
| 6 | Version/compat error |
|
||||
| 7 | Rate limited |
|
||||
| Exit | Meaning |
|
||||
| ---- | ------------------------------------------------ |
|
||||
| 0 | Success |
|
||||
| 1 | Generic error |
|
||||
| 2 | Usage error (bad flag, missing or invalid input) |
|
||||
| 4 | Auth error (not logged in, forbidden) |
|
||||
| 6 | Catalog error (unavailable, or unknown op id) |
|
||||
| 7 | Rate limited |
|
||||
|
||||
New error code: add to `ErrorCode` + map to `ExitCode` in `codes.ts`. Never scatter exit codes inline.
|
||||
New error code: add to `ErrorCode` and map it to an `ExitCode` in `codes.ts`. Never scatter exit codes inline.
|
||||
|
||||
---
|
||||
|
||||
## IOStreams
|
||||
## Output
|
||||
|
||||
I/O context passed through every layer. Carries stdout, stderr, stdin, TTY flags, `outputFormat`.
|
||||
Everything the CLI prints goes through the `io` plugin's service, never through a stream directly. Three writes cover every case; a new output format or a `--quiet` flag is a change inside this one plugin.
|
||||
|
||||
```typescript
|
||||
export type IOStreams = {
|
||||
out: NodeJS.WritableStream
|
||||
err: NodeJS.WritableStream
|
||||
in: NodeJS.ReadableStream
|
||||
isOutTTY: boolean
|
||||
isErrTTY: boolean
|
||||
outputFormat: string // 'json' | 'yaml' | 'name' | 'wide' | ''
|
||||
}
|
||||
export type IOService = Readonly<{
|
||||
document: (value: Printable | View) => Promise<void> // stdout: results, help
|
||||
emit: (value: Printable | View, channel: ChannelValue) => Promise<void> // either stream
|
||||
notice: (text: string) => void // one text line on stderr: warnings, progress
|
||||
raw: (chunk: string | Buffer) => Promise<void> // bytes as given: a text-kind body
|
||||
streams: IOStreams // stdin and the TTY flags
|
||||
}>
|
||||
```
|
||||
|
||||
| Factory | When |
|
||||
| --------------------- | --------------------------------- |
|
||||
| `realStreams(format)` | Production — wraps `process.std*` |
|
||||
| `bufferStreams()` | Tests — captures output in memory |
|
||||
| `nullStreams()` | When IO irrelevant |
|
||||
`Printable` (a JSON primitive, an array, or a plain object) is what a command may return and what `document` accepts. A `View` (`sys/io/view.ts`) is a `Printable` plus its text form; help and errors pass one, and the stream's mode picks which is written. Results and streams are JSON in every mode. Streamed events go through `raw`, one per line. stdout writes use the callback form so a closed pipe reaches `printEnvelope` as a rejection and the run ends at exit 0. `printEnvelope` emits the envelope through the same service on the err channel.
|
||||
|
||||
`outputFormat` set at construction. Do not mutate. Do not pass `format` as separate arg downstream — put in `IOStreams`, pass struct.
|
||||
|
||||
---
|
||||
|
||||
## Spinner
|
||||
|
||||
`runWithSpinner` wraps async call with animated spinner on stderr. Auto-disables for structured output — no manual `enabled:` flag needed.
|
||||
|
||||
```typescript
|
||||
const result = await runWithSpinner({ io, label: 'Fetching apps' }, () => client.list(params))
|
||||
```
|
||||
|
||||
`STRUCTURED_FORMATS = new Set(['json', 'yaml', 'name'])` drives disable check. New structured format = add to this set only — no other callsites change.
|
||||
|
||||
Only override `enabled` for intentional suppression (e.g., tests using `bufferStreams` already suppress via `isErrTTY: false`).
|
||||
|
||||
---
|
||||
|
||||
## Output protocol
|
||||
|
||||
Output rendering separated from data fetching via protocol objects.
|
||||
|
||||
- Data classes implement `TablePrintable` or `FormattedPrintable` from `src/framework/output`.
|
||||
- Streaming commands implement `StreamPrinter` from `src/framework/stream`.
|
||||
- `index.ts` wraps the result with `table({format, data})` or `formatted({format, data})` and returns it; `src/framework/run.ts` calls `stringifyOutput()`.
|
||||
- Commands that write incrementally (streaming) write directly from the strategy via `deps.io.out.write(stringifyOutput(...))`.
|
||||
|
||||
```typescript
|
||||
// handlers.ts — implement the protocol on the data object
|
||||
export class MyListOutput implements TablePrintable {
|
||||
tableColumns() {
|
||||
return COLUMNS
|
||||
}
|
||||
tableRows() {
|
||||
return this.rows.map((r) => r.tableRow())
|
||||
}
|
||||
json() {
|
||||
return { items: this.rows.map((r) => r.json()) }
|
||||
}
|
||||
}
|
||||
|
||||
// index.ts — wrap and return
|
||||
return table({ format: flags.output, data: result })
|
||||
```
|
||||
|
||||
New output format: add to `OutputFormat` in `framework/output.ts` and handle in `stringifyOutput`. Never add `if (format === 'json')` branches in `run.ts` or handlers.
|
||||
|
||||
---
|
||||
|
||||
## Strategy pattern (mode dispatch)
|
||||
|
||||
Singleton strategies + picker function. No switch ladders on discriminator.
|
||||
|
||||
```typescript
|
||||
export type RunStrategy = {
|
||||
execute: (ctx: RunContext) => Promise<void>
|
||||
}
|
||||
|
||||
const streamingText = new StreamingTextStrategy()
|
||||
const streamingStructured = new StreamingStructuredStrategy()
|
||||
|
||||
export function pickStrategy(isText: boolean, livePrint: boolean): RunStrategy {
|
||||
return isText && livePrint ? streamingText : streamingStructured
|
||||
}
|
||||
```
|
||||
|
||||
New mode = new class + one line in picker. Singletons avoid per-call allocation.
|
||||
|
||||
---
|
||||
|
||||
## HTTP clients
|
||||
|
||||
Keep resource clients under `src/api/`. They receive the shared `HttpClient` and call generated oRPC operations through `createOpenApiClient(...)` when the OpenAPI contract covers the endpoint. Reuse generated request and response types instead of duplicating wire shapes.
|
||||
|
||||
Pass `HttpClient` into behavior owners. Add a client or factory dependency only when it owns a real substitution or lifecycle boundary; behavior tests normally exercise the real client stack against `test/fixtures/dify-mock/`. Keep client construction out of `index.ts` so the command remains a framework and output boundary.
|
||||
`IOStreams` is the raw layer underneath: `realStreams()` wraps `process.std*` in production, `bufferStreams()` captures output in tests, and `ioService(streams)` wraps either.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Keep tests beside the owner as `*.test.ts`. When a command has a behavior module, test that public function directly for domain and protocol behavior. Test the command class or framework boundary when argument parsing, flags, help, output construction, or command wiring is the observable contract. Establish a failing case first when practical for behavior changes and bug fixes.
|
||||
Keep tests beside the owner as `*.test.ts`. When a command delegates to a pure module (`call/`, `protocol/`), test that module directly. Test the command class or the `commands` pipeline when argument parsing, help, or command wiring is the observable contract. Establish a failing case first when practical for behavior changes and bug fixes.
|
||||
|
||||
### dify-mock fixture server
|
||||
|
||||
`test/fixtures/dify-mock/server.ts` mirrors `/openapi/v1/*`. Each test starts isolated instance:
|
||||
`test/fixtures/dify-mock/server.ts` mirrors `/openapi/v1/*` and the catalog endpoint. Each test starts an isolated instance:
|
||||
|
||||
```typescript
|
||||
import { startMock } from '../../../test/fixtures/dify-mock/server.js'
|
||||
@@ -236,23 +129,16 @@ const mock = await startMock({ scenario: 'happy' })
|
||||
await mock.stop()
|
||||
```
|
||||
|
||||
| Scenario | Effect |
|
||||
| ----------------- | ----------------------------------------------------------------------------- |
|
||||
| `happy` (default) | Standard fixtures: 4 apps across 2 workspaces, 2 workspaces, 1 active session |
|
||||
| `sso` | `/workspaces` returns empty (external-SSO bearer model) |
|
||||
| `expired` | All authenticated routes return 401 `auth_expired` |
|
||||
| `pagination` | `/apps` honors `?page=` + `?limit=`, total > one page |
|
||||
| `slow` | Adds `Retry-After: 1` to GETs to test ky retry behavior |
|
||||
`happy` (default) is the standard fixture set: 4 apps across 2 workspaces, 2 workspaces, 1 active session. See `Scenario` in `scenarios.ts` for the full union — auth failures, rate limiting, catalog-fingerprint mismatches, HITL pauses, pending imports, and more.
|
||||
|
||||
New scenario: extend `Scenario` union in `scenarios.ts`, branch in relevant handler. No per-test mocks — one fixture surface keeps tests aligned with real API.
|
||||
New scenario: extend the `Scenario` union in `scenarios.ts`, branch in the relevant handler. No per-test mocks — one fixture surface keeps tests aligned with the real API.
|
||||
|
||||
### Assertions
|
||||
|
||||
Inline string/regex/JSON checks — no golden files.
|
||||
|
||||
```typescript
|
||||
expect(out).toMatch(/^ID\s+NAME\s+ROLE/)
|
||||
expect(JSON.parse(out).workspaces).toHaveLength(2)
|
||||
expect(JSON.parse(out).data).toHaveLength(2)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -270,6 +156,7 @@ expect(JSON.parse(out).workspaces).toHaveLength(2)
|
||||
| `pnpm tree:gen` | Regenerate `src/commands/tree.generated.ts` |
|
||||
| `pnpm tree:check` | Verify the generated tree matches the commands |
|
||||
| `pnpm build:bin` | Cross-compile standalone binaries via Bun (CI) |
|
||||
| `pnpm build:bin:local` | Same, pinned to the `dev` channel |
|
||||
|
||||
**`pnpm tree:gen` rule:** run after adding, removing, or renaming any command. The generated `tree.generated.ts` is the runtime command registry; a stale tree makes commands invisible at runtime. It also runs through `prebuild`, `predev`, and `pretest`.
|
||||
|
||||
@@ -296,7 +183,6 @@ Run `vp check --fix cli` from the repository root for scoped formatting, lint, a
|
||||
## PR conventions
|
||||
|
||||
- One feature, one PR. Bundle test + impl + doc update.
|
||||
- Branch off `feat/cli`. Never target `main`.
|
||||
- Commit style: `<type>(cli): <imperative subject>`. Types: `feat`, `fix`, `refactor`, `docs`, `chore`. Body explains why if non-obvious.
|
||||
- Plan/spec/superpowers files do not ship in CLI commits.
|
||||
- Verify diff before committing — `.local.json` and `.vitest-cache/` gitignored but check anyway.
|
||||
@@ -305,15 +191,11 @@ Run `vp check --fix cli` from the repository root for scoped formatting, lint, a
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
| Pattern | Do instead |
|
||||
| -------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| `if (format === 'json') { ... }` in `run.ts` | Printer handler per format |
|
||||
| `try { ... } catch (e) { if (isBaseError(e)) ... }` in every command | Throw `BaseError`; `src/framework/run.ts` normalizes and formats it |
|
||||
| Raw string error codes `'not_logged_in'` | `ErrorCode.NotLoggedIn` |
|
||||
| `enabled: !isHuman` in `runWithSpinner` | Set `outputFormat` on `IOStreams`; spinner auto-detects |
|
||||
| Long positional arg lists | Options struct |
|
||||
| `Record<string, Strategy>` dispatch map | Named singletons + picker function |
|
||||
| `src/framework/` import in `run.ts`, `api/`, or `auth/` | Framework imports belong in `index.ts`, `handlers.ts`, and strategies only |
|
||||
| `buildAuthedContext(this, opts)` in command body | `this.authedCtx(opts)` |
|
||||
| `console.log` in `src/` | Return `CommandOutput` from the command or use owned I/O for streaming |
|
||||
| New dependency without approval | Check first |
|
||||
| Pattern | Do instead |
|
||||
| ----------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| Raw string error codes `'not_logged_in'` | `ErrorCode.NotLoggedIn` |
|
||||
| Long positional arg lists | A Zod `input` object with named fields |
|
||||
| `if (kind === 'sse') { ... } else if (kind === 'list') ...` | A `Record<Kind, Renderer>` dispatch table (see `call/render/index.ts`) |
|
||||
| A command reaching into another plugin's closed-over state | `ctx.get(thatPlugin)` — the cached service, not its internals |
|
||||
| `console.log` in `src/` | Return the value from `run()`; the pipeline prints it |
|
||||
| New dependency without approval | Check first |
|
||||
|
||||
100
cli/README.md
100
cli/README.md
@@ -1,6 +1,6 @@
|
||||
# difyctl
|
||||
|
||||
CLI client for [Dify] platform. Browser device-flow signin, list/inspect apps, run with structured input, parse output as JSON, YAML, or human text.
|
||||
CLI client for the [Dify] platform. Browser device-flow sign-in, then a server-published catalog of operations you discover and call at run time, each one a command. Results are JSON; help and errors are text on a terminal, JSON in a pipe.
|
||||
|
||||
## Install (edge, internal)
|
||||
|
||||
@@ -33,47 +33,50 @@ Re-run to upgrade. For tagged `rc`/`stable` builds, use the GitHub installer (`i
|
||||
## Quickstart
|
||||
|
||||
```sh
|
||||
difyctl auth login # opens browser; paste the device code shown
|
||||
difyctl get app # list apps in default workspace
|
||||
difyctl describe app <app-id> # inspect parameters
|
||||
difyctl run app <app-id> "hello" # run, blocking
|
||||
difyctl run app <app-id> "hello" -o json | jq .answer # JSON output
|
||||
difyctl run app <app-id> --input name=world --input topic=cats # workflow inputs
|
||||
difyctl login --server https://dify.example.com # opens the browser; approve the device code shown
|
||||
difyctl help # the map: every command and operation namespace
|
||||
difyctl help chatbot # search commands and operations by plain words
|
||||
difyctl get workspace # workspaces visible to this account
|
||||
difyctl get console_app --limit 5 # list apps
|
||||
difyctl run console_app workflow --app-id <id> --inputs @vars.json --stream
|
||||
```
|
||||
|
||||
Background docs: `difyctl help account`, `difyctl help external`, `difyctl help environment`, `difyctl help agent`.
|
||||
difyctl has no built-in business commands. Every operation the server publishes is a command: `run.console_app.workflow` is `difyctl run console_app workflow`, and its input fields are flags (`--app-id`, `--inputs`). A field named like one of difyctl's own flags (`input`, `stream`, `only`, `output`, `verbose`, `json`, `help`) is passed through `--input`. The table lists the local commands; `difyctl help` lists the server's too. See [Agent skill] for the discovery flow.
|
||||
|
||||
## Commands
|
||||
|
||||
Run `difyctl --help` for the full list of commands.
|
||||
Run `difyctl <cmd> --help` for per-command reference.
|
||||
| Command | Purpose |
|
||||
| -------------------------- | --------------------------------------------------------------- |
|
||||
| `login` | Log in via the OAuth device flow |
|
||||
| `logout` | Log out of the current session |
|
||||
| `version` | Print the client and (if logged in) server versions |
|
||||
| `use workspace <id>` | Pin the local session to a workspace |
|
||||
| `get config [key]` | Print the local config, or one key |
|
||||
| `set config <key> <value>` | Set a local config value |
|
||||
| `unset config <key>` | Remove a local config value, restoring its default |
|
||||
| `refresh cache` | Refetch the server catalog and replace the local cache |
|
||||
| `delete cache` | Delete the local server catalog cache |
|
||||
| `get skills` | List the skills in the collection |
|
||||
| `install skills <dir>` | Write skills from the collection into a skills root (`--skill`) |
|
||||
|
||||
Use `difyctl help -o json --compact` to read the complete command map, then inspect the selected command with `difyctl help <path> -o json` before running it — that per-command descriptor is the only surface that carries args and flags.
|
||||
`login` blocks until the browser approval arrives. An agent runs it in the background, relays the `open <url>` and `code <code>` lines from its stderr to the user, and does not cancel the job. In a sandbox it adds `--no-keyring` and sets `DIFY_CONFIG_DIR` to persistent storage on every call, so the login survives the next session.
|
||||
|
||||
For agents (and scripting), start with `difyctl help agent` — the cross-command operating guide (output, discovery, auth, exit codes, errors, HITL, retry). Every help surface is also machine-readable: `difyctl help -o json` dumps the whole command tree plus the global contract (exit codes, output formats, error envelope, HITL protocol), and `difyctl help <path> -o json` returns one command's descriptor.
|
||||
`--verbose` is global: it may appear on any command, and it keeps the raw server
|
||||
response in the error envelope instead of dropping it.
|
||||
|
||||
## Agent skill
|
||||
`difyctl help` covers local commands and server operations as one tree. Bare, it prints the map. `difyctl help <namespace>` lists one namespace (the bare namespace, `difyctl get`, does the same), `difyctl help <words>` searches by plain words, and `difyctl help <id>` prints one descriptor, the same as `<command> --help`; the id may be dotted or spaced. `--all` adds hidden entries, `--full` on the bare `help` prints every descriptor. Help and errors are text on a terminal and JSON in a pipe; `--json` or `DIFY_OUTPUT=json` forces JSON. Results and streams are always JSON.
|
||||
|
||||
`difyctl skills install` installs a single, pure-delegation `SKILL.md` into your local agents so they auto-load it. The skill does not freeze the command set — it directs the agent through the live compact sitemap and per-command help, so it never drifts from your binary. It is embedded in the binary (version-stamped) rather than checked in.
|
||||
## Agent skills
|
||||
|
||||
- `difyctl skills install` — dry-run: detect installed agents (Claude Code, Codex, opencode, Cursor, pi) and print where the skill would land. Writes nothing.
|
||||
- `difyctl skills install --yes` — write to every detected agent, printing each path. `--agent claude-code[,cursor]` restricts to a subset; `<dir>` forces one explicit directory (handy when your agent isn't detected).
|
||||
- `difyctl skills install --stdout` — print the `SKILL.md` to stdout (for piping or self-install); writes nothing.
|
||||
difyctl installs agent skills from [`skills/`] at the repo root. It downloads them from GitHub at install time, from the commit the binary was built from; the binary holds no copy and needs no git. The basic skill, `difyctl`, teaches the discovery flow — `help` to see the map or search, `help <id>` to inspect one operation, then the operation's own command to run it — so it stays correct as the server's catalog grows. Scenario skills add guidance for one kind of task and open by reading the basic one.
|
||||
|
||||
Detection is by config-directory existence (`~/.claude`, `~/.codex`, `~/.config/opencode`, `~/.cursor`, `~/.pi`). If a copy ever looks stale, run `difyctl version` and re-run `difyctl skills install`.
|
||||
- `difyctl get skills` — the collection: each skill's name and description. Writes nothing.
|
||||
- `difyctl install skills <dir>` — write every skill into `<dir>`, the agent's skills root: the folder that holds one subfolder per skill, for example `~/.claude/skills` for Claude Code or `~/.codex/skills` for Codex. Each skill's folder is replaced whole. `--skill <name>` (repeatable) writes only those skills; a scenario skill brings `difyctl` along.
|
||||
- `--from` on both reads skills from elsewhere: any GitHub folder in the standard layout (`https://github.com/<owner>/<repo>/tree/<ref>/<folder>`), or a local folder when GitHub is blocked. Set `GITHUB_TOKEN` if GitHub's rate limit (60 requests an hour per IP) gets in the way.
|
||||
|
||||
## Output formats
|
||||
difyctl does not detect agents. You name the root, and re-running the same command refreshes the copies.
|
||||
|
||||
| Flag | Behavior |
|
||||
| --------- | ------------------------------------------------------ |
|
||||
| (none) | Human table, columns auto-sized to terminal. |
|
||||
| `-o wide` | Same as table, no column truncation. |
|
||||
| `-o json` | Pretty-printed JSON, machine-parseable, stable shape. |
|
||||
| `-o yaml` | YAML mirror of `-o json`. |
|
||||
| `-o name` | IDs only, newline-separated — pipes into `xargs`. |
|
||||
| `-o text` | kubectl-describe style human text (`describe`, `run`). |
|
||||
|
||||
Errors emit JSON envelope to stderr in `-o json` mode; else human message. Exit codes deterministic.
|
||||
The same files install through the Vercel skills installer: `npx skills add langgenius/dify --skill difyctl -g`. Always pass `--skill`; without it the installer also offers the contributor skills under `.agents/skills/`.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -83,30 +86,39 @@ Errors emit JSON envelope to stderr in `-o json` mode; else human message. Exit
|
||||
| macOS | `$HOME/.config/difyctl/` |
|
||||
| Windows | `%APPDATA%\difyctl\` |
|
||||
|
||||
Override with `DIFY_CONFIG_DIR=/some/path`. Files written `0600`, directory `0700`. Tokens use OS keychain by default, fall back to sealed file on hosts without one.
|
||||
`get config [key]` prints the whole local config or one key; `set config <key> <value>` and `unset config <key>` change it. The only config key today is `http.timeout` (request timeout in milliseconds).
|
||||
|
||||
For every env var `difyctl` reads, run `difyctl env list` (machine-readable) or `difyctl help environment` (narrative).
|
||||
Tokens use the OS keychain by default, falling back to `tokens.yml` on hosts without one; `login --no-keyring` chooses the file outright. Config and cache files are written `0600`, their directory `0700`.
|
||||
|
||||
| Env var | Effect |
|
||||
| ------------------- | ----------------------------------------------------------- |
|
||||
| `DIFY_SERVER` | Server base URL; with `DIFY_TOKEN`, skips interactive login |
|
||||
| `DIFY_TOKEN` | Bearer token; requires `DIFY_SERVER` |
|
||||
| `DIFY_WORKSPACE_ID` | Overrides the locally pinned workspace |
|
||||
| `DIFY_OUTPUT` | `json` or `text`; overrides the terminal/pipe detection |
|
||||
| `DIFY_CONFIG_DIR` | Overrides the config directory |
|
||||
| `DIFY_CACHE_DIR` | Overrides the catalog cache directory |
|
||||
|
||||
## Streaming
|
||||
|
||||
`run app` uses blocking transport by default. For long-running apps (likely exceed ~30s) pass `--stream`:
|
||||
Pass `--stream` on a streaming operation to print every event as one JSON line, instead of folding the whole run into a single result object:
|
||||
|
||||
```sh
|
||||
difyctl run app app-1 "tell me about cats" --stream
|
||||
difyctl run console_app chat --app-id … --query hello --inputs '{}' --stream
|
||||
```
|
||||
|
||||
Agent apps (`mode === 'agent-chat'` or `is_agent` flag set) stream regardless — Dify backend rejects blocking requests for agent mode. Combining `--stream` with `-o json` or `-o yaml` aggregates SSE events into same envelope shape as blocking response, so structured output identical regardless of transport.
|
||||
`--only <event>` filters the streamed events to the named event types; repeat it to keep more than one. Without `--stream`, a streaming operation's events fold into `{status, text, outputs?, message_id?, conversation_id?, error?, hints}`.
|
||||
|
||||
## HTTP retry
|
||||
## Exit codes
|
||||
|
||||
Idempotent requests (`GET`, `PUT`, `DELETE`) retry on transient network/DNS failures with exponential backoff. Default count: **3**. `POST` and `PATCH` never retry — side effects possible.
|
||||
|
||||
| Knob | Effect |
|
||||
| ------------------------ | ---------------------------------------------- |
|
||||
| `--http-retry <n>` | Per-invocation override. `0` disables retries. |
|
||||
| `DIFYCTL_HTTP_RETRY=<n>` | Process-level default. |
|
||||
|
||||
Resolution: flag → env → 3.
|
||||
| Code | Meaning |
|
||||
| ---- | -------------------------------------------- |
|
||||
| 0 | Success |
|
||||
| 1 | Error |
|
||||
| 2 | Usage or input error |
|
||||
| 4 | Not logged in, or forbidden |
|
||||
| 6 | Catalog unavailable, or unknown operation id |
|
||||
| 7 | Rate limited |
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -116,5 +128,7 @@ See [`ARD.md`] for architecture patterns, scaffolding recipe, dev workflow.
|
||||
|
||||
Apache-2.0.
|
||||
|
||||
[Agent skill]: #agent-skills
|
||||
[Dify]: https://dify.ai
|
||||
[`ARD.md`]: ARD.md
|
||||
[`skills/`]: ../skills
|
||||
|
||||
@@ -9,10 +9,6 @@ globalThis.__DIFYCTL_VERSION__ = info.version
|
||||
globalThis.__DIFYCTL_COMMIT__ = info.commit
|
||||
globalThis.__DIFYCTL_BUILD_DATE__ = info.buildDate
|
||||
globalThis.__DIFYCTL_CHANNEL__ = info.channel
|
||||
globalThis.__DIFYCTL_MIN_DIFY__ = info.minDify
|
||||
globalThis.__DIFYCTL_MAX_DIFY__ = info.maxDify
|
||||
|
||||
const { commandTree } = await import('../src/commands/tree.ts')
|
||||
const { run } = await import('../src/framework/run.ts')
|
||||
|
||||
await run(commandTree, process.argv.slice(2))
|
||||
const { main } = await import('../src/main.ts')
|
||||
process.exitCode = await main(process.argv.slice(2))
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
#!/usr/bin/env node
|
||||
// Production entry compiled by `bun build --compile` (see scripts/release-build.sh).
|
||||
// Imports from src/ so the release pipeline doesn't need `pnpm build` (dist/).
|
||||
import { commandTree } from '../src/commands/tree.js'
|
||||
import { run } from '../src/framework/run.js'
|
||||
import { main } from '../src/main.js'
|
||||
import { cancel } from '../src/plugins/commands/cancel.js'
|
||||
|
||||
const EPIPE = 'EPIPE'
|
||||
|
||||
// SIGINT unwinds the run through the shared controller; the exit code is whatever
|
||||
// the command settles on, since 130 is outside the frozen taxonomy.
|
||||
process.once('SIGINT', cancel)
|
||||
|
||||
// A reader that closed stdout (`| head`) still emits on the stream after the write
|
||||
// callback has already reported it. Swallowing only EPIPE keeps that from turning a
|
||||
// finished run into an unhandled 'error' event.
|
||||
process.stdout.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code !== EPIPE) throw err
|
||||
})
|
||||
|
||||
// Wrapped instead of top-level await — `bun build --bytecode` doesn't support TLA.
|
||||
void (async () => {
|
||||
await run(commandTree, process.argv.slice(2))
|
||||
process.exitCode = await main(process.argv.slice(2))
|
||||
})()
|
||||
|
||||
@@ -31,24 +31,19 @@
|
||||
"pretest": "pnpm tree:gen",
|
||||
"ci": "pnpm tree:check && pnpm test:coverage && pnpm build",
|
||||
"clean": "rm -rf dist node_modules/.cache",
|
||||
"smoke": "bun scripts/run-smoke.ts",
|
||||
"version:info": "bun scripts/print-buildinfo.ts",
|
||||
"build:bin": "scripts/release-build.sh",
|
||||
"build:bin:local": "DIFYCTL_CHANNEL=dev scripts/release-build.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dify/contracts": "workspace:*",
|
||||
"@napi-rs/keyring": "catalog:",
|
||||
"@orpc/client": "catalog:",
|
||||
"@orpc/contract": "catalog:",
|
||||
"@orpc/openapi-client": "catalog:",
|
||||
"cli-table3": "catalog:",
|
||||
"ajv": "catalog:",
|
||||
"eventsource-parser": "catalog:",
|
||||
"js-yaml": "catalog:",
|
||||
"lockfile": "catalog:",
|
||||
"open": "catalog:",
|
||||
"ora": "catalog:",
|
||||
"picocolors": "catalog:",
|
||||
"std-semver": "catalog:",
|
||||
"undici": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
@@ -77,10 +72,6 @@
|
||||
},
|
||||
"difyctl": {
|
||||
"channel": "stable",
|
||||
"compat": {
|
||||
"minDify": "1.16.0",
|
||||
"maxDify": "1.17.1"
|
||||
},
|
||||
"release": {
|
||||
"tagPrefix": "difyctl-v",
|
||||
"binName": "difyctl",
|
||||
|
||||
@@ -111,7 +111,7 @@ describe('formatModule', () => {
|
||||
`// @generated by scripts/generate-command-tree.ts — DO NOT EDIT.
|
||||
// Regenerate via \`pnpm tree:gen\`. Drift gated by \`pnpm tree:check\` in CI.
|
||||
|
||||
import type { CommandTree } from '@/framework/registry'
|
||||
import type { CommandTree } from '@/plugins/commands/registry'
|
||||
import AuthDevicesList from '@/commands/auth/devices/list/index'
|
||||
import AuthLogin from '@/commands/auth/login/index'
|
||||
import Version from '@/commands/version/index'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { readdir, readFile, rename, writeFile } from 'node:fs/promises'
|
||||
import { join, relative, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { isExcludedCommandPath } from '../src/framework/command-fs.js'
|
||||
import { isExcludedCommandPath } from '../src/plugins/commands/command-fs.js'
|
||||
|
||||
const RESERVED_JS_KEYWORDS = new Set([
|
||||
'break',
|
||||
@@ -106,7 +106,7 @@ function compareStrings(a: string, b: string): number {
|
||||
|
||||
function emitImports(entries: readonly CommandEntry[]): string {
|
||||
const sorted = [...entries].sort((a, b) => compareStrings(a.importPath, b.importPath))
|
||||
const lines = [`import type { CommandTree } from '@/framework/registry'`]
|
||||
const lines = [`import type { CommandTree } from '@/plugins/commands/registry'`]
|
||||
for (const e of sorted) lines.push(`import ${e.identifier} from '${e.importPath}'`)
|
||||
return lines.join('\n')
|
||||
}
|
||||
@@ -153,13 +153,17 @@ function emitEntry(key: string, node: TreeNode, indent: string): string {
|
||||
export function formatModule(entries: readonly CommandEntry[], tree: TreeNode): string {
|
||||
const importsBlock = emitImports(entries)
|
||||
const topKeys = [...tree.subcommands.keys()].sort()
|
||||
const literalParts = ['export const commandTree: CommandTree = {']
|
||||
for (const key of topKeys) {
|
||||
const child = tree.subcommands.get(key)!
|
||||
literalParts.push(emitEntry(key, child, ' '))
|
||||
}
|
||||
literalParts.push('}')
|
||||
return `${HEADER}\n${importsBlock}\n\n${literalParts.join('\n')}\n`
|
||||
// Empty object on one line, matching the repo formatter's own convention —
|
||||
// otherwise a fresh `tree:gen` and `tree:check` disagree right after a commit.
|
||||
const literal =
|
||||
topKeys.length === 0
|
||||
? 'export const commandTree: CommandTree = {}'
|
||||
: [
|
||||
'export const commandTree: CommandTree = {',
|
||||
...topKeys.map((key) => emitEntry(key, tree.subcommands.get(key)!, ' ')),
|
||||
'}',
|
||||
].join('\n')
|
||||
return `${HEADER}\n${importsBlock}\n\n${literal}\n`
|
||||
}
|
||||
|
||||
async function walk(dir: string): Promise<string[]> {
|
||||
@@ -215,7 +219,8 @@ export async function discoverCommands(commandsDir: string): Promise<CommandEntr
|
||||
|
||||
entries.sort((a, b) => compareStrings(a.importPath, b.importPath))
|
||||
|
||||
if (entries.length === 0) throw new Error(`no commands found under ${commandsDir}`)
|
||||
if (entries.length === 0)
|
||||
throw new Error(`no commands found under ${commandsDir} — expected <verb>/index.ts folders`)
|
||||
|
||||
assertUniqueIdentifiers(entries)
|
||||
return entries
|
||||
|
||||
@@ -12,7 +12,6 @@ const MANIFEST = [
|
||||
' "version": "0.1.0-edge.2fd7b82",',
|
||||
' "commit": "abc1234",',
|
||||
' "buildDate": "2026-06-14T12:00:00Z",',
|
||||
' "compat": {"minDify":"1.14.0","maxDify":"1.15.0"},',
|
||||
' "baseUrl": "https://pub.example.r2.dev/difyctl/edge/0.1.0-edge.2fd7b82",',
|
||||
' "targets": {',
|
||||
' "linux-x64": { "asset": "difyctl-v0.1.0-edge.2fd7b82-linux-x64", "sha256": "deadbeef" },',
|
||||
|
||||
@@ -12,8 +12,6 @@ export type BuildInfo = {
|
||||
commit: string
|
||||
buildDate: string
|
||||
channel: BuildChannel
|
||||
minDify: string
|
||||
maxDify: string
|
||||
}
|
||||
|
||||
export type Env = Record<string, string | undefined>
|
||||
@@ -35,7 +33,6 @@ export const defaultGitProbe: GitProbe = (cmd) => {
|
||||
type PackageManifest = {
|
||||
difyctl?: {
|
||||
channel?: string
|
||||
compat?: { minDify?: string; maxDify?: string }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,8 +72,6 @@ export function resolveBuildInfo(opts: ResolveOptions = {}): BuildInfo {
|
||||
const commit = env.DIFYCTL_COMMIT ?? git('git rev-parse HEAD') ?? 'none'
|
||||
|
||||
const buildDate = env.DIFYCTL_BUILD_DATE ?? now().toISOString()
|
||||
const minDify = env.DIFYCTL_MIN_DIFY ?? pkg.difyctl?.compat?.minDify ?? '0.0.0'
|
||||
const maxDify = env.DIFYCTL_MAX_DIFY ?? pkg.difyctl?.compat?.maxDify ?? '0.0.0'
|
||||
|
||||
return { version, commit, buildDate, channel: channel as BuildChannel, minDify, maxDify }
|
||||
return { version, commit, buildDate, channel: channel as BuildChannel }
|
||||
}
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
# Env (all optional; defaults derived from cli/package.json + git):
|
||||
# CLI_VERSION — package.json `version`
|
||||
# DIFYCTL_CHANNEL — package.json `difyctl.channel`
|
||||
# DIFYCTL_MIN_DIFY — package.json `difyctl.compat.minDify`; must be X.Y.Z
|
||||
# DIFYCTL_MAX_DIFY — package.json `difyctl.compat.maxDify`; must be X.Y.Z
|
||||
# DIFYCTL_COMMIT — `git rev-parse HEAD` (or "unknown")
|
||||
# DIFYCTL_BUILD_DATE — current UTC time
|
||||
#
|
||||
@@ -35,21 +33,12 @@ out_dir="${cli_root}/dist/bin"
|
||||
|
||||
read_pkg() { node -p "require('${cli_root}/package.json').$1" 2>/dev/null; }
|
||||
naming() { node "${_dir}/release-naming.mjs" "$@"; }
|
||||
require_bound() {
|
||||
[[ "$2" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \
|
||||
|| die "$1 must be a plain X.Y.Z version, got '$2'"
|
||||
}
|
||||
|
||||
CLI_VERSION="${CLI_VERSION:-$(read_pkg version)}"
|
||||
DIFYCTL_CHANNEL="${DIFYCTL_CHANNEL:-$(read_pkg difyctl.channel)}"
|
||||
DIFYCTL_MIN_DIFY="${DIFYCTL_MIN_DIFY:-$(read_pkg difyctl.compat.minDify)}"
|
||||
DIFYCTL_MAX_DIFY="${DIFYCTL_MAX_DIFY:-$(read_pkg difyctl.compat.maxDify)}"
|
||||
DIFYCTL_COMMIT="${DIFYCTL_COMMIT:-$(git -C "$cli_root" rev-parse HEAD 2>/dev/null || echo unknown)}"
|
||||
DIFYCTL_BUILD_DATE="${DIFYCTL_BUILD_DATE:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}"
|
||||
|
||||
require_bound DIFYCTL_MIN_DIFY "$DIFYCTL_MIN_DIFY"
|
||||
require_bound DIFYCTL_MAX_DIFY "$DIFYCTL_MAX_DIFY"
|
||||
|
||||
[[ "$CLI_VERSION" != "undefined" ]] || die "CLI_VERSION could not be derived from package.json"
|
||||
|
||||
[[ -f "$entry" ]] || die "entry not found: $entry"
|
||||
@@ -62,8 +51,6 @@ mkdir -p "$out_dir"
|
||||
defines=(
|
||||
"--define" "__DIFYCTL_VERSION__=\"${CLI_VERSION}\""
|
||||
"--define" "__DIFYCTL_CHANNEL__=\"${DIFYCTL_CHANNEL}\""
|
||||
"--define" "__DIFYCTL_MIN_DIFY__=\"${DIFYCTL_MIN_DIFY}\""
|
||||
"--define" "__DIFYCTL_MAX_DIFY__=\"${DIFYCTL_MAX_DIFY}\""
|
||||
"--define" "__DIFYCTL_COMMIT__=\"${DIFYCTL_COMMIT}\""
|
||||
"--define" "__DIFYCTL_BUILD_DATE__=\"${DIFYCTL_BUILD_DATE}\""
|
||||
)
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import {
|
||||
chmodSync,
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vite-plus/test'
|
||||
|
||||
const posix = (p: string) => p.replace(/\\/g, '/')
|
||||
|
||||
const SCRIPTS_DIR = posix(fileURLToPath(new URL('.', import.meta.url))).replace(/\/$/, '')
|
||||
|
||||
const BUILD_SH = 'release-build.sh'
|
||||
|
||||
type Run = { code: number; stderr: string }
|
||||
|
||||
const STUB_BUN = ['#!/bin/sh', 'echo "release-guards: bun must not run" >&2', 'exit 90', ''].join(
|
||||
'\n',
|
||||
)
|
||||
|
||||
const FAKE_MANIFEST = {
|
||||
version: '7.7.7',
|
||||
difyctl: {
|
||||
channel: 'stable',
|
||||
compat: { minDify: '2.0.0', maxDify: '2.5.0' },
|
||||
release: {
|
||||
tagPrefix: 'difyctl-v',
|
||||
binName: 'difyctl',
|
||||
checksumsSuffix: '-checksums.txt',
|
||||
targets: [{ id: 'linux-x64', bunTarget: 'bun-linux-x64', exe: false }],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
function tempDir(prefix: string): string {
|
||||
return posix(mkdtempSync(join(tmpdir(), prefix)))
|
||||
}
|
||||
|
||||
function runScript(
|
||||
script: string,
|
||||
cliVersion?: string,
|
||||
extraEnv: Record<string, string | undefined> = {},
|
||||
): Run {
|
||||
const stubDir = tempDir('difyctl-stub-bun-')
|
||||
writeFileSync(`${stubDir}/bun`, STUB_BUN)
|
||||
chmodSync(`${stubDir}/bun`, 0o755)
|
||||
try {
|
||||
const merged: Record<string, string | undefined> = {
|
||||
...process.env,
|
||||
PATH: `${stubDir}:${process.env.PATH ?? ''}`,
|
||||
CLI_VERSION: cliVersion,
|
||||
...extraEnv,
|
||||
}
|
||||
const childEnv: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(merged)) {
|
||||
if (value !== undefined) childEnv[key] = value
|
||||
}
|
||||
const r = spawnSync('bash', [script], { encoding: 'utf8', env: childEnv })
|
||||
return { code: r.status ?? 1, stderr: r.stderr ?? '' }
|
||||
} finally {
|
||||
rmSync(stubDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function fakeCliRoot(scriptName: string): string {
|
||||
const root = tempDir('difyctl-release-guard-')
|
||||
mkdirSync(`${root}/scripts/lib`, { recursive: true })
|
||||
cpSync(`${SCRIPTS_DIR}/${scriptName}`, `${root}/scripts/${scriptName}`)
|
||||
cpSync(`${SCRIPTS_DIR}/lib/common.sh`, `${root}/scripts/lib/common.sh`)
|
||||
cpSync(`${SCRIPTS_DIR}/release-naming.mjs`, `${root}/scripts/release-naming.mjs`)
|
||||
writeFileSync(`${root}/package.json`, JSON.stringify(FAKE_MANIFEST))
|
||||
return root
|
||||
}
|
||||
|
||||
// Always against a throwaway root: a valid bound carries the script through to
|
||||
// `rm -rf "$out_dir"`, which against the real cli root deletes a developer's build.
|
||||
function runInFakeRoot(extraEnv: Record<string, string | undefined>): Run {
|
||||
const root = fakeCliRoot(BUILD_SH)
|
||||
try {
|
||||
return runScript(`${root}/scripts/${BUILD_SH}`, '2.4.0', extraEnv)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(process.platform === 'win32')('release-build.sh compat bounds', () => {
|
||||
for (const bound of ['DIFYCTL_MIN_DIFY', 'DIFYCTL_MAX_DIFY']) {
|
||||
it.each(['undefined', '1.16'])(`rejects ${bound}=%s`, (bad) => {
|
||||
const r = runInFakeRoot({ [bound]: bad })
|
||||
expect(r.code).not.toBe(0)
|
||||
expect(r.stderr).toContain(bound)
|
||||
})
|
||||
}
|
||||
|
||||
it('does not wipe dist/bin when a bound guard fires', () => {
|
||||
const root = fakeCliRoot(BUILD_SH)
|
||||
const sentinel = `${root}/dist/bin/prior-build`
|
||||
try {
|
||||
mkdirSync(`${root}/dist/bin`, { recursive: true })
|
||||
writeFileSync(sentinel, 'output from an earlier build')
|
||||
mkdirSync(`${root}/bin`, { recursive: true })
|
||||
writeFileSync(`${root}/bin/run.ts`, 'export {}\n')
|
||||
|
||||
const r = runScript(`${root}/scripts/${BUILD_SH}`, '2.4.0', {
|
||||
DIFYCTL_MIN_DIFY: 'undefined',
|
||||
})
|
||||
expect(r.code).not.toBe(0)
|
||||
expect(existsSync(sentinel)).toBe(true)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -15,19 +15,15 @@
|
||||
// prerelease <channel> -> "true" | "false"
|
||||
// github-env -> key=value lines (all fields CI needs) for $GITHUB_ENV
|
||||
// edge-version <sha> -> <package version core>-edge.<sha>
|
||||
// validate -> exit 1 if difyctl.release, version, channel, or
|
||||
// difyctl.compat is malformed
|
||||
// validate -> exit 1 if difyctl.release, version, or channel is malformed
|
||||
// validate-version <version> <channel>
|
||||
// -> exit 1 unless version matches the channel's form
|
||||
// compat-check <difyVer> -> exit 1 if difyVer outside compat.minDify..maxDify
|
||||
|
||||
import { readFileSync, realpathSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const BUN_TARGET_RE = /^bun-(linux|darwin|windows)-(x64|arm64)$/
|
||||
const SEMVER_CORE_LEN = 3
|
||||
const SEMVER_CORE_RE = /^\d+\.\d+\.\d+$/
|
||||
const COMPAT_BOUNDS = ['minDify', 'maxDify']
|
||||
|
||||
// Add channels here: { name, prerelease, versionForm }.
|
||||
const CHANNELS = [
|
||||
@@ -40,14 +36,6 @@ const CHANNELS = [
|
||||
const channelByName = (name) => CHANNELS.find((c) => c.name === name)
|
||||
const channelNames = () => CHANNELS.map((c) => c.name).join(', ')
|
||||
|
||||
function parsePrecedence(v) {
|
||||
const s = String(v).replace(/^v/, '').replace(/\+.*$/, '')
|
||||
const i = s.indexOf('-')
|
||||
const core = i === -1 ? s : s.slice(0, i)
|
||||
const pre = i === -1 ? '' : s.slice(i + 1)
|
||||
return { nums: core.split('.').map(Number), pre }
|
||||
}
|
||||
|
||||
function versionCore(v) {
|
||||
return String(v).replace(/^v/, '').replace(/\+.*$/, '').split('-')[0]
|
||||
}
|
||||
@@ -80,50 +68,15 @@ function validateVersionForChannel(version, channelName) {
|
||||
return `valid: ${version} is a ${channelName} version`
|
||||
}
|
||||
|
||||
function comparePre(a, b) {
|
||||
const aparts = a.split('.')
|
||||
const bparts = b.split('.')
|
||||
const len = Math.max(aparts.length, bparts.length)
|
||||
for (let i = 0; i < len; i++) {
|
||||
if (aparts[i] === undefined) return -1
|
||||
if (bparts[i] === undefined) return 1
|
||||
const an = /^\d+$/.test(aparts[i])
|
||||
const bn = /^\d+$/.test(bparts[i])
|
||||
if (an && bn) {
|
||||
const d = Number(aparts[i]) - Number(bparts[i])
|
||||
if (d !== 0) return d < 0 ? -1 : 1
|
||||
} else if (an !== bn) {
|
||||
return an ? -1 : 1
|
||||
} else if (aparts[i] !== bparts[i]) {
|
||||
return aparts[i] < bparts[i] ? -1 : 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function comparePrecedence(a, b) {
|
||||
const A = parsePrecedence(a)
|
||||
const B = parsePrecedence(b)
|
||||
for (let i = 0; i < SEMVER_CORE_LEN; i++) {
|
||||
const x = A.nums[i] ?? 0
|
||||
const y = B.nums[i] ?? 0
|
||||
if (x !== y) return x < y ? -1 : 1
|
||||
}
|
||||
if (A.pre === B.pre) return 0
|
||||
if (A.pre === '') return 1
|
||||
if (B.pre === '') return -1
|
||||
return comparePre(A.pre, B.pre)
|
||||
}
|
||||
|
||||
function die(msg) {
|
||||
process.stderr.write(`release-naming: ${msg}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Tests point this at a fixture manifest so their assertions stay fixed while
|
||||
// the real version and compat window move with every release. The name is
|
||||
// mirrored in test/fixtures/pkg-manifest.ts rather than imported from here,
|
||||
// because this file's shebang breaks the Windows test runner.
|
||||
// the real version moves with every release. The name is mirrored in
|
||||
// test/fixtures/pkg-manifest.ts rather than imported from here, because this
|
||||
// file's shebang breaks the Windows test runner.
|
||||
const PKG_PATH_ENV = 'DIFYCTL_PKG_PATH'
|
||||
|
||||
function loadPkg() {
|
||||
@@ -133,20 +86,17 @@ function loadPkg() {
|
||||
return {
|
||||
version: pkg.version,
|
||||
channel: pkg.difyctl.channel,
|
||||
compat: pkg.difyctl.compat ?? {},
|
||||
release: pkg.difyctl.release,
|
||||
}
|
||||
}
|
||||
|
||||
// Emits key=value lines for $GITHUB_ENV.
|
||||
function githubEnv() {
|
||||
const { version, channel, compat, release } = loadPkg()
|
||||
const { version, channel, release } = loadPkg()
|
||||
const fields = {
|
||||
version,
|
||||
channel,
|
||||
prerelease: channelByName(channel)?.prerelease ?? false,
|
||||
minDify: compat.minDify,
|
||||
maxDify: compat.maxDify,
|
||||
tagPrefix: release.tagPrefix,
|
||||
difyctlTag: `${release.tagPrefix}${version}`,
|
||||
}
|
||||
@@ -197,15 +147,6 @@ function validateVersionChannel(version, channel) {
|
||||
return problem ? [problem] : []
|
||||
}
|
||||
|
||||
function validateCompat(compat) {
|
||||
const problems = COMPAT_BOUNDS.filter((b) => !SEMVER_CORE_RE.test(compat[b] ?? '')).map(
|
||||
(b) => `difyctl.compat.${b} must be a plain X.Y.Z version, found ${compat[b] ?? '(missing)'}`,
|
||||
)
|
||||
if (problems.length === 0 && comparePrecedence(compat.minDify, compat.maxDify) > 0)
|
||||
problems.push(`difyctl.compat.minDify (${compat.minDify}) is above maxDify (${compat.maxDify})`)
|
||||
return problems
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
const [cmd, ...rest] = argv
|
||||
switch (cmd) {
|
||||
@@ -231,35 +172,17 @@ function main(argv) {
|
||||
return CHANNELS.map((c) => c.name).join('\n')
|
||||
case 'github-env':
|
||||
return githubEnv()
|
||||
case 'compat-check': {
|
||||
const { compat } = loadPkg()
|
||||
const difyVersion = requireVersion(rest[0])
|
||||
if (!compat.minDify || !compat.maxDify)
|
||||
die('cli/package.json missing difyctl.compat.minDify/maxDify')
|
||||
if (
|
||||
comparePrecedence(difyVersion, compat.minDify) < 0 ||
|
||||
comparePrecedence(difyVersion, compat.maxDify) > 0
|
||||
)
|
||||
die(
|
||||
`Dify ${difyVersion} is outside difyctl compatibility window ${compat.minDify}..${compat.maxDify}; bump difyctl.compat in cli/package.json`,
|
||||
)
|
||||
return `compatible: Dify ${difyVersion} within ${compat.minDify}..${compat.maxDify}`
|
||||
}
|
||||
case 'prerelease': {
|
||||
const ch = channelByName(rest[0] ?? die('channel argument is required'))
|
||||
if (!ch) die(`unknown channel: ${rest[0]} (expected one of: ${channelNames()})`)
|
||||
return String(ch.prerelease)
|
||||
}
|
||||
case 'validate': {
|
||||
const { version, channel, compat, release } = loadPkg()
|
||||
const problems = [
|
||||
...validateRelease(release),
|
||||
...validateCompat(compat),
|
||||
...validateVersionChannel(version, channel),
|
||||
]
|
||||
const { version, channel, release } = loadPkg()
|
||||
const problems = [...validateRelease(release), ...validateVersionChannel(version, channel)]
|
||||
if (problems.length > 0)
|
||||
die(`invalid difyctl release config:\n - ${problems.join('\n - ')}`)
|
||||
return `difyctl release valid: version=${version} channel=${channel} compat=${compat.minDify}..${compat.maxDify} targets=${release.targets.length}`
|
||||
return `difyctl release valid: version=${version} channel=${channel} targets=${release.targets.length}`
|
||||
}
|
||||
case 'edge-version':
|
||||
return edgeVersion(rest[0])
|
||||
|
||||
@@ -5,7 +5,6 @@ import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vite-plus/test'
|
||||
import {
|
||||
FIXTURE_CHANNEL,
|
||||
FIXTURE_COMPAT,
|
||||
FIXTURE_TAG_PREFIX,
|
||||
FIXTURE_VERSION,
|
||||
pkgManifestEnv,
|
||||
@@ -40,7 +39,7 @@ function runWith(overrides: PkgManifestOverrides, args: string[]): RunResult {
|
||||
|
||||
type FixtureManifest = {
|
||||
version?: string
|
||||
difyctl: { channel?: string; compat: { minDify?: string; maxDify?: string } }
|
||||
difyctl: { channel?: string }
|
||||
}
|
||||
|
||||
function runOnManifest(mutate: (manifest: FixtureManifest) => void, args: string[]): RunResult {
|
||||
@@ -62,48 +61,6 @@ function parseKeyValues(stdout: string): Record<string, string> {
|
||||
)
|
||||
}
|
||||
|
||||
describe('release-naming compat-check', () => {
|
||||
const { minDify, maxDify } = FIXTURE_COMPAT // 2.0.0 .. 2.5.0
|
||||
const compatCheck = (difyVersion?: string) =>
|
||||
run(difyVersion === undefined ? ['compat-check'] : ['compat-check', difyVersion]).code
|
||||
|
||||
it('accepts a version inside the window', () => {
|
||||
expect(compatCheck('2.3.0')).toBe(0)
|
||||
})
|
||||
|
||||
it('accepts the inclusive lower bound', () => {
|
||||
expect(compatCheck(minDify)).toBe(0)
|
||||
})
|
||||
|
||||
it('accepts the inclusive upper bound', () => {
|
||||
expect(compatCheck(maxDify)).toBe(0)
|
||||
})
|
||||
|
||||
it('accepts a v-prefixed tag', () => {
|
||||
expect(compatCheck('v2.3.0')).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects a version below the lower bound', () => {
|
||||
expect(compatCheck('1.9.9')).not.toBe(0)
|
||||
})
|
||||
|
||||
it('rejects a version above the upper bound', () => {
|
||||
expect(compatCheck('2.5.1')).not.toBe(0)
|
||||
})
|
||||
|
||||
it('treats a prerelease of the lower bound as below it', () => {
|
||||
expect(compatCheck(`${minDify}-rc1`)).not.toBe(0)
|
||||
})
|
||||
|
||||
it('ignores build metadata on the bound', () => {
|
||||
expect(compatCheck(`${maxDify}+build123`)).toBe(0)
|
||||
})
|
||||
|
||||
it('requires a version argument', () => {
|
||||
expect(compatCheck()).not.toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('release-naming github-env', () => {
|
||||
it('emits every manifest field for $GITHUB_ENV, plus a composed difyctlTag', () => {
|
||||
const fields = parseKeyValues(run(['github-env']).stdout)
|
||||
@@ -111,8 +68,6 @@ describe('release-naming github-env', () => {
|
||||
version: FIXTURE_VERSION,
|
||||
channel: FIXTURE_CHANNEL,
|
||||
prerelease: 'false',
|
||||
minDify: FIXTURE_COMPAT.minDify,
|
||||
maxDify: FIXTURE_COMPAT.maxDify,
|
||||
tagPrefix: FIXTURE_TAG_PREFIX,
|
||||
difyctlTag: `${FIXTURE_TAG_PREFIX}${FIXTURE_VERSION}`,
|
||||
})
|
||||
@@ -179,35 +134,6 @@ describe('release-naming validate channel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('release-naming validate compat bounds', () => {
|
||||
const validateCompat = (minDify: string, maxDify: string) =>
|
||||
runWith({ compat: { minDify, maxDify } }, ['validate'])
|
||||
|
||||
it('accepts a well-formed window', () => {
|
||||
expect(validateCompat('1.16.0', '1.17.0').code).toBe(0)
|
||||
})
|
||||
|
||||
it('accepts equal bounds', () => {
|
||||
expect(validateCompat('1.17.0', '1.17.0').code).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects an inverted window', () => {
|
||||
const { code, stderr } = validateCompat('1.18.0', '1.17.0')
|
||||
expect(code).not.toBe(0)
|
||||
expect(stderr).toContain('is above maxDify')
|
||||
})
|
||||
|
||||
it.each(['1.x', '1.16', '1.16.0-rc1', ''])('rejects %s as a bound', (bad) => {
|
||||
expect(validateCompat(bad, '2.9.0').code).not.toBe(0)
|
||||
expect(validateCompat('1.0.0', bad).code).not.toBe(0)
|
||||
})
|
||||
|
||||
it('names the offending bound', () => {
|
||||
expect(validateCompat('1.x', '1.17.0').stderr).toContain('difyctl.compat.minDify')
|
||||
expect(validateCompat('1.16.0', '1.x').stderr).toContain('difyctl.compat.maxDify')
|
||||
})
|
||||
})
|
||||
|
||||
describe('release-naming validate-version', () => {
|
||||
it('accepts build metadata on a stable version', () => {
|
||||
expect(run(['validate-version', '1.16.1+r2', 'stable']).code).toBe(0)
|
||||
|
||||
@@ -41,7 +41,7 @@ function shaMap(checksumsPath) {
|
||||
function emitManifest(args) {
|
||||
requireArgs(args, ['channel', 'version', 'commit', 'build-date', 'base-url', 'checksums'])
|
||||
validateVersionForChannel(args.version, args.channel)
|
||||
const { release, compat } = loadPkg()
|
||||
const { release } = loadPkg()
|
||||
const shas = shaMap(args.checksums)
|
||||
|
||||
const targetLines = release.targets
|
||||
@@ -61,7 +61,6 @@ function emitManifest(args) {
|
||||
version: args.version,
|
||||
commit: args.commit,
|
||||
buildDate: args['build-date'],
|
||||
compat: { minDify: compat.minDify, maxDify: compat.maxDify },
|
||||
baseUrl: args['base-url'],
|
||||
}
|
||||
const headLines = Object.entries(head)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vite-plus/test'
|
||||
import { FIXTURE_COMPAT, FIXTURE_TARGET_IDS, pkgManifestEnv } from '../test/fixtures/pkg-manifest'
|
||||
import { FIXTURE_TARGET_IDS, pkgManifestEnv } from '../test/fixtures/pkg-manifest'
|
||||
|
||||
const SCRIPT = fileURLToPath(new URL('./release-r2-edge.mjs', import.meta.url))
|
||||
|
||||
@@ -50,7 +50,6 @@ type ManifestJson = {
|
||||
version: string
|
||||
commit: string
|
||||
buildDate: string
|
||||
compat: { minDify: string; maxDify: string }
|
||||
baseUrl: string
|
||||
targets: Record<(typeof FIXTURE_TARGET_IDS)[number], { asset: string; sha256: string }>
|
||||
}
|
||||
@@ -111,11 +110,6 @@ describe('release-r2-edge manifest', () => {
|
||||
expect(json.baseUrl).toBe(BASE_URL)
|
||||
})
|
||||
|
||||
it('carries the compat window from package.json', () => {
|
||||
const { json } = buildManifest()
|
||||
expect(json.compat).toEqual(FIXTURE_COMPAT)
|
||||
})
|
||||
|
||||
it('lists all 5 targets with asset name + sha256 from the checksums file', () => {
|
||||
const { json } = buildManifest()
|
||||
expect(Object.keys(json.targets).sort()).toEqual([
|
||||
|
||||
@@ -1,45 +1,60 @@
|
||||
#!/usr/bin/env -S bun
|
||||
import { execSync } from 'node:child_process'
|
||||
// scripts/run-smoke.ts — five checks against a running Dify server (or mock),
|
||||
// through `bun bin/dev.js`. DIFY_SERVER, DIFY_TOKEN, DIFY_CONFIG_DIR, and
|
||||
// DIFY_CACHE_DIR are read from the environment and passed through unchanged.
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { ExitCode } from '../src/errors/codes.js'
|
||||
|
||||
type Check = { name: string; run: () => void }
|
||||
|
||||
const baseUrlIdx = process.argv.indexOf('--base-url')
|
||||
const baseUrl = baseUrlIdx > -1 ? process.argv[baseUrlIdx + 1] : 'http://localhost:5001'
|
||||
if (!baseUrl) {
|
||||
console.error('usage: run-smoke.ts --base-url <url>')
|
||||
process.exit(2)
|
||||
function cli(args: string[]): string {
|
||||
return execFileSync('bun', ['bin/dev.js', ...args], { encoding: 'utf8' })
|
||||
}
|
||||
|
||||
const env = { ...process.env, DIFY_BASE_URL: baseUrl }
|
||||
function cliExitCode(args: string[]): number {
|
||||
return spawnSync('bun', ['bin/dev.js', ...args], { encoding: 'utf8' }).status ?? 1
|
||||
}
|
||||
|
||||
function cli(args: string): string {
|
||||
return execSync(`bun bin/dev.js ${args}`, { env, encoding: 'utf8' })
|
||||
function expectExit(args: string[], expected: number): void {
|
||||
const code = cliExitCode(args)
|
||||
if (code !== expected)
|
||||
throw new Error(`expected exit ${expected} for "${args.join(' ')}", got ${code}`)
|
||||
}
|
||||
|
||||
const checks: Check[] = [
|
||||
{
|
||||
name: 'config show',
|
||||
name: 'version prints .client.version',
|
||||
run: () => {
|
||||
cli('config show')
|
||||
const body = JSON.parse(cli(['version']))
|
||||
if (typeof body.client?.version !== 'string') throw new Error('no .client.version')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get workspace',
|
||||
name: 'help get.console_app carries .input.properties.workspace_id',
|
||||
run: () => {
|
||||
if (!cli('get workspace').includes('id')) throw new Error('no workspace listed')
|
||||
const body = JSON.parse(cli(['help', 'get.console_app', '--json']))
|
||||
if (body.input?.properties?.workspace_id === undefined)
|
||||
throw new Error('no .input.properties.workspace_id')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get apps',
|
||||
name: 'get console_app returns .data',
|
||||
run: () => {
|
||||
cli('get apps')
|
||||
const body = JSON.parse(cli(['get', 'console_app', '--input', '{"limit":1}']))
|
||||
if (body.data === undefined) throw new Error('no .data')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'difyctl version prints compat',
|
||||
run: () => {
|
||||
if (!cli('version').includes('compat:')) throw new Error('no compat line')
|
||||
},
|
||||
name: '--stream on an object-kind op exits 2 (usage)',
|
||||
run: () =>
|
||||
expectExit(
|
||||
['describe', 'console_app', '--input', '{"app_id":"x"}', '--stream'],
|
||||
ExitCode.Usage,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'a missing required input exits 2 (usage)',
|
||||
run: () => expectExit(['describe', 'console_app', '--input', '{}'], ExitCode.Usage),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import type { StubServer } from '@test/fixtures/stub-server'
|
||||
import { testHttpClient } from '@test/fixtures/http-client'
|
||||
import { jsonResponder, startStubServer } from '@test/fixtures/stub-server'
|
||||
import { afterEach, describe, expect, it } from 'vite-plus/test'
|
||||
import { isHttpClientError } from '@/errors/base'
|
||||
import { AccountSessionsClient } from './account-sessions.js'
|
||||
|
||||
const LIST_BODY = { page: 1, limit: 100, total: 0, has_more: false, data: [] }
|
||||
|
||||
function makeClient(host: string): AccountSessionsClient {
|
||||
return new AccountSessionsClient(testHttpClient(host, 'dfoa_test'))
|
||||
}
|
||||
|
||||
describe('AccountSessionsClient.list', () => {
|
||||
let stub: StubServer
|
||||
|
||||
afterEach(async () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
it('GETs account/sessions with no query when paging is unset', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(200, LIST_BODY, cap))
|
||||
|
||||
await makeClient(stub.url).list()
|
||||
|
||||
expect(stub.captured.method).toBe('GET')
|
||||
// page/limit are undefined → dropped, so no trailing query string at all.
|
||||
expect(stub.captured.url).toBe('/openapi/v1/account/sessions')
|
||||
})
|
||||
|
||||
it('forwards page/limit when supplied', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(200, LIST_BODY, cap))
|
||||
|
||||
await makeClient(stub.url).list({ page: 2, limit: 25 })
|
||||
|
||||
const q = new URL(stub.captured.url ?? '', 'http://x').searchParams
|
||||
expect(q.get('page')).toBe('2')
|
||||
expect(q.get('limit')).toBe('25')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AccountSessionsClient.revoke', () => {
|
||||
let stub: StubServer
|
||||
|
||||
afterEach(async () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
it('DELETEs the session by id and discards the JSON body without throwing', async () => {
|
||||
// The server replies 200 + {status:"revoked"}; revoke() returns void but the
|
||||
// typed client still parses the body — this guards against a regression where
|
||||
// a non-empty 200 body trips JSON handling.
|
||||
stub = await startStubServer((cap) => jsonResponder(200, { status: 'revoked' }, cap))
|
||||
|
||||
await expect(makeClient(stub.url).revoke('sess-1')).resolves.toBeUndefined()
|
||||
expect(stub.captured.method).toBe('DELETE')
|
||||
expect(stub.captured.url).toBe('/openapi/v1/account/sessions/sess-1')
|
||||
})
|
||||
|
||||
it('URL-encodes the session id', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(200, { status: 'revoked' }, cap))
|
||||
|
||||
await makeClient(stub.url).revoke('sess/1 2')
|
||||
|
||||
expect(stub.captured.url).toBe('/openapi/v1/account/sessions/sess%2F1%202')
|
||||
})
|
||||
|
||||
it('propagates 404 as a classified BaseError', async () => {
|
||||
stub = await startStubServer((cap) =>
|
||||
jsonResponder(404, { error: { code: 'not_found', message: 'session not found' } }, cap),
|
||||
)
|
||||
|
||||
await expect(makeClient(stub.url).revoke('missing')).rejects.toSatisfy(
|
||||
(err) => isHttpClientError(err) && err.httpStatus === 404,
|
||||
)
|
||||
})
|
||||
|
||||
it('revokeSelf DELETEs the self subresource', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(200, { status: 'revoked' }, cap))
|
||||
|
||||
await expect(makeClient(stub.url).revokeSelf()).resolves.toBeUndefined()
|
||||
expect(stub.captured.method).toBe('DELETE')
|
||||
expect(stub.captured.url).toBe('/openapi/v1/account/sessions/self')
|
||||
})
|
||||
})
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { SessionListResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { OpenApiClient } from '@/http/orpc'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { createOpenApiClient } from '@/http/orpc'
|
||||
|
||||
export class AccountSessionsClient {
|
||||
private readonly orpc: OpenApiClient
|
||||
|
||||
constructor(http: HttpClient) {
|
||||
this.orpc = createOpenApiClient(http)
|
||||
}
|
||||
|
||||
async list(q?: { page?: number; limit?: number }): Promise<SessionListResponse> {
|
||||
return this.orpc.account.sessions.get({ query: { page: q?.page, limit: q?.limit } })
|
||||
}
|
||||
|
||||
async revoke(sessionId: string): Promise<void> {
|
||||
await this.orpc.account.sessions.bySessionId.delete({ params: { session_id: sessionId } })
|
||||
}
|
||||
|
||||
async revokeSelf(): Promise<void> {
|
||||
await this.orpc.account.sessions.self.delete()
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import type { StubServer } from '@test/fixtures/stub-server'
|
||||
import { testHttpClient } from '@test/fixtures/http-client'
|
||||
import { jsonResponder, startStubServer } from '@test/fixtures/stub-server'
|
||||
import { afterEach, describe, expect, it } from 'vite-plus/test'
|
||||
import { isHttpClientError } from '@/errors/base'
|
||||
import { AccountClient } from './account.js'
|
||||
|
||||
function makeClient(host: string): AccountClient {
|
||||
return new AccountClient(testHttpClient(host, 'dfoa_test'))
|
||||
}
|
||||
|
||||
describe('AccountClient.get', () => {
|
||||
let stub: StubServer
|
||||
|
||||
afterEach(async () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
it('GETs account, sends the bearer, and returns the parsed payload', async () => {
|
||||
stub = await startStubServer((cap) =>
|
||||
jsonResponder(
|
||||
200,
|
||||
{
|
||||
subject_type: 'account',
|
||||
account: { id: 'acct-1', email: 'a@e.com', name: 'A' },
|
||||
},
|
||||
cap,
|
||||
),
|
||||
)
|
||||
|
||||
const res = await makeClient(stub.url).get()
|
||||
|
||||
expect(stub.captured.method).toBe('GET')
|
||||
expect(stub.captured.url).toBe('/openapi/v1/account')
|
||||
expect(stub.captured.headers?.authorization).toBe('Bearer dfoa_test')
|
||||
expect(res.account?.email).toBe('a@e.com')
|
||||
})
|
||||
|
||||
it('maps 401 to a classified BaseError', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(401, { error: 'expired' }, cap))
|
||||
|
||||
await expect(makeClient(stub.url).get()).rejects.toSatisfy(
|
||||
(err) => isHttpClientError(err) && err.httpStatus === 401,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { AccountResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { OpenApiClient } from '@/http/orpc'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { createOpenApiClient } from '@/http/orpc'
|
||||
|
||||
export class AccountClient {
|
||||
private readonly orpc: OpenApiClient
|
||||
|
||||
constructor(http: HttpClient) {
|
||||
this.orpc = createOpenApiClient(http)
|
||||
}
|
||||
|
||||
async get(): Promise<AccountResponse> {
|
||||
return this.orpc.account.get()
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
import type { StubServer } from '@test/fixtures/stub-server'
|
||||
import { testHttpClient } from '@test/fixtures/http-client'
|
||||
import { jsonResponder, startStubServer } from '@test/fixtures/stub-server'
|
||||
import { afterEach, describe, expect, it } from 'vite-plus/test'
|
||||
import { isHttpClientError } from '@/errors/base'
|
||||
import { AppDslClient } from './app-dsl.js'
|
||||
|
||||
const DSL_YAML = `app:\n mode: chat\n name: Test\nversion: '0.1.4'\n`
|
||||
|
||||
const COMPLETED_IMPORT = { id: 'imp-1', status: 'completed', app_id: 'app-1', app_mode: 'chat' }
|
||||
|
||||
function makeClient(host: string): AppDslClient {
|
||||
return new AppDslClient(testHttpClient(host, 'dfoa_test'))
|
||||
}
|
||||
|
||||
describe('AppDslClient.exportDsl', () => {
|
||||
let stub: StubServer
|
||||
|
||||
afterEach(async () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
it('returns the data string from the response', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(200, { data: DSL_YAML }, cap))
|
||||
|
||||
const yaml = await makeClient(stub.url).exportDsl('app-1')
|
||||
|
||||
expect(stub.captured.method).toBe('GET')
|
||||
expect(stub.captured.url?.split('?')[0]).toBe('/openapi/v1/apps/app-1/dsl')
|
||||
expect(yaml).toBe(DSL_YAML)
|
||||
})
|
||||
|
||||
it('throws when response has no data field', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(200, { wrong: 1 }, cap))
|
||||
|
||||
await expect(makeClient(stub.url).exportDsl('app-1')).rejects.toThrow(
|
||||
'export response missing data field',
|
||||
)
|
||||
})
|
||||
|
||||
it('propagates 404 as a classified HttpClientError', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(404, { error: 'not_found' }, cap))
|
||||
|
||||
await expect(makeClient(stub.url).exportDsl('missing')).rejects.toSatisfy(
|
||||
(err) => isHttpClientError(err) && err.httpStatus === 404,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AppDslClient.importApp', () => {
|
||||
let stub: StubServer
|
||||
|
||||
afterEach(async () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
it('POST to /workspaces/:id/apps/imports with body and returns Import', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(200, COMPLETED_IMPORT, cap))
|
||||
|
||||
const result = await makeClient(stub.url).importApp('ws-1', {
|
||||
mode: 'yaml-content',
|
||||
yaml_content: DSL_YAML,
|
||||
})
|
||||
|
||||
expect(stub.captured.method).toBe('POST')
|
||||
expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/apps/imports')
|
||||
expect(result.status).toBe('completed')
|
||||
expect(result.app_id).toBe('app-1')
|
||||
})
|
||||
|
||||
it('returns pending import on 202', async () => {
|
||||
const pending = {
|
||||
id: 'imp-1',
|
||||
status: 'pending',
|
||||
current_dsl_version: '0.1.4',
|
||||
imported_dsl_version: '0.0.9',
|
||||
}
|
||||
stub = await startStubServer((cap) => jsonResponder(202, pending, cap))
|
||||
|
||||
const result = await makeClient(stub.url).importApp('ws-1', {
|
||||
mode: 'yaml-content',
|
||||
yaml_content: DSL_YAML,
|
||||
})
|
||||
|
||||
expect(result.status).toBe('pending')
|
||||
expect(result.id).toBe('imp-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AppDslClient.confirmImport', () => {
|
||||
let stub: StubServer
|
||||
|
||||
afterEach(async () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
it('POST to confirm URL and returns completed Import', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(200, COMPLETED_IMPORT, cap))
|
||||
|
||||
const result = await makeClient(stub.url).confirmImport('ws-1', 'imp-1')
|
||||
|
||||
expect(stub.captured.method).toBe('POST')
|
||||
expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/apps/imports/imp-1:confirm')
|
||||
expect(result.status).toBe('completed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AppDslClient.checkDependencies', () => {
|
||||
let stub: StubServer
|
||||
|
||||
afterEach(async () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
it('returns empty leaked_dependencies on healthy app', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(200, { leaked_dependencies: [] }, cap))
|
||||
|
||||
const result = await makeClient(stub.url).checkDependencies('app-1')
|
||||
|
||||
expect(stub.captured.url?.split('?')[0]).toBe('/openapi/v1/apps/app-1/dependencies:check')
|
||||
expect(result.leaked_dependencies).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,59 +0,0 @@
|
||||
import type {
|
||||
AppDslImportPayload,
|
||||
CheckDependenciesResult,
|
||||
Import,
|
||||
} from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { OpenApiClient } from '@/http/orpc'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { createOpenApiClient } from '@/http/orpc'
|
||||
|
||||
export type ExportQuery = {
|
||||
readonly includeSecret?: boolean
|
||||
readonly workflowId?: string
|
||||
}
|
||||
|
||||
export class AppDslClient {
|
||||
private readonly orpc: OpenApiClient
|
||||
|
||||
constructor(http: HttpClient) {
|
||||
this.orpc = createOpenApiClient(http)
|
||||
}
|
||||
|
||||
async importApp(workspaceId: string, payload: AppDslImportPayload): Promise<Import> {
|
||||
return this.orpc.workspaces.byWorkspaceId.apps.imports.post({
|
||||
params: { workspace_id: workspaceId },
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
async confirmImport(workspaceId: string, importId: string): Promise<Import> {
|
||||
return this.orpc.workspaces.byWorkspaceId.apps.imports.byImportId.confirm.post({
|
||||
params: { workspace_id: workspaceId, import_id: importId },
|
||||
})
|
||||
}
|
||||
|
||||
async exportDsl(appId: string, query?: ExportQuery): Promise<string> {
|
||||
const resp = await this.orpc.apps.byAppId.dsl.get({
|
||||
params: { app_id: appId },
|
||||
query:
|
||||
query !== undefined
|
||||
? {
|
||||
include_secret: query.includeSecret,
|
||||
workflow_id: query.workflowId,
|
||||
}
|
||||
: undefined,
|
||||
})
|
||||
// The response schema is an open object {"data": "<yaml string>"}; the
|
||||
// contract generator marks it as loose because the backend annotation
|
||||
// does not narrow the shape. Extract `data` directly.
|
||||
const data = (resp as Record<string, unknown>).data
|
||||
if (typeof data !== 'string') throw new Error('export response missing data field')
|
||||
return data
|
||||
}
|
||||
|
||||
async checkDependencies(appId: string): Promise<CheckDependenciesResult> {
|
||||
return this.orpc.apps.byAppId.dependencies.check.get({
|
||||
params: { app_id: appId },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import type { DifyMock } from '@test/fixtures/dify-mock/server'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { startMock } from '@test/fixtures/dify-mock/server'
|
||||
import { testHttpClient } from '@test/fixtures/http-client'
|
||||
import { dump, load } from 'js-yaml'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { loadAppInfoCache } from '@/cache/app-info'
|
||||
import { ENV_CACHE_DIR } from '@/store/dir'
|
||||
import { CACHE_APP_INFO, cachePath, getCache } from '@/store/manager'
|
||||
import { FieldInfo, FieldParameters } from '@/types/app-meta'
|
||||
import { AppMetaClient } from './app-meta.js'
|
||||
import { AppsClient } from './apps.js'
|
||||
|
||||
describe('AppMetaClient', () => {
|
||||
let mock: DifyMock
|
||||
let dir: string
|
||||
let prevCacheDir: string | undefined
|
||||
beforeEach(async () => {
|
||||
mock = await startMock({ scenario: 'happy' })
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-meta-'))
|
||||
prevCacheDir = process.env[ENV_CACHE_DIR]
|
||||
process.env[ENV_CACHE_DIR] = dir
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prevCacheDir === undefined) delete process.env[ENV_CACHE_DIR]
|
||||
else process.env[ENV_CACHE_DIR] = prevCacheDir
|
||||
await mock.stop()
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('cache miss → fetch → populate; warm hit skips network', async () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const apps = new AppsClient(testHttpClient(mock.url, 'dfoa_test'))
|
||||
const spy = vi.spyOn(apps, 'describe')
|
||||
const client = new AppMetaClient({ apps, host: mock.url, cache })
|
||||
|
||||
const m1 = await client.get('app-1', [FieldInfo])
|
||||
expect(m1.info?.id).toBe('app-1')
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
|
||||
const m2 = await client.get('app-1', [FieldInfo])
|
||||
expect(m2.info?.id).toBe('app-1')
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('slim hit + full request triggers fresh fetch + merges', async () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const apps = new AppsClient(testHttpClient(mock.url, 'dfoa_test'))
|
||||
const spy = vi.spyOn(apps, 'describe')
|
||||
const client = new AppMetaClient({ apps, host: mock.url, cache })
|
||||
|
||||
await client.get('app-1', [FieldInfo])
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
|
||||
const full = await client.get('app-1', [FieldInfo, FieldParameters])
|
||||
expect(spy).toHaveBeenCalledTimes(2)
|
||||
expect(full.coveredFields.has(FieldParameters)).toBe(true)
|
||||
})
|
||||
|
||||
it('expired cache entry refetches', async () => {
|
||||
const cache = await loadAppInfoCache({
|
||||
store: getCache(CACHE_APP_INFO),
|
||||
ttlMs: 100,
|
||||
now: () => new Date('2026-05-09T00:00:00Z'),
|
||||
})
|
||||
const apps = new AppsClient(testHttpClient(mock.url, 'dfoa_test'))
|
||||
const spy = vi.spyOn(apps, 'describe')
|
||||
const client = new AppMetaClient({
|
||||
apps,
|
||||
host: mock.url,
|
||||
cache,
|
||||
now: () => new Date('2026-05-09T00:00:00Z'),
|
||||
})
|
||||
|
||||
await client.get('app-1', [FieldInfo])
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
|
||||
const client2 = new AppMetaClient({
|
||||
apps,
|
||||
host: mock.url,
|
||||
cache,
|
||||
now: () => new Date('2026-05-09T00:00:01Z'),
|
||||
})
|
||||
await client2.get('app-1', [FieldInfo])
|
||||
expect(spy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('invalidate forces next get to fetch', async () => {
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const apps = new AppsClient(testHttpClient(mock.url, 'dfoa_test'))
|
||||
const spy = vi.spyOn(apps, 'describe')
|
||||
const client = new AppMetaClient({ apps, host: mock.url, cache })
|
||||
|
||||
await client.get('app-1', [FieldInfo])
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
|
||||
await client.invalidate('app-1')
|
||||
await client.get('app-1', [FieldInfo])
|
||||
expect(spy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('no cache: each call hits network', async () => {
|
||||
const apps = new AppsClient(testHttpClient(mock.url, 'dfoa_test'))
|
||||
const spy = vi.spyOn(apps, 'describe')
|
||||
const client = new AppMetaClient({ apps, host: mock.url })
|
||||
|
||||
await client.get('app-1', [FieldInfo])
|
||||
await client.get('app-1', [FieldInfo])
|
||||
expect(spy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('corrupt cache entry refetches; valid sibling stays cached; no throw', async () => {
|
||||
const path = cachePath(dir, CACHE_APP_INFO)
|
||||
const apps = new AppsClient(testHttpClient(mock.url, 'dfoa_test'))
|
||||
|
||||
// Seed a real, production-serialized entry by fetching app-1 once (this
|
||||
// calls cache.set → serialize, so we never hand-write the on-disk shape).
|
||||
const seed = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await new AppMetaClient({ apps, host: mock.url, cache: seed }).get('app-1', [FieldInfo])
|
||||
|
||||
// Reuse that serialized entry as a valid sibling; corrupt the app-1 slot.
|
||||
const file = load(await readFile(path, 'utf8')) as { entries: Record<string, unknown> }
|
||||
const validEntry = file.entries[`${mock.url}::app-1`]
|
||||
await writeFile(
|
||||
path,
|
||||
dump({
|
||||
entries: {
|
||||
[`${mock.url}::app-1`]: 'corrupted-string',
|
||||
[`${mock.url}::sibling`]: validEntry,
|
||||
},
|
||||
}),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
// Reload: app-1 dropped, sibling kept.
|
||||
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const spy = vi.spyOn(apps, 'describe')
|
||||
const client = new AppMetaClient({ apps, host: mock.url, cache })
|
||||
|
||||
// app-1 corrupt → dropped → miss → refetched from the mock
|
||||
const a = await client.get('app-1', [FieldInfo])
|
||||
expect(a.info?.id).toBe('app-1')
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
|
||||
// sibling is the real serialized entry → served from cache, no network
|
||||
await client.get('sibling', [FieldInfo])
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { AppReader } from './app-reader'
|
||||
import type { AppInfoCache } from '@/cache/app-info'
|
||||
import type { AppMeta, AppMetaFieldKey } from '@/types/app-meta'
|
||||
import { covers, fromDescribe, mergeMeta } from '@/types/app-meta'
|
||||
|
||||
export type AppMetaClientOptions = {
|
||||
readonly apps: AppReader
|
||||
readonly host: string
|
||||
readonly cache?: AppInfoCache
|
||||
readonly now?: () => Date
|
||||
}
|
||||
|
||||
export class AppMetaClient {
|
||||
private readonly apps: AppReader
|
||||
private readonly host: string
|
||||
private readonly cache: AppInfoCache | undefined
|
||||
private readonly now: () => Date
|
||||
|
||||
constructor(opts: AppMetaClientOptions) {
|
||||
this.apps = opts.apps
|
||||
this.host = opts.host
|
||||
this.cache = opts.cache
|
||||
this.now = opts.now ?? (() => new Date())
|
||||
}
|
||||
|
||||
async get(appId: string, fields: readonly AppMetaFieldKey[] = []): Promise<AppMeta> {
|
||||
const cached = this.cache?.get(this.host, appId)
|
||||
if (
|
||||
cached !== undefined &&
|
||||
this.cache?.isFresh(cached, this.now()) === true &&
|
||||
covers(cached.meta, fields)
|
||||
)
|
||||
return cached.meta
|
||||
|
||||
const resp = await this.apps.describe(appId, fields.length === 0 ? undefined : fields)
|
||||
const fresh = fromDescribe(resp, fields)
|
||||
const merged =
|
||||
cached !== undefined && this.cache?.isFresh(cached, this.now()) === true
|
||||
? mergeMeta(cached.meta, fresh)
|
||||
: fresh
|
||||
if (this.cache !== undefined) await this.cache.set(this.host, appId, merged)
|
||||
return merged
|
||||
}
|
||||
|
||||
async invalidate(appId: string): Promise<void> {
|
||||
if (this.cache !== undefined) await this.cache.delete(this.host, appId)
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { ActiveContext } from '@/auth/hosts'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { describe, expect, it } from 'vite-plus/test'
|
||||
import { selectAppReader, SubjectKind, subjectOf } from './app-reader'
|
||||
import { AppsClient } from './apps'
|
||||
import { PermittedExternalAppsClient } from './permitted-external-apps'
|
||||
|
||||
const http = { baseURL: 'https://x', request: async () => new Response() } as unknown as HttpClient
|
||||
|
||||
function ctx(external: boolean): ActiveContext {
|
||||
return {
|
||||
host: 'h',
|
||||
email: 'e',
|
||||
ctx: {
|
||||
account: { id: 'a', email: 'e', name: 'n' },
|
||||
external_subject: external ? { email: 'e', issuer: 'i' } : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('selectAppReader', () => {
|
||||
it('account login → AppsClient', () => {
|
||||
expect(selectAppReader(ctx(false), http)).toBeInstanceOf(AppsClient)
|
||||
expect(subjectOf(ctx(false))).toBe(SubjectKind.Account)
|
||||
})
|
||||
it('external_subject present → PermittedExternalAppsClient', () => {
|
||||
expect(selectAppReader(ctx(true), http)).toBeInstanceOf(PermittedExternalAppsClient)
|
||||
expect(subjectOf(ctx(true))).toBe(SubjectKind.External)
|
||||
})
|
||||
})
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { AppDescribeResponse, AppListResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { ListQuery } from './apps'
|
||||
import type { ActiveContext } from '@/auth/hosts'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { AppsClient } from './apps'
|
||||
import { PermittedExternalAppsClient } from './permitted-external-apps'
|
||||
|
||||
export type AppReader = {
|
||||
list: (q: ListQuery) => Promise<AppListResponse>
|
||||
describe: (appId: string, fields?: readonly string[]) => Promise<AppDescribeResponse>
|
||||
}
|
||||
|
||||
// The auth subject behind an openapi bearer token. Each kind reads apps from its own surface.
|
||||
export const SubjectKind = {
|
||||
Account: 'account',
|
||||
External: 'external',
|
||||
} as const
|
||||
|
||||
export type SubjectKindValue = (typeof SubjectKind)[keyof typeof SubjectKind]
|
||||
|
||||
export function subjectOf(active: ActiveContext): SubjectKindValue {
|
||||
return active.ctx.external_subject !== undefined ? SubjectKind.External : SubjectKind.Account
|
||||
}
|
||||
|
||||
type AppReaderFactory = (http: HttpClient) => AppReader
|
||||
|
||||
// Maps each auth subject to the app reader for its surface.
|
||||
const APP_READER_BY_SUBJECT: Readonly<Record<SubjectKindValue, AppReaderFactory>> = {
|
||||
[SubjectKind.Account]: (http) => new AppsClient(http),
|
||||
[SubjectKind.External]: (http) => new PermittedExternalAppsClient(http),
|
||||
}
|
||||
|
||||
export function selectAppReader(active: ActiveContext, http: HttpClient): AppReader {
|
||||
return APP_READER_BY_SUBJECT[subjectOf(active)](http)
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import type { DifyMock } from '@test/fixtures/dify-mock/server'
|
||||
import { startMock } from '@test/fixtures/dify-mock/server'
|
||||
import { testHttpClient } from '@test/fixtures/http-client'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
|
||||
import { AppRunClient, buildRunBody } from './app-run.js'
|
||||
|
||||
describe('buildRunBody', () => {
|
||||
it('does not include response_mode', () => {
|
||||
expect('response_mode' in buildRunBody({})).toBe(false)
|
||||
})
|
||||
|
||||
it('omits query when message empty', () => {
|
||||
expect('query' in buildRunBody({})).toBe(false)
|
||||
})
|
||||
|
||||
it('maps message → query', () => {
|
||||
expect(buildRunBody({ message: 'hi' }).query).toBe('hi')
|
||||
})
|
||||
|
||||
it('passes through inputs', () => {
|
||||
const body = buildRunBody({ inputs: { a: '1', b: 42 } })
|
||||
expect(body.inputs).toEqual({ a: '1', b: 42 })
|
||||
})
|
||||
|
||||
it('omits conversation_id when missing/empty', () => {
|
||||
expect('conversation_id' in buildRunBody({ conversationId: '' })).toBe(false)
|
||||
})
|
||||
|
||||
it('includes workspace_id when set', () => {
|
||||
expect(buildRunBody({ workspaceId: 'ws-1' }).workspace_id).toBe('ws-1')
|
||||
})
|
||||
|
||||
it('includes workflow_id when workflowId provided', () => {
|
||||
expect(buildRunBody({ workflowId: 'wf-abc' }).workflow_id).toBe('wf-abc')
|
||||
})
|
||||
|
||||
it('omits workflow_id when workflowId empty', () => {
|
||||
expect('workflow_id' in buildRunBody({ workflowId: '' })).toBe(false)
|
||||
})
|
||||
|
||||
it('includes files when provided and non-empty', () => {
|
||||
const files = [{ type: 'image', url: 'http://example.com/img.png' }]
|
||||
expect(buildRunBody({ files }).files).toEqual(files)
|
||||
})
|
||||
|
||||
it('omits files when empty array', () => {
|
||||
expect('files' in buildRunBody({ files: [] })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AppRunClient.runStream', () => {
|
||||
let mock: DifyMock
|
||||
beforeEach(async () => {
|
||||
mock = await startMock({ scenario: 'happy' })
|
||||
})
|
||||
afterEach(async () => {
|
||||
await mock.stop()
|
||||
})
|
||||
|
||||
it('yields events for chat app', async () => {
|
||||
const c = new AppRunClient(testHttpClient(mock.url, 'dfoa_test'))
|
||||
const iter = await c.runStream('app-1', buildRunBody({ message: 'hi' }))
|
||||
const dec = new TextDecoder()
|
||||
const names: string[] = []
|
||||
const datas: string[] = []
|
||||
for await (const ev of iter) {
|
||||
names.push(ev.name)
|
||||
datas.push(dec.decode(ev.data))
|
||||
}
|
||||
expect(names).toEqual(['message', 'message', 'message_end'])
|
||||
expect(datas[0]).toContain('"answer":"echo: "')
|
||||
expect(datas[1]).toContain('"answer":"hi"')
|
||||
})
|
||||
|
||||
it('throws typed BaseError on non-2xx open', async () => {
|
||||
mock.setScenario('server-5xx')
|
||||
const c = new AppRunClient(testHttpClient(mock.url, { bearer: 'dfoa_test', retryAttempts: 0 }))
|
||||
await expect(c.runStream('app-1', buildRunBody({ message: 'hi' }))).rejects.toMatchObject({
|
||||
code: 'server_5xx',
|
||||
})
|
||||
})
|
||||
|
||||
it('aborts when signal fires', async () => {
|
||||
expect.assertions(1)
|
||||
const c = new AppRunClient(testHttpClient(mock.url, 'dfoa_test'))
|
||||
const ctrl = new AbortController()
|
||||
const iter = await c.runStream('app-1', buildRunBody({ message: 'hi' }), {
|
||||
signal: ctrl.signal,
|
||||
})
|
||||
ctrl.abort()
|
||||
try {
|
||||
for await (const _ of iter) {
|
||||
/* drain */
|
||||
}
|
||||
} catch (e) {
|
||||
expect((e as Error).name).toBe('AbortError')
|
||||
}
|
||||
})
|
||||
|
||||
it('derives event name from JSON event field when SSE event line absent', async () => {
|
||||
const c = new AppRunClient(testHttpClient(mock.url, 'dfoa_test'))
|
||||
const iter = await c.runStream('app-2', buildRunBody({ inputs: { x: '1' } }))
|
||||
const names: string[] = []
|
||||
for await (const ev of iter) names.push(ev.name)
|
||||
expect(names).toEqual([
|
||||
'workflow_started',
|
||||
'node_started',
|
||||
'node_finished',
|
||||
'workflow_finished',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('AppRunClient.stopTask', () => {
|
||||
let mock: DifyMock
|
||||
beforeEach(async () => {
|
||||
mock = await startMock({ scenario: 'happy' })
|
||||
})
|
||||
afterEach(async () => {
|
||||
await mock.stop()
|
||||
})
|
||||
|
||||
it('resolves without error for known app and task', async () => {
|
||||
const c = new AppRunClient(testHttpClient(mock.url, 'dfoa_test'))
|
||||
await expect(c.stopTask('app-1', 'task-42')).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AppRunClient.submitHumanInput', () => {
|
||||
let mock: DifyMock
|
||||
beforeEach(async () => {
|
||||
mock = await startMock({ scenario: 'happy' })
|
||||
})
|
||||
afterEach(async () => {
|
||||
await mock.stop()
|
||||
})
|
||||
|
||||
it('resolves without error', async () => {
|
||||
const c = new AppRunClient(testHttpClient(mock.url, 'dfoa_test'))
|
||||
await expect(
|
||||
c.submitHumanInput('app-1', 'tok-abc', 'approve', { comment: 'looks good' }),
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,102 +0,0 @@
|
||||
import type { OpenApiClient } from '@/http/orpc'
|
||||
import type { SseEvent } from '@/http/sse'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { createOpenApiClient } from '@/http/orpc'
|
||||
import { parseSSE } from '@/http/sse'
|
||||
import { normalizeDifyStream } from '@/http/sse-dify'
|
||||
|
||||
export type RunBodyArgs = {
|
||||
readonly message?: string
|
||||
readonly inputs?: Readonly<Record<string, unknown>>
|
||||
readonly conversationId?: string
|
||||
readonly workspaceId?: string
|
||||
readonly workflowId?: string
|
||||
readonly files?: readonly Record<string, unknown>[]
|
||||
}
|
||||
|
||||
export function buildRunBody(args: RunBodyArgs): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = {
|
||||
inputs: args.inputs ?? {},
|
||||
}
|
||||
if (args.message !== undefined && args.message !== '') body.query = args.message
|
||||
if (args.conversationId !== undefined && args.conversationId !== '')
|
||||
body.conversation_id = args.conversationId
|
||||
if (args.workspaceId !== undefined && args.workspaceId !== '')
|
||||
body.workspace_id = args.workspaceId
|
||||
if (args.workflowId !== undefined && args.workflowId !== '') body.workflow_id = args.workflowId
|
||||
if (args.files !== undefined && args.files.length > 0) body.files = args.files
|
||||
return body
|
||||
}
|
||||
|
||||
export type StreamOptions = {
|
||||
signal?: AbortSignal
|
||||
includeStateSnapshot?: boolean
|
||||
retryOnRateLimit?: boolean
|
||||
}
|
||||
|
||||
export class AppRunClient {
|
||||
private readonly http: HttpClient
|
||||
private readonly orpc: OpenApiClient
|
||||
|
||||
constructor(http: HttpClient) {
|
||||
this.http = http
|
||||
// Mixed class (SPEC §4.4): runStream / reconnectStream are SSE and stay on the raw
|
||||
// `http.stream` facade; stopTask / submitHumanInput are plain JSON and go through the
|
||||
// generated oRPC contract. Both facades share this one transport.
|
||||
this.orpc = createOpenApiClient(http)
|
||||
}
|
||||
|
||||
async runStream(
|
||||
appId: string,
|
||||
body: Record<string, unknown>,
|
||||
opts: StreamOptions = {},
|
||||
): Promise<AsyncIterable<SseEvent>> {
|
||||
const res = await this.http.stream(`apps/${encodeURIComponent(appId)}:run`, {
|
||||
method: 'POST',
|
||||
json: body,
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
signal: opts.signal,
|
||||
throwOnError: true,
|
||||
retryOnRateLimit: opts.retryOnRateLimit,
|
||||
})
|
||||
if (res.body === null) throw new Error('streaming response body missing')
|
||||
return normalizeDifyStream(parseSSE(res.body, opts.signal))
|
||||
}
|
||||
|
||||
async stopTask(appId: string, taskId: string): Promise<void> {
|
||||
await this.orpc.apps.byAppId.tasks.byTaskId.stop.post({
|
||||
params: { app_id: appId, task_id: taskId },
|
||||
})
|
||||
}
|
||||
|
||||
async submitHumanInput(
|
||||
appId: string,
|
||||
formToken: string,
|
||||
action: string,
|
||||
inputs: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await this.orpc.apps.byAppId.humanInputForms.byFormToken.submit.post({
|
||||
params: { app_id: appId, form_token: formToken },
|
||||
body: { action, inputs },
|
||||
})
|
||||
}
|
||||
|
||||
async reconnectStream(
|
||||
appId: string,
|
||||
workflowRunId: string,
|
||||
opts: StreamOptions = {},
|
||||
): Promise<AsyncIterable<SseEvent>> {
|
||||
const url = `apps/${encodeURIComponent(appId)}/tasks/${encodeURIComponent(workflowRunId)}/events`
|
||||
const res = await this.http.stream(url, {
|
||||
searchParams: {
|
||||
include_state_snapshot: opts.includeStateSnapshot === true ? 'true' : 'false',
|
||||
continue_on_pause: 'false',
|
||||
},
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
signal: opts.signal,
|
||||
throwOnError: true,
|
||||
})
|
||||
if (res.body === null) throw new Error('reconnect stream body missing')
|
||||
return normalizeDifyStream(parseSSE(res.body, opts.signal))
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import type { StubServer } from '@test/fixtures/stub-server'
|
||||
import { testHttpClient } from '@test/fixtures/http-client'
|
||||
import { jsonResponder, startStubServer } from '@test/fixtures/stub-server'
|
||||
import { afterEach, describe, expect, it } from 'vite-plus/test'
|
||||
import { isHttpClientError } from '@/errors/base'
|
||||
import { AppsClient } from './apps.js'
|
||||
|
||||
const LIST_BODY = { page: 1, limit: 20, total: 0, has_more: false, data: [] }
|
||||
const DESCRIBE_BODY = {
|
||||
info: { id: 'app-1', name: 'Demo', mode: 'chat', service_api_enabled: true },
|
||||
}
|
||||
|
||||
function makeClient(host: string): AppsClient {
|
||||
return new AppsClient(testHttpClient(host, 'dfoa_test'))
|
||||
}
|
||||
|
||||
function queryOf(url: string | undefined): URLSearchParams {
|
||||
return new URL(url ?? '', 'http://x').searchParams
|
||||
}
|
||||
|
||||
describe('AppsClient.list', () => {
|
||||
let stub: StubServer
|
||||
|
||||
afterEach(async () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
it('defaults page=1 & limit=20 and always sends workspace_id', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(200, LIST_BODY, cap))
|
||||
|
||||
await makeClient(stub.url).list({ workspaceId: 'ws-1' })
|
||||
|
||||
const q = queryOf(stub.captured.url)
|
||||
expect(stub.captured.method).toBe('GET')
|
||||
expect(q.get('workspace_id')).toBe('ws-1')
|
||||
expect(q.get('page')).toBe('1')
|
||||
expect(q.get('limit')).toBe('20')
|
||||
// Optional filters are omitted entirely when not supplied.
|
||||
expect(q.has('mode')).toBe(false)
|
||||
expect(q.has('name')).toBe(false)
|
||||
})
|
||||
|
||||
it('forwards explicit pagination and filters', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(200, LIST_BODY, cap))
|
||||
|
||||
await makeClient(stub.url).list({
|
||||
workspaceId: 'ws-1',
|
||||
page: 3,
|
||||
limit: 50,
|
||||
mode: 'chat',
|
||||
name: 'support bot',
|
||||
})
|
||||
|
||||
const q = queryOf(stub.captured.url)
|
||||
expect(q.get('page')).toBe('3')
|
||||
expect(q.get('limit')).toBe('50')
|
||||
expect(q.get('mode')).toBe('chat')
|
||||
expect(q.get('name')).toBe('support bot')
|
||||
})
|
||||
|
||||
it('treats empty-string filters as absent (not blank query params)', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(200, LIST_BODY, cap))
|
||||
|
||||
await makeClient(stub.url).list({ workspaceId: 'ws-1', mode: '', name: '' })
|
||||
|
||||
const q = queryOf(stub.captured.url)
|
||||
expect(q.has('mode')).toBe(false)
|
||||
expect(q.has('name')).toBe(false)
|
||||
})
|
||||
|
||||
it('propagates server 403 as a classified BaseError', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(403, { error: 'forbidden' }, cap))
|
||||
|
||||
await expect(makeClient(stub.url).list({ workspaceId: 'ws-1' })).rejects.toSatisfy(
|
||||
(err) => isHttpClientError(err) && err.httpStatus === 403,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AppsClient.describe', () => {
|
||||
let stub: StubServer
|
||||
|
||||
afterEach(async () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
it('hits /apps/<id>, omits workspace_id and fields when not given', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(200, DESCRIBE_BODY, cap))
|
||||
|
||||
const res = await makeClient(stub.url).describe('app-1')
|
||||
|
||||
expect(stub.captured.url?.split('?')[0]).toBe('/openapi/v1/apps/app-1')
|
||||
const q = queryOf(stub.captured.url)
|
||||
expect(q.has('workspace_id')).toBe(false)
|
||||
expect(q.has('fields')).toBe(false)
|
||||
expect(res.info?.id).toBe('app-1')
|
||||
})
|
||||
|
||||
it('joins fields with commas', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(200, DESCRIBE_BODY, cap))
|
||||
|
||||
await makeClient(stub.url).describe('app-1', ['parameters', 'input_schema'])
|
||||
|
||||
expect(queryOf(stub.captured.url).get('fields')).toBe('parameters,input_schema')
|
||||
})
|
||||
|
||||
it('URL-encodes the app id', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(200, DESCRIBE_BODY, cap))
|
||||
|
||||
await makeClient(stub.url).describe('app/with space')
|
||||
|
||||
expect(stub.captured.url?.split('?')[0]).toBe('/openapi/v1/apps/app%2Fwith%20space')
|
||||
})
|
||||
})
|
||||
@@ -1,53 +0,0 @@
|
||||
import type {
|
||||
AppDescribeResponse,
|
||||
AppListResponse,
|
||||
SupportedAppType,
|
||||
} from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { AppReader } from './app-reader'
|
||||
import type { OpenApiClient } from '@/http/orpc'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { createOpenApiClient } from '@/http/orpc'
|
||||
|
||||
export type ListQuery = {
|
||||
readonly workspaceId: string
|
||||
readonly page?: number
|
||||
readonly limit?: number
|
||||
readonly mode?: SupportedAppType | ''
|
||||
readonly name?: string
|
||||
}
|
||||
|
||||
// An absent or empty mode filter means "any mode" — collapse both to undefined for the query.
|
||||
export function normalizeMode(
|
||||
mode: SupportedAppType | '' | undefined,
|
||||
): SupportedAppType | undefined {
|
||||
return mode !== undefined && mode !== '' ? mode : undefined
|
||||
}
|
||||
|
||||
export class AppsClient implements AppReader {
|
||||
private readonly orpc: OpenApiClient
|
||||
|
||||
constructor(http: HttpClient) {
|
||||
this.orpc = createOpenApiClient(http)
|
||||
}
|
||||
|
||||
async list(q: ListQuery): Promise<AppListResponse> {
|
||||
return this.orpc.apps.get({
|
||||
query: {
|
||||
workspace_id: q.workspaceId,
|
||||
page: q.page ?? 1,
|
||||
limit: q.limit ?? 20,
|
||||
mode: normalizeMode(q.mode),
|
||||
name: q.name !== undefined && q.name !== '' ? q.name : undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async describe(appId: string, fields?: readonly string[]): Promise<AppDescribeResponse> {
|
||||
return this.orpc.apps.byAppId.get({
|
||||
params: { app_id: appId },
|
||||
query: {
|
||||
fields: fields !== undefined && fields.length > 0 ? fields.join(',') : undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
import type { DifyMock } from '@test/fixtures/dify-mock/server'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import type { CodeResponse } from './oauth-device.js'
|
||||
import { Buffer } from 'node:buffer'
|
||||
import * as http from 'node:http'
|
||||
import { startMock } from '@test/fixtures/dify-mock/server'
|
||||
import { testHttpClient } from '@test/fixtures/http-client'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
|
||||
import { isBaseError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { DEFAULT_CLIENT_ID, DeviceFlowApi } from './oauth-device.js'
|
||||
|
||||
type StubServer = {
|
||||
url: string
|
||||
stop: () => Promise<void>
|
||||
}
|
||||
|
||||
function startStub(
|
||||
handler: (req: http.IncomingMessage, res: http.ServerResponse) => void,
|
||||
): Promise<StubServer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = http.createServer(handler)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const addr = server.address() as AddressInfo
|
||||
resolve({
|
||||
url: `http://127.0.0.1:${addr.port}`,
|
||||
stop: () =>
|
||||
new Promise<void>((res, rej) => server.close((err) => (err ? rej(err) : res()))),
|
||||
})
|
||||
})
|
||||
server.on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
function jsonStub(
|
||||
status: number,
|
||||
body: unknown,
|
||||
): (req: http.IncomingMessage, res: http.ServerResponse) => void {
|
||||
return (_req, res) => {
|
||||
const payload = JSON.stringify(body)
|
||||
res.writeHead(status, {
|
||||
'content-type': 'application/json',
|
||||
'content-length': Buffer.byteLength(payload),
|
||||
})
|
||||
res.end(payload)
|
||||
}
|
||||
}
|
||||
|
||||
function makeApi(mock: DifyMock): DeviceFlowApi {
|
||||
return new DeviceFlowApi(testHttpClient(mock.url))
|
||||
}
|
||||
|
||||
describe('DeviceFlowApi.requestCode', () => {
|
||||
let mock: DifyMock
|
||||
|
||||
beforeEach(async () => {
|
||||
mock = await startMock()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await mock.stop()
|
||||
})
|
||||
|
||||
it('POSTs to /openapi/v1/oauth/device/code with default client_id', async () => {
|
||||
const api = makeApi(mock)
|
||||
const out = await api.requestCode({ device_label: 'difyctl on host' })
|
||||
expect(out.user_code).toBe('ABCD-1234')
|
||||
expect(out.device_code).toBeDefined()
|
||||
expect(DEFAULT_CLIENT_ID).toBe('difyctl')
|
||||
})
|
||||
|
||||
it('strips trailing slash from host', async () => {
|
||||
const api = new DeviceFlowApi(testHttpClient(`${mock.url}/`))
|
||||
const out = await api.requestCode({ device_label: 'l' })
|
||||
expect(out.device_code).toBeDefined()
|
||||
})
|
||||
|
||||
it('throws BaseError(unsupported_endpoint) on 404', async () => {
|
||||
let stub: StubServer | undefined
|
||||
try {
|
||||
stub = await startStub(jsonStub(404, {}))
|
||||
const api = new DeviceFlowApi(testHttpClient(stub.url))
|
||||
let caught: unknown
|
||||
try {
|
||||
await api.requestCode({ device_label: 'l' })
|
||||
} catch (e) {
|
||||
caught = e
|
||||
}
|
||||
expect(isBaseError(caught)).toBe(true)
|
||||
if (isBaseError(caught)) expect(caught.code).toBe(ErrorCode.UnsupportedEndpoint)
|
||||
} finally {
|
||||
await stub?.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects empty device_label', async () => {
|
||||
const api = makeApi(mock)
|
||||
await expect(api.requestCode({ device_label: '' })).rejects.toThrow(/device_label/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DeviceFlowApi.pollOnce', () => {
|
||||
let mock: DifyMock
|
||||
|
||||
beforeEach(async () => {
|
||||
mock = await startMock()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await mock.stop()
|
||||
})
|
||||
|
||||
it('returns approved with token on 200', async () => {
|
||||
const api = makeApi(mock)
|
||||
const r = await api.pollOnce({ device_code: 'devcode-1' })
|
||||
expect(r.status).toBe('approved')
|
||||
if (r.status === 'approved') expect(r.success.token).toBe('dfoa_test')
|
||||
})
|
||||
|
||||
it('maps authorization_pending to pending', async () => {
|
||||
let stub: StubServer | undefined
|
||||
try {
|
||||
stub = await startStub(jsonStub(400, { error: 'authorization_pending' }))
|
||||
const api = new DeviceFlowApi(testHttpClient(stub.url))
|
||||
const r = await api.pollOnce({ device_code: 'dc' })
|
||||
expect(r.status).toBe('pending')
|
||||
} finally {
|
||||
await stub?.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('maps slow_down to slow_down', async () => {
|
||||
mock.setScenario('slow-down')
|
||||
const api = makeApi(mock)
|
||||
const r = await api.pollOnce({ device_code: 'devcode-1' })
|
||||
expect(r.status).toBe('slow_down')
|
||||
})
|
||||
|
||||
it('maps expired_token to expired', async () => {
|
||||
mock.setScenario('expired')
|
||||
const api = makeApi(mock)
|
||||
const r = await api.pollOnce({ device_code: 'devcode-1' })
|
||||
expect(r.status).toBe('expired')
|
||||
})
|
||||
|
||||
it('maps access_denied to denied', async () => {
|
||||
mock.setScenario('denied')
|
||||
const api = makeApi(mock)
|
||||
const r = await api.pollOnce({ device_code: 'devcode-1' })
|
||||
expect(r.status).toBe('denied')
|
||||
})
|
||||
|
||||
it('throws BaseError(unsupported_endpoint) on 404', async () => {
|
||||
let stub: StubServer | undefined
|
||||
try {
|
||||
stub = await startStub(jsonStub(404, {}))
|
||||
const api = new DeviceFlowApi(testHttpClient(stub.url))
|
||||
await expect(api.pollOnce({ device_code: 'dc' })).rejects.toThrow(/device flow/i)
|
||||
} finally {
|
||||
await stub?.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('signals retryable on 5xx', async () => {
|
||||
mock.setScenario('server-5xx')
|
||||
const api = makeApi(mock)
|
||||
const r = await api.pollOnce({ device_code: 'devcode-1' })
|
||||
expect(r.status).toBe('retry_5xx')
|
||||
})
|
||||
|
||||
it('rejects 200 with empty body', async () => {
|
||||
let stub: StubServer | undefined
|
||||
try {
|
||||
stub = await startStub(jsonStub(200, {}))
|
||||
const api = new DeviceFlowApi(testHttpClient(stub.url))
|
||||
await expect(api.pollOnce({ device_code: 'dc' })).rejects.toThrow(/no OAuth envelope|token/i)
|
||||
} finally {
|
||||
await stub?.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects unknown error code', async () => {
|
||||
let stub: StubServer | undefined
|
||||
try {
|
||||
stub = await startStub(jsonStub(400, { error: 'something_else' }))
|
||||
const api = new DeviceFlowApi(testHttpClient(stub.url))
|
||||
await expect(api.pollOnce({ device_code: 'dc' })).rejects.toThrow(/unknown poll error/)
|
||||
} finally {
|
||||
await stub?.stop()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves dfoe_ token kind in approved branch', async () => {
|
||||
mock.setScenario('sso')
|
||||
const api = makeApi(mock)
|
||||
const r = await api.pollOnce({ device_code: 'devcode-1' })
|
||||
expect(r.status).toBe('approved')
|
||||
if (r.status === 'approved') {
|
||||
expect(r.success.token).toBe('dfoe_test')
|
||||
expect(r.success.subject_type).toBe('external_sso')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('DeviceFlowApi types', () => {
|
||||
it('CodeResponse has required fields', () => {
|
||||
const r: CodeResponse = {
|
||||
device_code: 'd',
|
||||
user_code: 'u',
|
||||
verification_uri: 'v',
|
||||
expires_in: 1,
|
||||
interval: 1,
|
||||
}
|
||||
expect(r.device_code).toBe('d')
|
||||
})
|
||||
})
|
||||
@@ -1,76 +0,0 @@
|
||||
import type { StubServer } from '@test/fixtures/stub-server'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { testHttpClient } from '@test/fixtures/http-client'
|
||||
import { jsonResponder, startStubServer } from '@test/fixtures/stub-server'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
|
||||
import { isHttpClientError } from '@/errors/base'
|
||||
import { FileUploadClient } from './file-upload.js'
|
||||
|
||||
const UPLOADED = {
|
||||
id: 'file-1',
|
||||
name: 'hello.png',
|
||||
size: 5,
|
||||
extension: 'png',
|
||||
mime_type: 'image/png',
|
||||
}
|
||||
|
||||
function makeClient(host: string): FileUploadClient {
|
||||
return new FileUploadClient(testHttpClient(host, 'dfoa_test'))
|
||||
}
|
||||
|
||||
describe('FileUploadClient.upload', () => {
|
||||
let stub: StubServer
|
||||
let dir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-upload-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await stub?.stop()
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('POSTs multipart/form-data (boundary intact, no JSON content-type) and returns the parsed file', async () => {
|
||||
const filePath = join(dir, 'hello.png')
|
||||
await writeFile(filePath, 'hello')
|
||||
stub = await startStubServer((cap) => jsonResponder(200, UPLOADED, cap))
|
||||
|
||||
const result = await makeClient(stub.url).upload('app-1', filePath)
|
||||
|
||||
expect(stub.captured.method).toBe('POST')
|
||||
expect(stub.captured.url).toBe('/openapi/v1/apps/app-1/files')
|
||||
// The client must let fetch own the multipart Content-Type + boundary; it
|
||||
// must NOT coerce this to application/json the way a json body would.
|
||||
const contentType = stub.captured.headers?.['content-type'] ?? ''
|
||||
expect(contentType).toMatch(/^multipart\/form-data; boundary=/)
|
||||
// Bearer still applied on the upload path.
|
||||
expect(stub.captured.headers?.authorization).toBe('Bearer dfoa_test')
|
||||
// The multipart payload carries the filename and the file bytes.
|
||||
expect(stub.captured.body).toContain('filename="hello.png"')
|
||||
expect(stub.captured.body).toContain('hello')
|
||||
expect(result.id).toBe('file-1')
|
||||
})
|
||||
|
||||
it('encodes the app id in the path', async () => {
|
||||
const filePath = join(dir, 'a.txt')
|
||||
await writeFile(filePath, 'x')
|
||||
stub = await startStubServer((cap) => jsonResponder(200, UPLOADED, cap))
|
||||
|
||||
await makeClient(stub.url).upload('app/with space', filePath)
|
||||
|
||||
expect(stub.captured.url).toBe('/openapi/v1/apps/app%2Fwith%20space/files')
|
||||
})
|
||||
|
||||
it('propagates a server 413 as a classified BaseError', async () => {
|
||||
const filePath = join(dir, 'big.bin')
|
||||
await writeFile(filePath, 'data')
|
||||
stub = await startStubServer((cap) => jsonResponder(413, { error: 'file too large' }, cap))
|
||||
|
||||
await expect(makeClient(stub.url).upload('app-1', filePath)).rejects.toSatisfy(
|
||||
(err) => isHttpClientError(err) && err.httpStatus === 413,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,72 +0,0 @@
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { basename, extname } from 'node:path'
|
||||
|
||||
export type UploadedFile = {
|
||||
id: string
|
||||
name: string
|
||||
size: number
|
||||
extension: string | null
|
||||
mime_type: string | null
|
||||
}
|
||||
|
||||
const MIME_MAP: Record<string, string> = {
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
webp: 'image/webp',
|
||||
gif: 'image/gif',
|
||||
svg: 'image/svg+xml',
|
||||
mp3: 'audio/mpeg',
|
||||
m4a: 'audio/mp4',
|
||||
wav: 'audio/wav',
|
||||
amr: 'audio/amr',
|
||||
mpga: 'audio/mpeg',
|
||||
mp4: 'video/mp4',
|
||||
mov: 'video/quicktime',
|
||||
mpeg: 'video/mpeg',
|
||||
webm: 'video/webm',
|
||||
pdf: 'application/pdf',
|
||||
txt: 'text/plain',
|
||||
md: 'text/markdown',
|
||||
markdown: 'text/markdown',
|
||||
mdx: 'text/markdown',
|
||||
csv: 'text/csv',
|
||||
html: 'text/html',
|
||||
htm: 'text/html',
|
||||
xml: 'application/xml',
|
||||
epub: 'application/epub+zip',
|
||||
vtt: 'text/vtt',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
ppt: 'application/vnd.ms-powerpoint',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
}
|
||||
|
||||
function mimeFromFilename(filename: string): string {
|
||||
const ext = extname(filename).replace(/^\./, '').toLowerCase()
|
||||
return MIME_MAP[ext] ?? 'application/octet-stream'
|
||||
}
|
||||
|
||||
export class FileUploadClient {
|
||||
private readonly http: HttpClient
|
||||
|
||||
constructor(http: HttpClient) {
|
||||
this.http = http
|
||||
}
|
||||
|
||||
async upload(appId: string, filePath: string): Promise<UploadedFile> {
|
||||
const filename = basename(filePath)
|
||||
const content = await readFile(filePath)
|
||||
const blob = new Blob([content], { type: mimeFromFilename(filename) })
|
||||
const form = new FormData()
|
||||
form.append('file', blob, filename)
|
||||
|
||||
return this.http.post<UploadedFile>(`apps/${encodeURIComponent(appId)}/files`, {
|
||||
body: form,
|
||||
timeoutMs: 60_000,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
import type { StubServer } from '@test/fixtures/stub-server'
|
||||
import { testHttpClient } from '@test/fixtures/http-client'
|
||||
import { jsonResponder, startStubServer } from '@test/fixtures/stub-server'
|
||||
import { afterEach, describe, expect, it } from 'vite-plus/test'
|
||||
import { isHttpClientError } from '@/errors/base'
|
||||
import { MembersClient } from './members.js'
|
||||
import { WorkspacesClient } from './workspaces.js'
|
||||
|
||||
function makeClient(host: string): MembersClient {
|
||||
return new MembersClient(testHttpClient(host, 'dfoa_test'))
|
||||
}
|
||||
|
||||
describe('MembersClient.list', () => {
|
||||
let stub: StubServer
|
||||
|
||||
afterEach(async () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
it('GETs /workspaces/<id>/members and returns parsed envelope', async () => {
|
||||
stub = await startStubServer((cap) =>
|
||||
jsonResponder(
|
||||
200,
|
||||
{
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 1,
|
||||
has_more: false,
|
||||
data: [{ id: 'm-1', name: 'Mia', email: 'mia@e.com', role: 'admin', status: 'active' }],
|
||||
},
|
||||
cap,
|
||||
),
|
||||
)
|
||||
|
||||
const result = await makeClient(stub.url).list('ws-1')
|
||||
|
||||
expect(stub.captured.method).toBe('GET')
|
||||
expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/members')
|
||||
expect(result.data[0]?.email).toBe('mia@e.com')
|
||||
})
|
||||
|
||||
it('URL-encodes workspace id', async () => {
|
||||
stub = await startStubServer((cap) =>
|
||||
jsonResponder(200, { page: 1, limit: 20, total: 0, has_more: false, data: [] }, cap),
|
||||
)
|
||||
|
||||
await makeClient(stub.url).list('ws with space')
|
||||
|
||||
expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws%20with%20space/members')
|
||||
})
|
||||
|
||||
it('forwards page/limit as query params', async () => {
|
||||
stub = await startStubServer((cap) =>
|
||||
jsonResponder(200, { page: 2, limit: 50, total: 0, has_more: false, data: [] }, cap),
|
||||
)
|
||||
|
||||
await makeClient(stub.url).list('ws-1', { page: 2, limit: 50 })
|
||||
|
||||
expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/members?page=2&limit=50')
|
||||
})
|
||||
|
||||
it('propagates server 403 as classified BaseError', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(403, { error: 'forbidden' }, cap))
|
||||
|
||||
await expect(makeClient(stub.url).list('ws-1')).rejects.toSatisfy(
|
||||
(err) => isHttpClientError(err) && err.httpStatus === 403,
|
||||
)
|
||||
})
|
||||
|
||||
it('propagates 404 as classified BaseError', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(404, { error: 'not found' }, cap))
|
||||
|
||||
await expect(makeClient(stub.url).list('ws-missing')).rejects.toSatisfy(
|
||||
(err) => isHttpClientError(err) && err.httpStatus === 404,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MembersClient.invite', () => {
|
||||
let stub: StubServer
|
||||
|
||||
afterEach(async () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
it('POSTs JSON body and returns parsed invite response', async () => {
|
||||
stub = await startStubServer((cap) =>
|
||||
jsonResponder(
|
||||
201,
|
||||
{
|
||||
result: 'success',
|
||||
email: 'new@e.com',
|
||||
role: 'normal',
|
||||
member_id: 'acct-9',
|
||||
invite_url: 'https://console.example.com/activate?email=new&token=tok',
|
||||
tenant_id: 'ws-1',
|
||||
},
|
||||
cap,
|
||||
),
|
||||
)
|
||||
|
||||
const result = await makeClient(stub.url).invite('ws-1', {
|
||||
email: 'new@e.com',
|
||||
role: 'normal',
|
||||
})
|
||||
|
||||
expect(stub.captured.method).toBe('POST')
|
||||
expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/members')
|
||||
expect(JSON.parse(stub.captured.body ?? '{}')).toEqual({
|
||||
email: 'new@e.com',
|
||||
role: 'normal',
|
||||
})
|
||||
expect(result.member_id).toBe('acct-9')
|
||||
expect(result.invite_url).toContain('token=tok')
|
||||
})
|
||||
|
||||
it('propagates 400 (already in tenant) as classified BaseError', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(400, { error: 'already in tenant' }, cap))
|
||||
|
||||
await expect(
|
||||
makeClient(stub.url).invite('ws-1', { email: 'u@e.com', role: 'normal' }),
|
||||
).rejects.toSatisfy((err) => isHttpClientError(err) && err.httpStatus === 400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MembersClient.remove', () => {
|
||||
let stub: StubServer
|
||||
|
||||
afterEach(async () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
it('DELETEs member by id and returns success', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(200, { result: 'success' }, cap))
|
||||
|
||||
const result = await makeClient(stub.url).remove('ws-1', 'm-1')
|
||||
|
||||
expect(stub.captured.method).toBe('DELETE')
|
||||
expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/members/m-1')
|
||||
expect(result.result).toBe('success')
|
||||
})
|
||||
|
||||
it('propagates 400 (cannot operate self / cannot remove owner)', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(400, { error: 'cannot operate self' }, cap))
|
||||
|
||||
await expect(makeClient(stub.url).remove('ws-1', 'm-1')).rejects.toSatisfy(
|
||||
(err) => isHttpClientError(err) && err.httpStatus === 400,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MembersClient.updateRole', () => {
|
||||
let stub: StubServer
|
||||
|
||||
afterEach(async () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
it('PATCHes role payload to the member resource', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(200, { result: 'success' }, cap))
|
||||
|
||||
const result = await makeClient(stub.url).updateRole('ws-1', 'm-1', { role: 'admin' })
|
||||
|
||||
expect(stub.captured.method).toBe('PATCH')
|
||||
expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/members/m-1')
|
||||
expect(JSON.parse(stub.captured.body ?? '{}')).toEqual({ role: 'admin' })
|
||||
expect(result.result).toBe('success')
|
||||
})
|
||||
|
||||
it('propagates 400 (admin cannot demote owner)', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(400, { error: 'no permission' }, cap))
|
||||
|
||||
await expect(
|
||||
makeClient(stub.url).updateRole('ws-1', 'm-1', { role: 'admin' }),
|
||||
).rejects.toSatisfy((err) => isHttpClientError(err) && err.httpStatus === 400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorkspacesClient.switch (integration with stub)', () => {
|
||||
let stub: StubServer
|
||||
|
||||
afterEach(async () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
it('POSTs /workspaces/<id>:switch and returns workspace detail', async () => {
|
||||
stub = await startStubServer((cap) =>
|
||||
jsonResponder(
|
||||
200,
|
||||
{
|
||||
id: 'ws-1',
|
||||
name: 'Workspace 1',
|
||||
role: 'owner',
|
||||
status: 'normal',
|
||||
current: true,
|
||||
created_at: '2026-05-18T00:00:00Z',
|
||||
},
|
||||
cap,
|
||||
),
|
||||
)
|
||||
|
||||
const client = new WorkspacesClient(testHttpClient(stub.url, 'dfoa_test'))
|
||||
const result = await client.switch('ws-1')
|
||||
|
||||
expect(stub.captured.method).toBe('POST')
|
||||
expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1:switch')
|
||||
expect(result.current).toBe(true)
|
||||
})
|
||||
|
||||
it('propagates 404 (non-member)', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(404, { error: 'not found' }, cap))
|
||||
|
||||
const client = new WorkspacesClient(testHttpClient(stub.url, 'dfoa_test'))
|
||||
await expect(client.switch('ws-x')).rejects.toSatisfy(
|
||||
(err) => isHttpClientError(err) && err.httpStatus === 404,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,58 +0,0 @@
|
||||
import type {
|
||||
MemberActionResponse,
|
||||
MemberInvitePayload,
|
||||
MemberInviteResponse,
|
||||
MemberListResponse,
|
||||
MemberRoleUpdatePayload,
|
||||
} from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { OpenApiClient } from '@/http/orpc'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { createOpenApiClient } from '@/http/orpc'
|
||||
|
||||
/**
|
||||
* Thin client for /openapi/v1/workspaces/<id>/members, over the generated oRPC contract.
|
||||
*
|
||||
* Non-2xx (400/403/404/422) surface as BaseError — the oRPC client maps them at the transport
|
||||
* seam. The CLI's AuthedCommand base layer renders those for the user; clients never swallow codes.
|
||||
*/
|
||||
export class MembersClient {
|
||||
private readonly orpc: OpenApiClient
|
||||
|
||||
constructor(http: HttpClient) {
|
||||
this.orpc = createOpenApiClient(http)
|
||||
}
|
||||
|
||||
async list(
|
||||
workspaceId: string,
|
||||
q?: { page?: number; limit?: number },
|
||||
): Promise<MemberListResponse> {
|
||||
return this.orpc.workspaces.byWorkspaceId.members.get({
|
||||
params: { workspace_id: workspaceId },
|
||||
query: { page: q?.page, limit: q?.limit },
|
||||
})
|
||||
}
|
||||
|
||||
async invite(workspaceId: string, payload: MemberInvitePayload): Promise<MemberInviteResponse> {
|
||||
return this.orpc.workspaces.byWorkspaceId.members.post({
|
||||
params: { workspace_id: workspaceId },
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
|
||||
async remove(workspaceId: string, memberId: string): Promise<MemberActionResponse> {
|
||||
return this.orpc.workspaces.byWorkspaceId.members.byMemberId.delete({
|
||||
params: { workspace_id: workspaceId, member_id: memberId },
|
||||
})
|
||||
}
|
||||
|
||||
async updateRole(
|
||||
workspaceId: string,
|
||||
memberId: string,
|
||||
payload: MemberRoleUpdatePayload,
|
||||
): Promise<MemberActionResponse> {
|
||||
return this.orpc.workspaces.byWorkspaceId.members.byMemberId.patch({
|
||||
params: { workspace_id: workspaceId, member_id: memberId },
|
||||
body: payload,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { DifyMock } from '@test/fixtures/dify-mock/server'
|
||||
import { startMock } from '@test/fixtures/dify-mock/server'
|
||||
import { testHttpClient } from '@test/fixtures/http-client'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
|
||||
import { MetaClient } from './meta.js'
|
||||
|
||||
describe('MetaClient', () => {
|
||||
let mock: DifyMock
|
||||
|
||||
beforeEach(async () => {
|
||||
mock = await startMock()
|
||||
})
|
||||
afterEach(async () => {
|
||||
await mock.stop()
|
||||
})
|
||||
|
||||
it('fetches /openapi/v1/_version without a bearer token', async () => {
|
||||
const client = new MetaClient(testHttpClient(mock.url))
|
||||
const info = await client.serverVersion()
|
||||
|
||||
expect(info.version).toBe('1.6.4')
|
||||
expect(info.edition).toBe('CLOUD')
|
||||
})
|
||||
|
||||
it('honors the auth-expired scenario by allowing the unauthed endpoint anyway', async () => {
|
||||
mock.setScenario('auth-expired')
|
||||
const client = new MetaClient(testHttpClient(mock.url))
|
||||
const info = await client.serverVersion()
|
||||
|
||||
// The meta endpoint is exempt from auth middleware, so an auth-expired
|
||||
// session does not stop the version probe.
|
||||
expect(info.version).toBe('1.6.4')
|
||||
})
|
||||
|
||||
it('returns an empty version string when the server scenario forces it', async () => {
|
||||
mock.setScenario('server-version-empty')
|
||||
const client = new MetaClient(testHttpClient(mock.url))
|
||||
const info = await client.serverVersion()
|
||||
|
||||
expect(info.version).toBe('')
|
||||
expect(info.edition).toBe('COMMUNITY')
|
||||
})
|
||||
|
||||
it('throws when the host has no Dify on it', async () => {
|
||||
// Closed port — connection refused.
|
||||
const client = new MetaClient(testHttpClient('http://127.0.0.1:1'))
|
||||
await expect(client.serverVersion()).rejects.toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { OpenApiClient } from '@/http/orpc'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { createOpenApiClient } from '@/http/orpc'
|
||||
|
||||
// Used by every /_version probe call site (the version command and the
|
||||
// per-command auto-nudge). Both must construct their HTTP client with this
|
||||
// timeout + retryAttempts: 0, otherwise the default 30s/3-retry budget kicks in.
|
||||
// The oRPC client below inherits that http instance's policy, so the probe timeout still holds.
|
||||
export const META_PROBE_TIMEOUT_MS = 2000
|
||||
|
||||
export class MetaClient {
|
||||
private readonly orpc: OpenApiClient
|
||||
|
||||
constructor(http: HttpClient) {
|
||||
this.orpc = createOpenApiClient(http)
|
||||
}
|
||||
|
||||
async serverVersion(): Promise<ServerVersionResponse> {
|
||||
return this.orpc.version.get()
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { BaseError, HttpClientError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
|
||||
export const DEFAULT_CLIENT_ID = 'difyctl'
|
||||
|
||||
export type CodeRequest = {
|
||||
client_id?: string
|
||||
device_label: string
|
||||
}
|
||||
|
||||
export type CodeResponse = {
|
||||
device_code: string
|
||||
user_code: string
|
||||
verification_uri: string
|
||||
expires_in: number
|
||||
interval: number
|
||||
}
|
||||
|
||||
export type PollRequest = {
|
||||
client_id?: string
|
||||
device_code: string
|
||||
}
|
||||
|
||||
export type PollAccount = {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export type PollWorkspace = {
|
||||
id: string
|
||||
name: string
|
||||
role: string
|
||||
}
|
||||
|
||||
export type PollSuccess = {
|
||||
token: string
|
||||
expires_at?: string
|
||||
subject_type?: string
|
||||
subject_email?: string
|
||||
subject_issuer?: string
|
||||
account?: PollAccount | null
|
||||
workspaces?: readonly PollWorkspace[]
|
||||
default_workspace_id?: string
|
||||
token_id?: string
|
||||
}
|
||||
|
||||
export type PollResult =
|
||||
| { status: 'pending' }
|
||||
| { status: 'slow_down' }
|
||||
| { status: 'expired' }
|
||||
| { status: 'denied' }
|
||||
| { status: 'retry_5xx' }
|
||||
| { status: 'approved'; success: PollSuccess }
|
||||
|
||||
const POLL_ERROR_TO_STATUS: Record<string, PollResult['status']> = {
|
||||
authorization_pending: 'pending',
|
||||
slow_down: 'slow_down',
|
||||
expired_token: 'expired',
|
||||
access_denied: 'denied',
|
||||
}
|
||||
|
||||
export class DeviceFlowApi {
|
||||
private readonly http: HttpClient
|
||||
|
||||
constructor(http: HttpClient) {
|
||||
this.http = http
|
||||
}
|
||||
|
||||
async requestCode(req: CodeRequest): Promise<CodeResponse> {
|
||||
if (req.device_label === '') {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageMissingArg,
|
||||
message: 'device_label is required',
|
||||
})
|
||||
}
|
||||
const body = { client_id: req.client_id ?? DEFAULT_CLIENT_ID, device_label: req.device_label }
|
||||
const res = await this.http.fetch('oauth/device/code', { method: 'POST', json: body })
|
||||
if (res.status === 404) throw versionSkew()
|
||||
if (!res.ok) {
|
||||
throw new HttpClientError({
|
||||
code: ErrorCode.Server4xxOther,
|
||||
message: `device/code: HTTP ${res.status}`,
|
||||
httpStatus: res.status,
|
||||
})
|
||||
}
|
||||
return (await res.json()) as CodeResponse
|
||||
}
|
||||
|
||||
async pollOnce(req: PollRequest): Promise<PollResult> {
|
||||
if (req.device_code === '') {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageMissingArg,
|
||||
message: 'device_code is required',
|
||||
})
|
||||
}
|
||||
const body = { client_id: req.client_id ?? DEFAULT_CLIENT_ID, device_code: req.device_code }
|
||||
const res = await this.http.fetch('oauth/device/token', { method: 'POST', json: body })
|
||||
if (res.status === 404) throw versionSkew()
|
||||
if (res.status >= 500) return { status: 'retry_5xx' }
|
||||
let payload: { error?: string } & Partial<PollSuccess> = {}
|
||||
try {
|
||||
const text = await res.text()
|
||||
payload = text === '' ? {} : (JSON.parse(text) as typeof payload)
|
||||
} catch (err) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.Unknown,
|
||||
message: `decode poll response: ${(err as Error).message}`,
|
||||
})
|
||||
}
|
||||
if (typeof payload.error === 'string' && payload.error !== '') {
|
||||
const status = POLL_ERROR_TO_STATUS[payload.error]
|
||||
if (status === undefined) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.Unknown,
|
||||
message: `unknown poll error "${payload.error}"`,
|
||||
})
|
||||
}
|
||||
return { status } as PollResult
|
||||
}
|
||||
if (typeof payload.token !== 'string' || payload.token === '') {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.Unknown,
|
||||
message: `poll: ${res.status} with no OAuth envelope`,
|
||||
})
|
||||
}
|
||||
return { status: 'approved', success: payload as PollSuccess }
|
||||
}
|
||||
}
|
||||
|
||||
function versionSkew(): HttpClientError {
|
||||
return new HttpClientError({
|
||||
code: ErrorCode.UnsupportedEndpoint,
|
||||
message: 'this Dify host does not implement the OAuth device flow',
|
||||
httpStatus: 404,
|
||||
})
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { PermittedExternalAppsClient } from './permitted-external-apps'
|
||||
|
||||
function fakeHttp() {
|
||||
return { baseURL: 'https://x', request: vi.fn() } as unknown as HttpClient
|
||||
}
|
||||
|
||||
type WithOrpc = { orpc: unknown }
|
||||
|
||||
describe('PermittedExternalAppsClient', () => {
|
||||
it('list calls permittedExternalApps.get with paging/filter query', async () => {
|
||||
const c = new PermittedExternalAppsClient(fakeHttp())
|
||||
const get = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ page: 1, limit: 20, total: 0, has_more: false, data: [] })
|
||||
;(c as unknown as WithOrpc).orpc = { permittedExternalApps: { get, byAppId: { get: vi.fn() } } }
|
||||
await c.list({ workspaceId: '', page: 2, limit: 5, mode: undefined, name: 'a' })
|
||||
expect(get).toHaveBeenCalledWith({ query: { page: 2, limit: 5, mode: undefined, name: 'a' } })
|
||||
})
|
||||
|
||||
it('describe calls permittedExternalApps.byAppId.get with app_id + fields', async () => {
|
||||
const c = new PermittedExternalAppsClient(fakeHttp())
|
||||
const dget = vi.fn().mockResolvedValue({ info: null, parameters: null, input_schema: null })
|
||||
;(c as unknown as WithOrpc).orpc = {
|
||||
permittedExternalApps: { get: vi.fn(), byAppId: { get: dget } },
|
||||
}
|
||||
await c.describe('app-1', ['info'])
|
||||
expect(dget).toHaveBeenCalledWith({ params: { app_id: 'app-1' }, query: { fields: 'info' } })
|
||||
})
|
||||
})
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { AppDescribeResponse, AppListResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { AppReader } from './app-reader'
|
||||
import type { ListQuery } from './apps'
|
||||
import type { OpenApiClient } from '@/http/orpc'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { createOpenApiClient } from '@/http/orpc'
|
||||
import { normalizeMode } from './apps'
|
||||
|
||||
export class PermittedExternalAppsClient implements AppReader {
|
||||
private readonly orpc: OpenApiClient
|
||||
|
||||
constructor(http: HttpClient) {
|
||||
this.orpc = createOpenApiClient(http)
|
||||
}
|
||||
|
||||
// workspaceId is ignored: the external grant is not workspace-scoped.
|
||||
async list(q: ListQuery): Promise<AppListResponse> {
|
||||
return this.orpc.permittedExternalApps.get({
|
||||
query: {
|
||||
page: q.page ?? 1,
|
||||
limit: q.limit ?? 20,
|
||||
mode: normalizeMode(q.mode),
|
||||
name: q.name !== undefined && q.name !== '' ? q.name : undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async describe(appId: string, fields?: readonly string[]): Promise<AppDescribeResponse> {
|
||||
return this.orpc.permittedExternalApps.byAppId.get({
|
||||
params: { app_id: appId },
|
||||
query: { fields: fields !== undefined && fields.length > 0 ? fields.join(',') : undefined },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { StubServer } from '@test/fixtures/stub-server'
|
||||
import { testHttpClient } from '@test/fixtures/http-client'
|
||||
import { jsonResponder, startStubServer } from '@test/fixtures/stub-server'
|
||||
import { afterEach, describe, expect, it } from 'vite-plus/test'
|
||||
import { isHttpClientError } from '@/errors/base'
|
||||
import { WorkspacesClient } from './workspaces.js'
|
||||
|
||||
// WorkspacesClient.switch is covered in members.test.ts; this file covers list(), which now
|
||||
// routes through the generated oRPC contract (OpenAPILink over http.request). The happy path
|
||||
// asserts the on-the-wire GET /openapi/v1/workspaces is unchanged; the 401 case asserts the
|
||||
// oRPC client surfaces it as the CLI's classified HttpClientError.
|
||||
|
||||
function makeClient(host: string): WorkspacesClient {
|
||||
return new WorkspacesClient(testHttpClient(host, 'dfoa_test'))
|
||||
}
|
||||
|
||||
describe('WorkspacesClient.list', () => {
|
||||
let stub: StubServer
|
||||
|
||||
afterEach(async () => {
|
||||
await stub?.stop()
|
||||
})
|
||||
|
||||
it('GETs /workspaces and returns the parsed list', async () => {
|
||||
stub = await startStubServer((cap) =>
|
||||
jsonResponder(
|
||||
200,
|
||||
{
|
||||
page: 1,
|
||||
limit: 200,
|
||||
total: 1,
|
||||
has_more: false,
|
||||
data: [{ id: 'ws-1', name: 'Default', role: 'owner', status: 'normal', current: true }],
|
||||
},
|
||||
cap,
|
||||
),
|
||||
)
|
||||
|
||||
const res = await makeClient(stub.url).list()
|
||||
|
||||
expect(stub.captured.method).toBe('GET')
|
||||
expect(stub.captured.url).toBe('/openapi/v1/workspaces?page=1&limit=200')
|
||||
expect(res.data[0]?.id).toBe('ws-1')
|
||||
})
|
||||
|
||||
it('maps 401 to a classified BaseError', async () => {
|
||||
stub = await startStubServer((cap) => jsonResponder(401, { error: 'expired' }, cap))
|
||||
|
||||
await expect(makeClient(stub.url).list()).rejects.toSatisfy(
|
||||
(err) => isHttpClientError(err) && err.httpStatus === 401,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,40 +0,0 @@
|
||||
import type {
|
||||
WorkspaceDetailResponse,
|
||||
WorkspaceListResponse,
|
||||
} from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { OpenApiClient } from '@/http/orpc'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { createOpenApiClient } from '@/http/orpc'
|
||||
import { fetchAllPages, LIMIT_MAX } from '@/limit/limit'
|
||||
|
||||
export class WorkspacesClient {
|
||||
private readonly orpc: OpenApiClient
|
||||
|
||||
constructor(http: HttpClient) {
|
||||
// oRPC client over the same transport (UA+bearer / retry / timeout / error-map) — SPEC §4.4:
|
||||
// one transport, a contract facade. Both methods are standard unary JSON, so both go through
|
||||
// the generated contract.
|
||||
this.orpc = createOpenApiClient(http)
|
||||
}
|
||||
|
||||
/** Every workspace the caller belongs to, all pages merged into one envelope. */
|
||||
async list(): Promise<WorkspaceListResponse> {
|
||||
const data = await fetchAllPages((page) =>
|
||||
this.orpc.workspaces.get({ query: { page, limit: LIMIT_MAX } }),
|
||||
)
|
||||
return { page: 1, limit: LIMIT_MAX, total: data.length, has_more: false, data }
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side workspace switch via OpenAPI POST
|
||||
* `/workspaces/{id}:switch` — the bearer-authed equivalent of the
|
||||
* console's POST `/workspaces/switch`. The server updates the caller's
|
||||
* `current` tenant_account_join row. Callers MUST refresh their local
|
||||
* `hosts.yml` only after this resolves — never fall back to a local
|
||||
* write if the request fails, or `hosts.yml` will drift from the
|
||||
* server's state.
|
||||
*/
|
||||
async switch(workspaceId: string): Promise<WorkspaceDetailResponse> {
|
||||
return this.orpc.workspaces.byWorkspaceId.switch.post({ params: { workspace_id: workspaceId } })
|
||||
}
|
||||
}
|
||||
121
cli/src/auth/device-api.test.ts
Normal file
121
cli/src/auth/device-api.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { DifyMock } from '@test/fixtures/dify-mock/server'
|
||||
import type { ServerResponse } from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { createServer } from 'node:http'
|
||||
import { startMock } from '@test/fixtures/dify-mock/server'
|
||||
import { afterEach, expect, it } from 'vite-plus/test'
|
||||
import { deviceApi } from './device-api'
|
||||
|
||||
const mocks: DifyMock[] = []
|
||||
afterEach(async () => {
|
||||
for (const m of mocks.splice(0)) await m.stop()
|
||||
})
|
||||
|
||||
// A one-shot raw HTTP server for responses the shared dify-mock has no scenario
|
||||
// for (an RFC 8628 pending poll, a malformed code response).
|
||||
async function withServer<T>(
|
||||
handler: (res: ServerResponse) => void,
|
||||
fn: (baseUrl: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const server = createServer((_req, res) => handler(res))
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()))
|
||||
const { port } = server.address() as AddressInfo
|
||||
try {
|
||||
return await fn(`http://127.0.0.1:${port}`)
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
server.close((err) => (err ? reject(err) : resolve())),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
it('maps a 404 on the code endpoint to a server_error naming the missing feature', async () => {
|
||||
const mock = await startMock({ scenario: 'no-device-flow' })
|
||||
mocks.push(mock)
|
||||
const api = deviceApi(mock.url, { insecure: true })
|
||||
await expect(api.requestCode({ device_label: 'test' })).rejects.toMatchObject({
|
||||
code: 'server_error',
|
||||
message: 'this Dify host does not implement the OAuth device flow',
|
||||
})
|
||||
})
|
||||
|
||||
it('maps a non-2xx code response to a server_error carrying the raw body', async () => {
|
||||
const mock = await startMock({ scenario: 'server-5xx' })
|
||||
mocks.push(mock)
|
||||
const api = deviceApi(mock.url, { insecure: true })
|
||||
await expect(api.requestCode({ device_label: 'test' })).rejects.toMatchObject({
|
||||
code: 'server_error',
|
||||
rawResponse: expect.stringContaining('upstream broken'),
|
||||
})
|
||||
})
|
||||
|
||||
it('maps an unreachable server to network_connection', async () => {
|
||||
const mock = await startMock()
|
||||
const { port } = mock
|
||||
await mock.stop()
|
||||
const api = deviceApi(`http://127.0.0.1:${port}`, { insecure: true })
|
||||
await expect(api.requestCode({ device_label: 'test' })).rejects.toMatchObject({
|
||||
code: 'network_connection',
|
||||
})
|
||||
})
|
||||
|
||||
it('maps the RFC 8628 poll error codes to their statuses', async () => {
|
||||
const mock = await startMock({ scenario: 'denied' })
|
||||
mocks.push(mock)
|
||||
const api = deviceApi(mock.url, { insecure: true })
|
||||
await expect(api.pollOnce({ device_code: 'dc' })).resolves.toEqual({ status: 'denied' })
|
||||
|
||||
mock.setScenario('expired')
|
||||
await expect(api.pollOnce({ device_code: 'dc' })).resolves.toEqual({ status: 'expired' })
|
||||
|
||||
mock.setScenario('slow-down')
|
||||
await expect(api.pollOnce({ device_code: 'dc' })).resolves.toEqual({ status: 'slow_down' })
|
||||
|
||||
mock.setScenario('happy')
|
||||
const approved = await api.pollOnce({ device_code: 'dc' })
|
||||
expect(approved.status).toBe('approved')
|
||||
})
|
||||
|
||||
it('reports authorization_pending as a pending poll', async () => {
|
||||
await withServer(
|
||||
(res) => {
|
||||
res.writeHead(400, { 'content-type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: 'authorization_pending' }))
|
||||
},
|
||||
async (baseUrl) => {
|
||||
const api = deviceApi(baseUrl, { insecure: true })
|
||||
await expect(api.pollOnce({ device_code: 'dc' })).resolves.toEqual({ status: 'pending' })
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects an incomplete code response without leaking a raw parse error', async () => {
|
||||
await withServer(
|
||||
(res) => {
|
||||
res.writeHead(200, { 'content-type': 'application/json' })
|
||||
res.end(JSON.stringify({ device_code: 'dc' }))
|
||||
},
|
||||
async (baseUrl) => {
|
||||
const api = deviceApi(baseUrl, { insecure: true })
|
||||
await expect(api.requestCode({ device_label: 'test' })).rejects.toMatchObject({
|
||||
code: 'server_error',
|
||||
message: expect.stringContaining('user_code'),
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a non-JSON code response without leaking a raw SyntaxError', async () => {
|
||||
await withServer(
|
||||
(res) => {
|
||||
res.writeHead(200, { 'content-type': 'text/plain' })
|
||||
res.end('not json')
|
||||
},
|
||||
async (baseUrl) => {
|
||||
const api = deviceApi(baseUrl, { insecure: true })
|
||||
await expect(api.requestCode({ device_label: 'test' })).rejects.toMatchObject({
|
||||
code: 'server_error',
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
206
cli/src/auth/device-api.ts
Normal file
206
cli/src/auth/device-api.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import type { Dispatcher } from 'undici'
|
||||
import { BaseError, HttpClientError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { buildFetchInit } from '@/net/fetch-init'
|
||||
import { proxyDispatcher } from '@/net/proxy'
|
||||
|
||||
export const DEFAULT_CLIENT_ID = 'difyctl'
|
||||
|
||||
export type CodeRequest = {
|
||||
client_id?: string
|
||||
device_label: string
|
||||
}
|
||||
|
||||
export type CodeResponse = {
|
||||
device_code: string
|
||||
user_code: string
|
||||
verification_uri: string
|
||||
expires_in: number
|
||||
interval: number
|
||||
}
|
||||
|
||||
export type PollRequest = {
|
||||
client_id?: string
|
||||
device_code: string
|
||||
}
|
||||
|
||||
export type PollAccount = {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export type PollWorkspace = {
|
||||
id: string
|
||||
name: string
|
||||
role: string
|
||||
}
|
||||
|
||||
export type PollSuccess = {
|
||||
token: string
|
||||
expires_at?: string
|
||||
subject_type?: string
|
||||
subject_email?: string
|
||||
subject_issuer?: string
|
||||
account?: PollAccount | null
|
||||
workspaces?: readonly PollWorkspace[]
|
||||
default_workspace_id?: string
|
||||
token_id?: string
|
||||
}
|
||||
|
||||
export type PollResult =
|
||||
| { status: 'pending' }
|
||||
| { status: 'slow_down' }
|
||||
| { status: 'expired' }
|
||||
| { status: 'denied' }
|
||||
| { status: 'retry_5xx' }
|
||||
| { status: 'approved'; success: PollSuccess }
|
||||
|
||||
export type DeviceApi = {
|
||||
readonly requestCode: (req: CodeRequest) => Promise<CodeResponse>
|
||||
readonly pollOnce: (req: PollRequest) => Promise<PollResult>
|
||||
}
|
||||
|
||||
export type DeviceApiOptions = Readonly<{ insecure: boolean }>
|
||||
|
||||
const DEVICE_CODE_PATH = '/openapi/v1/oauth/device/code'
|
||||
const DEVICE_TOKEN_PATH = '/openapi/v1/oauth/device/token'
|
||||
const DEVICE_FLOW_UNSUPPORTED_MESSAGE = 'this Dify host does not implement the OAuth device flow'
|
||||
|
||||
const HttpStatus = { NotFound: 404, ServerErrorFloor: 500 } as const
|
||||
|
||||
// RFC 8628 error codes, mapped to the states awaitAuthorization polls on.
|
||||
const POLL_ERROR_STATUS: Readonly<Record<string, PollResult['status']>> = {
|
||||
authorization_pending: 'pending',
|
||||
slow_down: 'slow_down',
|
||||
expired_token: 'expired',
|
||||
access_denied: 'denied',
|
||||
}
|
||||
|
||||
type PollPayload = { error?: string } & Partial<PollSuccess>
|
||||
|
||||
const CODE_RESPONSE_REQUIRED_FIELDS = ['device_code', 'user_code', 'verification_uri'] as const
|
||||
|
||||
function parseCodeResponse(text: string): CodeResponse {
|
||||
let payload: Record<string, unknown>
|
||||
try {
|
||||
payload = text === '' ? {} : (JSON.parse(text) as Record<string, unknown>)
|
||||
} catch (cause) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.ServerError,
|
||||
message: 'device/code response is not valid JSON',
|
||||
cause,
|
||||
})
|
||||
}
|
||||
const missing = CODE_RESPONSE_REQUIRED_FIELDS.filter(
|
||||
(field) => typeof payload[field] !== 'string' || payload[field] === '',
|
||||
)
|
||||
if (missing.length > 0) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.ServerError,
|
||||
message: `device/code response missing ${missing.join(', ')}`,
|
||||
})
|
||||
}
|
||||
return payload as unknown as CodeResponse
|
||||
}
|
||||
|
||||
function unsupportedDeviceFlow(method: string, url: URL): HttpClientError {
|
||||
return new HttpClientError({
|
||||
code: ErrorCode.ServerError,
|
||||
message: DEVICE_FLOW_UNSUPPORTED_MESSAGE,
|
||||
httpStatus: HttpStatus.NotFound,
|
||||
method,
|
||||
url: url.toString(),
|
||||
})
|
||||
}
|
||||
|
||||
async function nonOkError(method: string, url: URL, res: Response): Promise<HttpClientError> {
|
||||
const rawResponse = await res.text()
|
||||
return new HttpClientError({
|
||||
code: ErrorCode.ServerError,
|
||||
message: `${method} ${url.pathname} failed: HTTP ${res.status}`,
|
||||
httpStatus: res.status,
|
||||
method,
|
||||
url: url.toString(),
|
||||
rawResponse,
|
||||
})
|
||||
}
|
||||
|
||||
function parsePollPayload(text: string): PollPayload {
|
||||
if (text === '') return {}
|
||||
try {
|
||||
return JSON.parse(text) as PollPayload
|
||||
} catch (cause) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.Unknown,
|
||||
message: `decode poll response: ${(cause as Error).message}`,
|
||||
cause,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function deviceApi(server: string, opts: DeviceApiOptions): DeviceApi {
|
||||
const dispatcher: Dispatcher | undefined = opts.insecure
|
||||
? proxyDispatcher({ insecure: true })
|
||||
: proxyDispatcher()
|
||||
|
||||
async function post(path: string, body: unknown): Promise<{ url: URL; res: Response }> {
|
||||
const url = new URL(path, server)
|
||||
const init = buildFetchInit(
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
{ insecure: opts.insecure, dispatcher },
|
||||
)
|
||||
try {
|
||||
return { url, res: await fetch(url, init) }
|
||||
} catch (cause) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.NetworkConnection,
|
||||
message: `network error calling POST ${url}`,
|
||||
cause,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
requestCode: async (req) => {
|
||||
const { url, res } = await post(DEVICE_CODE_PATH, {
|
||||
client_id: req.client_id ?? DEFAULT_CLIENT_ID,
|
||||
device_label: req.device_label,
|
||||
})
|
||||
if (res.status === HttpStatus.NotFound) throw unsupportedDeviceFlow('POST', url)
|
||||
if (!res.ok) throw await nonOkError('POST', url, res)
|
||||
return parseCodeResponse(await res.text())
|
||||
},
|
||||
pollOnce: async (req) => {
|
||||
const { url, res } = await post(DEVICE_TOKEN_PATH, {
|
||||
client_id: req.client_id ?? DEFAULT_CLIENT_ID,
|
||||
device_code: req.device_code,
|
||||
})
|
||||
if (res.status === HttpStatus.NotFound) throw unsupportedDeviceFlow('POST', url)
|
||||
if (res.status >= HttpStatus.ServerErrorFloor) return { status: 'retry_5xx' }
|
||||
|
||||
const payload = parsePollPayload(await res.text())
|
||||
if (typeof payload.error === 'string' && payload.error !== '') {
|
||||
const status = POLL_ERROR_STATUS[payload.error]
|
||||
if (status === undefined) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.Unknown,
|
||||
message: `unknown poll error "${payload.error}"`,
|
||||
})
|
||||
}
|
||||
return { status } as PollResult
|
||||
}
|
||||
if (typeof payload.token !== 'string' || payload.token === '') {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.Unknown,
|
||||
message: `poll: ${res.status} with no OAuth envelope`,
|
||||
})
|
||||
}
|
||||
return { status: 'approved', success: payload as PollSuccess }
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { CodeResponse, PollRequest, PollResult, PollSuccess } from './device-api'
|
||||
import type { Clock } from './device-flow'
|
||||
import type { CodeResponse, PollRequest, PollResult, PollSuccess } from '@/api/oauth-device'
|
||||
import { describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { BaseError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { ErrorCode, ExitCode } from '@/errors/codes'
|
||||
import { isCancelled } from '@/plugins/commands/cancel'
|
||||
import {
|
||||
awaitAuthorization,
|
||||
DEFAULT_INTERVAL_MS,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
POLL_RETRY_ATTEMPTS,
|
||||
POLL_RETRY_CAP_MS,
|
||||
POLL_RETRY_INITIAL_MS,
|
||||
realClock,
|
||||
} from './device-flow'
|
||||
|
||||
const successPayload: PollSuccess = {
|
||||
@@ -133,7 +135,15 @@ describe('awaitAuthorization', () => {
|
||||
])
|
||||
const clock = new FakeClock()
|
||||
clock.cancelAt = 2
|
||||
await expect(awaitAuthorization(api, code, { clock })).rejects.toThrow(/expired|cancel/)
|
||||
// Cancelling is neither an expiry (exit 4) nor a usage mistake (exit 2).
|
||||
const err = await awaitAuthorization(api, code, { clock }).catch((e: unknown) => e)
|
||||
expect(err).toMatchObject({ code: 'unknown', message: 'cancelled' })
|
||||
expect((err as BaseError).exit()).toBe(ExitCode.Generic)
|
||||
})
|
||||
|
||||
it('realClock reports the run-wide cancellation flag, not a constant', async () => {
|
||||
expect(realClock().isCancelled).toBe(isCancelled)
|
||||
expect(realClock().isCancelled()).toBe(false)
|
||||
})
|
||||
|
||||
it('exposes constants matching Go reference', () => {
|
||||
@@ -162,9 +172,7 @@ describe('awaitAuthorization', () => {
|
||||
const api = {
|
||||
pollOnce: vi
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
new BaseError({ code: ErrorCode.UnsupportedEndpoint, message: 'old server' }),
|
||||
),
|
||||
.mockRejectedValue(new BaseError({ code: ErrorCode.ServerError, message: 'old server' })),
|
||||
}
|
||||
const clock = new FakeClock()
|
||||
await expect(awaitAuthorization(api, code, { clock })).rejects.toThrow(/old server/)
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { CodeResponse, PollRequest, PollResult, PollSuccess } from '@/api/oauth-device'
|
||||
import { DEFAULT_CLIENT_ID } from '@/api/oauth-device'
|
||||
import type { CodeResponse, PollRequest, PollResult, PollSuccess } from './device-api'
|
||||
import { BaseError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { isCancelled } from '@/plugins/commands/cancel'
|
||||
import { DEFAULT_CLIENT_ID } from './device-api'
|
||||
|
||||
export const DEFAULT_INTERVAL_MS = 5_000
|
||||
export const MAX_INTERVAL_MS = 60_000
|
||||
@@ -9,6 +10,8 @@ export const POLL_RETRY_ATTEMPTS = 5
|
||||
export const POLL_RETRY_INITIAL_MS = 1_000
|
||||
export const POLL_RETRY_CAP_MS = 16_000
|
||||
|
||||
const CANCELLED_MESSAGE = 'cancelled'
|
||||
|
||||
export type Clock = {
|
||||
sleepMs: (ms: number) => Promise<void>
|
||||
isCancelled: () => boolean
|
||||
@@ -32,37 +35,50 @@ export async function awaitAuthorization(
|
||||
|
||||
const baseInterval = code.interval > 0 ? code.interval * 1000 : DEFAULT_INTERVAL_MS
|
||||
let interval = baseInterval
|
||||
const req: PollRequest = {
|
||||
device_code: code.device_code,
|
||||
client_id: opts.clientId ?? DEFAULT_CLIENT_ID,
|
||||
}
|
||||
const req = pollRequest(code.device_code, opts)
|
||||
|
||||
while (true) {
|
||||
if (opts.clock.isCancelled()) throw expired()
|
||||
if (opts.clock.isCancelled()) throw cancelledError()
|
||||
const result = await pollWithRetry(api, req, opts.clock)
|
||||
switch (result.status) {
|
||||
case 'approved':
|
||||
return result.success
|
||||
case 'pending':
|
||||
break
|
||||
case 'slow_down':
|
||||
interval = Math.min(interval * 2, MAX_INTERVAL_MS)
|
||||
break
|
||||
case 'expired':
|
||||
throw expired()
|
||||
case 'denied':
|
||||
throw new BaseError({
|
||||
code: ErrorCode.AccessDenied,
|
||||
message: 'authorization denied',
|
||||
})
|
||||
case 'retry_5xx':
|
||||
throw new BaseError({
|
||||
code: ErrorCode.Server5xx,
|
||||
message: 'device-flow poll unavailable after retries',
|
||||
})
|
||||
}
|
||||
const success = settle(result)
|
||||
if (success !== undefined) return success
|
||||
if (result.status === 'slow_down') interval = Math.min(interval * 2, MAX_INTERVAL_MS)
|
||||
await opts.clock.sleepMs(interval)
|
||||
if (opts.clock.isCancelled()) throw expired()
|
||||
if (opts.clock.isCancelled()) throw cancelledError()
|
||||
}
|
||||
}
|
||||
|
||||
export async function pollAuthorization(
|
||||
api: DeviceFlowApiSubset,
|
||||
deviceCode: string,
|
||||
opts: AwaitOptions,
|
||||
): Promise<PollSuccess | undefined> {
|
||||
return settle(await pollWithRetry(api, pollRequest(deviceCode, opts), opts.clock))
|
||||
}
|
||||
|
||||
function pollRequest(deviceCode: string, opts: AwaitOptions): PollRequest {
|
||||
return { device_code: deviceCode, client_id: opts.clientId ?? DEFAULT_CLIENT_ID }
|
||||
}
|
||||
|
||||
function settle(result: PollResult): PollSuccess | undefined {
|
||||
switch (result.status) {
|
||||
case 'approved':
|
||||
return result.success
|
||||
case 'pending':
|
||||
case 'slow_down':
|
||||
return undefined
|
||||
case 'expired':
|
||||
throw expired()
|
||||
case 'denied':
|
||||
throw new BaseError({
|
||||
code: ErrorCode.AccessDenied,
|
||||
message: 'authorization denied',
|
||||
})
|
||||
case 'retry_5xx':
|
||||
throw new BaseError({
|
||||
code: ErrorCode.ServerError,
|
||||
message: 'device-flow poll unavailable after retries',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,19 +100,22 @@ async function pollWithRetry(
|
||||
|
||||
function expired(): BaseError {
|
||||
return new BaseError({
|
||||
code: ErrorCode.ExpiredToken,
|
||||
code: ErrorCode.AuthExpired,
|
||||
message: 'code expired before authorization',
|
||||
})
|
||||
}
|
||||
|
||||
// Ctrl-C during the wait is neither an expiry nor a usage mistake: the code is still
|
||||
// good, the operator just stopped waiting for it.
|
||||
function cancelledError(): BaseError {
|
||||
return new BaseError({ code: ErrorCode.Unknown, message: CANCELLED_MESSAGE })
|
||||
}
|
||||
|
||||
export function realClock(): Clock {
|
||||
const cancelled = false
|
||||
return {
|
||||
async sleepMs(ms) {
|
||||
await new Promise<void>((r) => setTimeout(r, ms))
|
||||
},
|
||||
isCancelled() {
|
||||
return cancelled
|
||||
},
|
||||
isCancelled,
|
||||
}
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
import type { AccountContext } from './hosts'
|
||||
import { useTempConfigDir } from '@test/fixtures/config-dir'
|
||||
import { MemStore } from '@test/fixtures/mem-store'
|
||||
import { describe, expect, it } from 'vite-plus/test'
|
||||
import { AccountContextSchema, notLoggedInError, Registry, RegistrySchema } from './hosts'
|
||||
|
||||
describe('RegistrySchema', () => {
|
||||
it('parses an empty registry with defaults', () => {
|
||||
const reg = RegistrySchema.parse({})
|
||||
expect(reg.token_storage).toBe('file')
|
||||
expect(reg.current_host).toBeUndefined()
|
||||
expect(reg.hosts).toEqual({})
|
||||
})
|
||||
|
||||
it('parses a populated multi-host registry', () => {
|
||||
const reg = RegistrySchema.parse({
|
||||
token_storage: 'keychain',
|
||||
current_host: 'cloud.dify.ai',
|
||||
hosts: {
|
||||
'cloud.dify.ai': {
|
||||
current_account: 'bob@corp.com',
|
||||
accounts: {
|
||||
'bob@corp.com': {
|
||||
account: { id: 'acct-1', email: 'bob@corp.com', name: 'Bob' },
|
||||
workspace: { id: 'ws-1', name: 'Space', role: 'owner' },
|
||||
token_id: 'tok_1',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(reg.current_host).toBe('cloud.dify.ai')
|
||||
expect(reg.hosts['cloud.dify.ai']?.current_account).toBe('bob@corp.com')
|
||||
expect(reg.hosts['cloud.dify.ai']?.accounts['bob@corp.com']?.account.name).toBe('Bob')
|
||||
})
|
||||
|
||||
it('defaults a host entry accounts map to {}', () => {
|
||||
const reg = RegistrySchema.parse({ hosts: { h: { current_account: 'x' } } })
|
||||
expect(reg.hosts.h?.accounts).toEqual({})
|
||||
})
|
||||
|
||||
it('rejects unknown token_storage values', () => {
|
||||
expect(() => RegistrySchema.parse({ token_storage: 'cloud' })).toThrow()
|
||||
})
|
||||
|
||||
it('AccountContextSchema keeps optional external_subject', () => {
|
||||
const ctx = AccountContextSchema.parse({
|
||||
account: { id: '', email: 'sso@x.io', name: '' },
|
||||
external_subject: { email: 'sso@x.io', issuer: 'https://issuer' },
|
||||
})
|
||||
expect(ctx.external_subject?.issuer).toBe('https://issuer')
|
||||
})
|
||||
|
||||
it('strips a stale available_workspaces field from legacy contexts', () => {
|
||||
const raw = {
|
||||
account: { id: 'acct-1', email: 'bob@corp.com', name: 'Bob' },
|
||||
workspace: { id: 'ws-1', name: 'Space', role: 'owner' },
|
||||
available_workspaces: [
|
||||
{ id: 'ws-1', name: 'Space', role: 'owner' },
|
||||
{ id: '00000000-0000-0000-0000-000000000002', name: 'Other', role: 'normal' },
|
||||
],
|
||||
} as unknown as Record<string, unknown>
|
||||
const ctx = AccountContextSchema.parse(raw)
|
||||
expect((ctx as Record<string, unknown>).available_workspaces).toBeUndefined()
|
||||
expect(ctx.workspace?.id).toBe('ws-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('notLoggedInError', () => {
|
||||
it('carries the default hint', () => {
|
||||
expect(notLoggedInError().toString()).toMatch(/auth login/)
|
||||
})
|
||||
it('accepts a custom hint', () => {
|
||||
expect(notLoggedInError("run 'difyctl use host'").toString()).toMatch(/use host/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Registry (pure)', () => {
|
||||
const baseReg = (): Registry => Registry.empty('file')
|
||||
const ctx = (email: string): AccountContext => ({
|
||||
account: { id: `id-${email}`, email, name: email },
|
||||
})
|
||||
|
||||
it('upsert creates host + account; remove drops them', () => {
|
||||
const reg = baseReg()
|
||||
reg.upsert('h1', 'a@x', ctx('a@x'))
|
||||
reg.upsert('h1', 'b@x', ctx('b@x'))
|
||||
expect(reg.hosts.h1?.accounts['a@x']?.account.email).toBe('a@x')
|
||||
reg.remove('h1', 'a@x')
|
||||
expect(reg.hosts.h1?.accounts['a@x']).toBeUndefined()
|
||||
expect(reg.hosts.h1?.accounts['b@x']).toBeDefined()
|
||||
reg.remove('h1', 'b@x')
|
||||
expect(reg.hosts.h1).toBeUndefined()
|
||||
})
|
||||
|
||||
it('setHost / setAccount set pointers', () => {
|
||||
const reg = baseReg()
|
||||
reg.upsert('h1', 'a@x', ctx('a@x'))
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
expect(reg.current_host).toBe('h1')
|
||||
expect(reg.hosts.h1?.current_account).toBe('a@x')
|
||||
})
|
||||
|
||||
it('resolveActive returns the active context with scheme', () => {
|
||||
const reg = baseReg()
|
||||
reg.upsert('h1', 'a@x', ctx('a@x'))
|
||||
reg.setScheme('h1', 'http')
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
const active = reg.resolveActive()
|
||||
expect(active?.host).toBe('h1')
|
||||
expect(active?.email).toBe('a@x')
|
||||
expect(active?.scheme).toBe('http')
|
||||
expect(active?.ctx.account.email).toBe('a@x')
|
||||
})
|
||||
|
||||
it('resolveActive returns the active context with insecureTls', () => {
|
||||
const reg = baseReg()
|
||||
reg.upsert('h1', 'a@x', ctx('a@x'))
|
||||
reg.setInsecureTls('h1', true)
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
expect(reg.resolveActive()?.insecureTls).toBe(true)
|
||||
})
|
||||
|
||||
it('setInsecureTls is a no-op for an unknown host', () => {
|
||||
const reg = baseReg()
|
||||
reg.setInsecureTls('missing', true)
|
||||
expect(reg.hosts.missing).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolveActive returns undefined for each missing pointer', () => {
|
||||
const reg = baseReg()
|
||||
expect(reg.resolveActive()).toBeUndefined()
|
||||
reg.upsert('h1', 'a@x', ctx('a@x'))
|
||||
reg.setHost('missing')
|
||||
expect(reg.resolveActive()).toBeUndefined()
|
||||
reg.setHost('h1')
|
||||
expect(reg.resolveActive()).toBeUndefined()
|
||||
reg.setAccount('missing@x')
|
||||
expect(reg.resolveActive()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('remove unsets pointers when removing the active account', () => {
|
||||
const reg = baseReg()
|
||||
reg.upsert('h1', 'a@x', ctx('a@x'))
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
reg.remove('h1', 'a@x')
|
||||
expect(reg.current_host).toBeUndefined()
|
||||
expect(reg.resolveActive()).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Registry.load / Registry.save', () => {
|
||||
useTempConfigDir('difyctl-reg-')
|
||||
|
||||
it('returns an empty registry when nothing saved', async () => {
|
||||
const reg = await Registry.load()
|
||||
expect(reg.current_host).toBeUndefined()
|
||||
expect(Object.keys(reg.hosts)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('round-trips a populated registry', async () => {
|
||||
const reg = Registry.empty('keychain')
|
||||
reg.upsert('cloud.dify.ai', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
|
||||
reg.setHost('cloud.dify.ai')
|
||||
reg.setAccount('a@x')
|
||||
await reg.save()
|
||||
const loaded = await Registry.load()
|
||||
expect(loaded?.current_host).toBe('cloud.dify.ai')
|
||||
expect(loaded?.hosts['cloud.dify.ai']?.accounts['a@x']?.account.email).toBe('a@x')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Registry.forget', () => {
|
||||
useTempConfigDir('difyctl-forget-')
|
||||
|
||||
it('drops token + active context, keeps siblings, unsets pointers', async () => {
|
||||
const store = new MemStore()
|
||||
const reg = Registry.empty('file')
|
||||
reg.upsert('h1', 'a@x', { account: { id: '1', email: 'a@x', name: 'A' } })
|
||||
reg.upsert('h1', 'b@x', { account: { id: '2', email: 'b@x', name: 'B' } })
|
||||
reg.setHost('h1')
|
||||
reg.setAccount('a@x')
|
||||
await reg.save()
|
||||
await store.write('h1', 'a@x', 'dfoa_a')
|
||||
|
||||
const active = reg.resolveActive()!
|
||||
await reg.forget(active, store)
|
||||
|
||||
expect(await store.read('h1', 'a@x')).toBe('')
|
||||
const after = await Registry.load()
|
||||
expect(after?.hosts.h1?.accounts['a@x']).toBeUndefined()
|
||||
expect(after?.hosts.h1?.accounts['b@x']).toBeDefined()
|
||||
expect(after?.current_host).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,183 +0,0 @@
|
||||
import type { StorageMode } from '@/store/store'
|
||||
import type { TokenStore } from '@/store/token-store'
|
||||
import { z } from 'zod'
|
||||
import { BaseError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { getHostStore } from '@/store/manager'
|
||||
import { STORAGE_MODES } from '@/store/store'
|
||||
|
||||
const StorageModeSchema = z.enum(STORAGE_MODES)
|
||||
|
||||
export type { StorageMode } from '@/store/store'
|
||||
|
||||
export const AccountSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
email: z.string().default(''),
|
||||
name: z.string().default(''),
|
||||
})
|
||||
export type Account = z.infer<typeof AccountSchema>
|
||||
|
||||
export const WorkspaceSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
role: z.string(),
|
||||
})
|
||||
export type Workspace = z.infer<typeof WorkspaceSchema>
|
||||
|
||||
export const ExternalSubjectSchema = z.object({
|
||||
email: z.string(),
|
||||
issuer: z.string(),
|
||||
})
|
||||
export type ExternalSubject = z.infer<typeof ExternalSubjectSchema>
|
||||
|
||||
export const AccountContextSchema = z.object({
|
||||
account: AccountSchema,
|
||||
workspace: WorkspaceSchema.optional(),
|
||||
token_id: z.string().optional(),
|
||||
token_expires_at: z.string().optional(),
|
||||
external_subject: ExternalSubjectSchema.optional(),
|
||||
})
|
||||
export type AccountContext = z.infer<typeof AccountContextSchema>
|
||||
|
||||
export const HostEntrySchema = z.object({
|
||||
scheme: z.string().optional(),
|
||||
insecure_tls: z.boolean().optional(),
|
||||
current_account: z.string().optional(),
|
||||
accounts: z.record(z.string(), AccountContextSchema).default({}),
|
||||
})
|
||||
export type HostEntry = z.infer<typeof HostEntrySchema>
|
||||
|
||||
export const RegistrySchema = z.object({
|
||||
token_storage: StorageModeSchema.default('file'),
|
||||
current_host: z.string().optional(),
|
||||
hosts: z.record(z.string(), HostEntrySchema).default({}),
|
||||
})
|
||||
export type RegistryData = z.infer<typeof RegistrySchema>
|
||||
|
||||
export type ActiveContext = {
|
||||
readonly host: string
|
||||
readonly email: string
|
||||
readonly ctx: AccountContext
|
||||
readonly scheme?: string
|
||||
readonly insecureTls?: boolean
|
||||
}
|
||||
|
||||
export function notLoggedInError(hint = "run 'difyctl auth login'"): BaseError {
|
||||
return new BaseError({ code: ErrorCode.NotLoggedIn, message: 'not logged in', hint })
|
||||
}
|
||||
|
||||
export class Registry {
|
||||
private readonly data: RegistryData
|
||||
|
||||
private constructor(data: RegistryData) {
|
||||
this.data = data
|
||||
}
|
||||
|
||||
static async load(): Promise<Registry> {
|
||||
const raw = await getHostStore().getTyped<Record<string, unknown>>()
|
||||
if (raw === null) return Registry.empty()
|
||||
return new Registry(RegistrySchema.parse(raw))
|
||||
}
|
||||
|
||||
static empty(mode: StorageMode = 'file'): Registry {
|
||||
return new Registry(RegistrySchema.parse({ token_storage: mode, hosts: {} }))
|
||||
}
|
||||
|
||||
static from(data: RegistryData): Registry {
|
||||
return new Registry(data)
|
||||
}
|
||||
|
||||
get hosts(): RegistryData['hosts'] {
|
||||
return this.data.hosts
|
||||
}
|
||||
get current_host(): string | undefined {
|
||||
return this.data.current_host
|
||||
}
|
||||
get token_storage(): StorageMode {
|
||||
return this.data.token_storage
|
||||
}
|
||||
set token_storage(mode: StorageMode) {
|
||||
this.data.token_storage = mode
|
||||
}
|
||||
|
||||
resolveActive(): ActiveContext | undefined {
|
||||
const host = this.data.current_host
|
||||
if (host === undefined || host === '') return undefined
|
||||
const entry = this.data.hosts[host]
|
||||
if (entry === undefined) return undefined
|
||||
const email = entry?.current_account
|
||||
if (!email) return undefined
|
||||
const ctx = entry.accounts[email]
|
||||
if (ctx === undefined) return undefined
|
||||
return { host, email, ctx, scheme: entry.scheme, insecureTls: entry.insecure_tls }
|
||||
}
|
||||
|
||||
requireActive(hint?: string): ActiveContext {
|
||||
const active = this.resolveActive()
|
||||
if (active === undefined) throw notLoggedInError(hint)
|
||||
return active
|
||||
}
|
||||
|
||||
upsert(host: string, email: string, ctx: AccountContext): void {
|
||||
const entry = this.data.hosts[host] ?? { accounts: {} }
|
||||
entry.accounts[email] = ctx
|
||||
this.data.hosts[host] = entry
|
||||
}
|
||||
|
||||
remove(host: string, email: string): void {
|
||||
const entry = this.data.hosts[host]
|
||||
if (entry === undefined) return
|
||||
const wasActive = entry.current_account === email
|
||||
delete entry.accounts[email]
|
||||
if (wasActive) entry.current_account = undefined
|
||||
if (Object.keys(entry.accounts).length === 0) {
|
||||
delete this.data.hosts[host]
|
||||
if (this.data.current_host === host) this.data.current_host = undefined
|
||||
} else if (wasActive && this.data.current_host === host) {
|
||||
this.data.current_host = undefined
|
||||
}
|
||||
}
|
||||
|
||||
setHost(host: string): void {
|
||||
this.data.current_host = host
|
||||
}
|
||||
|
||||
setAccount(email: string): void {
|
||||
const host = this.data.current_host
|
||||
if (host === undefined) return
|
||||
const entry = this.data.hosts[host]
|
||||
if (entry !== undefined) entry.current_account = email
|
||||
}
|
||||
|
||||
setScheme(host: string, scheme: string): void {
|
||||
const entry = this.data.hosts[host]
|
||||
if (entry !== undefined) entry.scheme = scheme
|
||||
}
|
||||
|
||||
setInsecureTls(host: string, insecure: boolean): void {
|
||||
const entry = this.data.hosts[host]
|
||||
if (entry !== undefined) entry.insecure_tls = insecure
|
||||
}
|
||||
|
||||
activate(host: string, email: string, ctx: AccountContext): void {
|
||||
this.upsert(host, email, ctx)
|
||||
this.setHost(host)
|
||||
this.setAccount(email)
|
||||
}
|
||||
|
||||
// Teardown for "this credential is gone": drop the token, drop the context
|
||||
// (unsets pointers when active), persist. Logout + self-revoke share it.
|
||||
async forget(active: ActiveContext, store: TokenStore): Promise<void> {
|
||||
try {
|
||||
await store.remove(active.host, active.email)
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
this.remove(active.host, active.email)
|
||||
await this.save()
|
||||
}
|
||||
|
||||
async save(): Promise<void> {
|
||||
await getHostStore().setTyped(RegistrySchema.parse(this.data))
|
||||
}
|
||||
}
|
||||
45
cli/src/auth/logout.ts
Normal file
45
cli/src/auth/logout.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { CommandContext } from '@/plugins/base'
|
||||
import type { Login } from '@/plugins/session'
|
||||
import { BaseError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { errorMessage } from '@/errors/message'
|
||||
import { catalog } from '@/plugins/catalog'
|
||||
import { http } from '@/plugins/http'
|
||||
import { io } from '@/plugins/io'
|
||||
import { session } from '@/plugins/session'
|
||||
import { token } from '@/plugins/token'
|
||||
|
||||
const SESSIONS_SELF_PATH = '/openapi/v1/account/sessions/self'
|
||||
const REVOKE_FAILED_PREFIX = 'server revoke failed: '
|
||||
|
||||
/** Throws when the current login comes from DIFY_SERVER/DIFY_TOKEN, which neither
|
||||
* login nor logout may touch — each passes its own explanation. */
|
||||
export function assertNotEnvLogin(fromEnv: boolean, message: string): void {
|
||||
if (fromEnv) throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message })
|
||||
}
|
||||
|
||||
// Shared by `login` (logging out a previous session before starting a new one)
|
||||
// and `logout` itself: best-effort server-side revoke, then unconditional local
|
||||
// cleanup of the token, the cached catalog and the session pin.
|
||||
//
|
||||
// catalog.clear() must run before session.clear(): the catalog plugin resolves
|
||||
// its cache-file path from session.current() the first time it is built, so
|
||||
// clearing the session first leaves it unable to find its own file.
|
||||
export async function revokeAndClearSession(ctx: CommandContext, login: Login): Promise<void> {
|
||||
const streams = await ctx.get(io)
|
||||
const httpService = await ctx.get(http)
|
||||
try {
|
||||
await httpService.request(() => ({ method: 'DELETE', path: SESSIONS_SELF_PATH }))
|
||||
} catch (err) {
|
||||
streams.notice(`${REVOKE_FAILED_PREFIX}${errorMessage(err)}`)
|
||||
}
|
||||
|
||||
const tokenService = await ctx.get(token)
|
||||
await tokenService.remove(login)
|
||||
|
||||
const catalogService = await ctx.get(catalog)
|
||||
await catalogService.clear()
|
||||
|
||||
const sessionService = await ctx.get(session)
|
||||
await sessionService.clear()
|
||||
}
|
||||
142
cli/src/cache/app-info.test.ts
vendored
142
cli/src/cache/app-info.test.ts
vendored
@@ -1,142 +0,0 @@
|
||||
import type { AppMeta } from '@/types/app-meta'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { dump, load } from 'js-yaml'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
|
||||
import { ENV_CACHE_DIR } from '@/store/dir'
|
||||
import { CACHE_APP_INFO, cachePath, getCache } from '@/store/manager'
|
||||
import { platform } from '@/sys/index'
|
||||
import { FieldInfo, FieldParameters } from '@/types/app-meta'
|
||||
import { APP_INFO_TTL_MS, loadAppInfoCache } from './app-info'
|
||||
|
||||
function appInfoPath(dir: string): string {
|
||||
return cachePath(dir, CACHE_APP_INFO)
|
||||
}
|
||||
|
||||
function metaInfoOnly(id = 'app-1'): AppMeta {
|
||||
return {
|
||||
info: {
|
||||
id,
|
||||
name: 'Greeter',
|
||||
description: '',
|
||||
mode: 'chat',
|
||||
updated_at: undefined,
|
||||
service_api_enabled: false,
|
||||
is_agent: false,
|
||||
},
|
||||
parameters: null,
|
||||
inputSchema: null,
|
||||
coveredFields: new Set([FieldInfo]),
|
||||
}
|
||||
}
|
||||
|
||||
describe('app-info disk cache', () => {
|
||||
let dir: string
|
||||
let prevCacheDir: string | undefined
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-cache-'))
|
||||
prevCacheDir = process.env[ENV_CACHE_DIR]
|
||||
process.env[ENV_CACHE_DIR] = dir
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prevCacheDir === undefined) delete process.env[ENV_CACHE_DIR]
|
||||
else process.env[ENV_CACHE_DIR] = prevCacheDir
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('round-trips an entry across reloads', async () => {
|
||||
const c1 = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await c1.set('http://localhost:9999', 'app-1', metaInfoOnly())
|
||||
|
||||
const c2 = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
const got = c2.get('http://localhost:9999', 'app-1')
|
||||
expect(got).toBeDefined()
|
||||
expect(got?.meta.info?.id).toBe('app-1')
|
||||
expect(got?.meta.coveredFields.has(FieldInfo)).toBe(true)
|
||||
})
|
||||
|
||||
it('isFresh respects TTL', async () => {
|
||||
const now = new Date('2026-05-09T00:00:00Z')
|
||||
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO), now: () => now })
|
||||
await c.set('h', 'app-1', metaInfoOnly())
|
||||
const r = c.get('h', 'app-1')
|
||||
expect(r).toBeDefined()
|
||||
expect(c.isFresh(r!, now)).toBe(true)
|
||||
expect(c.isFresh(r!, new Date(now.getTime() + APP_INFO_TTL_MS - 1))).toBe(true)
|
||||
expect(c.isFresh(r!, new Date(now.getTime() + APP_INFO_TTL_MS))).toBe(false)
|
||||
expect(c.isFresh(r!, new Date(now.getTime() + APP_INFO_TTL_MS + 60_000))).toBe(false)
|
||||
})
|
||||
|
||||
it('keys by (host, app_id) — different hosts isolate', async () => {
|
||||
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await c.set('h1', 'app-1', metaInfoOnly())
|
||||
expect(c.get('h2', 'app-1')).toBeUndefined()
|
||||
expect(c.get('h1', 'app-1')).toBeDefined()
|
||||
})
|
||||
|
||||
it('delete removes entry from disk', async () => {
|
||||
const c1 = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await c1.set('h', 'app-1', metaInfoOnly())
|
||||
await c1.delete('h', 'app-1')
|
||||
|
||||
const c2 = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
expect(c2.get('h', 'app-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('writes file with 0600 permission', async () => {
|
||||
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await c.set('h', 'app-1', metaInfoOnly())
|
||||
const { stat } = await import('node:fs/promises')
|
||||
const s = await stat(appInfoPath(dir))
|
||||
if (platform() !== 'win32') expect(s.mode & 0o777).toBe(0o600)
|
||||
})
|
||||
|
||||
it('missing cache file is not an error', async () => {
|
||||
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
expect(c.get('h', 'app-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('corrupt cache file is treated as empty', async () => {
|
||||
await writeFile(appInfoPath(dir), ': : not valid yaml', 'utf8')
|
||||
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
expect(c.get('h', 'app-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops a corrupt single entry but keeps valid siblings', async () => {
|
||||
// Seed a real serialized entry via set() — no hand-authored on-disk shape.
|
||||
const seed = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await seed.set('h', 'app-2', metaInfoOnly('app-2'))
|
||||
|
||||
// Inject a corrupt sibling alongside the real one.
|
||||
const file = load(await readFile(appInfoPath(dir), 'utf8')) as {
|
||||
entries: Record<string, unknown>
|
||||
}
|
||||
file.entries['h::app-1'] = 'corrupted-string-not-object'
|
||||
await writeFile(appInfoPath(dir), dump(file), 'utf8')
|
||||
|
||||
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
expect(c.get('h', 'app-1')).toBeUndefined()
|
||||
expect(c.get('h', 'app-2')?.meta.info?.id).toBe('app-2')
|
||||
})
|
||||
|
||||
it('treats a non-object entries map as empty', async () => {
|
||||
await writeFile(appInfoPath(dir), dump({ entries: 'not-an-object' }), 'utf8')
|
||||
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
expect(c.get('h', 'app-1')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('updates same key in place (no growth)', async () => {
|
||||
const c = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
|
||||
await c.set('h', 'app-1', metaInfoOnly())
|
||||
const slim: AppMeta = {
|
||||
...metaInfoOnly(),
|
||||
coveredFields: new Set([FieldInfo, FieldParameters]),
|
||||
parameters: { opening_statement: 'hi' },
|
||||
}
|
||||
await c.set('h', 'app-1', slim)
|
||||
const raw = await readFile(appInfoPath(dir), 'utf8')
|
||||
const parsed = load(raw) as { entries: Record<string, unknown> }
|
||||
expect(Object.keys(parsed.entries)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
129
cli/src/cache/app-info.ts
vendored
129
cli/src/cache/app-info.ts
vendored
@@ -1,129 +0,0 @@
|
||||
import type { Store } from '@/store/store'
|
||||
import type { AppMeta, AppMetaCacheRecord, AppMetaFieldKey } from '@/types/app-meta'
|
||||
import { CACHE_APP_INFO, getCache } from '@/store/manager'
|
||||
import { FieldInfo, FieldInputSchema, FieldParameters } from '@/types/app-meta'
|
||||
|
||||
export const APP_INFO_TTL_MS = 60 * 60 * 1000
|
||||
|
||||
// All entries live under one top-level key; the inner record uses
|
||||
// `host::appId` composites that contain `::` (never `.`), so they're
|
||||
// safe as map keys without colliding with Store's dot-path semantics.
|
||||
const ENTRIES_KEY = { key: 'entries', default: {} as Record<string, DiskEntry> } as const
|
||||
|
||||
type DiskEntry = {
|
||||
meta: SerializedMeta
|
||||
fetched_at: string
|
||||
}
|
||||
|
||||
type SerializedMeta = {
|
||||
info: AppMeta['info']
|
||||
parameters: unknown
|
||||
input_schema: unknown
|
||||
covered_fields: AppMetaFieldKey[]
|
||||
}
|
||||
|
||||
export type AppInfoCache = {
|
||||
get: (host: string, appId: string) => AppMetaCacheRecord | undefined
|
||||
set: (host: string, appId: string, meta: AppMeta) => Promise<void>
|
||||
delete: (host: string, appId: string) => Promise<void>
|
||||
isFresh: (record: AppMetaCacheRecord, now?: Date) => boolean
|
||||
}
|
||||
|
||||
type State = {
|
||||
entries: Map<string, AppMetaCacheRecord>
|
||||
}
|
||||
|
||||
export type AppInfoCacheOptions = {
|
||||
readonly store?: Store
|
||||
readonly ttlMs?: number
|
||||
readonly now?: () => Date
|
||||
}
|
||||
|
||||
export async function loadAppInfoCache(opts: AppInfoCacheOptions = {}): Promise<AppInfoCache> {
|
||||
const store = opts.store ?? getCache(CACHE_APP_INFO)
|
||||
const ttlMs = opts.ttlMs ?? APP_INFO_TTL_MS
|
||||
const state: State = { entries: await readEntries(store) }
|
||||
return {
|
||||
get: (host, appId) => state.entries.get(key(host, appId)),
|
||||
set: async (host, appId, meta) => {
|
||||
const record: AppMetaCacheRecord = {
|
||||
meta,
|
||||
fetchedAt: (opts.now ?? (() => new Date()))().toISOString(),
|
||||
}
|
||||
state.entries.set(key(host, appId), record)
|
||||
await writeEntries(store, state.entries)
|
||||
},
|
||||
delete: async (host, appId) => {
|
||||
state.entries.delete(key(host, appId))
|
||||
await writeEntries(store, state.entries)
|
||||
},
|
||||
isFresh: (record, now) => {
|
||||
const t = (now ?? new Date()).getTime() - new Date(record.fetchedAt).getTime()
|
||||
return t >= 0 && t < ttlMs
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function key(host: string, appId: string): string {
|
||||
return `${host}::${appId}`
|
||||
}
|
||||
|
||||
async function readEntries(store: Store): Promise<Map<string, AppMetaCacheRecord>> {
|
||||
const out = new Map<string, AppMetaCacheRecord>()
|
||||
let raw: Record<string, DiskEntry>
|
||||
try {
|
||||
raw = await store.get(ENTRIES_KEY)
|
||||
} catch {
|
||||
return out
|
||||
}
|
||||
// A scalar/array survives Object.entries as garbage rather than throwing.
|
||||
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return out
|
||||
|
||||
for (const [k, e] of Object.entries(raw)) {
|
||||
try {
|
||||
out.set(k, deserialize(e))
|
||||
} catch {
|
||||
// Drop unreadable entry → becomes a cache miss → consumer refetches.
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function deserialize(e: DiskEntry): AppMetaCacheRecord {
|
||||
const covered = new Set<AppMetaFieldKey>(filterFields(e.meta.covered_fields))
|
||||
return {
|
||||
meta: {
|
||||
info: e.meta.info,
|
||||
parameters: e.meta.parameters,
|
||||
inputSchema: e.meta.input_schema,
|
||||
coveredFields: covered,
|
||||
},
|
||||
fetchedAt: e.fetched_at,
|
||||
}
|
||||
}
|
||||
|
||||
function filterFields(input: unknown): AppMetaFieldKey[] {
|
||||
if (!Array.isArray(input)) return []
|
||||
const valid = new Set<AppMetaFieldKey>([FieldInfo, FieldParameters, FieldInputSchema])
|
||||
return input.filter(
|
||||
(s): s is AppMetaFieldKey => typeof s === 'string' && valid.has(s as AppMetaFieldKey),
|
||||
)
|
||||
}
|
||||
|
||||
function serialize(record: AppMetaCacheRecord): DiskEntry {
|
||||
return {
|
||||
meta: {
|
||||
info: record.meta.info,
|
||||
parameters: record.meta.parameters,
|
||||
input_schema: record.meta.inputSchema,
|
||||
covered_fields: [...record.meta.coveredFields],
|
||||
},
|
||||
fetched_at: record.fetchedAt,
|
||||
}
|
||||
}
|
||||
|
||||
async function writeEntries(store: Store, entries: Map<string, AppMetaCacheRecord>): Promise<void> {
|
||||
const out: Record<string, DiskEntry> = {}
|
||||
for (const [k, v] of entries) out[k] = serialize(v)
|
||||
await store.set(ENTRIES_KEY, out)
|
||||
}
|
||||
56
cli/src/cache/compat-store.test.ts
vendored
56
cli/src/cache/compat-store.test.ts
vendored
@@ -1,56 +0,0 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
|
||||
import { ENV_CACHE_DIR } from '@/store/dir'
|
||||
import { CACHE_COMPAT, getCache } from '@/store/manager'
|
||||
import { loadCompatStore } from './compat-store'
|
||||
|
||||
const HOST = 'https://cloud.dify.ai'
|
||||
const NOW = new Date('2026-05-20T12:00:00.000Z')
|
||||
|
||||
describe('compat-store', () => {
|
||||
let dir: string
|
||||
let prev: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-compat-'))
|
||||
prev = process.env[ENV_CACHE_DIR]
|
||||
process.env[ENV_CACHE_DIR] = dir
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prev === undefined) delete process.env[ENV_CACHE_DIR]
|
||||
else process.env[ENV_CACHE_DIR] = prev
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const store = (now: Date = NOW) =>
|
||||
loadCompatStore({ store: getCache(CACHE_COMPAT), now: () => now })
|
||||
|
||||
it('is not fresh before anything is marked', async () => {
|
||||
expect((await store()).isFreshCompatible(HOST)).toBe(false)
|
||||
})
|
||||
|
||||
it('is fresh right after markCompatible, and persists across loads', async () => {
|
||||
await (await store()).markCompatible(HOST)
|
||||
expect((await store()).isFreshCompatible(HOST)).toBe(true)
|
||||
})
|
||||
|
||||
it('stays fresh within the 1h TTL', async () => {
|
||||
const past = new Date(NOW.getTime() - 30 * 60 * 1000)
|
||||
await (await store(past)).markCompatible(HOST)
|
||||
expect((await store(NOW)).isFreshCompatible(HOST)).toBe(true)
|
||||
})
|
||||
|
||||
it('expires after the 1h TTL', async () => {
|
||||
const past = new Date(NOW.getTime() - 61 * 60 * 1000)
|
||||
await (await store(past)).markCompatible(HOST)
|
||||
expect((await store(NOW)).isFreshCompatible(HOST)).toBe(false)
|
||||
})
|
||||
|
||||
it('tracks hosts independently', async () => {
|
||||
const s = await store()
|
||||
await s.markCompatible(HOST)
|
||||
expect(s.isFreshCompatible('https://other.dify.ai')).toBe(false)
|
||||
})
|
||||
})
|
||||
67
cli/src/cache/compat-store.ts
vendored
67
cli/src/cache/compat-store.ts
vendored
@@ -1,67 +0,0 @@
|
||||
import type { Store } from '@/store/store'
|
||||
import { CACHE_COMPAT, getCache } from '@/store/manager'
|
||||
|
||||
// How long a host stays "known compatible" before difyctl re-probes /_version.
|
||||
export const COMPAT_TTL_MS = 60 * 60 * 1000
|
||||
|
||||
// Only *positive* (compatible) verdicts are cached — never "too old". A host that
|
||||
// was too old is re-probed every time, so a just-upgraded server clears a previous
|
||||
// block immediately instead of staying locked out for the whole TTL.
|
||||
const COMPATIBLE_KEY = { key: 'compatible', default: {} as Record<string, string> } as const
|
||||
|
||||
export type CompatStore = {
|
||||
readonly isFreshCompatible: (host: string, now?: Date) => boolean
|
||||
readonly markCompatible: (host: string, now?: Date) => Promise<void>
|
||||
}
|
||||
|
||||
export type CompatStoreOptions = {
|
||||
readonly store?: Store
|
||||
readonly now?: () => Date
|
||||
readonly ttlMs?: number
|
||||
}
|
||||
|
||||
export async function loadCompatStore(opts: CompatStoreOptions = {}): Promise<CompatStore> {
|
||||
const store = opts.store ?? getCache(CACHE_COMPAT)
|
||||
const ttlMs = opts.ttlMs ?? COMPAT_TTL_MS
|
||||
const clock = opts.now ?? (() => new Date())
|
||||
const memory = await readCompatible(store)
|
||||
|
||||
return {
|
||||
isFreshCompatible: (host, now) => {
|
||||
const last = memory.get(host)
|
||||
if (last === undefined) return false
|
||||
const elapsed = Math.max(0, (now ?? clock()).getTime() - last)
|
||||
return elapsed < ttlMs
|
||||
},
|
||||
markCompatible: async (host, now) => {
|
||||
const stamp = (now ?? clock()).getTime()
|
||||
memory.set(host, stamp)
|
||||
// Re-read disk inside the write cycle so concurrent processes touching
|
||||
// different hosts don't clobber each other's stamps.
|
||||
const onDisk = await readCompatible(store)
|
||||
onDisk.set(host, stamp)
|
||||
await writeCompatible(store, onDisk)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function readCompatible(store: Store): Promise<Map<string, number>> {
|
||||
const out = new Map<string, number>()
|
||||
let raw: Record<string, string>
|
||||
try {
|
||||
raw = await store.get(COMPATIBLE_KEY)
|
||||
} catch {
|
||||
return out
|
||||
}
|
||||
for (const [host, iso] of Object.entries(raw)) {
|
||||
const t = Date.parse(iso)
|
||||
if (!Number.isNaN(t)) out.set(host, t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function writeCompatible(store: Store, state: Map<string, number>): Promise<void> {
|
||||
const compatible: Record<string, string> = {}
|
||||
for (const [host, t] of state) compatible[host] = new Date(t).toISOString()
|
||||
await store.set(COMPATIBLE_KEY, compatible)
|
||||
}
|
||||
98
cli/src/cache/nudge-store.test.ts
vendored
98
cli/src/cache/nudge-store.test.ts
vendored
@@ -1,98 +0,0 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { load } from 'js-yaml'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'
|
||||
import { ENV_CACHE_DIR } from '@/store/dir'
|
||||
import { CACHE_NUDGE, cachePath, getCache } from '@/store/manager'
|
||||
import { loadNudgeStore, WARN_INTERVAL_MS } from './nudge-store'
|
||||
|
||||
function nudgeStorePath(dir: string): string {
|
||||
return cachePath(dir, CACHE_NUDGE)
|
||||
}
|
||||
|
||||
const HOST = 'https://cloud.dify.ai'
|
||||
|
||||
describe('NudgeStore', () => {
|
||||
let dir: string
|
||||
let prevCacheDir: string | undefined
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'difyctl-nudge-'))
|
||||
prevCacheDir = process.env[ENV_CACHE_DIR]
|
||||
process.env[ENV_CACHE_DIR] = dir
|
||||
})
|
||||
afterEach(async () => {
|
||||
if (prevCacheDir === undefined) delete process.env[ENV_CACHE_DIR]
|
||||
else process.env[ENV_CACHE_DIR] = prevCacheDir
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('canWarn=true when no prior record exists', async () => {
|
||||
const store = await loadNudgeStore({ store: getCache(CACHE_NUDGE) })
|
||||
expect(store.canWarn(HOST)).toBe(true)
|
||||
})
|
||||
|
||||
it('canWarn=false within the silence window, true past it', async () => {
|
||||
const t0 = new Date('2026-05-19T12:00:00.000Z')
|
||||
const store = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t0 })
|
||||
await store.markWarned(HOST)
|
||||
expect(store.canWarn(HOST, new Date('2026-05-19T18:00:00.000Z'))).toBe(false)
|
||||
expect(store.canWarn(HOST, new Date('2026-05-20T12:00:00.000Z'))).toBe(true)
|
||||
})
|
||||
|
||||
it('canWarn clamps negative elapsed under clock skew (treats as still in window)', async () => {
|
||||
const t0 = new Date('2026-05-19T12:00:00.000Z')
|
||||
const store = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t0 })
|
||||
await store.markWarned(HOST)
|
||||
const pastClock = new Date('2026-05-19T11:00:00.000Z') // clock moved backwards 1h
|
||||
expect(store.canWarn(HOST, pastClock)).toBe(false)
|
||||
})
|
||||
|
||||
it('markWarned persists across store reloads', async () => {
|
||||
const t0 = new Date('2026-05-19T12:00:00.000Z')
|
||||
const s1 = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t0 })
|
||||
await s1.markWarned(HOST)
|
||||
const s2 = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t0 })
|
||||
expect(s2.canWarn(HOST)).toBe(false)
|
||||
})
|
||||
|
||||
it('treats a corrupt cache file as empty', async () => {
|
||||
const path = nudgeStorePath(dir)
|
||||
await writeCacheFile(path, '{ not valid json')
|
||||
const store = await loadNudgeStore({ store: getCache(CACHE_NUDGE) })
|
||||
expect(store.canWarn(HOST)).toBe(true)
|
||||
})
|
||||
|
||||
it('writes ISO timestamps under warned/<host> on disk', async () => {
|
||||
const t = new Date('2026-05-19T12:00:00.000Z')
|
||||
const store = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t })
|
||||
await store.markWarned(HOST)
|
||||
const raw = await readFile(nudgeStorePath(dir), 'utf8')
|
||||
const parsed = load(raw) as Record<string, unknown>
|
||||
expect((parsed.warned as Record<string, string>)[HOST]).toBe(t.toISOString())
|
||||
})
|
||||
|
||||
it('concurrent writers across different hosts: both stamps survive (merge-on-write)', async () => {
|
||||
// Two stores independently loaded (simulating two CLI processes), each
|
||||
// warns about a different host. Without merge-on-write the second writer
|
||||
// would clobber the first.
|
||||
const t = new Date('2026-05-19T12:00:00.000Z')
|
||||
const a = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t })
|
||||
const b = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t })
|
||||
await a.markWarned('https://a.example')
|
||||
await b.markWarned('https://b.example')
|
||||
const reread = await loadNudgeStore({ store: getCache(CACHE_NUDGE), now: () => t })
|
||||
expect(reread.canWarn('https://a.example')).toBe(false)
|
||||
expect(reread.canWarn('https://b.example')).toBe(false)
|
||||
})
|
||||
|
||||
it('exposes WARN_INTERVAL_MS as 24h', () => {
|
||||
expect(WARN_INTERVAL_MS).toBe(24 * 60 * 60 * 1000)
|
||||
})
|
||||
})
|
||||
|
||||
async function writeCacheFile(path: string, body: string): Promise<void> {
|
||||
const { mkdir } = await import('node:fs/promises')
|
||||
await mkdir(dirname(path), { recursive: true })
|
||||
await writeFile(path, body)
|
||||
}
|
||||
67
cli/src/cache/nudge-store.ts
vendored
67
cli/src/cache/nudge-store.ts
vendored
@@ -1,67 +0,0 @@
|
||||
import type { Store } from '@/store/store'
|
||||
import { CACHE_NUDGE, getCache } from '@/store/manager'
|
||||
|
||||
export const WARN_INTERVAL_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
// Single top-level key holding host→ISO map. Hosts contain dots
|
||||
// (cloud.dify.ai), so we cannot use them as Store paths directly —
|
||||
// `doSet` would split on dots and create nested objects.
|
||||
const WARNED_KEY = { key: 'warned', default: {} as Record<string, string> } as const
|
||||
|
||||
export type NudgeStore = {
|
||||
readonly canWarn: (host: string, now?: Date) => boolean
|
||||
readonly markWarned: (host: string, now?: Date) => Promise<void>
|
||||
}
|
||||
|
||||
export type NudgeStoreOptions = {
|
||||
readonly store?: Store
|
||||
readonly now?: () => Date
|
||||
readonly intervalMs?: number
|
||||
}
|
||||
|
||||
export async function loadNudgeStore(opts: NudgeStoreOptions = {}): Promise<NudgeStore> {
|
||||
const store = opts.store ?? getCache(CACHE_NUDGE)
|
||||
const intervalMs = opts.intervalMs ?? WARN_INTERVAL_MS
|
||||
const clock = opts.now ?? (() => new Date())
|
||||
const memory = await readWarned(store)
|
||||
|
||||
return {
|
||||
canWarn: (host, now) => {
|
||||
const last = memory.get(host)
|
||||
if (last === undefined) return true
|
||||
const elapsed = Math.max(0, (now ?? clock()).getTime() - last)
|
||||
return elapsed >= intervalMs
|
||||
},
|
||||
markWarned: async (host, now) => {
|
||||
const stamp = (now ?? clock()).getTime()
|
||||
memory.set(host, stamp)
|
||||
// Re-read disk inside the write cycle so concurrent processes touching
|
||||
// different hosts don't clobber each other's stamps. Same-host writers
|
||||
// converge on a near-identical timestamp, so order doesn't matter.
|
||||
const onDisk = await readWarned(store)
|
||||
onDisk.set(host, stamp)
|
||||
await writeWarned(store, onDisk)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function readWarned(store: Store): Promise<Map<string, number>> {
|
||||
const out = new Map<string, number>()
|
||||
let raw: Record<string, string>
|
||||
try {
|
||||
raw = await store.get(WARNED_KEY)
|
||||
} catch {
|
||||
return out
|
||||
}
|
||||
for (const [host, iso] of Object.entries(raw)) {
|
||||
const t = Date.parse(iso)
|
||||
if (!Number.isNaN(t)) out.set(host, t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function writeWarned(store: Store, state: Map<string, number>): Promise<void> {
|
||||
const warned: Record<string, string> = {}
|
||||
for (const [host, t] of state) warned[host] = new Date(t).toISOString()
|
||||
await store.set(WARNED_KEY, warned)
|
||||
}
|
||||
7
cli/src/call/errors.ts
Normal file
7
cli/src/call/errors.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { BaseError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
|
||||
/** Every rejection of caller-supplied operation input, from load through request building. */
|
||||
export function inputInvalid(message: string, cause?: unknown): BaseError {
|
||||
return new BaseError({ code: ErrorCode.InputInvalid, message, cause })
|
||||
}
|
||||
196
cli/src/call/execute.test.ts
Normal file
196
cli/src/call/execute.test.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import type { TestWorld } from '@test/fixtures/kernel'
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { testContext } from '@test/fixtures/kernel'
|
||||
import { afterEach, expect, it } from 'vite-plus/test'
|
||||
import { commands } from '@/plugins/commands'
|
||||
|
||||
const worlds: TestWorld[] = []
|
||||
async function world(argv: string[]) {
|
||||
const w = await testContext({ login: true, argv })
|
||||
worlds.push(w)
|
||||
return w
|
||||
}
|
||||
afterEach(async () => {
|
||||
for (const w of worlds.splice(0)) await w.stop()
|
||||
})
|
||||
|
||||
it('fills the pin, validates, sends the fingerprint and prints a list with the server hint', async () => {
|
||||
const w = await world(['get', 'console_app', '--input', '{"limit":1}'])
|
||||
expect(await (await w.ctx.get(commands)).run()).toBe(0)
|
||||
expect(w.mock.lastRequest?.path).toBe('/openapi/v1/apps?limit=1&workspace_id=ws-1')
|
||||
expect(w.mock.lastRequest?.headers['x-dify-catalog']).toHaveLength(64)
|
||||
expect(JSON.parse(w.io.outBuf()).hints[0]).toMatchObject({
|
||||
op: 'get.console_app',
|
||||
input: { workspace_id: 'ws-1', limit: 1, page: 2 },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects invalid input with details and the schema, exit 2, before any op request', async () => {
|
||||
const w = await world(['describe', 'console_app', '--input', '{}'])
|
||||
await expect((await w.ctx.get(commands)).run()).rejects.toMatchObject({
|
||||
code: 'input_invalid',
|
||||
details: [{ type: 'required', loc: ['app_id'] }],
|
||||
schema: expect.objectContaining({ type: 'object' }),
|
||||
})
|
||||
expect(w.mock.requestCount).toBe(1) // only the first-run catalog fetch
|
||||
})
|
||||
|
||||
it('refuses --stream on a non-sse op, exit 2', async () => {
|
||||
const w = await world(['describe', 'console_app', '--input', '{"app_id":"app-1"}', '--stream'])
|
||||
await expect((await w.ctx.get(commands)).run()).rejects.toMatchObject({
|
||||
code: 'usage_invalid_flag',
|
||||
message: expect.stringContaining('--stream'),
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses --only without --stream, exit 2, before any op request', async () => {
|
||||
const w = await world([
|
||||
'run',
|
||||
'console_app',
|
||||
'workflow',
|
||||
'--input',
|
||||
'{"app_id":"app-2","inputs":{}}',
|
||||
'--only',
|
||||
'workflow_finished',
|
||||
])
|
||||
await expect((await w.ctx.get(commands)).run()).rejects.toMatchObject({
|
||||
code: 'usage_invalid_flag',
|
||||
message: '--only requires --stream',
|
||||
})
|
||||
expect(w.mock.requestCount).toBe(1) // only the first-run catalog fetch
|
||||
})
|
||||
|
||||
it('warns once on stderr for a deprecated op and still runs it', async () => {
|
||||
const w = await world([
|
||||
'legacy_run',
|
||||
'console_app',
|
||||
'--input',
|
||||
'{"app_id":"app-1","inputs":{},"query":"hi"}',
|
||||
])
|
||||
expect(await (await w.ctx.get(commands)).run()).toBe(0)
|
||||
expect(w.io.errBuf().trim()).toBe('deprecated: legacy_run.console_app')
|
||||
expect(JSON.parse(w.io.outBuf())).toMatchObject({
|
||||
status: 'ended',
|
||||
text: { answer: 'echo: hi' },
|
||||
})
|
||||
})
|
||||
|
||||
it('folds a per-mode run and copies the reply hint', async () => {
|
||||
const w = await world([
|
||||
'run',
|
||||
'console_app',
|
||||
'chat',
|
||||
'--input',
|
||||
'{"app_id":"app-1","inputs":{},"query":"hi"}',
|
||||
])
|
||||
expect(await (await w.ctx.get(commands)).run()).toBe(0)
|
||||
expect(JSON.parse(w.io.outBuf())).toMatchObject({
|
||||
status: 'ended',
|
||||
text: { answer: 'echo: hi' },
|
||||
conversation_id: 'conv-1',
|
||||
hints: [{ op: 'run.console_app.chat', input: { conversation_id: 'conv-1', query: null } }],
|
||||
})
|
||||
})
|
||||
|
||||
it('streams with --stream --only', async () => {
|
||||
const w = await world([
|
||||
'run',
|
||||
'console_app',
|
||||
'workflow',
|
||||
'--input',
|
||||
'{"app_id":"app-2","inputs":{}}',
|
||||
'--stream',
|
||||
'--only',
|
||||
'workflow_finished',
|
||||
])
|
||||
await (await w.ctx.get(commands)).run()
|
||||
const lines = w.io
|
||||
.outBuf()
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((l) => JSON.parse(l))
|
||||
expect(lines).toHaveLength(1)
|
||||
expect(lines[0].event).toBe('workflow_finished')
|
||||
})
|
||||
|
||||
it('sends multipart when files are given', async () => {
|
||||
const seed = await testContext({ login: true })
|
||||
worlds.push(seed)
|
||||
const tmp = join(seed.dir, 'r.pdf')
|
||||
writeFileSync(tmp, 'pdf')
|
||||
const w = await testContext({
|
||||
login: true,
|
||||
argv: [
|
||||
'run',
|
||||
'console_app',
|
||||
'workflow',
|
||||
'--input',
|
||||
JSON.stringify({ app_id: 'app-2', inputs: {}, files: { doc: tmp } }),
|
||||
],
|
||||
reuseDirOf: seed,
|
||||
})
|
||||
worlds.push(w)
|
||||
await (await w.ctx.get(commands)).run()
|
||||
expect(w.mock.lastRequest?.contentType).toMatch(/^multipart\/form-data/)
|
||||
expect(w.mock.lastRunParts?.['files[doc]']).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('a paused run is exit 0 with status suspended and the server hints', async () => {
|
||||
const w = await world([
|
||||
'run',
|
||||
'console_app',
|
||||
'workflow',
|
||||
'--input',
|
||||
'{"app_id":"app-2","inputs":{}}',
|
||||
])
|
||||
w.mock.setScenario('hitl-pause')
|
||||
expect(await (await w.ctx.get(commands)).run()).toBe(0)
|
||||
expect(JSON.parse(w.io.outBuf())).toMatchObject({
|
||||
status: 'suspended',
|
||||
hints: [{ op: 'submit.run.form', input: { form_token: 'ft-hitl-1', action: 'submit' } }],
|
||||
})
|
||||
})
|
||||
|
||||
it('a stale catalog is refreshed once and the op is retried', async () => {
|
||||
const w = await world(['help'])
|
||||
await (await w.ctx.get(commands)).run()
|
||||
w.mock.setScenario('catalog-changed')
|
||||
const w2 = await testContext({
|
||||
login: true,
|
||||
argv: ['ping', 'workspace', '--input', '{"workspace_id":"ws-1"}'],
|
||||
reuseDirOf: w,
|
||||
})
|
||||
worlds.push(w2)
|
||||
expect(await (await w2.ctx.get(commands)).run()).toBe(0)
|
||||
})
|
||||
|
||||
it('a stream that dies mid-run folds as incomplete, exit 0, with one stderr notice', async () => {
|
||||
const w = await world([
|
||||
'run',
|
||||
'console_app',
|
||||
'chat',
|
||||
'--input',
|
||||
'{"app_id":"app-1","inputs":{},"query":"hi"}',
|
||||
])
|
||||
w.mock.setScenario('broken-stream')
|
||||
expect(await (await w.ctx.get(commands)).run()).toBe(0)
|
||||
expect(JSON.parse(w.io.outBuf())).toMatchObject({
|
||||
status: 'incomplete',
|
||||
text: { answer: 'partial' },
|
||||
})
|
||||
expect(w.io.errBuf().trim().split('\n')).toHaveLength(1)
|
||||
expect(w.io.errBuf()).toMatch(/^stream ended early: /)
|
||||
})
|
||||
|
||||
it('registers the stream abort as a deferred cleanup', async () => {
|
||||
const w = await world([
|
||||
'run',
|
||||
'console_app',
|
||||
'workflow',
|
||||
'--input',
|
||||
'{"app_id":"app-2","inputs":{}}',
|
||||
])
|
||||
await (await w.ctx.get(commands)).run()
|
||||
expect(w.ctx.deferred).toHaveLength(1)
|
||||
})
|
||||
96
cli/src/call/execute.ts
Normal file
96
cli/src/call/execute.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { CallFlags } from '@/call/flags'
|
||||
import type { CommandContext } from '@/plugins/base'
|
||||
import type { CatalogOp } from '@/plugins/catalog'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { readLocalFile } from '@/call/files'
|
||||
import { unsupportedFlags } from '@/call/flags'
|
||||
import { applyPins, loadInput, resolveFileRefs } from '@/call/input'
|
||||
import { rendererFor } from '@/call/render/index'
|
||||
import { buildRequest } from '@/call/request'
|
||||
import { validateInput } from '@/call/validate'
|
||||
import { BaseError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { runSignal } from '@/plugins/commands/cancel'
|
||||
import { helpHint, Outcome } from '@/plugins/commands/command'
|
||||
import { http } from '@/plugins/http'
|
||||
import { io } from '@/plugins/io'
|
||||
import { session } from '@/plugins/session'
|
||||
import { KIND } from '@/protocol/kinds'
|
||||
import { spacedId } from '@/protocol/op-id'
|
||||
import { PIN } from '@/protocol/pins'
|
||||
|
||||
/** One catalog operation, its call flags, and the fields typed as flags of their own. */
|
||||
export type CallRequest = Readonly<{
|
||||
id: string
|
||||
op: CatalogOp
|
||||
flags: CallFlags
|
||||
fields: Record<string, unknown>
|
||||
}>
|
||||
|
||||
const FILE_ENCODING = 'utf8'
|
||||
const DEPRECATED_NOTICE = 'deprecated'
|
||||
const ONLY_NEEDS_STREAM = '--only requires --stream'
|
||||
|
||||
function assertSupportedFlags(kind: string, flags: CallFlags): void {
|
||||
const [refused] = unsupportedFlags(kind, flags)
|
||||
if (refused !== undefined) {
|
||||
throw new BaseError({
|
||||
code: ErrorCode.UsageInvalidFlag,
|
||||
message: `--${refused} is not supported by ${kind} operations`,
|
||||
})
|
||||
}
|
||||
// --only filters the events --stream emits; on its own it filters nothing.
|
||||
if (flags.only !== undefined && flags.stream !== true)
|
||||
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: ONLY_NEEDS_STREAM })
|
||||
}
|
||||
|
||||
async function readAll(stream: NodeJS.ReadableStream): Promise<string> {
|
||||
stream.setEncoding(FILE_ENCODING)
|
||||
let text = ''
|
||||
for await (const chunk of stream) text += chunk
|
||||
return text
|
||||
}
|
||||
|
||||
function assertValidInput(op: CatalogOp, opId: string, input: Record<string, unknown>): void {
|
||||
const details = validateInput(op.input, input)
|
||||
if (details.length === 0) return
|
||||
throw new BaseError({
|
||||
code: ErrorCode.InputInvalid,
|
||||
message: `invalid input for "${opId}"`,
|
||||
hint: helpHint(spacedId(opId)),
|
||||
details: [...details],
|
||||
schema: op.input,
|
||||
})
|
||||
}
|
||||
|
||||
export async function executeCall(req: CallRequest, ctx: CommandContext): Promise<Outcome> {
|
||||
assertSupportedFlags(req.op.kind, req.flags)
|
||||
|
||||
const out = await ctx.get(io)
|
||||
const source = {
|
||||
stdin: () => readAll(out.streams.in),
|
||||
readFile: (path: string) => readFile(path, FILE_ENCODING),
|
||||
}
|
||||
const whole = await loadInput({ ...source, raw: req.flags.input })
|
||||
const fields = await resolveFileRefs(req.fields, req.op.input, source)
|
||||
const workspaceId = await (await ctx.get(session)).workspaceId()
|
||||
const body = applyPins({ ...whole, ...fields }, req.op.input, { [PIN.Workspace]: workspaceId })
|
||||
assertValidInput(req.op, req.id, body)
|
||||
|
||||
if (req.op.deprecated) out.notice(`${DEPRECATED_NOTICE}: ${req.id}`)
|
||||
|
||||
// SIGINT cancels the whole invocation; this controller ends only this call's stream.
|
||||
const call = new AbortController()
|
||||
if (req.op.kind === KIND.Sse) ctx.defer(() => call.abort())
|
||||
const signal = AbortSignal.any([runSignal(), call.signal])
|
||||
|
||||
const res = await (
|
||||
await ctx.get(http)
|
||||
).request(async (cat) => ({
|
||||
...(await buildRequest(cat.opOrThrow(req.id), body, { readFile: readLocalFile })),
|
||||
signal,
|
||||
}))
|
||||
|
||||
const code = await rendererFor(req.op.kind, out)(res, out, req.flags, req.id)
|
||||
return new Outcome(code)
|
||||
}
|
||||
8
cli/src/call/files.ts
Normal file
8
cli/src/call/files.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { FileReader } from './request'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { basename } from 'node:path'
|
||||
|
||||
export const readLocalFile: FileReader = async (path) => ({
|
||||
bytes: await readFile(path),
|
||||
name: basename(path),
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user