Compare commits

...

26 Commits

Author SHA1 Message Date
GareArc
c27bbde168 feat(api): let agents edit, test and publish workflow apps over openapi
DSL export returns a draft_hash covering the graph, features, environment
and conversation variables; an import that sends an older hash fails
instead of wiping newer edits. A blank secret imported over an app keeps
the stored value. The incompatible-node error names the nodes.

New openapi ops: draft test runs, run history with node steps, publish,
version list and restore, and environment variables (secrets masked on
read). Publish shares one service method with the console.
2026-09-28 03:40:15 -07:00
GareArc
ce8836f30e fix(cli): show the error text of a failed import 2026-09-28 01:56:23 -07:00
GareArc
8976e91bbc Merge remote-tracking branch 'origin/main' into feat/difyctl-v2
# Conflicts:
#	pnpm-lock.yaml
2026-09-27 21:27:13 -07:00
GareArc
6021f0e2d2 Merge remote-tracking branch 'origin/main' into feat/difyctl-v2
# Conflicts:
#	api/controllers/openapi/app_dsl.py
2026-09-25 04:19:37 -07:00
GareArc
ab49e9eaae feat(cli): install skills from GitHub instead of the binary
difyctl no longer embeds skills. `get skills` and `install skills` read
the standard Agent Skills layout from a GitHub folder or a local folder
(--from). The default is the repo's skills/ folder at the commit the
binary was built from. One Trees API call lists the files; each file
comes from raw.githubusercontent.com and is checked against its git
hash. No git is needed. Skills may now carry scripts and assets.

The skill generator, its generated module and skills:gen/check are gone.
2026-09-25 03:52:20 -07:00
GareArc
01a4939731 refactor(openapi,cli): fold list into get and remove/revoke into delete
Follows kubectl: get lists a collection, describe shows one item, delete
removes one. list.* ops become get.*, the single-item get.* ops become
describe.* (describe.workspace, describe.account, describe.run.form),
remove.workspace.member and revoke.account.* become delete.*, and
stream.run.event becomes get.run.event. The local commands follow:
get skills, delete cache.
2026-09-23 17:11:10 -07:00
GareArc
5095851c4d Merge remote-tracking branch 'origin/main' into feat/difyctl-v2
# Conflicts:
#	pnpm-lock.yaml
#	pnpm-workspace.yaml
2026-09-23 16:47:08 -07:00
GareArc
ff0283417e refactor(openapi,cli): verb-first op ids and commands
Catalog op ids now lead with the verb: `console_app.workflow.run` is
`run.console_app.workflow`, `workspace.members.invite` is
`invite.workspace.member`. Resources are singular, as in kubectl. Ids
whose last word was not a plain verb now read as verb + object:
`set.workspace.member_role`, `confirm.console_app.dsl_import`,
`revoke.account.current_session`, `stream.run.event`. The CLI keeps
typing an id as its dotted words, so commands read
`difyctl run console_app workflow`.

The local commands follow the same order by moving their folders:
`use workspace`, `get/set/unset config`, `refresh/clear cache`,
`list/install skills`.

Server hints build their op ids from each route's declaration, so they
follow without change. Tests, the mock catalog, the skill, READMEs and
the generated API docs are updated to the new ids.
2026-09-23 16:36:48 -07:00
GareArc
4f5c73f3c6 refactor(cli): remove the hidden ops and call commands
Every catalog op is already a spaced command that takes --input, and help
lists and describes them, so `ops`, `ops describe` and `call` were
duplicates. A dotted id from a server hint maps to its command by turning
the dots into spaces.

