Skip to content

Commit 6ac2535

Browse files
committed
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.
1 parent 5a733bd commit 6ac2535

50 files changed

Lines changed: 3214 additions & 352 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎api/controllers/console/app/workflow.py‎

Lines changed: 3 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,6 @@
8787
from graphon.variables import SecretVariable, SegmentType, VariableBase
8888
from graphon.variables.exc import VariableError
8989
from libs import helper
90-
from libs.datetime_utils import naive_utc_now
9190
from libs.helper import TimestampField, dump_response, to_timestamp, uuid_value
9291
from libs.login import login_required
9392
from models import Account, App
@@ -101,10 +100,7 @@
101100
from services.errors.llm import InvokeRateLimitError
102101
from services.workflow_ref_service import WorkflowRefService
103102
from services.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError, WorkflowService
104-
from services.workflow_variable_reference_validator import (
105-
format_variable_reference_errors,
106-
validate_variable_references,
107-
)
103+
from services.workflow_variable_reference_validator import advisory_variable_reference_warning
108104

109105
logger = logging.getLogger(__name__)
110106

@@ -1274,21 +1270,6 @@ def post(self, payload: DraftWorkflowNodeRunPayload, current_user: Account, app_
12741270
).model_dump(mode="json")
12751271

12761272

1277-
def _advisory_variable_reference_warning(graph_text: str | None) -> str | None:
1278-
"""Return a non-blocking publish warning. A checker failure must not fail publish."""
1279-
if not graph_text:
1280-
return None
1281-
try:
1282-
graph = json.loads(graph_text)
1283-
if not isinstance(graph, dict):
1284-
return None
1285-
issues = validate_variable_references(graph)
1286-
return format_variable_reference_errors(issues) if issues else None
1287-
except Exception:
1288-
logger.warning("Skipped advisory variable reference check", exc_info=True)
1289-
return None
1290-
1291-
12921273
@console_ns.route("/apps/<uuid:app_id>/workflows/publish")
12931274
class PublishedWorkflowApi(Resource):
12941275
@console_ns.doc("get_published_workflow")
@@ -1337,25 +1318,18 @@ def post(self, args: PublishWorkflowPayload, current_user: Account, app_model: A
13371318

13381319
workflow_service = WorkflowService()
13391320
with sessionmaker(db.engine).begin() as session:
1340-
workflow = workflow_service.publish_workflow(
1321+
workflow = workflow_service.publish_app_workflow(
13411322
session=session,
13421323
app_model=app_model,
13431324
account=current_user,
13441325
marked_name=args.marked_name or "",
13451326
marked_comment=args.marked_comment or "",
13461327
)
13471328

1348-
# Update app_model within the same session to ensure atomicity
1349-
app_model_in_session = session.get(App, app_model.id)
1350-
if app_model_in_session:
1351-
app_model_in_session.workflow_id = workflow.id
1352-
app_model_in_session.updated_by = current_user.id
1353-
app_model_in_session.updated_at = naive_utc_now()
1354-
13551329
workflow_created_at = TimestampField().format(workflow.created_at)
13561330
graph_text = workflow.graph
13571331

1358-
warning = _advisory_variable_reference_warning(graph_text)
1332+
warning = advisory_variable_reference_warning(graph_text)
13591333
payload: dict[str, object] = {
13601334
"result": "success",
13611335
"created_at": workflow_created_at,

‎api/controllers/openapi/__init__.py‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ def finish_document(self, document: dict[str, object]) -> dict[str, object]:
6161
DeviceMutateResponse,
6262
DevicePollRequest,
6363
DeviceTokenResponse,
64+
DraftWorkflowRunPayload,
65+
EnvVariableListResponse,
66+
EnvVariableSetPayload,
6467
FileUploadPayload,
6568
FormSubmitResponse,
6669
HealthResponse,
@@ -77,13 +80,20 @@ def finish_document(self, document: dict[str, object]) -> dict[str, object]:
7780
OpenApiFormSubmitPayload,
7881
PermittedExternalAppsListQuery,
7982
PermittedExternalAppsListResponse,
83+
PublishPayload,
84+
PublishResponse,
85+
RestoreResponse,
8086
RevokeResponse,
87+
RunListQuery,
88+
RunListResponse,
8189
ServerVersionResponse,
8290
SessionListQuery,
8391
SessionListResponse,
8492
SessionRow,
8593
TaskStopResponse,
8694
UsageInfo,
95+
VersionListQuery,
96+
VersionListResponse,
8797
WorkflowRunData,
8898
WorkflowRunPayload,
8999
WorkspaceDetailResponse,
@@ -93,6 +103,7 @@ def finish_document(self, document: dict[str, object]) -> dict[str, object]:
93103
WorkspaceSummaryResponse,
94104
)
95105
from fields.file_fields import FileResponse
106+
from fields.workflow_run_fields import WorkflowRunDetailResponse, WorkflowRunNodeExecutionListResponse
96107
from services.entities.dsl_entities import CheckDependenciesResult, Import
97108

98109
register_schema_models(
@@ -108,13 +119,18 @@ def finish_document(self, document: dict[str, object]) -> dict[str, object]:
108119
DevicePollRequest,
109120
DeviceLookupQuery,
110121
DeviceMutateRequest,
122+
DraftWorkflowRunPayload,
111123
FileUploadPayload,
112124
MemberInvitePayload,
113125
MemberListQuery,
114126
MemberRoleUpdatePayload,
115127
OpenApiFormSubmitPayload,
128+
EnvVariableSetPayload,
116129
PermittedExternalAppsListQuery,
130+
PublishPayload,
131+
RunListQuery,
117132
SessionListQuery,
133+
VersionListQuery,
118134
WorkflowRunPayload,
119135
WorkspaceListQuery,
120136
)
@@ -160,6 +176,13 @@ def finish_document(self, document: dict[str, object]) -> dict[str, object]:
160176
FileResponse,
161177
ServerVersionResponse,
162178
HealthResponse,
179+
RunListResponse,
180+
PublishResponse,
181+
VersionListResponse,
182+
RestoreResponse,
183+
EnvVariableListResponse,
184+
WorkflowRunDetailResponse,
185+
WorkflowRunNodeExecutionListResponse,
163186
)
164187
# Standalone definition for contract codegen; ErrorBody.code stays an open
165188
# string on the wire so old clients keep parsing future codes.
@@ -169,6 +192,7 @@ def finish_document(self, document: dict[str, object]) -> dict[str, object]:
169192
account,
170193
app_dsl,
171194
app_run,
195+
app_workflow,
172196
apps,
173197
apps_permitted_external,
174198
files,
@@ -186,6 +210,7 @@ def finish_document(self, document: dict[str, object]) -> dict[str, object]:
186210
"account",
187211
"app_dsl",
188212
"app_run",
213+
"app_workflow",
189214
"apps",
190215
"apps_permitted_external",
191216
"files",

‎api/controllers/openapi/_errors.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ class OpenApiErrorCode(StrEnum):
6868
RECIPIENT_SURFACE_MISMATCH = "recipient_surface_mismatch"
6969
CATALOG_STALE = "catalog_stale"
7070
TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE = "trigger_workflow_service_mode_unavailable"
71+
RUN_NOT_FOUND = "run_not_found"
72+
VERSION_NOT_FOUND = "version_not_found"
73+
VERSION_NOT_RESTORABLE = "version_not_restorable"
74+
DRAFT_NOT_FOUND = "draft_not_found"
75+
SECRET_MASK_UNKNOWN_ID = "secret_mask_unknown_id"
76+
SECRET_MASK_NOT_SECRET = "secret_mask_not_secret"
7177

7278

7379
class ErrorDetail(BaseModel):
@@ -270,3 +276,39 @@ class InvalidFilePart(OpenApiError): # noqa: N818
270276
code = 422
271277
error_code = OpenApiErrorCode.INVALID_PARAM
272278
description = "A request part could not be used"
279+
280+
281+
class RunNotFound(OpenApiError): # noqa: N818
282+
code = 404
283+
error_code = OpenApiErrorCode.RUN_NOT_FOUND
284+
description = "No run with this id exists for this app."
285+
286+
287+
class VersionNotFound(OpenApiError): # noqa: N818
288+
code = 404
289+
error_code = OpenApiErrorCode.VERSION_NOT_FOUND
290+
description = "No version with this id exists for this app."
291+
292+
293+
class VersionNotRestorable(OpenApiError): # noqa: N818
294+
code = 400
295+
error_code = OpenApiErrorCode.VERSION_NOT_RESTORABLE
296+
description = "Only a published version can be restored."
297+
298+
299+
class DraftNotFound(OpenApiError): # noqa: N818
300+
code = 404
301+
error_code = OpenApiErrorCode.DRAFT_NOT_FOUND
302+
description = "This app has no draft."
303+
304+
305+
class SecretMaskUnknownId(OpenApiError): # noqa: N818
306+
code = 400
307+
error_code = OpenApiErrorCode.SECRET_MASK_UNKNOWN_ID
308+
description = "The value is the masked secret placeholder, but env_id is not an existing secret."
309+
310+
311+
class SecretMaskNotSecret(OpenApiError): # noqa: N818
312+
code = 400
313+
error_code = OpenApiErrorCode.SECRET_MASK_NOT_SECRET
314+
description = "The value is the masked secret placeholder, which only a secret value_type can keep."

‎api/controllers/openapi/_models.py‎

Lines changed: 121 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
from controllers.common.human_input import HumanInputFormSubmitPayload
1313
from controllers.openapi._upload import UploadPart, UploadParts
1414
from enums import DeploymentEdition
15-
from libs.helper import EmailStr, UUIDStr, UUIDStrOrEmpty, uuid_value
15+
from fields.workflow_run_fields import WorkflowRunPaginationResponse
16+
from graphon.variables import SegmentType
17+
from libs.helper import EmailStr, UUIDStr, UUIDStrOrEmpty, to_timestamp, uuid_value
1618
from models.model import AppMode
1719
from services.app_dsl_service import Import
1820

@@ -343,21 +345,23 @@ class RunPayloadBase(BaseModel):
343345
"""What every run takes; each mode's payload adds its own fields and forbids the rest."""
344346

345347
inputs: dict[str, Any] = Field(
348+
default_factory=dict,
346349
description=(
347350
"Variables declared by the app. The exact shape is per app: read `input_schema` from "
348351
"describe.console_app. A file variable takes a Dify file mapping (remote url or upload id) here, "
349-
"or a local file in `files`, not both."
350-
)
352+
"or a local path in `files`, not both."
353+
),
351354
)
352355
files: UploadParts | None = Field(
353356
default=None,
354357
description=(
355-
"Local files keyed by the app's file variable name; the server uploads each one and sets "
356-
"`inputs[<name>]`. Send a list (part name `files[<name>][]`) for a file-list variable"
358+
"Local file paths keyed by the app's file variable name; each file is uploaded and becomes "
359+
"that variable's value. Give a list of paths for a file-list variable"
357360
),
358361
)
359362
attachments: list[UploadPart] | None = Field(
360-
default=None, description="Local files attached to the run itself (the app's `sys.files`), not to a variable"
363+
default=None,
364+
description="Local file paths attached to the run itself (the app's `sys.files`), not to a variable",
361365
)
362366
workspace_id: UUIDStrOrEmpty | None = Field(default=None, description="Workspace that owns the app")
363367

@@ -366,6 +370,10 @@ class WorkflowRunPayload(RunPayloadBase, _WorkflowVersionFields):
366370
model_config = ConfigDict(extra="forbid")
367371

368372

373+
class DraftWorkflowRunPayload(RunPayloadBase):
374+
model_config = ConfigDict(extra="forbid")
375+
376+
369377
class ChatRunPayload(RunPayloadBase, _ConversationFields):
370378
model_config = ConfigDict(extra="forbid")
371379

@@ -498,6 +506,11 @@ class AppDslImportPayload(BaseModel):
498506
icon: str | None = Field(None)
499507
icon_background: str | None = Field(None)
500508
app_id: str | None = Field(None, description="Existing app ID to overwrite (workflow/advanced-chat apps only)")
509+
draft_hash: str | None = Field(
510+
None,
511+
description="draft_hash from the export or restore this import is based on. The import fails if the "
512+
"draft's graph, features, environment variables or conversation variables changed since. Requires app_id",
513+
)
501514

502515
@model_validator(mode="after")
503516
def _validate_source_by_mode(self) -> AppDslImportPayload:
@@ -521,6 +534,11 @@ class AppDslExportResponse(BaseModel):
521534
"""Export DSL response."""
522535

523536
data: str = Field(..., description="DSL YAML string")
537+
draft_hash: str | None = Field(
538+
None,
539+
description="Hash of the draft's graph, features, environment variables and conversation variables; "
540+
"pass it to the import to refuse overwriting newer edits",
541+
)
524542