The ops plugin keeps only the command tree; its list, describe, resolve
and command methods had no other users. Op help now shows the spaced
usage line. The op-running tests move to src/call/execute.test.ts and run
through spaced commands. The skill, READMEs, ARD and smoke script follow.
2026-09-22 23:20:09 -07:00
GareArc
651daa39be Merge remote-tracking branch 'origin/main' into feat/difyctl-v2
# Conflicts:
#	pnpm-lock.yaml
2026-09-22 22:46:00 -07:00
GareArc
6179a6a0b7 feat(cli): login --no-wait and --resume for agents that cannot keep a process alive
Agent sandboxes kill background jobs between turns, so a blocking login
died before the user approved and the token never arrived.

`login --no-wait` requests the code, prints it, saves the pending login
(server, insecure, no_keyring, device code) to login-pending.yml under the
config dir, and exits. `login --resume` polls once: it prints status
pending while the user has not approved, saves the login on approval, and
drops the pending file on approval, denial or expiry.

The poll result handling moves out of the wait loop so both paths share it.
Help examples and the skill point sandboxed agents at the new flags.
2026-09-22 22:22:24 -07:00
GareArc
a44dae75c2 feat(cli): login --no-keyring and sandbox guidance in help
`login --no-keyring` skips the keyring probe and keeps the token in
tokens.yml under the config dir. On Linux hosts without a secret service the
keyring library falls back to the kernel keyutils store, which passes the
probe but does not outlive the session, so an agent in a sandbox found its
login file with no token behind it. The flag replaces the test-only
DIFY_E2E_NO_KEYRING env.

Login help now carries the agent rule: the summary says it blocks and must
run in the background and never be cancelled, every flag has a description,
and a second example shows the sandbox form with --no-keyring and
DIFY_CONFIG_DIR on persistent storage. The skill and README say the same.
2026-09-22 22:05:08 -07:00
GareArc
021ac2b7e1 feat(cli): text help and errors on a terminal, catalog ops as spaced commands
Help and errors render as text when the stream is a terminal and as JSON in
a pipe; `--json` and `DIFY_OUTPUT=json` force JSON. Results and streams stay
JSON in every mode. The error text is v1's renderer over the fixed envelope.

Every catalog op is merged into the command tree at run time as the spaced
form of its id (`console_app workflow run`), with its top-level input fields
as flags, coerced by one JSON Schema shape normalizer. Help lists statics and
ops as one tree; `call <id>` stays as the hidden id form and `ops` is hidden.
Bare `difyctl` prints a pointer to help and the skill installer.

The Command contract carries what the op adapter needs: `schema()` is the
documented input, `flags()` what argv accepts, `facets()` the op half of a
descriptor, and `finalize(input, path)` the one input gate. The ops service
owns the op command tree and adapter; the pipeline has no op-vs-static branch.
`View` moves to `sys/io`, so no plugin imports another plugin's private file.

api: openapi callers get the full node event payloads. `InvokeFrom.OPENAPI`
joins DEBUGGER and SERVICE_API in the response converter, so `node_started`
and `node_finished` carry inputs, outputs and execution metadata instead of
null.

The skill and README teach agents to run `login --no-browser` in the
background and relay the url and code, never cancel it.
2026-09-22 21:04:31 -07:00
GareArc
100fff4ba3 docs(cli): teach the help views in the base skill, README and ARD
The skill explains the id grammar and the four help views and lists no
ids. The ARD drops the compact writer it still described and names the
discovery module.
2026-09-22 04:33:30 -07:00
GareArc
4349c67a79 feat(cli): help discovers commands and ops: map, namespace, id, search
`help` prints the map of both sides: static heads with their verbs, op
heads with a count and their groups. One word that is a namespace lists
everything under it; one word that is an op id prints its descriptor;
anything else is a ranked search across both. `--full` keeps the flat
list. Views that need ops fetch the catalog first; without a login or a
reachable server the static half still prints, with one stderr line.
2026-09-22 04:33:30 -07:00
GareArc
2f0923be28 feat(cli): pure discovery module: id tree and ranked search
Namespaces are every proper prefix of an id. Search is BM25 over id
tokens, summary, example titles and input schema text, with a four-rung
match ladder (exact, term prefix, token prefix for compounds, one edit
for typos). Live entries rank above deprecated ones regardless of score.
Nothing here does I/O; the help pipeline wires it up next.
2026-09-22 04:33:30 -07:00
GareArc
b2516d5baa refactor(cli): stop reading tags from the catalog
The server no longer publishes them; they only repeated the op id's
first segment.
2026-09-22 04:33:30 -07:00
GareArc
623cd905e3 feat(cli): brief help lists and indented JSON output
`help` and `help <namespace>` now list only ids and summaries; `--full`
adds each row's descriptor. The detail of one command or op stays with
`<cmd> --help` and `ops describe`.

Every JSON result on stdout is indented the way help already was. The
compact writer is gone; `--stream` still prints one event per line.
2026-09-22 04:33:30 -07:00
GareArc
02ba22b6ed test(cli): stop naming the removed :run route in the catalog fixtures
The server deleted `console_app.run`. The fixtures keep one deprecated op
so the deprecation notice stays tested, but under a synthetic id that no
longer claims a route the server does not have.
2026-09-22 04:33:29 -07:00
GareArc
96b10658c9 fix(cli): name an unnamed download after the op and its content type
A file response without Content-Disposition used to land as download.bin.
It is now <op-id>.<ext>, the extension read from the content type, with
bin only for a type the table does not know. --output still overrides.
2026-09-22 04:33:29 -07:00
GareArc
da4e0a9af3 refactor(cli): review fixes for errors, types and stream output
- Domain failures throw BaseError, never a raw Error.
- Storage mode and fold status types derive from their constant sets.
- One shared not-logged-in error and login hint, named from BINARY.
- Drop the Retry-After hint; 429 stays exit 7 with no wait advice.
- `--stream` prints each event's data verbatim instead of re-encoded.
2026-09-22 04:33:29 -07:00
GareArc
8c639df933 fix(ci): keep skill sources LF on every platform
The Windows runner checks skills/ out with CRLF, the frontmatter regex
in the skill generator expects LF, and skills:check fails with
'frontmatter name must be "difyctl"'. Pin skills/ to LF the same way
*.generated.ts already is.
2026-09-22 04:33:29 -07:00
GareArc
ddecdb6257 feat(cli): ship the agent skills as a collection with list and install
Skills are plain markdown under skills/ at the repo root, one folder per
skill. cli/scripts/generate-skill-collection.ts embeds the folder into
src/skills/collection.generated.ts, committed and drift-checked by
`pnpm skills:check` like the command tree.