525543

526544
class AppDslImportResponse(Import, Hinted):
@@ -540,7 +558,7 @@ class OpenApiFormSubmitPayload(HumanInputFormSubmitPayload):
540558

541559
files: UploadParts | None = Field(
542560
default=None,
543-
description="Local files keyed by the form's file input name, same convention as the run ops' `files`",
561+
description="Local file paths keyed by the form's file input name, same convention as the run ops' `files`",
544562
)
545563

546564

@@ -550,3 +568,99 @@ class HumanInputFormDefinitionResponse(BaseModel):
550568
resolved_default_values: dict[str, str]
551569
user_actions: list[dict[str, Any]] = Field(default_factory=list)
552570
expiration_time: int | None = None
571+
572+
573+
class RunListQuery(BaseModel):
574+
last_id: UUIDStr | None = Field(None, description="Cursor: id of the last run on the previous page")
575+
limit: int = Field(20, ge=1, le=MAX_PAGE_LIMIT)
576+
status: Literal["running", "succeeded", "failed", "stopped", "partial-succeeded"] | None = None
577+
triggered_from: Literal["debugging", "app-run"] | None = Field(
578+
None, description="debugging: draft test runs; app-run: real use. Omitted: debugging, as in the console"
579+
)
580+
581+
582+
class RunListResponse(WorkflowRunPaginationResponse, Hinted):
583+
"""Cursor page of runs; `hints` carries the next page."""
584+
585+
586+
class PublishPayload(BaseModel):
587+
model_config = ConfigDict(extra="forbid")
588+
589+
marked_name: str = Field("", max_length=20, description="Version name")
590+
marked_comment: str = Field("", max_length=100, description="Version note")
591+
592+
593+
class PublishResponse(BaseModel):
594+
version_id: str
595+
created_at: int
596+
warning: str | None = Field(None, description="Variable references that may read a skipped branch")
597+
598+
599+
class VersionListQuery(PageQuery):
600+
named_only: bool = Field(False, description="Only versions that have a name")
601+
602+
603+
class VersionRow(BaseModel):
604+
model_config = ConfigDict(from_attributes=True)
605+
606+
id: str
607+
marked_name: str
608+
marked_comment: str
609+
created_by: str | None = None
610+
created_at: int
611+
612+
@field_validator("created_at", mode="before")
613+
@classmethod
614+
def _timestamp(cls, value: datetime | int) -> int | None:
615+
return to_timestamp(value)
616+
617+
618+
class VersionListResponse(Hinted):
619+
"""Page of published versions, newest first; there is no total, `hints` carries the next page."""
620+
621+
page: int
622+
limit: int
623+
has_more: bool
624+
data: list[VersionRow]
625+
626+
627+
class EnvVariableRow(BaseModel):
628+
id: str = Field(description="What set and delete take to address this variable")
629+
name: str = Field(description="What nodes use to refer to this variable")
630+
description: str = ""
631+
value_type: str
632+
value: Any = Field(description="The value; a secret with a value is masked, an empty one reads as empty")
633+
634+
635+
class EnvVariableListResponse(BaseModel):
636+
data: list[EnvVariableRow]
637+
638+
639+
class EnvVariableValueType(StrEnum):
640+
"""Value types the draft environment-variable ``set`` op accepts.
641+
642+
A curated subset of ``SegmentType``: what the console's environment-variable editor
643+
allows (``ENVIRONMENT_VARIABLE_SUPPORTED_TYPES`` in controllers/console/app/workflow.py).
644+
Members reference ``SegmentType.*.value`` so the subset relationship is type-checked.
645+
"""
646+
647+
STRING = SegmentType.STRING.value
648+
NUMBER = SegmentType.NUMBER.value
649+
SECRET = SegmentType.SECRET.value
650+
651+
652+
class EnvVariableSetPayload(BaseModel):
653+
model_config = ConfigDict(extra="forbid")
654+
655+
name: str = Field(description="Variable name")
656+
value_type: EnvVariableValueType = Field(description="string, number or secret")
657+
value: Any = Field(description="The value; sending the masked value of an existing secret keeps the stored one")
658+
description: str = Field("", description="What the variable is for")
659+
660+
661+
class RestoreResponse(BaseModel):
662+
result: Literal["success"]
663+
draft_hash: str = Field(
664+
description="Hash of the restored draft's graph, features, environment variables and conversation "
665+
"variables; pass it to a DSL import as draft_hash"
666+
)

0 commit comments

Comments
 (0)