`skills list` prints each skill's name and description. `skills install
<dir>` writes the whole collection, or the skills named with --skill,
under <dir>/<name>/, overwriting what is there; <dir> is the agent's
skills root and the caller names it. Re-running after an upgrade is the
refresh. The v1 agent detection, TS skill template, and the --stdout and
--yes flags are removed.
2026-09-22 04:33:29 -07:00
GareArc
263080e907 fix(cli): refetch the catalog on a 404 when the server's catalog moved
A route the server dropped answers 404 before its catalog guard runs, so
the stale-catalog 412 never fires for it and the cached op kept pointing
at the dead path. On a 404 the client now fetches the catalog once; if
the fingerprint differs it replaces the cache, rebuilds and resends,
otherwise the 404 stands. A genuine 404 costs one catalog GET.

The mock now mirrors Flask: unknown paths skip the stale guard and answer
the canonical not_found body.
2026-09-22 04:33:29 -07:00
GareArc
4c96259fec fix(cli): fall back to the server's hint when the CLI has none
v1 lifted ErrorBody.hint into the printed hint whenever the CLI had no
remediation of its own. v2 left it nested under `server`, so hints such
as the catalog-stale recovery never reached the top-level envelope.
2026-09-22 04:33:29 -07:00
GareArc
6b7d38f18c feat(cli): difyctl v2 over the server catalog
Replace the v1 command tree, contract client and version compat with a
plugin kernel and three catalog-driven commands: `ops`, `ops describe` and
`call <op-id>`. The server publishes its operations at /openapi/v1/_catalog;
the CLI caches that document, sends its fingerprint on every request and
refetches once on a 412 catalog_stale.

- kernel: definePlugin with typed needs, lazy Context, defers
- plugins: argv, global-flags, env, config, session, token, catalog, http,
  ops, io, commands; each in its own folder, reached only through ctx.get
- protocol: kinds, bind, pins and the per-event fold table
- call: flag table by response kind, input loading and pins, JSON Schema
  validation, request building, one renderer per kind
- static commands: version, login, logout, workspace list|use,
  config get|set|unset, cache refresh|clear, skills install
- exit codes frozen at 0/1/2/4/6/7; Ctrl-C folds a stream as incomplete
- docs, agent skill and release wiring updated for v2
2026-09-22 04:33:28 -07:00
485 changed files with 14787 additions and 31608 deletions

3
.gitattributes vendored
View File

@@ -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

View File

@@ -5,6 +5,7 @@ on:
branches: [main]
paths:
- 'cli/**'
- 'skills/**'
- 'packages/contracts/generated/api/openapi/**'
workflow_dispatch:

View File

@@ -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

View File

@@ -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()

View File

@@ -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/**'

View File

@@ -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,

View File

@@ -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",

View File

@@ -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."

View File

@@ -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"
)

View File

@@ -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>"}),),

View File

@@ -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):

View File

@@ -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>"}),),

View 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")

View File

@@ -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=(

View File

@@ -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>"}),),

View File

@@ -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

View File

@@ -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=(

View File

@@ -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=(

View File

@@ -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=(

View File

@@ -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=(

View File

@@ -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:

View File

@@ -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, "

View File

@@ -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 |

View File

@@ -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 |

View File

@@ -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):

View File

@@ -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:

View File

@@ -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)

View File

@@ -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.

View File

@@ -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)

View File

@@ -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",

View File

@@ -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

View File

@@ -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

View 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()

View File

@@ -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.

View File

@@ -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

View File

@@ -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": "批准",

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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()

View File

@@ -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], [])

View File

@@ -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

View File

@@ -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 |

View File

@@ -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

View File

@@ -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))

View File

@@ -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))
})()

View File

@@ -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",

View File

@@ -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'

View File

@@ -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

View File

@@ -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" },',

View File

@@ -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 }
}

View File

@@ -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}\""
)

View File

@@ -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 })
}
})
})

View File

@@ -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])

View File

@@ -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)

View File

@@ -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)

View File

@@ -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([

View File

@@ -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),
},
]

View File

@@ -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')
})
})

View File

@@ -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()
}
}

View File

@@ -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,
)
})
})

View File

@@ -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()
}
}

View File

@@ -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([])
})
})

View File

@@ -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 },
})
}
}

View File

@@ -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)
})
})

View File

@@ -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)
}
}

View File

@@ -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)
})
})

View File

@@ -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)
}

View File

@@ -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()
})
})

View File

@@ -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))
}
}

View File

@@ -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')
})
})

View File

@@ -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,
},
})
}
}

View File

@@ -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')
})
})

View File

@@ -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,
)
})
})

View File

@@ -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,
})
}
}

View File

@@ -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,
)
})
})

View File

@@ -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,
})
}
}

View File

@@ -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()
})
})

View File

@@ -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()
}
}

View File

@@ -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,
})
}

View File

@@ -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' } })
})
})

View File

@@ -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 },
})
}
}

View File

@@ -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,
)
})
})

View File

@@ -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 } })
}
}

View 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
View 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 }
},
}
}

View File

@@ -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/)

View File

@@ -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,
}
}

View File

@@ -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()
})
})

View File

@@ -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
View 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()
}

View File

@@ -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)
})
})

View File

@@ -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)
}

View File

@@ -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)
})
})

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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
View 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 })
}

View 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
View 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
View 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